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>
This commit is contained in:
Serhii
2026-01-17 04:09:10 +01:00
committed by GitHub
co-authored by Serhii-Borodenko OmarHatem
parent cf795bcc48
commit 2dd6cdce1e
17 changed files with 241 additions and 15 deletions
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="251dp"
android:height="251dp"
android:viewportWidth="251"
android:viewportHeight="251">
<path
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"
android:fillColor="#233461"/>
<path
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"
android:fillColor="#91B0FF"/>
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="251dp"
android:height="251dp"
android:viewportWidth="251"
android:viewportHeight="251">
<path
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"
android:fillColor="#233461"/>
<path
android:pathData="M63.02,177.86V137.2L113.5,125L63.02,112.8V72.14L188.52,125L63.02,177.86Z"
android:fillColor="#91B0FF"/>
</vector>
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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
}
}
@@ -0,0 +1,4 @@
<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
<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"/>
</svg>

After

Width:  |  Height:  |  Size: 778 B

@@ -0,0 +1,4 @@
<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
<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"/>
</svg>

After

Width:  |  Height:  |  Size: 778 B

@@ -0,0 +1,4 @@
<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
<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"/>
</svg>

After

Width:  |  Height:  |  Size: 778 B

+23
View File
@@ -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
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
<path d="M63.0195 177.864V137.199L113.5 125L63.0195 112.801V72.1353L188.52 125L63.0195 177.864Z" fill="#91B0FF"/>
</svg>

After

Width:  |  Height:  |  Size: 294 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
<path d="M63.0195 177.864V137.199L113.5 125L63.0195 112.801V72.1353L188.52 125L63.0195 177.864Z" fill="#91B0FF"/>
</svg>

After

Width:  |  Height:  |  Size: 294 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="251" height="251" viewBox="0 0 251 251" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="250" height="250" rx="125" fill="#233461"/>
<path d="M63.0195 177.864V137.199L113.5 125L63.0195 112.801V72.1353L188.52 125L63.0195 177.864Z" fill="#91B0FF"/>
</svg>

After

Width:  |  Height:  |  Size: 294 B

