feat: split legacy/payment apps via APP_MODE flag

This commit is contained in:
2026-04-07 00:09:48 +02:00
parent 3a375044b2
commit c263e4227e
10 changed files with 197 additions and 66 deletions

55
.vscode/launch.json vendored
View File

@@ -2,39 +2,82 @@
// Use IntelliSense to learn about possible attributes. // Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes. // Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // 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", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "SF Development", "name": "SF Development (Legacy)",
"cwd": "apps/mobile_app", "cwd": "apps/mobile_app",
"request": "launch", "request": "launch",
"type": "dart", "type": "dart",
"args": [ "args": [
"--flavor", "--flavor",
"development", "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", "cwd": "apps/mobile_app",
"request": "launch", "request": "launch",
"type": "dart", "type": "dart",
"args": [ "args": [
"--flavor", "--flavor",
"staging", "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", "cwd": "apps/mobile_app",
"request": "launch", "request": "launch",
"type": "dart", "type": "dart",
"args": [ "args": [
"--flavor", "--flavor",
"production", "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"
] ]
} }
] ]

View File

@@ -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';

View File

@@ -7,6 +7,7 @@ import 'package:design_system/design_system.dart';
import 'package:sca_treezor/sca_treezor.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/environment_enum.dart';
import 'package:sf_app_platform/config/env/questia_env_config.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/navigation/app_router.dart';
import 'package:sf_app_platform/save_family_app.dart'; import 'package:sf_app_platform/save_family_app.dart';
import 'package:navigation/navigation.dart'; import 'package:navigation/navigation.dart';
@@ -30,9 +31,11 @@ Future<void> initApp(EnvironmentEnum env) async {
await configureDependencies( await configureDependencies(
QuestiaEnvConfig(), QuestiaEnvConfig(),
log: env.isDevelopment || kDebugMode, log: env.isDevelopment || kDebugMode,
onTokenExpired: () => appRouter.go( // Treezor-specific detection (message + 500) runs in both modes;
AppRoutes.legacyLogin, // only the destination route differs based on the active app mode.
), //change to payments app to AppRoutes.scaTreezor onTokenExpired: isPaymentMode
? () => appRouter.go(AppRoutes.scaTreezor)
: () => appRouter.go(AppRoutes.legacyLogin),
onUnauthorized: () async { onUnauthorized: () async {
final currentLocation = final currentLocation =
appRouter.routerDelegate.currentConfiguration.uri.path; appRouter.routerDelegate.currentConfiguration.uri.path;
@@ -41,7 +44,7 @@ Future<void> initApp(EnvironmentEnum env) async {
await GetIt.I<TreezorWalletConnectionService>().logout(); await GetIt.I<TreezorWalletConnectionService>().logout();
} catch (_) {} } catch (_) {}
await clearSessionData(); await clearSessionData();
appRouter.go(AppRoutes.legacyLogin); appRouter.go(isPaymentMode ? AppRoutes.login : AppRoutes.legacyLogin);
}, },
); );

View File

