Three fixes found by running the app on a real handset against production. 1. An expired token left the app looking signed in and unable to work. MilerApi.onUnauthorized was declared and called on every 401 but never assigned, so the token was dropped and nothing else happened: the profile stayed on disk, logged_out stayed false, and the rider saw his own name over a dashboard whose every call returned 401. He reads that as "no work today". The teardown now lives in endSession() and both ways out of a session — the Log out button and the 401 path — use it. 2. Arrived was written locally even when the rider was not there. updateArrivedStatus answers false for three different things and the caller treated all of them as "the write did not land", which is only true of one. A geofence refusal and a server refusal now stop the rung and hand back the reason; a dead network still advances, as it should. 3. A multi-destination customer pickup collapsed onto one stop. GET /miler/bookings returns a row per destination once collected, all with the same bookingid and reference. Every local store keys on that id, so the accepted store deduped two of three drops away and their consignment ids were unrecoverable. orderid is now the stop key; bookingreference stays the booking's name. Cards show "Stop 2 of 3" and the receiver's own name and number rather than the sender's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
492 lines
18 KiB
Dart
492 lines
18 KiB
Dart
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<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
|
await Firebase.initializeApp();
|
|
await NotificationServce.display(message);
|
|
}
|
|
|
|
String currentVersion = '';
|
|
String storeVersion = '';
|
|
bool updateRequired = false;
|
|
|
|
Future<void> 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<void> checkForUpdate(BuildContext context) async {
|
|
// Version check is handled in splash screen
|
|
}
|
|
|
|
Future<bool> recheckVersion() async {
|
|
final newVersion = NewVersionPlus(
|
|
iOSId: '284882215',
|
|
androidId: "com.doormile.partner",
|
|
);
|
|
|
|
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<void> 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<void> _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<ConnectivityResult> _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<void> _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<RiderLogController>()) {
|
|
final ctl = Get.find<RiderLogController>();
|
|
// 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<void> _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);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|