diff --git a/android/src/main/kotlin/atomic/financial/atomic_transact_flutter/AtomicTransactFlutterPlugin.kt b/android/src/main/kotlin/atomic/financial/atomic_transact_flutter/AtomicTransactFlutterPlugin.kt index 50d5e92..42a2f95 100644 --- a/android/src/main/kotlin/atomic/financial/atomic_transact_flutter/AtomicTransactFlutterPlugin.kt +++ b/android/src/main/kotlin/atomic/financial/atomic_transact_flutter/AtomicTransactFlutterPlugin.kt @@ -12,6 +12,9 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.json.JSONObject import org.json.JSONArray @@ -24,6 +27,7 @@ class AtomicTransactFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAwa /// when the Flutter Engine is detached from the Activity private lateinit var channel : MethodChannel private lateinit var activity : Activity + private var pausedTransactRef: PausedTransactRef? = null override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "atomic_transact_flutter") @@ -138,6 +142,24 @@ class AtomicTransactFlutterPlugin: FlutterPlugin, MethodCallHandler, ActivityAwa Transact.close(activity) } else if (call.method == "hideTransact") { Transact.hideTransact(activity) + } else if (call.method == "pauseTransact") { + CoroutineScope(Dispatchers.Main).launch { + try { + pausedTransactRef = Transact.pauseTransact() + result.success(null) + } catch (e: Transact.PauseTransactException) { + result.error("PauseTransactError", e.message, null) + } + } + } else if (call.method == "resumeTransact") { + val ref = pausedTransactRef + if (ref != null) { + ref.resume(activity) + pausedTransactRef = null + result.success(null) + } else { + result.error("ResumeTransactError", "No paused Transact to resume", null) + } } else { result.notImplemented() } diff --git a/example/lib/models/app_state.dart b/example/lib/models/app_state.dart index 1746dc5..a8e1a3e 100644 --- a/example/lib/models/app_state.dart +++ b/example/lib/models/app_state.dart @@ -58,6 +58,14 @@ class AppState extends ChangeNotifier { bool get debug => _debug; set debug(bool v) { _debug = v; notifyListeners(); } + bool _pauseAfterInit = false; + bool get pauseAfterInit => _pauseAfterInit; + set pauseAfterInit(bool v) { _pauseAfterInit = v; notifyListeners(); } + + int _pauseDelaySeconds = 3; + int get pauseDelaySeconds => _pauseDelaySeconds; + set pauseDelaySeconds(int v) { _pauseDelaySeconds = v; notifyListeners(); } + // Pay Link PayLinkTask _payLinkTask = PayLinkTask.switchPayment; PayLinkTask get payLinkTask => _payLinkTask; diff --git a/example/lib/screens/pay_link_screen.dart b/example/lib/screens/pay_link_screen.dart index d6401dc..4ebcda6 100644 --- a/example/lib/screens/pay_link_screen.dart +++ b/example/lib/screens/pay_link_screen.dart @@ -10,7 +10,7 @@ import 'company_login_screen.dart'; import '../widgets/public_token_banner.dart'; import '../widgets/select_grid.dart'; -class PayLinkScreen extends StatelessWidget { +class PayLinkScreen extends StatefulWidget { final AppState state; final EventLog eventLog; final VoidCallback onNavigateToSettings; @@ -22,8 +22,30 @@ class PayLinkScreen extends StatelessWidget { required this.onNavigateToSettings, }); + @override + State createState() => _PayLinkScreenState(); +} + +class _PayLinkScreenState extends State { + AppState get state => widget.state; + EventLog get eventLog => widget.eventLog; + + bool _transactActive = false; + PausedTransactRef? _pausedRef; + void _onInitialize() { final config = state.buildPayLinkConfig(); + final shouldPause = state.pauseAfterInit; + final delaySeconds = state.pauseDelaySeconds; + + setState(() { + _transactActive = true; + _pausedRef = null; + }); + + if (shouldPause) { + _schedulePause(delaySeconds); + } Atomic.transact( config: config, @@ -75,6 +97,10 @@ class PayLinkScreen extends StatelessWidget { )); }, onCompletion: (type, response, error) { + setState(() { + _transactActive = false; + _pausedRef = null; + }); if (type == AtomicTransactCompletionType.error) { eventLog.add(EventEntry( type: EventType.error, @@ -99,6 +125,27 @@ class PayLinkScreen extends StatelessWidget { ); } + void _schedulePause(int delaySeconds) { + Future.delayed(Duration(seconds: delaySeconds), () async { + if (!mounted || !_transactActive || _pausedRef != null) return; + try { + final ref = await Atomic.pauseTransact(); + if (mounted) setState(() => _pausedRef = ref); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to pause: $e')), + ); + } + } + }); + } + + Future _onResume() async { + await _pausedRef?.resume(); + setState(() => _pausedRef = null); + } + void _showConfigPreview(BuildContext context) { final config = state.buildPayLinkConfig(); final json = const JsonEncoder.withIndent(' ').convert(config.toJson()); @@ -152,6 +199,21 @@ class PayLinkScreen extends StatelessWidget { ); } + Widget _buildBottomButtons() { + if (_pausedRef != null) { + return FullWidthButton( + text: 'Resume', + onPressed: _onResume, + ); + } + final label = state.pauseAfterInit ? 'Initialize and Pause' : 'Initialize'; + return FullWidthButton( + text: label, + enabled: state.publicToken.isNotEmpty && !_transactActive, + onPressed: _onInitialize, + ); + } + @override Widget build(BuildContext context) { return Column( @@ -172,7 +234,7 @@ class PayLinkScreen extends StatelessWidget { children: [ PublicTokenBanner( publicToken: state.publicToken, - onNavigateToSettings: onNavigateToSettings, + onNavigateToSettings: widget.onNavigateToSettings, ), SingleSelectGrid( title: 'Task', @@ -214,11 +276,7 @@ class PayLinkScreen extends StatelessWidget { ), ), ), - FullWidthButton( - text: 'Initialize', - enabled: state.publicToken.isNotEmpty, - onPressed: _onInitialize, - ), + _buildBottomButtons(), const SizedBox(height: 16), ], ); diff --git a/example/lib/screens/settings_screen.dart b/example/lib/screens/settings_screen.dart index 809a37e..9bd5287 100644 --- a/example/lib/screens/settings_screen.dart +++ b/example/lib/screens/settings_screen.dart @@ -63,6 +63,21 @@ class SettingsScreen extends StatelessWidget { value: state.debug, onChanged: (v) => state.debug = v, ), + const SizedBox(height: 16), + const Divider(indent: 16, endIndent: 16), + const SizedBox(height: 8), + const SectionHeader('Pause'), + ToggleRow( + title: 'Pause After Initialize', + subtitle: 'Automatically pause Transact after a delay', + value: state.pauseAfterInit, + onChanged: (v) => state.pauseAfterInit = v, + ), + if (state.pauseAfterInit) + _PauseDelayPicker( + seconds: state.pauseDelaySeconds, + onChanged: (v) => state.pauseDelaySeconds = v, + ), const SizedBox(height: 32), ], ), @@ -107,3 +122,48 @@ class _UrlModeSelector extends StatelessWidget { ); } } + +class _PauseDelayPicker extends StatelessWidget { + final int seconds; + final ValueChanged onChanged; + + const _PauseDelayPicker({required this.seconds, required this.onChanged}); + + static const _options = [1, 2, 3, 5, 10, 15, 30]; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Delay (seconds)', + style: TextStyle(fontSize: 14, color: atomicOnSurfaceVariant), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: _options.map((s) { + final selected = s == seconds; + return ChoiceChip( + label: Text('${s}s'), + selected: selected, + onSelected: (_) => onChanged(s), + selectedColor: atomicPurple, + backgroundColor: atomicSurface, + side: BorderSide( + color: selected ? atomicPurple : atomicOutline, + ), + labelStyle: TextStyle( + color: selected ? Colors.white : atomicOnBackground, + ), + ); + }).toList(), + ), + ], + ), + ); + } +} diff --git a/example/lib/screens/user_link_screen.dart b/example/lib/screens/user_link_screen.dart index cce936c..6adab2a 100644 --- a/example/lib/screens/user_link_screen.dart +++ b/example/lib/screens/user_link_screen.dart @@ -10,7 +10,7 @@ import '../widgets/public_token_banner.dart'; import '../widgets/select_grid.dart'; import 'company_login_screen.dart'; -class UserLinkScreen extends StatelessWidget { +class UserLinkScreen extends StatefulWidget { final AppState state; final EventLog eventLog; final VoidCallback onNavigateToSettings; @@ -22,8 +22,30 @@ class UserLinkScreen extends StatelessWidget { required this.onNavigateToSettings, }); + @override + State createState() => _UserLinkScreenState(); +} + +class _UserLinkScreenState extends State { + AppState get state => widget.state; + EventLog get eventLog => widget.eventLog; + + bool _transactActive = false; + PausedTransactRef? _pausedRef; + void _onInitialize() { final config = state.buildUserLinkConfig(); + final shouldPause = state.pauseAfterInit; + final delaySeconds = state.pauseDelaySeconds; + + setState(() { + _transactActive = true; + _pausedRef = null; + }); + + if (shouldPause) { + _schedulePause(delaySeconds); + } Atomic.transact( config: config, @@ -66,6 +88,10 @@ class UserLinkScreen extends StatelessWidget { )); }, onCompletion: (type, response, error) { + setState(() { + _transactActive = false; + _pausedRef = null; + }); if (type == AtomicTransactCompletionType.error) { eventLog.add(EventEntry( type: EventType.error, @@ -89,6 +115,27 @@ class UserLinkScreen extends StatelessWidget { ); } + void _schedulePause(int delaySeconds) { + Future.delayed(Duration(seconds: delaySeconds), () async { + if (!mounted || !_transactActive || _pausedRef != null) return; + try { + final ref = await Atomic.pauseTransact(); + if (mounted) setState(() => _pausedRef = ref); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to pause: $e')), + ); + } + } + }); + } + + Future _onResume() async { + await _pausedRef?.resume(); + setState(() => _pausedRef = null); + } + void _showConfigPreview(BuildContext context) { final config = state.buildUserLinkConfig(); final json = const JsonEncoder.withIndent(' ').convert(config.toJson()); @@ -142,6 +189,21 @@ class UserLinkScreen extends StatelessWidget { ); } + Widget _buildBottomButtons() { + if (_pausedRef != null) { + return FullWidthButton( + text: 'Resume', + onPressed: _onResume, + ); + } + final label = state.pauseAfterInit ? 'Initialize and Pause' : 'Initialize'; + return FullWidthButton( + text: label, + enabled: state.publicToken.isNotEmpty && !_transactActive, + onPressed: _onInitialize, + ); + } + @override Widget build(BuildContext context) { return Column( @@ -162,7 +224,7 @@ class UserLinkScreen extends StatelessWidget { children: [ PublicTokenBanner( publicToken: state.publicToken, - onNavigateToSettings: onNavigateToSettings, + onNavigateToSettings: widget.onNavigateToSettings, ), SingleSelectGrid( title: 'Task', @@ -204,11 +266,7 @@ class UserLinkScreen extends StatelessWidget { ), ), ), - FullWidthButton( - text: 'Initialize', - enabled: state.publicToken.isNotEmpty, - onPressed: _onInitialize, - ), + _buildBottomButtons(), const SizedBox(height: 16), ], ); diff --git a/ios/atomic_transact_flutter/Sources/atomic_transact_flutter/AtomicTransactFlutterPlugin.swift b/ios/atomic_transact_flutter/Sources/atomic_transact_flutter/AtomicTransactFlutterPlugin.swift index b51ddc1..7d304da 100644 --- a/ios/atomic_transact_flutter/Sources/atomic_transact_flutter/AtomicTransactFlutterPlugin.swift +++ b/ios/atomic_transact_flutter/Sources/atomic_transact_flutter/AtomicTransactFlutterPlugin.swift @@ -3,8 +3,9 @@ import UIKit import AtomicTransact public class AtomicTransactFlutterPlugin: NSObject, FlutterPlugin { - + let channel: FlutterMethodChannel; + var pausedTransactRef: Atomic.PausedTransactRef? public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "atomic_transact_flutter", binaryMessenger: registrar.messenger()) @@ -95,6 +96,29 @@ public class AtomicTransactFlutterPlugin: NSObject, FlutterPlugin { Atomic.dismissTransact() case "hideTransact": Atomic.hideTransact() + case "pauseTransact": + Task { @MainActor in + do { + let ref = try await Atomic.pauseTransact() + self.pausedTransactRef = ref + result(nil) + } catch { + result(FlutterError(code: "PauseTransactError", message: "No Transact is currently presented", details: nil)) + } + } + return + case "resumeTransact": + Task { @MainActor in + if let ref = self.pausedTransactRef, + let controller = UIApplication.shared.windows.filter({$0.isKeyWindow}).first?.rootViewController { + ref.resume(source: controller) + self.pausedTransactRef = nil + result(nil) + } else { + result(FlutterError(code: "ResumeTransactError", message: "No paused Transact to resume", details: nil)) + } + } + return default: result(FlutterMethodNotImplemented) diff --git a/lib/platform_interface/atomic_method_channel.dart b/lib/platform_interface/atomic_method_channel.dart index 81430f7..0e16d53 100644 --- a/lib/platform_interface/atomic_method_channel.dart +++ b/lib/platform_interface/atomic_method_channel.dart @@ -69,6 +69,16 @@ class AtomicMethodChannel extends AtomicPlatformInterface { await _channel.invokeMethod('hideTransact'); } + @override + Future pauseTransact() async { + await _channel.invokeMethod('pauseTransact'); + } + + @override + Future resumeTransact() async { + await _channel.invokeMethod('resumeTransact'); + } + /// Handles receiving messages on the [MethodChannel] Future _onMethodCall(MethodCall call) async { switch (call.method) { diff --git a/lib/platform_interface/atomic_platform_interface.dart b/lib/platform_interface/atomic_platform_interface.dart index c74ba1e..d23f8ee 100644 --- a/lib/platform_interface/atomic_platform_interface.dart +++ b/lib/platform_interface/atomic_platform_interface.dart @@ -64,4 +64,12 @@ abstract class AtomicPlatformInterface extends PlatformInterface { Future hideTransact() async { throw UnimplementedError('hideTransact() has not been implemented.'); } + + Future pauseTransact() async { + throw UnimplementedError('pauseTransact() has not been implemented.'); + } + + Future resumeTransact() async { + throw UnimplementedError('resumeTransact() has not been implemented.'); + } } diff --git a/lib/src/atomic.dart b/lib/src/atomic.dart index 55dfa80..9caf3ea 100644 --- a/lib/src/atomic.dart +++ b/lib/src/atomic.dart @@ -97,4 +97,24 @@ class Atomic { _isLoading = false; await _platform.hideTransact(); } + + /// Hides any currently open Transact views and returns a reference to present them again later. + /// Call [PausedTransactRef.resume] to present the Transact view again. + /// Throws [PauseTransactException] if no Transact is currently presented. + static Future pauseTransact() async { + await _platform.pauseTransact(); + return PausedTransactRef._(_platform); + } +} + +/// A reference to a paused Transact session. Call [resume] to present the Transact view again. +class PausedTransactRef { + final AtomicPlatformInterface _platform; + + PausedTransactRef._(this._platform); + + /// Presents the paused Transact view again. + Future resume() async { + await _platform.resumeTransact(); + } } diff --git a/lib/src/types.dart b/lib/src/types.dart index 3a56bdb..943c07e 100644 --- a/lib/src/types.dart +++ b/lib/src/types.dart @@ -110,6 +110,15 @@ typedef AtomicTaskStatusUpdateHandler = void Function( AtomicTransactTaskStatusUpdate taskStatus, ); +/// Error thrown when pauseTransact fails +class PauseTransactException implements Exception { + final String message; + PauseTransactException(this.message); + + @override + String toString() => 'PauseTransactException: $message'; +} + /// iOS modal presentation styles enum AtomicPresentationStyleIOS { /// Full screen presentation style