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>
174 lines
5.9 KiB
Dart
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 {}
|
|
}
|