feat: split legacy/payment apps via APP_MODE flag
This commit is contained in:
55
.vscode/launch.json
vendored
55
.vscode/launch.json
vendored
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
20
apps/mobile_app/lib/core/config/app_mode.dart
Normal file
20
apps/mobile_app/lib/core/config/app_mode.dart
Normal 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';
|
||||
@@ -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<void> 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<void> initApp(EnvironmentEnum env) async {
|
||||
await GetIt.I<TreezorWalletConnectionService>().logout();
|
||||
} catch (_) {}
|
||||
await clearSessionData();
|
||||
appRouter.go(AppRoutes.legacyLogin);
|
||||
appRouter.go(isPaymentMode ? AppRoutes.login : AppRoutes.legacyLogin);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
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() {
|
||||
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) {
|
||||
|
||||
@@ -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<SaveFamilyApp>
|
||||
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<QuestiaRepository>(),
|
||||
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<QuestiaRepository>(),
|
||||
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<SaveFamilyApp>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export 'src/domain/initial_route.dart';
|
||||
export 'src/splash_builder.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<InitialRoute, String> routeMap;
|
||||
|
||||
@override
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
@@ -52,11 +58,9 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<InitialRoute, String> routeMap;
|
||||
|
||||
Page<void> buildPage(BuildContext context, GoRouterState state) {
|
||||
final navigationContract = GetIt.I<NavigationContract>();
|
||||
@@ -20,6 +26,7 @@ class SplashBuilder {
|
||||
child: SplashScreen(
|
||||
navigationContract: navigationContract,
|
||||
checkSessionUseCase: checkSessionUseCase,
|
||||
routeMap: routeMap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ Future<void> configureDependencies(
|
||||
cookieJar: cookieJar,
|
||||
);
|
||||
|
||||
if (onTokenExpired != null && onUnauthorized != null) {
|
||||
if (onUnauthorized != null) {
|
||||
dio.interceptors.add(
|
||||
TreezorTokenInterceptor(
|
||||
onTokenExpired: onTokenExpired,
|
||||
onUnauthorized: onUnauthorized,
|
||||
onTokenExpired: onTokenExpired,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user