Previously, in Part 7, the app learned where you are and how to get you to the clinic. This part removes the last bit of typing: a QR on the booking ticket, a native camera page pushed over the Blazor UI, and a parser that decides whether what the camera saw is a ticket at all. It is also the part where the bench ran out — and where a unit test caught a bug in a regular expression that no one would have caught by eye.

The prompt: a QR that says what it is

Verbatim from the commit in the repo (tag pocket-08):

"Put a QR on the booking ticket and let the app read it. Server:
QRCoder, a tiny URI payload (cliniclive://visit/CODE) so the app recognises
its own tickets, an endpoint that serves the PNG for a known code, the web
ticket showing it on a white tile. App: capability #7, ICodeScanner —
supported on phones only; ask for the camera, push a NATIVE page with a
ZXing camera view over the Blazor UI, resolve with the first QR, pop. A
shared TicketCode parser that accepts the URI, a bare code or a URL with a
code in it, and rejects anything else. A 'Scan my ticket' button on Home
that only appears when scanning is possible. Prove as much as the bench
allows."

"Prove as much as the bench allows" is a prompt that anticipates its own limit, and the limit arrived. The rest is precise about a subtle thing: the payload is a URI, not six bare letters, so the app can recognize its own tickets and a generic reader shows something self-explanatory. And the scanner is "supported on phones only" — a laptop webcam pointed at your face is not something you hold up to a piece of paper.

Model pick: Opus for two judgments — the native-page-over-WebView design, and the question "is the camera a credential?" (no: it produces the same six characters a keyboard would, so scanning changes convenience, not security). Sonnet for the rest: QRCoder, the endpoint, the manifest, the button.

The ticket

The server side is small. QRCoder draws the code, and the payload is the scheme plus the code:

public static class TicketQr
{
    public const string Scheme = "cliniclive://visit/";

    public static string Payload(string code) => Scheme + code;

    public static byte[] Png(string code, int pixelsPerModule = 8)
    {
        using var generator = new QRCodeGenerator();
        using var data = generator.CreateQrCode(Payload(code), QRCodeGenerator.ECCLevel.M);
        using var png = new PngByteQRCode(data);
        return png.GetGraphic(pixelsPerModule);
    }
}

The endpoint joins Part 3's group: GET /api/pocket/visits/{code}/qr.png, 404 for a code the clinic doesn't know, and a plain Cache-Control header for a day because the image for a code never changes. (The first draft used .CacheOutput(), which needs the output-cache middleware registered — swapped before it could throw at runtime.) The website's ticket from season two gains an <img> pointing at that endpoint, on a white tile so it stays scannable in dark mode, and its hint now reads "or scan it with the ClinicLive Pocket app".

The clinic website's booking confirmation at phone width: 'You're booked, Leo.' above a dashed ticket showing Thursday 3 September · 15:30, the code 5GYZRH in large spaced petrol letters, a QR code on white, and the hint 'Your check-in code. Type it at the kiosk when you arrive, or scan it with the ClinicLive Pocket app — it's also all you need to cancel.' A 'Book another appointment' button sits below.
Leo's ticket on the clinic website, QR included. This shot took two goes: the first was taken mid pop-in animation with the image not yet loaded, so the harness now waits 900 ms after the ticket appears. The code and the QR carry the same six characters — the camera is a convenience, not a second credential.

The party trick: a native page over the WebView

Capability seven is the first that can't be answered from inside the WebView at all. The trick is remembering what a Blazor Hybrid app actually is: a native app whose main page happens to be a WebView. Native pages can be pushed on top of it.

public sealed class CodeScanner : ICodeScanner
{
    // Phones and tablets have a camera you'd hold up to a ticket; a laptop webcam
    // pointing at your face does not count.
    public bool IsSupported =>
        DeviceInfo.Platform == DevicePlatform.Android || DeviceInfo.Platform == DevicePlatform.iOS;

    public async Task<string?> ScanAsync()
    {
        /* … Permissions.Camera: check, request, null if refused … */

        var host = Application.Current?.Windows.FirstOrDefault()?.Page;
        /* … no page yet → null … */

        var page = new ScanPage();
        await host.Navigation.PushModalAsync(page);
        try
        {
            return await page.Result;
        }
        finally
        {
            if (host.Navigation.ModalStack.Contains(page))
            {
                await host.Navigation.PopModalAsync();
            }
        }
    }
}

Ask for the camera, push a modal page, await a TaskCompletionSource, pop. ScanPage itself is one C# file and no XAML: ZXing.Net.Maui's CameraBarcodeReaderView restricted to QR codes on the rear camera, a hint label, a Cancel button, and the Android back button wired to cancel:

_camera = new CameraBarcodeReaderView
{
    Options = new BarcodeReaderOptions
    {
        Formats = BarcodeFormat.QrCode,
        AutoRotate = true,
        Multiple = false,
    },
    CameraLocation = CameraLocation.Rear,
};
_camera.BarcodesDetected += OnDetected;

/* … hint label, Cancel button, a Grid stacking camera, hint and button … */

private void OnDetected(object? sender, BarcodeDetectionEventArgs e)
{
    var first = e.Results.FirstOrDefault()?.Value;
    if (!string.IsNullOrWhiteSpace(first))
    {
        // Detection arrives on a camera thread; the page is UI.
        MainThread.BeginInvokeOnMainThread(() => Finish(first));
    }
}

Three lines of plumbing make it real: .UseBarcodeReader() on the MAUI builder, CAMERA in the manifest, and uses-feature android.hardware.camera with required="false" — a device without a camera can still install the app, because the code can always be typed. Home shows "📷 Scan my ticket" only when Scanner.IsSupported says so; the browser host's answer is a plain "not here", and its visitors type six characters.

The Pocket Home screen on Android: the heading 'Your visit, in your pocket.', a Find my appointment card with a six-dot code input, a pale disabled 'Show my visit' button, the word 'or', and an outlined button reading '📷 Scan my ticket'. Home, on a phone
The same Home screen dimmed behind Android's camera dialog: 'Allow ClinicLive Pocket to take pictures and record video?' with While using the app, Only this time and Don't allow. After the tap
The scan button exists only where IsSupported is true; the browser host never renders it. Camera permission is asked at the tap — the same rule as notifications in Part 5 and location in Part 7.
The native scanner page: a live camera preview of the emulator's virtual room — wooden walls, a ladder bookshelf, a low-poly white cat on the floor, a rug, and a television showing a checkerboard test pattern with a green square — with white text at the top reading 'Point the camera at the QR on your ticket' and a white Cancel button at the bottom. The status bar is petrol; there is no app header.
The camera page, live, over the Blazor UI. That is the emulator's built-in virtual scene — the room you get when a virtual camera looks at nothing in particular — and it is as close as the bench got to a scan, for reasons the next section owns up to. Note there is no ClinicLive header: this is a native page, not a web page.

The parser, and the regex that lied

The camera returns raw text; TicketCode.TryParse, in the shared project, decides whether it's a ticket. The clinic's alphabet has no 0, O, 1, I or L; a code is six of those characters standing alone — so the URI, a bare code and a URL with the code in a query string all work, and a random QR from a cereal box doesn't:

public static partial class TicketCode
{
    // The alphabet is ABCDEFGHJKMNPQRSTUVWXYZ23456789. The first draft wrote the class as
    // [A-HJ-NP-Z2-9] — and J-N spans J,K,L,M,N, quietly letting L back in. The test that
    // feeds it a cereal-box URL ("cereal" = six letters) went red and caught it.
    [GeneratedRegex("(?<![A-Z0-9])([A-HJ-KM-NP-Z2-9]{6})(?![A-Z0-9])", RegexOptions.IgnoreCase)]
    private static partial Regex CodePattern();

    public static bool TryParse(string? raw, out string code)
    {
        /* … match, upper-case the captured group, true; otherwise false … */
    }
}

Read the first draft's character class slowly: [A-HJ-NP-Z2-9]. It skips I by stopping at H and restarting at J. It skips O by stopping at N and restarting at P. But the range J-N is J, K, L, M, N — and L is banned. Nobody reads a regex range letter by letter, and the AI didn't either. The test did: the rejection theory feeds it https://cereal.example/box-of-flakes, and "cereal" is six letters standing alone between // and a dot. With L allowed, it parsed as the ticket CEREAL. The fix splits the range — J-K, M-N — and the comment keeps the story. Character ranges are where regexes lie.

What the bench could not stage

Here is the limit the prompt anticipated. The permission dialog and the live camera page were photographed. A decode was not. The Android emulator can, in principle, put a poster into its virtual scene — a -virtualscene-poster launch option, and a matching adb emu command — and both accepted the QR image without complaint. The poster never appeared. The room was swept through 360 degrees and tilted with mouse automation (the script survives in the repo as Look-Around.ps1, kept as a curiosity), for about forty minutes, and the checkerboard TV was the only pattern the camera ever saw. So, plainly: no on-device QR decode was photographed in this series, and no screenshot in this post shows one.

What the bench could do is prove every link of the chain except the lens. The server draws the PNG; a real decoder — ZXing, the same family the phone runs — reads it back; the app's parser pulls the code out. The ImageSharp binding is managed-only, so the test runs on Linux CI too:

[Fact]
public void The_ticket_qr_decodes_back_to_its_code()
{
    var png = TicketQr.Png("PWPAP2");

    using var image = Image.Load<Rgba32>(png);
    // Fully qualified: ZXing and ZXing.ImageSharp both define a BarcodeReader<T>.
    var reader = new ZXing.ImageSharp.BarcodeReader<Rgba32> { Options = { PossibleFormats = [BarcodeFormat.QR_CODE] } };
    var result = reader.Decode(image);

    Assert.NotNull(result);
    Assert.Equal("cliniclive://visit/PWPAP2", result.Text);
    Assert.True(TicketCode.TryParse(result.Text, out var code));
    Assert.Equal("PWPAP2", code);
}

If the payload format drifts, the error-correction level stops a decode, or the parser stops recognizing the URI, this goes red before a patient's ticket does. It is a weaker proof than a phone reading a screen, and the series says so; it is also a proof that runs on every push, which the phone never would.

What the AI got wrong: the regex, first and most important — a character class that read correctly and wasn't, which a human review would have waved through too; it's in this post only because a four-input rejection test was cheap to write. Then the small ones: .CacheOutput() without the middleware that makes it work; a CS0104 ambiguity because ZXing and its ImageSharp binding both define BarcodeReader<T>; a ticket screenshot taken before the image loaded. And the expensive one: about forty minutes of automation chasing a poster the emulator advertises and never drew, where the better move — deciding what evidence would substitute — came late. The lesson of the part: the bug that would have hurt a patient was found by the cheapest tool in the series.

The meter: ≈ $5.60 on season three's running meter. The poster hunt was the most expensive dead end of the season so far, and it bought a script nobody needs; the feature itself was cheap.

Checkpoint: git checkout pocket-08 in the repo: book on the website and the ticket carries a QR; fetch /api/pocket/visits/{code}/qr.png for a real code and a 404 for a made-up one; on Android, Home shows "Scan my ticket", the camera dialog appears at the tap, and the scanner page opens over the app — on a real phone that is where you'd point it at the ticket, which this bench could not. 28 tests green, including the round trip and the cereal box.

The app now does everything a patient needs while it's open and online. Part 9: Offline and Settings: Remember the Visit, Survive a Dead Signal is about neither: remembering the visit on the phone, showing what the clinic last said when there's no signal, and — at last — a setting for the physical phone that Part 3 promised.