Compare commits

...

10 Commits

21 changed files with 1504 additions and 319 deletions

View File

@ -7,12 +7,12 @@
<key>Andromida.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
<integer>1</integer>
</dict>
<key>AndromidaWidgetExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -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,
@ -1874,6 +1921,14 @@
"comment" : "Habit title for reading a physical book as part of a RitualPreset.",
"isCommentAutoGenerated" : true
},
"Real" : {
"comment" : "The text for the \"Real\" option in the time of day picker.",
"isCommentAutoGenerated" : true
},
"Real Time (%@)" : {
"comment" : "Text displayed in the debug picker to indicate whether it is showing the real time or a simulated time.",
"isCommentAutoGenerated" : true
},
"Reflect" : {
"localizations" : {
"en" : {
@ -1907,6 +1962,9 @@
"Release shoulder tension" : {
"comment" : "Habit title for releasing shoulder tension during mindfulness practice.",
"isCommentAutoGenerated" : true
},
"Reload Widget Timelines" : {
},
"Remaining" : {
"comment" : "Label for the number of days remaining in a ritual's progress.",
@ -2103,6 +2161,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 +2227,14 @@
},
"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
},
"Simulate Time of Day" : {
"comment" : "A label for the time of day picker.",
"isCommentAutoGenerated" : true
},
"Skip" : {
"comment" : "Button label to skip onboarding.",
@ -2357,6 +2434,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 +2591,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 +2717,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 +2844,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"

View File

@ -89,19 +89,49 @@ enum RitualPresetLibrary {
HabitPreset(title: String(localized: "Drink water"), symbolName: "drop.fill")
]
),
RitualPreset(
title: String(localized: "Afternoon Energy"),
theme: String(localized: "Beat the slump"),
notes: String(localized: "Combat the afternoon energy dip with healthy habits."),
durationDays: 28,
timeOfDay: .afternoon,
iconName: "bolt.fill",
category: PresetCategory.health.rawValue,
habits: [
HabitPreset(title: String(localized: "Drink water or green tea"), symbolName: "cup.and.saucer.fill"),
HabitPreset(title: String(localized: "Eat a healthy snack"), symbolName: "carrot.fill"),
HabitPreset(title: String(localized: "Step outside for fresh air"), symbolName: "sun.max.fill"),
HabitPreset(title: String(localized: "Quick stretching break"), symbolName: "figure.flexibility")
]
),
RitualPreset(
title: String(localized: "Evening Nutrition"),
theme: String(localized: "Nourish mindfully"),
notes: String(localized: "Build healthy evening eating habits."),
durationDays: 28,
timeOfDay: .evening,
iconName: "fork.knife",
category: PresetCategory.health.rawValue,
habits: [
HabitPreset(title: String(localized: "Eat dinner before 7pm"), symbolName: "clock.fill"),
HabitPreset(title: String(localized: "Include vegetables"), symbolName: "leaf.fill"),
HabitPreset(title: String(localized: "Avoid heavy foods"), symbolName: "hand.raised.fill"),
HabitPreset(title: String(localized: "Drink herbal tea"), symbolName: "cup.and.saucer.fill")
]
),
RitualPreset(
title: String(localized: "Sleep Preparation"),
theme: String(localized: "Rest better tonight"),
notes: String(localized: "Wind down with habits that promote quality sleep."),
durationDays: 28,
timeOfDay: .evening,
timeOfDay: .night,
iconName: "moon.zzz.fill",
category: PresetCategory.health.rawValue,
habits: [
HabitPreset(title: String(localized: "No caffeine after 2pm"), symbolName: "cup.and.saucer.fill"),
HabitPreset(title: String(localized: "Dim lights 1 hour before bed"), symbolName: "lightbulb.fill"),
HabitPreset(title: String(localized: "Dim the lights"), symbolName: "lightbulb.fill"),
HabitPreset(title: String(localized: "Set consistent bedtime"), symbolName: "bed.double.fill"),
HabitPreset(title: String(localized: "Keep room cool"), symbolName: "thermometer.snowflake")
HabitPreset(title: String(localized: "Keep room cool"), symbolName: "thermometer.snowflake"),
HabitPreset(title: String(localized: "No screens in bed"), symbolName: "iphone.slash")
]
)
]
@ -124,6 +154,35 @@ enum RitualPresetLibrary {
HabitPreset(title: String(localized: "Close unnecessary tabs"), symbolName: "xmark.square.fill")
]
),
RitualPreset(
title: String(localized: "Midday Check-In"),
theme: String(localized: "Stay on track"),
notes: String(localized: "A quick midday review to maintain momentum."),
durationDays: 28,
timeOfDay: .midday,
iconName: "gauge.with.dots.needle.50percent",
category: PresetCategory.productivity.rawValue,
habits: [
HabitPreset(title: String(localized: "Review morning progress"), symbolName: "checkmark.square.fill"),
HabitPreset(title: String(localized: "Adjust afternoon priorities"), symbolName: "slider.horizontal.3"),
HabitPreset(title: String(localized: "Clear quick tasks"), symbolName: "bolt.fill")
]
),
RitualPreset(
title: String(localized: "Afternoon Deep Work"),
theme: String(localized: "Power through"),
notes: String(localized: "Set up for a focused afternoon work session."),
durationDays: 28,
timeOfDay: .afternoon,
iconName: "laptopcomputer",
category: PresetCategory.productivity.rawValue,
habits: [
HabitPreset(title: String(localized: "Close distracting apps"), symbolName: "xmark.app.fill"),
HabitPreset(title: String(localized: "Set a 90-minute timer"), symbolName: "timer"),
HabitPreset(title: String(localized: "Work on one important task"), symbolName: "target"),
HabitPreset(title: String(localized: "No meetings block"), symbolName: "calendar.badge.minus")
]
),
RitualPreset(
title: String(localized: "End-of-Day Review"),
theme: String(localized: "Close loops, plan ahead"),
@ -172,6 +231,35 @@ enum RitualPresetLibrary {
HabitPreset(title: String(localized: "Practice gratitude"), symbolName: "heart.fill")
]
),
RitualPreset(
title: String(localized: "Mindful Lunch"),
theme: String(localized: "Pause and nourish"),
notes: String(localized: "Bring awareness to your midday meal."),
durationDays: 28,
timeOfDay: .midday,
iconName: "leaf.fill",
category: PresetCategory.mindfulness.rawValue,
habits: [
HabitPreset(title: String(localized: "Eat without screens"), symbolName: "iphone.slash"),
HabitPreset(title: String(localized: "Chew slowly and taste"), symbolName: "mouth.fill"),
HabitPreset(title: String(localized: "Notice hunger and fullness"), symbolName: "heart.fill")
]
),
RitualPreset(
title: String(localized: "Afternoon Reset"),
theme: String(localized: "Center yourself"),
notes: String(localized: "A brief afternoon pause to recenter."),
durationDays: 28,
timeOfDay: .afternoon,
iconName: "circle.dotted",
category: PresetCategory.mindfulness.rawValue,
habits: [
HabitPreset(title: String(localized: "2-minute breathing pause"), symbolName: "wind"),
HabitPreset(title: String(localized: "Notice your posture"), symbolName: "figure.stand"),
HabitPreset(title: String(localized: "Release tension"), symbolName: "arrow.down.circle.fill"),
HabitPreset(title: String(localized: "Set afternoon intention"), symbolName: "star.fill")
]
),
RitualPreset(
title: String(localized: "Gratitude Practice"),
theme: String(localized: "Find the good"),
@ -186,6 +274,21 @@ enum RitualPresetLibrary {
HabitPreset(title: String(localized: "Reflect on a positive moment"), symbolName: "sun.max.fill")
]
),
RitualPreset(
title: String(localized: "Night Reflection"),
theme: String(localized: "Process your day"),
notes: String(localized: "Close the day with awareness and intention."),
durationDays: 28,
timeOfDay: .night,
iconName: "moon.stars.fill",
category: PresetCategory.mindfulness.rawValue,
habits: [
HabitPreset(title: String(localized: "Journal for 5 minutes"), symbolName: "book.fill"),
HabitPreset(title: String(localized: "What went well today?"), symbolName: "hand.thumbsup.fill"),
HabitPreset(title: String(localized: "What could be better?"), symbolName: "lightbulb.fill"),
HabitPreset(title: String(localized: "Let go of the day"), symbolName: "leaf.fill")
]
),
RitualPreset(
title: String(localized: "Breathwork"),
theme: String(localized: "Calm your nervous system"),
@ -199,21 +302,6 @@ enum RitualPresetLibrary {
HabitPreset(title: String(localized: "Body scan for tension"), symbolName: "figure.stand"),
HabitPreset(title: String(localized: "Release shoulder tension"), symbolName: "arrow.down.circle.fill")
]
),
RitualPreset(
title: String(localized: "Evening Reflection"),
theme: String(localized: "Process your day"),
notes: String(localized: "Close the day with awareness and intention."),
durationDays: 28,
timeOfDay: .evening,
iconName: "moon.stars.fill",
category: PresetCategory.mindfulness.rawValue,
habits: [
HabitPreset(title: String(localized: "Journal for 5 minutes"), symbolName: "book.fill"),
HabitPreset(title: String(localized: "What went well today?"), symbolName: "hand.thumbsup.fill"),
HabitPreset(title: String(localized: "What could be better?"), symbolName: "lightbulb.fill"),
HabitPreset(title: String(localized: "Let go of the day"), symbolName: "leaf.fill")
]
)
]
@ -235,17 +323,32 @@ enum RitualPresetLibrary {
]
),
RitualPreset(
title: String(localized: "Digital Detox"),
theme: String(localized: "Disconnect to reconnect"),
notes: String(localized: "Give your mind a break from screens."),
title: String(localized: "Midday Self-Check"),
theme: String(localized: "How are you feeling?"),
notes: String(localized: "A quick check-in with yourself."),
durationDays: 28,
timeOfDay: .evening,
iconName: "iphone.slash",
timeOfDay: .midday,
iconName: "heart.circle.fill",
category: PresetCategory.selfCare.rawValue,
habits: [
HabitPreset(title: String(localized: "No screens 1 hour before bed"), symbolName: "iphone.slash"),
HabitPreset(title: String(localized: "Read a physical book"), symbolName: "book.closed.fill"),
HabitPreset(title: String(localized: "Have a real conversation"), symbolName: "person.2.fill")
HabitPreset(title: String(localized: "Rate your energy level"), symbolName: "battery.50percent"),
HabitPreset(title: String(localized: "Notice your mood"), symbolName: "face.smiling.fill"),
HabitPreset(title: String(localized: "Adjust if needed"), symbolName: "slider.horizontal.3")
]
),
RitualPreset(
title: String(localized: "Afternoon Break"),
theme: String(localized: "Pause and recharge"),
notes: String(localized: "Give yourself permission to rest."),
durationDays: 28,
timeOfDay: .afternoon,
iconName: "cup.and.saucer.fill",
category: PresetCategory.selfCare.rawValue,
habits: [
HabitPreset(title: String(localized: "Step away from work"), symbolName: "figure.walk"),
HabitPreset(title: String(localized: "Enjoy a beverage mindfully"), symbolName: "cup.and.saucer.fill"),
HabitPreset(title: String(localized: "Listen to calming music"), symbolName: "music.note"),
HabitPreset(title: String(localized: "Look out a window"), symbolName: "window.horizontal")
]
),
RitualPreset(
@ -263,20 +366,34 @@ enum RitualPresetLibrary {
HabitPreset(title: String(localized: "Herbal tea"), symbolName: "cup.and.saucer.fill")
]
),
RitualPreset(
title: String(localized: "Night Skincare"),
theme: String(localized: "Repair and restore"),
notes: String(localized: "Nighttime skincare for rejuvenation."),
durationDays: 28,
timeOfDay: .night,
iconName: "sparkles",
category: PresetCategory.selfCare.rawValue,
habits: [
HabitPreset(title: String(localized: "Remove makeup"), symbolName: "drop.fill"),
HabitPreset(title: String(localized: "Cleanse face"), symbolName: "bubbles.and.sparkles.fill"),
HabitPreset(title: String(localized: "Apply night cream"), symbolName: "moon.fill"),
HabitPreset(title: String(localized: "Eye cream"), symbolName: "eye.fill")
]
),
RitualPreset(
title: String(localized: "Weekly Reset"),
theme: String(localized: "Prepare for a fresh week"),
notes: String(localized: "Sunday evening ritual to start Monday strong."),
notes: String(localized: "Sunday ritual to start Monday strong."),
durationDays: 28,
timeOfDay: .evening,
timeOfDay: .anytime,
iconName: "arrow.counterclockwise.circle.fill",
category: PresetCategory.selfCare.rawValue,
habits: [
HabitPreset(title: String(localized: "Review last week"), symbolName: "calendar"),
HabitPreset(title: String(localized: "Plan the week ahead"), symbolName: "calendar.badge.plus"),
HabitPreset(title: String(localized: "Prepare clothes for Monday"), symbolName: "tshirt.fill"),
HabitPreset(title: String(localized: "Tidy your space"), symbolName: "sparkles"),
HabitPreset(title: String(localized: "Early bedtime"), symbolName: "bed.double.fill")
HabitPreset(title: String(localized: "Prepare clothes"), symbolName: "tshirt.fill"),
HabitPreset(title: String(localized: "Tidy your space"), symbolName: "sparkles")
]
)
]

