Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-01 16:55:16 -06:00
parent baedb98b2b
commit 08fef9ffe3
10 changed files with 276 additions and 157 deletions

View File

@ -11,6 +11,9 @@
"-%lld%% vs last week" : { "-%lld%% vs last week" : {
"comment" : "A description of how a user's usage has changed compared to the previous week. The argument is the percentage by which the usage has increased or decreased.", "comment" : "A description of how a user's usage has changed compared to the previous week. The argument is the percentage by which the usage has increased or decreased.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
":" : {
}, },
"%@ %@" : { "%@ %@" : {
"comment" : "A subline of text showing the start and end dates of an arc.", "comment" : "A subline of text showing the start and end dates of an arc.",
@ -319,10 +322,32 @@
"comment" : "A placeholder text for a text field that allows users to input the name of a new habit.", "comment" : "A placeholder text for a text field that allows users to input the name of a new habit.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Add Andromida to your Home Screen for quick check-ins." : {
"comment" : "Description on the widget discovery card in onboarding.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Add Andromida to your Home Screen for quick check-ins."
}
}
}
},
"Add notes or reminders..." : { "Add notes or reminders..." : {
"comment" : "A placeholder text for a text field where the user can add notes or reminders.", "comment" : "A placeholder text for a text field where the user can add notes or reminders.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Add the widget" : {
"comment" : "Title for the widget setup sheet in onboarding.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Add the widget"
}
}
}
},
"Add to My Rituals" : { "Add to My Rituals" : {
"comment" : "A button label that says \"Add to My Rituals\".", "comment" : "A button label that says \"Add to My Rituals\".",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -1271,6 +1296,17 @@
"comment" : "Title of the history view.", "comment" : "Title of the history view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"How to add" : {
"comment" : "CTA button label to show widget setup steps.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "How to add"
}
}
}
},
"Hydrate" : { "Hydrate" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -1383,6 +1419,17 @@
"comment" : "Habit title for keeping the bedroom cool at night.", "comment" : "Habit title for keeping the bedroom cool at night.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Keep your rituals visible at a glance." : {
"comment" : "Subtitle for the widget setup sheet in onboarding.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Keep your rituals visible at a glance."
}
}
}
},
"Last arc completed with %lld%% habit completion over %lld days." : { "Last arc completed with %lld%% habit completion over %lld days." : {
"comment" : "A caption that provides details about the last arc of a ritual, including the number of days it lasted and the percentage of habit completions.", "comment" : "A caption that provides details about the last arc of a ritual, including the number of days it lasted and the percentage of habit completions.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -2103,6 +2150,17 @@
"comment" : "A label displayed above the ritual's scheduling information.", "comment" : "A label displayed above the ritual's scheduling information.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Search for Andromida and pick a size." : {
"comment" : "Widget setup step: search for the app and choose size.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Search for Andromida and pick a size."
}
}
}
},
"Search icons" : { "Search icons" : {
"comment" : "A placeholder text for a search bar in an icon picker sheet.", "comment" : "A placeholder text for a search bar in an icon picker sheet.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -2158,6 +2216,10 @@
}, },
"Shows rituals for the current time of day. Check in here daily." : { "Shows rituals for the current time of day. Check in here daily." : {
},
"Simulate Foreground Refresh" : {
"comment" : "Title of a settings option that simulates a foreground refresh of the app.",
"isCommentAutoGenerated" : true
}, },
"Skip" : { "Skip" : {
"comment" : "Button label to skip onboarding.", "comment" : "Button label to skip onboarding.",
@ -2357,6 +2419,17 @@
} }
} }
}, },
"Tap Edit, then Add Widget." : {
"comment" : "Widget setup step: tap Edit and Add Widget.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Tap Edit, then Add Widget."
}
}
}
},
"Tap for details" : { "Tap for details" : {
"comment" : "A hint that appears when a user taps on an element to learn more about it.", "comment" : "A hint that appears when a user taps on an element to learn more about it.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -2503,6 +2576,17 @@
"Total Check-ins" : { "Total Check-ins" : {
"comment" : "Title for an insight card showing the total number of habits completed all-time." "comment" : "Title for an insight card showing the total number of habits completed all-time."
}, },
"Touch and hold your Home Screen." : {
"comment" : "Widget setup step: long-press Home Screen.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Touch and hold your Home Screen."
}
}
}
},
"Track your streaks, progress, and trends over time." : { "Track your streaks, progress, and trends over time." : {
"comment" : "Description of a feature card in the \"WhatsNextStepView\" that explains how to use the app's insights feature.", "comment" : "Description of a feature card in the \"WhatsNextStepView\" that explains how to use the app's insights feature.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -2618,6 +2702,17 @@
} }
} }
}, },
"Widgets" : {
"comment" : "Title for the widgets discovery card in onboarding.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Widgets"
}
}
}
},
"Wind down with a reminder when it's time for your evening ritual" : { "Wind down with a reminder when it's time for your evening ritual" : {
"comment" : "Description for notification permission screen when user selected evening rituals." "comment" : "Description for notification permission screen when user selected evening rituals."
}, },
@ -2734,94 +2829,6 @@
} }
} }
} }
},
"Widgets" : {
"comment" : "Title for the widgets discovery card in onboarding.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Widgets"
}
}
}
},
"Add Andromida to your Home Screen for quick check-ins." : {
"comment" : "Description on the widget discovery card in onboarding.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Add Andromida to your Home Screen for quick check-ins."
}
}
}
},
"How to add" : {
"comment" : "CTA button label to show widget setup steps.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "How to add"
}
}
}
},
"Add the widget" : {
"comment" : "Title for the widget setup sheet in onboarding.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Add the widget"
}
}
}
},
"Keep your rituals visible at a glance." : {
"comment" : "Subtitle for the widget setup sheet in onboarding.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Keep your rituals visible at a glance."
}
}
}
},
"Touch and hold your Home Screen." : {
"comment" : "Widget setup step: long-press Home Screen.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Touch and hold your Home Screen."
}
}
}
},
"Tap Edit, then Add Widget." : {
"comment" : "Widget setup step: tap Edit and Add Widget.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Tap Edit, then Add Widget."
}
}
}
},
"Search for Andromida and pick a size." : {
"comment" : "Widget setup step: search for the app and choose size.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Search for Andromida and pick a size."
}
}
}
} }
}, },
"version" : "1.1" "version" : "1.1"

