Previously, in Part 6, the clinic learned to reach a phone whose app was closed, and we learned what "closed" means. This part is gentler. The Find-us tab has shown the clinic's card since Part 3; now it can open directions with one tap and answer "how far away am I?" — with the honest possibility that both answers are no.
The prompt: two questions that may both be answered "no"
Verbatim from the commit in the
repo (tag pocket-07):
"Capability #6, ILocator: where is this device, and can you take them
somewhere? Both answers may be no — declined, no fix indoors, a desktop with
no GPS, a browser without a secure context — and the page must cope. MAUI in
one file via Essentials: ask for LocationWhenInUse explicitly at the moment
the user taps 'Use my location', medium accuracy with a ten-second limit,
Map.TryOpenAsync with driving directions. Browser: navigator.geolocation and
a Google Maps directions link. Put the distance formula in the SHARED project
— the browser host has no MAUI — and unit-test it against a known distance.
The Find-us screen's copy must promise exactly what the code does: used
once, only when asked, never sent anywhere. Prove it on the emulator with a
GPS fix 1.3 km from the demo clinic."
The prompt is unusual for how much of it is about failure: four ways to get nothing, listed before the feature. "Put the distance formula in the SHARED project" is an architecture call disguised as a detail — MAUI has a distance helper, but the browser host has no MAUI. And "copy must promise exactly what the code does" turns a privacy sentence into a specification the code has to satisfy.
Model pick: Sonnet, medium effort, for nearly all of it — MAUI Essentials does the heavy lifting and the page is a card with two buttons. Two things went to Opus: the wording of the privacy promise, and the diagnosis of why a maps app that was installed insisted it wasn't (below).
Both answers may be no
public readonly record struct GeoPoint(double Latitude, double Longitude);
/// Capability #6: "where is this device, and can you take them somewhere?"
/// Both answers may legitimately be "no": permission refused, no fix indoors,
/// a desktop with no GPS, a browser without a secure context. The page copes.
public interface ILocator
{
/// The device's current position, or null if the user declined or nothing could be found in time.
Task<GeoPoint?> GetCurrentAsync();
/// Hand off to the platform's maps app with the destination set. False if nothing could open.
Task<bool> OpenDirectionsAsync(GeoPoint destination, string label);
}
null and false are the whole error model. No exception crosses
the interface, so a page written against it can't forget to catch one. The MAUI answer is
one file, and the permission is requested here rather than left to the framework — so the
moment is ours to choose:
public async Task<GeoPoint?> GetCurrentAsync()
{
try
{
var status = await Permissions.CheckStatusAsync<Permissions.LocationWhenInUse>();
if (status != PermissionStatus.Granted)
{
status = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();
}
if (status != PermissionStatus.Granted)
{
return null;
}
// Medium accuracy is plenty for "how far is the clinic" and kinder to the
// battery; ten seconds is the most a person will wait staring at "Finding you…".
var request = new GeolocationRequest(GeolocationAccuracy.Medium, TimeSpan.FromSeconds(10));
var location = await Geolocation.Default.GetLocationAsync(request)
?? await Geolocation.Default.GetLastKnownLocationAsync();
return location is null ? null : new GeoPoint(location.Latitude, location.Longitude);
}
/* … FeatureNotSupported, FeatureNotEnabled, Permission: each returns null … */
}
Directions is Map.Default.TryOpenAsync with driving navigation — Google Maps
on Android, Apple Maps on iOS, whichever app the platform considers "the maps app" —
returning false, not throwing, when nothing answers. The browser host answers with
navigator.geolocation (which insists on a secure context and, in most
browsers, a user gesture) and a Google Maps directions URL in a new tab.
The formula goes in the shared project
Location.CalculateDistance exists in MAUI Essentials and would have been one
line. It would also have been one line the browser host couldn't compile. So the
great-circle distance lives in ClinicLive.Pocket.Shared, and its comment makes
a second argument for that: it's fifteen lines every developer should have typed once.
public static class GeoMath
{
private const double EarthRadiusKm = 6371.0088;
public static double DistanceKm(GeoPoint a, GeoPoint b)
{
var dLat = ToRadians(b.Latitude - a.Latitude);
var dLng = ToRadians(b.Longitude - a.Longitude);
var lat1 = ToRadians(a.Latitude);
var lat2 = ToRadians(b.Latitude);
var h = Math.Sin(dLat / 2) * Math.Sin(dLat / 2)
+ Math.Cos(lat1) * Math.Cos(lat2) * Math.Sin(dLng / 2) * Math.Sin(dLng / 2);
return 2 * EarthRadiusKm * Math.Asin(Math.Sqrt(h));
}
/* … Describe(km): "350 m", "1.3 km", "13 km" — the precision people actually use … */
}
Shared code is testable code: the test project now references
Pocket.Shared, and GeoMathTests checks the haversine against a
known distance, a point against itself, and Describe against its three
precisions. Which is where this part's most instructive mistake lives — in the
test, not the formula:
[Fact]
public void Haversine_matches_a_known_distance()
{
// Greenwich Observatory to the Eiffel Tower: 333.8 km on a great circle.
// (The first version of this test said "about 341" — the AI remembered
// London–Paris city-centre distance, and Greenwich sits east of London. The
// test failed, the formula was right, the expectation was wrong. Worked by
// hand before changing the number.)
var greenwich = new GeoPoint(51.4769, 0.0005);
var eiffel = new GeoPoint(48.8584, 2.2945);
var km = GeoMath.DistanceKm(greenwich, eiffel);
Assert.InRange(km, 333, 335);
}
The AI asserted 341 km from memory — roughly London to Paris, center to center. The formula said 333.8 and went red. The temptation, when a test fails, is to make it pass; the discipline is to work out which side is wrong first. Greenwich is east of central London and so a little closer to Paris; the haversine was right, the expectation was fixed, and the reasoning stays in the comment for the next person who "remembers" a distance. A failing test is a question, not an order.
Copy that promises what the code does
The Find-us page keeps two things apart. "Open in Maps" never needs your location — the
destination is the clinic. "How far away am I?" does, and its one sentence was written to
be checkable against the code above: used once (one GetCurrentAsync
per tap), only when you ask (no location call outside LocateAsync),
never sends it anywhere (the sum runs in GeoMath on the device, and
the API has no endpoint that accepts a position). When the answer is "no", the card says
so and points at the map above instead.
Android
Browser1.3 km, staged
The emulator has no GPS, but it takes one on the command line: adb emu geo fix
with a longitude and latitude 1.3 km from the demo clinic's configured coordinates. The
manifest declares both ACCESS_COARSE_LOCATION and
ACCESS_FINE_LOCATION — not because the feature needs precision, but because
declaring both is what gives the user the Precise/Approximate choice in the dialog. Tap
"Use my location", allow, and the page says 1.3 km, with a haptic tap for the fix.
At the tap
The fix"No maps app answered" — with Google Maps installed
The catch of the part. "Open in Maps" reported that no maps app answered, on an emulator
image with Google Maps installed. TryOpenAsync was telling the truth: since
Android 11, an app cannot see other apps' intent handlers unless its manifest
declares which ones it needs — package visibility. Our code asked "who handles
geo:?" and the OS, quite deliberately, answered "nobody you're allowed to know
about". The fix is a declaration, not code:
<!-- Part 7: Android 11+ package visibility. Google Maps was installed and "Open in
Maps" still reported no maps app — an app may not SEE other apps' intent
handlers unless it declares which ones it needs. -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="geo" />
</intent>
</queries>
After that, "Open in Maps" launched Google Maps — which immediately died. "Maps keeps stopping", says the dialog; logcat says the Maps process died as the top activity. The emulator's bundled Maps build is stale without a Play sign-in, and it fell over on its own before drawing anything. Read that carefully: our intent resolved, Maps became the foreground app, and then Maps crashed. What we control worked; what we don't, didn't; and the post shows the dialog rather than a screenshot of Maps borrowed from somewhere else.
What the AI got wrong: both real catches were in things it
remembered, not things it wrote. It remembered London-to-Paris and asserted
341 km; the formula it had just written was right, and the fix was the expectation,
worked by hand first. It suspected TryOpenAsync and the emulator when Maps
"wasn't there"; the code was right again, and the manifest was missing a
<queries> block it hadn't remembered Android 11 requires. One more,
older: the test build failed twice on a locked ClinicLive.dll, because the
tests reference the server project and building them rebuilds it while the server is
running — stop-before-build applies to dependencies too, season one's lesson still
biting. The pattern: on Android 11+, "nobody handles this" can mean "you haven't
declared that you're looking".
The meter: ≈ $4.70 on season three's running meter — the cheapest part of the season so far, because Essentials did the work. The package-visibility diagnosis was the only expensive thinking; the rest was a form and a formula.
Checkpoint: git checkout pocket-07 in
the repo: open the Find-us
tab on the emulator, give it a position with adb emu geo fix, tap "Use my
location" and choose Precise or Approximate — the card says how far; "Open in Maps"
hands off to whatever answers geo: on your device (on this bench, a
crashing emulator Maps). 20 tests green, the GeoMathTests being the first
written against the shared project.
Typing six characters into the app is fine. Pointing a camera at the ticket is nicer — and raises a question worth answering before the code: does a camera make the ticket a credential? A QR on the booking page, a native scanner page pushed over the WebView, a parser whose test caught a regex lying, and the one thing the bench could not stage — Part 8: Scan to Check In: A QR on the Ticket, a Native Scanner Over the WebView.