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: