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.

The Find us tab on the Android emulator: a card for ClinicLive Demo Clinic at 1 Example Parade, Demo Town, phone +00 0000 0000, open Mon–Fri 09:00–17:00, with a filled petrol 'Open in Maps' button; below it a 'How far away am I?' card with the sentence 'Uses your location once, only when you ask, and never sends it anywhere — the sum is done right here on the device.' and an outlined 'Use my location' button. Android
The same Find us page in a browser at phone width: an identical clinic card, Open in Maps button and 'How far away am I?' card with the same promise and 'Use my location' button, under a ClinicLive Pocket header and above the four-tab bar. Browser
One Razor component, two hosts, the same promise in both. The address and phone are the demo clinic's placeholders. On the web, "Use my location" goes to the browser's own permission bar and "Open in Maps" opens Google Maps in a new tab.

1.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.

The Find us tab dimmed behind Android's location dialog: 'Allow ClinicLive Pocket to access this device's location?', two round map thumbnails labelled Precise (selected, with a blue ring) and Approximate, and three buttons — While using the app, Only this time, Don't allow. At the tap
The 'How far away am I?' card after allowing: the label 'Distance to the clinic', a large petrol '1.3 km', the line 'As the crow flies — the maps app knows the roads.' and an outlined 'Check again' button. The fix
Left: Android's location dialog with Precise selected — the choice exists because the manifest asks for both accuracies. Right: "1.3 km", the staged fix described the way a person would say it, with the caveat that it's a straight line.

"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.

The Find us tab dimmed behind an Android system dialog titled 'Maps keeps stopping', offering App info and Close app. The clinic card and the top of the Open in Maps button are visible behind it.
The honest frame: the intent resolved and Google Maps took the foreground, then the emulator's stale Maps build crashed. On a phone with a working Maps this would be the directions screen; on this bench it's this dialog, and pretending otherwise would break the series' one rule.

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.