View File

@ -26,6 +26,7 @@ final class RitualStore: RitualStoreProviding {
private var pendingReminderTask: Task<Void, Never>? private var pendingReminderTask: Task<Void, Never>?
private var insightCardsNeedRefresh = true private var insightCardsNeedRefresh = true
private var cachedInsightCards: [InsightCard] = [] private var cachedInsightCards: [InsightCard] = []
private var lastRefreshDate: Date?
/// Reminder scheduler for time-slot based notifications /// Reminder scheduler for time-slot based notifications
let reminderScheduler = ReminderScheduler() let reminderScheduler = ReminderScheduler()
@ -105,6 +106,16 @@ final class RitualStore: RitualStoreProviding {
reloadRituals() reloadRituals()
checkForCompletedArcs() checkForCompletedArcs()
} }
/// Refreshes rituals if the last refresh was beyond the minimum interval.
func refreshIfNeeded(minimumInterval: TimeInterval = 5) {
let now = Date()
if let lastRefreshDate, now.timeIntervalSince(lastRefreshDate) < minimumInterval {
return
}
lastRefreshDate = now
refresh()
}
func ritualProgress(for ritual: Ritual) -> Double { func ritualProgress(for ritual: Ritual) -> Double {
let habits = ritual.habits let habits = ritual.habits

View File

@ -150,6 +150,7 @@ struct HistoryView: View {
refreshToken = UUID() refreshToken = UUID()
} }
.onAppear { .onAppear {
store.refreshIfNeeded()
refreshProgressCache() refreshProgressCache()
} }
.sheet(item: $selectedDateItem) { item in .sheet(item: $selectedDateItem) { item in

View File

@ -80,6 +80,7 @@ struct InsightsView: View {
} }
} }
.onAppear { .onAppear {
store.refreshIfNeeded()
cardOrder = store.insightCardOrder cardOrder = store.insightCardOrder
Task { Task {
await Task.yield() await Task.yield()

View File

@ -125,6 +125,9 @@ struct RitualsView: View {
.onChange(of: store.rituals) { _, _ in .onChange(of: store.rituals) { _, _ in
refreshToken = UUID() refreshToken = UUID()
} }
.onAppear {
store.refreshIfNeeded()
}
} }
// MARK: - Current Tab Content // MARK: - Current Tab Content
@ -141,7 +144,7 @@ struct RitualsView: View {
// Time of day header // Time of day header
HStack(spacing: Design.Spacing.small) { HStack(spacing: Design.Spacing.small) {
SymbolIcon(group.timeOfDay.symbolName, size: .inline, color: AppAccent.primary) SymbolIcon(group.timeOfDay.symbolName, size: .inline, color: AppAccent.primary)
Text(group.timeOfDay.displayName).styled(.subheadingEmphasis, emphasis: .secondary) Text(group.timeOfDay.displayNameWithRange).styled(.subheadingEmphasis, emphasis: .secondary)
} }
.padding(.top, Design.Spacing.small) .padding(.top, Design.Spacing.small)

View File

@ -106,22 +106,29 @@ struct PresetLibrarySheet: View {
Spacer() Spacer()
VStack(alignment: .trailing, spacing: Design.Spacing.xSmall) { VStack(alignment: .trailing, spacing: Design.Spacing.xSmall) {
SymbolIcon(preset.timeOfDay.symbolName, size: .badge, color: AppTextColors.tertiary)
Text(String(localized: "\(preset.habits.count) habits")).styled(.caption, emphasis: .tertiary) Text(String(localized: "\(preset.habits.count) habits")).styled(.caption, emphasis: .tertiary)
} }
} }
// Habit preview // Habit preview and Time of Day
HStack(spacing: Design.Spacing.small) { HStack(alignment: .center, spacing: Design.Spacing.small) {
ForEach(preset.habits.prefix(4)) { habit in // Habit icons
SymbolIcon(habit.symbolName, size: .badge, color: AppTextColors.tertiary) HStack(spacing: Design.Spacing.small) {
ForEach(preset.habits.prefix(4)) { habit in
SymbolIcon(habit.symbolName, size: .badge, color: AppTextColors.tertiary)
}
if preset.habits.count > 4 {
Text("+\(preset.habits.count - 4)").styled(.caption2, emphasis: .tertiary)
}
} }
if preset.habits.count > 4 { Spacer()
Text("+\(preset.habits.count - 4)").styled(.caption2, emphasis: .tertiary)
} // Time of Day pill
timeOfDayPill(for: preset.timeOfDay)
} }
.padding(.top, Design.Spacing.small)
} }
.padding(Design.Spacing.large) .padding(Design.Spacing.large)
.background(AppSurface.card) .background(AppSurface.card)
@ -187,7 +194,7 @@ struct PresetDetailSheet: View {
.foregroundStyle(AppTextColors.secondary) .foregroundStyle(AppTextColors.secondary)
HStack(spacing: Design.Spacing.large) { HStack(spacing: Design.Spacing.large) {
Label(preset.timeOfDay.displayName, systemImage: preset.timeOfDay.symbolName) Label(preset.timeOfDay.displayNameWithRange, systemImage: preset.timeOfDay.symbolName)
Label(String(localized: "\(preset.durationDays) days"), systemImage: "calendar") Label(String(localized: "\(preset.durationDays) days"), systemImage: "calendar")
} }
.typography(.caption) .typography(.caption)
@ -260,3 +267,40 @@ struct PresetDetailSheet: View {
#Preview { #Preview {
PresetLibrarySheet(store: RitualStore.preview) PresetLibrarySheet(store: RitualStore.preview)
} }
extension PresetLibrarySheet {
private func timeOfDayPill(for timeOfDay: TimeOfDay) -> some View {
HStack(spacing: 4) {
Image(systemName: timeOfDay.symbolName)
.font(.system(size: 10))
Text(timeOfDay.displayName)
.font(.system(size: 10, weight: .bold))
Text(":")
.font(.system(size: 10))
Text(timeOfDay.timeRange)
.font(.system(size: 10))
}
.foregroundStyle(timeOfDayColor(for: timeOfDay))
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xxxSmall)
.background(timeOfDayColor(for: timeOfDay).opacity(0.15))
.clipShape(.capsule)
}
private func timeOfDayColor(for timeOfDay: TimeOfDay) -> Color {
switch timeOfDay {
case .morning:
return Color.orange
case .midday:
return Color.yellow
case .afternoon:
return Color.orange.opacity(0.8)
case .evening:
return Color.purple
case .night:
return Color.indigo
case .anytime:
return AppTextColors.secondary
}
}
}

