From 637cb7ffa3bc366940d754251c319eb36fbba73a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 18:19:09 -0300 Subject: [PATCH 1/2] fix(ios): match the Ring QR sheet to Android and fill its background The sheet painted its colour with .background on the content, so the rest of the .large detent showed system material. Use .presentationBackground and size the detent to the content. Layout now follows RingScanHandoff.kt: card surface, 220pt code, no spinner, text Copy link, Get the app only without Ring, and no Cancel since dragging the sheet already cancels. Co-Authored-By: Claude Opus 5.5 (1M context) --- iosApp/iosApp/Views/OnboardingScreen.swift | 3 +- iosApp/iosApp/Views/RingScanSheet.swift | 186 ++++++++++++++------- 2 files changed, 125 insertions(+), 64 deletions(-) diff --git a/iosApp/iosApp/Views/OnboardingScreen.swift b/iosApp/iosApp/Views/OnboardingScreen.swift index 2ef5004b..6e5ec759 100644 --- a/iosApp/iosApp/Views/OnboardingScreen.swift +++ b/iosApp/iosApp/Views/OnboardingScreen.swift @@ -78,8 +78,7 @@ struct OnboardingScreen: View { ringInstalledHere: scanPrompt.ringInstalledHere, stillWaiting: scanPrompt.stillWaiting, onOpenRingHere: scanPrompt.onOpenRingHere, - onGetRing: scanPrompt.onGetRing, - onCancel: scanPrompt.onCancel + onGetRing: scanPrompt.onGetRing ) } } diff --git a/iosApp/iosApp/Views/RingScanSheet.swift b/iosApp/iosApp/Views/RingScanSheet.swift index 5b291782..c7545c4e 100644 --- a/iosApp/iosApp/Views/RingScanSheet.swift +++ b/iosApp/iosApp/Views/RingScanSheet.swift @@ -30,90 +30,152 @@ struct RingScanPanel: View { var onGetRing: () -> Void var onCancel: () -> Void - @State private var didCopy = false - var body: some View { - VStack(spacing: 20) { - Text("onboarding_qr_title") - .font(.title2.bold()) - .foregroundStyle(LoopkyColor.foregroundPrimary) - .multilineTextAlignment(.center) + RingScanContent( + authUrl: authUrl, + message: ringInstalledHere ? "onboarding_qr_body" : "onboarding_qr_sheet_body", + stillWaiting: stillWaiting + ) { + if ringInstalledHere { + Button("onboarding_qr_open_here", action: onOpenRingHere) + .buttonStyle(.loopkySoft) + .accessibilityIdentifier("onboarding_qr_open_here") + } + CopyLinkButton(authUrl: authUrl) + + Button("onboarding_get_ring", action: onGetRing) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(LoopkyColor.accentPrimary) + .accessibilityIdentifier("onboarding_qr_get_ring") - Text(ringInstalledHere ? "onboarding_qr_body" : "onboarding_qr_sheet_body") - .font(.subheadline) + // The panel's only way out: it replaces the sign-in buttons rather than covering them. + Button("onboarding_qr_cancel", action: onCancel) + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(LoopkyColor.foregroundMuted) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("onboarding_qr_cancel") + } + .padding(.horizontal, 24) + .padding(.vertical, 28) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 28, style: .continuous).fill(LoopkyColor.surfaceCard) + ) + } +} - QrCodeView(text: authUrl) +/// The phone presentation of [RingScanPanel]: a native sheet with detents, so it gets the system's +/// drag-to-dismiss and Liquid Glass chrome on the iOS 26 SDK for free. +/// +/// There is no Cancel button, matching Android: dragging the sheet away already lands on +/// `onCancelSignIn` through the presenting binding, so a third control would restate the gesture. +struct RingScanSheet: View { + let authUrl: String + let ringInstalledHere: Bool + var stillWaiting: Bool = false + var onOpenRingHere: () -> Void + var onGetRing: () -> Void - // In place of the waiting line, not under it, so the panel does not grow and push - // Cancel — the way out the note points to — below the fold. - HStack(spacing: 8) { - ProgressView().controlSize(.small) - if stillWaiting { - StillWaitingNote() - } else { - Text("onboarding_qr_waiting") - .font(.footnote) - .foregroundStyle(LoopkyColor.foregroundMuted) - } - } + @State private var contentHeight: CGFloat = 0 - VStack(spacing: 10) { + var body: some View { + // A QR big enough to scan plus its controls does not fit a short phone, and a VStack that + // overflows a sheet clips in silence. + ScrollView { + RingScanContent( + authUrl: authUrl, + message: ringInstalledHere ? "onboarding_qr_body" : "onboarding_qr_sheet_body", + stillWaiting: stillWaiting + ) { if ringInstalledHere { Button("onboarding_qr_open_here", action: onOpenRingHere) .buttonStyle(.loopkySoft) + .accessibilityIdentifier("onboarding_qr_open_here") } - Button(didCopy ? "onboarding_qr_copied" : "onboarding_qr_copy") { - UIPasteboard.general.string = authUrl - didCopy = true + CopyLinkButton(authUrl: authUrl) + if !ringInstalledHere { + Button("onboarding_get_ring", action: onGetRing) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(LoopkyColor.accentPrimary) + .accessibilityIdentifier("onboarding_qr_get_ring") } - .buttonStyle(.loopkyOutline) + } + .padding(.horizontal, 24) + .padding(.top, 32) + .padding(.bottom, 24) + .frame(maxWidth: .infinity) + // The code is a single-task screen, so it keeps a focused measure on a regular width + // rather than spreading a 220pt QR across a form sheet. + .contentPane(PaneWidth.focused) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { contentHeight = $0 } + } + .scrollBounceBehavior(.basedOnSize) + // On the presentation, not the content: a `.background` on the content only paints as far + // as the content reaches, and the rest of the `.large` detent showed the system material. + .presentationBackground(LoopkyColor.surfaceCard) + // Sized to the content, like Android's sheet wrapping its column. The system clamps a + // detent taller than the screen to full height, where the ScrollView takes over. + .presentationDetents(contentHeight > 0 ? [.height(contentHeight)] : [.large]) + .presentationDragIndicator(.visible) + .accessibilityIdentifier("onboarding_ring_qr_sheet") + } +} - Button("onboarding_get_ring", action: onGetRing) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(LoopkyColor.accentPrimary) - .accessibilityIdentifier("onboarding_qr_get_ring") +/// The handoff itself, shared by the iPad panel and the phone sheet so the two cannot drift. Only +/// the body copy and the trailing controls differ, so both are passed in. +private struct RingScanContent: View { + let authUrl: String + let message: LocalizedStringKey + let stillWaiting: Bool + @ViewBuilder let actions: Actions - Button("onboarding_qr_cancel", action: onCancel) - .font(.subheadline) + var body: some View { + VStack(spacing: 16) { + Text("onboarding_qr_title") + .font(.system(size: 20, weight: .heavy)) + .foregroundStyle(LoopkyColor.foregroundPrimary) + .multilineTextAlignment(.center) + + Text(message) + .font(.system(size: 14)) + .lineSpacing(4) + .foregroundStyle(LoopkyColor.foregroundSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + QrCodeView(text: authUrl, size: 220) + + // In place of the waiting line, not under it, so the panel does not grow and push + // Cancel, the way out the note points to, below the fold. + if stillWaiting { + StillWaitingNote() + } else { + Text("onboarding_qr_waiting") + .font(.system(size: 13)) .foregroundStyle(LoopkyColor.foregroundMuted) + .multilineTextAlignment(.center) } + + actions } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("onboarding_ring_qr") } } -/// The phone presentation of [RingScanPanel]: a native sheet with detents, so it gets the system's -/// drag-to-dismiss and Liquid Glass chrome on the iOS 26 SDK for free. -struct RingScanSheet: View { +/// The escape hatch for a camera that will not read the code: the same one-shot URL, on the +/// clipboard, to be pasted into Ring by hand. +private struct CopyLinkButton: View { let authUrl: String - let ringInstalledHere: Bool - var stillWaiting: Bool = false - var onOpenRingHere: () -> Void - var onGetRing: () -> Void - var onCancel: () -> Void + @State private var didCopy = false var body: some View { - RingScanPanel( - authUrl: authUrl, - ringInstalledHere: ringInstalledHere, - stillWaiting: stillWaiting, - onOpenRingHere: onOpenRingHere, - onGetRing: onGetRing, - onCancel: onCancel - ) - .padding(24) - .frame(maxWidth: .infinity) - // The code is a single-task screen, so it keeps a focused measure on a regular width - // rather than spreading a 200pt QR across a form sheet. - .contentPane(PaneWidth.focused) - .background(LoopkyColor.surfacePrimary) - .presentationDetents([.large]) - .presentationDragIndicator(.visible) - // Dragging the sheet away is the same intent as tapping Cancel: back out without leaving - // an error behind. Without this the authorisation would keep polling behind a gone sheet. - .interactiveDismissDisabled(false) + Button(didCopy ? "onboarding_qr_copied" : "onboarding_qr_copy") { + UIPasteboard.general.string = authUrl + didCopy = true + } + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(LoopkyColor.accentSecondary) + .accessibilityIdentifier("onboarding_qr_copy") } } From cfa20b61d9e734a621e0a9f8c8eef01e9cc96f9a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 18:19:09 -0300 Subject: [PATCH 2/2] docs(journeys): record the iOS Ring QR sheet run Co-Authored-By: Claude Opus 5.5 (1M context) --- journeys/RESULTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/journeys/RESULTS.md b/journeys/RESULTS.md index 537f40e1..3ff177c1 100644 --- a/journeys/RESULTS.md +++ b/journeys/RESULTS.md @@ -153,6 +153,16 @@ Tablet and iOS are untouched by this: the panel already showed the code and alre on `ringInstalledHere`. iOS was not re-driven — no Mac in this session — but its only change is dropping the `deeplinkFired` guard that suppressed the sheet. +### 2026-09-24 — iOS sheet matches Android's — ✅ PASS (iPhone 17e, iPad Pro 11-inch (M5) portrait) + +The iOS QR sheet now follows `RingScanHandoff.kt`: card surface, 220pt code on a white plate, no +spinner, text-style Copy link, "Get the app" only without Ring, and no Cancel (a drag dismisses and +cancels, as before). Two fixes: the sheet's colour was a `.background` on the content, so the +`.large` detent showed system material below it; it is now `.presentationBackground`. And the +detent is sized to the content instead of `.large`. Checked in light and dark on the iPhone, and as +a form sheet on the iPad in portrait. Not driven: the iPad's inline panel in landscape (no rotation +from `xcodebuildmcp`), which now draws as a card like Android's tablet panel. + ## 02 — Paste-to-Import → triage → publish — ✅ PASS Re-verified on `emulator-5554` 2026-06-17 after adding the triage step + card options.