Compare commits
10 Commits
6c524ba855
...
27a2ceb534
| Author | SHA1 | Date | |
|---|---|---|---|
| 27a2ceb534 | |||
| 938b2bc033 | |||
| 319bad27a1 | |||
| f1cbd5f6a1 | |||
| 1689e7cec2 | |||
| 94e311ac47 | |||
| 3f385ac0f8 | |||
| 08fef9ffe3 | |||
| baedb98b2b | |||
| 49f3fb90a9 |
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"]),
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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] {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)"
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
600
PRD.md
Normal 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
88
TODO.md
@ -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.
|
||||
Loading…
Reference in New Issue
Block a user