View File

@ -161,16 +161,15 @@ struct RitualEditSheet: View {
private var scheduleSection: some View { private var scheduleSection: some View {
Section { Section {
VStack(alignment: .leading, spacing: Design.Spacing.small) { VStack(alignment: .leading, spacing: Design.Spacing.small) {
Picker(String(localized: "Time of Day"), selection: $timeOfDay) { Picker(selection: $timeOfDay) {
ForEach(TimeOfDay.allCases, id: \.self) { time in ForEach(TimeOfDay.allCases, id: \.self) { time in
Label(time.displayName, systemImage: time.symbolName) Text(time.displayNameWithRange)
.tag(time) .tag(time)
} }
} label: {
Text(String(localized: "Time of Day"))
} }
.pickerStyle(.menu)
// Show the time range for the selected time of day
Label(timeOfDay.timeRange, systemImage: timeOfDay.symbolName)
.styled(.caption, emphasis: .tertiary)
} }
.listRowBackground(AppSurface.card) .listRowBackground(AppSurface.card)
@ -461,15 +460,18 @@ struct IconPickerSheet: View {
("Wellness", ["heart.fill", "heart.circle.fill", "drop.fill", "leaf.fill", "flame.fill", "bolt.fill", "pill.fill", "cross.fill", "stethoscope.circle.fill", "bandage.fill", "lungs.fill", "ear.fill"]), ("Wellness", ["heart.fill", "heart.circle.fill", "drop.fill", "leaf.fill", "flame.fill", "bolt.fill", "pill.fill", "cross.fill", "stethoscope.circle.fill", "bandage.fill", "lungs.fill", "ear.fill"]),
("Time", ["sunrise.fill", "sun.max.fill", "sunset.fill", "moon.stars.fill", "moon.fill", "moon.zzz.fill", "clock.fill", "hourglass", "timer", "alarm.fill", "calendar", "calendar.badge.clock"]), ("Time", ["sunrise.fill", "sun.max.fill", "sunset.fill", "moon.stars.fill", "moon.fill", "moon.zzz.fill", "clock.fill", "hourglass", "timer", "alarm.fill", "calendar", "calendar.badge.clock"]),
("Activity", ["figure.walk", "figure.run", "figure.mind.and.body", "figure.flexibility", "figure.cooldown", "figure.hiking", "figure.yoga", "figure.strengthtraining.traditional", "figure.dance", "figure.pool.swim", "dumbbell.fill", "sportscourt.fill", "bicycle"]), ("Activity", ["figure.walk", "figure.run", "figure.mind.and.body", "figure.flexibility", "figure.cooldown", "figure.hiking", "figure.yoga", "figure.strengthtraining.traditional", "figure.dance", "figure.pool.swim", "dumbbell.fill", "sportscourt.fill", "bicycle"]),
("Mind", ["brain", "brain.head.profile", "sparkles", "star.fill", "lightbulb.fill", "eye.fill", "wand.and.stars", "hands.and.sparkles.fill", "person.and.background.dotted"]), ("Mind", ["brain", "brain.head.profile", "sparkles", "star.fill", "lightbulb.fill", "eye.fill", "wand.and.stars", "hands.and.sparkles.fill", "person.and.background.dotted", "infinity"]),
("Education", ["graduationcap.fill", "book.pages.fill", "abc", "pencil.and.ruler.fill", "backpack.fill"]),
("Objects", ["book.fill", "book.closed.fill", "books.vertical.fill", "pencil.and.list.clipboard", "cup.and.saucer.fill", "mug.fill", "bed.double.fill", "tshirt.fill", "fork.knife", "gift.fill", "key.fill"]), ("Objects", ["book.fill", "book.closed.fill", "books.vertical.fill", "pencil.and.list.clipboard", "cup.and.saucer.fill", "mug.fill", "bed.double.fill", "tshirt.fill", "fork.knife", "gift.fill", "key.fill"]),
("Nature", ["tree.fill", "wind", "cloud.fill", "snowflake", "sun.horizon.fill", "moon.fill", "mountain.2.fill", "water.waves", "leaf.arrow.circlepath", "flower.fill"]), ("Nature", ["tree.fill", "wind", "cloud.fill", "snowflake", "sun.horizon.fill", "moon.fill", "mountain.2.fill", "water.waves", "leaf.arrow.circlepath", "flower.fill", "sprout.fill"]),
("Home", ["house.fill", "bed.double.fill", "bathtub.fill", "shower.fill", "lamp.desk.fill", "chair.fill", "sofa.fill", "washer.fill"]), ("Home", ["house.fill", "bed.double.fill", "bathtub.fill", "shower.fill", "lamp.desk.fill", "chair.fill", "sofa.fill", "washer.fill", "hammer.fill", "wrench.adjustable.fill", "screwdriver.fill"]),
("Work", ["briefcase.fill", "folder.fill", "doc.text.fill", "list.bullet.clipboard.fill", "checklist", "tray.full.fill", "chart.bar.fill", "gauge.with.dots.needle.bottom.50percent"]), ("Work", ["briefcase.fill", "folder.fill", "doc.text.fill", "list.bullet.clipboard.fill", "checklist", "tray.full.fill", "chart.bar.fill", "gauge.with.dots.needle.bottom.50percent"]),
("Social", ["person.fill", "person.2.fill", "person.wave.2.fill", "bubble.left.fill", "phone.fill", "envelope.fill", "hand.wave.fill", "face.smiling.fill"]), ("Social", ["person.fill", "person.2.fill", "person.wave.2.fill", "bubble.left.fill", "phone.fill", "envelope.fill", "hand.wave.fill", "face.smiling.fill"]),
("Hobbies", ["camera.shutter.button.fill", "gamecontroller.fill", "puzzlepiece.fill", "die.face.5.fill", "binoculars.fill", "theatermasks.fill", "paintpalette.fill"]),
("Actions", ["checkmark.circle.fill", "target", "scope", "hand.thumbsup.fill", "hand.raised.fill", "bell.fill", "megaphone.fill", "flag.fill", "pin.fill"]), ("Actions", ["checkmark.circle.fill", "target", "scope", "hand.thumbsup.fill", "hand.raised.fill", "bell.fill", "megaphone.fill", "flag.fill", "pin.fill"]),
("Finance", ["banknote.fill", "dollarsign.circle.fill", "creditcard.fill", "cart.fill", "bag.fill"]), ("Finance", ["banknote.fill", "dollarsign.circle.fill", "creditcard.fill", "cart.fill", "bag.fill"]),
("Travel", ["airplane", "car.fill", "tram.fill", "ferry.fill", "mappin.circle.fill", "globe.americas.fill"]), ("Travel", ["airplane", "car.fill", "tram.fill", "ferry.fill", "mappin.circle.fill", "globe.americas.fill"]),
("Animals", ["dog.fill", "cat.fill", "pawprint.fill", "bird.fill", "tortoise.fill", "lizard.fill", "fish.fill", "hare.fill", "ladybug.fill"]),
("Art", ["paintpalette.fill", "paintbrush.fill", "pencil.tip.crop.circle", "scissors", "theatermasks.fill", "music.note", "guitars.fill"]), ("Art", ["paintpalette.fill", "paintbrush.fill", "pencil.tip.crop.circle", "scissors", "theatermasks.fill", "music.note", "guitars.fill"]),
("Tech", ["iphone", "desktopcomputer", "keyboard.fill", "headphones", "wifi", "antenna.radiowaves.left.and.right", "camera.fill", "photo.fill"]), ("Tech", ["iphone", "desktopcomputer", "keyboard.fill", "headphones", "wifi", "antenna.radiowaves.left.and.right", "camera.fill", "photo.fill"]),
("Arrows", ["arrow.up.circle.fill", "arrow.down.circle.fill", "arrow.counterclockwise.circle.fill", "arrow.2.circlepath", "arrow.triangle.2.circlepath"]) ("Arrows", ["arrow.up.circle.fill", "arrow.down.circle.fill", "arrow.counterclockwise.circle.fill", "arrow.2.circlepath", "arrow.triangle.2.circlepath"])
@ -557,17 +559,20 @@ struct HabitIconPickerSheet: View {
("Common", ["circle.fill", "star.fill", "heart.fill", "bolt.fill", "leaf.fill", "flame.fill", "drop.fill", "sparkles", "checkmark.circle.fill", "target"]), ("Common", ["circle.fill", "star.fill", "heart.fill", "bolt.fill", "leaf.fill", "flame.fill", "drop.fill", "sparkles", "checkmark.circle.fill", "target"]),
("Wellness", ["heart.fill", "heart.circle.fill", "cross.fill", "pill.fill", "stethoscope.circle.fill", "bandage.fill", "medical.thermometer.fill", "lungs.fill", "waveform.path.ecg", "face.smiling.fill"]), ("Wellness", ["heart.fill", "heart.circle.fill", "cross.fill", "pill.fill", "stethoscope.circle.fill", "bandage.fill", "medical.thermometer.fill", "lungs.fill", "waveform.path.ecg", "face.smiling.fill"]),
("Fitness", ["figure.walk", "figure.run", "figure.mind.and.body", "figure.flexibility", "figure.cooldown", "figure.hiking", "figure.yoga", "figure.strengthtraining.traditional", "figure.dance", "figure.pool.swim", "figure.outdoor.cycle", "dumbbell.fill", "sportscourt.fill", "bicycle", "tennis.racket", "basketball.fill", "soccerball"]), ("Fitness", ["figure.walk", "figure.run", "figure.mind.and.body", "figure.flexibility", "figure.cooldown", "figure.hiking", "figure.yoga", "figure.strengthtraining.traditional", "figure.dance", "figure.pool.swim", "figure.outdoor.cycle", "dumbbell.fill", "sportscourt.fill", "bicycle", "tennis.racket", "basketball.fill", "soccerball"]),
("Food & Drink", ["drop.fill", "cup.and.saucer.fill", "mug.fill", "fork.knife", "carrot.fill", "fish.fill", "leaf.fill", "takeoutbag.and.cup.and.straw.fill", "birthday.cake.fill", "wineglass.fill"]), ("Food & Drink", ["drop.fill", "cup.and.saucer.fill", "mug.fill", "fork.knife", "carrot.fill", "fish.fill", "leaf.fill", "takeoutbag.and.cup.and.straw.fill", "birthday.cake.fill", "wineglass.fill", "apple.logo"]),
("Mind", ["brain", "brain.head.profile", "lightbulb.fill", "eye.fill", "ear.fill", "sparkles", "wand.and.stars", "hands.and.sparkles.fill", "person.and.background.dotted", "moon.zzz.fill"]), ("Mind", ["brain", "brain.head.profile", "lightbulb.fill", "eye.fill", "ear.fill", "sparkles", "wand.and.stars", "hands.and.sparkles.fill", "person.and.background.dotted", "moon.zzz.fill", "infinity"]),
("Reading & Writing", ["book.fill", "book.closed.fill", "books.vertical.fill", "text.book.closed.fill", "pencil", "pencil.and.list.clipboard", "note.text", "doc.text.fill", "newspaper.fill", "bookmark.fill"]), ("Education", ["graduationcap.fill", "book.pages.fill", "abc", "pencil.and.ruler.fill", "backpack.fill"]),
("Reading & Writing", ["book.fill", "book.closed.fill", "books.vertical.fill", "text.book.closed.fill", "pencil", "pencil.and.list.clipboard", "note.text", "doc.text.fill", "newspaper.fill", "bookmark.fill", "square.and.pencil"]),
("Time", ["clock.fill", "alarm.fill", "timer", "hourglass", "sunrise.fill", "sunset.fill", "moon.fill", "moon.stars.fill", "calendar", "calendar.badge.clock"]), ("Time", ["clock.fill", "alarm.fill", "timer", "hourglass", "sunrise.fill", "sunset.fill", "moon.fill", "moon.stars.fill", "calendar", "calendar.badge.clock"]),
("Home", ["bed.double.fill", "bathtub.fill", "shower.fill", "washer.fill", "house.fill", "lamp.desk.fill", "chair.fill", "sofa.fill", "lightbulb.fill", "fan.fill"]), ("Home", ["bed.double.fill", "bathtub.fill", "shower.fill", "washer.fill", "house.fill", "lamp.desk.fill", "chair.fill", "sofa.fill", "lightbulb.fill", "fan.fill", "hammer.fill", "wrench.adjustable.fill", "screwdriver.fill", "sink.fill", "mop.fill"]),
("Work", ["briefcase.fill", "folder.fill", "tray.full.fill", "archivebox.fill", "calendar", "calendar.badge.plus", "checkmark.square.fill", "list.bullet.clipboard.fill", "checklist", "chart.bar.fill", "gauge.with.dots.needle.bottom.50percent", "laptopcomputer"]), ("Work", ["briefcase.fill", "folder.fill", "tray.full.fill", "archivebox.fill", "calendar", "calendar.badge.plus", "checkmark.square.fill", "list.bullet.clipboard.fill", "checklist", "chart.bar.fill", "gauge.with.dots.needle.bottom.50percent", "laptopcomputer"]),
("Social", ["person.fill", "person.2.fill", "person.wave.2.fill", "bubble.left.fill", "phone.fill", "envelope.fill", "hand.wave.fill", "message.fill", "video.fill", "hand.thumbsup.fill"]), ("Social", ["person.fill", "person.2.fill", "person.wave.2.fill", "bubble.left.fill", "phone.fill", "envelope.fill", "hand.wave.fill", "message.fill", "video.fill", "hand.thumbsup.fill"]),
("Nature", ["sun.max.fill", "cloud.fill", "wind", "snowflake", "tree.fill", "flower.fill", "mountain.2.fill", "water.waves", "leaf.arrow.circlepath", "globe.americas.fill"]), ("Nature", ["sun.max.fill", "cloud.fill", "wind", "snowflake", "tree.fill", "flower.fill", "mountain.2.fill", "water.waves", "leaf.arrow.circlepath", "globe.americas.fill", "sprout.fill", "fossil.shell.fill"]),
("Tech", ["iphone", "desktopcomputer", "keyboard.fill", "headphones", "wifi", "antenna.radiowaves.left.and.right", "laptopcomputer", "applewatch", "airpods", "gamecontroller.fill"]), ("Tech", ["iphone", "desktopcomputer", "keyboard.fill", "headphones", "wifi", "antenna.radiowaves.left.and.right", "laptopcomputer", "applewatch", "airpods", "gamecontroller.fill"]),
("Finance", ["banknote.fill", "dollarsign.circle.fill", "creditcard.fill", "cart.fill", "bag.fill", "chart.line.uptrend.xyaxis", "building.columns.fill"]), ("Finance", ["banknote.fill", "dollarsign.circle.fill", "creditcard.fill", "cart.fill", "bag.fill", "chart.line.uptrend.xyaxis", "building.columns.fill"]),
("Travel", ["airplane", "car.fill", "tram.fill", "ferry.fill", "mappin.circle.fill", "globe.americas.fill", "suitcase.fill", "fuelpump.fill"]), ("Travel", ["airplane", "car.fill", "tram.fill", "ferry.fill", "mappin.circle.fill", "globe.americas.fill", "suitcase.fill", "fuelpump.fill"]),
("Animals", ["dog.fill", "cat.fill", "pawprint.fill", "bird.fill", "tortoise.fill", "lizard.fill", "fish.fill", "hare.fill", "ladybug.fill"]),
("Hobbies", ["camera.shutter.button.fill", "gamecontroller.fill", "puzzlepiece.fill", "die.face.5.fill", "binoculars.fill", "theatermasks.fill", "paintpalette.fill"]),
("Art & Music", ["paintpalette.fill", "paintbrush.fill", "pencil.tip.crop.circle", "scissors", "theatermasks.fill", "music.note", "guitars.fill", "pianokeys", "headphones", "film.fill"]), ("Art & Music", ["paintpalette.fill", "paintbrush.fill", "pencil.tip.crop.circle", "scissors", "theatermasks.fill", "music.note", "guitars.fill", "pianokeys", "headphones", "film.fill"]),
("Self-Care", ["comb.fill", "eyebrow", "lips.fill", "hand.raised.fingers.spread.fill", "sparkles", "shower.fill", "bathtub.fill", "leaf.fill"]), ("Self-Care", ["comb.fill", "eyebrow", "lips.fill", "hand.raised.fingers.spread.fill", "sparkles", "shower.fill", "bathtub.fill", "leaf.fill"]),
("Cleaning", ["trash.fill", "archivebox.fill", "tshirt.fill", "washer.fill", "sparkles", "bubble.left.and.bubble.right.fill"]), ("Cleaning", ["trash.fill", "archivebox.fill", "tshirt.fill", "washer.fill", "sparkles", "bubble.left.and.bubble.right.fill"]),

View File

@ -8,6 +8,10 @@ struct RootView: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var selectedTab: RootTab @State private var selectedTab: RootTab
@State private var analyticsPrewarmTask: Task<Void, Never>? @State private var analyticsPrewarmTask: Task<Void, Never>?
@State private var isForegroundRefreshing = false
private let foregroundRefreshMinimumSeconds: TimeInterval = 0.15
private let debugForegroundRefreshMinimumSeconds: TimeInterval = 0.8
private let debugForegroundRefreshKey = "debugForegroundRefreshNextForeground"
/// The available tabs in the app. /// The available tabs in the app.
enum RootTab: Hashable { enum RootTab: Hashable {
@ -37,47 +41,67 @@ struct RootView: View {
} }
var body: some View { var body: some View {
TabView(selection: $selectedTab) { ZStack {
Tab(String(localized: "Today"), systemImage: "sun.max.fill", value: RootTab.today) { TabView(selection: $selectedTab) {
NavigationStack { Tab(String(localized: "Today"), systemImage: "sun.max.fill", value: RootTab.today) {
TodayView(store: store, categoryStore: categoryStore) NavigationStack {
TodayView(store: store, categoryStore: categoryStore)
}
}
Tab(String(localized: "Rituals"), systemImage: "sparkles", value: RootTab.rituals) {
NavigationStack {
RitualsView(store: store, categoryStore: categoryStore)
}
}
Tab(String(localized: "Insights"), systemImage: "chart.bar.fill", value: RootTab.insights) {
NavigationStack {
InsightsView(store: store)
}
}
Tab(String(localized: "History"), systemImage: "calendar", value: RootTab.history) {
NavigationStack {
HistoryView(store: store)
}
}
Tab(String(localized: "Settings"), systemImage: "gearshape.fill", value: RootTab.settings) {
NavigationStack {
SettingsView(store: settingsStore, ritualStore: store, categoryStore: categoryStore)
}
} }
} }
Tab(String(localized: "Rituals"), systemImage: "sparkles", value: RootTab.rituals) { if isForegroundRefreshing {
NavigationStack { AppSurface.primary
RitualsView(store: store, categoryStore: categoryStore) .ignoresSafeArea()
} .overlay {
} ProgressView()
.tint(AppAccent.primary)
Tab(String(localized: "Insights"), systemImage: "chart.bar.fill", value: RootTab.insights) { }
NavigationStack { .transition(.opacity)
InsightsView(store: store)
}
}
Tab(String(localized: "History"), systemImage: "calendar", value: RootTab.history) {
NavigationStack {
HistoryView(store: store)
}
}
Tab(String(localized: "Settings"), systemImage: "gearshape.fill", value: RootTab.settings) {
NavigationStack {
SettingsView(store: settingsStore, ritualStore: store, categoryStore: categoryStore)
}
} }
} }
.tint(AppAccent.primary) .tint(AppAccent.primary)
.background(AppSurface.primary.ignoresSafeArea()) .background(AppSurface.primary.ignoresSafeArea())
.animation(.easeInOut(duration: 0.12), value: isForegroundRefreshing)
.onChange(of: scenePhase) { _, newPhase in .onChange(of: scenePhase) { _, newPhase in
if newPhase == .active { if newPhase == .active {
store.reminderScheduler.clearBadge() store.reminderScheduler.clearBadge()
refreshCurrentTab() let useDebugOverlay = UserDefaults.standard.bool(forKey: debugForegroundRefreshKey)
if useDebugOverlay {
UserDefaults.standard.set(false, forKey: debugForegroundRefreshKey)
}
refreshAllTabs(
showOverlay: true,
minimumSeconds: useDebugOverlay ? debugForegroundRefreshMinimumSeconds : foregroundRefreshMinimumSeconds
)
} }
} }
.onChange(of: selectedTab) { _, _ in .onChange(of: selectedTab) { _, _ in
refreshCurrentTab() refreshAllTabs(showOverlay: false, minimumSeconds: foregroundRefreshMinimumSeconds)
} }
.onChange(of: store.reminderScheduler.shouldNavigateToToday) { _, shouldNavigate in .onChange(of: store.reminderScheduler.shouldNavigateToToday) { _, shouldNavigate in
if shouldNavigate { if shouldNavigate {
@ -107,23 +131,35 @@ struct RootView: View {
} }
} }
private func refreshCurrentTab() { private func refreshAllTabs(showOverlay: Bool, minimumSeconds: TimeInterval) {
Task { Task { @MainActor in
let start = Date()
if showOverlay {
isForegroundRefreshing = true
}
// Let tab selection UI update before refreshing data. // Let tab selection UI update before refreshing data.
await Task.yield() await Task.yield()
store.refresh() if showOverlay {
analyticsPrewarmTask?.cancel() store.refresh()
if selectedTab != .insights { } else {
analyticsPrewarmTask = Task { @MainActor in store.refreshIfNeeded()
try? await Task.sleep(for: .milliseconds(350))
guard !Task.isCancelled else { return }
store.refreshAnalyticsIfNeeded()
store.refreshInsightCardsIfNeeded()
}
} }
if selectedTab == .settings { analyticsPrewarmTask?.cancel()
settingsStore.refresh() analyticsPrewarmTask = Task { @MainActor in
await store.reminderScheduler.refreshStatus() try? await Task.sleep(for: .milliseconds(350))
guard !Task.isCancelled else { return }
store.refreshAnalyticsIfNeeded()
store.refreshInsightCardsIfNeeded()
}
settingsStore.refresh()
await store.reminderScheduler.refreshStatus()
if showOverlay {
let elapsed = Date().timeIntervalSince(start)
let remaining = max(0, minimumSeconds - elapsed)
if remaining > 0 {
try? await Task.sleep(for: .seconds(remaining))
}
isForegroundRefreshing = false
} }
} }
} }

View File

@ -164,6 +164,14 @@ struct SettingsView: View {
ritualStore.reminderScheduler.scheduleTestNotification() ritualStore.reminderScheduler.scheduleTestNotification()
} }
} }
SettingsRow(
systemImage: "arrow.clockwise",
title: String(localized: "Simulate Foreground Refresh"),
iconColor: AppStatus.info
) {
UserDefaults.standard.set(true, forKey: "debugForegroundRefreshNextForeground")
}
} }
#endif #endif
@ -174,7 +182,7 @@ struct SettingsView: View {
} }
.onAppear { .onAppear {
store.refresh() store.refresh()
ritualStore?.refresh() ritualStore?.refreshIfNeeded()
Task { Task {
await ritualStore?.reminderScheduler.refreshStatus() await ritualStore?.reminderScheduler.refreshStatus()
} }

View File

@ -84,6 +84,9 @@ struct TodayView: View {
ArcRenewalSheet(store: store, categoryStore: categoryStore, ritual: ritual) ArcRenewalSheet(store: store, categoryStore: categoryStore, ritual: ritual)
} }
} }
.onAppear {
store.refreshIfNeeded()
}
} }
private func habitRows(for ritual: Ritual) -> [HabitRowModel] { private func habitRows(for ritual: Ritual) -> [HabitRowModel] {