diff --git a/Andromida.xcodeproj/project.pbxproj b/Andromida.xcodeproj/project.pbxproj index 2abe565..f8e0401 100644 --- a/Andromida.xcodeproj/project.pbxproj +++ b/Andromida.xcodeproj/project.pbxproj @@ -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; diff --git a/Andromida/Andromida.entitlements b/Andromida/Andromida.entitlements index c280ba7..d1853b1 100644 --- a/Andromida/Andromida.entitlements +++ b/Andromida/Andromida.entitlements @@ -4,7 +4,9 @@ com.apple.developer.icloud-container-identifiers - com.apple.developer.ubiquity-kvstore-identifier - $(TeamIdentifierPrefix)$(CFBundleIdentifier) + com.apple.developer.icloud-services + + CloudKit + diff --git a/Andromida/App/Localization/Localizable.xcstrings b/Andromida/App/Localization/Localizable.xcstrings index 92ba7ed..35d7e26 100644 --- a/Andromida/App/Localization/Localizable.xcstrings +++ b/Andromida/App/Localization/Localizable.xcstrings @@ -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" : { diff --git a/Andromida/App/Models/HabitCompletion.swift b/Andromida/App/Models/HabitCompletion.swift new file mode 100644 index 0000000..c519d9d --- /dev/null +++ b/Andromida/App/Models/HabitCompletion.swift @@ -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 +} diff --git a/Andromida/App/Models/InsightCard.swift b/Andromida/App/Models/InsightCard.swift index 7c62670..31f5d06 100644 --- a/Andromida/App/Models/InsightCard.swift +++ b/Andromida/App/Models/InsightCard.swift @@ -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" +} diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index fa3d057..bf673da 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -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 { + guard !rituals.isEmpty else { return [] } + + // Get all completed day IDs from all habits + var allDayIDs: Set = [] + 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.. [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 { + var dates: Set = [] + + 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 } diff --git a/Andromida/App/Views/History/Components/HistoryDayCell.swift b/Andromida/App/Views/History/Components/HistoryDayCell.swift new file mode 100644 index 0000000..0a22995 --- /dev/null +++ b/Andromida/App/Views/History/Components/HistoryDayCell.swift @@ -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) +} diff --git a/Andromida/App/Views/History/Components/ProgressRing.swift b/Andromida/App/Views/History/Components/ProgressRing.swift new file mode 100644 index 0000000..494f1df --- /dev/null +++ b/Andromida/App/Views/History/Components/ProgressRing.swift @@ -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) +} diff --git a/Andromida/App/Views/History/HistoryDayDetailSheet.swift b/Andromida/App/Views/History/HistoryDayDetailSheet.swift new file mode 100644 index 0000000..b3a93b2 --- /dev/null +++ b/Andromida/App/Views/History/HistoryDayDetailSheet.swift @@ -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: [] + ) +} diff --git a/Andromida/App/Views/History/HistoryMonthView.swift b/Andromida/App/Views/History/HistoryMonthView.swift new file mode 100644 index 0000000..b888981 --- /dev/null +++ b/Andromida/App/Views/History/HistoryMonthView.swift @@ -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) +} diff --git a/Andromida/App/Views/History/HistoryView.swift b/Andromida/App/Views/History/HistoryView.swift new file mode 100644 index 0000000..23952d0 --- /dev/null +++ b/Andromida/App/Views/History/HistoryView.swift @@ -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) +} diff --git a/Andromida/App/Views/Insights/Components/InsightCardView.swift b/Andromida/App/Views/Insights/Components/InsightCardView.swift index 76983d6..b611b5b 100644 --- a/Andromida/App/Views/Insights/Components/InsightCardView.swift +++ b/Andromida/App/Views/Insights/Components/InsightCardView.swift @@ -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) } diff --git a/Andromida/App/Views/Insights/Components/InsightDetailSheet.swift b/Andromida/App/Views/Insights/Components/InsightDetailSheet.swift new file mode 100644 index 0000000..597a620 --- /dev/null +++ b/Andromida/App/Views/Insights/Components/InsightDetailSheet.swift @@ -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") + ] + )) +} diff --git a/Andromida/App/Views/Insights/InsightsView.swift b/Andromida/App/Views/Insights/InsightsView.swift index 375e6ed..e4cc8bd 100644 --- a/Andromida/App/Views/Insights/InsightsView.swift +++ b/Andromida/App/Views/Insights/InsightsView.swift @@ -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) } } } diff --git a/Andromida/App/Views/RootView.swift b/Andromida/App/Views/RootView.swift index cc28454..a7d26c3 100644 --- a/Andromida/App/Views/RootView.swift +++ b/Andromida/App/Views/RootView.swift @@ -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) } } } diff --git a/Andromida/App/Views/Settings/SettingsView.swift b/Andromida/App/Views/Settings/SettingsView.swift index fdfb7f0..7c5c749 100644 --- a/Andromida/App/Views/Settings/SettingsView.swift +++ b/Andromida/App/Views/Settings/SettingsView.swift @@ -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) } } diff --git a/Andromida/Shared/AppMetrics.swift b/Andromida/Shared/AppMetrics.swift index a726d4f..1df6eda 100644 --- a/Andromida/Shared/AppMetrics.swift +++ b/Andromida/Shared/AppMetrics.swift @@ -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 { diff --git a/TODO.md b/TODO.md index cfa91aa..c01ec71 100644 --- a/TODO.md +++ b/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.