Files
doormile_customer_app/lib/data/location_service.dart
Thiru-tenext 0d66627c3c Replace the customer app with Doormile CX
Book a pickup, track it to delivery — the rebuilt customer app.

- Design language from doormile-screens.html: brand #8F0F06, Manrope +
  Geist Mono (variable fonts), bordered cards instead of shadows, crimson
  brand headers, sliding tab indicator, mono for anything read digit by digit.
- lib/data (one live API implementation, plus a debug-only offline fake),
  lib/state, lib/ui (tokens, widgets, screens).
- 84 tests, plus a design snapshot harness that renders every screen with the
  real fonts: flutter test test/design_snapshot_test.dart --run-skipped
  --update-goldens

This replaces the previous app (pubspec 'doormile', app id
com.doormile.customer). That tree remains in history at 6c7d656; note its
android/app/google-services.json is not carried over, and the application id
here is in.doormile.customer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:07:33 +05:30

174 lines
5.9 KiB
Dart

import 'package:geolocator/geolocator.dart';
/// Why a location fix is not available. Each value maps to a distinct thing we
/// tell the customer, so none of them is a generic failure.
enum LocationDenial {
/// Device location is switched off entirely.
serviceOff,
/// Permission refused this time; asking again is allowed.
denied,
/// Refused permanently — only Settings can undo it.
deniedForever,
/// Permission granted, but the fix timed out or the platform failed.
unavailable,
}
/// A resolved device position, or the reason there isn't one.
class LocationFix {
const LocationFix.ok(this.lat, this.lng, {this.accuracy}) : denial = null;
const LocationFix.denied(this.denial)
: lat = null,
lng = null,
accuracy = null;
final double? lat;
final double? lng;
/// Horizontal accuracy in metres, when the platform reports it. Drawn as the
/// halo around the blue dot, so the customer can see how sure we are.
final double? accuracy;
final LocationDenial? denial;
bool get hasFix => lat != null && lng != null;
/// What to show the customer when there is no fix. Never a raw error.
String get message => switch (denial) {
LocationDenial.serviceOff =>
'Turn on location to drop the pin for you. You can still search for the address.',
LocationDenial.denied =>
'Allow location access and we will drop the pin for you.',
LocationDenial.deniedForever =>
'Location is blocked for Doormile. Enable it in Settings, or search for the address.',
_ => 'We could not get your location. Search for the address instead.',
};
}
/// The only place the app touches platform GPS.
///
/// Every failure — permission refused, location off, no plugin (widget tests) —
/// comes back as a [LocationFix] with a [LocationDenial]. Nothing throws, so a
/// caller never has to guard the booking flow behind a try/catch.
class LocationService {
LocationService();
/// Swappable so tests can answer without a platform.
static LocationService instance = LocationService();
/// Coimbatore city centre. Where the map opens before we know better, so the
/// customer never stares at a grey ocean tile.
static const fallbackLat = 11.0168;
static const fallbackLng = 76.9558;
/// How long to wait on a permission or service answer before giving up.
static const _probe = Duration(seconds: 4);
/// Asks for permission if we do not have it yet, then returns a fix.
///
/// Every platform call is bounded: a wedged location plugin must never leave
/// the customer watching "Getting address…" with no way forward.
Future<LocationFix> current({
Duration timeout = const Duration(seconds: 12),
}) async {
final enabled = await _guard(Geolocator.isLocationServiceEnabled, _probe);
if (enabled == null) {
return const LocationFix.denied(LocationDenial.unavailable);
}
if (!enabled) return const LocationFix.denied(LocationDenial.serviceOff);
var permission = await _guard(Geolocator.checkPermission, _probe);
if (permission == LocationPermission.denied) {
permission = await _guard(Geolocator.requestPermission, timeout);
}
if (permission == null) {
return const LocationFix.denied(LocationDenial.unavailable);
}
if (permission == LocationPermission.deniedForever) {
return const LocationFix.denied(LocationDenial.deniedForever);
}
if (permission == LocationPermission.denied) {
return const LocationFix.denied(LocationDenial.denied);
}
final fix = await _guard(
() => Geolocator.getCurrentPosition(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.high,
timeLimit: timeout,
),
),
timeout,
// A cached fix beats no fix at all when the precise one times out.
) ?? await _guard<Position?>(() => Geolocator.getLastKnownPosition(), _probe);
return fix == null
? const LocationFix.denied(LocationDenial.unavailable)
: LocationFix.ok(
fix.latitude,
fix.longitude,
accuracy: fix.accuracy > 0 ? fix.accuracy : null,
);
}
/// Runs a platform call, returning null instead of throwing or hanging.
/// Covers MissingPluginException, a refused channel, and a plugin that
/// simply never answers.
static Future<T?> _guard<T>(Future<T> Function() call, Duration limit) async {
try {
return await call().timeout(limit);
} on Object {
return null;
}
}
/// Opens the OS settings page so a permanently denied customer can recover.
Future<void> openSettings() async {
try {
await Geolocator.openAppSettings();
} on Object {
// Nothing useful to do if the platform refuses.
}
}
}
/// Answers instantly with a known position, without touching the platform.
///
/// **A test seam, and only that.** Widget tests run on a fake clock that a real
/// platform-channel reply never reaches, so every test that renders the pickup
/// screen installs one of these as [LocationService.instance]. Nothing in the
/// app does, and nothing should: a build that invents where the customer is
/// sends a Miler to the wrong door.
class FixedLocationService extends LocationService {
FixedLocationService({
this.lat = LocationService.fallbackLat,
this.lng = LocationService.fallbackLng,
this.accuracy = 18,
this.denial,
});
/// A service that always refuses, for exercising the denied states.
FixedLocationService.denied(LocationDenial reason)
: lat = LocationService.fallbackLat,
lng = LocationService.fallbackLng,
accuracy = null,
denial = reason;
final double lat;
final double lng;
final double? accuracy;
final LocationDenial? denial;
@override
Future<LocationFix> current({
Duration timeout = const Duration(seconds: 12),
}) async => denial != null
? LocationFix.denied(denial!)
: LocationFix.ok(lat, lng, accuracy: accuracy);
@override
Future<void> openSettings() async {}
}