@@ -17,13 +17,33 @@ import 'package:notifications/notifications.dart';
import 'package:payments/payments.dart'; import 'package:payments/payments.dart';
import 'package:profile/profile.dart'; import 'package:profile/profile.dart';
import 'package:settings/settings.dart'; import 'package:settings/settings.dart';
import 'package:sf_app_platform/core/config/app_mode.dart';
import 'package:splash/splash.dart'; import 'package:splash/splash.dart';
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>(); final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
late final GoRouter appRouter; 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, String>{
InitialRoute.onboarding: AppRoutes.legacyOnboarding,
InitialRoute.login: AppRoutes.legacyLogin,
InitialRoute.home: AppRoutes.controlPanel,
};
const _paymentSplashRouteMap = <InitialRoute, String>{
InitialRoute.onboarding: AppRoutes.onboarding,
InitialRoute.login: AppRoutes.login,
InitialRoute.home: AppRoutes.dashboardHome,
};
void configureAppRouter() { void configureAppRouter() {
final splashRouteMap = isPaymentMode
? _paymentSplashRouteMap
: _legacySplashRouteMap;
appRouter = GoRouter( appRouter = GoRouter(
navigatorKey: rootNavigatorKey, navigatorKey: rootNavigatorKey,
initialLocation: AppRoutes.splash, initialLocation: AppRoutes.splash,
@@ -32,7 +52,7 @@ void configureAppRouter() {
GoRoute( GoRoute(
path: AppRoutes.splash, path: AppRoutes.splash,
name: 'splash', name: 'splash',
pageBuilder: SplashBuilder().buildPage, pageBuilder: SplashBuilder(routeMap: splashRouteMap).buildPage,
), ),
StatefulShellRoute.indexedStack( StatefulShellRoute.indexedStack(
builder: (context, state, navShell) { builder: (context, state, navShell) {

View File

@@ -2,6 +2,7 @@ import 'package:auth/auth.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.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:sf_app_platform/navigation/app_router.dart';
import 'package:navigation/navigation.dart'; import 'package:navigation/navigation.dart';
import 'package:sf_app_platform/providers/app_state_provider.dart'; import 'package:sf_app_platform/providers/app_state_provider.dart';
@@ -24,48 +25,58 @@ class SaveFamilyApp extends ConsumerStatefulWidget {
class SaveFamilyAppState extends ConsumerState<SaveFamilyApp> class SaveFamilyAppState extends ConsumerState<SaveFamilyApp>
with WidgetsBindingObserver { with WidgetsBindingObserver {
late final WalletHeartbeatService walletHeartbeat; WalletHeartbeatService? _walletHeartbeat;
late final LegacyHeartbeatService legacyHeartbeat; LegacyHeartbeatService? _legacyHeartbeat;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
walletHeartbeat = WalletHeartbeatService(
repository: ref.read(treezorRepositoryProvider), if (isPaymentMode) {
sessionLocal: SessionLocalDatasourceImpl(), _walletHeartbeat = WalletHeartbeatService(
onError: () => appRouter.go( repository: ref.read(treezorRepositoryProvider),
AppRoutes.legacyLogin, sessionLocal: SessionLocalDatasourceImpl(),
), //change to payments app to AppRoutes.scaTreezor onError: () => appRouter.go(AppRoutes.scaTreezor),
); );
legacyHeartbeat = LegacyHeartbeatService( }
repository: GetIt.I<QuestiaRepository>(),
onUnauthorized: () { if (isLegacyMode) {
clearSessionData(); _legacyHeartbeat = LegacyHeartbeatService(
appRouter.go(AppRoutes.legacyLogin); repository: GetIt.I<QuestiaRepository>(),
}, onUnauthorized: () {
); clearSessionData();
appRouter.go(AppRoutes.legacyLogin);
},
);
appRouter.routerDelegate.addListener(_onRouteChanged);
}
onBeforeSessionCleared = () { onBeforeSessionCleared = () {
walletHeartbeat.stop(); _walletHeartbeat?.stop();
legacyHeartbeat.stop(); _legacyHeartbeat?.stop();
}; };
appRouter.routerDelegate.addListener(_onRouteChanged);
} }
void _onRouteChanged() { void _onRouteChanged() {
final heartbeat = _legacyHeartbeat;
if (heartbeat == null) return;
final location = appRouter.routerDelegate.currentConfiguration.uri.path; final location = appRouter.routerDelegate.currentConfiguration.uri.path;
if (location.startsWith(AppRoutes.legacyDashboard)) { if (location.startsWith(AppRoutes.legacyDashboard)) {
legacyHeartbeat.start(); heartbeat.start();
} else { } else {
legacyHeartbeat.stop(); heartbeat.stop();
} }
} }
@override @override
void dispose() { void dispose() {
appRouter.routerDelegate.removeListener(_onRouteChanged); if (isLegacyMode) {
walletHeartbeat.stop(); appRouter.routerDelegate.removeListener(_onRouteChanged);
legacyHeartbeat.stop(); }
_walletHeartbeat?.stop();
_legacyHeartbeat?.stop();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }
@@ -75,12 +86,14 @@ class SaveFamilyAppState extends ConsumerState<SaveFamilyApp>
debugPrint('State: $state'); debugPrint('State: $state');
ref.read(appLifecycleStateProvider.notifier).setState(state); ref.read(appLifecycleStateProvider.notifier).setState(state);
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
// walletHeartbeat.start(); _walletHeartbeat?.start();
_onRouteChanged(); if (isLegacyMode) {
_onRouteChanged();
}
ref.read(permissionsProvider.notifier).checkPermissions(); ref.read(permissionsProvider.notifier).checkPermissions();
} else if (state == AppLifecycleState.paused) { } else if (state == AppLifecycleState.paused) {
// walletHeartbeat.stop(); _walletHeartbeat?.stop();
legacyHeartbeat.stop(); _legacyHeartbeat?.stop();
} }
super.didChangeAppLifecycleState(state); super.didChangeAppLifecycleState(state);
} }

View File

@@ -1 +1,2 @@
export 'src/domain/initial_route.dart';
export 'src/splash_builder.dart'; export 'src/splash_builder.dart';

View File

@@ -10,11 +10,17 @@ class SplashScreen extends StatefulWidget {
super.key, super.key,
required this.navigationContract, required this.navigationContract,
required this.checkSessionUseCase, required this.checkSessionUseCase,
required this.routeMap,
}); });
final NavigationContract navigationContract; final NavigationContract navigationContract;
final CheckSessionUseCase checkSessionUseCase; 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<InitialRoute, String> routeMap;
@override @override
State<SplashScreen> createState() => _SplashScreenState(); State<SplashScreen> createState() => _SplashScreenState();
} }
@@ -52,11 +58,9 @@ class _SplashScreenState extends State<SplashScreen>
void _navigateIfReady() { void _navigateIfReady() {
if (!_animationDone || _route == null || !mounted) return; if (!_animationDone || _route == null || !mounted) return;
final destination = switch (_route!) { final destination = widget.routeMap[_route!];
InitialRoute.onboarding => AppRoutes.legacyOnboarding, if (destination == null) return;
InitialRoute.login => AppRoutes.legacyLogin,
InitialRoute.home => AppRoutes.controlPanel,
};
widget.navigationContract.goTo(destination); widget.navigationContract.goTo(destination);
} }

View File

@@ -5,10 +5,16 @@ import 'package:navigation/navigation_contract.dart';
import 'package:sf_infrastructure/sf_infrastructure.dart'; import 'package:sf_infrastructure/sf_infrastructure.dart';
import 'package:sf_shared/sf_shared.dart'; import 'package:sf_shared/sf_shared.dart';
import 'package:splash/src/domain/check_session_use_case_impl.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'; import 'package:splash/src/presentation/splash_screen.dart';
class SplashBuilder { 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<InitialRoute, String> routeMap;
Page<void> buildPage(BuildContext context, GoRouterState state) { Page<void> buildPage(BuildContext context, GoRouterState state) {
final navigationContract = GetIt.I<NavigationContract>(); final navigationContract = GetIt.I<NavigationContract>();
@@ -20,6 +26,7 @@ class SplashBuilder {
child: SplashScreen( child: SplashScreen(
navigationContract: navigationContract, navigationContract: navigationContract,
checkSessionUseCase: checkSessionUseCase, checkSessionUseCase: checkSessionUseCase,
routeMap: routeMap,
), ),
); );
} }

View File

@@ -29,11 +29,11 @@ Future<void> configureDependencies(
cookieJar: cookieJar, cookieJar: cookieJar,
); );
if (onTokenExpired != null && onUnauthorized != null) { if (onUnauthorized != null) {
dio.interceptors.add( dio.interceptors.add(
TreezorTokenInterceptor( TreezorTokenInterceptor(
onTokenExpired: onTokenExpired,
onUnauthorized: onUnauthorized, onUnauthorized: onUnauthorized,
onTokenExpired: onTokenExpired,
), ),
); );
} }

View File

@@ -4,32 +4,53 @@ import 'package:dio/dio.dart';
class TreezorTokenInterceptor extends Interceptor { class TreezorTokenInterceptor extends Interceptor {
TreezorTokenInterceptor({ TreezorTokenInterceptor({
required void Function() onTokenExpired,
required void Function() onUnauthorized, required void Function() onUnauthorized,
}) : _onTokenExpired = onTokenExpired, void Function()? onTokenExpired,
_onUnauthorized = onUnauthorized; }) : _onUnauthorized = onUnauthorized,
_onTokenExpired = onTokenExpired;
// ignore: unused_field /// Called when the backend signals a session expiration that should
final void Function() _onTokenExpired; /// 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; final void Function() _onUnauthorized;
bool _handling = false; bool _handling = false;
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) { void onError(DioException err, ErrorInterceptorHandler handler) {
if (!_handling) { if (!_handling) {
// final message = _extractApiMessage(err.response?.data); // Treezor-specific handling: only enabled when running in payment mode
// if (message != null && message.contains('Treezor Token Expired')) { // (i.e. when an `onTokenExpired` callback was provided).
// _handling = true; if (_onTokenExpired != null) {
// _onTokenExpired(); final message = _extractApiMessage(err.response?.data);
// Future.delayed(const Duration(seconds: 2), () => _handling = false); if (message != null && message.contains('Treezor Token Expired')) {
// } _handling = true;
// else if (err.response?.statusCode == 500) { _onTokenExpired();
// _handling = true; Future.delayed(
// _onTokenExpired(); const Duration(seconds: 2),
// Future.delayed(const Duration(seconds: 2), () => _handling = false); () => _handling = false,
// } );
// else } else if (err.response?.statusCode == 500) {
if (err.response?.statusCode == 401) { _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; _handling = true;
_onUnauthorized(); _onUnauthorized();
Future.delayed(const Duration(seconds: 2), () => _handling = false); Future.delayed(const Duration(seconds: 2), () => _handling = false);
@@ -39,7 +60,6 @@ class TreezorTokenInterceptor extends Interceptor {
handler.next(err); handler.next(err);
} }
// ignore: unused_element
String? _extractApiMessage(Object? data) { String? _extractApiMessage(Object? data) {
if (data == null) return null; if (data == null) return null;