first commit

This commit is contained in:
2026-08-11 13:16:33 +05:30
parent 18531bf46b
commit c8350563d9
388 changed files with 69656 additions and 0 deletions

View 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(),
);
}
}

View 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;
}
}

View 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;
}
}
}

View 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.

View 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');
}
}
}