CW-1130-add-app-shortcuts (#2599)

* add app shortcuts * update icons * Improve deep link and quick action navigation * bypass auth when allowedCreate state is active * remove spaces from file names [skip ci] --------- Co-authored-by: Serhii-Borodenko <17529954+Serhii-Borodenko@users.noreply.github.com> Co-authored-by: OmarHatem <omarh.ismail1@gmail.com>

Serhii committed Jan 17, 2026 at 05:09 UTC 2dd6cdce1efc2cd9a421cba90cf93d9d180f101e
17 files changed +241 -15
android/app/src/main/res/drawable/receive.xml new
+12
@@ -0,0 +1,12 @@
1 +<vector xmlns:android="http://schemas.android.com/apk/res/android"
2 + android:width="251dp"
3 + android:height="251dp"
4 + android:viewportWidth="251"
5 + android:viewportHeight="251">
6 + <path
7 + android:pathData="M125.5,0.5L125.5,0.5A125,125 0,0 1,250.5 125.5L250.5,125.5A125,125 0,0 1,125.5 250.5L125.5,250.5A125,125 0,0 1,0.5 125.5L0.5,125.5A125,125 0,0 1,125.5 0.5z"
8 + android:fillColor="#233461"/>
9 + <path
10 + android:pathData="M125,154.99L91.2,121.19L99.55,112.61L119.06,132.13V65.63H130.94V132.13L150.45,112.61L158.8,121.19L125,154.99ZM79.94,184.38C75.94,184.38 72.55,182.99 69.78,180.22C67.01,177.45 65.63,174.06 65.63,170.06V148.6H77.5V170.06C77.5,170.67 77.75,171.23 78.26,171.74C78.77,172.25 79.33,172.5 79.94,172.5H170.06C170.67,172.5 171.23,172.25 171.74,171.74C172.25,171.23 172.5,170.67 172.5,170.06V148.6H184.38V170.06C184.38,174.06 182.99,177.45 180.22,180.22C177.45,182.99 174.06,184.38 170.06,184.38H79.94Z"
11 + android:fillColor="#91B0FF"/>
12 +</vector>
android/app/src/main/res/drawable/send.xml new
+12
@@ -0,0 +1,12 @@
1 +<vector xmlns:android="http://schemas.android.com/apk/res/android"
2 + android:width="251dp"
3 + android:height="251dp"
4 + android:viewportWidth="251"
5 + android:viewportHeight="251">
6 + <path
7 + android:pathData="M125.5,0.5L125.5,0.5A125,125 0,0 1,250.5 125.5L250.5,125.5A125,125 0,0 1,125.5 250.5L125.5,250.5A125,125 0,0 1,0.5 125.5L0.5,125.5A125,125 0,0 1,125.5 0.5z"
8 + android:fillColor="#233461"/>
9 + <path
10 + android:pathData="M63.02,177.86V137.2L113.5,125L63.02,112.8V72.14L188.52,125L63.02,177.86Z"
11 + android:fillColor="#91B0FF"/>
12 +</vector>
ios/Runner/Assets.xcassets/Contents.json new
+6
@@ -0,0 +1,6 @@
1 +{
2 + "info" : {
3 + "author" : "xcode",
4 + "version" : 1
5 + }
6 +}
ios/Runner/Assets.xcassets/receive.imageset/Contents.json new
+23
@@ -0,0 +1,23 @@
1 +{
2 + "images" : [
3 + {
4 + "filename" : "receive.svg",
5 + "idiom" : "universal",
6 + "scale" : "1x"
7 + },
8 + {
9 + "filename" : "receive-1.svg",
10 + "idiom" : "universal",
11 + "scale" : "2x"
12 + },
13 + {
14 + "filename" : "receive-2.svg",
15 + "idiom" : "universal",
16 + "scale" : "3x"
17 + }
18 + ],
19 + "info" : {
20 + "author" : "xcode",
21 + "version" : 1
22 + }
23 +}
ios/Runner/Assets.xcassets/receive.imageset/receive-1.svg new
+4
@@ -0,0 +1,4 @@
1 +<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
2 +<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
3 +<path d="M125 154.992L91.2018 121.194L99.5459 112.608L119.063 132.125V65.625H130.938V132.125L150.454 112.608L158.798 121.194L125 154.992ZM79.9364 184.375C75.9371 184.375 72.5521 182.99 69.7813 180.219C67.0104 177.448 65.625 174.063 65.625 170.064V148.598H77.5V170.064C77.5 170.673 77.754 171.231 78.262 171.738C78.7686 172.246 79.3268 172.5 79.9364 172.5H170.064C170.673 172.5 171.231 172.246 171.738 171.738C172.246 171.231 172.5 170.673 172.5 170.064V148.598H184.375V170.064C184.375 174.063 182.99 177.448 180.219 180.219C177.448 182.99 174.063 184.375 170.064 184.375H79.9364Z" fill="#91B0FF"/>
4 +</svg>
ios/Runner/Assets.xcassets/receive.imageset/receive-2.svg new
+4
@@ -0,0 +1,4 @@
1 +<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
2 +<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
3 +<path d="M125 154.992L91.2018 121.194L99.5459 112.608L119.063 132.125V65.625H130.938V132.125L150.454 112.608L158.798 121.194L125 154.992ZM79.9364 184.375C75.9371 184.375 72.5521 182.99 69.7813 180.219C67.0104 177.448 65.625 174.063 65.625 170.064V148.598H77.5V170.064C77.5 170.673 77.754 171.231 78.262 171.738C78.7686 172.246 79.3268 172.5 79.9364 172.5H170.064C170.673 172.5 171.231 172.246 171.738 171.738C172.246 171.231 172.5 170.673 172.5 170.064V148.598H184.375V170.064C184.375 174.063 182.99 177.448 180.219 180.219C177.448 182.99 174.063 184.375 170.064 184.375H79.9364Z" fill="#91B0FF"/>
4 +</svg>
ios/Runner/Assets.xcassets/receive.imageset/receive.svg new
+4
@@ -0,0 +1,4 @@
1 +<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
2 +<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
3 +<path d="M125 154.992L91.2018 121.194L99.5459 112.608L119.063 132.125V65.625H130.938V132.125L150.454 112.608L158.798 121.194L125 154.992ZM79.9364 184.375C75.9371 184.375 72.5521 182.99 69.7813 180.219C67.0104 177.448 65.625 174.063 65.625 170.064V148.598H77.5V170.064C77.5 170.673 77.754 171.231 78.262 171.738C78.7686 172.246 79.3268 172.5 79.9364 172.5H170.064C170.673 172.5 171.231 172.246 171.738 171.738C172.246 171.231 172.5 170.673 172.5 170.064V148.598H184.375V170.064C184.375 174.063 182.99 177.448 180.219 180.219C177.448 182.99 174.063 184.375 170.064 184.375H79.9364Z" fill="#91B0FF"/>
4 +</svg>
ios/Runner/Assets.xcassets/send.imageset/Contents.json new
+23
@@ -0,0 +1,23 @@
1 +{
2 + "images" : [
3 + {
4 + "filename" : "send.svg",
5 + "idiom" : "universal",
6 + "scale" : "1x"
7 + },
8 + {
9 + "filename" : "send-1.svg",
10 + "idiom" : "universal",
11 + "scale" : "2x"
12 + },
13 + {
14 + "filename" : "send-2.svg",
15 + "idiom" : "universal",
16 + "scale" : "3x"
17 + }
18 + ],
19 + "info" : {
20 + "author" : "xcode",
21 + "version" : 1
22 + }
23 +}
ios/Runner/Assets.xcassets/send.imageset/send-1.svg new
+4
@@ -0,0 +1,4 @@
1 +<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
2 +<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
3 +<path d="M63.0195 177.864V137.199L113.5 125L63.0195 112.801V72.1353L188.52 125L63.0195 177.864Z" fill="#91B0FF"/>
4 +</svg>
ios/Runner/Assets.xcassets/send.imageset/send-2.svg new
+4
@@ -0,0 +1,4 @@
1 +<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
2 +<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
3 +<path d="M63.0195 177.864V137.199L113.5 125L63.0195 112.801V72.1353L188.52 125L63.0195 177.864Z" fill="#91B0FF"/>
4 +</svg>
ios/Runner/Assets.xcassets/send.imageset/send.svg new
+4
@@ -0,0 +1,4 @@
1 +<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
2 +<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
3 +<path d="M63.0195 177.864V137.199L113.5 125L63.0195 112.801V72.1353L188.52 125L63.0195 177.864Z" fill="#91B0FF"/>
4 +</svg>
lib/main.dart
+69 -4
@@ -60,6 +60,7 @@ import 'package:flutter_daemon/flutter_daemon.dart';
60 import 'package:flutter_mobx/flutter_mobx.dart';
61 import 'package:hive/hive.dart';
62 import 'package:cw_core/root_dir.dart';
63 +import 'package:quick_actions/quick_actions.dart';
64 import 'package:shared_preferences/shared_preferences.dart';
65 import 'package:cw_core/window_size.dart';
66 import 'package:logging/logging.dart';
@@ -70,6 +71,7 @@ import 'package:trezor_connect/trezor_connect.dart';
71 final navigatorKey = GlobalKey<NavigatorState>();
72 final rootKey = GlobalKey<RootState>();
73 final RouteObserver<PageRoute<dynamic>> routeObserver = RouteObserver<PageRoute<dynamic>>();
74 +final quickActionsStream = StreamController<Uri?>.broadcast();
75
76 Future<void> main({Key? topLevelKey}) async {
77 await runAppWithZone(topLevelKey: topLevelKey);
@@ -80,6 +82,43 @@ Future<void> runAppWithZone({Key? topLevelKey}) async {
82
83 await runZonedGuarded(() async {
84 WidgetsFlutterBinding.ensureInitialized();
85 +
86 + final Completer<String?> initialShortcutCompleter = Completer<String?>();
87 +
88 + const QuickActions quickActions = QuickActions();
89 +
90 + quickActions.initialize((String shortcutType) {
91 + // Complete the completer only once (for cold starts)
92 + if (!initialShortcutCompleter.isCompleted) {
93 + initialShortcutCompleter.complete(shortcutType);
94 + }
95 +
96 + // Convert the shortcut type to a URI and add it to the stream
97 + final uri = Uri.parse('cakewallet://quickaction/$shortcutType');
98 + quickActionsStream.sink.add(uri);
99 + });
100 +
101 + quickActions.setShortcutItems(<ShortcutItem>[
102 + const ShortcutItem(
103 + type: 'send',
104 + icon: 'send',
105 + localizedTitle: 'Send',
106 + localizedSubtitle: 'Send funds'),
107 + const ShortcutItem(
108 + type: 'receive',
109 + icon: 'receive',
110 + localizedTitle: 'Receive',
111 + localizedSubtitle: 'Receive funds')
112 + ]);
113 +
114 + // Fallback in case the initial shortcut is not received (normal cold start)
115 + Future<void>.delayed(const Duration(milliseconds: 500), () {
116 + if (!initialShortcutCompleter.isCompleted) {
117 + initialShortcutCompleter.complete(null);
118 + }
119 + });
120 +
121 + final initialQuickAction = await initialShortcutCompleter.future;
122 FlutterError.onError = ExceptionHandler.onError;
123
124 /// A callback that is invoked when an unhandled error occurs in the root
@@ -133,11 +172,11 @@ Future<void> runAppWithZone({Key? topLevelKey}) async {
172 runApp(
173 DefaultAssetBundle(
174 bundle: TestAssetBundle(),
136 - child: App(key: topLevelKey),
175 + child: App(key: topLevelKey, initialQuickAction:initialQuickAction,quickActionsStream: quickActionsStream.stream),
176 ),
177 );
178 } else {
140 - runApp(App(key: topLevelKey));
179 + runApp(App(key: topLevelKey, initialQuickAction: initialQuickAction, quickActionsStream: quickActionsStream.stream));
180 }
181
182 isAppRunning = true;
@@ -333,9 +372,11 @@ Future<void> initialSetup({
372 }
373
374 class App extends StatefulWidget {
336 - App({this.key});
375 + App({this.key, this.initialQuickAction, required this.quickActionsStream});
376
377 final Key? key;
378 + final String? initialQuickAction;
379 + final Stream<Uri?> quickActionsStream;
380 @override
381 AppState createState() => AppState();
382 }
@@ -366,9 +407,13 @@ class AppState extends State<App> with SingleTickerProviderStateMixin {
407 statusBarBrightness: statusBarBrightness,
408 statusBarIconBrightness: statusBarIconBrightness));
409
410 + final appRouteObserver = AppRouteObserver();
411 +
412 return Root(
413 key: widget.key ?? rootKey,
414 appStore: appStore,
415 + initialQuickAction: widget.initialQuickAction,
416 + quickActionsStream: widget.quickActionsStream,
417 authenticationStore: authenticationStore,
418 navigatorKey: navigatorKey,
419 authService: authService,
@@ -379,7 +424,7 @@ class AppState extends State<App> with SingleTickerProviderStateMixin {
424 child: ThemeProvider(
425 themeStore: appStore.themeStore,
426 materialAppBuilder: (context, theme, darkTheme, themeMode) => MaterialApp(
382 - navigatorObservers: [routeObserver],
427 + navigatorObservers: [routeObserver, appRouteObserver],
428 navigatorKey: navigatorKey,
429 debugShowCheckedModeBanner: false,
430 theme: theme,
@@ -505,3 +550,23 @@ Future<void> backgroundSync() async {
550 }
551 }
552 }
553 +
554 +class AppRouteObserver extends RouteObserver<PageRoute<dynamic>> {
555 + final AppStore appStore = getIt.get<AppStore>();
556 +
557 + @override
558 + void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
559 + super.didPush(route, previousRoute);
560 + if (route is PageRoute) {
561 + appStore.currentRouteName = route.settings.name;
562 + }
563 + }
564 +
565 + @override
566 + void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
567 + super.didPop(route, previousRoute);
568 + if (previousRoute is PageRoute) {
569 + appStore.currentRouteName = previousRoute.settings.name;
570 + }
571 + }
572 +}
lib/router.dart
+6 -5
@@ -160,12 +160,13 @@ late RouteSettings currentRouteSettings;
160
161 Route<T> handleRouteWithPlatformAwareness<T>(
162 Widget Function(BuildContext) builder, {
163 + RouteSettings? settings,
164 bool fullscreenDialog = false,
165 }) {
166 if (Platform.isIOS) {
166 - return CupertinoPageRoute<T>(builder: builder, fullscreenDialog: fullscreenDialog);
167 + return CupertinoPageRoute<T>(builder: builder, fullscreenDialog: fullscreenDialog, settings: settings);
168 } else {
168 - return MaterialPageRoute<T>(builder: builder, fullscreenDialog: fullscreenDialog);
169 + return MaterialPageRoute<T>(builder: builder, fullscreenDialog: fullscreenDialog, settings: settings);
170 }
171 }
172
@@ -423,6 +424,7 @@ Route<dynamic> createRoute(RouteSettings settings) {
424 param1: initialPaymentRequest,
425 param2: coinTypeToSpendFrom,
426 ),
427 + settings: settings
428 );
429
430 case Routes.sendTemplate:
@@ -430,12 +432,11 @@ Route<dynamic> createRoute(RouteSettings settings) {
432 fullscreenDialog: true, builder: (_) => getIt.get<SendTemplatePage>());
433
434 case Routes.receive:
433 - return CupertinoPageRoute<void>(builder: (context) => getIt.get<ReceivePage>());
435 + return CupertinoPageRoute<void>(builder: (context) => getIt.get<ReceivePage>(), settings: settings);
436
437 case Routes.addressPage:
438 return handleRouteWithPlatformAwareness(
437 - (context) => getIt.get<AddressPage>(),
438 - );
439 + (context) => getIt.get<AddressPage>(), settings: settings);
440
441 case Routes.transactionDetails:
442 return CupertinoPageRoute<void>(
lib/src/screens/root/root.dart
+24 -2
@@ -36,6 +36,8 @@ class Root extends StatefulWidget {
36 required this.tradeMonitor,
37 required this.nodeSwitchingService,
38 required this.trezorConnect,
39 + this.initialQuickAction,
40 + required this.quickActionsStream,
41 }) : super(key: key);
42
43 final AuthenticationStore authenticationStore;
@@ -47,6 +49,8 @@ class Root extends StatefulWidget {
49 final TradeMonitor tradeMonitor;
50 final NodeSwitchingService nodeSwitchingService;
51 final TrezorConnect trezorConnect;
52 + final String? initialQuickAction;
53 + final Stream<Uri?> quickActionsStream;
54
55 @override
56 RootState createState() => RootState();
@@ -103,7 +107,18 @@ class RootState extends State<Root> with WidgetsBindingObserver {
107 handleDeepLinking(uri);
108 });
109
110 + // listen for quick actions
111 + widget.quickActionsStream.listen((Uri? uri) {
112 + handleDeepLinking(uri);
113 + });
114 +
115 handleDeepLinking(await getInitialUri());
116 +
117 + if (widget.initialQuickAction != null) {
118 + final uri = Uri.parse('cakewallet://quickaction/${widget.initialQuickAction}');
119 + handleDeepLinking(uri);
120 + }
121 +
122 } catch (e) {
123 printV(e);
124 }
@@ -121,7 +136,14 @@ class RootState extends State<Root> with WidgetsBindingObserver {
136
137 bool requireAuth = await widget.authService.requireAuth();
138
124 - if (!requireAuth && widget.authenticationStore.state == AuthenticationState.allowed) {
139 +
140 + if (widget.authenticationStore.state == AuthenticationState.allowedCreate) {
141 + requireAuth = false;
142 + }
143 +
144 + if (!requireAuth &&
145 + (widget.authenticationStore.state == AuthenticationState.allowed ||
146 + widget.authenticationStore.state == AuthenticationState.allowedCreate)) {
147 _navigateToDeepLinkScreen();
148 return;
149 }
@@ -129,7 +151,7 @@ class RootState extends State<Root> with WidgetsBindingObserver {
151 _deepLinksReactionDisposer = reaction(
152 (_) => widget.authenticationStore.state,
153 (AuthenticationState state) {
132 - if (state == AuthenticationState.allowed) {
154 + if (state == AuthenticationState.allowed || state == AuthenticationState.allowedCreate) {
155 if (widget.appStore.wallet == null) {
156 waitForWalletInstance(context);
157 } else {
lib/store/app_store.dart
+3
@@ -34,6 +34,9 @@ abstract class AppStoreBase with Store {
34 @observable
35 WalletBase<Balance, TransactionHistoryBase<TransactionInfo>, TransactionInfo>? wallet;
36
37 + @observable
38 + String? currentRouteName;
39 +
40 WalletListStore walletList;
41
42 SettingsStore settingsStore;
lib/view_model/link_view_model.dart
+38 -4
@@ -28,6 +28,8 @@ class LinkViewModel {
28 bool get _isValidPaymentUri => currentLink?.path.isNotEmpty ?? false;
29 bool get isWalletConnectLink => currentLink?.authority == 'wc';
30 bool get isNanoGptLink => currentLink?.scheme == 'nano-gpt';
31 + bool get isQuickActionLink =>
32 + currentLink?.scheme == 'cakewallet' && currentLink?.host == 'quickaction';
33
34 String? getRouteToGo() {
35 if (isWalletConnectLink) {
@@ -38,6 +40,19 @@ class LinkViewModel {
40 return Routes.walletConnectConnectionsListing;
41 }
42
43 + // Check for a quick action first.
44 + if (isQuickActionLink) {
45 + final action = currentLink!.pathSegments.isNotEmpty ? currentLink!.pathSegments.first : null;
46 + switch (action) {
47 + case 'send':
48 + return Routes.send;
49 + case 'receive':
50 + return Routes.addressPage;
51 + default:
52 + return null;
53 + }
54 + }
55 +
56 if (authenticationStore.state == AuthenticationState.uninitialized) {
57 return null;
58 }
@@ -65,6 +80,10 @@ class LinkViewModel {
80 return currentLink;
81 }
82
83 + if (isQuickActionLink) {
84 + return null;
85 + }
86 +
87 if (isNanoGptLink) {
88 switch (currentLink?.authority ?? '') {
89 case "exchange":
@@ -113,16 +132,31 @@ class LinkViewModel {
132 return;
133 }
134
135 + // Prevent navigating to the same route again.
136 + if (appStore.currentRouteName == route) {
137 + return;
138 + }
139 +
140 if (isNanoGptLink) {
141 if (route == Routes.buySellPage || route == Routes.exchange) {
142 await _errorToast(S.current.nano_gpt_thanks_message, fontSize: 14);
143 }
144 }
145 +
146 + // Quick actions must reset navigation to Dashboard → TargetPage
147 + if (isQuickActionLink) {
148 + currentLink = null;
149 + navigatorKey.currentState?.pushNamedAndRemoveUntil(
150 + route,
151 + ModalRoute.withName(Routes.dashboard),
152 + arguments: args,
153 + );
154 + return;
155 + }
156 +
157 + // Normal navigation flow
158 currentLink = null;
122 - navigatorKey.currentState?.pushNamed(
123 - route,
124 - arguments: args,
125 - );
159 + navigatorKey.currentState?.pushNamed(route, arguments: args);
160 }
161 }
162 }
pubspec_base.yaml
+1
@@ -152,6 +152,7 @@ dependencies:
152 ref: b48a9defddce036048bae02e2069ebcbd6824afc
153 torch_dart:
154 path: ./scripts/torch_dart
155 + quick_actions: ^1.1.0
156
157 dev_dependencies:
158 flutter_test: