Trade-offs made building the TapSense sample app in SwiftUI. Entries here are either a
platform-forced departure from what the layout naturally suggests, or something a real Simulator
or device run surfaced that reading the source alone wouldn't have shown. See
NFCLocatorCore/DECISIONS.md for the library-level decisions
this app builds on.
A single route table drives navigation — one flat table of routes with a bottom bar shown/hidden
per-current-route rather than a nested per-tab graph (see TapSenseDestinations.BOTTOM_BAR_ROUTES).
Router (root + path) implements "clear the path and swap root" for top-level tab switches —
this means switching tabs doesn't preserve each tab's own scroll/navigation state, a minor UX
simplification with no functional effect the design specifies elsewhere.
Back button: default chevron with an empty title, not a hidden bar
The first attempt hid the navigation bar entirely (.toolbar(.hidden, for: .navigationBar)) to
match the design's intended absence of back-button chrome. Confirmed by driving the built app
in the Simulator: this also disabled the interactive swipe-back gesture — a real regression, not
a hypothetical one, since every pushed screen except Tap Guide (which has its own explicit close
button) would otherwise have had no way back at all. navigationBarBackButtonHidden(true) was
tried next and has the same documented coupling. The fix: leave the default back button in place,
but give every screen (including tab roots, whose title is what a pushed child's back button
reads) .navigationTitle("") — iOS then renders a bare chevron with no "Back" text, visually
matching the intended design, with both tap and swipe-to-go-back intact. Tap Guide alone keeps
navigationBarBackButtonHidden(true), since its own close (X) button makes the default one
redundant and swipe-back isn't needed there.
Found by running the app: a screen's outer VStack only reports its content's natural size
to its parent unless a child inside it is flexible (a ScrollView filling most of the screen
masks this in practice; PhoneConfirmedScreen, whose content is short and centered, showed it
immediately as a background color that stopped short of the screen edges). Every screen's root
container now explicitly requests the full available size before .background() is applied,
rather than relying on a ScrollView child to imply it.
Swift classes aren't substitutable in tests the way a protocol-typed dependency is, so
TapReaderModeStarting (start(onTagDetected:onSessionEnded:)/stop) is the minimal seam
TapTestViewModel depends on instead of the concrete controller — TapReaderModeController
conforms to it for real use, TapTestViewModelTests uses a fake. This keeps the state machine
(Ready → Detecting → Detected/TimedOut, retry) fully testable without Core NFC or a physical
device.
Tap Test didn't work at all on a real device — two compounding bugs, found only by reasoning through the untestable path
Every earlier verification pass could only exercise the !isNfcSupported branch of
TapReaderModeController — NFCTagReaderSession.readingAvailable is unconditionally false in
the Simulator, so the actual session-handling code had never executed once, in any build,
before a real device revealed it didn't work. Two real, compounding bugs, not one:
didInvalidateWithErroronly logged. A Core NFC session is ephemeral:NFCTagReaderSessionends on its own when the user dismisses the system "Hold Near the Top of iPhone" sheet, when the OS's own ~60s session timeout elapses, or immediately afterbegin()if the app's NFC entitlement isn't correctly provisioned. None of that was ever reported back toTapTestViewModel— the screen just sat on "Detecting…" until the app's own 25s timer fired, regardless of what the real session was doing. Fixed:startnow takes a secondonSessionEndedcallback, invoked fromdidInvalidateWithError, which the view model uses to move out of.detectingimmediately instead of waiting on an unrelated timer.retry()didn't restart anything. By the time a user sees a reason to retry, the realNFCTagReaderSessionhas almost always already ended (that's.timedOut's call, and now bug #1's fix makes it fire correctly). Resetting only the UI countdown left every retry after the first listening to nothing, timing out again no matter what was presented to the phone — this alone could fully explain "doesn't work at all." Fixed:retry()now callsstop()thenstartListening(), genuinely re-arming Core NFC each time.
Reconciling "did this session end on purpose or is this a stale callback from a session we've
already moved past" is done by comparing the session instance's identity
(session === self.session) inside the delegate callbacks, not a boolean flag — a flag set by
stop() and cleared by the next start() (as in an earlier draft of this fix) races exactly the
sequence retry() triggers: stop() sets it, start() immediately clears it again, and the
first session's now-asynchronous invalidation callback arrives after that, misreading the
fresh session's state. Identity comparison has no such window: a callback naming any session
other than the one self.session currently holds is definitionally stale and ignored.
Tap Test on a real device went straight to "No NFC signal detected yet" — a second, distinct bug the first fix's own device testing exposed
After the fix above, the screen stopped hanging on "Detecting…" forever — but on the actual
device it was tested on, it went to .timedOut ("No NFC signal detected yet") immediately,
with no system "Hold Near the Top of iPhone" sheet ever appearing. That copy is wrong for what
actually happened: it reads as "you held your phone up and nothing was read," but a session that
never even reached the system sheet was rejected before the user had any chance to present a tag
at all — Core NFC only calls tagReaderSessionDidBecomeActive once a session has genuinely
started polling, and this callback was never firing. That combination (immediate invalidation,
no activation) is the textbook signature of NFCTagReaderSession.begin() being rejected outright
— almost always because the installed build isn't actually signed with the NFC entitlement
provisioned, even though readingAvailable (a hardware/OS check only, not an entitlement check —
see below) reported true and the app never took the .nfcUnsupported branch at all.
Fixed at the root: TapReaderModeController now tracks sessionBecameActive, set only from
tagReaderSessionDidBecomeActive (reset per session generation, same identity-comparison
discipline as above), and threads it through onSessionEnded's new becameActive parameter.
TapTestViewModel uses it to route to two genuinely different states: .timedOut when the
session did activate and the user had a real chance to present a tag, and the new
.readerUnavailable when it didn't — copy: "Couldn't start the NFC reader," not "No NFC signal
detected yet." The same .readerUnavailable state also now covers start() returning false
outright (previously mislabeled .timedOut too). This doesn't fix the underlying provisioning
problem — that's outside the app's code, see below — but it stops the UI from lying about what
happened, which is what made "the feature doesn't work at all" so hard to diagnose from the
outside in the first place.
If you hit .readerUnavailable on a device: this is not something retry() can fix by itself.
Confirm in Xcode → target → Signing & Capabilities that "Near Field Communication Tag Reading" is
listed under the target's capabilities and that the provisioning profile shown there actually
includes it (Automatic signing usually needs the project opened in Xcode.app itself, connected to
your Apple ID, at least once for a newly added capability like this one to be registered with
the App ID on the developer portal and folded into the profile — a bare xcodebuild command-line
build, even with DEVELOPMENT_TEAM set, does not reliably trigger that first-time registration).
Still failing instantly with no system sheet, even with a locally-verified entitlement — the reader session was starting mid-navigation-transition
Correction — see the section below this one. At the time this was written, a full
xcodebuild -destination generic/platform=iOS build was run signed (not
CODE_SIGNING_ALLOWED=NO) using whatever Apple Development identity happened to be in the build
machine's keychain, and both the built app's embedded entitlements (codesign -d --entitlements) and the provisioning profile it was signed with (security cms -D -i embedded.mobileprovision) were inspected directly. Both correctly listed
com.apple.developer.nfc.readersession.formats = [NDEF, TAG], which was (wrongly) taken as proof
that provisioning wasn't the problem. It proved something narrower: the entitlement key round-
trips correctly through project.yml → XcodeGen → codesign for that signing identity. It said
nothing about whether the account actually used to install onto the test device had the NFC Tag
Reading capability approved for its own App ID — a different Apple ID than the one that build was
signed with. The real answer, confirmed directly from the device's own console log, is in "Found:
NFCError code 2" below.
With that ruled out, the same symptom (immediate failure, system sheet never appears, 100%
reproducible) has another, purely code-side explanation: TapTestScreen's .task — which calls
readerModeController.start() — fires the instant the screen is inserted into the navigation
stack, which is the start of the push transition (Run a tap test on Tap Guide → pushes Tap
Test), not after it settles. Calling NFCTagReaderSession.begin() while the screen is still
mid-transition is a known way to get Core NFC to reject the session outright: it invalidates
before ever becoming active, so the system sheet never appears at all — from the user's
perspective, indistinguishable from a real entitlement problem, and exactly what "transitions to
error immediately, without any delay, no dialog ever shown" describes. Fixed: .task now awaits
a short Constants.readerStartDelay (500ms — comfortably past a standard push transition) before
calling startListening(), checking Task.isCancelled first so backing out of the screen before
the delay elapses doesn't start a session on a screen that's already gone.
Also hardened for the next time this needs debugging on a device: didInvalidateWithError's log
line moved from .d (debug) to .e (error) and now includes the underlying NSError's
domain/code, not just localizedDescription — the one diagnostic signal available for a
failure mode that can't be reproduced in the Simulator at all.
The transition-timing fix didn't fix it either — on a fresh install, on an iPhone 7+, with confirmed-correct provisioning
Retested after the fix above with the entitlement/provisioning question and the "stale install"
question both directly ruled out (fresh delete-then-reinstall, a real iPhone 7-or-later, not an
iPad or a Mac running the iOS app — no iPad model supports NFCTagReaderSession's tag reading at
all, which would otherwise have been a clean, fully-explanatory answer on its own). Still
immediate, still no system sheet.
.readerUnavailable is reached two ways, and they weren't equally diagnosable: onSessionEnded
firing with becameActive: false (an async round trip through Core NFC, however fast) was
already logged at error level with the NSError domain/code. start() returning false
directly — either Self.isSupported (NFCTagReaderSession.readingAvailable) reading false, or
NFCTagReaderSession(pollingOption:delegate:) itself returning nil — was a bare, silent,
synchronous return with no logging at all. That path matches "zero perceptible delay, no
dialog whatsoever" far better than any async invalidation ever could — there's no system round
trip on that path at all, nothing to be slow. It was also the one branch with no diagnostic
trail. Fixed: both early-return branches of start() now log at error level before returning
false, so the next Console.app capture will show, unambiguously, whether this is
readingAvailable itself reporting false on this specific device at this specific moment (a
genuine runtime/OS-state condition — e.g. another app or a background NFC feature such as Wallet
Express Transit holding the controller — not a config or provisioning gap, both of which are now
independently confirmed correct) or the session object failing to construct at all.
This is the point in the investigation where every angle checkable through static analysis, code
review, and a real signed build's inspected output — entitlement, provisioning profile, Info.plist
keys, deployment target vs. hardware minimum, fresh-install state, screen-transition timing — has
been individually verified and ruled out. What's left needs the actual NFCReaderError
domain/code from the device itself, which no amount of further code reading can substitute for;
see README.md's "Running Tap Test on a real device" section for exactly where to find it.
Found: NFCError code 2, "Missing required entitlement" — and why the earlier "provisioning is fine" conclusion was wrong
The device console log came back unambiguous: domain: NFCError, code: 2, "Missing required entitlement". This is a provisioning problem after all — but the entitlement inspection earlier
in this document didn't catch it, and the reason why matters: that inspection signed a build with
whatever Apple Development identity happened to be in this machine's keychain
(security find-identity -v -p codesigning → a different Apple Development identity than the
one on file for this project's owner). It proved the entitlements key round-trips correctly
through project.yml → XcodeGen → codesign — a real and useful thing to confirm — but it never
touched the account, App ID, or profile that actually signs the build installed on the test
iPhone. Two separate questions got conflated: "does the tooling embed the entitlement correctly"
(yes, confirmed) and "does your Apple Developer account have the NFC Tag Reading capability
approved for com.tapsense.app, with a profile that reflects it" (the actual open question, and
per this error, currently no).
The concrete, most common cause of exactly this error — entitlement key present in the project,
but the OS rejects the session at runtime with "missing required entitlement" — is that the App ID
on the developer portal has never actually had the NFC Tag Reading capability turned on, or
Xcode's automatically-managed profile predates it being turned on and hasn't refreshed. Declaring
com.apple.developer.nfc.readersession.formats in project.yml's entitlements: block (which is
what this project does) puts the key in the .entitlements file XcodeGen generates, but it does
not, by itself, register the capability against the App ID on Apple's servers the way adding it
through Xcode's own Signing & Capabilities "+ Capability" picker does. This is a real, sharp edge
in the Automatic-signing + generated-project workflow, not a mistake in this repo's config.
Fix (needs your own Apple Developer account — not something further code changes can resolve):
- Open
TapSense.xcodeprojin Xcode.app, select the TapSense target → Signing & Capabilities. - If Near Field Communication Tag Reading is not listed as its own capability card there (separate from just having the entitlement key present), click + Capability and add it explicitly. This is the action that actually tells Xcode to register the capability against the App ID on the developer portal and regenerate the profile — the missing step.
- Let Xcode finish resolving signing (it does this automatically once the capability is added; watch for it to stop showing a signing error).
- Delete the app from the device, then rebuild and reinstall (a stale cached profile from before the capability existed won't self-refresh otherwise).
- If it's still rejected: check developer.apple.com/account → Certificates, Identifiers &
Profiles → Identifiers →
com.tapsense.appdirectly, and confirm NFC Tag Reading is checked there. If Xcode's automatic signing still won't pick up the change, delete the cached profile at~/Library/Developer/Xcode/UserData/Provisioning Profiles/(and, on older Xcode versions,~/Library/MobileDevice/Provisioning Profiles/) to force a fresh download.
Once this is done, re-check the TapReaderModeController log line on the next run — it should
either disappear entirely (the session now activates, the real "Hold Near the Top of iPhone"
sheet appears) or, if something is still wrong, report a different domain/code that will point
at whatever's next.
Same "Missing required entitlement" error recurred after the capability was added — the CLI build was the culprit this time
Reported again after the fix above had already been applied through Xcode.app (capability added,
confirmed present in Signing & Capabilities, signing resolved with no error shown) — same exact
log line, domain: NFCError, code: 2. Per Apple's own NFCError.h, code 2 is
NFCReaderErrorSecurityViolation, "Missing required entitlement and/or privacy settings from the
client app" — there is only one error under this code, so a repeat means the installed binary's
embedded profile still doesn't carry the entitlement, not that the portal-side registration failed
a second time.
Root cause: this project's own documented device-build command —
xcodebuild -project TapSense.xcodeproj -scheme TapSense -destination 'generic/platform=iOS' build — never carried -allowProvisioningUpdates. Xcode.app's Signing & Capabilities UI resolves
and downloads an updated provisioning profile itself, entirely independent of the command line;
plain xcodebuild build from Terminal does not talk to the developer portal at all unless
-allowProvisioningUpdates is passed — it silently reuses whatever profile is already cached on
disk. Reproducible sequence: add the capability in Xcode.app (which downloads a fresh profile into
Xcode's own cache and makes the Xcode-driven build/run succeed) → close Xcode → rebuild from
Terminal with the exact command this README documented → xcodebuild finds a usable cached
profile (the one from before the capability existed, still otherwise valid) and happily signs with
it, producing a build that installs fine and fails at the identical NFCError code 2 the moment
NFCTagReaderSession.begin() runs — indistinguishable from the capability never having been added
at all, from this log line alone.
Fix: -allowProvisioningUpdates (and, for a device never before registered to this account,
-allowProvisioningDeviceRegistration) added to the device-build command in README.md, so the
same command that builds also keeps the profile in sync going forward. Confirmed independent of
the log line, directly: codesign -d --entitlements :- <path-to-built-TapSense.app> after a fresh
build prints the actual entitlements plist baked into that specific binary —
com.apple.developer.nfc.readersession.formats either is or isn't in it, which settles whether a
stale profile is the problem before spending another on-device test cycle finding out.
Building for a real device needs a Development Team — and DEVELOPMENT_TEAM in project.yml, not just Xcode's UI
Core NFC's reader-session entitlement can only be provisioned under a paid Apple Developer
Program membership; a free/personal Apple ID cannot get it approved at all, and building for a
device with no team selected fails outright (confirmed: xcodebuild ... -destination 'generic/platform=iOS' build errors with "Signing for 'TapSense' requires a development team"
before it even reaches the compiler — the underlying Swift/Core NFC code itself was separately
confirmed to compile cleanly for the device architecture with CODE_SIGNING_ALLOWED=NO). Picking
a team purely through Xcode's Signing & Capabilities UI doesn't survive this project's workflow,
because xcodegen generate rewrites the whole .xcodeproj from project.yml on every run —
project.yml now reads DEVELOPMENT_TEAM from the ${DEVELOPMENT_TEAM} shell environment
variable at generate time instead, so export DEVELOPMENT_TEAM=<your team ID> before generating
is durable across regenerations. See README.md for the full device-testing setup.
The bottom bar's Home/My Phone/Settings glyphs use SF Symbols (house.fill, iphone,
gearshape.fill) — standard platform icons are the idiomatic iOS choice for generic navigation
affordances, and avoid maintaining custom vector paths for controls a system font already covers
well. TapSenseLogo (the concentric-ring mark used on Splash, Onboarding, and the Tap Guide FAB)
is a hand-drawn brand asset instead, including its singleRing variant and pulse animation math
— it's the one piece of chrome specific to this product's identity, not a generic control, so it's
drawn pixel-for-pixel rather than approximated with a system symbol.
NFCLocatorCore uses SwiftData for its catalog cache (a genuine collection with query needs).
App settings are a single small struct with no query surface, so TapSenseSettingsStore is a
plain Codable struct written to Application Support with the file's isExcludedFromBackup
resource value set. Writing settings somewhere backups skip entirely — rather than relying on
export-rule exclusions layered on a backed-up location — matters concretely here: without it, a
restored backup can silently reintroduce a completed-onboarding flag after a genuine reinstall,
making onboarding permanently unreachable.
NFCLocatorCore's SwiftUI AntennaSilhouette takes its colors as explicit parameters rather than
reading them from an ambient theme, so drawing the phone-mockup card's dark-appropriate
primary/tertiary/outline tones needs no environment-override wrapper at all —
DarkMockupColors.swift is a set of plain functions returning the right fixed color for a given
screen context, passed directly into AntennaMarker.
iOS has no user-facing on/off toggle for NFC hardware and no settings screen to deep-link to
for one — Core NFC scanning is simply available or unavailable based on hardware + entitlement.
An earlier version of this app faked an isEnabled flag that always mirrored isNfcSupported,
and showed Home's/Settings' "NFC status: on/off" rows and Troubleshoot's "Open NFC settings"
action against it — all three implied a toggle a user could go find and flip, which doesn't exist
on this platform and would only confuse. All three are gone: NfcStateObserver now carries only
isNfcSupported (real hardware/entitlement capability, not a toggle), and TapTestUIState has no
.nfcOff case — a session that fails to start folds into .timedOut (see the Tap Test bugfix
above), same as one that started but detected nothing. The "no NFC hardware" notices on Home/My
Phone/Tap Test stay exactly as they were: isNfcSupported is a real, meaningful capability check,
just not a toggle.
All three were reported together after real-device testing (physical iPhone 12) and root-caused independently — unrelated causes, coincidentally surfacing at the same time.
Wrong device name ("iPhone 13 2" instead of "iPhone 12"). DisplayNames.swift's
friendlyModelNames table was keyed on the catalog's raw comma-separated hw.machine format
("iphone15,2"), but TapSenseDeviceFingerprintProvider — the real-device path, used for every
iPhone not in the bundled seed catalog (a single apple / iphone15,2 entry at the time this bug
was found — since expanded, see NFCLocatorCore/DECISIONS.md) — already runs the raw sysctl string through
DeviceFingerprint.normalize() before it ever reaches this table, arriving as "iphone13_2"
(underscore, not comma). The table could therefore only ever match a catalog hit, never a real
device on the generic-fallback path, silently falling through to toDisplayDeviceName()'s
generic capitalization instead — which is what "iPhone 13 2"-style garbling actually was. Not an
iPhone-12-specific bug: every iPhone except the one seeded catalog example was affected the same
way. Fixed by normalizing the lookup key the same way before matching, re-keying every existing
table entry (Samsung/Pixel/OnePlus/etc., not just the Apple ones) to match, and substantially
expanding Apple coverage — hw.machine identifiers for iPhone 8 through the iPhone 16 line plus
the SE line, sourced from Apple's own (well-documented, not marketing-generation-numbered)
identifiers, since iPhone13,2 being the iPhone 12 rather than the 13 is exactly the kind of
mismatch that caused this bug in the first place and is easy to get wrong again by guessing.
Regression tests cover both the raw comma form and the already-normalized form for iPhone 12
specifically, plus a couple of other generations, in DisplayNamesTests.swift.
My Phone's content unreachable below "Test this location." Confirmed by driving the screen
directly (multiple gesture techniques, an intentionally oversized 600pt diagnostic block to rule
out a content-height explanation, and isolating AntennaMarker as a possible gesture-blocking
suspect by temporarily replacing it with a plain color — none of which was it): the floating
bottom tab bar, added via .safeAreaInset(edge: .bottom) on the enclosing NavigationStack (see
TapSenseRootView), reserves visual space but doesn't reliably give a ScrollView's own content
enough scrollable headroom to clear it once content is tall enough to need scrolling at all — My
Phone's back-content state (ending in the "Test this location" button) is the first screen tall
enough to expose it, since Home and Settings' content happens to fit without scrolling. Fixed by
adding TapSenseBottomBar.reservedBottomClearance (110pt — sized off the bar's actual intrinsic
height: the raised FAB item plus its own top/bottom padding, since there's no explicit
.frame(height:) to read) as trailing content padding on all three tab-root ScrollViews
(Home/My Phone/Settings) — applied to all three defensively, not just the one that already showed
the symptom, since they share the exact same at-risk layout pattern.
Dim aqua on "Tap not working?" / "Skip" / "Use my phone automatically." All three route
through the same .tapSenseText button style, which read colors.linkForeground —
TapSenseColors.linkForeground was hardcoded to TapSensePalette.aquaLink (0x0E4B54, a
near-black teal) regardless of light/dark mode. That color is the on-primary contrast color —
meant for dark text/icons drawn on top of an aqua-filled background (see onPrimary/onTertiary
elsewhere in the same file) — not a color meant to sit directly on the app's own background,
which is what a link's text actually does. ActionRow (Troubleshoot's "View my tap zone"/"Learn
NFC basics") already correctly read colors.primary directly and looked right, which is what
made the mismatch visible by direct comparison. Fixed by making linkForeground theme-aware the
same way primary/tertiary already are (isDark ? aquaDark : aqua), rather than a fixed color
with no light/dark branch.
My Phone's Back and Front tabs both had a "Report inaccurate guidance" / "Thanks — noted for this
session" button. It called NFCLocatorAnalytics.guidanceDismissed(confidence:timeVisibleMillis:)
— a real, intentional library integration seam (see NFCLocatorCore's own doc comment on that
protocol method) — but TapSense's own implementation of that protocol,
OSLogNfcLocatorAnalytics, only writes to the local OS log; nothing is transmitted anywhere. The
button's copy ("Report…") told the user their feedback went somewhere it didn't, which is both a
misleading affordance and, for a sample app meant to demonstrate a "collects nothing" privacy
posture end to end, a needless thing to have to explain in an App Store privacy review. Removed:
the button in both backContent and frontContent, MyPhoneViewModel.reportInaccurateGuidance/
uiState.reportSent/the associated timer Task, and the two now-dead localized strings. The
library's guidanceDismissed API itself is untouched — it's a legitimate seam for an integrator
with a real backend to wire up, this sample app just no longer pretends to be that integrator.
Removing the button left both tabs' final action button flush against the reserved tab-bar
clearance with no breathing room of its own, so each picked up an explicit .padding(.bottom, 24) — the same treatment Home's "Tap not working?" link needed for the same reason (its existing
.padding(.top, 16).padding(.vertical, 14) gave it 30pt above but only 14pt below, consolidated
here into an explicit .padding(.top, 30).padding(.bottom, 24)).
Reported as "too much spacing at the lower part of the bottom navigation," on both a real device
and the Simulator. Root-caused by sampling rendered pixel rows directly rather than guessing:
TapSenseBottomBar's HStack(alignment: .top) sizes itself to its tallest child, and that
child is fabItem — 52pt circle + spacing + label, ~71pt tall before its -14/-10 offsets
shift it upward for the "raised" look. Offsets reposition rendering only; they don't shrink the
layout size SwiftUI computes the row's height from. So the row's true height was driven by the
FAB's unshifted footprint, while Home/My Phone/Settings (natural height ~44pt: 18pt icon + 4pt
spacing + labelSmall's 14pt line height + the item's own 4pt padding) sat top-aligned within a
much taller row, leaving a band of plain surface-colored dead space below them — confirmed by
measuring: those three items' content ended roughly 60pt above where the row actually stopped.
Fixed by constraining the row to TapSenseBottomBar.itemRowHeight (44pt, matching the regular
items) via .frame(height:alignment: .top). This does not clip or otherwise affect fabItem's
rendering — SwiftUI doesn't clip a child's rendering to an ancestor's frame by default (the same
principle the ripple-animation fix elsewhere in this app relies on) — so the circle still floats
above the bar exactly as before; only the row's own reported height shrinks to match what's
actually visible in the other three items. Verified by re-measuring pixel rows after the fix (the
dead-space band roughly halved, from ~60pt to ~34pt of the remainder being the normal breathing
room most tab bars leave above the home indicator) and by confirming all four tap targets
(Home/My Phone/Settings/the FAB) still navigate correctly — .frame(height:) on the parent
HStack doesn't shrink or move each item's own .contentShape(Rectangle()) hit-testing area,
which is sized from that item's own (unconstrained) content.
Added per the "Phone selection & compatibility search" design mockup, which shows a segmented
Android/Apple control directly below the title, above the search field — present in the design
but never implemented. PhoneSelectionPlatform (.android/.apple) is a plain binary split on
manufacturer.lowercased() == "apple", not a whitelist — every non-Apple catalog entry today
(Samsung/Google/OnePlus/Xiaomi/Motorola/Sony) is a real Android OEM, so "not Apple" is the whole
rule and needs no maintenance as more Android OEMs are added to the catalog. PlatformFilterToggle
mirrors My Phone's Back/Front SideToggle visual language (same toggleTrackLight/Dark/
toggleTabSelectedDark tokens) but full-width with two equal-width segments, matching the mockup
rather than SideToggle's compact, content-sized style — this control gates the whole catalog
list, not a secondary per-screen toggle. PhoneSelectionViewModel.applyFilters() applies the
platform filter first and the search query on top of that result, so switching platforms
preserves and re-scopes an in-progress search rather than clearing it, and searching never
surfaces a result from the platform the user isn't browsing. Defaults to .android, matching the
mockup's default-selected segment.
A real interaction bug surfaced while verifying this, worth recording: the toggle segments'
Buttons used Text(...).padding(.vertical, 10) with no explicit height — visually well under
Apple's 44pt minimum tap-target guideline, which iOS enforces by silently expanding a control's
hit-testable area beyond its rendered bounds, invisibly. With only 8pt of layout space between
the toggle and the search field below it, that invisible expansion was large enough to swallow
taps clearly inside the search field's own visible bounds — confirmed empirically (not guessed)
by driving the screen directly: identical tap coordinates focused the search field correctly with
the toggle absent and silently failed with it present, and a tap visually centered in the search
box's rendered area registered as a toggle selection instead. Fixed by giving each segment an
explicit .frame(minHeight: 44) (meeting the guideline with real, rendered space instead of
invisible overflow) plus .contentShape(Rectangle()) (pinning the hit area to exactly that
rendered frame, no ambiguity) and increasing the toggle-to-search-field gap to 14pt. The lesson
generalizes: any control sized visually smaller than 44pt tall is a latent hit-testing hazard for
whatever sits close beneath it, regardless of how much space looks sufficient on screen.
- Tap Test's live reader session only works on a physical device with a paid-team-signed build —
NFCTagReaderSession.readingAvailableis alwaysfalsein the Simulator, which is exactly what drove this app's own NFC-unsupported-state screenshots during development. - A Core NFC session shows a system-drawn "Hold Near the Top of iPhone" sheet for as long as
it's active, and times out after roughly 60 seconds with no API to extend it — see
TapReaderModeController's doc comment for howretry()andrestartPolling()work within that constraint.