diff --git a/Strand/Screens/JournalReminderCard.swift b/Strand/Screens/JournalReminderCard.swift index 464d34a095..47922b8ccb 100644 --- a/Strand/Screens/JournalReminderCard.swift +++ b/Strand/Screens/JournalReminderCard.swift @@ -30,15 +30,21 @@ struct JournalReminderCard: View { private static let stripDays = 7 var body: some View { - Group { - if reminderEnabled, let logged = loggedDays { - card(logged) + if reminderEnabled { + // A VStack, not a Group: a Group hands its modifiers to its children, and before the first load + // there is no child, so the `.task` below never ran, `loggedDays` stayed nil and the card never + // appeared. The stack is a view of its own even while empty, so the load always starts. + VStack(spacing: 0) { + if let logged = loggedDays { + card(logged) + } + } + // Re-read whenever a sync bumps refreshSeq, so the strip and the "logged today" state stay current + // after the user logs and comes back. Flipping the toggle removes and recreates this view, which + // starts a fresh task on its own, so the toggle needs no place in the id. + .task(id: repo.refreshSeq) { + await reload() } - } - // Re-read whenever a sync bumps refreshSeq or the toggle flips (mirrors AutoWorkoutCard's task id), - // so the strip and the "logged today" state stay current after the user logs and comes back. - .task(id: JournalReminderLoadKey(seq: repo.refreshSeq, enabled: reminderEnabled)) { - await reload() } } @@ -141,9 +147,3 @@ struct JournalReminderCard: View { } } } - -/// Reload key: a sync (seq) or toggle flip re-reads completion. Mirrors `AutoWorkoutLoadKey`. -private struct JournalReminderLoadKey: Equatable { - let seq: Int - let enabled: Bool -} diff --git a/Strand/Screens/SkinTempCardsView.swift b/Strand/Screens/SkinTempCardsView.swift index 7852ad3ca0..243b2f3a7d 100644 --- a/Strand/Screens/SkinTempCardsView.swift +++ b/Strand/Screens/SkinTempCardsView.swift @@ -286,14 +286,25 @@ struct MenstrualCycleHomeCard: View { /// The user's "not for me" opt-out (#hide-cycle): when set, the card is suppressed entirely, /// reversibly from Automations. USER-controlled, never age-based. @AppStorage(AppModel.cycleAwarenessHiddenKey) private var cycleHidden = false - @State private var starts: [String] = [] + /// Logged period starts, loaded outside the view tree (see `PeriodStartsLoader` for why a `.task` here + /// could never start while the card was hidden). + @StateObject private var loader = PeriodStartsLoader() @State private var showingTracker = false + private var starts: [String] { loader.starts } + private var shouldShow: Bool { !cycleHidden && (profile.cycleAwarenessApplies || cycleEnabled || !starts.isEmpty) } var body: some View { + // Re-read whenever a period start is logged or deleted. The body re-runs on that (it observes `repo`), + // and the loader ignores an unchanged seq. + let _ = loader.request(seq: repo.cycleTrackingSeq) { [repo, model, cycleEnabled] in + let starts = await repo.periodStarts() + if cycleEnabled, model.cyclePhase == nil { await model.refreshV5Signals() } + return starts + } Group { if shouldShow { NoopCard(tint: StrandPalette.restColor) { @@ -375,10 +386,6 @@ struct MenstrualCycleHomeCard: View { .accessibilityElement(children: .contain) } } - .task(id: repo.cycleTrackingSeq) { - starts = await repo.periodStarts() - if cycleEnabled, model.cyclePhase == nil { await model.refreshV5Signals() } - } .sheet(isPresented: $showingTracker) { Group { if let result = model.cyclePhase { @@ -430,6 +437,31 @@ struct MenstrualCycleHomeCard: View { } } +/// Loads the logged period starts for `MenstrualCycleHomeCard` outside the view tree. One reason to show +/// the card is that starts exist, and SwiftUI never runs a `.task` attached to a `Group` with no child, so +/// with cycle awareness off and the toggle off the load never started and the card never appeared. That +/// empty state is permanent for most users, so an always-present placeholder would hold a slot in the +/// Today stack and add its spacing; the view only reports the current seq here and this object loads. +@MainActor +final class PeriodStartsLoader: ObservableObject { + @Published private(set) var starts: [String] = [] + private var lastSeq: Int? + private var load: Task? + + /// Called from the card's body on every pass; starts a load only when the seq changed. Publishes + /// later, from the task, never during the view update. + func request(seq: Int, load fetch: @escaping @MainActor () async -> [String]) { + guard seq != lastSeq else { return } + lastSeq = seq + load?.cancel() + load = Task { [weak self] in + let next = await fetch() + guard !Task.isCancelled, let self else { return } + self.starts = next + } + } +} + // MARK: - Cycle tracker detail /// Local-only logged-period detail. It deliberately records only cycle day 1: the engine needs an