diff --git a/Strand/App/ShortcutHealthImportAlert.swift b/Strand/App/ShortcutHealthImportAlert.swift new file mode 100644 index 0000000000..73db337e25 --- /dev/null +++ b/Strand/App/ShortcutHealthImportAlert.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct ShortcutHealthImportAlert: ViewModifier { + let model: AppModel + @State private var pending: ShortcutHealthImport.PendingImport? + + private var isPresented: Binding { + Binding(get: { pending != nil }, set: { showing in + if !showing { model.cancelPendingHealthImport() } + }) + } + + func body(content: Content) -> some View { + content + .onReceive(model.$pendingShortcutHealthImport.removeDuplicates()) { pending = $0 } + .alert("Import Apple Health data?", isPresented: isPresented) { + Button("Import") { model.confirmPendingHealthImport() } + Button("Cancel", role: .cancel) { model.cancelPendingHealthImport() } + } message: { + if let pending { + Text("A Shortcut wants to add \(pending.daysCount) days and \(pending.workoutsCount) workouts to the Apple Health import source.") + } else { + Text("A Shortcut wants to add data to the Apple Health import source.") + } + } + } +} diff --git a/Strand/App/StrandApp.swift b/Strand/App/StrandApp.swift index 588e83a2e4..ef5499da53 100644 --- a/Strand/App/StrandApp.swift +++ b/Strand/App/StrandApp.swift @@ -26,9 +26,11 @@ struct StrandApp: App { let router = NavRouter() _router = StateObject(wrappedValue: router) NotificationPresenter.shared.onCoachBriefTapped = { [weak router] in router?.openCoach() } + _model = State(initialValue: AppModel()) } - @StateObject private var model = AppModel() + // Own the model here; live-value views observe it without invalidating every scene on a pulse. + @State private var model: AppModel /// Shared cross-screen navigation hook (e.g. Live → Devices). The macOS shell (`RootView`) /// observes it and drives the sidebar selection. @StateObject private var router: NavRouter diff --git a/StrandTests/ShortcutHealthImportAlertTests.swift b/StrandTests/ShortcutHealthImportAlertTests.swift new file mode 100644 index 0000000000..66d5e07331 --- /dev/null +++ b/StrandTests/ShortcutHealthImportAlertTests.swift @@ -0,0 +1,122 @@ +import AppKit +import SwiftUI +import XCTest +import WhoopStore +@testable import Strand + +@MainActor +final class ShortcutHealthImportAlertTests: XCTestCase { + // Build each test store before AppModel's startup task can open the default database. + func testPulsesUpdateTheLeafWithoutRebuildingItsOwnerAndCancelKeepsTheStoreEmpty() async throws { + let previousModel = AppModel.shared + defer { AppModel.shared = previousModel } + let store = try await WhoopStore.inMemory() + let model = AppModel() + model.repo.setStoreForTesting(store) + let counts = Counts() + let window = host(model, counts: counts) + defer { window.orderOut(nil) } + try await wait { counts.leaf > 0 } + let ownerBefore = counts.owner + for bpm in 70...81 { + model.bpm = bpm + try await wait { counts.bpm == bpm } + } + XCTAssertEqual(counts.owner, ownerBefore) + XCTAssertGreaterThanOrEqual(counts.leaf, 12) + XCTAssertNil(window.attachedSheet) + + model.handleHealthImportURL(importURL) + try await wait { window.attachedSheet != nil } + let rows = try await store.appleDaily(deviceId: "apple-health", from: "2026-06-01", to: "2026-06-01") + XCTAssertTrue(rows.isEmpty, "Staging must not import before confirmation") + try button("Cancel", in: window).performClick(nil) + try await wait { window.attachedSheet == nil && model.pendingShortcutHealthImport == nil } + let afterCancel = try await store.appleDaily(deviceId: "apple-health", from: "2026-06-01", to: "2026-06-01") + XCTAssertTrue(afterCancel.isEmpty) + } + + func testAlreadyStagedImportIsPresentedAndConfirmationWritesThePreparedPayload() async throws { + let previousModel = AppModel.shared + defer { AppModel.shared = previousModel } + let store = try await WhoopStore.inMemory() + let model = AppModel() + model.repo.setStoreForTesting(store) + model.handleHealthImportURL(importURL) + let window = host(model, counts: Counts()) + defer { window.orderOut(nil) } + try await wait { window.attachedSheet != nil } + try button("Import", in: window).performClick(nil) + try await wait { window.attachedSheet == nil && model.pendingShortcutHealthImport == nil } + for _ in 0..<60 { + let rows = try await store.appleDaily(deviceId: "apple-health", from: "2026-06-01", to: "2026-06-01") + if !rows.isEmpty { + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows.first?.steps, 1_000) + return + } + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTFail("The confirmed payload was not imported: \(model.appleHealthImportSummary ?? "no import started")") + } + + private var importURL: URL { + let payload = Data("D,2026-06-01,1000,,,,,,,".utf8).base64EncodedString() + var url = URLComponents() + url.scheme = "noop" + url.host = "import-health" + url.queryItems = [URLQueryItem(name: "v", value: "1"), URLQueryItem(name: "payload", value: payload)] + return url.url! + } + + private func host(_ model: AppModel, counts: Counts) -> NSWindow { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 200), + styleMask: [.titled], backing: .buffered, defer: false) + window.contentView = NSHostingView(rootView: Owner(model: model, counts: counts) + .environment(\.locale, Locale(identifier: "en"))) + window.makeKeyAndOrderFront(nil) + return window + } + + private func wait(_ condition: () -> Bool) async throws { + for _ in 0..<60 { + if condition() { return } + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTFail("SwiftUI did not settle") + } + + private func button(_ title: String, in window: NSWindow) throws -> NSButton { + func find(_ view: NSView) -> NSButton? { + if let button = view as? NSButton, button.title == title { return button } + return view.subviews.lazy.compactMap(find).first + } + return try XCTUnwrap(window.attachedSheet?.contentView.flatMap(find), "Missing alert button: \(title)") + } + + private final class Counts { + var owner = 0 + var leaf = 0 + var bpm: Int? + } + + private struct Owner: View { + @State var model: AppModel + let counts: Counts + var body: some View { + counts.owner += 1 + return Pulse(counts: counts).environmentObject(model) + .modifier(ShortcutHealthImportAlert(model: model)) + } + } + + private struct Pulse: View { + @EnvironmentObject var model: AppModel + let counts: Counts + var body: some View { + counts.leaf += 1 + counts.bpm = model.bpm + return Text(model.bpm.map(String.init) ?? "—") + } + } +} diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index 41c6829964..36357547da 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -25,11 +25,12 @@ struct RootTabView: View { /// The live gym session, owned at the app root — see `LiftSessionController`. @EnvironmentObject private var liftSession: LiftSessionController + // The shell uses repository actions only; its screens observe the data themselves. + let repo: Repository /// External entry points must wait until the mandatory first-run gates have completed. The root owns /// that state; keeping it explicit here prevents this shell's window-level sheet from covering a gate. let homeScreenQuickActionsEnabled: Bool - @EnvironmentObject private var repo: Repository /// Cross-screen navigation requests (e.g. Live → "Manage devices"). Devices isn't a tab — it lives /// behind the More list — so a request presents it as a sheet, matching the quick-action screens. @EnvironmentObject private var router: NavRouter diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index d505bc60d3..44006875e7 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -16,7 +16,8 @@ import UserNotifications struct StrandiOSApp: App { /// UIKit bridge for Home Screen quick actions. SwiftUI keeps ownership of the scene and window. @UIApplicationDelegateAdaptor(HomeScreenQuickActionAppDelegate.self) private var appDelegate - @StateObject private var model: AppModel + // Own the model here; live-value views observe it without invalidating every scene on a pulse. + @State private var model: AppModel @StateObject private var health: HealthKitBridge /// The phone→watch link. Built + activated here so the watch app actually receives snapshots on a /// real device; without an owner that pushes it, the watch only ever shows placeholder data. @@ -85,7 +86,7 @@ struct StrandiOSApp: App { _router = StateObject(wrappedValue: router) NotificationPresenter.shared.onCoachBriefTapped = { [weak router] in router?.openCoach() } let model = AppModel() - _model = StateObject(wrappedValue: model) + _model = State(initialValue: model) CoachBriefScheduler.register(generateBrief: { [weak coach = model.coach] in await coach?.generateBrief() }, log: { [weak model] line in @@ -177,48 +178,9 @@ struct StrandiOSApp: App { } } - /// The Shortcut-import alert's presentation binding, hoisted OUT of the `.alert` chain. - /// - /// An inline `Binding(get:set:)` is two untyped closures the solver must infer in place, on a - /// modifier chain that had already blown the type-check budget. Declaring it as a `Binding` - /// property replaces all of that with one known type. Hoisting the message alone was not enough — - /// the build failed again at the same modifier, which is why this one is here too. - private var healthImportAlertPresented: Binding { - Binding( - get: { model.pendingShortcutHealthImport != nil }, - set: { showing in - if !showing { model.cancelPendingHealthImport() } - } - ) - } - - /// The Shortcut-import alert's buttons, hoisted for the same reason as the binding above. - @ViewBuilder - private var healthImportAlertButtons: some View { - Button("Import") { model.confirmPendingHealthImport() } - Button("Cancel", role: .cancel) { model.cancelPendingHealthImport() } - } - - /// The Shortcut-import alert's message, hoisted OUT of the `.alert` chain. - /// - /// Not a style preference. This closure — an `if let` around two interpolated `Text`s — sits on a - /// modifier chain that grew past the Swift type-checker's budget, and the build failed with - /// "unable to type-check this expression in reasonable time" pointing at `} message: {`. The - /// expression did not change; the chain around it did. Hoisting a sub-expression into its own - /// declaration gives the solver a fixed type to work from instead of one more unknown in a chain - /// it is already struggling with. - @ViewBuilder - private var healthImportAlertMessage: some View { - if let pending = model.pendingShortcutHealthImport { - Text("A Shortcut wants to add \(pending.daysCount) days and \(pending.workoutsCount) workouts to the Apple Health import source.") - } else { - Text("A Shortcut wants to add data to the Apple Health import source.") - } - } - var body: some Scene { WindowGroup { - iOSRootView() + iOSRootView(repo: model.repo) .environmentObject(model) .environmentObject(model.ble) // #334: Today pull-to-sync reads BLEManager (no HR churn) .environmentObject(model.live) @@ -323,11 +285,7 @@ struct StrandiOSApp: App { model.handleHealthImportURL(url) } } - .alert("Import Apple Health data?", isPresented: healthImportAlertPresented) { - healthImportAlertButtons - } message: { - healthImportAlertMessage - } + .modifier(ShortcutHealthImportAlert(model: model)) // Bring the watch link up once at launch (WCSession ignores a redundant activate), then // push the first snapshot so a watch that's already on-wrist gets current scores without // waiting for the next foreground. activate() is idempotent + a no-op where WC isn't @@ -452,6 +410,7 @@ struct StrandiOSApp: App { /// excluded `RootView()` sidebar for `RootTabView()`. The shared `OnboardingWizard`, `TermsGateView`, /// `WhatsNewView`, `AppChangelog`, and `Terms` symbols all compile into the iOS target unchanged. private struct iOSRootView: View { + let repo: Repository @AppStorage("noop.onboarded") private var onboarded = false @AppStorage("noop.lastSeenChangelogVersion") private var lastSeenChangelog = "" @AppStorage("noop.acceptedTermsVersion") private var acceptedTerms = "" @@ -482,7 +441,7 @@ private struct iOSRootView: View { private var shell: some View { ZStack { - RootTabView(homeScreenQuickActionsEnabled: + RootTabView(repo: repo, homeScreenQuickActionsEnabled: demoBypass || (onboarded && acceptedTerms == Terms.currentVersion && automaticLaunchSheetResolved)) if !onboarded && !demoBypass {