Location and maps
Consent and permissions, the location service, sampling only during tours through a foreground service, the GPS tolerance check at arrival, maps and external navigation in the delegate app, the regional WhatsApp lookup of the reseller app, and testing with mock locations.
Location is the most sensitive capability of the project: it proves visits (cahier §6.3, §6.4, §12 GPS), feeds the back-office live map, and must stay proportionate and transparent (cahier §8, §10; FLD-15, SEC-07). This page covers MOB-10, MDA-03, MDA-12 and the reseller app's WhatsApp region (MRA-08). Architectural choice: ADR-016.
Open decisions and the default the app is built with:
| Decision | Default built | Setting read from appConfig.get |
|---|---|---|
| D-06 GPS check-in tolerance | Blocking beyond 150 m, fixes less precise than 100 m refused | field.gpsToleranceMeters, field.gpsMaxAccuracyMeters, field.allowOutOfToleranceCheckIn |
| D-07 Sampling frequency and visibility | Only while a tour is IN_PROGRESS, every 5 min or 200 m, kept 90 days | field.locationIntervalSeconds, field.locationDistanceMeters, field.locationRetentionDays |
| D-24 Background location on Android | Foreground service of type location, no ACCESS_BACKGROUND_LOCATION | — |
| D-14 Maps provider | Google Maps Platform | — |
Because every threshold comes from settings, a decision taken late changes values, not code.

