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