import 'dart:async'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/api/api_client_provider.dart'; import 'core/auth/auth_notifier.dart'; import 'core/auth/auth_providers_provider.dart'; import 'core/auth/token_storage.dart'; import 'core/chat/active_session_notifier.dart'; import 'core/chat/chat_notifier.dart'; import 'core/notifications/notification_service.dart'; import 'core/pairing/pairing_notifier.dart'; import 'core/theme/halo_theme.dart'; import 'firebase/firebase_options_dev.dart'; import 'router.dart'; /// Shared app bootstrap, parameterised per build flavor. /// /// The flavor entrypoints (`main_dev.dart`, `main_staging.dart`, /// `main_prod.dart`) each call this with their environment's /// [FirebaseOptions] and a [flavor] tag. The bare [main] below delegates to /// dev so a plain `flutter run` (no `-t`) still launches the dev environment. /// /// `flavor` is currently informational (kept on hand for future flavor-gated /// behaviour / analytics tagging); the API base URL is supplied separately via /// `--dart-define-from-file=env/.json` (see BUILD_FLAVORS.md). Future bootstrap({ required FirebaseOptions firebaseOptions, required String flavor, }) async { WidgetsFlutterBinding.ensureInitialized(); // Pre-warm flutter_secure_storage. The first call triggers AndroidX // Security MasterKey generation (RSA in Keystore) — fast on hardware-backed // keystores but multi-second on emulator's software-emulated TEE. Kicking // it off here in parallel with Firebase init hides the latency behind the // splash instead of paying it on the user's first interaction. unawaited(TokenStorage().readRefreshToken()); await Firebase.initializeApp(options: firebaseOptions); final messaging = FirebaseMessaging.instance; await messaging.requestPermission(); runApp(const ProviderScope(child: App())); } void main() async { // Bare `flutter run` (no `-t lib/main_.dart`) defaults to dev so // local development works out of the box. Build-flavor APKs use the // flavor-specific entrypoints instead. await bootstrap( firebaseOptions: DevFirebaseOptions.currentPlatform, flavor: 'dev', ); } class App extends ConsumerStatefulWidget { const App({super.key}); @override ConsumerState createState() => _AppState(); } class _AppState extends ConsumerState with WidgetsBindingObserver { bool _fcmRegistered = false; bool _authProvidersPreloaded = false; // Tracks whether the OS has paused/detached this isolate. The // activeSessionProvider runs a 15s poll (see active_session_notifier.dart); // each tick fires the listener below, which would otherwise re-open the // chat WebSocket immediately after didChangeAppLifecycleState closed it // — defeating the WS→FCM fallback. We gate the reconnect on this flag so // the WS stays closed while the app is backgrounded, even as polling // continues. bool _appPaused = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); // Phase 4: preload server-driven auth-provider gating once on cold start. // Cached via @Riverpod(keepAlive: true) — subsequent reads are instant. WidgetsBinding.instance.addPostFrameCallback((_) { if (_authProvidersPreloaded) return; _authProvidersPreloaded = true; ref.read(authProvidersProvider.future); }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { // Background → close the chat WebSocket so backend `sendMessage` falls // back to FCM (chat.service.js:51 — `if (!delivered) sendPushNotification`). // Without this, Android keeps the TCP socket alive after the Dart // isolate is paused, so the backend believes the customer is online and // never fires the push — the user sees no alert until they reopen the // app. See flow_customer.mermaid.md §5 (chat room) and the // implementation note in main.dart's activeSession listener. // // Foreground → re-establish the WS for the current active session, if // any. activeSessionProvider's last cached snapshot drives the target // session id; the chat notifier's `connectIfNotConnected` is a no-op // when the same session is already wired up. final notifier = ref.read(chatProvider.notifier); if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { _appPaused = true; if (notifier.connectedSessionId != null) { notifier.disconnect(); } } else if (state == AppLifecycleState.resumed) { _appPaused = false; final snapshot = ref.read(activeSessionProvider).valueOrNull; final sessionId = snapshot?.sessionId; if (sessionId != null && (snapshot?.hasSession ?? false) && notifier.connectedSessionId != sessionId) { notifier.connectIfNotConnected(sessionId); } // If pairing is still in a waiting state on resume, the `paired` push // may have landed while we were backgrounded and the user came back // via the app icon (no tap → no _navigateFromMessage). Force a fresh // activeSession fetch so the listener below can advance the pairing // state from the server's truth. final pairingState = ref.read(pairingProvider); if (pairingState is PairingSearchingData || pairingState is PairingTargetedWaitingData) { // ignore: discarded_futures ref.read(activeSessionProvider.notifier).refresh(); } } } void _registerFcmToken() { if (_fcmRegistered) return; _fcmRegistered = true; Future(() async { try { final token = await FirebaseMessaging.instance.getToken(); if (token != null) { await ref.read(apiClientProvider).post('/api/shared/device-token', data: {'token': token}); } } catch (_) { _fcmRegistered = false; } }); } @override Widget build(BuildContext context) { // FCM registration on auth. ref.listen(authProvider, (prev, next) { final data = next.valueOrNull; if (data is AuthAuthenticatedData || data is AuthAnonymousData) { _registerFcmToken(); } else { // Logged out (or initial) — ensure the chat WS is closed. ref.read(chatProvider.notifier).disconnect(); } }); // Global chat WebSocket lifecycle: connect whenever the user has an // active session, regardless of which screen is mounted. The chat screen // only joins this connection — it doesn't own it. FCM remains the // background-only fallback. // // Gate on `_appPaused`: activeSessionProvider runs a 15s poll that fires // this listener on every tick. If we reconnect while the app is // backgrounded, we undo the disconnect that didChangeAppLifecycleState // just performed and the FCM fallback never triggers for messages that // arrive during background. ref.listen(activeSessionProvider, (prev, next) { final snapshot = next.valueOrNull; final notifier = ref.read(chatProvider.notifier); if (snapshot == null || !snapshot.hasSession) { if (notifier.connectedSessionId != null) { notifier.disconnect(); } return; } if (_appPaused) return; final sessionId = snapshot.sessionId; // Recovery: if pairing is still in a waiting state (or stuck in // ALREADY_ACTIVE error from a stale in-flight session) but the server // already has us in an active session, the WS `paired` event was lost // (FCM fallback fired and the customer didn't tap, OR we missed it // mid-reconnect, OR the new chat-request was blocked by a pre-existing // session that the mitra has since accepted). Advance the pairing state // from the polled snapshot so the searching screen unsticks. if (sessionId != null) { final pairingState = ref.read(pairingProvider); if (pairingState is PairingSearchingData || pairingState is PairingTargetedWaitingData || pairingState is PairingErrorData) { // ignore: discarded_futures ref.read(pairingProvider.notifier).applyPairedFromPush( sessionId: sessionId, mitraName: snapshot.mitraName, ); } } if (sessionId != null && notifier.connectedSessionId != sessionId) { notifier.connectIfNotConnected(sessionId); } }); final router = ref.watch(routerProvider); NotificationService.initialize( router, onDataMessage: (data) { // FCM `paired` arrived (likely because the backend's WS push // couldn't find the customer's socket). Advance the pairing // notifier so the searching screen navigates without requiring // the user to tap the notification. if (data['type'] == 'paired') { final sessionId = data['session_id'] as String?; if (sessionId == null) return; final mitraName = (data['mitra_display_name'] as String?) ?? 'Bestie'; // ignore: discarded_futures ref.read(pairingProvider.notifier).applyPairedFromPush( sessionId: sessionId, mitraName: mitraName, ); } }, ); return MaterialApp.router( title: 'Halo Bestie', theme: haloThemeData(), routerConfig: router, ); } }