View File

@ -26,6 +26,7 @@ final class RitualStore: RitualStoreProviding {
private var pendingReminderTask: Task<Void, Never>?
private var insightCardsNeedRefresh = true
private var cachedInsightCards: [InsightCard] = []
private var lastRefreshDate: Date?
/// Reminder scheduler for time-slot based notifications
let reminderScheduler = ReminderScheduler()
@ -33,6 +34,19 @@ final class RitualStore: RitualStoreProviding {
/// Ritual that needs renewal prompt (arc just completed)
var ritualNeedingRenewal: Ritual?
/// The current time of day, updated periodically. Observable for UI refresh.
private(set) var currentTimeOfDay: TimeOfDay = TimeOfDay.current()
/// Debug override for time of day (nil = use real time)
var debugTimeOfDayOverride: TimeOfDay? {
didSet {
updateCurrentTimeOfDay()
analyticsNeedsRefresh = true
insightCardsNeedRefresh = true
reloadRituals()
}
}
/// Rituals that have been dismissed for renewal this session
private var dismissedRenewalRituals: Set<PersistentIdentifier> = []
@ -102,10 +116,37 @@ final class RitualStore: RitualStoreProviding {
/// Refreshes rituals and derived state for current date/time.
func refresh() {
updateCurrentTimeOfDay()
reloadRituals()
checkForCompletedArcs()
}
/// Updates the current time of day and returns true if it changed.
@discardableResult
func updateCurrentTimeOfDay() -> Bool {
let newTimeOfDay = debugTimeOfDayOverride ?? TimeOfDay.current()
if newTimeOfDay != currentTimeOfDay {
currentTimeOfDay = newTimeOfDay
return true
}
return false
}
/// Returns the effective time of day (considering debug override).
func effectiveTimeOfDay() -> TimeOfDay {
debugTimeOfDayOverride ?? TimeOfDay.current()
}
/// 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
guard !habits.isEmpty else { return 0 }
@ -123,7 +164,11 @@ final class RitualStore: RitualStoreProviding {
}
func toggleHabitCompletion(_ habit: ArcHabit) {
let dayID = dayIdentifier(for: Date())
toggleHabitCompletion(habit, date: Date())
}
func toggleHabitCompletion(_ habit: ArcHabit, date: Date = Date()) {
let dayID = dayIdentifier(for: date)
let wasCompleted = habit.completedDayIDs.contains(dayID)
if wasCompleted {
@ -170,8 +215,17 @@ final class RitualStore: RitualStoreProviding {
/// Returns rituals appropriate for the current time of day that have active arcs covering today.
/// Filters based on time of day: morning (before 11am), midday (11am-2pm), afternoon (2pm-5pm),
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
/// Uses the store's `currentTimeOfDay` which respects debug overrides.
func ritualsForToday() -> [Ritual] {
RitualAnalytics.ritualsActive(on: Date(), from: currentRituals)
let today = Date()
let timeOfDay = effectiveTimeOfDay()
return currentRituals.filter { ritual in
guard ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) != nil else {
return false
}
return ritual.timeOfDay == .anytime || ritual.timeOfDay == timeOfDay
}
}
/// Groups current rituals by time of day for display

View File

@ -11,7 +11,7 @@ import Bedrock
/// A sheet showing habit completion details for a specific day.
struct HistoryDayDetailSheet: View {
let date: Date
let completions: [HabitCompletion]
let ritual: Ritual?
let store: RitualStore
@Environment(\.dismiss) private var dismiss
@ -21,6 +21,14 @@ struct HistoryDayDetailSheet: View {
return formatter.string(from: date)
}
private var isToday: Bool {
Calendar.current.isDateInToday(date)
}
private var completions: [HabitCompletion] {
store.habitCompletions(for: date, ritual: ritual)
}
private var completionRate: Double {
guard !completions.isEmpty else { return 0 }
let completed = completions.filter { $0.isCompleted }.count
@ -222,7 +230,7 @@ struct HistoryDayDetailSheet: View {
}
private func habitRow(_ completion: HabitCompletion) -> some View {
HStack(spacing: Design.Spacing.medium) {
let content = HStack(spacing: Design.Spacing.medium) {
Image(systemName: completion.habit.symbolName)
.foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary)
.frame(width: AppMetrics.Size.iconMedium)
@ -237,13 +245,26 @@ struct HistoryDayDetailSheet: View {
.foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary)
}
.padding(.vertical, Design.Spacing.small)
if isToday {
return AnyView(
Button {
store.toggleHabitCompletion(completion.habit, date: date)
} label: {
content
}
.buttonStyle(.plain)
)
} else {
return AnyView(content)
}
}
}
#Preview {
HistoryDayDetailSheet(
date: Date(),
completions: [],
ritual: nil,
store: RitualStore.preview
)
}

View File

@ -106,6 +106,7 @@ struct HistoryView: View {
.id(refreshToken)
}
.padding(Design.Spacing.large)
.adaptiveContentWidth()
}
.background(LinearGradient(
colors: [AppSurface.primary, AppSurface.secondary],
@ -113,7 +114,7 @@ struct HistoryView: View {
endPoint: .bottomTrailing
))
.navigationTitle(String(localized: "History"))
.navigationBarTitleDisplayMode(.large)
.navigationBarTitleDisplayMode(horizontalSizeClass == .regular ? .inline : .large)
.toolbar {
ToolbarItem(placement: .primaryAction) {
if hasMoreHistory || monthsToShow > baseMonthsToShow {
@ -149,12 +150,13 @@ struct HistoryView: View {
refreshToken = UUID()
}
.onAppear {
store.refreshIfNeeded()
refreshProgressCache()
}
.sheet(item: $selectedDateItem) { item in
HistoryDayDetailSheet(
date: item.date,
completions: store.habitCompletions(for: item.date, ritual: selectedRitual),
ritual: selectedRitual,
store: store
)
}

View File

@ -4,6 +4,7 @@ import UniformTypeIdentifiers
struct InsightsView: View {
@Bindable var store: RitualStore
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var refreshToken = UUID()
@State private var isEditing = false
@State private var draggingCard: InsightCardType?
@ -44,6 +45,7 @@ struct InsightsView: View {
}
}
.padding(Design.Spacing.large)
.adaptiveContentWidth()
}
.background(LinearGradient(
colors: [AppSurface.primary, AppSurface.secondary],
@ -51,7 +53,7 @@ struct InsightsView: View {
endPoint: .bottomTrailing
))
.navigationTitle(String(localized: "Insights"))
.navigationBarTitleDisplayMode(.large)
.navigationBarTitleDisplayMode(horizontalSizeClass == .regular ? .inline : .large)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
@ -78,6 +80,7 @@ struct InsightsView: View {
}
}
.onAppear {
store.refreshIfNeeded()
cardOrder = store.insightCardOrder
Task {
await Task.yield()

View File

@ -114,7 +114,7 @@ struct ArcDetailView: View {
.sheet(item: $selectedDateItem) { item in
HistoryDayDetailSheet(
date: item.date,
completions: arcHabitCompletions(for: item.date),
ritual: ritual,
store: store
)
}

View File

@ -63,7 +63,7 @@ struct RitualsView: View {
endPoint: .bottomTrailing
))
.navigationTitle(String(localized: "Rituals"))
.navigationBarTitleDisplayMode(.large)
.navigationBarTitleDisplayMode(horizontalSizeClass == .regular ? .inline : .large)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
@ -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)

View File

@ -94,10 +94,6 @@ struct PresetLibrarySheet: View {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
HStack {
Text(preset.title).styled(.heading, emphasis: .primary)
if isAdded {
SymbolIcon("checkmark.circle.fill", size: .badge, color: AppStatus.success)
}
}
Text(preset.theme).styled(.subheading, emphasis: .secondary)
@ -106,14 +102,17 @@ 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)
if isAdded {
SymbolIcon("checkmark.circle.fill", size: .badge, color: AppStatus.success)
}
}
}
// Habit preview
// Habit preview and Time of Day
HStack(alignment: .center, spacing: Design.Spacing.small) {
// Habit icons and count
HStack(spacing: Design.Spacing.small) {
HStack(spacing: Design.Spacing.xxxSmall) {
ForEach(preset.habits.prefix(4)) { habit in
SymbolIcon(habit.symbolName, size: .badge, color: AppTextColors.tertiary)
}
@ -122,6 +121,16 @@ struct PresetLibrarySheet: View {
Text("+\(preset.habits.count - 4)").styled(.caption2, emphasis: .tertiary)
}
}
Text(String(localized: "\(preset.habits.count) habits")).styled(.caption, 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 +196,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 +269,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
}
}
}

View File

@ -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"]),

View File

@ -8,6 +8,11 @@ struct RootView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var selectedTab: RootTab
@State private var analyticsPrewarmTask: Task<Void, Never>?
@State private var isForegroundRefreshing = false
@State private var isResumingFromBackground = true
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 {
@ -34,13 +39,19 @@ struct RootView: View {
self.settingsStore = settingsStore
self.categoryStore = categoryStore
self._selectedTab = State(initialValue: initialTab)
// Update time-of-day immediately before any views render.
// This ensures correct rituals are shown when app resumes from background.
store.updateCurrentTimeOfDay()
}
var body: some View {
ZStack {
TabView(selection: $selectedTab) {
Tab(String(localized: "Today"), systemImage: "sun.max.fill", value: RootTab.today) {
NavigationStack {
TodayView(store: store, categoryStore: categoryStore)
.id(store.currentTimeOfDay)
}
}
@ -68,16 +79,59 @@ struct RootView: View {
}
}
}
if isForegroundRefreshing {
AppSurface.primary
.ignoresSafeArea()
.overlay {
ProgressView()
.tint(AppAccent.primary)
}
.transition(.opacity)
}
// Brief overlay when resuming to hide stale snapshot
if isResumingFromBackground {
AppSurface.primary
.ignoresSafeArea()
}
}
.tint(AppAccent.primary)
.background(AppSurface.primary.ignoresSafeArea())
.animation(.easeInOut(duration: 0.12), value: isForegroundRefreshing)
.animation(.easeIn(duration: 0.05), value: isResumingFromBackground)
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
store.reminderScheduler.clearBadge()
refreshCurrentTab()
// Update time-of-day immediately (synchronously) before any UI refresh.
// This ensures the correct rituals are shown without a visible transition.
store.updateCurrentTimeOfDay()
// Hide resume overlay after a tiny delay to allow view to update
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(50))
isResumingFromBackground = false
}
let useDebugOverlay = UserDefaults.standard.bool(forKey: debugForegroundRefreshKey)
if useDebugOverlay {
UserDefaults.standard.set(false, forKey: debugForegroundRefreshKey)
}
// Only show overlay for debug refreshes. Normal foreground refreshes
// happen silently to avoid jarring transitions when crossing time boundaries.
refreshAllTabs(
showOverlay: useDebugOverlay,
minimumSeconds: useDebugOverlay ? debugForegroundRefreshMinimumSeconds : foregroundRefreshMinimumSeconds
)
} else if newPhase == .background {
// Prepare for next resume
isResumingFromBackground = true
}
}
.onChange(of: selectedTab) { _, _ in
refreshCurrentTab()
refreshAllTabs(showOverlay: false, minimumSeconds: foregroundRefreshMinimumSeconds)
}
.onChange(of: store.reminderScheduler.shouldNavigateToToday) { _, shouldNavigate in
if shouldNavigate {
@ -107,23 +161,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()
if showOverlay {
store.refresh()
} else {
store.refreshIfNeeded()
}
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 selectedTab == .settings {
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

@ -1,8 +1,10 @@
import SwiftUI
import Bedrock
import WidgetKit
struct SettingsView: View {
@Bindable var store: SettingsStore
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var ritualStore: RitualStore?
var categoryStore: CategoryStore?
@ -163,24 +165,44 @@ 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")
}
SettingsRow(
systemImage: "widget.small",
title: String(localized: "Reload Widget Timelines"),
iconColor: AppAccent.primary
) {
WidgetCenter.shared.reloadAllTimelines()
}
if let ritualStore {
TimeOfDayDebugPicker(store: ritualStore)
}
}
#endif
Spacer(minLength: Design.Spacing.xxxLarge)
}
.padding(.horizontal, Design.Spacing.large)
.adaptiveContentWidth(maxWidth: Design.Size.maxContentWidthPortrait)
.padding(Design.Spacing.large)
.adaptiveContentWidth()
}
.onAppear {
store.refresh()
ritualStore?.refresh()
ritualStore?.refreshIfNeeded()
Task {
await ritualStore?.reminderScheduler.refreshStatus()
}
}
.background(AppSurface.primary)
.navigationTitle(String(localized: "Settings"))
.navigationBarTitleDisplayMode(.large)
.navigationBarTitleDisplayMode(horizontalSizeClass == .regular ? .inline : .large)
}
}
@ -231,6 +253,85 @@ extension SettingsView {
}
}
// MARK: - Debug Time of Day Picker
#if DEBUG
/// Debug picker for simulating different times of day
private struct TimeOfDayDebugPicker: View {
@Bindable var store: RitualStore
private var currentSelection: TimeOfDay? {
store.debugTimeOfDayOverride
}
private var displayText: String {
if let override = store.debugTimeOfDayOverride {
return override.displayName
}
return String(localized: "Real Time (\(TimeOfDay.current().displayName))")
}
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
HStack {
Image(systemName: "clock.badge.questionmark")
.foregroundStyle(AppStatus.warning)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "Simulate Time of Day"))
.font(.body)
.foregroundStyle(AppTextColors.primary)
Text(displayText)
.font(.caption)
.foregroundStyle(AppTextColors.secondary)
}
Spacer()
}
.padding(.vertical, Design.Spacing.small)
// Time of day options
LazyVGrid(columns: [GridItem(.adaptive(minimum: 80))], spacing: Design.Spacing.small) {
// Real time option
Button {
store.debugTimeOfDayOverride = nil
} label: {
Text(String(localized: "Real"))
.font(.caption.weight(.medium))
.foregroundStyle(currentSelection == nil ? AppTextColors.inverse : AppTextColors.primary)
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xSmall)
.frame(maxWidth: .infinity)
.background(currentSelection == nil ? AppAccent.primary : AppSurface.secondary)
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.small))
}
.buttonStyle(.plain)
// Each time of day
ForEach(TimeOfDay.allCases, id: \.self) { time in
Button {
store.debugTimeOfDayOverride = time
} label: {
Text(time.displayName)
.font(.caption.weight(.medium))
.foregroundStyle(currentSelection == time ? AppTextColors.inverse : AppTextColors.primary)
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xSmall)
.frame(maxWidth: .infinity)
.background(currentSelection == time ? AppAccent.primary : AppSurface.secondary)
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.small))
}
.buttonStyle(.plain)
}
}
}
.padding(.horizontal, Design.Spacing.medium)
}
}
#endif
#Preview {
NavigationStack {
SettingsView(store: SettingsStore.preview, ritualStore: nil)

View File

@ -5,10 +5,14 @@ struct TodayView: View {
@Bindable var store: RitualStore
@Bindable var categoryStore: CategoryStore
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.scenePhase) private var scenePhase
/// Rituals to show now based on current time of day
/// Rituals to show now based on current time of day.
/// Depends on `store.currentTimeOfDay` which is observable.
private var todayRituals: [Ritual] {
store.ritualsForToday()
// Access currentTimeOfDay to establish observation dependency
_ = store.currentTimeOfDay
return store.ritualsForToday()
}
/// Whether there are active rituals but none for the current time
@ -75,7 +79,7 @@ struct TodayView: View {
endPoint: .bottomTrailing
))
.navigationTitle(String(localized: "Today"))
.navigationBarTitleDisplayMode(.large)
.navigationBarTitleDisplayMode(horizontalSizeClass == .regular ? .inline : .large)
.sheet(isPresented: .init(
get: { showRenewalSheet },
set: { if !$0 { store.dismissRenewalPrompt() } }
@ -84,6 +88,18 @@ struct TodayView: View {
ArcRenewalSheet(store: store, categoryStore: categoryStore, ritual: ritual)
}
}
.onAppear {
store.updateCurrentTimeOfDay()
store.refreshIfNeeded()
}
.onChange(of: scenePhase) { _, newPhase in
// Check for time-of-day changes when app becomes active
if newPhase == .active {
if store.updateCurrentTimeOfDay() {
store.refresh()
}
}
}
}
private func habitRows(for ritual: Ritual) -> [HabitRowModel] {

View File

@ -26,6 +26,24 @@ struct RitualStoreTests {
#expect(store.isHabitCompletedToday(habit) == true)
}
@MainActor
@Test func toggleHabitCompletionForSpecificDate() throws {
let store = makeStore()
store.createQuickRitual()
guard let habit = store.activeRitual?.habits.first else {
throw TestError.missingHabit
}
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date())!
store.toggleHabitCompletion(habit, date: yesterday)
let completions = store.habitCompletions(for: yesterday)
let completion = completions.first { $0.habit.id == habit.id }
#expect(completion?.isCompleted == true)
#expect(store.isHabitCompletedToday(habit) == false)
}
@MainActor
@Test func arcRenewalCreatesNewArc() throws {
let store = makeStore()

View File

@ -13,3 +13,96 @@ struct WidgetEntry: TimelineEntry {
let currentTimeOfDayRange: String
let nextRitualInfo: String?
}
// MARK: - Preview Helpers
extension WidgetEntry {
/// Creates a preview entry for a specific time of day
static func preview(
timeOfDay: String,
symbol: String,
range: String,
habits: [HabitEntry] = [],
completionRate: Double = 0.65,
streak: Int = 7,
nextRitual: String? = nil
) -> WidgetEntry {
WidgetEntry(
date: Date(),
configuration: ConfigurationAppIntent(),
completionRate: completionRate,
currentStreak: streak,
nextHabits: habits,
weeklyTrend: [],
currentTimeOfDay: timeOfDay,
currentTimeOfDaySymbol: symbol,
currentTimeOfDayRange: range,
nextRitualInfo: nextRitual
)
}
/// Preview entries for each time of day
static let morningPreview = WidgetEntry.preview(
timeOfDay: "Morning",
symbol: "sunrise.fill",
range: "Before 11am",
habits: [
HabitEntry(id: UUID(), title: "Morning Meditation", symbolName: "figure.mind.and.body", ritualTitle: "Mindfulness", isCompleted: false),
HabitEntry(id: UUID(), title: "Drink Water", symbolName: "drop.fill", ritualTitle: "Health", isCompleted: true),
HabitEntry(id: UUID(), title: "Take Vitamins", symbolName: "pill.fill", ritualTitle: "Health", isCompleted: false)
],
nextRitual: "Next: Midday Movement (11am 2pm)"
)
static let middayPreview = WidgetEntry.preview(
timeOfDay: "Midday",
symbol: "sun.max.fill",
range: "11am 2pm",
habits: [
HabitEntry(id: UUID(), title: "Midday Walk", symbolName: "figure.walk", ritualTitle: "Movement", isCompleted: false),
HabitEntry(id: UUID(), title: "Stretch Break", symbolName: "figure.flexibility", ritualTitle: "Movement", isCompleted: false)
],
nextRitual: "Next: Deep Work (2pm 5pm)"
)
static let afternoonPreview = WidgetEntry.preview(
timeOfDay: "Afternoon",
symbol: "sun.haze.fill",
range: "2pm 5pm",
habits: [
HabitEntry(id: UUID(), title: "Deep Focus Block", symbolName: "brain", ritualTitle: "Productivity", isCompleted: true),
HabitEntry(id: UUID(), title: "Clear Inbox", symbolName: "envelope.fill", ritualTitle: "Productivity", isCompleted: false)
],
nextRitual: "Next: Evening Wind-Down (5pm 9pm)"
)
static let eveningPreview = WidgetEntry.preview(
timeOfDay: "Evening",
symbol: "sunset.fill",
range: "5pm 9pm",
habits: [
HabitEntry(id: UUID(), title: "Evening Reflection", symbolName: "book.fill", ritualTitle: "Mindfulness", isCompleted: false),
HabitEntry(id: UUID(), title: "Gratitude Journal", symbolName: "heart.text.square.fill", ritualTitle: "Mindfulness", isCompleted: false)
],
nextRitual: "Next: Sleep Prep (After 9pm)"
)
static let nightPreview = WidgetEntry.preview(
timeOfDay: "Night",
symbol: "moon.stars.fill",
range: "After 9pm",
habits: [
HabitEntry(id: UUID(), title: "No Screens", symbolName: "iphone.slash", ritualTitle: "Sleep Prep", isCompleted: false),
HabitEntry(id: UUID(), title: "Read Book", symbolName: "book.closed.fill", ritualTitle: "Sleep Prep", isCompleted: false)
],
nextRitual: "Next: Morning Routine (Tomorrow)"
)
static let emptyPreview = WidgetEntry.preview(
timeOfDay: "Afternoon",
symbol: "sun.haze.fill",
range: "2pm 5pm",
habits: [],
nextRitual: "Next: Evening Wind-Down (5pm 9pm)"
)
}

View File

@ -23,17 +23,70 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
}
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> WidgetEntry {
await fetchLatestData(for: configuration)
await fetchLatestData(for: configuration, at: Date())
}
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<WidgetEntry> {
let entry = await fetchLatestData(for: configuration)
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date()
return Timeline(entries: [entry], policy: .after(nextUpdate))
// Create entries for each time-of-day boundary for the next 24 hours.
// This ensures the widget displays the correct rituals at each time period.
var entries: [WidgetEntry] = []
let calendar = Calendar.current
let now = Date()
// Create an entry for right now
let currentEntry = await fetchLatestData(for: configuration, at: now)
entries.append(currentEntry)
// Create entries at each upcoming time-of-day boundary
let boundaryDates = upcomingTimeOfDayBoundaries(from: now, count: 5)
for boundaryDate in boundaryDates {
let entry = await fetchLatestData(for: configuration, at: boundaryDate)
entries.append(entry)
}
// Request a new timeline after the last entry (roughly 24 hours)
let lastEntryDate = entries.last?.date ?? now
let nextRefresh = calendar.date(byAdding: .hour, value: 1, to: lastEntryDate) ?? lastEntryDate
return Timeline(entries: entries, policy: .after(nextRefresh))
}
/// Returns the next N time-of-day boundary dates.
/// Boundaries are at 11:00, 14:00, 17:00, 21:00, and 00:00.
private func upcomingTimeOfDayBoundaries(from startDate: Date, count: Int) -> [Date] {
let calendar = Calendar.current
let boundaryHours = [0, 11, 14, 17, 21] // midnight, morning end, midday end, afternoon end, evening end
var boundaries: [Date] = []
var checkDate = startDate
while boundaries.count < count {
let currentHour = calendar.component(.hour, from: checkDate)
let startOfDay = calendar.startOfDay(for: checkDate)
// Find the next boundary hour after the current hour
for hour in boundaryHours {
if hour > currentHour {
if let boundaryDate = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: startOfDay) {
boundaries.append(boundaryDate)
if boundaries.count >= count { break }
}
}
}
// Move to the next day for remaining boundaries
if let nextDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) {
checkDate = nextDay
} else {
break
}
}
return boundaries
}
@MainActor
private func fetchLatestData(for configuration: ConfigurationAppIntent) -> WidgetEntry {
private func fetchLatestData(for configuration: ConfigurationAppIntent, at targetDate: Date) -> WidgetEntry {
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
let configurationURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite")
@ -47,12 +100,18 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
let descriptor = FetchDescriptor<Ritual>()
let rituals = try context.fetch(descriptor)
let today = Date()
// Use targetDate for time-of-day calculation, but today for completion data
let today = Calendar.current.startOfDay(for: targetDate)
let dayID = RitualAnalytics.dayIdentifier(for: today)
let timeOfDay = TimeOfDay.current(for: today)
let timeOfDay = TimeOfDay.current(for: targetDate)
// Match the app's logic for "Today" view
let todayRituals = RitualAnalytics.ritualsActive(on: today, from: rituals)
// Filter rituals for the target time of day
let activeRituals = rituals.filter { ritual in
guard ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) != nil else {
return false
}
return ritual.timeOfDay == .anytime || ritual.timeOfDay == timeOfDay
}
.sorted { lhs, rhs in
if lhs.timeOfDay != rhs.timeOfDay {
return lhs.timeOfDay < rhs.timeOfDay
@ -61,7 +120,7 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
}
var visibleHabits: [HabitEntry] = []
for ritual in todayRituals {
for ritual in activeRituals {
if let arc = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) {
// Sort habits within each ritual by their sortIndex
let sortedHabits = (arc.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
@ -88,8 +147,8 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
// Next ritual info
var nextRitualString: String? = nil
if let nextRitual = RitualAnalytics.nextUpcomingRitual(from: rituals, currentDate: today) {
let isTomorrow = !Calendar.current.isDate(nextRitual.startDate, inSameDayAs: today)
if let nextRitual = RitualAnalytics.nextUpcomingRitual(from: rituals, currentDate: targetDate) {
let isTomorrow = !Calendar.current.isDate(nextRitual.startDate, inSameDayAs: targetDate)
if isTomorrow {
let format = String(localized: "Next ritual: Tomorrow %@ (%@)")
nextRitualString = String.localizedStringWithFormat(
@ -108,7 +167,7 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
}
return WidgetEntry(
date: today,
date: targetDate,
configuration: configuration,
completionRate: overallRate,
currentStreak: streak,

View File

@ -1,5 +1,6 @@
import SwiftUI
import WidgetKit
import Bedrock
struct AndromidaWidgetView: View {
var entry: WidgetEntry
@ -28,3 +29,51 @@ extension Color {
static let brandingPrimary = Color(red: 0.12, green: 0.09, blue: 0.08)
static let brandingAccent = Color(red: 0.95, green: 0.60, blue: 0.45) // Matches the orange-ish accent in your app
}
// MARK: - Previews for Testing Time-of-Day Changes
#Preview("Morning", as: .systemMedium) {
AndromidaWidget()
} timeline: {
WidgetEntry.morningPreview
}
#Preview("Midday", as: .systemMedium) {
AndromidaWidget()
} timeline: {
WidgetEntry.middayPreview
}
#Preview("Afternoon", as: .systemMedium) {
AndromidaWidget()
} timeline: {
WidgetEntry.afternoonPreview
}
#Preview("Evening", as: .systemMedium) {
AndromidaWidget()
} timeline: {
WidgetEntry.eveningPreview
}
#Preview("Night", as: .systemMedium) {
AndromidaWidget()
} timeline: {
WidgetEntry.nightPreview
}
#Preview("Empty State", as: .systemMedium) {
AndromidaWidget()
} timeline: {
WidgetEntry.emptyPreview
}
#Preview("Large - All Times", as: .systemLarge) {
AndromidaWidget()
} timeline: {
WidgetEntry.morningPreview
WidgetEntry.middayPreview
WidgetEntry.afternoonPreview
WidgetEntry.eveningPreview
WidgetEntry.nightPreview
}

View File

@ -33,7 +33,6 @@ struct LargeWidgetView: View {
.background(AppTextColors.primary.opacity(0.2))
if entry.nextHabits.isEmpty {
Spacer()
WidgetEmptyStateView(
iconSize: .section,
title: String(localized: "No rituals scheduled for \(entry.currentTimeOfDay.lowercased())."),
@ -43,7 +42,6 @@ struct LargeWidgetView: View {
nextRitual: entry.nextRitualInfo,
isCompact: false
)
Spacer()
} else {
Text(String(localized: "Habits"))
.styled(.captionEmphasis, emphasis: .custom(AppTextColors.secondary))

600
PRD.md Normal file
View File

@ -0,0 +1,600 @@
# Andromida (Rituals) - Product Requirements Document
> **Version**: 1.0
> **Last Updated**: February 2026
> **Status**: Active Development
---
## 1. Overview
### 1.1 Product Vision
Andromida (branded as "Rituals") is a premium, offline-first habit tracker built around customizable "ritual arcs" rather than endless streaks. The app focuses on steady, daily check-ins with a calm visual language, zero paid backend dependencies, and optional iCloud sync.
### 1.2 Target Audience
- Health-conscious individuals seeking structured daily routines
- Productivity enthusiasts who prefer time-bound goals over infinite streaks
- Users who value privacy and offline-first functionality
- Apple ecosystem users who want seamless iCloud integration
### 1.3 Key Differentiators
- **Arc-Based Approach**: Habits are grouped into ritual arcs (7-365 days) with defined start and end dates, allowing for natural cycles of focus and renewal
- **No Paid APIs**: Entirely self-contained with no external service dependencies
- **Offline-First**: Full functionality without network connectivity
- **Privacy-Focused**: All data stored locally with optional iCloud sync
---
## 2. User Goals
| Goal | Description |
|------|-------------|
| **Build Habits** | Establish and maintain daily rituals through consistent check-ins |
| **Track Progress** | Visualize completion rates, streaks, and historical performance |
| **Stay Motivated** | Receive milestone achievements and contextual insights |
| **Customize Experience** | Create personalized rituals with flexible scheduling |
| **Maintain Privacy** | Keep personal data local with optional cloud backup |
---
## 3. Functional Requirements
### 3.1 Today Tab
The primary daily interaction surface for habit check-ins.
| Requirement | Description |
|-------------|-------------|
| FR-TODAY-01 | Display focus ritual cards with circular progress rings showing completion percentage |
| FR-TODAY-02 | Enable tap-to-complete habit check-ins with haptic and sound feedback |
| FR-TODAY-03 | Filter rituals by time of day (morning, midday, afternoon, evening, night, anytime) |
| FR-TODAY-04 | Show smart empty states distinguishing "no rituals" from "no rituals for this time" |
| FR-TODAY-05 | Display arc renewal prompts when ritual arcs complete |
| FR-TODAY-06 | Support adaptive 2-column grid layout on iPad and landscape orientations |
| FR-TODAY-07 | Fresh install starts clean with no pre-seeded rituals |
### 3.2 Rituals Tab
Ritual creation and management interface.
| Requirement | Description |
|-------------|-------------|
| FR-RITUALS-01 | Display all active rituals with card-based UI |
| FR-RITUALS-02 | Create rituals from scratch or browse preset library |
| FR-RITUALS-03 | Provide 14 categorized presets across 4 categories (Health, Productivity, Mindfulness, Self-Care) |
| FR-RITUALS-04 | Support full ritual lifecycle: create, edit, delete |
| FR-RITUALS-05 | Enable drag-to-reorder habits within rituals |
#### 3.2.1 Ritual Detail View
| Requirement | Description |
|-------------|-------------|
| FR-DETAIL-01 | Show progress card with day count and completion summary |
| FR-DETAIL-02 | Display time remaining countdown (e.g., "12 days remaining") |
| FR-DETAIL-03 | Track ritual-specific streaks |
| FR-DETAIL-04 | Show milestone achievements (First Day, One Week, Halfway, Three Weeks, Complete) |
| FR-DETAIL-05 | Display habit performance breakdown with per-habit completion rates |
| FR-DETAIL-06 | Show status badges for time of day and category |
| FR-DETAIL-07 | Provide action menu for edit, end arc/start new arc, and delete |
#### 3.2.2 Ritual Editor
| Requirement | Description |
|-------------|-------------|
| FR-EDITOR-01 | Support custom ritual creation with title, theme, and notes |
| FR-EDITOR-02 | Provide icon picker with 50+ categorized SF Symbols and search |
| FR-EDITOR-03 | Provide habit icon picker with 100+ icons organized by category |
| FR-EDITOR-04 | Allow custom category input beyond preset categories |
| FR-EDITOR-05 | Support flexible duration: slider (7-365 days), quick presets, and custom input |
| FR-EDITOR-06 | Enable time-of-day scheduling (morning, midday, afternoon, evening, night, anytime) |
| FR-EDITOR-07 | Support drag-to-reorder habits |
#### 3.2.3 Arc Renewal System
| Requirement | Description |
|-------------|-------------|
| FR-RENEWAL-01 | Allow rituals to be renewed when arcs complete |
| FR-RENEWAL-02 | Preserve historical data (old arcs remain frozen and accessible) |
| FR-RENEWAL-03 | Copy habits from previous arc to new arc during renewal |
| FR-RENEWAL-04 | Display renewal prompts automatically when arcs complete |
### 3.3 History Tab
Historical view of past completions and performance.
| Requirement | Description |
|-------------|-------------|
| FR-HISTORY-01 | Display scrollable month calendar grid (expandable to 12 months) |
| FR-HISTORY-02 | Show daily progress rings with color coding (green=100%, accent=50%+, gray=<50%) |
| FR-HISTORY-03 | Enable filtering by ritual using horizontal pill picker |
| FR-HISTORY-04 | Support tap on any day to open detail sheet |
| FR-HISTORY-05 | Detail sheet shows: progress ring with percentage, comparison to weekly average, streak context, motivational message, grouped habit list by ritual |
| FR-HISTORY-06 | Support adaptive 2-column grid layout on iPad and landscape |
### 3.4 Insights Tab
Analytics and trend visualization.
| Requirement | Description |
|-------------|-------------|
| FR-INSIGHTS-01 | Display 8 tappable insight cards with full-screen detail sheets |
| FR-INSIGHTS-02 | **Active Rituals**: Count with per-ritual breakdown |
| FR-INSIGHTS-03 | **Streak**: Current and longest streak tracking |
| FR-INSIGHTS-04 | **Habits Today**: Completed count with per-ritual breakdown |
| FR-INSIGHTS-05 | **Completion**: Today's percentage with 7-day trend chart |
| FR-INSIGHTS-06 | **Days Active**: Total active days with detailed breakdown |
| FR-INSIGHTS-07 | **7-Day Avg**: Weekly average completion percentage with trend chart |
| FR-INSIGHTS-08 | **Total Check-ins**: All-time habit completions across all rituals |
| FR-INSIGHTS-09 | **Best Ritual**: Highest-performing ritual by completion rate |
| FR-INSIGHTS-10 | Show trend indicators (up/down/stable) with week-over-week comparison |
| FR-INSIGHTS-11 | Display contextual tips based on performance |
| FR-INSIGHTS-12 | Enable drag-and-drop card reordering in edit mode |
| FR-INSIGHTS-13 | Show sparkline charts for trend visualization |
### 3.5 Settings Tab
Application configuration and preferences.
| Requirement | Description |
|-------------|-------------|
| FR-SETTINGS-01 | Configure smart reminders based on ritual time slots (morning 7:00 AM, midday 12:00 PM, evening 6:00 PM) |
| FR-SETTINGS-02 | Toggle haptic feedback for habit check-ins |
| FR-SETTINGS-03 | Toggle sound feedback for habit check-ins |
| FR-SETTINGS-04 | Select theme (light, dark, system) |
| FR-SETTINGS-05 | Enable/disable iCloud settings sync |
| FR-SETTINGS-06 | Manage categories (create, edit, delete user categories) |
| FR-SETTINGS-07 | Provide debug tools in DEBUG builds: reset onboarding, app icon generation, branding preview, preload demo data, clear all completions, simulate arc completion |
### 3.6 Widget
Home screen widget for at-a-glance progress.
| Requirement | Description |
|-------------|-------------|
| FR-WIDGET-01 | Support small, medium, and large widget sizes |
| FR-WIDGET-02 | Display today's completion rate |
| FR-WIDGET-03 | Show current streak |
| FR-WIDGET-04 | List next habits (up to 4) |
| FR-WIDGET-05 | Indicate current time of day |
| FR-WIDGET-06 | Show next ritual information |
| FR-WIDGET-07 | Support App Intents for widget configuration |
| FR-WIDGET-08 | Update widget content every 15 minutes |
| FR-WIDGET-09 | Use App Group shared container for SwiftData access |
### 3.7 Onboarding
First-launch setup wizard.
| Requirement | Description |
|-------------|-------------|
| FR-ONBOARD-01 | **Welcome**: Introduction to the app |
| FR-ONBOARD-02 | **Goal Selection**: Choose from predefined goals |
| FR-ONBOARD-03 | **Time Selection**: Select preferred ritual times (morning/evening/both) |
| FR-ONBOARD-04 | **Ritual Preview**: Preview and optionally create preset rituals |
| FR-ONBOARD-05 | **First Check-In**: Complete first habit check-in |
| FR-ONBOARD-06 | **Notifications**: Set up reminder permissions |
| FR-ONBOARD-07 | **What's Next**: Orientation to Today, Rituals, and Insights tabs |
| FR-ONBOARD-08 | Allow onboarding reset from Settings (DEBUG builds) |
### 3.8 Deep Linking
URL scheme support for navigation.
| Requirement | Description |
|-------------|-------------|
| FR-DEEPLINK-01 | Support `andromida://today` to navigate to Today tab |
| FR-DEEPLINK-02 | Support `andromida://rituals` to navigate to Rituals tab |
| FR-DEEPLINK-03 | Support `andromida://insights` to navigate to Insights tab |
| FR-DEEPLINK-04 | Support `andromida://history` to navigate to History tab |
---
## 4. Non-Functional Requirements
### 4.1 Performance
| Requirement | Description |
|-------------|-------------|
| NFR-PERF-01 | Analytics calculations must use caching to avoid recalculation on each access |
| NFR-PERF-02 | History view must cache progress data for smooth scrolling |
| NFR-PERF-03 | App launch must complete without white flash using native LaunchScreen.storyboard |
| NFR-PERF-04 | Widget updates must complete within WidgetKit timeline constraints |
### 4.2 Accessibility
| Requirement | Description |
|-------------|-------------|
| NFR-A11Y-01 | Support Dynamic Type for all text elements |
| NFR-A11Y-02 | Provide VoiceOver accessibility labels and hints |
| NFR-A11Y-03 | Ensure sufficient color contrast ratios |
| NFR-A11Y-04 | Support reduced motion preferences |
### 4.3 Localization
| Requirement | Description |
|-------------|-------------|
| NFR-L10N-01 | All user-facing strings must be localized using String Catalogs |
| NFR-L10N-02 | Support English (en), Spanish (es-MX), and French (fr-CA) |
| NFR-L10N-03 | Use locale-appropriate date and number formatting |
### 4.4 Privacy & Security
| Requirement | Description |
|-------------|-------------|
| NFR-PRIV-01 | All user data must be stored locally on device |
| NFR-PRIV-02 | iCloud sync must be opt-in and clearly disclosed |
| NFR-PRIV-03 | No user data may be transmitted to external services |
| NFR-PRIV-04 | App must function fully offline |
---
## 5. Technical Requirements
### 5.1 Platform
| Requirement | Description |
|-------------|-------------|
| TR-PLAT-01 | iOS 18.6+ minimum deployment target |
| TR-PLAT-02 | Swift 5 with modern concurrency (async/await, actors) |
| TR-PLAT-03 | SwiftUI for all user interface |
| TR-PLAT-04 | SwiftData for persistence with `@Observable` pattern |
| TR-PLAT-05 | WidgetKit for Home screen widgets |
### 5.2 Architecture
| Requirement | Description |
|-------------|-------------|
| TR-ARCH-01 | Follow Clean Architecture with separation: Views, State, Services, Models |
| TR-ARCH-02 | Use protocol-oriented design for testability |
| TR-ARCH-03 | Implement `@Observable` stores (not ObservableObject) |
| TR-ARCH-04 | Use dependency injection via protocols |
### 5.3 Data Persistence
| Requirement | Description |
|-------------|-------------|
| TR-DATA-01 | Use SwiftData with optional CloudKit sync for ritual data |
| TR-DATA-02 | Use NSUbiquitousKeyValueStore (via Bedrock CloudSyncManager) for settings sync |
| TR-DATA-03 | Use UserDefaults for user-created categories and preferences |
| TR-DATA-04 | Use App Group shared container for widget data access |
### 5.4 Third-Party Dependencies
| Requirement | Description |
|-------------|-------------|
| TR-DEPS-01 | Bedrock design system package (internal) for theming, branding, and common UI components |
| TR-DEPS-02 | No external third-party frameworks without explicit approval |
---
## 6. Data Model
### 6.1 Core Entities
#### Ritual
Primary entity representing a habit collection.
| Property | Type | Description |
|----------|------|-------------|
| id | UUID | Unique identifier |
| title | String | Ritual name |
| theme | String | Visual theme identifier |
| notes | String? | Optional user notes |
| defaultDurationDays | Int | Default arc duration (7-365) |
| timeOfDay | TimeOfDay | Scheduling preference |
| iconName | String | SF Symbol name |
| category | String | Category identifier |
| sortIndex | Int | Display order |
| arcs | [RitualArc] | Related arcs (one-to-many, cascade delete) |
**Computed Properties**: `currentArc`, `hasActiveArc`, `sortedArcs`, `latestArc`, `completedArcCount`, `habits`
#### RitualArc
Time-bound period for a ritual.
| Property | Type | Description |
|----------|------|-------------|
| id | UUID | Unique identifier |
| startDate | Date | Arc start date |
| endDate | Date | Arc end date |
| arcNumber | Int | Sequential arc number |
| isActive | Bool | Whether arc is currently active |
| habits | [ArcHabit] | Related habits (one-to-many, cascade delete) |
| ritual | Ritual | Parent ritual (inverse relationship) |
**Computed Properties**: `durationDays`, **Methods**: `contains(date:)`, `dayIndex(for:)`, `createRenewalArc()`
#### ArcHabit
Individual habit within an arc.
| Property | Type | Description |
|----------|------|-------------|
| id | UUID | Unique identifier |
| title | String | Habit name |
| symbolName | String | SF Symbol name |
| goal | Int | Target completions |
| completedDayIDs | [String] | Array of date identifiers |
| sortIndex | Int | Display order |
| arc | RitualArc | Parent arc (inverse relationship) |
**Methods**: `copyForNewArc()`
### 6.2 Supporting Models
#### Category
User-defined or preset category.
| Property | Type | Description |
|----------|------|-------------|
| id | String | Unique identifier |
| name | String | Display name |
| colorName | String | Color identifier (13 available colors) |
| isPreset | Bool | Whether system-defined |
**Preset Categories**: Health, Productivity, Mindfulness, Self-Care
#### TimeOfDay
Scheduling enumeration.
| Case | Time Range |
|------|------------|
| morning | Before 11:00 AM |
| midday | 11:00 AM - 2:00 PM |
| afternoon | 2:00 PM - 5:00 PM |
| evening | 5:00 PM - 9:00 PM |
| night | After 9:00 PM |
| anytime | Flexible timing |
#### InsightCard
Analytics display card.
| Type | Description |
|------|-------------|
| Active | Active ritual count with breakdown |
| Streak | Current and longest streak |
| HabitsToday | Today's completed habits |
| Completion | Today's percentage with trend |
| DaysActive | Total active days |
| SevenDayAvg | Weekly average percentage |
| TotalCheckins | All-time completions |
| BestRitual | Top-performing ritual |
#### Milestone
Achievement markers.
| Milestone | Description |
|-----------|-------------|
| First Day | First day completed (day 1) |
| One Week | First week completed (day 7) |
| Halfway | 50% of arc completed (dynamic based on arc duration) |
| Three Weeks | 21 days completed |
| Complete | Full arc completed |
### 6.3 Entity Relationships
```
Ritual (1) ──────< RitualArc (many)
└────< ArcHabit (many)
```
- Ritual → RitualArc: One-to-many with cascade delete
- RitualArc → ArcHabit: One-to-many with cascade delete
- RitualArc → Ritual: Inverse relationship
---
## 7. Design System
### 7.1 Theme Integration
The app uses the Bedrock design system with a custom `RitualsTheme` providing:
- **Surface Colors**: primary, secondary, tertiary, card backgrounds
- **Text Colors**: primary, secondary, tertiary, disabled, inverse
- **Accent Colors**: primary, light, dark, secondary
- **Status Colors**: success, warning, error, info
- **Border Colors**: subtle, standard, emphasized, selected
- **Interactive Colors**: selected, hover, pressed, focus
### 7.2 Color Assets
| Asset | Purpose |
|-------|---------|
| Background, BackgroundAlt, BackgroundTertiary | Surface backgrounds |
| Card | Card backgrounds |
| Divider | Separator lines |
| Accent, AccentLight, AccentDark, AccentSecondary, AccentSoft | Brand and interactive colors |
| TextPrimary, TextSecondary, TextTertiary, TextDisabled | Typography |
| Success, Warning, Error, Info | Status indicators |
### 7.3 Branding
| Element | Specification |
|---------|---------------|
| App Name | Andromida (displayed as "Rituals") |
| Bundle ID | com.mbrucedogs.Andromida |
| App Group | group.com.mbrucedogs.Andromida |
| CloudKit Container | iCloud.com.mbrucedogs.Andromida |
### 7.4 Adaptive Layout
| Requirement | Description |
|-------------|-------------|
| Max content width constraints for iPad and landscape |
| 2-column grid layouts where appropriate |
| Responsive spacing and typography |
---
## 8. Preset Library
### 8.1 Health (3 presets)
- Morning Hydration
- Midday Movement
- Sleep Preparation
### 8.2 Productivity (3 presets)
- Deep Work Prep
- End-of-Day Review
- Focus Reset
### 8.3 Mindfulness (4 presets)
- Morning Meditation
- Gratitude Practice
- Breathwork
- Evening Reflection
### 8.4 Self-Care (4 presets)
- Morning Skincare
- Digital Detox
- Evening Wind-Down
- Weekly Reset
---
## 9. TODO
Remaining features and enhancements to be implemented.
### 9.1 HealthKit Integration
Sync habit completions to Apple Health for relevant habit types.
| Item | Description |
|------|-------------|
| Water tracking | Sync hydration habits to HealthKit water intake |
| Mindfulness | Sync meditation/breathwork habits to HealthKit mindful minutes |
| Exercise | Sync movement habits to HealthKit activity |
| Implementation plan | See `.cursor/plans/healthkit_integration_plan_ce4f933c.plan.md` |
### 9.2 Watch App
Companion watchOS app for quick habit check-ins.
| Item | Description |
|------|-------------|
| Quick check-ins | Allow habit completion directly from Apple Watch |
| Complications | Show today's progress on watch face |
| Sync | Real-time sync with iOS app via WatchConnectivity |
### 9.3 Export/Import
Backup and restore ritual data.
| Item | Description |
|------|-------------|
| Export format | JSON or other portable format for ritual data |
| Import | Restore rituals from backup file |
| Share | Share ritual templates with others |
### 9.4 Statistics
Extended analytics with longer time horizons.
| Item | Description |
|------|-------------|
| Monthly summary | Aggregate completion stats by month |
| Yearly summary | Year-in-review style analytics |
| Trend analysis | Long-term habit formation insights |
---
## 10. Project Structure
```
Andromida/
├── Andromida/ # App target
│ ├── App/
│ │ ├── Localization/ # String catalogs
│ │ ├── Models/ # SwiftData + DTOs
│ │ │ ├── Ritual.swift
│ │ │ ├── RitualArc.swift
│ │ │ ├── ArcHabit.swift
│ │ │ ├── Category.swift
│ │ │ ├── InsightCard.swift
│ │ │ ├── Milestone.swift
│ │ │ ├── OnboardingGoal.swift
│ │ │ └── RitualPresets.swift
│ │ ├── Protocols/ # Interfaces for stores/services
│ │ │ ├── RitualStoreProviding.swift
│ │ │ ├── RitualSeedProviding.swift
│ │ │ └── InsightTipsProviding.swift
│ │ ├── Services/ # Stateless logic
│ │ │ ├── ReminderScheduler.swift
│ │ │ └── RitualSeedService.swift
│ │ ├── State/ # @Observable stores
│ │ │ ├── RitualStore.swift
│ │ │ ├── CategoryStore.swift
│ │ │ └── SettingsStore.swift
│ │ └── Views/ # SwiftUI features
│ │ ├── Today/
│ │ ├── Rituals/
│ │ ├── History/
│ │ ├── Insights/
│ │ ├── Settings/
│ │ └── Onboarding/
│ ├── Shared/ # Theme + branding + analytics
│ │ ├── Configuration/ # xcconfig files
│ │ ├── Services/ # RitualAnalytics
│ │ └── Theme/ # RitualsTheme
│ └── Resources/ # LaunchScreen.storyboard
├── AndromidaWidget/ # Widget extension
│ ├── Intents/ # App Intents
│ ├── Models/ # Widget-specific models
│ ├── Providers/ # Timeline provider
│ └── Views/ # Widget views
├── AndromidaTests/ # Unit tests
├── AndromidaUITests/ # UI tests
└── Bedrock/ # Shared design system package
```
---
## 11. Key Files Reference
| File | Purpose |
|------|---------|
| `Andromida/AndromidaApp.swift` | App entry point |
| `Andromida/Shared/Theme/RitualsTheme.swift` | Bedrock theme configuration |
| `Andromida/Shared/BrandingConfig.swift` | Branding constants |
| `Andromida/Shared/Configuration/Base.xcconfig` | Build configuration source of truth |
| `Andromida/Resources/LaunchScreen.storyboard` | Native launch screen |
| `Andromida/App/State/RitualStore.swift` | Primary data store |
| `Andromida/App/State/SettingsStore.swift` | Settings with cloud sync |
| `Andromida/App/State/CategoryStore.swift` | Category management |
| `Andromida/App/Services/ReminderScheduler.swift` | Notification scheduling |
| `AndromidaWidget/AndromidaWidget.swift` | Widget entry point |
---
## 12. Testing Requirements
| Requirement | Description |
|-------------|-------------|
| Unit tests in `AndromidaTests/` covering store logic and analytics |
| UI tests in `AndromidaUITests/` for critical user flows |
| Test command: `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro'` |
---
## Revision History
| Version | Date | Description |
|---------|------|-------------|
| 1.0 | February 2026 | Initial PRD based on implemented features |
| 1.1 | February 2026 | Fixed time-of-day refresh bug in Today view and Widget; added debug time simulation |

88
TODO.md
View File

@ -1,88 +0,0 @@
# Andromida Focus & Fix List
## 1) Onboarding walkthrough (Sherpa)
- [x] Restore Sherpa tags for focus ritual card and first habit row without triggering Swift compiler crashes.
- [x] Confirm walkthrough starts on first launch (ensure `hasCompletedOnboarding` is false in `@AppStorage`).
- [x] Add a debug-only "Reset Onboarding" action in Settings to clear `hasCompletedOnboarding`.
- [x] Verify tags visually align with the intended UI elements on iPhone 17 Pro Max.
## 2) Swift compiler stability
- [x] Identify the minimal Sherpa usage pattern that avoids the "failed to produce diagnostic" crash.
- [x] Avoid `#Preview` macro ambiguity when Sherpa is imported (use `#if DEBUG` + `PreviewProvider` or remove previews for Sherpa-tagged views).
- [x] Avoid ambiguous accessibility modifier overloads when Sherpa is imported.
## 3) Today tab UX polish
- [x] Re-add accessibility value/hint for habit rows once Sherpa-related ambiguity is resolved.
- [x] Confirm focus ritual card and habit rows still match the intended visual hierarchy after refactors.
- [x] Smart empty states: distinguish "no rituals" vs "no rituals for current time of day".
- [x] Fresh install starts clean (no pre-seeded rituals).
## 4) Settings & product readiness
- [x] Add a paid-app placeholder (e.g., "Pro unlock" copy) without backend requirements.
- [x] Confirm default settings and theme in Settings match Bedrock branding.
- [x] Wire up haptics setting to habit check-in feedback.
- [x] Wire up sound setting to habit check-in feedback.
- [x] Wire up ritual length setting to quick ritual creation.
- [x] Add daily reminder notification scheduling with time picker.
## 5) Data & defaults
- [x] Confirm seed ritual creation and quick ritual creation behave as expected.
- [x] Validate SwiftData sync (if enabled) doesn't require any external API.
- [x] Remove automatic seed rituals on fresh install.
## 6) QA checklist
- [x] First-launch walkthrough appears on a clean install.
- [x] Onboarding can be manually reset from Settings.
- [x] No build warnings or Swift compiler crashes.
- [x] iPhone 17 Pro Max simulator layout verified on Today, Rituals, Insights, Settings.
## 7) Completed enhancements
- [x] **History view** View past/completed rituals with completion percentages.
- [x] Scrollable month calendar grid
- [x] Daily progress rings with color coding
- [x] Filter by ritual using horizontal pill picker
- [x] Tap day for detail sheet with habit list
- [x] New History tab in tab bar
- [x] Percentage display inside progress ring
- [x] Comparison to weekly average badge
- [x] Streak context badge
- [x] Motivational messages
- [x] **Ritual management** Create, edit, delete, and archive rituals.
- [x] Model enhancements (isEnabled, isArchived, timeOfDay, iconName, category)
- [x] RitualStore CRUD methods (create, update, delete, enable, archive)
- [x] Preset library with 13 categorized presets (Health, Productivity, Mindfulness, Self-Care)
- [x] RitualsView toolbar menu (Create New, Browse Presets)
- [x] RitualEditSheet for create/edit form with icon picker
- [x] PresetLibrarySheet with category tabs and detail views
- [x] RitualDetailView action menu (Edit, Enable/Disable, Archive, Delete)
- [x] Destructive action confirmations with history warning
- [x] Today view filtering by isEnabled, isArchived, and timeOfDay
- [x] Custom category input (beyond preset categories)
- [x] Habit icon picker with 100+ icons, search, and categories
- [x] Flexible duration: slider (7-365 days) + quick presets + custom input
- [x] Drag-to-reorder habits
- [x] **Ritual detail enhancements**
- [x] Time remaining countdown
- [x] Ritual-specific streak tracking
- [x] Milestone achievements (Day 1, Week 1, Halfway, Complete)
- [x] Habit performance breakdown with completion rates
- [x] **Insights enhancements** Weekly/monthly trends, streak data, charts.
- [x] Tappable insight cards with detail sheets
- [x] Explanations for each metric
- [x] Per-ritual breakdowns
- [x] Streak tracking (current & longest)
- [x] 7-day trend chart with sparkline preview
- [x] Trend indicators (up/down/stable) with week-over-week comparison
- [x] Contextual tips based on performance
- [x] Days Active breakdown showing calculation details
## 8) Future enhancements
- [ ] **HealthKit integration** Sync habit completions (water, mindfulness, exercise) to Apple Health. See plan: `.cursor/plans/healthkit_integration_plan_ce4f933c.plan.md`
- [ ] **Widget** Home screen widget showing today's progress.
- [ ] **Watch app** Companion app for quick habit check-ins.
- [x] **Smart Reminders** Time-slot based reminders (morning/midday/evening) scheduled automatically based on active rituals.
- [ ] **Export/Import** Backup and restore ritual data.
- [ ] **Statistics** Monthly/yearly summary views.