first commit
This commit is contained in:
128
lib/helpers/app_bootstrap.dart
Normal file
128
lib/helpers/app_bootstrap.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/controllers/auth.dart';
|
||||
import 'package:miler/views/introscreens/introscreen.dart';
|
||||
import 'package:miler/views/onboardscreens/Mpin.dart';
|
||||
import 'package:miler/views/onboardscreens/Sign_in.dart';
|
||||
import 'package:miler/widget/Bottom_page.dart';
|
||||
|
||||
/// Decides which screen the app opens on, and nothing else.
|
||||
///
|
||||
/// This replaces the old animated splash screen. The splash held two very
|
||||
/// different jobs: a four-second brand animation, and the routing rules that
|
||||
/// decide login vs. intro vs. home. Only the second one is load-bearing.
|
||||
///
|
||||
/// The four seconds were pure cost for a rider who opens this app 30–50 times
|
||||
/// a shift — the native launch image already covers the cold-start gap, so the
|
||||
/// animation was time added on top of it, every single time. Removing it is
|
||||
/// the single biggest latency win on the whole flow.
|
||||
///
|
||||
/// What survives is the routing, unchanged in behaviour:
|
||||
/// • app version changed since last run → force re-verification (MPIN if the
|
||||
/// rider was signed in, otherwise Sign In)
|
||||
/// • signed in + on duty → straight to the dashboard
|
||||
/// • signed in + off duty → the go-on-duty banner
|
||||
/// • signed out, intro already seen → Sign In
|
||||
/// • first ever launch → Introscreen
|
||||
class AppBootstrap extends StatefulWidget {
|
||||
const AppBootstrap({super.key});
|
||||
|
||||
@override
|
||||
State<AppBootstrap> createState() => _AppBootstrapState();
|
||||
}
|
||||
|
||||
class _AppBootstrapState extends State<AppBootstrap> {
|
||||
bool _routed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Route on the first frame — GetX needs a built navigator before `offAll`.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _resolveStartScreen());
|
||||
}
|
||||
|
||||
Future<void> _resolveStartScreen() async {
|
||||
if (_routed) return;
|
||||
_routed = true;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final bool isLoggedOut = prefs.getBool('logged_out') ?? false;
|
||||
final bool hasSeenIntro = prefs.getBool('has_seen_intro') ?? false;
|
||||
final int? savedUserId = prefs.getInt('userid');
|
||||
|
||||
// ── App update → force re-verification ──
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final currentVersion = packageInfo.version;
|
||||
final lastRunVersion = prefs.getString('last_run_version');
|
||||
|
||||
if (lastRunVersion != null && lastRunVersion != currentVersion) {
|
||||
debugPrint(
|
||||
'[BOOTSTRAP] App update detected: $lastRunVersion -> $currentVersion. '
|
||||
'Forcing re-verification.',
|
||||
);
|
||||
|
||||
final String? savedPhone = prefs.getString('contactno');
|
||||
await prefs.setString('last_run_version', currentVersion);
|
||||
|
||||
if (savedUserId != null &&
|
||||
savedPhone != null &&
|
||||
savedPhone.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[BOOTSTRAP] User was logged in. Redirecting to MPIN page.',
|
||||
);
|
||||
|
||||
final auth = Get.put(AuthController());
|
||||
auth.currentPhone = savedPhone;
|
||||
|
||||
await prefs.remove('userid');
|
||||
await prefs.remove('partnerid');
|
||||
await prefs.remove('onduty');
|
||||
await prefs.setBool('logged_out', true);
|
||||
|
||||
if (mounted) Get.offAll(() => Mpin());
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[BOOTSTRAP] User was not logged in. Redirecting to Sign In.',
|
||||
);
|
||||
if (mounted) Get.offAll(() => const SignIn());
|
||||
return;
|
||||
}
|
||||
await prefs.setString('last_run_version', currentVersion);
|
||||
} catch (e) {
|
||||
debugPrint('[BOOTSTRAP] Version check error: $e');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// ── Normal launch ──
|
||||
//
|
||||
// A signed-in rider lands on the tabs whether or not he is on duty. The
|
||||
// duty gate that used to sit here made "open the app" and "start working"
|
||||
// the same decision, so a rider checking his earnings between shifts had to
|
||||
// pass a slider that put him back on the clock. Duty is a switch in Home's
|
||||
// app bar, on the screen where the work is.
|
||||
if (!isLoggedOut && savedUserId != null && savedUserId > 0) {
|
||||
Get.offAll(() => const BottomPage());
|
||||
} else {
|
||||
Get.offAll(() => hasSeenIntro ? const SignIn() : Introscreen());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Held for the few milliseconds the prefs read takes. Plain white so it is
|
||||
// indistinguishable from the native launch image — the rider should never
|
||||
// perceive a screen here at all.
|
||||
return Scaffold(
|
||||
backgroundColor: ColorConstants.pureSurface,
|
||||
body: SizedBox.expand(),
|
||||
);
|
||||
}
|
||||
}
|
||||
10
lib/helpers/http_overrides.dart
Normal file
10
lib/helpers/http_overrides.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
class MyHttpOverrides extends HttpOverrides {
|
||||
@override
|
||||
HttpClient createHttpClient(SecurityContext? context) {
|
||||
return super.createHttpClient(context)
|
||||
..badCertificateCallback =
|
||||
(X509Certificate cert, String host, int port) => true;
|
||||
}
|
||||
}
|
||||
98
lib/helpers/miler_router.dart
Normal file
98
lib/helpers/miler_router.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
// ── Routing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A driving route between two points, from OSRM's public router.
|
||||
///
|
||||
/// Replaces the Directions API call, which needed the revoked key. Same shape
|
||||
/// of answer — an ordered list of coordinates — so the callers' route drawing,
|
||||
/// distance table and camera fitting are untouched.
|
||||
class MilerRouter {
|
||||
static const String _host = 'https://router.project-osrm.org';
|
||||
|
||||
/// Sent with every request. OSRM's demo server, like OSM's tile server,
|
||||
/// blocks anonymous clients first.
|
||||
static const String _agent = 'com.doormile.partner';
|
||||
|
||||
/// The road geometry from [start] to [end], or an empty list.
|
||||
///
|
||||
/// Never throws: a route screen with no line is a degraded screen, but a
|
||||
/// route screen that crashed is a rider standing in the street with nothing.
|
||||
/// Callers fall back to the direct line on an empty answer.
|
||||
static Future<List<LatLng>> route(LatLng start, LatLng end) async {
|
||||
final uri = Uri.parse(
|
||||
'$_host/route/v1/driving/'
|
||||
'${start.longitude},${start.latitude};${end.longitude},${end.latitude}'
|
||||
'?overview=full&geometries=geojson',
|
||||
);
|
||||
|
||||
try {
|
||||
final res = await http
|
||||
.get(uri, headers: const {'User-Agent': _agent})
|
||||
.timeout(const Duration(seconds: 8));
|
||||
if (res.statusCode != 200) {
|
||||
debugPrint(
|
||||
'[MAP] OSRM ${res.statusCode} — falling back to a direct line.',
|
||||
);
|
||||
return const [];
|
||||
}
|
||||
|
||||
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
if (body['code'] != 'Ok') {
|
||||
debugPrint('[MAP] OSRM said ${body['code']} — direct line instead.');
|
||||
return const [];
|
||||
}
|
||||
|
||||
final routes = body['routes'] as List<dynamic>?;
|
||||
if (routes == null || routes.isEmpty) return const [];
|
||||
|
||||
final coords =
|
||||
(routes.first as Map<String, dynamic>)['geometry']['coordinates']
|
||||
as List<dynamic>;
|
||||
|
||||
// GeoJSON is [longitude, latitude] — the opposite order to everything
|
||||
// else in this app, and a silent way to end up routing through the sea.
|
||||
return [
|
||||
for (final c in coords)
|
||||
LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble()),
|
||||
];
|
||||
} catch (e) {
|
||||
debugPrint('[MAP] OSRM unreachable ($e) — direct line instead.');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Road distance in metres between two points, or null when the router
|
||||
/// cannot answer.
|
||||
///
|
||||
/// Replaces a direct call to the Google Directions API in
|
||||
/// `PickupsController`, which was reading the same revoked key and therefore
|
||||
/// silently falling back to straight-line distance on every trip — the number
|
||||
/// riders are *paid* on.
|
||||
static Future<double?> roadDistanceMetres(LatLng start, LatLng end) async {
|
||||
final uri = Uri.parse(
|
||||
'$_host/route/v1/driving/'
|
||||
'${start.longitude},${start.latitude};${end.longitude},${end.latitude}'
|
||||
'?overview=false',
|
||||
);
|
||||
try {
|
||||
final res = await http
|
||||
.get(uri, headers: const {'User-Agent': _agent})
|
||||
.timeout(const Duration(seconds: 6));
|
||||
if (res.statusCode != 200) return null;
|
||||
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
if (body['code'] != 'Ok') return null;
|
||||
final routes = body['routes'] as List<dynamic>?;
|
||||
if (routes == null || routes.isEmpty) return null;
|
||||
final d = (routes.first as Map<String, dynamic>)['distance'];
|
||||
return d is num ? d.toDouble() : null;
|
||||
} catch (e) {
|
||||
debugPrint('[ROUTE] OSRM distance unavailable ($e)');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
lib/helpers/poll_policy.dart
Normal file
39
lib/helpers/poll_policy.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
/// ───────────────────────────────────────────────────────────────────────────
|
||||
/// HOW OFTEN THE APP ASKS THE SERVER ANYTHING
|
||||
///
|
||||
/// One policy, because the two screens that poll had invented their own and
|
||||
/// neither was tied to what the rider was actually doing.
|
||||
///
|
||||
/// • Bookings polled every **3 seconds, always** — on duty, off duty, phone in
|
||||
/// pocket, screen off, nothing running. Twelve hundred requests an hour for
|
||||
/// a queue that changes a few times a shift.
|
||||
/// • Home polled every 5s on duty and 20s off, which at least noticed duty,
|
||||
/// but not whether there was any live work to watch.
|
||||
///
|
||||
/// Neither stopped when the app went to the background. A rider on duty with
|
||||
/// the phone in his pocket was running a request every three seconds, all
|
||||
/// shift, on his own battery and his own data pack. That is not a technical
|
||||
/// detail — it is the app quietly costing him money and charge for nothing.
|
||||
///
|
||||
/// The rule now: **poll fast only while something is actually happening.**
|
||||
///
|
||||
/// live stop in progress → 5s (the rider is working it; staleness shows)
|
||||
/// on duty, nothing live → 30s (waiting for the hub to assign)
|
||||
/// off duty → 60s (nothing can arrive; this is just a heartbeat)
|
||||
/// app backgrounded → stop (see [PollLifecycle])
|
||||
///
|
||||
/// A stop the rider is working is still watched at the old speed. Everything
|
||||
/// else drops by a factor of ten.
|
||||
/// ───────────────────────────────────────────────────────────────────────────
|
||||
Duration pollInterval({required bool hasLiveWork, required bool onDuty}) {
|
||||
if (hasLiveWork) return const Duration(seconds: 5);
|
||||
if (onDuty) return const Duration(seconds: 30);
|
||||
return const Duration(seconds: 60);
|
||||
}
|
||||
|
||||
/// Polling must also stop when the app is not in front of the rider: the work
|
||||
/// it does then can never be seen, because nobody is looking at the screen.
|
||||
/// Both polling screens implement that in their own
|
||||
/// `didChangeAppLifecycleState` — cancel on pause, restart plus one immediate
|
||||
/// catch-up fetch on resume, so the rider never comes back to a screen showing
|
||||
/// what was true a minute ago.
|
||||
144
lib/helpers/shift_end_alarm.dart
Normal file
144
lib/helpers/shift_end_alarm.dart
Normal file
@@ -0,0 +1,144 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:miler/background/backgroundservice.dart';
|
||||
|
||||
/// Helper class to schedule/cancel shift end alarms
|
||||
/// Works even when app is killed (uses Android AlarmManager)
|
||||
class ShiftEndAlarm {
|
||||
static const MethodChannel _channel = MethodChannel('doormile/shift_end');
|
||||
|
||||
/// Schedule an alarm for shift end time
|
||||
/// This alarm will fire even if the app is killed
|
||||
/// If shift end time has already passed today, it will trigger immediately
|
||||
static Future<bool> scheduleAlarm({
|
||||
required String endTime, // Format: "HH:mm:ss" or "HH:mm"
|
||||
String startTime =
|
||||
'', // Format: "HH:mm:ss" or "HH:mm" (for overnight shift detection)
|
||||
}) async {
|
||||
if (!Platform.isAndroid) {
|
||||
debugPrint('[SHIFT_END_ALARM] Only supported on Android');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint(
|
||||
'[SHIFT_END_ALARM] 📅 scheduleAlarm called - endTime: "$endTime", startTime: "$startTime"',
|
||||
);
|
||||
|
||||
// Check if shift end time has already passed today
|
||||
final now = DateTime.now();
|
||||
debugPrint('[SHIFT_END_ALARM] 📅 Current time: ${now.toString()}');
|
||||
|
||||
final endParts = endTime.split(':');
|
||||
if (endParts.length >= 2) {
|
||||
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,
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'[SHIFT_END_ALARM] 📅 Shift end time today: ${endToday.toString()}',
|
||||
);
|
||||
debugPrint(
|
||||
'[SHIFT_END_ALARM] 📅 Time comparison: now.isAfter(endToday) = ${now.isAfter(endToday)}',
|
||||
);
|
||||
|
||||
// If shift end time has passed, trigger immediately
|
||||
if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) {
|
||||
debugPrint(
|
||||
'[SHIFT_END_ALARM] ⚡ Shift end time ($endTime) has already passed - triggering break log immediately',
|
||||
);
|
||||
await handleShiftEnd();
|
||||
} else {
|
||||
debugPrint(
|
||||
'[SHIFT_END_ALARM] ⏰ Shift end time ($endTime) has not passed yet - will schedule alarm',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debugPrint('[SHIFT_END_ALARM] ⚠️ Invalid endTime format: "$endTime"');
|
||||
}
|
||||
|
||||
final result = await _channel.invokeMethod<bool>(
|
||||
'scheduleShiftEndAlarm',
|
||||
{'endTime': endTime, 'startTime': startTime},
|
||||
);
|
||||
final success = result ?? false;
|
||||
if (success) {
|
||||
debugPrint(
|
||||
'[SHIFT_END_ALARM] ✅ Successfully scheduled alarm for shift end: $endTime',
|
||||
);
|
||||
} else {
|
||||
debugPrint(
|
||||
'[SHIFT_END_ALARM] ⚠️ Failed to schedule alarm for shift end: $endTime',
|
||||
);
|
||||
}
|
||||
return success;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('[SHIFT_END_ALARM] ❌ Error scheduling alarm: ${e.message}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('[SHIFT_END_ALARM] ❌ Unexpected error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel the scheduled shift end alarm
|
||||
static Future<bool> cancelAlarm() async {
|
||||
if (!Platform.isAndroid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final result = await _channel.invokeMethod<bool>('cancelShiftEndAlarm');
|
||||
debugPrint('[SHIFT_END_ALARM] ✅ Cancelled shift end alarm');
|
||||
return result ?? false;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('[SHIFT_END_ALARM] ❌ Error cancelling alarm: ${e.message}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('[SHIFT_END_ALARM] ❌ Unexpected error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle shift end when alarm fires (called by BroadcastReceiver)
|
||||
/// This will create the break log and set rider offline
|
||||
static Future<void> handleShiftEnd() async {
|
||||
try {
|
||||
debugPrint(
|
||||
'[SHIFT_END_ALARM] 🔔 Shift end alarm fired - creating break log...',
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Check if still on duty (might have been manually set offline)
|
||||
final int onduty = prefs.getInt('onduty') ?? 0;
|
||||
if (onduty != 1) {
|
||||
debugPrint(
|
||||
'[SHIFT_END_ALARM] Already offline, skipping break log creation',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the background service to create break log
|
||||
// This will create break log and set offline
|
||||
await BackgroundCollectionLog.checkShiftEnd();
|
||||
|
||||
debugPrint('[SHIFT_END_ALARM] ✅ Break log created successfully');
|
||||
} catch (e) {
|
||||
debugPrint('[SHIFT_END_ALARM] ❌ Error handling shift end: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user