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>
This commit is contained in:
173
lib/data/location_service.dart
Normal file
173
lib/data/location_service.dart
Normal file
@@ -0,0 +1,173 @@
|
||||
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 {}
|
||||
}
|
||||
Reference in New Issue
Block a user