Permissions and consent
The consent screen DA-04 appears after the first sign-in and until Me.consents.locationTrackingAcceptedAt is set; it reappears if a required permission is revoked.
- Consent text (validated by HOCO with counsel, D-07): positions are recorded only at tour start, at each arrival and while a tour is En cours; never during a suspension, after closing or outside tours; kept
field.locationRetentionDaysdays (90 by default); visible to managers with the field-operations permission; the trail is used for the live map and the kilometres of the closing summary (cahier §6.9). The accepted version is written withme.update(consents.locationTrackingVersion), so the back-office can prove who accepted what. - Order of requests: notifications first (the tour notification must be visible — that is the transparency), then foreground location. Each system dialog is preceded by one sentence in the app's own words.
- Precise location is required for check-in: Android 12+ lets users grant only approximate location (accuracy of kilometres), which can never pass the 100 m accuracy rule.
- Background location is not requested. The foreground service started by « Démarrer la tournée » keeps receiving positions with the screen off using the while-in-use permission (D-24 option 1). If HOCO ever chooses option 2, the app adds a second, separate request after foreground access, with a prominent in-app disclosure, and the Play Console declaration and video of build and release become mandatory.
- Settings transparency (DA-26): when location is used, the current tour state, the last ping sent and a link to the consent text.
apps/delegate/android/app/src/main/AndroidManifest.xml — permissions and package visibility (no ACCESS_BACKGROUND_LOCATION):
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="google.navigation" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="geo" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
</queries>
<application android:label="@string/app_name" android:allowBackup="false">
<meta-data android:name="com.google.android.geo.API_KEY" android:value="${mapsApiKey}" />
</application>
</manifest>Photos never need a media-read permission: visit proof uses the camera only, and documents use the system photo picker.
| Permission state | Detected by | App behaviour |
|---|---|---|
| Location service off (GPS disabled) | Geolocator.isLocationServiceEnabled() | DA-28 GPS désactivé state with « Activer la localisation » (Geolocator.openLocationSettings()) |
| Denied | LocationPermission.denied | Limited mode banner; « Démarrer la tournée » and « Arrivé » explain why they are disabled |
| Denied forever | LocationPermission.deniedForever | « Ouvrir les paramètres » (Geolocator.openAppSettings()) |
| Approximate only | Geolocator.getLocationAccuracy() returns reduced | Re-ask for precise with the reason |
| Granted while in use | whileInUse | Normal |
Location service
hoco_maps exposes an interface so tests and the dev flavor can replace the GPS:
abstract interface class LocationService {
Future<LocationAccess> ensureAccess(); // service on, permission, precision
Future<Fix> bestFix({Duration window = const Duration(seconds: 20), double goodEnough = 30});
Stream<Fix> tourStream(LocationPolicy policy); // foreground service while listened
}
final class Fix {
const Fix({required this.lat, required this.lng, required this.accuracy, required this.capturedAt, required this.isMock});
final double lat;
final double lng;
final double accuracy; // metres
final DateTime capturedAt; // UTC
final bool isMock; // Position.isMocked on Android
}GeolocatorLocationService (package geolocator) implements it:
- One-shot fixes (tour start, arrival, prospect position): listen with
LocationAccuracy.bestfor up to 20 s, keep the most precise fix, stop early when accuracy is 30 m or better; show the live accuracy while acquiring (« Précision ± 45 m »). A fix older than 30 s is never reused for a check-in. - Tour stream:
AndroidSettingswithLocationAccuracy.medium(balanced power: Wi-Fi and cell assisted, about 100 m),intervalDuration: 60 s,distanceFilter: 0, andforegroundNotificationConfigso the plugin runs a foreground service while the stream is listened to. The sampling decision is made in Dart (below). - Coordinates are WGS84 degrees, accuracy in metres,
capturedAtUTC — theGeoPointof the API (GEO-03).
Sampling during tours
Positions are recorded only while the tour is En cours — never while Suspendue, after closing, or outside tours (MDA-12).
final settings = AndroidSettings(
accuracy: LocationAccuracy.medium,
distanceFilter: 0,
intervalDuration: const Duration(seconds: 60),
foregroundNotificationConfig: ForegroundNotificationConfig(
notificationTitle: l10n.tourTrackingTitle, // « Tournée en cours »
notificationText: l10n.tourTrackingBody, // « Votre position est partagée jusqu'à la clôture ou la pause. »
notificationChannelName: l10n.tourTrackingChannel,
setOngoing: true,
enableWakeLock: false,
),
);
/// "Every 5 minutes or 200 m": a sample is kept when either threshold is crossed.
/// Movement must exceed twice the fix accuracy so that GPS jitter is not taken for travel.
bool shouldRecord(Fix fix, Fix? last, LocationPolicy p) {
if (last == null) return true;
if (fix.capturedAt.difference(last.capturedAt) >= p.interval) return true;
final moved = Geolocator.distanceBetween(last.lat, last.lng, fix.lat, fix.lng);
return moved >= p.distanceMeters && moved > 2 * fix.accuracy;
}| Event | Sampling |
|---|---|
« Démarrer la tournée » (delegate.tours.start, applied locally) | Start the stream (and the foreground service with its notification); record the start position |
| « Suspendre » | Stop the stream; the notification disappears |
| « Reprendre » | Restart |
| Tour closed, cancelled by the back-office (delta or push), sign-out | Stop |
| App process killed during a tour (swiped away, OEM battery killer) | Sampling stops until the app is opened again; on launch, a tour still IN_PROGRESS restarts the stream. Android does not allow restarting a location foreground service from the background |
Samples go to location_pings and are sent in batches to sync.locationPings every 15 minutes or 20 samples (offline sync › pings).
Battery. Balanced accuracy instead of GPS-best for the trail; high accuracy only for one-shot fixes; network batched every 15 minutes rather than per sample (radio wake-ups cost more than fixes); no wake lock. Many phones sold in Algeria (Xiaomi, Oppo, Realme, Samsung) kill background work aggressively: DA-26 includes a short « Optimisation de la batterie » help per brand that opens the battery-optimisation settings page — the app does not request the restricted ignore-optimisations permission. Battery drain over a full tour is measured in the field test (MOB-13); Assumption — target at most 10 % of battery over an 8-hour tour attributable to the app, to confirm there.
Escape hatch. If the field test shows sampling dying too often with the app swiped away, move the stream into a dedicated service isolate (flutter_foreground_task) — the LocationService interface stays the same.
GPS tolerance check at arrival
« Arrivé » (DA-08, DA-10) calls delegate.visits.arrive. It is offline-capable: the app evaluates the tolerance locally with the same algorithm and the settings snapshot, records the check-in, queues it, and the server's verdict at replay is final.
enum ArrivalVerdict { withinTolerance, accuracyTooLow, outOfTolerance, noReferenceLocation, mockLocation }
ArrivalVerdict checkArrival({required Fix fix, required GeoPoint? store, required FieldSettings s}) {
if (fix.isMock) return ArrivalVerdict.mockLocation;
if (fix.accuracy > s.gpsMaxAccuracyMeters) return ArrivalVerdict.accuracyTooLow;
if (store == null) return ArrivalVerdict.noReferenceLocation;
final d = Geolocator.distanceBetween(fix.lat, fix.lng, store.lat, store.lng);
return d <= s.gpsToleranceMeters ? ArrivalVerdict.withinTolerance : ArrivalVerdict.outOfTolerance;
}| Verdict | UX | Request sent |
|---|---|---|
| Within tolerance | Green « Validation GPS — Position confirmée », arrival time shown, visit opens | occurredAt, location (with accuracy and capturedAt), isMockLocation: false |
| Accuracy too low | « Précision GPS insuffisante (± 180 m) — rapprochez-vous d'une fenêtre ou sortez »; the app keeps listening for 30 s and retries automatically | Nothing until a usable fix (server code GPS_ACCURACY_TOO_LOW) |
| Out of tolerance, blocking (default) | DA-28 dialog: « Vous êtes à 361 m du point de vente (tolérance : 150 m) », distance on the map with the tolerance circle; actions « Réessayer », « Ouvrir la navigation », « Signaler une position erronée » (the store pin may be wrong — creates a back-office correction task) | Nothing (server code GPS_OUT_OF_TOLERANCE) |
Out of tolerance, allowed (field.allowOutOfToleranceCheckIn) | Same dialog with « Confirmer l'arrivée avec justification » (10–200 characters) | With justification; the server records OUT_OF_TOLERANCE_JUSTIFIED |
| No reference location (prospect without coordinates) | « Ce point de vente n'a pas encore de position — votre position sera proposée » | Normal; the server answers NO_REFERENCE_LOCATION and may propose the position (posLocationCandidate) |
| Mock location | « Position simulée détectée », check-in refused in prod; allowed in dev and staging for tests | In prod nothing (server code GPS_MOCK_LOCATION); in test flavors isMockLocation: true |
At replay the server may disagree (store pin corrected meanwhile, settings changed): the item comes back REJECTED or CONFLICT with the server message and the visit returns to À visiter locally, explained in the sync centre. Only one visit may be checked in at a time (409 VISIT_IN_PROGRESS); the app enforces it locally too.
Maps
Recommendation: google_maps_flutter (Google Maps Platform), pending D-14: best coverage of Algerian streets and points of interest, native map loads on Android are not billed, and navigation hands off to the Google Maps app most delegates already use. Mapbox (mapbox_maps_flutter) would bring offline map packs but weaker geocoding of Algerian addresses. The map widgets live behind hoco_maps (TourMap, ProspectsMap), so a provider change stays inside one package.
Setup: Maps SDK for Android enabled in HOCO's Google Cloud project, one API key per flavor restricted to Android apps with the package name and the SHA-1 fingerprints (debug, upload and Play app-signing keys), injected through manifestPlaceholders (repository › flavors). Map style JSON from Akram (muted points of interest) passed to GoogleMap(style: …).
| Element | Rendering |
|---|---|
| Stops of the tour (DA-06 mini-map, DA-08) | Numbered markers in the same order as the list (FLD-06), coloured by visit status tone: À visiter info, Arrivé and En cours progress, Visité success, Non réalisée danger, Annulée muted, late (derived) warning. Marker bitmaps (number × tone) are drawn once with a PictureRecorder and cached (BitmapDescriptor.bytes) |
| Route | A polyline through the current position and the remaining stops in order — straight segments in V1, no routing API |
| Next destination card | Name, address, « 1 sur 4 », distance and ETA, « Démarrer GPS », « Arrivé » |
| Distance and ETA | Straight-line distance × 1.3 and an average urban speed of 25 km/h, labelled as an estimate (« ≈ 12 min »). Assumption — a routing API (Google Routes) is billed per request; V1 does without it unless HOCO accepts the cost; optimised routes are V2 (MDA-20) |
| Tolerance circle | Around the store at field.gpsToleranceMeters on DA-10 and in the out-of-tolerance dialog |
Prospects map (DA-13 ?view=map) | Markers by ResellerStatus tone with clustering (the plugin's ClusterManager); tap → card → POS sheet |
Offline, the map shows only tiles the SDK has cached; the list remains the reliable view and carries the same order, distances and statuses. Distances in lists are computed locally from the last fix (« 1,2 km », « 850 m »).
External navigation
« Démarrer GPS » hands navigation to an installed app (FLD-07):
Future<bool> openNavigation(GeoPoint to, {String? label}) async {
final dest = '${to.lat},${to.lng}';
final candidates = [
Uri.parse('google.navigation:q=$dest&mode=d'), // Google Maps, turn by turn
Uri.parse('geo:$dest?q=$dest(${Uri.encodeComponent(label ?? '')})'), // any maps app
Uri.https('www.google.com', '/maps/dir/', {'api': '1', 'destination': dest, 'travelmode': 'driving'}),
];
for (final uri in candidates) {
try {
if (await launchUrl(uri, mode: LaunchMode.externalApplication)) return true;
} on PlatformException {
continue;
}
}
return false; // show « Aucune application de navigation »
}The <queries> entries of the manifest make these intents resolvable on Android 11+. On iOS (after the pilot, if decided) the order becomes comgooglemaps://, then Apple Maps https://maps.apple.com/?daddr=….
WhatsApp support region (reseller app)
A fixed WhatsApp button floats over every reseller-app screen, guests included (SUP-05, RA-32). It opens the number of the user's support region (D-36 holds the regions and numbers):
Signed in: use Me.supportRegion.whatsappNumber — no extra call and no location (auth › Me). supportRegion is null when no region covers the wilaya → step 4.
Guest, first tap: a sheet explains « Pour vous mettre en relation avec l'équipe de votre région, nous pouvons utiliser votre position approximative. » with « Utiliser ma position » and « Choisir ma wilaya ». Only if the guest chooses the first does the app request coarse location (ACCESS_COARSE_LOCATION only — the reseller app never asks for precise location for this). The position is rounded to two decimals (about 1 km) and sent to support.whatsapp as lat/lng; it is not stored.
Guest who declines, denies the permission, or has no fix within 10 s: the wilaya picker (cached reference list), then support.whatsapp?wilayaCode=…. The chosen wilaya is remembered on the device so the next tap opens WhatsApp directly.
Fallback: settings.support.whatsapp from appConfig.get.
Open: launchUrl(Uri.parse('https://wa.me/213770900300?text=…'), mode: LaunchMode.externalApplication) — the number without +, an optional prefilled text (« Bonjour, je suis Magasin El Wafa (REV-000123) » when signed in). If WhatsApp is not installed the link opens in the browser. Region hours are shown under the button sheet when outside opening hours.
The reseller app's only other location use is the optional « Utiliser ma position » on the delivery-address form (RA-19), also coarse-or-precise on the user's choice. Both are declared as optional approximate location in the data-safety form (build and release).
Testing with mock locations
| Where | How |
|---|---|
| Emulator | Extended controls → Location: single points or GPX/KML route playback; adb emu geo fix -0.6331 35.6969 (longitude first) |
| Physical phone | Developer options → Select mock location app with a mock-GPS app; positions come with isMocked: true, accepted only by dev and staging builds |
| Widget and integration tests | FakeLocationService replaying a recorded Oran tour (fixture of timestamped fixes, including bad-accuracy and jump samples) through the LocationService interface |
| Dev flavor | Debug menu « Simuler une position »: pick a stop and an offset (0, 120, 400 m) and an accuracy (10, 60, 180 m) to walk every tolerance verdict |
| Field test (MOB-13) | A real tour with a delegate: check-in distances and accuracies logged, airplane-mode visits, battery drain over the day, the foreground notification visible all along |
Offline sync (delegate app)
The client side of the delegate app's offline-first design: drift schema, outbox model, ordering and dependency rules, replay through sync.batch, result handling, backoff, delta pulls and merge rules, photo queue, conflicts UX, purge and the test matrix.
Notifications and deep links
Push in both apps: FCM per flavor, device registration, the payload contract, foreground, background and terminated handling, routing by event, Android channels, App Links for invitation links, Play install referrer attribution and the iOS fallback.