Play rejected the upload: the listing expects com.doormile.miler and the build
carried com.doormile.partner. This is a new app rather than an update — a
package rename gives Play a different app, so nothing carries over from the old
listing and versionCode restarts against an empty history.
applicationId moves; `namespace` and the Kotlin `package com.doormile.partner`
declarations deliberately do not. Those are the code's own package and are
allowed to differ from the application id — renaming them would mean moving
source directories to change a string nothing outside the build reads.
Four things did have to follow the id, because each of them names the app to
something outside it:
* the SHIFT_END_ALARM broadcast action, or two builds installed side by side
would answer each other's shift alarms;
* the update checker's androidId in main.dart and UpdateScreen.dart, which
looks the app up ON PLAY — left stale it would poll a different listing and
report a rider up to date when he is not;
* the Play URL the update screen sends him to, which would have opened the
store page for an app he does not have;
* the map tile and routing user-agents, which identify this app to OSM and
OSRM.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
496 lines
18 KiB
Dart
496 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',
|
|
// 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<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);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|