The APK has roughly fifteen scan-flow fragments: fragment_searching, fragment_connecting, fragment_scale_align, fragment_warming_up, fragment_phone_down, fragment_get_ready, fragment_countdown, fragment_scanning, fragment_step_off, fragment_scan_success, fragment_scan_timed_out, fragment_error_generic, fragment_error_tip, fragment_warning_timing_out, and fragment_pick_scanner. Reading them in sequence, they all share the same five-element template:
┌─────────────┐ │ │ │ [icon] │ ← centered, ~64sp, accent color │ │ │ Title │ ← bold, ~title2, white │ │ │ Body copy │ ← body, secondaryText │ │ │ [action] │ ← if any: button or progress indicator │ │ └─────────────┘Different icon, different copy, sometimes a button or progress indicator at the bottom, sometimes nothing. Background is the same flat tab_bg = #052235 everywhere. Type scale is the same. The pattern is so consistent that the APK probably has it as a shared XML include, though the layouts each include the full surface for fragment-isolation reasons.
The iOS port had been improvising. ConnectingView was a tiny VStack with an indeterminate progress wheel. WaitForScaleView was a different VStack with a 60sp scalemass icon and a hand-rolled timeout indicator. AligningView was yet another VStack. Each had .padding(32) or .padding() ad-hoc, .purple accents from an earlier theme draft, .white ProgressView tints, and a .black background that didn’t match the rest of the app.
Phase D’s job: pull the shared shape out into a ScanFlowChrome view and apply it.
My ScanFlowView state machine has nine inner states, and the chrome needed to cover all of them:
stateDiagram-v2
[*] --> connecting
connecting --> selectMirror
selectMirror --> waitForScale
waitForScale --> preflight
preflight --> alignScale
alignScale --> confirmAlignment
confirmAlignment --> scanning
scanning --> processing
processing --> complete
processing --> failed
complete --> [*]
failed --> [*]The chrome
struct ScanFlowChrome<Content: View>: View { let icon: String let title: String let subtitle: String? @ViewBuilder let content: () -> Content var body: some View { VStack(spacing: 24) { Spacer() Image(systemName: icon) .font(.system(size: 64, weight: .regular)) .foregroundStyle(AppTheme.accent) Text(title) .font(.title2.bold()) .foregroundStyle(.white) .multilineTextAlignment(.center) if let subtitle { Text(subtitle) .font(.subheadline) .foregroundStyle(AppTheme.secondaryText) .multilineTextAlignment(.center) .padding(.horizontal, 32) } content() Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, 24) }}The @ViewBuilder content slot at the bottom is where each fragment’s specific action goes: a ProgressView, a button, a status row, a list of detected mirrors, whatever. The rest is the same across all of them. Using this for ConnectingView:
ScanFlowChrome(icon: "dot.radiowaves.left.and.right", title: "Looking for your mirror", subtitle: "Bluetooth scan in progress. Make sure your mirror is powered on and within 10 feet.") { ProgressView().tint(AppTheme.accent).scaleEffect(1.2)}Five lines, instead of the previous version’s ad-hoc VStack. Every other state can adopt the same pattern.
The palette sweep
While the inner views still have their own bodies (they need state-specific layouts: MirrorPickerView’s List, ScanningView’s progress UI), I did a global find-replace pass on the obvious tokens:
.foregroundStyle(.purple)→.foregroundStyle(AppTheme.accent)(4 occurrences). The icons were all using the earlier.purplefrom when the accent was a placeholder. Now they’re consistently#ff0040matching the rest of the app.ProgressView().tint(.white)→ProgressView().tint(AppTheme.accent)(5 occurrences). The progress spinners now feel like part of the same UI rather than generic system widgets.- The ScanFlowView root background:
Color.black.ignoresSafeArea()→AppTheme.background.ignoresSafeArea(). So now scan flow uses the same#052235as the rest of the app.
That’s it for the global sweep. The per-state layouts (mirror list, alignment-confirm two-button row, scan progress, etc.) keep their unique shapes but inherit the new palette.
What’s deferred
The APK has fifteen scan-flow fragments. iOS implements about half. Specifically missing as their own states:
fragment_warming_up: between BLE connection and the first scan action. The APK shows a “warming up the sensors” screen for a few seconds; iOS just transitions immediately.fragment_phone_down: “put your phone down on the floor / surface so we don’t see you holding it during scan.” I assume the user reads the laser-on instructions instead.fragment_countdown: 3, 2, 1 timer before scan starts. iOS goes straight to scanning once init+scanToken are written.fragment_step_off: between scan complete and processing, “step off the scale now.” iOS just shows ProcessingView.dialog_scale_align_tip: pop-up with alignment instructions. iOS expects users to figure out from the on-screen text.
Each of these would need real BLE state-machine work to wire up correctly, not just UI. The mirror sends specific characteristic value transitions for some of them (scanState changes from .warmingUp to .scanning to .processing), and adding screens without correctly observing those signals would mean stuck states. Phase D scope was UI polish for the existing flow; adding new BLE-gated phases is its own atomic commit.
When that commit lands, the chrome is already in place. Each new state just needs a ScanFlowChrome(icon:, title:, subtitle:) invocation with the right copy. That’s the value of pulling the shape out now.
Phase E next: the first-scan walkthrough. Roughly 10 OOBE fragments (fragment_first_scan_intro, fragment_first_scan_prepare, fragment_first_scan_pose, etc.) that introduce a new owner to the scanning process. iOS currently has a single FirstLaunchSheet that asks for name/height/units; the APK walks through the whole “here’s what to expect when you scan” sequence. That’s the next phase.
Follow along
Procedure 21-AThis post is mostly a refactor plus a palette sweep, so there isn’t much hidden journey to strip out. Here’s the condensed version if you’re doing the same APK-faithful pass on your own SwiftUI port:
- 1Decompile the official APK and look for a repeated fragment shape. Grep
res/layoutfor a family of fragments (icon, title, body, optional action) before you build five one-off screens that all do almost the same thing. - 2Pull the shared shape into one parameterized view with a
@ViewBuilder contentslot for the one part that actually differs per screen. - 3Do a palette sweep for leftover placeholder styling. Grep for the colors you know are stale (an old accent, a system tint, a background that predates your theme) and replace them with your real theme tokens.
- 4Don’t let UI polish get ahead of the state machine it’s wired to. If a screen needs a real protocol signal (a BLE notify value, a state transition) to be correct, treat adding it as a separate commit. Shipping the UI without the signal it depends on just produces a stuck screen later.
Agent prompts Procedure 21-A as prompts for a coding agent · 4 prompts
Paste into Claude Code or a similar agent, one at a time, from the root of your iOS project. Each prompt maps to the step with the same number above.