+69 -4
View File
@@ -60,6 +60,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';
@@ -70,6 +71,7 @@ import 'package:trezor_connect/trezor_connect.dart';
final navigatorKey = GlobalKey<NavigatorState>();
final rootKey = GlobalKey<RootState>();
final RouteObserver<PageRoute<dynamic>> routeObserver = RouteObserver<PageRoute<dynamic>>();
final quickActionsStream = StreamController<Uri?>.broadcast();
Future<void> main({Key? topLevelKey}) async {
await runAppWithZone(topLevelKey: topLevelKey);
@@ -80,6 +82,43 @@ Future<void> runAppWithZone({Key? topLevelKey}) async {
await runZonedGuarded(() async {
WidgetsFlutterBinding.ensureInitialized();
final Completer<String?> initialShortcutCompleter = Completer<String?>();
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(<ShortcutItem>[
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<void>.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
@@ -133,11 +172,11 @@ Future<void> 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;
@@ -333,9 +372,11 @@ Future<void> initialSetup({
}
class App extends StatefulWidget {
App({this.key});
App({this.key, this.initialQuickAction, required this.quickActionsStream});
final Key? key;
final String? initialQuickAction;
final Stream<Uri?> quickActionsStream;
@override
AppState createState() => AppState();
}
@@ -366,9 +407,13 @@ class AppState extends State<App> 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,
@@ -379,7 +424,7 @@ class AppState extends State<App> with SingleTickerProviderStateMixin {
child: ThemeProvider(
themeStore: appStore.themeStore,
materialAppBuilder: (context, theme, darkTheme, themeMode) => MaterialApp(
navigatorObservers: [routeObserver],
navigatorObservers: [routeObserver, appRouteObserver],
navigatorKey: navigatorKey,
debugShowCheckedModeBanner: false,
theme: theme,
@@ -505,3 +550,23 @@ Future<void> backgroundSync() async {
}
}
}
class AppRouteObserver extends RouteObserver<PageRoute<dynamic>> {
final AppStore appStore = getIt.get<AppStore>();
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPush(route, previousRoute);
if (route is PageRoute) {
appStore.currentRouteName = route.settings.name;
}
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPop(route, previousRoute);
if (previousRoute is PageRoute) {
appStore.currentRouteName = previousRoute.settings.name;
}
}
}
+6 -5
View File
@@ -160,12 +160,13 @@ late RouteSettings currentRouteSettings;
Route<T> handleRouteWithPlatformAwareness<T>(
Widget Function(BuildContext) builder, {
RouteSettings? settings,
bool fullscreenDialog = false,
}) {
if (Platform.isIOS) {
return CupertinoPageRoute<T>(builder: builder, fullscreenDialog: fullscreenDialog);
return CupertinoPageRoute<T>(builder: builder, fullscreenDialog: fullscreenDialog, settings: settings);
} else {
return MaterialPageRoute<T>(builder: builder, fullscreenDialog: fullscreenDialog);
return MaterialPageRoute<T>(builder: builder, fullscreenDialog: fullscreenDialog, settings: settings);
}
}
@@ -423,6 +424,7 @@ Route<dynamic> createRoute(RouteSettings settings) {
param1: initialPaymentRequest,
param2: coinTypeToSpendFrom,
),
settings: settings
);
case Routes.sendTemplate:
@@ -430,12 +432,11 @@ Route<dynamic> createRoute(RouteSettings settings) {
fullscreenDialog: true, builder: (_) => getIt.get<SendTemplatePage>());
case Routes.receive:
return CupertinoPageRoute<void>(builder: (context) => getIt.get<ReceivePage>());
return CupertinoPageRoute<void>(builder: (context) => getIt.get<ReceivePage>(), settings: settings);
case Routes.addressPage:
return handleRouteWithPlatformAwareness(
(context) => getIt.get<AddressPage>(),
);
(context) => getIt.get<AddressPage>(), settings: settings);
case Routes.transactionDetails:
return CupertinoPageRoute<void>(
+24 -2
View File
@@ -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<Uri?> quickActionsStream;
@override
RootState createState() => RootState();
@@ -103,7 +107,18 @@ class RootState extends State<Root> 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);
}
@@ -121,7 +136,14 @@ class RootState extends State<Root> with WidgetsBindingObserver {
bool requireAuth = await widget.authService.requireAuth();
if (!requireAuth && widget.authenticationStore.state == AuthenticationState.allowed) {
if (widget.authenticationStore.state == AuthenticationState.allowedCreate) {
requireAuth = false;
}
if (!requireAuth &&
(widget.authenticationStore.state == AuthenticationState.allowed ||
widget.authenticationStore.state == AuthenticationState.allowedCreate)) {
_navigateToDeepLinkScreen();
return;
}
@@ -129,7 +151,7 @@ class RootState extends State<Root> with WidgetsBindingObserver {
_deepLinksReactionDisposer = reaction(
(_) => widget.authenticationStore.state,
(AuthenticationState state) {
if (state == AuthenticationState.allowed) {
if (state == AuthenticationState.allowed || state == AuthenticationState.allowedCreate) {
if (widget.appStore.wallet == null) {
waitForWalletInstance(context);
} else {
+3
View File
@@ -34,6 +34,9 @@ abstract class AppStoreBase with Store {
@observable
WalletBase<Balance, TransactionHistoryBase<TransactionInfo>, TransactionInfo>? wallet;
@observable
String? currentRouteName;
WalletListStore walletList;
SettingsStore settingsStore;
+38 -4
View File
@@ -28,6 +28,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) {
@@ -38,6 +40,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.addressPage;
default:
return null;
}
}
if (authenticationStore.state == AuthenticationState.uninitialized) {
return null;
}
@@ -65,6 +80,10 @@ class LinkViewModel {
return currentLink;
}
if (isQuickActionLink) {
return null;
}
if (isNanoGptLink) {
switch (currentLink?.authority ?? '') {
case "exchange":
@@ -113,16 +132,31 @@ 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);
}
}
// Quick actions must reset navigation to Dashboard → TargetPage
if (isQuickActionLink) {
currentLink = null;
navigatorKey.currentState?.pushNamedAndRemoveUntil(
route,
ModalRoute.withName(Routes.dashboard),
arguments: args,
);
return;
}
// Normal navigation flow
currentLink = null;
navigatorKey.currentState?.pushNamed(
route,
arguments: args,
);
navigatorKey.currentState?.pushNamed(route, arguments: args);
}
}
}
+1
View File
@@ -152,6 +152,7 @@ dependencies:
ref: b48a9defddce036048bae02e2069ebcbd6824afc
torch_dart:
path: ./scripts/torch_dart
quick_actions: ^1.1.0
dev_dependencies:
flutter_test: