From c263e4227e72eb53196d80eb6f0ea0cf35be753b Mon Sep 17 00:00:00 2001 From: JulianAlcala Date: Tue, 7 Apr 2026 00:09:48 +0200 Subject: [PATCH] feat: split legacy/payment apps via APP_MODE flag --- .vscode/launch.json | 55 +++++++++++++-- apps/mobile_app/lib/core/config/app_mode.dart | 20 ++++++ apps/mobile_app/lib/core/init_app.dart | 11 +-- .../mobile_app/lib/navigation/app_router.dart | 22 +++++- apps/mobile_app/lib/save_family_app.dart | 69 +++++++++++-------- modules/splash/lib/splash.dart | 1 + .../lib/src/presentation/splash_screen.dart | 14 ++-- modules/splash/lib/src/splash_builder.dart | 9 ++- .../lib/configure_dependencies.dart | 4 +- .../network/treezor_token_interceptor.dart | 58 +++++++++++----- 10 files changed, 197 insertions(+), 66 deletions(-) create mode 100644 apps/mobile_app/lib/core/config/app_mode.dart diff --git a/.vscode/launch.json b/.vscode/launch.json index 9011ed43..551f7626 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,39 +2,82 @@ // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + // + // Configurations are split between (Legacy) and (Payment) variants. + // (Legacy) is the default and matches historical behavior; (Payment) + // boots straight into the Treezor wallet flow via APP_MODE=payment. "version": "0.2.0", "configurations": [ { - "name": "SF Development", + "name": "SF Development (Legacy)", "cwd": "apps/mobile_app", "request": "launch", "type": "dart", "args": [ "--flavor", "development", - "--dart-define-from-file=config/development.json" + "--dart-define-from-file=config/development.json", + "--dart-define=APP_MODE=legacy" ] }, { - "name": "SF Staging", + "name": "SF Development (Payment)", + "cwd": "apps/mobile_app", + "request": "launch", + "type": "dart", + "args": [ + "--flavor", + "development", + "--dart-define-from-file=config/development.json", + "--dart-define=APP_MODE=payment" + ] + }, + { + "name": "SF Staging (Legacy)", "cwd": "apps/mobile_app", "request": "launch", "type": "dart", "args": [ "--flavor", "staging", - "--dart-define-from-file=config/staging.json" + "--dart-define-from-file=config/staging.json", + "--dart-define=APP_MODE=legacy" ] }, { - "name": "SF Production", + "name": "SF Staging (Payment)", + "cwd": "apps/mobile_app", + "request": "launch", + "type": "dart", + "args": [ + "--flavor", + "staging", + "--dart-define-from-file=config/staging.json", + "--dart-define=APP_MODE=payment" + ] + }, + { + "name": "SF Production (Legacy)", "cwd": "apps/mobile_app", "request": "launch", "type": "dart", "args": [ "--flavor", "production", - "--dart-define-from-file=config/production.json" + "--dart-define-from-file=config/production.json", + "--dart-define=APP_MODE=legacy" + ] + }, + { + "name": "SF Production (Payment)", + "cwd": "apps/mobile_app", + "request": "launch", + "type": "dart", + "args": [ + "--flavor", + "production", + "--dart-define-from-file=config/production.json", + "--dart-define=APP_MODE=payment" ] } ] diff --git a/apps/mobile_app/lib/core/config/app_mode.dart b/apps/mobile_app/lib/core/config/app_mode.dart new file mode 100644 index 00000000..c6b95956 --- /dev/null +++ b/apps/mobile_app/lib/core/config/app_mode.dart @@ -0,0 +1,20 @@ +/// Compile-time constant that controls which app the splash screen +/// navigates to when the app starts. +/// +/// Set via `--dart-define=APP_MODE=payment` (or `legacy`) at launch time. +/// Defaults to `legacy` to preserve historical behavior when no flag is +/// passed (e.g. `flutter run` from CLI without arguments). +/// +/// Used only for local development to switch between the legacy app +/// (watch/device control) and the payment app (Treezor wallet) without +/// needing separate flavors or entry points. +const String appMode = String.fromEnvironment( + 'APP_MODE', + defaultValue: 'legacy', +); + +/// Whether the app should boot into the payment (Treezor wallet) flow. +bool get isPaymentMode => appMode == 'payment'; + +/// Whether the app should boot into the legacy (watch/device) flow. +bool get isLegacyMode => appMode == 'legacy'; diff --git a/apps/mobile_app/lib/core/init_app.dart b/apps/mobile_app/lib/core/init_app.dart index 92a9437a..69291204 100644 --- a/apps/mobile_app/lib/core/init_app.dart +++ b/apps/mobile_app/lib/core/init_app.dart @@ -7,6 +7,7 @@ import 'package:design_system/design_system.dart'; import 'package:sca_treezor/sca_treezor.dart'; import 'package:sf_app_platform/config/env/environment_enum.dart'; import 'package:sf_app_platform/config/env/questia_env_config.dart'; +import 'package:sf_app_platform/core/config/app_mode.dart'; import 'package:sf_app_platform/navigation/app_router.dart'; import 'package:sf_app_platform/save_family_app.dart'; import 'package:navigation/navigation.dart'; @@ -30,9 +31,11 @@ Future initApp(EnvironmentEnum env) async { await configureDependencies( QuestiaEnvConfig(), log: env.isDevelopment || kDebugMode, - onTokenExpired: () => appRouter.go( - AppRoutes.legacyLogin, - ), //change to payments app to AppRoutes.scaTreezor + // Treezor-specific detection (message + 500) runs in both modes; + // only the destination route differs based on the active app mode. + onTokenExpired: isPaymentMode + ? () => appRouter.go(AppRoutes.scaTreezor) + : () => appRouter.go(AppRoutes.legacyLogin), onUnauthorized: () async { final currentLocation = appRouter.routerDelegate.currentConfiguration.uri.path; @@ -41,7 +44,7 @@ Future initApp(EnvironmentEnum env) async { await GetIt.I().logout(); } catch (_) {} await clearSessionData(); - appRouter.go(AppRoutes.legacyLogin); + appRouter.go(isPaymentMode ? AppRoutes.login : AppRoutes.legacyLogin); }, ); diff --git a/apps/mobile_app/lib/navigation/app_router.dart b/apps/mobile_app/lib/navigation/app_router.dart index 482a3914..b9787c5f 100644 --- a/apps/mobile_app/lib/navigation/app_router.dart +++ b/apps/mobile_app/lib/navigation/app_router.dart @@ -17,13 +17,33 @@ import 'package:notifications/notifications.dart'; import 'package:payments/payments.dart'; import 'package:profile/profile.dart'; import 'package:settings/settings.dart'; +import 'package:sf_app_platform/core/config/app_mode.dart'; import 'package:splash/splash.dart'; final GlobalKey rootNavigatorKey = GlobalKey(); late final GoRouter appRouter; +/// Maps the splash's session check result to the destination route based +/// on the active [appMode]. Set `--dart-define=APP_MODE=payment` (or use +/// the `(Payment)` launch configurations) to boot into the payment app. +const _legacySplashRouteMap = { + InitialRoute.onboarding: AppRoutes.legacyOnboarding, + InitialRoute.login: AppRoutes.legacyLogin, + InitialRoute.home: AppRoutes.controlPanel, +}; + +const _paymentSplashRouteMap = { + InitialRoute.onboarding: AppRoutes.onboarding, + InitialRoute.login: AppRoutes.login, + InitialRoute.home: AppRoutes.dashboardHome, +}; + void configureAppRouter() { + final splashRouteMap = isPaymentMode + ? _paymentSplashRouteMap + : _legacySplashRouteMap; + appRouter = GoRouter( navigatorKey: rootNavigatorKey, initialLocation: AppRoutes.splash, @@ -32,7 +52,7 @@ void configureAppRouter() { GoRoute( path: AppRoutes.splash, name: 'splash', - pageBuilder: SplashBuilder().buildPage, + pageBuilder: SplashBuilder(routeMap: splashRouteMap).buildPage, ), StatefulShellRoute.indexedStack( builder: (context, state, navShell) { diff --git a/apps/mobile_app/lib/save_family_app.dart b/apps/mobile_app/lib/save_family_app.dart index b792fb4e..cc579cb0 100644 --- a/apps/mobile_app/lib/save_family_app.dart +++ b/apps/mobile_app/lib/save_family_app.dart @@ -2,6 +2,7 @@ import 'package:auth/auth.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sf_app_platform/core/config/app_mode.dart'; import 'package:sf_app_platform/navigation/app_router.dart'; import 'package:navigation/navigation.dart'; import 'package:sf_app_platform/providers/app_state_provider.dart'; @@ -24,48 +25,58 @@ class SaveFamilyApp extends ConsumerStatefulWidget { class SaveFamilyAppState extends ConsumerState with WidgetsBindingObserver { - late final WalletHeartbeatService walletHeartbeat; - late final LegacyHeartbeatService legacyHeartbeat; + WalletHeartbeatService? _walletHeartbeat; + LegacyHeartbeatService? _legacyHeartbeat; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - walletHeartbeat = WalletHeartbeatService( - repository: ref.read(treezorRepositoryProvider), - sessionLocal: SessionLocalDatasourceImpl(), - onError: () => appRouter.go( - AppRoutes.legacyLogin, - ), //change to payments app to AppRoutes.scaTreezor - ); - legacyHeartbeat = LegacyHeartbeatService( - repository: GetIt.I(), - onUnauthorized: () { - clearSessionData(); - appRouter.go(AppRoutes.legacyLogin); - }, - ); + + if (isPaymentMode) { + _walletHeartbeat = WalletHeartbeatService( + repository: ref.read(treezorRepositoryProvider), + sessionLocal: SessionLocalDatasourceImpl(), + onError: () => appRouter.go(AppRoutes.scaTreezor), + ); + } + + if (isLegacyMode) { + _legacyHeartbeat = LegacyHeartbeatService( + repository: GetIt.I(), + onUnauthorized: () { + clearSessionData(); + appRouter.go(AppRoutes.legacyLogin); + }, + ); + appRouter.routerDelegate.addListener(_onRouteChanged); + } + onBeforeSessionCleared = () { - walletHeartbeat.stop(); - legacyHeartbeat.stop(); + _walletHeartbeat?.stop(); + _legacyHeartbeat?.stop(); }; - appRouter.routerDelegate.addListener(_onRouteChanged); } void _onRouteChanged() { + final heartbeat = _legacyHeartbeat; + if (heartbeat == null) return; + final location = appRouter.routerDelegate.currentConfiguration.uri.path; if (location.startsWith(AppRoutes.legacyDashboard)) { - legacyHeartbeat.start(); + heartbeat.start(); } else { - legacyHeartbeat.stop(); + heartbeat.stop(); } } @override void dispose() { - appRouter.routerDelegate.removeListener(_onRouteChanged); - walletHeartbeat.stop(); - legacyHeartbeat.stop(); + if (isLegacyMode) { + appRouter.routerDelegate.removeListener(_onRouteChanged); + } + _walletHeartbeat?.stop(); + _legacyHeartbeat?.stop(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -75,12 +86,14 @@ class SaveFamilyAppState extends ConsumerState debugPrint('State: $state'); ref.read(appLifecycleStateProvider.notifier).setState(state); if (state == AppLifecycleState.resumed) { - // walletHeartbeat.start(); - _onRouteChanged(); + _walletHeartbeat?.start(); + if (isLegacyMode) { + _onRouteChanged(); + } ref.read(permissionsProvider.notifier).checkPermissions(); } else if (state == AppLifecycleState.paused) { - // walletHeartbeat.stop(); - legacyHeartbeat.stop(); + _walletHeartbeat?.stop(); + _legacyHeartbeat?.stop(); } super.didChangeAppLifecycleState(state); } diff --git a/modules/splash/lib/splash.dart b/modules/splash/lib/splash.dart index 8c108fb5..1ab20bc8 100644 --- a/modules/splash/lib/splash.dart +++ b/modules/splash/lib/splash.dart @@ -1 +1,2 @@ +export 'src/domain/initial_route.dart'; export 'src/splash_builder.dart'; diff --git a/modules/splash/lib/src/presentation/splash_screen.dart b/modules/splash/lib/src/presentation/splash_screen.dart index 4ed19c25..b5de6c21 100644 --- a/modules/splash/lib/src/presentation/splash_screen.dart +++ b/modules/splash/lib/src/presentation/splash_screen.dart @@ -10,11 +10,17 @@ class SplashScreen extends StatefulWidget { super.key, required this.navigationContract, required this.checkSessionUseCase, + required this.routeMap, }); final NavigationContract navigationContract; final CheckSessionUseCase checkSessionUseCase; + /// Maps each [InitialRoute] to its destination path. Provided by the + /// app layer so the splash module stays agnostic of which app + /// (legacy vs payment) is being launched. + final Map routeMap; + @override State createState() => _SplashScreenState(); } @@ -52,11 +58,9 @@ class _SplashScreenState extends State void _navigateIfReady() { if (!_animationDone || _route == null || !mounted) return; - final destination = switch (_route!) { - InitialRoute.onboarding => AppRoutes.legacyOnboarding, - InitialRoute.login => AppRoutes.legacyLogin, - InitialRoute.home => AppRoutes.controlPanel, - }; + final destination = widget.routeMap[_route!]; + if (destination == null) return; + widget.navigationContract.goTo(destination); } diff --git a/modules/splash/lib/src/splash_builder.dart b/modules/splash/lib/src/splash_builder.dart index 49d6b38a..eda63fec 100644 --- a/modules/splash/lib/src/splash_builder.dart +++ b/modules/splash/lib/src/splash_builder.dart @@ -5,10 +5,16 @@ import 'package:navigation/navigation_contract.dart'; import 'package:sf_infrastructure/sf_infrastructure.dart'; import 'package:sf_shared/sf_shared.dart'; import 'package:splash/src/domain/check_session_use_case_impl.dart'; +import 'package:splash/src/domain/initial_route.dart'; import 'package:splash/src/presentation/splash_screen.dart'; class SplashBuilder { - const SplashBuilder(); + const SplashBuilder({required this.routeMap}); + + /// Maps each [InitialRoute] to its destination path. The app layer + /// provides this map so the splash module stays agnostic of which + /// app (legacy vs payment) is being launched. + final Map routeMap; Page buildPage(BuildContext context, GoRouterState state) { final navigationContract = GetIt.I(); @@ -20,6 +26,7 @@ class SplashBuilder { child: SplashScreen( navigationContract: navigationContract, checkSessionUseCase: checkSessionUseCase, + routeMap: routeMap, ), ); } diff --git a/packages/sf_infrastructure/lib/configure_dependencies.dart b/packages/sf_infrastructure/lib/configure_dependencies.dart index c7be29b6..7001162f 100644 --- a/packages/sf_infrastructure/lib/configure_dependencies.dart +++ b/packages/sf_infrastructure/lib/configure_dependencies.dart @@ -29,11 +29,11 @@ Future configureDependencies( cookieJar: cookieJar, ); - if (onTokenExpired != null && onUnauthorized != null) { + if (onUnauthorized != null) { dio.interceptors.add( TreezorTokenInterceptor( - onTokenExpired: onTokenExpired, onUnauthorized: onUnauthorized, + onTokenExpired: onTokenExpired, ), ); } diff --git a/packages/sf_infrastructure/lib/src/network/treezor_token_interceptor.dart b/packages/sf_infrastructure/lib/src/network/treezor_token_interceptor.dart index 2c09cae2..f6b52263 100644 --- a/packages/sf_infrastructure/lib/src/network/treezor_token_interceptor.dart +++ b/packages/sf_infrastructure/lib/src/network/treezor_token_interceptor.dart @@ -4,32 +4,53 @@ import 'package:dio/dio.dart'; class TreezorTokenInterceptor extends Interceptor { TreezorTokenInterceptor({ - required void Function() onTokenExpired, required void Function() onUnauthorized, - }) : _onTokenExpired = onTokenExpired, - _onUnauthorized = onUnauthorized; + void Function()? onTokenExpired, + }) : _onUnauthorized = onUnauthorized, + _onTokenExpired = onTokenExpired; - // ignore: unused_field - final void Function() _onTokenExpired; + /// Called when the backend signals a session expiration that should + /// trigger a re-login flow. The destination route is decided by the + /// caller (e.g. SCA screen in payment mode, login screen in legacy + /// mode). When `null`, the Treezor-specific detection (message + 500) + /// is skipped entirely and only generic 401 responses are handled. + final void Function()? _onTokenExpired; + + /// Called on any 401 response. Active in both apps. final void Function() _onUnauthorized; + bool _handling = false; @override void onError(DioException err, ErrorInterceptorHandler handler) { if (!_handling) { - // final message = _extractApiMessage(err.response?.data); - // if (message != null && message.contains('Treezor Token Expired')) { - // _handling = true; - // _onTokenExpired(); - // Future.delayed(const Duration(seconds: 2), () => _handling = false); - // } - // else if (err.response?.statusCode == 500) { - // _handling = true; - // _onTokenExpired(); - // Future.delayed(const Duration(seconds: 2), () => _handling = false); - // } - // else - if (err.response?.statusCode == 401) { + // Treezor-specific handling: only enabled when running in payment mode + // (i.e. when an `onTokenExpired` callback was provided). + if (_onTokenExpired != null) { + final message = _extractApiMessage(err.response?.data); + if (message != null && message.contains('Treezor Token Expired')) { + _handling = true; + _onTokenExpired(); + Future.delayed( + const Duration(seconds: 2), + () => _handling = false, + ); + } else if (err.response?.statusCode == 500) { + _handling = true; + _onTokenExpired(); + Future.delayed( + const Duration(seconds: 2), + () => _handling = false, + ); + } else if (err.response?.statusCode == 401) { + _handling = true; + _onUnauthorized(); + Future.delayed( + const Duration(seconds: 2), + () => _handling = false, + ); + } + } else if (err.response?.statusCode == 401) { _handling = true; _onUnauthorized(); Future.delayed(const Duration(seconds: 2), () => _handling = false); @@ -39,7 +60,6 @@ class TreezorTokenInterceptor extends Interceptor { handler.next(err); } - // ignore: unused_element String? _extractApiMessage(Object? data) { if (data == null) return null;