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

This commit is contained in:
Matt Bruce 2026-01-25 19:56:09 -06:00
parent ed26a9d367
commit b444e85b04
18 changed files with 1464 additions and 111 deletions

View File

@ -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;

View File

@ -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>

View File

@ -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" : {

View 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
}

View File

@ -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"
}

View File

@ -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
}

View 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)
}

View 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)
}

View 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: []
)
}

View 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)
}

View 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)
}

View File

@ -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)
}
Text(value)
// 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(caption)
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)
}
}
.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")
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)
}

View 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")
]
))
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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
View File

@ -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.