Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
ed26a9d367
commit
b444e85b04
@ -408,6 +408,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Andromida/Andromida.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||
@ -441,6 +442,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Andromida/Andromida.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||
|
||||
@ -4,7 +4,9 @@
|
||||
<dict>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array/>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -3,6 +3,22 @@
|
||||
"strings" : {
|
||||
"" : {
|
||||
|
||||
},
|
||||
"%lld" : {
|
||||
"comment" : "A text view displaying the day number in a history calendar cell. The text is centered and has a small font size.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"%lld of %lld" : {
|
||||
"comment" : "A title that shows the number of habits completed on a specific day, followed by a label that describes what that number represents. The first argument is the count of completed habits. The second argument is a string that describes the nature of the habits being counted.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$lld of %2$lld"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld of %lld habits complete" : {
|
||||
"localizations" : {
|
||||
@ -26,6 +42,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld percent complete" : {
|
||||
"comment" : "A label describing the completion percentage of a task. The argument is the percentage.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"%lld%%" : {
|
||||
"comment" : "A text label displaying a percentage. The argument is a value between 0.0 and 1.0.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"7-Day Trend" : {
|
||||
"comment" : "A heading for the 7-day trend section of an insight detail sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"A fresh ritual created from your focus today." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -97,6 +125,7 @@
|
||||
}
|
||||
},
|
||||
"Across all rituals" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -118,7 +147,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Active" : {
|
||||
"comment" : "Title for an insight card showing the number of active rituals.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Active rituals" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -166,6 +200,10 @@
|
||||
"comment" : "Subtitle for a feature in the \"Rituals Pro\" upgrade screen, related to \"Advanced Insights\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"All" : {
|
||||
"comment" : "Title for the \"All\" option in the history ritual filter picker.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Balanced daily check-ins" : {
|
||||
"comment" : "Description of what the \"Steady\" focus style means for the user.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -214,7 +252,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Breakdown" : {
|
||||
"comment" : "A label displayed above the breakdown of a user's insights.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Check-ins completed" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -279,6 +322,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Clear All Completions" : {
|
||||
|
||||
},
|
||||
"Coming Soon" : {
|
||||
"comment" : "A label indicating that a feature is coming soon.",
|
||||
@ -306,6 +352,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Completed today" : {
|
||||
"comment" : "Explanation of the Insight Card titled \"Habits today\", describing what the value represents.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Completion" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -327,6 +377,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Consecutive perfect days" : {
|
||||
|
||||
},
|
||||
"Create as many arcs as you need" : {
|
||||
"comment" : "Subtitle for a feature row in the \"Rituals Pro\" upgrade view, describing the ability to create as many rituals as needed.",
|
||||
@ -376,6 +429,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Current streak" : {
|
||||
"comment" : "Label for a breakdown item in the \"Streak\" insight card, indicating the current streak of consecutive days with 100% habit completion.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Custom Ritual" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -420,6 +477,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Day" : {
|
||||
"comment" : "Label for the x-axis in the mini sparkline chart within an `InsightCardView`.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Day %lld of %lld" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -464,6 +525,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Days" : {
|
||||
"comment" : "Title of an insight card showing the total number of days the user has been working on their rituals.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Days on your journey" : {
|
||||
"comment" : "Title of an insight card that shows the total number of days a user has been working on their rituals.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Debug" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -491,7 +560,6 @@
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Done" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -592,6 +660,7 @@
|
||||
|
||||
},
|
||||
"Feel a soft response on check-in" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -815,6 +884,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"habits completed" : {
|
||||
"comment" : "A description of how many habits have been completed on a specific day.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Habits today" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -861,6 +934,10 @@
|
||||
},
|
||||
"Help us build more features" : {
|
||||
|
||||
},
|
||||
"History" : {
|
||||
"comment" : "Title of the history view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Hydrate" : {
|
||||
"localizations" : {
|
||||
@ -928,6 +1005,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"In progress now" : {
|
||||
"comment" : "Description of the \"Active\" insight card, indicating the number of active rituals.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Insights" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -972,6 +1053,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Last 7 days average" : {
|
||||
"comment" : "Explanation of the \"Weekly trend\" insight card, describing how the user's average completion rate over the last 7 days is displayed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Last synced %@" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@ -995,6 +1080,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Longest streak" : {
|
||||
"comment" : "Label for the longest streak breakdown item.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Mindful minute" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -1083,6 +1172,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No habits tracked" : {
|
||||
"comment" : "A message displayed when a user has not tracked any habits on a given day.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No ritual yet" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -1218,6 +1311,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Preload 6 Months Demo Data" : {
|
||||
|
||||
},
|
||||
"Preview launch and icon" : {
|
||||
"localizations" : {
|
||||
@ -1321,7 +1417,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Ritual arcs you're currently working on. Each ritual is a focused journey lasting 2-8 weeks, helping you build lasting habits through consistency." : {
|
||||
"comment" : "Explanation of the insight card titled \"Active\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ritual days" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -1483,6 +1584,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show less" : {
|
||||
"comment" : "A button label that indicates to collapse the history view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Show more" : {
|
||||
"comment" : "A button label that indicates more content is available.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Sign in to iCloud to enable sync" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@ -1594,6 +1703,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Streak" : {
|
||||
"comment" : "Title of a section in the Insight Cards view, related to streaks of consecutive perfect days.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Stretch" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -1806,6 +1919,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Tap for details" : {
|
||||
"comment" : "A hint that appears when a user taps on an element to learn more about it.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Tap to check in" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -1828,6 +1945,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Tap to view details" : {
|
||||
"comment" : "A hint that appears when a user taps on a day cell to explain that it will navigate to more details about that day.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"The number of habits you've checked off today across all your active rituals. Each check-in builds momentum toward your goals." : {
|
||||
"comment" : "Explanation of the value for the Insight Card titled \"Habits today\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"The total number of days you've been working on your rituals. This shows your progress through each arc, combining all active rituals." : {
|
||||
"comment" : "Explanation of the \"Days\" insight card in the Ritual Insights section.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"This day has no habit data recorded." : {
|
||||
"comment" : "A description displayed when a day has no habit completion data.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Time for your rituals" : {
|
||||
"comment" : "Title of a notification displayed at the start of the day.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -1854,7 +1987,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Today's progress" : {
|
||||
"comment" : "Explanation of the \"Today's progress\" insight card in the Ritual Insights section.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Total arcs in motion" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -1877,6 +2015,7 @@
|
||||
}
|
||||
},
|
||||
"Total days logged" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -1914,6 +2053,21 @@
|
||||
"comment" : "Text for a settings card that allows users to upgrade to the Pro version of the app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Vibrate when completing habits" : {
|
||||
|
||||
},
|
||||
"Weekly completion chart" : {
|
||||
"comment" : "An accessibility label for the weekly completion chart in the insight detail sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Weekly trend" : {
|
||||
"comment" : "Title of an insight card that shows the user their average completion rate over the last 7 days.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"What this means" : {
|
||||
"comment" : "A label displayed above the explanation text in the insight detail sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Why arcs keep habits grounded" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -1980,6 +2134,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Your average completion rate over the last 7 days. This shows how consistent you've been recently and helps identify patterns." : {
|
||||
"comment" : "Text for the \"Weekly trend\" insight card, describing its purpose and functionality.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Your completion percentage for today across all rituals. This resets each morning, giving you a fresh start every day." : {
|
||||
"comment" : "Explanation of the insight card that shows the user their completion percentage for today across all their active rituals. This percentage resets each morning, providing a fresh start every day.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Your current streak of consecutive days with 100% habit completion. Complete all your habits today to keep the streak going!" : {
|
||||
"comment" : "Explanation for the \"Streak\" insight card.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Your focus ritual lives here" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -2002,6 +2168,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Your journey over time" : {
|
||||
"comment" : "Subtitle for the History view, describing what the view is about.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Your next chapter" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
|
||||
16
Andromida/App/Models/HabitCompletion.swift
Normal file
16
Andromida/App/Models/HabitCompletion.swift
Normal file
@ -0,0 +1,16 @@
|
||||
//
|
||||
// HabitCompletion.swift
|
||||
// Andromida
|
||||
//
|
||||
// Represents a habit's completion status for a specific day.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents a habit's completion status with its ritual context.
|
||||
struct HabitCompletion: Identifiable {
|
||||
var id: UUID { habit.id }
|
||||
let habit: Habit
|
||||
let ritualTitle: String
|
||||
let isCompleted: Bool
|
||||
}
|
||||
@ -1,23 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
/// A single insight metric displayed on the Insights tab.
|
||||
struct InsightCard: Identifiable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let value: String
|
||||
let caption: String
|
||||
let caption: String // Short description shown on card
|
||||
let explanation: String // Full explanation for detail sheet
|
||||
let symbolName: String
|
||||
let breakdown: [BreakdownItem]? // Optional per-ritual breakdown
|
||||
let trendData: [TrendDataPoint]? // Optional trend data for sparkline charts
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
title: String,
|
||||
value: String,
|
||||
caption: String,
|
||||
symbolName: String
|
||||
explanation: String = "",
|
||||
symbolName: String,
|
||||
breakdown: [BreakdownItem]? = nil,
|
||||
trendData: [TrendDataPoint]? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.caption = caption
|
||||
self.explanation = explanation
|
||||
self.symbolName = symbolName
|
||||
self.breakdown = breakdown
|
||||
self.trendData = trendData
|
||||
}
|
||||
}
|
||||
|
||||
/// A single item in the insight breakdown list.
|
||||
struct BreakdownItem: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
let value: String
|
||||
}
|
||||
|
||||
/// A single data point for trend charts.
|
||||
struct TrendDataPoint: Identifiable {
|
||||
let id = UUID()
|
||||
let date: Date
|
||||
let value: Double // 0.0 to 1.0 for percentage completion
|
||||
let label: String // e.g., "Mon", "Tue"
|
||||
}
|
||||
|
||||
@ -119,36 +119,212 @@ final class RitualStore: RitualStoreProviding {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Streak Tracking
|
||||
|
||||
/// Returns the set of all day IDs that had 100% completion across all rituals
|
||||
private func perfectDays() -> Set<String> {
|
||||
guard !rituals.isEmpty else { return [] }
|
||||
|
||||
// Get all completed day IDs from all habits
|
||||
var allDayIDs: Set<String> = []
|
||||
for ritual in rituals {
|
||||
for habit in ritual.habits {
|
||||
allDayIDs.formUnion(habit.completedDayIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to days where ALL habits were completed
|
||||
return allDayIDs.filter { dayID in
|
||||
rituals.allSatisfy { ritual in
|
||||
ritual.habits.allSatisfy { habit in
|
||||
habit.completedDayIDs.contains(dayID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates the current streak (consecutive perfect days ending today or yesterday)
|
||||
func currentStreak() -> Int {
|
||||
let perfect = perfectDays()
|
||||
guard !perfect.isEmpty else { return 0 }
|
||||
|
||||
var streak = 0
|
||||
var checkDate = calendar.startOfDay(for: Date())
|
||||
|
||||
// Check if today is a perfect day
|
||||
if perfect.contains(dayIdentifier(for: checkDate)) {
|
||||
streak = 1
|
||||
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
|
||||
}
|
||||
|
||||
// Count consecutive days backwards
|
||||
while perfect.contains(dayIdentifier(for: checkDate)) {
|
||||
streak += 1
|
||||
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
|
||||
}
|
||||
|
||||
return streak
|
||||
}
|
||||
|
||||
/// Calculates the longest streak of consecutive perfect days
|
||||
func longestStreak() -> Int {
|
||||
let perfect = perfectDays()
|
||||
guard !perfect.isEmpty else { return 0 }
|
||||
|
||||
// Sort the perfect days chronologically
|
||||
let sortedDates = perfect.compactMap { dayID -> Date? in
|
||||
dayFormatter.date(from: dayID)
|
||||
}.sorted()
|
||||
|
||||
guard !sortedDates.isEmpty else { return 0 }
|
||||
|
||||
var longest = 1
|
||||
var current = 1
|
||||
|
||||
for i in 1..<sortedDates.count {
|
||||
let prev = sortedDates[i - 1]
|
||||
let curr = sortedDates[i]
|
||||
|
||||
if let nextDay = calendar.date(byAdding: .day, value: 1, to: prev),
|
||||
calendar.isDate(nextDay, inSameDayAs: curr) {
|
||||
current += 1
|
||||
longest = max(longest, current)
|
||||
} else {
|
||||
current = 1
|
||||
}
|
||||
}
|
||||
|
||||
return longest
|
||||
}
|
||||
|
||||
/// Returns completion data for the last 7 days for a trend chart
|
||||
func weeklyTrendData() -> [TrendDataPoint] {
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let shortWeekdayFormatter = DateFormatter()
|
||||
shortWeekdayFormatter.calendar = calendar
|
||||
shortWeekdayFormatter.dateFormat = "EEE"
|
||||
|
||||
var dataPoints: [TrendDataPoint] = []
|
||||
|
||||
for daysAgo in (0..<7).reversed() {
|
||||
guard let date = calendar.date(byAdding: .day, value: -daysAgo, to: today) else { continue }
|
||||
let dayID = dayIdentifier(for: date)
|
||||
|
||||
// Calculate completion rate for this day
|
||||
let allHabits = rituals.flatMap { $0.habits }
|
||||
guard !allHabits.isEmpty else {
|
||||
dataPoints.append(TrendDataPoint(date: date, value: 0, label: shortWeekdayFormatter.string(from: date)))
|
||||
continue
|
||||
}
|
||||
|
||||
let completed = allHabits.filter { $0.completedDayIDs.contains(dayID) }.count
|
||||
let rate = Double(completed) / Double(allHabits.count)
|
||||
|
||||
dataPoints.append(TrendDataPoint(
|
||||
date: date,
|
||||
value: rate,
|
||||
label: shortWeekdayFormatter.string(from: date)
|
||||
))
|
||||
}
|
||||
|
||||
return dataPoints
|
||||
}
|
||||
|
||||
/// Returns the average completion rate for the last 7 days
|
||||
func weeklyAverageCompletion() -> Int {
|
||||
let trend = weeklyTrendData()
|
||||
guard !trend.isEmpty else { return 0 }
|
||||
let sum = trend.reduce(0.0) { $0 + $1.value }
|
||||
return Int((sum / Double(trend.count)) * 100)
|
||||
}
|
||||
|
||||
func insightCards() -> [InsightCard] {
|
||||
let totalHabits = rituals.flatMap { $0.habits }.count
|
||||
let completedToday = rituals.flatMap { $0.habits }.filter { isHabitCompletedToday($0) }.count
|
||||
let completionRate = totalHabits == 0 ? 0 : Int((Double(completedToday) / Double(totalHabits)) * 100)
|
||||
|
||||
// Build per-ritual breakdowns
|
||||
let ritualDaysBreakdown = rituals.map { ritual in
|
||||
BreakdownItem(
|
||||
label: ritual.title,
|
||||
value: ritualDayLabel(for: ritual)
|
||||
)
|
||||
}
|
||||
|
||||
let habitsBreakdown = rituals.map { ritual in
|
||||
let completed = ritual.habits.filter { isHabitCompletedToday($0) }.count
|
||||
return BreakdownItem(
|
||||
label: ritual.title,
|
||||
value: "\(completed) of \(ritual.habits.count)"
|
||||
)
|
||||
}
|
||||
|
||||
let activeDays = rituals.map { ritualDayIndex(for: $0) }.reduce(0, +)
|
||||
|
||||
// Streak tracking
|
||||
let current = currentStreak()
|
||||
let longest = longestStreak()
|
||||
let streakBreakdown = [
|
||||
BreakdownItem(label: String(localized: "Current streak"), value: "\(current) days"),
|
||||
BreakdownItem(label: String(localized: "Longest streak"), value: "\(longest) days")
|
||||
]
|
||||
|
||||
// Weekly trend
|
||||
let trendData = weeklyTrendData()
|
||||
let weeklyAverage = weeklyAverageCompletion()
|
||||
let trendBreakdown = trendData.map { point in
|
||||
BreakdownItem(label: point.label, value: "\(Int(point.value * 100))%")
|
||||
}
|
||||
|
||||
return [
|
||||
InsightCard(
|
||||
title: String(localized: "Active rituals"),
|
||||
title: String(localized: "Active"),
|
||||
value: "\(rituals.count)",
|
||||
caption: String(localized: "Total arcs in motion"),
|
||||
symbolName: "sparkles"
|
||||
caption: String(localized: "In progress now"),
|
||||
explanation: String(localized: "Ritual arcs you're currently working on. Each ritual is a focused journey lasting 2-8 weeks, helping you build lasting habits through consistency."),
|
||||
symbolName: "sparkles",
|
||||
breakdown: rituals.map { BreakdownItem(label: $0.title, value: $0.theme) }
|
||||
),
|
||||
InsightCard(
|
||||
title: String(localized: "Streak"),
|
||||
value: "\(current)",
|
||||
caption: String(localized: "Consecutive perfect days"),
|
||||
explanation: String(localized: "Your current streak of consecutive days with 100% habit completion. Complete all your habits today to keep the streak going!"),
|
||||
symbolName: "flame.fill",
|
||||
breakdown: streakBreakdown
|
||||
),
|
||||
InsightCard(
|
||||
title: String(localized: "Habits today"),
|
||||
value: "\(completedToday)",
|
||||
caption: String(localized: "Check-ins completed"),
|
||||
symbolName: "checkmark.circle.fill"
|
||||
caption: String(localized: "Completed today"),
|
||||
explanation: String(localized: "The number of habits you've checked off today across all your active rituals. Each check-in builds momentum toward your goals."),
|
||||
symbolName: "checkmark.circle.fill",
|
||||
breakdown: habitsBreakdown
|
||||
),
|
||||
InsightCard(
|
||||
title: String(localized: "Completion"),
|
||||
value: "\(completionRate)%",
|
||||
caption: String(localized: "Across all rituals"),
|
||||
symbolName: "chart.bar.fill"
|
||||
caption: String(localized: "Today's progress"),
|
||||
explanation: String(localized: "Your completion percentage for today across all rituals. This resets each morning, giving you a fresh start every day."),
|
||||
symbolName: "chart.bar.fill",
|
||||
breakdown: nil
|
||||
),
|
||||
InsightCard(
|
||||
title: String(localized: "Ritual days"),
|
||||
title: String(localized: "Days"),
|
||||
value: "\(activeDays)",
|
||||
caption: String(localized: "Total days logged"),
|
||||
symbolName: "calendar"
|
||||
caption: String(localized: "Days on your journey"),
|
||||
explanation: String(localized: "The total number of days you've been working on your rituals. This shows your progress through each arc, combining all active rituals."),
|
||||
symbolName: "calendar",
|
||||
breakdown: ritualDaysBreakdown
|
||||
),
|
||||
InsightCard(
|
||||
title: String(localized: "Weekly trend"),
|
||||
value: "\(weeklyAverage)%",
|
||||
caption: String(localized: "Last 7 days average"),
|
||||
explanation: String(localized: "Your average completion rate over the last 7 days. This shows how consistent you've been recently and helps identify patterns."),
|
||||
symbolName: "chart.line.uptrend.xyaxis",
|
||||
breakdown: trendBreakdown,
|
||||
trendData: trendData
|
||||
)
|
||||
]
|
||||
}
|
||||
@ -200,4 +376,132 @@ final class RitualStore: RitualStoreProviding {
|
||||
private func dayIdentifier(for date: Date) -> String {
|
||||
dayFormatter.string(from: date)
|
||||
}
|
||||
|
||||
// MARK: - History / Calendar Support
|
||||
|
||||
/// Returns the completion rate for a specific date, optionally filtered by ritual.
|
||||
/// - Parameters:
|
||||
/// - date: The date to check
|
||||
/// - ritual: If provided, only check habits from this ritual. If nil, check all habits.
|
||||
/// - Returns: Completion rate from 0.0 to 1.0
|
||||
func completionRate(for date: Date, ritual: Ritual? = nil) -> Double {
|
||||
let dayID = dayIdentifier(for: date)
|
||||
let habits: [Habit]
|
||||
|
||||
if let ritual = ritual {
|
||||
habits = ritual.habits
|
||||
} else {
|
||||
habits = rituals.flatMap { $0.habits }
|
||||
}
|
||||
|
||||
guard !habits.isEmpty else { return 0 }
|
||||
|
||||
let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count
|
||||
return Double(completed) / Double(habits.count)
|
||||
}
|
||||
|
||||
/// Returns all dates that have any habit activity.
|
||||
func datesWithActivity() -> Set<Date> {
|
||||
var dates: Set<Date> = []
|
||||
|
||||
for ritual in rituals {
|
||||
for habit in ritual.habits {
|
||||
for dayID in habit.completedDayIDs {
|
||||
if let date = dayFormatter.date(from: dayID) {
|
||||
dates.insert(calendar.startOfDay(for: date))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates
|
||||
}
|
||||
|
||||
/// Returns the earliest date with any ritual activity.
|
||||
func earliestActivityDate() -> Date? {
|
||||
datesWithActivity().min()
|
||||
}
|
||||
|
||||
/// Returns habit completion details for a specific date.
|
||||
/// - Parameters:
|
||||
/// - date: The date to check
|
||||
/// - ritual: If provided, only return habits from this ritual. If nil, return all habits.
|
||||
/// - Returns: Array of habit completions with ritual context
|
||||
func habitCompletions(for date: Date, ritual: Ritual? = nil) -> [HabitCompletion] {
|
||||
let dayID = dayIdentifier(for: date)
|
||||
let targetRituals = ritual.map { [$0] } ?? rituals
|
||||
|
||||
var completions: [HabitCompletion] = []
|
||||
|
||||
for r in targetRituals {
|
||||
for habit in r.habits {
|
||||
completions.append(HabitCompletion(
|
||||
habit: habit,
|
||||
ritualTitle: r.title,
|
||||
isCompleted: habit.completedDayIDs.contains(dayID)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return completions
|
||||
}
|
||||
|
||||
/// Checks if a habit was completed on a specific date.
|
||||
func isHabitCompleted(_ habit: Habit, on date: Date) -> Bool {
|
||||
let dayID = dayIdentifier(for: date)
|
||||
return habit.completedDayIDs.contains(dayID)
|
||||
}
|
||||
|
||||
// MARK: - Debug / Demo Data
|
||||
|
||||
#if DEBUG
|
||||
/// Preloads 6 months of random completion data for testing the history view.
|
||||
/// Each day has a random chance (60-90%) of completing each habit.
|
||||
func preloadDemoData() {
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
|
||||
// Go back 6 months
|
||||
guard let sixMonthsAgo = calendar.date(byAdding: .month, value: -6, to: today) else { return }
|
||||
|
||||
// Update each ritual's start date to be 6 months ago
|
||||
for ritual in rituals {
|
||||
ritual.startDate = sixMonthsAgo
|
||||
ritual.durationDays = 180 + 28 // Cover 6 months plus buffer
|
||||
}
|
||||
|
||||
// Generate completions for each day from 6 months ago to yesterday
|
||||
var currentDate = sixMonthsAgo
|
||||
|
||||
while currentDate < today {
|
||||
let dayID = dayIdentifier(for: currentDate)
|
||||
|
||||
for ritual in rituals {
|
||||
for habit in ritual.habits {
|
||||
// Random completion with ~70% average success rate
|
||||
// Vary between 50-90% to create realistic patterns
|
||||
let threshold = Double.random(in: 0.5...0.9)
|
||||
let shouldComplete = Double.random(in: 0...1) < threshold
|
||||
|
||||
if shouldComplete && !habit.completedDayIDs.contains(dayID) {
|
||||
habit.completedDayIDs.append(dayID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate
|
||||
}
|
||||
|
||||
saveContext()
|
||||
}
|
||||
|
||||
/// Clears all completion data (for testing).
|
||||
func clearAllCompletions() {
|
||||
for ritual in rituals {
|
||||
for habit in ritual.habits {
|
||||
habit.completedDayIDs.removeAll()
|
||||
}
|
||||
}
|
||||
saveContext()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
85
Andromida/App/Views/History/Components/HistoryDayCell.swift
Normal file
85
Andromida/App/Views/History/Components/HistoryDayCell.swift
Normal file
@ -0,0 +1,85 @@
|
||||
//
|
||||
// HistoryDayCell.swift
|
||||
// Andromida
|
||||
//
|
||||
// A single day cell in the history calendar showing completion progress.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// A single day cell showing the day number and a progress ring.
|
||||
struct HistoryDayCell: View {
|
||||
let date: Date
|
||||
let progress: Double
|
||||
let isToday: Bool
|
||||
var isFuture: Bool = false
|
||||
let onTap: () -> Void
|
||||
|
||||
private let calendar = Calendar.current
|
||||
|
||||
private var dayNumber: Int {
|
||||
calendar.component(.day, from: date)
|
||||
}
|
||||
|
||||
private var textColor: Color {
|
||||
if isFuture {
|
||||
return AppTextColors.tertiary
|
||||
} else if isToday {
|
||||
return AppAccent.primary
|
||||
} else {
|
||||
return AppTextColors.primary
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
ZStack {
|
||||
// Progress ring (or empty ring for future days)
|
||||
ProgressRing(progress: progress, size: AppMetrics.Size.historyDayCell)
|
||||
|
||||
// Day number
|
||||
Text("\(dayNumber)")
|
||||
.font(.caption2)
|
||||
.fontWeight(isToday ? .bold : .regular)
|
||||
.foregroundStyle(textColor)
|
||||
}
|
||||
.frame(width: AppMetrics.Size.historyDayCell, height: AppMetrics.Size.historyDayCell)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isFuture)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
.accessibilityHint(isFuture ? "" : String(localized: "Tap to view details"))
|
||||
}
|
||||
|
||||
private var accessibilityLabel: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
let dateString = formatter.string(from: date)
|
||||
if isFuture {
|
||||
return "\(dateString), future date"
|
||||
}
|
||||
let percentComplete = Int(progress * 100)
|
||||
return "\(dateString), \(percentComplete) percent complete"
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty placeholder cell for days outside the current month.
|
||||
struct HistoryDayPlaceholder: View {
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(width: AppMetrics.Size.historyDayCell, height: AppMetrics.Size.historyDayCell)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
HistoryDayCell(date: Date(), progress: 1.0, isToday: true) {}
|
||||
HistoryDayCell(date: Date(), progress: 0.66, isToday: false) {}
|
||||
HistoryDayCell(date: Date(), progress: 0.33, isToday: false) {}
|
||||
HistoryDayCell(date: Date(), progress: 0.0, isToday: false) {}
|
||||
}
|
||||
.padding()
|
||||
.background(AppSurface.primary)
|
||||
}
|
||||
58
Andromida/App/Views/History/Components/ProgressRing.swift
Normal file
58
Andromida/App/Views/History/Components/ProgressRing.swift
Normal file
@ -0,0 +1,58 @@
|
||||
//
|
||||
// ProgressRing.swift
|
||||
// Andromida
|
||||
//
|
||||
// A circular progress indicator for the history calendar.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// A circular progress ring showing completion percentage.
|
||||
struct ProgressRing: View {
|
||||
let progress: Double // 0.0 to 1.0
|
||||
let size: CGFloat
|
||||
var lineWidth: CGFloat = 3
|
||||
|
||||
private var color: Color {
|
||||
if progress >= 1.0 { return AppStatus.success }
|
||||
if progress >= 0.5 { return AppAccent.primary }
|
||||
return AppTextColors.tertiary
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background ring
|
||||
Circle()
|
||||
.stroke(
|
||||
AppBorder.subtle,
|
||||
lineWidth: lineWidth
|
||||
)
|
||||
|
||||
// Progress ring
|
||||
Circle()
|
||||
.trim(from: 0, to: min(progress, 1.0))
|
||||
.stroke(
|
||||
color,
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeOut(duration: Design.Animation.standard), value: progress)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String(localized: "\(Int(progress * 100)) percent complete"))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HStack(spacing: Design.Spacing.large) {
|
||||
ProgressRing(progress: 0.0, size: 40)
|
||||
ProgressRing(progress: 0.25, size: 40)
|
||||
ProgressRing(progress: 0.5, size: 40)
|
||||
ProgressRing(progress: 0.75, size: 40)
|
||||
ProgressRing(progress: 1.0, size: 40)
|
||||
}
|
||||
.padding()
|
||||
.background(AppSurface.primary)
|
||||
}
|
||||
165
Andromida/App/Views/History/HistoryDayDetailSheet.swift
Normal file
165
Andromida/App/Views/History/HistoryDayDetailSheet.swift
Normal file
@ -0,0 +1,165 @@
|
||||
//
|
||||
// HistoryDayDetailSheet.swift
|
||||
// Andromida
|
||||
//
|
||||
// Detail sheet showing habit completions for a specific day.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// A sheet showing habit completion details for a specific day.
|
||||
struct HistoryDayDetailSheet: View {
|
||||
let date: Date
|
||||
let completions: [HabitCompletion]
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var dateTitle: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .full
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private var completionRate: Double {
|
||||
guard !completions.isEmpty else { return 0 }
|
||||
let completed = completions.filter { $0.isCompleted }.count
|
||||
return Double(completed) / Double(completions.count)
|
||||
}
|
||||
|
||||
private var completedCount: Int {
|
||||
completions.filter { $0.isCompleted }.count
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||
// Summary header
|
||||
summaryHeader
|
||||
|
||||
// Habit list grouped by ritual
|
||||
if completions.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
habitList
|
||||
}
|
||||
|
||||
Spacer(minLength: Design.Spacing.xxxLarge)
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppSurface.primary)
|
||||
.navigationTitle(shortDateTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "Done")) {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationBackground(AppSurface.primary)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
|
||||
Text(String(localized: "No habits tracked"))
|
||||
.font(.headline)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
|
||||
Text(String(localized: "This day has no habit data recorded."))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Design.Spacing.xxxLarge)
|
||||
}
|
||||
|
||||
private var shortDateTitle: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private var summaryHeader: some View {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
ProgressRing(progress: completionRate, size: 80, lineWidth: 6)
|
||||
|
||||
Text("\(completedCount) of \(completions.count)")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text(String(localized: "habits completed"))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Design.Spacing.large)
|
||||
}
|
||||
|
||||
private var habitList: some View {
|
||||
let groupedByRitual = Dictionary(grouping: completions) { $0.ritualTitle }
|
||||
let sortedRituals = groupedByRitual.keys.sorted()
|
||||
|
||||
return VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||
ForEach(sortedRituals, id: \.self) { ritualTitle in
|
||||
if let habits = groupedByRitual[ritualTitle] {
|
||||
ritualSection(title: ritualTitle, habits: habits)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ritualSection(title: String, habits: [HabitCompletion]) -> some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
VStack(spacing: Design.Spacing.xSmall) {
|
||||
ForEach(habits) { completion in
|
||||
habitRow(completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.background(AppSurface.card)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
}
|
||||
|
||||
private func habitRow(_ completion: HabitCompletion) -> some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: completion.habit.symbolName)
|
||||
.foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary)
|
||||
.frame(width: AppMetrics.Size.iconMedium)
|
||||
|
||||
Text(completion.habit.title)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: completion.isCompleted ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary)
|
||||
}
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HistoryDayDetailSheet(
|
||||
date: Date(),
|
||||
completions: []
|
||||
)
|
||||
}
|
||||
125
Andromida/App/Views/History/HistoryMonthView.swift
Normal file
125
Andromida/App/Views/History/HistoryMonthView.swift
Normal file
@ -0,0 +1,125 @@
|
||||
//
|
||||
// HistoryMonthView.swift
|
||||
// Andromida
|
||||
//
|
||||
// A single month calendar grid showing daily completion progress.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// A calendar grid for a single month with progress rings for each day.
|
||||
struct HistoryMonthView: View {
|
||||
let month: Date
|
||||
let selectedRitual: Ritual?
|
||||
let completionRate: (Date, Ritual?) -> Double
|
||||
let onDayTapped: (Date) -> Void
|
||||
|
||||
private let calendar = Calendar.current
|
||||
private let weekdaySymbols = Calendar.current.shortWeekdaySymbols
|
||||
|
||||
private var monthTitle: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM yyyy"
|
||||
return formatter.string(from: month)
|
||||
}
|
||||
|
||||
private var daysInMonth: [Date?] {
|
||||
// Get the first day of the month
|
||||
guard let firstOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: month)) else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get the range of days in this month
|
||||
guard let range = calendar.range(of: .day, in: .month, for: firstOfMonth) else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get the weekday of the first day (1 = Sunday, 7 = Saturday)
|
||||
let firstWeekday = calendar.component(.weekday, from: firstOfMonth)
|
||||
|
||||
// Create array with leading nil placeholders for alignment
|
||||
var days: [Date?] = Array(repeating: nil, count: firstWeekday - 1)
|
||||
|
||||
// Add actual days
|
||||
for day in range {
|
||||
if let date = calendar.date(byAdding: .day, value: day - 1, to: firstOfMonth) {
|
||||
days.append(date)
|
||||
}
|
||||
}
|
||||
|
||||
return days
|
||||
}
|
||||
|
||||
private var today: Date {
|
||||
calendar.startOfDay(for: Date())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
// Month header
|
||||
Text(monthTitle)
|
||||
.font(.headline)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
.padding(.horizontal, Design.Spacing.small)
|
||||
|
||||
// Weekday headers
|
||||
weekdayHeader
|
||||
|
||||
// Day grid
|
||||
dayGrid
|
||||
}
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(AppSurface.card)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
}
|
||||
|
||||
private var weekdayHeader: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(weekdaySymbols, id: \.self) { symbol in
|
||||
Text(symbol)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var dayGrid: some View {
|
||||
let columns = Array(repeating: GridItem(.flexible(), spacing: Design.Spacing.xSmall), count: 7)
|
||||
|
||||
return LazyVGrid(columns: columns, spacing: Design.Spacing.xSmall) {
|
||||
ForEach(Array(daysInMonth.enumerated()), id: \.offset) { index, date in
|
||||
if let date = date {
|
||||
let isToday = calendar.isDate(date, inSameDayAs: today)
|
||||
let isFuture = date > today
|
||||
|
||||
// Show all days - future days show with 0 progress and are not tappable
|
||||
HistoryDayCell(
|
||||
date: date,
|
||||
progress: isFuture ? 0 : completionRate(date, selectedRitual),
|
||||
isToday: isToday,
|
||||
isFuture: isFuture,
|
||||
onTap: { if !isFuture { onDayTapped(date) } }
|
||||
)
|
||||
} else {
|
||||
// Placeholder for alignment
|
||||
HistoryDayPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ScrollView {
|
||||
HistoryMonthView(
|
||||
month: Date(),
|
||||
selectedRitual: nil,
|
||||
completionRate: { _, _ in Double.random(in: 0...1) },
|
||||
onDayTapped: { _ in }
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
.background(AppSurface.primary)
|
||||
}
|
||||
166
Andromida/App/Views/History/HistoryView.swift
Normal file
166
Andromida/App/Views/History/HistoryView.swift
Normal file
@ -0,0 +1,166 @@
|
||||
//
|
||||
// HistoryView.swift
|
||||
// Andromida
|
||||
//
|
||||
// A scrollable calendar history view showing daily habit completion.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
|
||||
/// Main history view with scrollable months and ritual filtering.
|
||||
/// Wrapper struct to make Date identifiable for sheet presentation
|
||||
struct IdentifiableDate: Identifiable {
|
||||
let id = UUID()
|
||||
let date: Date
|
||||
}
|
||||
|
||||
struct HistoryView: View {
|
||||
@Bindable var store: RitualStore
|
||||
@State private var selectedRitual: Ritual?
|
||||
@State private var selectedDateItem: IdentifiableDate?
|
||||
@State private var showingExpandedHistory = false
|
||||
|
||||
private let calendar = Calendar.current
|
||||
|
||||
/// Generate months based on expanded state
|
||||
/// - Collapsed: Last month + current month (2 months)
|
||||
/// - Expanded: Up to 12 months of history
|
||||
/// Months are ordered oldest first, newest last (chronological order)
|
||||
private var months: [Date] {
|
||||
let today = Date()
|
||||
let currentMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: today)) ?? today
|
||||
|
||||
// Determine how far back to go
|
||||
let monthsBack = showingExpandedHistory ? 11 : 1 // 12 months or 2 months total
|
||||
guard let startMonth = calendar.date(byAdding: .month, value: -monthsBack, to: currentMonth) else {
|
||||
return [currentMonth]
|
||||
}
|
||||
|
||||
// Build list of months in chronological order (oldest first)
|
||||
var result: [Date] = []
|
||||
var current = startMonth
|
||||
|
||||
while current <= currentMonth {
|
||||
result.append(current)
|
||||
current = calendar.date(byAdding: .month, value: 1, to: current) ?? current
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Check if there's more history available beyond what's shown
|
||||
private var hasMoreHistory: Bool {
|
||||
guard let earliestActivity = store.earliestActivityDate() else { return false }
|
||||
let today = Date()
|
||||
guard let twoMonthsAgo = calendar.date(byAdding: .month, value: -1, to: today) else { return false }
|
||||
return earliestActivity < twoMonthsAgo
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||
// Header with Show More/Less button
|
||||
headerSection
|
||||
|
||||
// Ritual filter picker
|
||||
ritualPicker
|
||||
|
||||
// Month calendars
|
||||
ForEach(months, id: \.self) { month in
|
||||
HistoryMonthView(
|
||||
month: month,
|
||||
selectedRitual: selectedRitual,
|
||||
completionRate: { date, ritual in
|
||||
store.completionRate(for: date, ritual: ritual)
|
||||
},
|
||||
onDayTapped: { date in
|
||||
selectedDateItem = IdentifiableDate(date: date)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
}
|
||||
.background(LinearGradient(
|
||||
colors: [AppSurface.primary, AppSurface.secondary],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
))
|
||||
.sheet(item: $selectedDateItem) { item in
|
||||
HistoryDayDetailSheet(
|
||||
date: item.date,
|
||||
completions: store.habitCompletions(for: item.date, ritual: selectedRitual)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
HStack(alignment: .top) {
|
||||
SectionHeaderView(
|
||||
title: String(localized: "History"),
|
||||
subtitle: String(localized: "Your journey over time")
|
||||
)
|
||||
|
||||
Spacer()
|
||||
|
||||
if hasMoreHistory || showingExpandedHistory {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: Design.Animation.standard)) {
|
||||
showingExpandedHistory.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: Design.Spacing.xSmall) {
|
||||
Text(showingExpandedHistory ? String(localized: "Show less") : String(localized: "Show more"))
|
||||
.font(.subheadline)
|
||||
Image(systemName: showingExpandedHistory ? "chevron.up" : "chevron.down")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var ritualPicker: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
// "All" option
|
||||
filterChip(
|
||||
title: String(localized: "All"),
|
||||
isSelected: selectedRitual == nil,
|
||||
action: { selectedRitual = nil }
|
||||
)
|
||||
|
||||
// Individual rituals
|
||||
ForEach(store.rituals) { ritual in
|
||||
filterChip(
|
||||
title: ritual.title,
|
||||
isSelected: selectedRitual?.id == ritual.id,
|
||||
action: { selectedRitual = ritual }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func filterChip(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
.fontWeight(isSelected ? .semibold : .regular)
|
||||
.foregroundStyle(isSelected ? AppTextColors.inverse : AppTextColors.primary)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.background(isSelected ? AppAccent.primary : AppSurface.card)
|
||||
.clipShape(.capsule)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HistoryView(store: RitualStore.preview)
|
||||
}
|
||||
@ -1,51 +1,102 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import Charts
|
||||
|
||||
struct InsightCardView: View {
|
||||
private let title: String
|
||||
private let value: String
|
||||
private let caption: String
|
||||
private let symbolName: String
|
||||
|
||||
init(
|
||||
title: String,
|
||||
value: String,
|
||||
caption: String,
|
||||
symbolName: String
|
||||
) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.caption = caption
|
||||
self.symbolName = symbolName
|
||||
}
|
||||
let card: InsightCard
|
||||
@State private var showingDetail = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showingDetail = true
|
||||
} label: {
|
||||
cardContent
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.sheet(isPresented: $showingDetail) {
|
||||
InsightDetailSheet(card: card)
|
||||
}
|
||||
}
|
||||
|
||||
private var cardContent: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: symbolName)
|
||||
Image(systemName: card.symbolName)
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.accessibilityHidden(true)
|
||||
Text(title)
|
||||
Text(card.title)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Subtle tap affordance
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
// Show mini sparkline if trend data is available
|
||||
if let trendData = card.trendData, !trendData.isEmpty {
|
||||
HStack(alignment: .bottom, spacing: Design.Spacing.medium) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
Text(card.value)
|
||||
.font(.title)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
.bold()
|
||||
Text(card.caption)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Mini sparkline chart
|
||||
Chart(trendData) { point in
|
||||
BarMark(
|
||||
x: .value("Day", point.label),
|
||||
y: .value("Completion", point.value)
|
||||
)
|
||||
.foregroundStyle(
|
||||
point.value >= 1.0 ? AppStatus.success :
|
||||
point.value >= 0.5 ? AppAccent.primary :
|
||||
AppTextColors.tertiary
|
||||
)
|
||||
.cornerRadius(2)
|
||||
}
|
||||
.chartYScale(domain: 0...1)
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis(.hidden)
|
||||
.frame(width: 80, height: 40)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
} else {
|
||||
Text(card.value)
|
||||
.font(.title)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
.bold()
|
||||
Text(card.caption)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
Text(value)
|
||||
.font(.title)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
.bold()
|
||||
Text(caption)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.background(AppSurface.card)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityHint(String(localized: "Tap for details"))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
InsightCardView(title: "Completion", value: "72%", caption: "Across all rituals", symbolName: "chart.bar.fill")
|
||||
.padding(Design.Spacing.large)
|
||||
InsightCardView(card: InsightCard(
|
||||
title: "Completion",
|
||||
value: "72%",
|
||||
caption: "Today's progress",
|
||||
explanation: "Your completion percentage for today across all rituals.",
|
||||
symbolName: "chart.bar.fill"
|
||||
))
|
||||
.padding(Design.Spacing.large)
|
||||
.background(AppSurface.primary)
|
||||
}
|
||||
|
||||
196
Andromida/App/Views/Insights/Components/InsightDetailSheet.swift
Normal file
196
Andromida/App/Views/Insights/Components/InsightDetailSheet.swift
Normal file
@ -0,0 +1,196 @@
|
||||
//
|
||||
// InsightDetailSheet.swift
|
||||
// Andromida
|
||||
//
|
||||
// Detail sheet shown when tapping an insight card.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import Charts
|
||||
|
||||
struct InsightDetailSheet: View {
|
||||
let card: InsightCard
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xLarge) {
|
||||
// Header with icon and value
|
||||
headerSection
|
||||
|
||||
// Chart (if trend data available)
|
||||
if let trendData = card.trendData, !trendData.isEmpty {
|
||||
chartSection(trendData)
|
||||
}
|
||||
|
||||
// Explanation
|
||||
explanationSection
|
||||
|
||||
// Breakdown (if available)
|
||||
if let breakdown = card.breakdown, !breakdown.isEmpty {
|
||||
breakdownSection(breakdown)
|
||||
}
|
||||
|
||||
Spacer(minLength: Design.Spacing.xxxLarge)
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
}
|
||||
.background(AppSurface.primary)
|
||||
.navigationTitle(card.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(String(localized: "Done")) {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
// MARK: - Header Section
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: card.symbolName)
|
||||
.font(.system(size: Design.BaseFontSize.largeTitle * 2))
|
||||
.foregroundStyle(AppAccent.primary)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(card.value)
|
||||
.font(.system(size: Design.BaseFontSize.largeTitle * 2, weight: .bold))
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text(card.caption)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Design.Spacing.large)
|
||||
}
|
||||
|
||||
// MARK: - Explanation Section
|
||||
|
||||
private var explanationSection: some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
Text(String(localized: "What this means"))
|
||||
.font(.headline)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Text(card.explanation)
|
||||
.font(.body)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(AppSurface.card)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
}
|
||||
|
||||
// MARK: - Chart Section
|
||||
|
||||
private func chartSection(_ data: [TrendDataPoint]) -> some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
Text(String(localized: "7-Day Trend"))
|
||||
.font(.headline)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Chart(data) { point in
|
||||
BarMark(
|
||||
x: .value("Day", point.label),
|
||||
y: .value("Completion", point.value)
|
||||
)
|
||||
.foregroundStyle(
|
||||
point.value >= 1.0 ? AppStatus.success.gradient :
|
||||
point.value >= 0.5 ? AppAccent.primary.gradient :
|
||||
AppTextColors.tertiary.gradient
|
||||
)
|
||||
.cornerRadius(Design.CornerRadius.small)
|
||||
}
|
||||
.chartYScale(domain: 0...1)
|
||||
.chartYAxis {
|
||||
AxisMarks(values: [0, 0.5, 1.0]) { value in
|
||||
AxisValueLabel {
|
||||
if let v = value.as(Double.self) {
|
||||
Text("\(Int(v * 100))%")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(AppTextColors.tertiary)
|
||||
}
|
||||
}
|
||||
AxisGridLine()
|
||||
.foregroundStyle(AppBorder.subtle)
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel()
|
||||
.font(.caption2)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
}
|
||||
.frame(height: 180)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(String(localized: "Weekly completion chart"))
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(AppSurface.card)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
}
|
||||
|
||||
// MARK: - Breakdown Section
|
||||
|
||||
private func breakdownSection(_ breakdown: [BreakdownItem]) -> some View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
Text(String(localized: "Breakdown"))
|
||||
.font(.headline)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
VStack(spacing: Design.Spacing.xSmall) {
|
||||
ForEach(breakdown) { item in
|
||||
HStack {
|
||||
Text(item.label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AppTextColors.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(item.value)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AppTextColors.secondary)
|
||||
}
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
|
||||
if item.id != breakdown.last?.id {
|
||||
Divider()
|
||||
.background(AppBorder.subtle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(AppSurface.card)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
InsightDetailSheet(card: InsightCard(
|
||||
title: "Ritual days",
|
||||
value: "16",
|
||||
caption: "Days on your journey",
|
||||
explanation: "The total number of days you've been working on your rituals. This shows your progress through each arc, combining all active rituals.",
|
||||
symbolName: "calendar",
|
||||
breakdown: [
|
||||
BreakdownItem(label: "Morning Clarity", value: "Day 1 of 28"),
|
||||
BreakdownItem(label: "Evening Reset", value: "Day 15 of 28")
|
||||
]
|
||||
))
|
||||
}
|
||||
@ -18,12 +18,7 @@ struct InsightsView: View {
|
||||
|
||||
LazyVGrid(columns: columns, spacing: Design.Spacing.medium) {
|
||||
ForEach(store.insightCards()) { card in
|
||||
InsightCardView(
|
||||
title: card.title,
|
||||
value: card.value,
|
||||
caption: card.caption,
|
||||
symbolName: card.symbolName
|
||||
)
|
||||
InsightCardView(card: card)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,9 +27,15 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Tab(String(localized: "History"), systemImage: "calendar") {
|
||||
NavigationStack {
|
||||
HistoryView(store: store)
|
||||
}
|
||||
}
|
||||
|
||||
Tab(String(localized: "Settings"), systemImage: "gearshape.fill") {
|
||||
NavigationStack {
|
||||
SettingsView(store: settingsStore)
|
||||
SettingsView(store: settingsStore, ritualStore: store)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import UserNotifications
|
||||
|
||||
struct SettingsView: View {
|
||||
@Bindable var store: SettingsStore
|
||||
var ritualStore: RitualStore?
|
||||
|
||||
private let focusOptions: [(String, FocusStyle)] = FocusStyle.allCases.map { ($0.title, $0) }
|
||||
|
||||
@ -165,6 +166,24 @@ struct SettingsView: View {
|
||||
) {
|
||||
UserDefaults.standard.removeObject(forKey: "hasCompletedOnboarding")
|
||||
}
|
||||
|
||||
if let ritualStore {
|
||||
SettingsRow(
|
||||
systemImage: "calendar.badge.plus",
|
||||
title: String(localized: "Preload 6 Months Demo Data"),
|
||||
iconColor: AppStatus.info
|
||||
) {
|
||||
ritualStore.preloadDemoData()
|
||||
}
|
||||
|
||||
SettingsRow(
|
||||
systemImage: "trash",
|
||||
title: String(localized: "Clear All Completions"),
|
||||
iconColor: AppStatus.error
|
||||
) {
|
||||
ritualStore.clearAllCompletions()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -197,6 +216,6 @@ extension SettingsView {
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
SettingsView(store: SettingsStore.preview)
|
||||
SettingsView(store: SettingsStore.preview, ritualStore: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ enum AppMetrics {
|
||||
static let progressRing: CGFloat = 72
|
||||
static let buttonHeight: CGFloat = 46
|
||||
static let insightCardMinWidth: CGFloat = 160
|
||||
static let historyDayCell: CGFloat = 40
|
||||
}
|
||||
|
||||
enum Shadow {
|
||||
|
||||
89
TODO.md
89
TODO.md
@ -15,71 +15,38 @@
|
||||
- [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.
|
||||
|
||||
## 4) QA checklist
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## PRIORITY: Wire up existing settings
|
||||
|
||||
### 5) Haptic feedback ⚡
|
||||
- [ ] Add haptic feedback on habit check-in using `UIImpactFeedbackGenerator`.
|
||||
- [ ] Respect `hapticsEnabled` setting from SettingsStore.
|
||||
- [ ] Add haptics to other interactions (ritual creation, onboarding completion).
|
||||
|
||||
### 6) Sound effects ⚡
|
||||
- [ ] Add completion sound when habit is checked in.
|
||||
- [ ] Respect `soundEnabled` setting from SettingsStore.
|
||||
- [ ] Use Bedrock `SoundManager` if available, or create audio service.
|
||||
|
||||
### 7) Daily reminders (notifications) ⚡
|
||||
- [ ] Request notification permission when "Daily reminders" is enabled.
|
||||
- [ ] Schedule daily local notification at user-preferred time.
|
||||
- [ ] Add time picker to Settings for reminder time.
|
||||
- [ ] Cancel notifications when setting is disabled.
|
||||
- [ ] Handle notification authorization denied state in UI.
|
||||
|
||||
### 8) Ritual pacing settings ⚡
|
||||
- [ ] Use `ritualLengthDays` setting when creating new rituals via `createQuickRitual()`.
|
||||
- [ ] Use `focusStyle` setting to affect ritual recommendations or insights.
|
||||
- [ ] Consider adding visual indicator of current pacing in Today view.
|
||||
|
||||
---
|
||||
|
||||
## Lower priority
|
||||
|
||||
### 9) Settings & product readiness
|
||||
- [x] Add a paid-app placeholder (e.g., "Pro unlock" copy) without backend requirements.
|
||||
- [ ] Confirm default settings and theme in Settings match Bedrock branding.
|
||||
|
||||
### 10) Data & defaults
|
||||
- [ ] Confirm seed ritual creation and quick ritual creation behave as expected.
|
||||
- [ ] Validate SwiftData sync (if enabled) doesn't require any external API.
|
||||
|
||||
---
|
||||
|
||||
## Future features
|
||||
|
||||
### 11) History view
|
||||
- [ ] Add History tab or section to view completed/past rituals.
|
||||
- [ ] Show completion percentage for each past ritual arc.
|
||||
- [ ] Allow viewing habits and check-in history for past rituals.
|
||||
|
||||
### 12) Ritual management
|
||||
- [ ] Add ability to create custom rituals (not just quick ritual).
|
||||
- [ ] Add ability to edit existing rituals (title, theme, habits).
|
||||
- [ ] Add ability to delete rituals.
|
||||
- [ ] Add ability to archive completed rituals.
|
||||
|
||||
### 13) Insights enhancements
|
||||
- [ ] Show weekly/monthly trends.
|
||||
- [ ] Show streak data (consecutive days with all habits completed).
|
||||
- [ ] Add charts or visualizations for progress over time.
|
||||
|
||||
### 14) Future enhancements
|
||||
- [ ] **HealthKit integration** – See plan: `.cursor/plans/healthkit_integration_plan_ce4f933c.plan.md`
|
||||
## 7) Future enhancements
|
||||
- [ ] **HealthKit integration** – Sync habit completions (water, mindfulness, exercise) to Apple Health. See plan: `.cursor/plans/healthkit_integration_plan_ce4f933c.plan.md`
|
||||
- [x] **History view** – View past/completed rituals with completion percentages. See plan: `.cursor/plans/calendar_history_view_88026c7b.plan.md`
|
||||
- [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
|
||||
- [ ] **Ritual management** – Create, edit, delete, and archive rituals.
|
||||
- [x] **Insights enhancements** – Weekly/monthly trends, streak data, charts. See plan: `.cursor/plans/insights_overhaul_50b59fa7.plan.md`
|
||||
- [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
|
||||
- [ ] **Widget** – Home screen widget showing today's progress.
|
||||
- [ ] **Watch app** – Companion app for quick habit check-ins.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user