Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions Strand/App/ShortcutHealthImportAlert.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import SwiftUI

struct ShortcutHealthImportAlert: ViewModifier {
let model: AppModel
@State private var pending: ShortcutHealthImport.PendingImport?

private var isPresented: Binding<Bool> {
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.")
}
}
}
}
4 changes: 3 additions & 1 deletion Strand/App/StrandApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions StrandTests/ShortcutHealthImportAlertTests.swift
Original file line number Diff line number Diff line change
@@ -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) ?? "—")
}
}
}
3 changes: 2 additions & 1 deletion StrandiOS/App/RootTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 7 additions & 48 deletions StrandiOS/App/StrandiOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Bool>`
/// 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<Bool> {
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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 {
Expand Down
Loading