From 6226b3b08803d20f77843ec0599750baca1e870d Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:23:55 +0200 Subject: [PATCH] fix(today): run the auto-detect scan, so "looks like a workout?" can appear AutoWorkoutCard ran its scan from a .task on a Group whose only child is the card, shown once a candidate exists. SwiftUI never runs a .task attached to a Group with no child, so the scan never started and the prompt never appeared on any Today screen, even with "Auto-detect workouts" switched on. A placeholder view would start the task but is no fix here: whenever the detector finds nothing it would sit in the Today stack and add its spacing. The scan now runs in AutoWorkoutCandidateLoader, an ObservableObject the card's body hands the current key (refresh sequence, toggle); it ignores an unchanged key and publishes from its task, never during the view update. --- Strand/Screens/AutoWorkoutCard.swift | 70 +++++++++++++++++++--------- 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/Strand/Screens/AutoWorkoutCard.swift b/Strand/Screens/AutoWorkoutCard.swift index 8938320204..b6cbef752c 100644 --- a/Strand/Screens/AutoWorkoutCard.swift +++ b/Strand/Screens/AutoWorkoutCard.swift @@ -20,22 +20,20 @@ struct AutoWorkoutCard: View { /// Whether the toggle is on. Read here too so the card disappears the instant it's switched off. @AppStorage(PuffinExperiment.autoDetectWorkoutsKey) private var autoDetectEnabled = false - /// The current suggestion, loaded in `.task`. nil → nothing to show. - @State private var candidate: DetectedWorkout? - /// Hide immediately on Save/X without waiting for the next reload (avoids a flash of the old card). - @State private var handledThisSession = false + /// The current suggestion and the session guard, owned outside the view tree (see + /// `AutoWorkoutCandidateLoader` for why a `.task` here could never start). + @StateObject private var loader = AutoWorkoutCandidateLoader() /// Guards the Save button while the write is in flight. @State private var saving = false var body: some View { - Group { - if autoDetectEnabled, !handledThisSession, let w = candidate { - card(for: w) - } + // Re-scan whenever the data refreshes (a sync bumps refreshSeq) or the toggle flips. The body + // re-runs on both (it observes `repo` and the toggle), and the loader ignores an unchanged key. + let _ = loader.request(AutoWorkoutLoadKey(seq: repo.refreshSeq, enabled: autoDetectEnabled)) { [repo] in + await repo.autoDetectCandidate() } - // Re-scan whenever the data refreshes (a sync bumps refreshSeq) or the toggle flips on. - .task(id: AutoWorkoutLoadKey(seq: repo.refreshSeq, enabled: autoDetectEnabled)) { - await reload() + if autoDetectEnabled, !loader.handled, let w = loader.candidate { + card(for: w) } } @@ -106,17 +104,9 @@ struct AutoWorkoutCard: View { return String(localized: "Looks like a workout on \(Self.dateFmt.string(from: startDate)) around \(start)-\(end) (avg HR \(w.avgBpm), \(w.durationMin) min). Save it?") } - private func reload() async { - guard autoDetectEnabled else { candidate = nil; return } - let next = await repo.autoDetectCandidate() - // A fresh scan resets the session guard so a NEW window can surface after one is handled. - if next != candidate { handledThisSession = false } - candidate = next - } - private func save(_ w: DetectedWorkout) { saving = true - handledThisSession = true + loader.markHandled() Task { _ = await repo.saveDetectedWorkout(w) await repo.refresh() // surfaces the new workout + drops it from re-suggestion @@ -126,8 +116,7 @@ struct AutoWorkoutCard: View { private func dismiss(_ w: DetectedWorkout) { repo.dismissDetectedSuggestion(w) - handledThisSession = true - candidate = nil + loader.dismiss() } /// HH:mm in the user's locale/timezone. @@ -150,3 +139,40 @@ private struct AutoWorkoutLoadKey: Equatable { let seq: Int let enabled: Bool } + +/// Loads the detector's suggestion for `AutoWorkoutCard` outside the view tree. The card is empty until +/// a suggestion exists, and SwiftUI never runs a `.task` attached to a `Group` with no child, so the +/// scan never started and the card never appeared. Any always-present placeholder would start the task +/// but take a slot in the dashboard's stack and add its spacing, so the view only reports the current +/// key here and this object runs the scan. +@MainActor +final class AutoWorkoutCandidateLoader: ObservableObject { + @Published private(set) var candidate: DetectedWorkout? + /// Hide immediately on Save/X without waiting for the next scan (avoids a flash of the old card). + @Published private(set) var handled = false + private var lastKey: AutoWorkoutLoadKey? + private var scan: Task? + + /// Called from the card's body on every pass; starts a scan only when the key changed. Publishes + /// later, from the task, never during the view update. + fileprivate func request(_ key: AutoWorkoutLoadKey, + load: @escaping @MainActor () async -> DetectedWorkout?) { + guard key != lastKey else { return } + lastKey = key + scan?.cancel() + scan = Task { [weak self] in + let next = key.enabled ? await load() : nil + guard !Task.isCancelled, let self else { return } + // A fresh scan resets the session guard so a NEW window can surface after one is handled. + if next != self.candidate { self.handled = false } + self.candidate = next + } + } + + func markHandled() { handled = true } + + func dismiss() { + handled = true + candidate = nil + } +}