diff --git a/Andromida/App/Localization/Localizable.xcstrings b/Andromida/App/Localization/Localizable.xcstrings index ba0c9e2..cb34c75 100644 --- a/Andromida/App/Localization/Localizable.xcstrings +++ b/Andromida/App/Localization/Localizable.xcstrings @@ -11,6 +11,9 @@ "-%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.", "isCommentAutoGenerated" : true + }, + ":" : { + }, "%@ – %@" : { "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.", "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..." : { "comment" : "A placeholder text for a text field where the user can add notes or reminders.", "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" : { "comment" : "A button label that says \"Add to My Rituals\".", "isCommentAutoGenerated" : true @@ -1271,6 +1296,17 @@ "comment" : "Title of the history view.", "isCommentAutoGenerated" : true }, + "How to add" : { + "comment" : "CTA button label to show widget setup steps.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "How to add" + } + } + } + }, "Hydrate" : { "localizations" : { "en" : { @@ -1383,6 +1419,17 @@ "comment" : "Habit title for keeping the bedroom cool at night.", "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." : { "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, @@ -2103,6 +2150,17 @@ "comment" : "A label displayed above the ritual's scheduling information.", "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" : { "comment" : "A placeholder text for a search bar in an icon picker sheet.", "isCommentAutoGenerated" : true @@ -2158,6 +2216,10 @@ }, "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" : { "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" : { "comment" : "A hint that appears when a user taps on an element to learn more about it.", "isCommentAutoGenerated" : true @@ -2503,6 +2576,17 @@ "Total Check-ins" : { "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." : { "comment" : "Description of a feature card in the \"WhatsNextStepView\" that explains how to use the app's insights feature.", "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" : { "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" diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index b014e6a..19641df 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -26,6 +26,7 @@ final class RitualStore: RitualStoreProviding { private var pendingReminderTask: Task? private var insightCardsNeedRefresh = true private var cachedInsightCards: [InsightCard] = [] + private var lastRefreshDate: Date? /// Reminder scheduler for time-slot based notifications let reminderScheduler = ReminderScheduler() @@ -105,6 +106,16 @@ final class RitualStore: RitualStoreProviding { reloadRituals() 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 { let habits = ritual.habits diff --git a/Andromida/App/Views/History/HistoryView.swift b/Andromida/App/Views/History/HistoryView.swift index 5463ae7..7ad61d0 100644 --- a/Andromida/App/Views/History/HistoryView.swift +++ b/Andromida/App/Views/History/HistoryView.swift @@ -150,6 +150,7 @@ struct HistoryView: View { refreshToken = UUID() } .onAppear { + store.refreshIfNeeded() refreshProgressCache() } .sheet(item: $selectedDateItem) { item in diff --git a/Andromida/App/Views/Insights/InsightsView.swift b/Andromida/App/Views/Insights/InsightsView.swift index abba7e8..378bb76 100644 --- a/Andromida/App/Views/Insights/InsightsView.swift +++ b/Andromida/App/Views/Insights/InsightsView.swift @@ -80,6 +80,7 @@ struct InsightsView: View { } } .onAppear { + store.refreshIfNeeded() cardOrder = store.insightCardOrder Task { await Task.yield() diff --git a/Andromida/App/Views/Rituals/RitualsView.swift b/Andromida/App/Views/Rituals/RitualsView.swift index f5ceb23..4894dd7 100644 --- a/Andromida/App/Views/Rituals/RitualsView.swift +++ b/Andromida/App/Views/Rituals/RitualsView.swift @@ -125,6 +125,9 @@ struct RitualsView: View { .onChange(of: store.rituals) { _, _ in refreshToken = UUID() } + .onAppear { + store.refreshIfNeeded() + } } // MARK: - Current Tab Content @@ -141,7 +144,7 @@ struct RitualsView: View { // Time of day header HStack(spacing: Design.Spacing.small) { 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) diff --git a/Andromida/App/Views/Rituals/Sheets/PresetLibrarySheet.swift b/Andromida/App/Views/Rituals/Sheets/PresetLibrarySheet.swift index d1f4351..ede0ad1 100644 --- a/Andromida/App/Views/Rituals/Sheets/PresetLibrarySheet.swift +++ b/Andromida/App/Views/Rituals/Sheets/PresetLibrarySheet.swift @@ -106,22 +106,29 @@ struct PresetLibrarySheet: View { Spacer() 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) } } - // Habit preview - HStack(spacing: Design.Spacing.small) { - ForEach(preset.habits.prefix(4)) { habit in - SymbolIcon(habit.symbolName, size: .badge, color: AppTextColors.tertiary) + // Habit preview and Time of Day + HStack(alignment: .center, spacing: Design.Spacing.small) { + // Habit icons + 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 { - Text("+\(preset.habits.count - 4)").styled(.caption2, emphasis: .tertiary) - } + Spacer() + + // Time of Day pill + timeOfDayPill(for: preset.timeOfDay) } + .padding(.top, Design.Spacing.small) } .padding(Design.Spacing.large) .background(AppSurface.card) @@ -187,7 +194,7 @@ struct PresetDetailSheet: View { .foregroundStyle(AppTextColors.secondary) 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") } .typography(.caption) @@ -260,3 +267,40 @@ struct PresetDetailSheet: View { #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 + } + } +} diff --git a/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift b/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift index 42d3aa4..cc3b53b 100644 --- a/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift +++ b/Andromida/App/Views/Rituals/Sheets/RitualEditSheet.swift @@ -161,16 +161,15 @@ struct RitualEditSheet: View { private var scheduleSection: some View { Section { 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 - Label(time.displayName, systemImage: time.symbolName) + Text(time.displayNameWithRange) .tag(time) } + } label: { + Text(String(localized: "Time of Day")) } - - // Show the time range for the selected time of day - Label(timeOfDay.timeRange, systemImage: timeOfDay.symbolName) - .styled(.caption, emphasis: .tertiary) + .pickerStyle(.menu) } .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"]), ("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"]), - ("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"]), - ("Nature", ["tree.fill", "wind", "cloud.fill", "snowflake", "sun.horizon.fill", "moon.fill", "mountain.2.fill", "water.waves", "leaf.arrow.circlepath", "flower.fill"]), - ("Home", ["house.fill", "bed.double.fill", "bathtub.fill", "shower.fill", "lamp.desk.fill", "chair.fill", "sofa.fill", "washer.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", "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"]), ("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"]), ("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"]), + ("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"]), ("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"]) @@ -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"]), ("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"]), - ("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"]), - ("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"]), - ("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"]), + ("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", "infinity"]), + ("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"]), - ("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"]), ("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"]), ("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"]), + ("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"]), ("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"]), diff --git a/Andromida/App/Views/RootView.swift b/Andromida/App/Views/RootView.swift index 8eb701d..d78d820 100644 --- a/Andromida/App/Views/RootView.swift +++ b/Andromida/App/Views/RootView.swift @@ -8,6 +8,10 @@ struct RootView: View { @Environment(\.scenePhase) private var scenePhase @State private var selectedTab: RootTab @State private var analyticsPrewarmTask: Task? + @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. enum RootTab: Hashable { @@ -37,47 +41,67 @@ struct RootView: View { } var body: some View { - TabView(selection: $selectedTab) { - Tab(String(localized: "Today"), systemImage: "sun.max.fill", value: RootTab.today) { - NavigationStack { - TodayView(store: store, categoryStore: categoryStore) + ZStack { + TabView(selection: $selectedTab) { + Tab(String(localized: "Today"), systemImage: "sun.max.fill", value: RootTab.today) { + 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) { - 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) - } + + if isForegroundRefreshing { + AppSurface.primary + .ignoresSafeArea() + .overlay { + ProgressView() + .tint(AppAccent.primary) + } + .transition(.opacity) } } .tint(AppAccent.primary) .background(AppSurface.primary.ignoresSafeArea()) + .animation(.easeInOut(duration: 0.12), value: isForegroundRefreshing) .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { 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 - refreshCurrentTab() + refreshAllTabs(showOverlay: false, minimumSeconds: foregroundRefreshMinimumSeconds) } .onChange(of: store.reminderScheduler.shouldNavigateToToday) { _, shouldNavigate in if shouldNavigate { @@ -107,23 +131,35 @@ struct RootView: View { } } - private func refreshCurrentTab() { - Task { + private func refreshAllTabs(showOverlay: Bool, minimumSeconds: TimeInterval) { + Task { @MainActor in + let start = Date() + if showOverlay { + isForegroundRefreshing = true + } // Let tab selection UI update before refreshing data. await Task.yield() - store.refresh() - analyticsPrewarmTask?.cancel() - if selectedTab != .insights { - analyticsPrewarmTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(350)) - guard !Task.isCancelled else { return } - store.refreshAnalyticsIfNeeded() - store.refreshInsightCardsIfNeeded() - } + if showOverlay { + store.refresh() + } else { + store.refreshIfNeeded() } - if selectedTab == .settings { - settingsStore.refresh() - await store.reminderScheduler.refreshStatus() + analyticsPrewarmTask?.cancel() + analyticsPrewarmTask = Task { @MainActor in + 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 } } } diff --git a/Andromida/App/Views/Settings/SettingsView.swift b/Andromida/App/Views/Settings/SettingsView.swift index 6a92730..d7e1abb 100644 --- a/Andromida/App/Views/Settings/SettingsView.swift +++ b/Andromida/App/Views/Settings/SettingsView.swift @@ -164,6 +164,14 @@ struct SettingsView: View { ritualStore.reminderScheduler.scheduleTestNotification() } } + + SettingsRow( + systemImage: "arrow.clockwise", + title: String(localized: "Simulate Foreground Refresh"), + iconColor: AppStatus.info + ) { + UserDefaults.standard.set(true, forKey: "debugForegroundRefreshNextForeground") + } } #endif @@ -174,7 +182,7 @@ struct SettingsView: View { } .onAppear { store.refresh() - ritualStore?.refresh() + ritualStore?.refreshIfNeeded() Task { await ritualStore?.reminderScheduler.refreshStatus() } diff --git a/Andromida/App/Views/Today/TodayView.swift b/Andromida/App/Views/Today/TodayView.swift index f810eb3..6e6979f 100644 --- a/Andromida/App/Views/Today/TodayView.swift +++ b/Andromida/App/Views/Today/TodayView.swift @@ -84,6 +84,9 @@ struct TodayView: View { ArcRenewalSheet(store: store, categoryStore: categoryStore, ritual: ritual) } } + .onAppear { + store.refreshIfNeeded() + } } private func habitRows(for ritual: Ritual) -> [HabitRowModel] {