diff --git a/android/app/src/main/res/drawable/receive.xml b/android/app/src/main/res/drawable/receive.xml new file mode 100644 index 0000000000..6d54fd70ad --- /dev/null +++ b/android/app/src/main/res/drawable/receive.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/app/src/main/res/drawable/send.xml b/android/app/src/main/res/drawable/send.xml new file mode 100644 index 0000000000..c83ea31d40 --- /dev/null +++ b/android/app/src/main/res/drawable/send.xml @@ -0,0 +1,12 @@ + + + + diff --git a/ios/Runner/Assets.xcassets/Contents.json b/ios/Runner/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/ios/Runner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/receive.imageset/Contents.json b/ios/Runner/Assets.xcassets/receive.imageset/Contents.json new file mode 100644 index 0000000000..5dbd4f70e2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/receive.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "receive.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "receive 1.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "receive 2.svg", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/receive.imageset/receive 1.svg b/ios/Runner/Assets.xcassets/receive.imageset/receive 1.svg new file mode 100644 index 0000000000..14c4f7f146 --- /dev/null +++ b/ios/Runner/Assets.xcassets/receive.imageset/receive 1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ios/Runner/Assets.xcassets/receive.imageset/receive 2.svg b/ios/Runner/Assets.xcassets/receive.imageset/receive 2.svg new file mode 100644 index 0000000000..14c4f7f146 --- /dev/null +++ b/ios/Runner/Assets.xcassets/receive.imageset/receive 2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ios/Runner/Assets.xcassets/receive.imageset/receive.svg b/ios/Runner/Assets.xcassets/receive.imageset/receive.svg new file mode 100644 index 0000000000..14c4f7f146 --- /dev/null +++ b/ios/Runner/Assets.xcassets/receive.imageset/receive.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ios/Runner/Assets.xcassets/send.imageset/Contents.json b/ios/Runner/Assets.xcassets/send.imageset/Contents.json new file mode 100644 index 0000000000..f648efd931 --- /dev/null +++ b/ios/Runner/Assets.xcassets/send.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "send.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "send 1.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "send 2.svg", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/send.imageset/send 1.svg b/ios/Runner/Assets.xcassets/send.imageset/send 1.svg new file mode 100644 index 0000000000..63d78e94b3 --- /dev/null +++ b/ios/Runner/Assets.xcassets/send.imageset/send 1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ios/Runner/Assets.xcassets/send.imageset/send 2.svg b/ios/Runner/Assets.xcassets/send.imageset/send 2.svg new file mode 100644 index 0000000000..63d78e94b3 --- /dev/null +++ b/ios/Runner/Assets.xcassets/send.imageset/send 2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ios/Runner/Assets.xcassets/send.imageset/send.svg b/ios/Runner/Assets.xcassets/send.imageset/send.svg new file mode 100644 index 0000000000..63d78e94b3 --- /dev/null +++ b/ios/Runner/Assets.xcassets/send.imageset/send.svg @@ -0,0 +1,4 @@ + + + + diff --git a/lib/main.dart b/lib/main.dart index beaa1524b6..027484dda3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -57,6 +57,7 @@ import 'package:flutter_daemon/flutter_daemon.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:hive/hive.dart'; import 'package:cw_core/root_dir.dart'; +import 'package:quick_actions/quick_actions.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:cw_core/window_size.dart'; import 'package:logging/logging.dart'; @@ -67,6 +68,7 @@ import 'package:trezor_connect/trezor_connect.dart'; final navigatorKey = GlobalKey(); final rootKey = GlobalKey(); final RouteObserver> routeObserver = RouteObserver>(); +final quickActionsStream = StreamController.broadcast(); Future main({Key? topLevelKey}) async { await runAppWithZone(topLevelKey: topLevelKey); @@ -77,6 +79,43 @@ Future runAppWithZone({Key? topLevelKey}) async { await runZonedGuarded(() async { WidgetsFlutterBinding.ensureInitialized(); + + final Completer initialShortcutCompleter = Completer(); + + const QuickActions quickActions = QuickActions(); + + quickActions.initialize((String shortcutType) { + // Complete the completer only once (for cold starts) + if (!initialShortcutCompleter.isCompleted) { + initialShortcutCompleter.complete(shortcutType); + } + + // Convert the shortcut type to a URI and add it to the stream + final uri = Uri.parse('cakewallet://quickaction/$shortcutType'); + quickActionsStream.sink.add(uri); + }); + + quickActions.setShortcutItems([ + const ShortcutItem( + type: 'send', + icon: 'send', + localizedTitle: 'Send', + localizedSubtitle: 'Send funds'), + const ShortcutItem( + type: 'receive', + icon: 'receive', + localizedTitle: 'Receive', + localizedSubtitle: 'Receive funds') + ]); + + // Fallback in case the initial shortcut is not received (normal cold start) + Future.delayed(const Duration(milliseconds: 500), () { + if (!initialShortcutCompleter.isCompleted) { + initialShortcutCompleter.complete(null); + } + }); + + final initialQuickAction = await initialShortcutCompleter.future; FlutterError.onError = ExceptionHandler.onError; /// A callback that is invoked when an unhandled error occurs in the root @@ -114,11 +153,11 @@ Future runAppWithZone({Key? topLevelKey}) async { runApp( DefaultAssetBundle( bundle: TestAssetBundle(), - child: App(key: topLevelKey), + child: App(key: topLevelKey, initialQuickAction:initialQuickAction,quickActionsStream: quickActionsStream.stream), ), ); } else { - runApp(App(key: topLevelKey)); + runApp(App(key: topLevelKey, initialQuickAction: initialQuickAction, quickActionsStream: quickActionsStream.stream)); } isAppRunning = true; @@ -334,9 +373,11 @@ Future initialSetup({ } class App extends StatefulWidget { - App({this.key}); + App({this.key, this.initialQuickAction, required this.quickActionsStream}); final Key? key; + final String? initialQuickAction; + final Stream quickActionsStream; @override AppState createState() => AppState(); } @@ -367,9 +408,13 @@ class AppState extends State with SingleTickerProviderStateMixin { statusBarBrightness: statusBarBrightness, statusBarIconBrightness: statusBarIconBrightness)); + final appRouteObserver = AppRouteObserver(); + return Root( key: widget.key ?? rootKey, appStore: appStore, + initialQuickAction: widget.initialQuickAction, + quickActionsStream: widget.quickActionsStream, authenticationStore: authenticationStore, navigatorKey: navigatorKey, authService: authService, @@ -380,7 +425,7 @@ class AppState extends State with SingleTickerProviderStateMixin { child: ThemeProvider( themeStore: appStore.themeStore, materialAppBuilder: (context, theme, darkTheme, themeMode) => MaterialApp( - navigatorObservers: [routeObserver], + navigatorObservers: [routeObserver, appRouteObserver], navigatorKey: navigatorKey, debugShowCheckedModeBanner: false, theme: theme, @@ -506,3 +551,23 @@ Future backgroundSync() async { } } } + +class AppRouteObserver extends RouteObserver> { + final AppStore appStore = getIt.get(); + + @override + void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); + if (route is PageRoute) { + appStore.currentRouteName = route.settings.name; + } + } + + @override + void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); + if (previousRoute is PageRoute) { + appStore.currentRouteName = previousRoute.settings.name; + } + } +} diff --git a/lib/router.dart b/lib/router.dart index 02fe8275df..b588c5a515 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -160,12 +160,13 @@ late RouteSettings currentRouteSettings; Route handleRouteWithPlatformAwareness( Widget Function(BuildContext) builder, { + RouteSettings? settings, bool fullscreenDialog = false, }) { if (Platform.isIOS) { - return CupertinoPageRoute(builder: builder, fullscreenDialog: fullscreenDialog); + return CupertinoPageRoute(builder: builder, fullscreenDialog: fullscreenDialog, settings: settings); } else { - return MaterialPageRoute(builder: builder, fullscreenDialog: fullscreenDialog); + return MaterialPageRoute(builder: builder, fullscreenDialog: fullscreenDialog, settings: settings); } } @@ -412,6 +413,7 @@ Route createRoute(RouteSettings settings) { param1: initialPaymentRequest, param2: coinTypeToSpendFrom, ), + settings: settings ); case Routes.sendTemplate: @@ -419,7 +421,7 @@ Route createRoute(RouteSettings settings) { fullscreenDialog: true, builder: (_) => getIt.get()); case Routes.receive: - return CupertinoPageRoute(builder: (context) => getIt.get()); + return CupertinoPageRoute(builder: (context) => getIt.get(), settings: settings); case Routes.addressPage: return handleRouteWithPlatformAwareness( diff --git a/lib/src/screens/root/root.dart b/lib/src/screens/root/root.dart index 7f71dcb462..3c8b659548 100644 --- a/lib/src/screens/root/root.dart +++ b/lib/src/screens/root/root.dart @@ -36,6 +36,8 @@ class Root extends StatefulWidget { required this.tradeMonitor, required this.nodeSwitchingService, required this.trezorConnect, + this.initialQuickAction, + required this.quickActionsStream, }) : super(key: key); final AuthenticationStore authenticationStore; @@ -47,6 +49,8 @@ class Root extends StatefulWidget { final TradeMonitor tradeMonitor; final NodeSwitchingService nodeSwitchingService; final TrezorConnect trezorConnect; + final String? initialQuickAction; + final Stream quickActionsStream; @override RootState createState() => RootState(); @@ -103,7 +107,18 @@ class RootState extends State with WidgetsBindingObserver { handleDeepLinking(uri); }); + // listen for quick actions + widget.quickActionsStream.listen((Uri? uri) { + handleDeepLinking(uri); + }); + handleDeepLinking(await getInitialUri()); + + if (widget.initialQuickAction != null) { + final uri = Uri.parse('cakewallet://quickaction/${widget.initialQuickAction}'); + handleDeepLinking(uri); + } + } catch (e) { printV(e); } diff --git a/lib/store/app_store.dart b/lib/store/app_store.dart index 44f1807e33..3447b8617b 100644 --- a/lib/store/app_store.dart +++ b/lib/store/app_store.dart @@ -34,6 +34,9 @@ abstract class AppStoreBase with Store { @observable WalletBase, TransactionInfo>? wallet; + @observable + String? currentRouteName; + WalletListStore walletList; SettingsStore settingsStore; diff --git a/lib/view_model/link_view_model.dart b/lib/view_model/link_view_model.dart index 1a89943d4e..01591f70bb 100644 --- a/lib/view_model/link_view_model.dart +++ b/lib/view_model/link_view_model.dart @@ -25,6 +25,8 @@ class LinkViewModel { bool get _isValidPaymentUri => currentLink?.path.isNotEmpty ?? false; bool get isWalletConnectLink => currentLink?.authority == 'wc'; bool get isNanoGptLink => currentLink?.scheme == 'nano-gpt'; + bool get isQuickActionLink => + currentLink?.scheme == 'cakewallet' && currentLink?.host == 'quickaction'; String? getRouteToGo() { if (isWalletConnectLink) { @@ -35,6 +37,19 @@ class LinkViewModel { return Routes.walletConnectConnectionsListing; } + // Check for a quick action first. + if (isQuickActionLink) { + final action = currentLink!.pathSegments.isNotEmpty ? currentLink!.pathSegments.first : null; + switch (action) { + case 'send': + return Routes.send; + case 'receive': + return Routes.receive; + default: + return null; + } + } + if (authenticationStore.state == AuthenticationState.uninitialized) { return null; } @@ -62,6 +77,10 @@ class LinkViewModel { return currentLink; } + if (isQuickActionLink) { + return null; + } + if (isNanoGptLink) { switch (currentLink?.authority ?? '') { case "exchange": @@ -101,6 +120,11 @@ class LinkViewModel { return; } + // Prevent navigating to the same route again. + if (appStore.currentRouteName == route) { + return; + } + if (isNanoGptLink) { if (route == Routes.buySellPage || route == Routes.exchange) { await _errorToast(S.current.nano_gpt_thanks_message, fontSize: 14); diff --git a/pubspec_base.yaml b/pubspec_base.yaml index d30834060a..75b18f2eed 100644 --- a/pubspec_base.yaml +++ b/pubspec_base.yaml @@ -152,6 +152,7 @@ dependencies: ref: b48a9defddce036048bae02e2069ebcbd6824afc torch_dart: path: ./scripts/torch_dart + quick_actions: ^1.1.0 dev_dependencies: flutter_test: