From 4139f4c5301c24e7e2c9d276d1ae7ec2215b1eb2 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Sun, 25 Jan 2026 21:14:02 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- .../App/Localization/Localizable.xcstrings | 171 +++++++++++++++++ Andromida/App/Models/Milestone.swift | 55 ++++++ Andromida/App/Models/TrendDirection.swift | 53 ++++++ .../App/Protocols/InsightTipsProviding.swift | 104 +++++++++++ .../App/Protocols/RitualStoreProviding.swift | 12 ++ Andromida/App/State/RitualStore.swift | 172 ++++++++++++++++++ .../History/Components/ProgressRing.swift | 30 ++- .../Views/History/HistoryDayDetailSheet.swift | 88 ++++++++- Andromida/App/Views/History/HistoryView.swift | 3 +- .../Insights/Components/InsightCardView.swift | 20 +- .../Components/InsightDetailSheet.swift | 115 ++++++++++-- .../App/Views/Insights/InsightsView.swift | 2 +- .../Components/HabitPerformanceView.swift | 92 ++++++++++ .../Components/RitualMilestonesView.swift | 74 ++++++++ .../App/Views/Rituals/RitualDetailView.swift | 79 +++++++- .../Components/TodayRitualSectionView.swift | 12 +- 16 files changed, 1046 insertions(+), 36 deletions(-) create mode 100644 Andromida/App/Models/Milestone.swift create mode 100644 Andromida/App/Models/TrendDirection.swift create mode 100644 Andromida/App/Protocols/InsightTipsProviding.swift create mode 100644 Andromida/App/Views/Rituals/Components/HabitPerformanceView.swift create mode 100644 Andromida/App/Views/Rituals/Components/RitualMilestonesView.swift diff --git a/Andromida/App/Localization/Localizable.xcstrings b/Andromida/App/Localization/Localizable.xcstrings index d523158..5533ecd 100644 --- a/Andromida/App/Localization/Localizable.xcstrings +++ b/Andromida/App/Localization/Localizable.xcstrings @@ -3,6 +3,22 @@ "strings" : { "" : { + }, + "-%lld%% vs last week" : { + "comment" : "A description of how a user's usage has changed compared to the previous week. The argument is the percentage by which the usage has increased or decreased.", + "isCommentAutoGenerated" : true + }, + "%@, Day %lld" : { + "comment" : "A view representing a milestone achievement in a ritual journey. The first argument is the title of the milestone. The second argument is the day on which the milestone was achieved.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@, Day %2$lld" + } + } + } }, "%lld" : { "comment" : "A text view displaying the day number in a history calendar cell. The text is centered and has a small font size.", @@ -12,10 +28,22 @@ "comment" : "A label displaying the duration of a preset in days. The argument is the number of days the preset is active.", "isCommentAutoGenerated" : true }, + "%lld days remaining" : { + "comment" : "A label showing how many days are left in the current ritual arc. The argument is the number of days remaining in the arc.", + "isCommentAutoGenerated" : true + }, "%lld habits" : { "comment" : "A label showing the number of habits included in a ritual preset. The argument is the count of habits in the preset.", "isCommentAutoGenerated" : true }, + "%lld habits left to complete today." : { + "comment" : "A tip suggesting that more habits need to be completed to reach the goal. The argument is the number of habits remaining.", + "isCommentAutoGenerated" : true + }, + "%lld more days to beat your record!" : { + "comment" : "A tip suggesting that a user should work towards beating their current streak. The argument is the number of days remaining to beat the current streak.", + "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, @@ -54,6 +82,14 @@ "comment" : "A label describing the completion percentage of a task. The argument is the percentage.", "isCommentAutoGenerated" : true }, + "%lld percent completion rate" : { + "comment" : "A value describing the completion rate of a habit. The value is a percentage.", + "isCommentAutoGenerated" : true + }, + "%lld-day streak" : { + "comment" : "A badge indicating a user's current streak of completed habits. The number in the label is replaced with the actual streak length.", + "isCommentAutoGenerated" : true + }, "%lld%%" : { "comment" : "A text label displaying a percentage. The argument is a value between 0.0 and 1.0.", "isCommentAutoGenerated" : true @@ -62,6 +98,10 @@ "comment" : "A label that appears in the preset card to indicate that there are more habits than can be shown in the preview. The number inside the plus sign is the count of additional habits.", "isCommentAutoGenerated" : true }, + "+%lld%% vs last week" : { + "comment" : "A textual representation of a percentage change, indicating whether it is positive or negative. The argument is the percentage change, as an integer.", + "isCommentAutoGenerated" : true + }, "5-minute meditation" : { "comment" : "Title of a habit preset within a mindfulness ritual preset.", "isCommentAutoGenerated" : true @@ -70,6 +110,10 @@ "comment" : "A heading for the 7-day trend section of an insight detail sheet.", "isCommentAutoGenerated" : true }, + "21 days is when habits start to stick." : { + "comment" : "Tip text indicating that 21 days is when habits start to stick.", + "isCommentAutoGenerated" : true + }, "A calm mind sets the tone for a calm day." : { "comment" : "Notes for the \"Morning Meditation\" ritual preset.", "isCommentAutoGenerated" : true @@ -126,6 +170,10 @@ "comment" : "Description of what \"Gentle\" focus style means for the user.", "isCommentAutoGenerated" : true }, + "A week of activity shows commitment!" : { + "comment" : "Tip suggesting that a week of activity indicates strong commitment to habits.", + "isCommentAutoGenerated" : true + }, "About" : { "localizations" : { "en" : { @@ -148,6 +196,14 @@ } } }, + "Above weekly average" : { + "comment" : "Label text for a badge indicating that a user's habit completion rate is above the average for the week.", + "isCommentAutoGenerated" : true + }, + "Achieved" : { + "comment" : "A description of a milestone that has been completed.", + "isCommentAutoGenerated" : true + }, "Across all rituals" : { "extractionState" : "stale", "localizations" : { @@ -214,6 +270,10 @@ "comment" : "A label indicating that a preset has been successfully added to the user's rituals.", "isCommentAutoGenerated" : true }, + "Adding 1-2 more habits could help build momentum." : { + "comment" : "Tip to consider adding more habits to build momentum.", + "isCommentAutoGenerated" : true + }, "Adjust arc duration" : { "localizations" : { "en" : { @@ -243,6 +303,9 @@ "All" : { "comment" : "Title for the \"All\" option in the history ritual filter picker.", "isCommentAutoGenerated" : true + }, + "All habits complete! Great work today." : { + }, "Anytime" : { "comment" : "Name of the \"Anytime\" time of day option in the Ritual editor.", @@ -256,6 +319,10 @@ "comment" : "Name of a habit within a self-care ritual preset.", "isCommentAutoGenerated" : true }, + "Arc complete!" : { + "comment" : "A message displayed when a ritual's streak reaches its maximum value.", + "isCommentAutoGenerated" : true + }, "Archive" : { "comment" : "A button that archives a ritual, hiding it from the user's active list but preserving its history.", "isCommentAutoGenerated" : true @@ -294,6 +361,10 @@ } } }, + "Below weekly average" : { + "comment" : "Label text for a badge indicating that their habit completion rate is below the average for the week.", + "isCommentAutoGenerated" : true + }, "Body scan for tension" : { "comment" : "Habit title for a mindfulness ritual that involves scanning the body for tension.", "isCommentAutoGenerated" : true @@ -474,6 +545,14 @@ "comment" : "A label indicating that a feature is coming soon.", "isCommentAutoGenerated" : true }, + "Complete" : { + "comment" : "Title of the final milestone in a 28-day ritual arc.", + "isCommentAutoGenerated" : true + }, + "Complete all habits today to start a new streak." : { + "comment" : "Tip for the \"Habits today\" insight card, encouraging users to complete all their habits to start a new streak.", + "isCommentAutoGenerated" : true + }, "Completed" : { "localizations" : { "en" : { @@ -524,6 +603,10 @@ }, "Consecutive perfect days" : { + }, + "Consider focusing on fewer habits for better consistency." : { + "comment" : "Tip to consider focusing on fewer habits for better consistency.", + "isCommentAutoGenerated" : true }, "Create" : { "comment" : "A button label that says \"Create\".", @@ -645,6 +728,10 @@ "comment" : "Label for the x-axis in the mini sparkline chart within an `InsightCardView`.", "isCommentAutoGenerated" : true }, + "Day %lld" : { + "comment" : "A small label displaying the day and title of a milestone. The first argument is the day of the milestone. The second argument is the title of the milestone.", + "isCommentAutoGenerated" : true + }, "Day %lld of %lld" : { "localizations" : { "en" : { @@ -907,6 +994,14 @@ "comment" : "Title of a ritual preset that encourages transitioning to rest at the end of the day.", "isCommentAutoGenerated" : true }, + "Every habit counts. You've got this." : { + "comment" : "Motivational message for a completion rate between 0.25 and 0.5.", + "isCommentAutoGenerated" : true + }, + "Excellent consistency! You're building strong habits." : { + "comment" : "Tip provided in the \"Completion\" insight card when the user has a high completion rate.", + "isCommentAutoGenerated" : true + }, "Faster iCloud synchronization" : { }, @@ -937,6 +1032,10 @@ "comment" : "Title of a habit in a ritual preset focused on practicing gratitude.", "isCommentAutoGenerated" : true }, + "First Day" : { + "comment" : "Title of the first milestone in a 28-day ritual arc.", + "isCommentAutoGenerated" : true + }, "Focus Reset" : { "comment" : "Title of a ritual preset that encourages users to refocus when they feel scattered.", "isCommentAutoGenerated" : true @@ -1135,10 +1234,18 @@ "comment" : "Title of a ritual preset focused on practicing gratitude.", "isCommentAutoGenerated" : true }, + "Great progress! Almost there." : { + "comment" : "Motivational message for a completion rate between 0.75 and 1.0.", + "isCommentAutoGenerated" : true + }, "Habit name" : { "comment" : "A text field label for entering a habit name.", "isCommentAutoGenerated" : true }, + "Habit Performance" : { + "comment" : "A title for a view that shows how well a user is performing various habits.", + "isCommentAutoGenerated" : true + }, "Habits" : { "localizations" : { "en" : { @@ -1187,6 +1294,10 @@ } } }, + "Halfway" : { + "comment" : "Title of a milestone achievement halfway through a 28-day ritual arc.", + "isCommentAutoGenerated" : true + }, "Haptics" : { "localizations" : { "en" : { @@ -1385,6 +1496,10 @@ "comment" : "Title of a ritual preset that encourages regular physical activity during the day.", "isCommentAutoGenerated" : true }, + "Milestones" : { + "comment" : "A label displayed above the list of milestones.", + "isCommentAutoGenerated" : true + }, "Mindful minute" : { "localizations" : { "en" : { @@ -1579,6 +1694,10 @@ } } }, + "Not yet achieved" : { + "comment" : "A description of a milestone that the user has not yet achieved.", + "isCommentAutoGenerated" : true + }, "Notes" : { "localizations" : { "en" : { @@ -1605,6 +1724,18 @@ "comment" : "Subtitle text for the reminder toggle when notifications are disabled in settings.", "isCommentAutoGenerated" : true }, + "On track" : { + "comment" : "Label for a badge indicating that a user is on track with their habit completions.", + "isCommentAutoGenerated" : true + }, + "One Week" : { + "comment" : "Title for a milestone that occurs one week into a ritual arc.", + "isCommentAutoGenerated" : true + }, + "Perfect day! You crushed it." : { + "comment" : "A motivational message displayed when a user achieves 100% completion on a ritual.", + "isCommentAutoGenerated" : true + }, "Plan the week ahead" : { "comment" : "Habit title for a ritual preset that helps users plan their week ahead.", "isCommentAutoGenerated" : true @@ -2056,6 +2187,10 @@ "comment" : "Theme of the \"Sleep Preparation\" ritual preset.", "isCommentAutoGenerated" : true }, + "Small steps lead to big changes." : { + "comment" : "A motivational message indicating that small steps can lead to significant changes.", + "isCommentAutoGenerated" : true + }, "Soft landings" : { "localizations" : { "en" : { @@ -2078,6 +2213,10 @@ } } }, + "Solid effort. Keep building momentum." : { + "comment" : "A motivational message for a completion rate between 0.5 and 0.75.", + "isCommentAutoGenerated" : true + }, "Sound" : { "localizations" : { "en" : { @@ -2100,6 +2239,10 @@ } } }, + "Stable" : { + "comment" : "Description of a trend direction when there is no significant change.", + "isCommentAutoGenerated" : true + }, "Start with stillness" : { "comment" : "Theme of the \"Morning Meditation\" ritual preset.", "isCommentAutoGenerated" : true @@ -2450,6 +2593,10 @@ "comment" : "An alert message that appears when the user attempts to delete a ritual. It explains that deleting the ritual cannot be undone.", "isCommentAutoGenerated" : true }, + "Three Weeks" : { + "comment" : "Title of a milestone that is achieved after three weeks of a ritual journey.", + "isCommentAutoGenerated" : true + }, "Tidy workspace" : { "comment" : "Habit within a ritual preset that encourages tidying one's workspace.", "isCommentAutoGenerated" : true @@ -2466,6 +2613,10 @@ "comment" : "A label for the time of day picker in the ritual edit sheet.", "isCommentAutoGenerated" : true }, + "Tips" : { + "comment" : "A section header that indicates that the view contains tips.", + "isCommentAutoGenerated" : true + }, "Today" : { "localizations" : { "en" : { @@ -2492,6 +2643,10 @@ "comment" : "Explanation of the \"Today's progress\" insight card in the Ritual Insights section.", "isCommentAutoGenerated" : true }, + "Tomorrow is a fresh start." : { + "comment" : "A motivational message displayed when the user's completion rate is below 1%.", + "isCommentAutoGenerated" : true + }, "Total arcs in motion" : { "extractionState" : "stale", "localizations" : { @@ -2542,6 +2697,18 @@ "comment" : "Theme of the \"Evening Wind-Down\" ritual preset.", "isCommentAutoGenerated" : true }, + "Trending down" : { + "comment" : "Accessibility label for a trend direction of \"down\".", + "isCommentAutoGenerated" : true + }, + "Trending up" : { + "comment" : "Accessibility label for a trend direction indicating an increase.", + "isCommentAutoGenerated" : true + }, + "Try starting with just one habit to build momentum." : { + "comment" : "Tip to start with a single habit to build momentum.", + "isCommentAutoGenerated" : true + }, "Unlimited Rituals" : { "comment" : "Description of a feature in the \"Pro\" upgrade section, describing that users can create as many rituals as they need.", "isCommentAutoGenerated" : true @@ -2649,6 +2816,10 @@ "comment" : "Habit title for a ritual preset that encourages the user to write down their thoughts.", "isCommentAutoGenerated" : true }, + "You're at your best streak! Keep it going." : { + "comment" : "Tip provided when the user is at their longest streak and it is greater than zero.", + "isCommentAutoGenerated" : true + }, "Your active and recent arcs" : { "localizations" : { "en" : { diff --git a/Andromida/App/Models/Milestone.swift b/Andromida/App/Models/Milestone.swift new file mode 100644 index 0000000..72747d9 --- /dev/null +++ b/Andromida/App/Models/Milestone.swift @@ -0,0 +1,55 @@ +// +// Milestone.swift +// Andromida +// +// Represents a milestone achievement within a ritual arc. +// + +import Foundation + +/// A milestone that can be achieved during a ritual journey. +struct Milestone: Identifiable { + let id = UUID() + let day: Int + let title: String + let symbolName: String + let isAchieved: Bool + + /// Standard milestones for a 28-day ritual arc. + static func standardMilestones(currentDay: Int, totalDays: Int) -> [Milestone] { + let halfwayDay = totalDays / 2 + + return [ + Milestone( + day: 1, + title: String(localized: "First Day"), + symbolName: "star.fill", + isAchieved: currentDay >= 1 + ), + Milestone( + day: 7, + title: String(localized: "One Week"), + symbolName: "7.circle.fill", + isAchieved: currentDay >= 7 + ), + Milestone( + day: halfwayDay, + title: String(localized: "Halfway"), + symbolName: "flag.fill", + isAchieved: currentDay >= halfwayDay + ), + Milestone( + day: 21, + title: String(localized: "Three Weeks"), + symbolName: "brain.fill", + isAchieved: currentDay >= 21 + ), + Milestone( + day: totalDays, + title: String(localized: "Complete"), + symbolName: "trophy.fill", + isAchieved: currentDay >= totalDays + ) + ].filter { $0.day <= totalDays } + } +} diff --git a/Andromida/App/Models/TrendDirection.swift b/Andromida/App/Models/TrendDirection.swift new file mode 100644 index 0000000..c9c4b78 --- /dev/null +++ b/Andromida/App/Models/TrendDirection.swift @@ -0,0 +1,53 @@ +// +// TrendDirection.swift +// Andromida +// +// Represents the direction of a trend for insight visualization. +// + +import SwiftUI +import Bedrock + +/// Represents the direction of change in a metric. +enum TrendDirection { + case up + case down + case stable + + var symbolName: String { + switch self { + case .up: return "arrow.up.right" + case .down: return "arrow.down.right" + case .stable: return "arrow.right" + } + } + + var color: Color { + switch self { + case .up: return AppStatus.success + case .down: return AppStatus.warning + case .stable: return AppTextColors.secondary + } + } + + var accessibilityLabel: String { + switch self { + case .up: return String(localized: "Trending up") + case .down: return String(localized: "Trending down") + case .stable: return String(localized: "Stable") + } + } + + /// Determines trend direction based on percentage change. + /// - Parameter percentageChange: The change as a decimal (e.g., 0.1 for 10% increase) + /// - Returns: The appropriate trend direction + static func from(percentageChange: Double) -> TrendDirection { + if percentageChange > 0.05 { + return .up + } else if percentageChange < -0.05 { + return .down + } else { + return .stable + } + } +} diff --git a/Andromida/App/Protocols/InsightTipsProviding.swift b/Andromida/App/Protocols/InsightTipsProviding.swift new file mode 100644 index 0000000..01caea7 --- /dev/null +++ b/Andromida/App/Protocols/InsightTipsProviding.swift @@ -0,0 +1,104 @@ +// +// InsightTipsProviding.swift +// Andromida +// +// Protocol for generating contextual tips based on insight metrics. +// + +import Foundation + +/// Context information for generating insight tips. +struct InsightContext { + let completionRate: Double + let currentStreak: Int + let longestStreak: Int + let daysActive: Int + let totalHabits: Int + let completedToday: Int +} + +/// Protocol for generating contextual tips for insights. +protocol InsightTipsProviding { + /// Generates tips based on the insight card and user context. + /// - Parameters: + /// - card: The insight card to generate tips for + /// - context: Current user metrics context + /// - Returns: Array of tip strings + func tips(for card: InsightCard, context: InsightContext) -> [String] +} + +/// Default implementation providing standard tips. +struct DefaultInsightTipsProvider: InsightTipsProviding { + func tips(for card: InsightCard, context: InsightContext) -> [String] { + switch card.title { + case String(localized: "Active"): + return activeTips(context: context) + case String(localized: "Streak"): + return streakTips(context: context) + case String(localized: "Habits today"): + return habitsTips(context: context) + case String(localized: "Completion"): + return completionTips(context: context) + case String(localized: "Days Active"): + return daysActiveTips(context: context) + default: + return [] + } + } + + private func activeTips(context: InsightContext) -> [String] { + var tips: [String] = [] + if context.totalHabits > 10 { + tips.append(String(localized: "Consider focusing on fewer habits for better consistency.")) + } + if context.totalHabits < 3 { + tips.append(String(localized: "Adding 1-2 more habits could help build momentum.")) + } + return tips + } + + private func streakTips(context: InsightContext) -> [String] { + var tips: [String] = [] + if context.currentStreak == 0 { + tips.append(String(localized: "Complete all habits today to start a new streak.")) + } else if context.currentStreak < context.longestStreak { + let daysToGo = context.longestStreak - context.currentStreak + tips.append(String(localized: "\(daysToGo) more days to beat your record!")) + } else if context.currentStreak == context.longestStreak && context.currentStreak > 0 { + tips.append(String(localized: "You're at your best streak! Keep it going.")) + } + return tips + } + + private func habitsTips(context: InsightContext) -> [String] { + var tips: [String] = [] + let remaining = context.totalHabits - context.completedToday + if remaining > 0 { + tips.append(String(localized: "\(remaining) habits left to complete today.")) + } else if context.totalHabits > 0 { + tips.append(String(localized: "All habits complete! Great work today.")) + } + return tips + } + + private func completionTips(context: InsightContext) -> [String] { + var tips: [String] = [] + if context.completionRate < 0.5 { + tips.append(String(localized: "Try starting with just one habit to build momentum.")) + } else if context.completionRate >= 0.8 { + tips.append(String(localized: "Excellent consistency! You're building strong habits.")) + } + return tips + } + + private func daysActiveTips(context: InsightContext) -> [String] { + var tips: [String] = [] + if context.daysActive >= 7 { + tips.append(String(localized: "A week of activity shows commitment!")) + } + if context.daysActive >= 21 { + tips.append(String(localized: "21 days is when habits start to stick.")) + } + return tips + } +} diff --git a/Andromida/App/Protocols/RitualStoreProviding.swift b/Andromida/App/Protocols/RitualStoreProviding.swift index 2436950..0289925 100644 --- a/Andromida/App/Protocols/RitualStoreProviding.swift +++ b/Andromida/App/Protocols/RitualStoreProviding.swift @@ -14,4 +14,16 @@ protocol RitualStoreProviding { func completionSummary(for ritual: Ritual) -> String func insightCards() -> [InsightCard] func createQuickRitual() + + // Enhanced Analytics + func weeklyAverageForDate(_ date: Date) -> Double + func streakIncluding(_ date: Date) -> Int? + func daysRemaining(for ritual: Ritual) -> Int + func streakForRitual(_ ritual: Ritual) -> Int + func habitCompletionRates(for ritual: Ritual) -> [(habit: Habit, rate: Double)] + func milestonesAchieved(for ritual: Ritual) -> [Milestone] + func weekOverWeekChange() -> Double + func motivationalMessage(for rate: Double) -> String + func insightContext() -> InsightContext + func completionRate(for date: Date, ritual: Ritual?) -> Double } diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index 98ef786..a947fa9 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -570,6 +570,178 @@ final class RitualStore: RitualStoreProviding { return habit.completedDayIDs.contains(dayID) } + // MARK: - Enhanced Analytics + + /// Returns the weekly average completion rate for the week containing the given date. + /// - Parameter date: A date within the week to analyze + /// - Returns: Average completion rate from 0.0 to 1.0 + func weeklyAverageForDate(_ date: Date) -> Double { + guard let weekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date)) else { + return 0 + } + + var totalRate = 0.0 + var daysWithData = 0 + + for dayOffset in 0..<7 { + guard let day = calendar.date(byAdding: .day, value: dayOffset, to: weekStart) else { continue } + let rate = completionRate(for: day) + if rate > 0 || hasAnyHabitsOnDate(day) { + totalRate += rate + daysWithData += 1 + } + } + + return daysWithData > 0 ? totalRate / Double(daysWithData) : 0 + } + + /// Checks if there were any habits tracked on the given date. + private func hasAnyHabitsOnDate(_ date: Date) -> Bool { + let dayID = dayIdentifier(for: date) + return rituals.flatMap { $0.habits }.contains { habit in + habit.completedDayIDs.contains(dayID) + } + } + + /// Returns the streak length that includes the given date, or nil if the date wasn't a perfect day. + /// - Parameter date: The date to check + /// - Returns: The streak length if the date was part of a streak, nil otherwise + func streakIncluding(_ date: Date) -> Int? { + let dayID = dayIdentifier(for: date) + let perfect = perfectDays() + + guard perfect.contains(dayID) else { return nil } + + // Count backwards from the date + var streakBefore = 0 + var checkDate = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: date)) ?? date + while perfect.contains(dayIdentifier(for: checkDate)) { + streakBefore += 1 + checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate + } + + // Count forwards from the date + var streakAfter = 0 + checkDate = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: date)) ?? date + while perfect.contains(dayIdentifier(for: checkDate)) { + streakAfter += 1 + checkDate = calendar.date(byAdding: .day, value: 1, to: checkDate) ?? checkDate + } + + return streakBefore + 1 + streakAfter + } + + /// Returns the number of days remaining in the ritual arc. + /// - Parameter ritual: The ritual to check + /// - Returns: Days remaining (0 if completed or past end date) + func daysRemaining(for ritual: Ritual) -> Int { + let today = calendar.startOfDay(for: Date()) + let start = calendar.startOfDay(for: ritual.startDate) + guard let endDate = calendar.date(byAdding: .day, value: ritual.durationDays - 1, to: start) else { + return 0 + } + + let days = calendar.dateComponents([.day], from: today, to: endDate).day ?? 0 + return max(0, days) + } + + /// Returns the current streak for a specific ritual. + /// - Parameter ritual: The ritual to check + /// - Returns: Current streak count for this ritual only + func streakForRitual(_ ritual: Ritual) -> Int { + guard !ritual.habits.isEmpty else { return 0 } + + var streak = 0 + var checkDate = calendar.startOfDay(for: Date()) + + while true { + let dayID = dayIdentifier(for: checkDate) + let allCompleted = ritual.habits.allSatisfy { $0.completedDayIDs.contains(dayID) } + + if allCompleted { + streak += 1 + checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate + } else { + break + } + } + + return streak + } + + /// Returns completion rates for each habit in a ritual. + /// - Parameter ritual: The ritual to analyze + /// - Returns: Array of tuples with habit and its completion rate + func habitCompletionRates(for ritual: Ritual) -> [(habit: Habit, rate: Double)] { + let currentDay = ritualDayIndex(for: ritual) + guard currentDay > 0 else { return ritual.habits.map { ($0, 0.0) } } + + return ritual.habits.map { habit in + let completedDays = habit.completedDayIDs.count + let rate = Double(completedDays) / Double(currentDay) + return (habit, min(rate, 1.0)) + } + } + + /// Returns milestones for a ritual with achievement status. + /// - Parameter ritual: The ritual to check + /// - Returns: Array of milestones with achievement status + func milestonesAchieved(for ritual: Ritual) -> [Milestone] { + let currentDay = ritualDayIndex(for: ritual) + return Milestone.standardMilestones(currentDay: currentDay, totalDays: ritual.durationDays) + } + + /// Returns the week-over-week change in completion rate. + /// - Returns: Percentage change (e.g., 0.15 for 15% improvement) + func weekOverWeekChange() -> Double { + let today = calendar.startOfDay(for: Date()) + guard let lastWeekStart = calendar.date(byAdding: .day, value: -7, to: today) else { + return 0 + } + + let thisWeekAvg = weeklyAverageForDate(today) + let lastWeekAvg = weeklyAverageForDate(lastWeekStart) + + guard lastWeekAvg > 0 else { return thisWeekAvg > 0 ? 1.0 : 0 } + return (thisWeekAvg - lastWeekAvg) / lastWeekAvg + } + + /// Returns a motivational message based on the completion rate. + /// - Parameter rate: The completion rate (0.0 to 1.0) + /// - Returns: A localized motivational message + func motivationalMessage(for rate: Double) -> String { + switch rate { + case 1.0: + return String(localized: "Perfect day! You crushed it.") + case 0.75..<1.0: + return String(localized: "Great progress! Almost there.") + case 0.5..<0.75: + return String(localized: "Solid effort. Keep building momentum.") + case 0.25..<0.5: + return String(localized: "Every habit counts. You've got this.") + case 0.01..<0.25: + return String(localized: "Small steps lead to big changes.") + default: + return String(localized: "Tomorrow is a fresh start.") + } + } + + /// Returns the insight context for tips generation. + func insightContext() -> InsightContext { + let totalHabits = rituals.flatMap { $0.habits }.count + let completedToday = rituals.flatMap { $0.habits }.filter { isHabitCompletedToday($0) }.count + let rate = totalHabits > 0 ? Double(completedToday) / Double(totalHabits) : 0 + + return InsightContext( + completionRate: rate, + currentStreak: currentStreak(), + longestStreak: longestStreak(), + daysActive: datesWithActivity().count, + totalHabits: totalHabits, + completedToday: completedToday + ) + } + // MARK: - Debug / Demo Data #if DEBUG diff --git a/Andromida/App/Views/History/Components/ProgressRing.swift b/Andromida/App/Views/History/Components/ProgressRing.swift index 494f1df..ea5024e 100644 --- a/Andromida/App/Views/History/Components/ProgressRing.swift +++ b/Andromida/App/Views/History/Components/ProgressRing.swift @@ -13,6 +13,7 @@ struct ProgressRing: View { let progress: Double // 0.0 to 1.0 let size: CGFloat var lineWidth: CGFloat = 3 + var showPercentage: Bool = false private var color: Color { if progress >= 1.0 { return AppStatus.success } @@ -38,6 +39,15 @@ struct ProgressRing: View { ) .rotationEffect(.degrees(-90)) .animation(.easeOut(duration: Design.Animation.standard), value: progress) + + // Percentage text inside ring + if showPercentage { + Text("\(Int(progress * 100))%") + .font(.system(size: size * 0.25, weight: .bold)) + .foregroundStyle(AppTextColors.primary) + .minimumScaleFactor(0.8) + .lineLimit(1) + } } .frame(width: size, height: size) .accessibilityElement(children: .ignore) @@ -46,12 +56,20 @@ struct ProgressRing: View { } #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) + VStack(spacing: Design.Spacing.large) { + 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) + } + + HStack(spacing: Design.Spacing.large) { + ProgressRing(progress: 0.0, size: 80, lineWidth: 6, showPercentage: true) + ProgressRing(progress: 0.5, size: 80, lineWidth: 6, showPercentage: true) + ProgressRing(progress: 1.0, size: 80, lineWidth: 6, showPercentage: true) + } } .padding() .background(AppSurface.primary) diff --git a/Andromida/App/Views/History/HistoryDayDetailSheet.swift b/Andromida/App/Views/History/HistoryDayDetailSheet.swift index b3a93b2..5e1dcb0 100644 --- a/Andromida/App/Views/History/HistoryDayDetailSheet.swift +++ b/Andromida/App/Views/History/HistoryDayDetailSheet.swift @@ -12,6 +12,7 @@ import Bedrock struct HistoryDayDetailSheet: View { let date: Date let completions: [HabitCompletion] + let store: RitualStore @Environment(\.dismiss) private var dismiss private var dateTitle: String { @@ -30,6 +31,14 @@ struct HistoryDayDetailSheet: View { completions.filter { $0.isCompleted }.count } + private var weeklyAverage: Double { + store.weeklyAverageForDate(date) + } + + private var streakLength: Int? { + store.streakIncluding(date) + } + var body: some View { NavigationStack { ScrollView(.vertical, showsIndicators: false) { @@ -37,6 +46,11 @@ struct HistoryDayDetailSheet: View { // Summary header summaryHeader + // Context badges (comparison, streak) + if !completions.isEmpty { + contextSection + } + // Habit list grouped by ritual if completions.isEmpty { emptyState @@ -93,7 +107,7 @@ struct HistoryDayDetailSheet: View { private var summaryHeader: some View { VStack(spacing: Design.Spacing.medium) { - ProgressRing(progress: completionRate, size: 80, lineWidth: 6) + ProgressRing(progress: completionRate, size: 80, lineWidth: 6, showPercentage: true) Text("\(completedCount) of \(completions.count)") .font(.title2) @@ -103,11 +117,80 @@ struct HistoryDayDetailSheet: View { Text(String(localized: "habits completed")) .font(.subheadline) .foregroundStyle(AppTextColors.secondary) + + // Motivational message + if !completions.isEmpty { + Text(store.motivationalMessage(for: completionRate)) + .font(.subheadline) + .foregroundStyle(AppTextColors.tertiary) + .italic() + .padding(.top, Design.Spacing.xSmall) + } } .frame(maxWidth: .infinity) .padding(.vertical, Design.Spacing.large) } + private var contextSection: some View { + VStack(spacing: Design.Spacing.small) { + HStack(spacing: Design.Spacing.medium) { + // Comparison badge + comparisonBadge + + // Streak badge (if applicable) + if let streak = streakLength { + streakBadge(streak) + } + } + } + .frame(maxWidth: .infinity) + } + + private var comparisonBadge: some View { + let difference = completionRate - weeklyAverage + let isAbove = difference > 0.05 + let isBelow = difference < -0.05 + + let text: String + let symbolName: String + let color: Color + + if isAbove { + text = String(localized: "Above weekly average") + symbolName = "arrow.up.circle.fill" + color = AppStatus.success + } else if isBelow { + text = String(localized: "Below weekly average") + symbolName = "arrow.down.circle.fill" + color = AppStatus.warning + } else { + text = String(localized: "On track") + symbolName = "equal.circle.fill" + color = AppAccent.primary + } + + return Label(text, systemImage: symbolName) + .font(.caption) + .foregroundStyle(color) + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .background(color.opacity(Design.Opacity.subtle)) + .clipShape(.capsule) + } + + private func streakBadge(_ streak: Int) -> some View { + Label( + String(localized: "\(streak)-day streak"), + systemImage: "flame.fill" + ) + .font(.caption) + .foregroundStyle(AppStatus.success) + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .background(AppStatus.success.opacity(Design.Opacity.subtle)) + .clipShape(.capsule) + } + private var habitList: some View { let groupedByRitual = Dictionary(grouping: completions) { $0.ritualTitle } let sortedRituals = groupedByRitual.keys.sorted() @@ -160,6 +243,7 @@ struct HistoryDayDetailSheet: View { #Preview { HistoryDayDetailSheet( date: Date(), - completions: [] + completions: [], + store: RitualStore.preview ) } diff --git a/Andromida/App/Views/History/HistoryView.swift b/Andromida/App/Views/History/HistoryView.swift index 23952d0..5832163 100644 --- a/Andromida/App/Views/History/HistoryView.swift +++ b/Andromida/App/Views/History/HistoryView.swift @@ -90,7 +90,8 @@ struct HistoryView: View { .sheet(item: $selectedDateItem) { item in HistoryDayDetailSheet( date: item.date, - completions: store.habitCompletions(for: item.date, ritual: selectedRitual) + completions: store.habitCompletions(for: item.date, ritual: selectedRitual), + store: store ) } } diff --git a/Andromida/App/Views/Insights/Components/InsightCardView.swift b/Andromida/App/Views/Insights/Components/InsightCardView.swift index 8d64184..6bdb50a 100644 --- a/Andromida/App/Views/Insights/Components/InsightCardView.swift +++ b/Andromida/App/Views/Insights/Components/InsightCardView.swift @@ -4,6 +4,7 @@ import Charts struct InsightCardView: View { let card: InsightCard + let store: RitualStore @State private var showingDetail = false var body: some View { @@ -14,7 +15,7 @@ struct InsightCardView: View { } .buttonStyle(.plain) .sheet(isPresented: $showingDetail) { - InsightDetailSheet(card: card) + InsightDetailSheet(card: card, store: store) } } @@ -78,13 +79,16 @@ struct InsightCardView: View { } #Preview { - InsightCardView(card: InsightCard( - title: "Completion", - value: "72%", - caption: "Today's progress", - explanation: "Your completion percentage for today 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" + ), + store: RitualStore.preview + ) .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 index 597a620..2abad5a 100644 --- a/Andromida/App/Views/Insights/Components/InsightDetailSheet.swift +++ b/Andromida/App/Views/Insights/Components/InsightDetailSheet.swift @@ -11,8 +11,23 @@ import Charts struct InsightDetailSheet: View { let card: InsightCard + let store: RitualStore @Environment(\.dismiss) private var dismiss + private let tipsProvider: InsightTipsProviding = DefaultInsightTipsProvider() + + private var weekOverWeekChange: Double { + store.weekOverWeekChange() + } + + private var trendDirection: TrendDirection { + TrendDirection.from(percentageChange: weekOverWeekChange) + } + + private var tips: [String] { + tipsProvider.tips(for: card, context: store.insightContext()) + } + var body: some View { NavigationStack { ScrollView(.vertical, showsIndicators: false) { @@ -20,6 +35,11 @@ struct InsightDetailSheet: View { // Header with icon and value headerSection + // Trend indicator (for cards with trend data) + if card.trendData != nil { + trendIndicatorSection + } + // Chart (if trend data available) if let trendData = card.trendData, !trendData.isEmpty { chartSection(trendData) @@ -28,6 +48,11 @@ struct InsightDetailSheet: View { // Explanation explanationSection + // Tips section (if any tips available) + if !tips.isEmpty { + tipsSection + } + // Breakdown (if available) if let breakdown = card.breakdown, !breakdown.isEmpty { breakdownSection(breakdown) @@ -74,6 +99,71 @@ struct InsightDetailSheet: View { .padding(.vertical, Design.Spacing.large) } + // MARK: - Trend Indicator Section + + private var trendIndicatorSection: some View { + HStack(spacing: Design.Spacing.medium) { + // Trend direction badge + HStack(spacing: Design.Spacing.xSmall) { + Image(systemName: trendDirection.symbolName) + .font(.caption) + + Text(trendDirection.accessibilityLabel) + .font(.caption) + } + .foregroundStyle(trendDirection.color) + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .background(trendDirection.color.opacity(Design.Opacity.subtle)) + .clipShape(.capsule) + + // Week-over-week change + if abs(weekOverWeekChange) > 0.01 { + let changePercent = Int(abs(weekOverWeekChange) * 100) + let changeText = weekOverWeekChange >= 0 + ? String(localized: "+\(changePercent)% vs last week") + : String(localized: "-\(changePercent)% vs last week") + + Text(changeText) + .font(.caption) + .foregroundStyle(AppTextColors.secondary) + } + + Spacer() + } + .frame(maxWidth: .infinity) + } + + // MARK: - Tips Section + + private var tipsSection: some View { + VStack(alignment: .leading, spacing: Design.Spacing.small) { + Label(String(localized: "Tips"), systemImage: "lightbulb.fill") + .font(.headline) + .foregroundStyle(AppTextColors.primary) + + VStack(alignment: .leading, spacing: Design.Spacing.small) { + ForEach(tips, id: \.self) { tip in + HStack(alignment: .top, spacing: Design.Spacing.small) { + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(AppAccent.primary) + .padding(.top, Design.Spacing.xSmall) + + Text(tip) + .font(.subheadline) + .foregroundStyle(AppTextColors.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + .padding(Design.Spacing.large) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AppAccent.primary.opacity(Design.Opacity.subtle)) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + } + // MARK: - Explanation Section private var explanationSection: some View { @@ -182,15 +272,18 @@ struct InsightDetailSheet: View { } #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") - ] - )) + 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") + ] + ), + store: RitualStore.preview + ) } diff --git a/Andromida/App/Views/Insights/InsightsView.swift b/Andromida/App/Views/Insights/InsightsView.swift index e4cc8bd..fb498ea 100644 --- a/Andromida/App/Views/Insights/InsightsView.swift +++ b/Andromida/App/Views/Insights/InsightsView.swift @@ -18,7 +18,7 @@ struct InsightsView: View { LazyVGrid(columns: columns, spacing: Design.Spacing.medium) { ForEach(store.insightCards()) { card in - InsightCardView(card: card) + InsightCardView(card: card, store: store) } } } diff --git a/Andromida/App/Views/Rituals/Components/HabitPerformanceView.swift b/Andromida/App/Views/Rituals/Components/HabitPerformanceView.swift new file mode 100644 index 0000000..b537eb6 --- /dev/null +++ b/Andromida/App/Views/Rituals/Components/HabitPerformanceView.swift @@ -0,0 +1,92 @@ +// +// HabitPerformanceView.swift +// Andromida +// +// Displays habit completion rates for a ritual. +// + +import SwiftUI +import Bedrock + +/// A view showing habit completion performance within a ritual. +struct HabitPerformanceView: View { + let habitRates: [(habit: Habit, rate: Double)] + + private var sortedByRate: [(habit: Habit, rate: Double)] { + habitRates.sorted { $0.rate > $1.rate } + } + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.small) { + Text(String(localized: "Habit Performance")) + .font(.headline) + .foregroundStyle(AppTextColors.primary) + + VStack(spacing: Design.Spacing.xSmall) { + ForEach(sortedByRate, id: \.habit.id) { item in + habitRow(item.habit, rate: item.rate) + } + } + } + .padding(Design.Spacing.large) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AppSurface.card) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + } + + private func habitRow(_ habit: Habit, rate: Double) -> some View { + VStack(spacing: Design.Spacing.xSmall) { + HStack(spacing: Design.Spacing.medium) { + Image(systemName: habit.symbolName) + .foregroundStyle(colorForRate(rate)) + .frame(width: AppMetrics.Size.iconMedium) + + Text(habit.title) + .font(.subheadline) + .foregroundStyle(AppTextColors.primary) + + Spacer() + + Text("\(Int(rate * 100))%") + .font(.subheadline) + .bold() + .foregroundStyle(colorForRate(rate)) + } + + // Progress bar + GeometryReader { geometry in + ZStack(alignment: .leading) { + Rectangle() + .fill(AppBorder.subtle) + .frame(height: Design.LineWidth.thick) + + Rectangle() + .fill(colorForRate(rate)) + .frame(width: geometry.size.width * min(rate, 1.0), height: Design.LineWidth.thick) + } + } + .frame(height: Design.LineWidth.thick) + .clipShape(.rect(cornerRadius: Design.CornerRadius.small)) + } + .padding(.vertical, Design.Spacing.xSmall) + .accessibilityElement(children: .combine) + .accessibilityLabel(habit.title) + .accessibilityValue(String(localized: "\(Int(rate * 100)) percent completion rate")) + } + + private func colorForRate(_ rate: Double) -> Color { + if rate >= 0.8 { return AppStatus.success } + if rate >= 0.5 { return AppAccent.primary } + return AppTextColors.tertiary + } +} + +#Preview { + HabitPerformanceView(habitRates: [ + (Habit(title: "Meditate", symbolName: "brain.fill"), 0.85), + (Habit(title: "Exercise", symbolName: "figure.walk"), 0.65), + (Habit(title: "Read", symbolName: "book.fill"), 0.40) + ]) + .padding(Design.Spacing.large) + .background(AppSurface.primary) +} diff --git a/Andromida/App/Views/Rituals/Components/RitualMilestonesView.swift b/Andromida/App/Views/Rituals/Components/RitualMilestonesView.swift new file mode 100644 index 0000000..3153b8c --- /dev/null +++ b/Andromida/App/Views/Rituals/Components/RitualMilestonesView.swift @@ -0,0 +1,74 @@ +// +// RitualMilestonesView.swift +// Andromida +// +// Displays milestone achievements for a ritual journey. +// + +import SwiftUI +import Bedrock + +/// A view showing milestone achievements for a ritual. +struct RitualMilestonesView: View { + let milestones: [Milestone] + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.small) { + Text(String(localized: "Milestones")) + .font(.headline) + .foregroundStyle(AppTextColors.primary) + + HStack(spacing: Design.Spacing.small) { + ForEach(milestones) { milestone in + milestoneItem(milestone) + } + } + } + .padding(Design.Spacing.large) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AppSurface.card) + .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) + } + + private func milestoneItem(_ milestone: Milestone) -> some View { + VStack(spacing: Design.Spacing.xSmall) { + ZStack { + Circle() + .fill(milestone.isAchieved ? AppStatus.success.opacity(Design.Opacity.medium) : AppSurface.secondary) + .frame(width: AppMetrics.Size.milestoneIcon, height: AppMetrics.Size.milestoneIcon) + + Image(systemName: milestone.symbolName) + .font(.system(size: Design.BaseFontSize.caption)) + .foregroundStyle(milestone.isAchieved ? AppStatus.success : AppTextColors.tertiary) + } + + Text("Day \(milestone.day)") + .font(.caption2) + .foregroundStyle(milestone.isAchieved ? AppTextColors.primary : AppTextColors.tertiary) + + Text(milestone.title) + .font(.caption2) + .foregroundStyle(milestone.isAchieved ? AppTextColors.secondary : AppTextColors.tertiary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(milestone.title), Day \(milestone.day)") + .accessibilityValue(milestone.isAchieved ? String(localized: "Achieved") : String(localized: "Not yet achieved")) + } +} + +// MARK: - AppMetrics Extension + +extension AppMetrics.Size { + static let milestoneIcon: CGFloat = 36 +} + +#Preview { + VStack { + RitualMilestonesView(milestones: Milestone.standardMilestones(currentDay: 10, totalDays: 28)) + } + .padding(Design.Spacing.large) + .background(AppSurface.primary) +} diff --git a/Andromida/App/Views/Rituals/RitualDetailView.swift b/Andromida/App/Views/Rituals/RitualDetailView.swift index b153a71..3d2ad31 100644 --- a/Andromida/App/Views/Rituals/RitualDetailView.swift +++ b/Andromida/App/Views/Rituals/RitualDetailView.swift @@ -16,6 +16,22 @@ struct RitualDetailView: View { self.ritual = ritual } + private var daysRemaining: Int { + store.daysRemaining(for: ritual) + } + + private var ritualStreak: Int { + store.streakForRitual(ritual) + } + + private var milestones: [Milestone] { + store.milestonesAchieved(for: ritual) + } + + private var habitRates: [(habit: Habit, rate: Double)] { + store.habitCompletionRates(for: ritual) + } + var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: Design.Spacing.large) { @@ -31,8 +47,19 @@ struct RitualDetailView: View { progress: store.ritualProgress(for: ritual) ) - // Status badges + // Status badges with time remaining and streak statusBadges + + // Time remaining and streak info + timeAndStreakSection + + // Milestones + RitualMilestonesView(milestones: milestones) + + // Habit performance + if !habitRates.isEmpty { + HabitPerformanceView(habitRates: habitRates) + } // Notes if !ritual.notes.isEmpty { @@ -194,6 +221,56 @@ struct RitualDetailView: View { Spacer() } } + + // MARK: - Time and Streak Section + + private var timeAndStreakSection: some View { + HStack(spacing: Design.Spacing.medium) { + // Days remaining + if daysRemaining > 0 { + HStack(spacing: Design.Spacing.xSmall) { + Image(systemName: "hourglass") + .font(.caption) + Text(String(localized: "\(daysRemaining) days remaining")) + .font(.caption) + } + .foregroundStyle(AppTextColors.secondary) + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .background(AppSurface.card) + .clipShape(.capsule) + } else { + HStack(spacing: Design.Spacing.xSmall) { + Image(systemName: "checkmark.seal.fill") + .font(.caption) + Text(String(localized: "Arc complete!")) + .font(.caption) + } + .foregroundStyle(AppStatus.success) + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .background(AppStatus.success.opacity(Design.Opacity.subtle)) + .clipShape(.capsule) + } + + // Ritual streak + if ritualStreak > 0 { + HStack(spacing: Design.Spacing.xSmall) { + Image(systemName: "flame.fill") + .font(.caption) + Text(String(localized: "\(ritualStreak)-day streak")) + .font(.caption) + } + .foregroundStyle(AppStatus.success) + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .background(AppStatus.success.opacity(Design.Opacity.subtle)) + .clipShape(.capsule) + } + + Spacer() + } + } } #Preview { diff --git a/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift b/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift index 9db8f01..47e1fb3 100644 --- a/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift +++ b/Andromida/App/Views/Today/Components/TodayRitualSectionView.swift @@ -76,12 +76,12 @@ struct TodayRitualSectionView: View { .sherpaTag(RitualsOnboardingTag.firstHabit) } ForEach(habitRows.dropFirst()) { habit in - TodayHabitRowView( - title: habit.title, - symbolName: habit.symbolName, - isCompleted: habit.isCompleted, - action: habit.action - ) + TodayHabitRowView( + title: habit.title, + symbolName: habit.symbolName, + isCompleted: habit.isCompleted, + action: habit.action + ) } } }