import 'dart:io'; import 'package:miler/helpers/http_overrides.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:get/get.dart'; import 'package:miler/helpers/app_bootstrap.dart'; import 'package:miler/views/offline/offline_banner.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/controllers/profile_controller.dart'; import 'package:miler/controllers/riderlog.dart'; import 'package:miler/controllers/pickup.dart'; import 'package:miler/controllers/logcontroller.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:miler/providers/notifications/notificationservce.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:new_version_plus/new_version_plus.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/background/backgroundservice.dart'; import 'package:miler/helpers/shift_end_alarm.dart'; import 'package:miler/data/accepted_store.dart'; import 'package:miler/data/miler_api.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/data/session.dart'; import 'package:miler/views/onboardscreens/Sign_in.dart'; import 'package:miler/xpress/delivery_bootstrap.dart'; import 'package:miler/views/helpers/constants/app_theme.dart'; import 'package:miler/views/helpers/widgets/page_transitions.dart'; import 'views/offline/offline_page.dart'; // Background message handler @pragma('vm:entry-point') Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { await Firebase.initializeApp(); await NotificationServce.display(message); } String currentVersion = ''; String storeVersion = ''; bool updateRequired = false; Future getAppVersion() async { SharedPreferences prefs = await SharedPreferences.getInstance(); PackageInfo packageInfo = await PackageInfo.fromPlatform(); String version = packageInfo.version; currentVersion = version; prefs.setString('CurrentVersion', currentVersion); debugPrint('Current version from main: $currentVersion'); } Future checkForUpdate(BuildContext context) async { // Version check is handled in splash screen } Future recheckVersion() async { final newVersion = NewVersionPlus( iOSId: '284882215', // Must track applicationId in android/app/build.gradle. This is the id the // update check looks up on Play, so a stale value here silently checks a // DIFFERENT listing: riders are told they are up to date when they are not, // or sent to a store page for an app they do not have. androidId: "com.doormile.miler", ); try { final status = await newVersion.getVersionStatus(); if (status != null) { debugPrint( "Recheck - Current: ${status.localVersion}, Store: ${status.storeVersion}", ); return status.canUpdate; } } catch (e) { debugPrint("Error rechecking version: $e"); } return false; } Future main() async { WidgetsFlutterBinding.ensureInitialized(); // ── Awaited, because the screens read what it writes ── // // This was `unawaited(...)`, which meant the one-time drain of the // pre-scoping stores raced the first frame. On the launch after an upgrade — // the only launch where it does anything — Home, Deliveries and Activity // could all read the scoped key *before* the migration had written it, and a // rider mid-shift opened the app to an empty day. The rows were not lost; he // simply could not see them until he killed and reopened the app, which is // not a thing he knows to do. // // It is cheap on every other launch: `containsKey` against an already-loaded // preferences map, for eight base keys, and it returns before the first // `await` when there is nothing to move. // // Guarded, and deliberately not fatal. A migration that throws — a corrupt // blob, a prefs backend that will not open — must not stop the rider signing // in and working. He loses local history he can no longer see anyway; the // server's record is untouched by any of this. try { await migrateLegacyStores(); } catch (e, st) { debugPrint('[SCOPE] legacy store migration failed, continuing: $e\n$st'); } HttpOverrides.global = MyHttpOverrides(); try { await Firebase.initializeApp(); FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); } catch (e) { debugPrint('[FIREBASE] Initialization failed: $e'); // Firebase may not be configured for this platform (e.g. iOS simulator without GoogleService-Info.plist) } // Which business this rider works for, resolved once from the login response // — see [TenantController]. Before the first frame, because it decides // whether Home draws a payment-carrying parcel route or a meal run — and, // now, whether the rider gets the parcel dashboard at all or the ported // Xpress-rider delivery one under `lib/xpress`. // // Moved above the controller registrations below, which it now selects. // The tenant can change under a rider who never signs out — and until the // backend began returning `tenantname`, one who was already signed in had no // way to receive it at all. Refreshed from `GET /miler/profile` first so the // resolution below reads the current answer. Cheap and non-blocking for a // rider we already have a name for; see [refreshTenantFromProfile]. // ── What happens when the server stops accepting this rider ── // // `MilerApi` drops the bearer token on any 401 and calls `onUnauthorized`. // Nothing ever assigned it, so the second half never happened: the credential // was deleted and the app went on believing it was signed in, because the // profile and `logged_out = false` were both still on disk. Every call after // that came back `401 authorization header is required`, forever, behind a // dashboard showing the rider's own name. // // Observed on a handset: rider 23, name and tenant drawn on Account, no // `authtoken` in SharedPreferences, Home / Deliveries / Activity and the // heartbeat all failing in a loop. What it costs is not a blank screen — it // is a rider who sees no jobs, concludes there is no work today, and is never // told to sign in again. // // Registered here, before the line branch below, because both lines make // authenticated calls and either can be the one to receive the 401. MilerApi.onUnauthorized = _handleSessionExpired; await refreshTenantFromProfile(); final profile = await TenantController.to.load(); // ── One line's controllers, not both ── // // The delivery line has its own `RiderLogController`, `LogController` and // `ProfileController` — same names, different classes, and GetX keys on the // type. Registering both sets would put one rider on two duty-logging loops // writing to two different backends. See [bootstrapDeliveryLine]. if (profile.isDelivery) { await bootstrapDeliveryLine(); _applySystemChrome(); runApp(const _RootApp()); return; } // Loaded at boot, not on first visit to Account: the Home app bar draws the // rider's photo, so the controller has to be populated before the first // frame or the avatar flashes its letter fallback on every launch. await Get.put(ProfileController(), permanent: true).loadFromPrefs(); // Anything the retired demo layer left on this device. Deleting the mock // generator from the source could not reach rows it had already written to // SharedPreferences, and those kept surfacing on Bookings and Activity with // nothing in the repo to explain them. See [purgeDemoRecords]. await purgeDemoRecords(); Get.put(RiderLogController(), permanent: true); Get.put(PickupController(), permanent: true); final logController = Get.put(LogController(), permanent: true); await logController.startLogging(); _applySystemChrome(); runApp(const _RootApp()); } /// True while a session-expiry teardown is already under way. /// /// A 401 rarely arrives alone — the home poll, the deliveries queue and the /// background heartbeat can each get one within the same second, and every one /// of them calls this. Without the latch the rider would be torn down three /// times and pushed at the sign-in screen three times, which on GetX stacks /// three routes he then has to dismiss. bool _signingOut = false; /// Ends the session the server has stopped accepting, and says so. /// /// Deliberately not silent. The rider did not choose this, so he is told what /// happened in the words of the thing that happened — his session ended — and /// landed somewhere he can act on it. Dropping him on sign-in with no message /// reads as the app having crashed and lost his work. Future _handleSessionExpired() async { if (_signingOut) return; _signingOut = true; try { await endSession(); // A 401 can reach a background isolate, where there is no navigator and // `Get.offAll` would throw. The teardown above has still happened, so the // next launch reads `logged_out` and opens sign-in — the rider is not left // holding a dead session either way. if (Get.context == null) { debugPrint('[AUTH] session expired with no UI attached — ' 'cleared, sign-in will open on next launch'); return; } Get.offAll(() => const SignIn()); Get.snackbar( 'Signed out', 'Your session ended. Please sign in again to see your jobs.', snackPosition: SnackPosition.BOTTOM, ); } catch (e, st) { // Never let the teardown itself throw into an API response handler: the // call that received the 401 has its own error path and this must not // replace it with a crash. debugPrint('[AUTH] session teardown failed: $e\n$st'); } finally { _signingOut = false; } } /// Paints the status and navigation bars to match the page behind them. void _applySystemChrome() { SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: ColorConstants.surface, statusBarIconBrightness: Brightness.dark, statusBarBrightness: Brightness.light, systemNavigationBarColor: ColorConstants.surface, systemNavigationBarIconBrightness: Brightness.dark, ), ); } class _RootApp extends StatefulWidget { const _RootApp(); @override State<_RootApp> createState() => _RootAppState(); } class _RootAppState extends State<_RootApp> with WidgetsBindingObserver { List _connectionStatus = [ConnectivityResult.none]; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); Connectivity().onConnectivityChanged.listen((result) { if (mounted) { setState(() { _connectionStatus = result; }); } }); Connectivity().checkConnectivity().then((result) { if (mounted) { setState(() { _connectionStatus = result; }); } }); // ✅ Check if shift ended while app was killed OR if shift end time has already passed _checkShiftEndOnStartup(); // Decides banner-vs-blocker only. See [_hasSession]. _readSessionPresence(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } /// Check if shift ended while app was killed OR if shift end time has already passed /// This will trigger break log immediately if rider is on duty and shift end time passed Future _checkShiftEndOnStartup() async { try { final prefs = await SharedPreferences.getInstance(); final int onduty = prefs.getInt('onduty') ?? 0; // Only check if rider was on duty if (onduty != 1) { debugPrint('[APP_STARTUP] Rider not on duty, skipping shift end check'); return; } // Check if shift has ended final String endTimeStr = prefs.getString('endtime') ?? ''; if (endTimeStr.isEmpty) { debugPrint('[APP_STARTUP] No endtime found, skipping shift end check'); return; } debugPrint( '[APP_STARTUP] Checking shift end - Current time: ${DateTime.now()}, End time: $endTimeStr', ); // Use the same logic as BackgroundCollectionLog.checkShiftEnd() final now = DateTime.now(); final endParts = endTimeStr.split(':'); if (endParts.length < 2) return; final int endHour = int.tryParse(endParts[0]) ?? 0; final int endMinute = int.tryParse(endParts[1]) ?? 0; final int endSecond = endParts.length > 2 ? (int.tryParse(endParts[2]) ?? 0) : 0; final DateTime endToday = DateTime( now.year, now.month, now.day, endHour, endMinute, endSecond, ); bool isShiftOver = false; final String startTimeStr = prefs.getString('starttime') ?? ''; if (startTimeStr.isNotEmpty) { final startParts = startTimeStr.split(':'); if (startParts.length >= 2) { final int startHour = int.tryParse(startParts[0]) ?? 0; final int startMinute = int.tryParse(startParts[1]) ?? 0; final double startVal = startHour + (startMinute / 60.0); final double endVal = endHour + (endMinute / 60.0); if (startVal > endVal) { // Overnight shift final DateTime startToday = DateTime( now.year, now.month, now.day, startHour, startMinute, ); if (now.isAfter(endToday) && now.isBefore(startToday)) { isShiftOver = true; } } else { // Normal day shift if (now.isAfter(endToday)) { isShiftOver = true; } } } else { if (now.isAfter(endToday)) { isShiftOver = true; } } } else { if (now.isAfter(endToday)) { isShiftOver = true; } } if (isShiftOver) { debugPrint( '[APP_STARTUP] ⏰ Shift end time ($endTimeStr) has already passed - creating break log IMMEDIATELY...', ); // Call the background service to create break log await BackgroundCollectionLog.checkShiftEnd(); debugPrint('[APP_STARTUP] ✅ Break log created (if needed)'); } else { debugPrint( '[APP_STARTUP] Shift end time ($endTimeStr) has not passed yet - scheduling alarm', ); // Also reschedule the alarm in case it was cancelled try { final String startTime = prefs.getString('starttime') ?? ''; await ShiftEndAlarm.scheduleAlarm( endTime: endTimeStr, startTime: startTime, ); } catch (e) { debugPrint('[APP_STARTUP] Error rescheduling alarm: $e'); } } } catch (e) { debugPrint('[APP_STARTUP] Error checking shift end: $e'); } } @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); if (state == AppLifecycleState.resumed) { // When app comes back to foreground, ensure rider logs resume try { if (Get.isRegistered()) { final ctl = Get.find(); // Fire an immediate rider log (best-effort) ctl.createLoginNowV2(); // Ensure the periodic background/foreground loop is running ctl.startAutoCreateLoginLoop(); } } catch (_) { // Ignore lifecycle errors; app should not crash because of logging } } } bool get _isOffline { return _connectionStatus.isEmpty || _connectionStatus.every((status) => status == ConnectivityResult.none); } /// Whether there is a signed-in rider whose work is already on the device. /// /// Read once, purely to choose between two *presentations* of being offline — /// it changes no auth behaviour and no routing. [AppBootstrap] still decides /// where the app actually goes. bool _hasSession = false; Future _readSessionPresence() async { try { final prefs = await SharedPreferences.getInstance(); final loggedOut = prefs.getBool('logged_out') ?? false; final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; if (mounted) setState(() => _hasSession = !loggedOut && userId != 0); } catch (_) { // Unknown → treat as no session, which is the more cautious of the two // presentations. It only affects which offline UI is shown. } } @override Widget build(BuildContext context) { // ⭐ RESPONSIVE WRAPPER ADDED return ScreenUtilInit( designSize: const Size(390, 844), // YOUR BASE SIZE minTextAdapt: true, splitScreenMode: true, builder: (_, __) { return GetMaterialApp( debugShowCheckedModeBanner: false, title: 'Miler partner', // Smooth, consistent page motion across the whole app (Uber-style // horizontal slide with a gentle ease) instead of abrupt cuts. // Screens navigated to by hand use `openScreen`/`openSheet` from // views/helpers/widgets/page_transitions.dart; this covers the // whole-app swaps (`Get.offAll`) that don't go through them. defaultTransition: Transition.cupertino, transitionDuration: kMilerPushIn, theme: AppTheme.light, home: const AppBootstrap(), // ── Offline is a strip, not a wall ── // // This used to stack an opaque [OfflinePage] over the whole app the // moment connectivity dropped — so a rider at a doorstep in a // basement lost the address, the customer, the parcel count and the // OTP field while holding the parcel. Everything he needs at the door // was already fetched; the network is required to *report* the stop, // not to work it. // // So a signed-in rider keeps his screen and gets a line of text above // it. The full-screen page survives only for a cold start with no // session, where there genuinely is nothing to show him. builder: (context, child) { final baseChild = child ?? const SizedBox.shrink(); if (_isOffline && !_hasSession) { return Stack(children: [baseChild, const OfflinePage()]); } return OfflineScope(offline: _isOffline, child: baseChild); }, ); }, ); } }