From 00dd8e9f12e262b50900ae060f6c5f1ef41bf9b9 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 26 Jan 2026 17:43:18 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- .../App/Localization/Localizable.xcstrings | 56 +++ Andromida/App/State/RitualStore.swift | 146 ++++++++ .../App/Views/Rituals/RitualDetailView.swift | 155 +++++--- .../Views/Rituals/Sheets/ArcDetailSheet.swift | 340 ++++++++++++++++++ 4 files changed, 641 insertions(+), 56 deletions(-) create mode 100644 Andromida/App/Views/Rituals/Sheets/ArcDetailSheet.swift diff --git a/Andromida/App/Localization/Localizable.xcstrings b/Andromida/App/Localization/Localizable.xcstrings index d666326..6065120 100644 --- a/Andromida/App/Localization/Localizable.xcstrings +++ b/Andromida/App/Localization/Localizable.xcstrings @@ -130,6 +130,30 @@ "comment" : "A text label displaying a percentage. The argument is a value between 0.0 and 1.0.", "isCommentAutoGenerated" : true }, + "%lld%% ahead of Arc %lld at day %lld" : { + "comment" : "A string and two boolean values describing how much further a ritual is in its routine than another ritual. The first string is in the format \"X% ahead of Arc Y at day Z\". The second and third values are booleans indicating whether the ritual is ahead, behind, or on the same day as the other ritual.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld%% ahead of Arc %2$lld at day %3$lld" + } + } + } + }, + "%lld%% behind Arc %lld at day %lld" : { + "comment" : "A string describing how much further a user's ritual is behind another's at a specific day. The first argument is the absolute value of the percentage difference. The second argument is the number of the arc that the user is behind. The third argument is the day index at which the comparison was made.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld%% behind Arc %2$lld at day %3$lld" + } + } + } + }, "%lld%% complete" : { "comment" : "A text label showing the percentage of the ritual that has been completed.", "isCommentAutoGenerated" : true @@ -350,6 +374,10 @@ "comment" : "A label below the celebration header, indicating which arc of a ritual has just completed. The argument is the arc number of the completed arc.", "isCommentAutoGenerated" : true }, + "Arc %lld Details" : { + "comment" : "The title of the navigation bar at the top of the view. The placeholder is replaced with the arc number.", + "isCommentAutoGenerated" : true + }, "Arc complete!" : { "comment" : "A message displayed when a ritual's streak reaches its maximum value.", "isCommentAutoGenerated" : true @@ -369,6 +397,10 @@ "comment" : "Label text for a badge indicating that their habit completion rate is below the average for the week.", "isCommentAutoGenerated" : true }, + "Best Streak" : { + "comment" : "Title for a stat card displaying the longest streak of days in an arc.", + "isCommentAutoGenerated" : true + }, "Body scan for tension" : { "comment" : "Habit title for a mindfulness ritual that involves scanning the body for tension.", "isCommentAutoGenerated" : true @@ -684,6 +716,10 @@ "comment" : "A subtitle for the \"Reminders\" section in the settings view, showing which times are set for reminders.", "isCommentAutoGenerated" : true }, + "Daily Progress" : { + "comment" : "A label displayed above the mini calendar heatmap.", + "isCommentAutoGenerated" : true + }, "Day" : { "comment" : "Label for the x-axis in the mini sparkline chart within an `InsightCardView`.", "isCommentAutoGenerated" : true @@ -1111,6 +1147,10 @@ "comment" : "Motivational message for a completion rate between 0.75 and 1.0.", "isCommentAutoGenerated" : true }, + "Habit Breakdown" : { + "comment" : "A section header describing the breakdown of a user's habits across a completed arc.", + "isCommentAutoGenerated" : true + }, "Habit name" : { "comment" : "A text field label for entering a habit name.", "isCommentAutoGenerated" : true @@ -1678,6 +1718,10 @@ "comment" : "A motivational message displayed when a user achieves 100% completion on a ritual.", "isCommentAutoGenerated" : true }, + "Perfect Days" : { + "comment" : "Title of a stat card in the Arc Detail Sheet that shows the number of perfect days in the arc.", + "isCommentAutoGenerated" : true + }, "Plan the week ahead" : { "comment" : "Habit title for a ritual preset that helps users plan their week ahead.", "isCommentAutoGenerated" : true @@ -2018,6 +2062,18 @@ "comment" : "A message displayed in an alert when deleting a category.", "isCommentAutoGenerated" : true }, + "Same as Arc %lld at day %lld" : { + "comment" : "A message indicating that their progress on a ritual is the same as on a previous arc. The first argument is the number of the arc being compared. The second argument is the day on which the comparison was made.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Same as Arc %1$lld at day %2$lld" + } + } + } + }, "Save" : { "comment" : "The text for a button that saves data.", "isCommentAutoGenerated" : true diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index 316944a..9fa59a1 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -908,6 +908,58 @@ final class RitualStore: RitualStoreProviding { return streak } + /// Compares current arc completion rate to the previous arc at the same day index. + /// Returns nil if there's no previous arc to compare against. + /// - Returns: Tuple of (percentageDelta, previousArcNumber, currentDayIndex) + func arcComparison(for ritual: Ritual) -> (delta: Int, previousArcNumber: Int, dayIndex: Int)? { + guard let currentArc = ritual.currentArc else { return nil } + + // Find the previous arc (most recent completed arc) + let completedArcs = (ritual.arcs ?? []) + .filter { !$0.isActive } + .sorted { $0.arcNumber > $1.arcNumber } + + guard let previousArc = completedArcs.first else { return nil } + + let currentDayIndex = ritualDayIndex(for: ritual) + guard currentDayIndex > 0 else { return nil } + + // Calculate current arc's completion rate up to today + let currentHabits = currentArc.habits ?? [] + guard !currentHabits.isEmpty else { return nil } + + let currentCheckIns = currentHabits.reduce(0) { $0 + $1.completedDayIDs.count } + let currentPossible = currentHabits.count * currentDayIndex + let currentRate = currentPossible > 0 ? Double(currentCheckIns) / Double(currentPossible) : 0 + + // Calculate previous arc's completion rate at the same day index + let previousHabits = previousArc.habits ?? [] + guard !previousHabits.isEmpty else { return nil } + + // Count check-ins from previous arc up to the same day index + let previousDayIDs = generateDayIDs(from: previousArc.startDate, days: currentDayIndex) + let previousCheckIns = previousHabits.reduce(0) { total, habit in + total + habit.completedDayIDs.filter { previousDayIDs.contains($0) }.count + } + let previousPossible = previousHabits.count * currentDayIndex + let previousRate = previousPossible > 0 ? Double(previousCheckIns) / Double(previousPossible) : 0 + + let delta = Int((currentRate - previousRate) * 100) + return (delta, previousArc.arcNumber, currentDayIndex) + } + + /// Generates day ID strings for a range of days starting from a date. + private func generateDayIDs(from startDate: Date, days: Int) -> Set { + var ids: Set = [] + let start = calendar.startOfDay(for: startDate) + for i in 0.. [(habit: ArcHabit, rate: Double)] { let currentDay = ritualDayIndex(for: ritual) @@ -920,6 +972,100 @@ final class RitualStore: RitualStoreProviding { } } + // MARK: - Arc-Specific Analytics + + /// Returns completion rates for each habit in a specific arc. + func habitCompletionRates(for arc: RitualArc) -> [(habit: ArcHabit, rate: Double)] { + let habits = arc.habits ?? [] + let totalDays = arc.durationDays + guard totalDays > 0 else { return habits.map { ($0, 0.0) } } + + return habits.map { habit in + let completedDays = habit.completedDayIDs.count + let rate = Double(completedDays) / Double(totalDays) + return (habit, min(rate, 1.0)) + } + } + + /// Returns the longest streak of consecutive perfect days within a specific arc. + func longestStreak(for arc: RitualArc) -> Int { + let habits = arc.habits ?? [] + guard !habits.isEmpty else { return 0 } + + let arcDayIDs = generateDayIDs(from: arc.startDate, days: arc.durationDays) + + // Find which days had all habits completed + var perfectDayIDs: Set = [] + for dayID in arcDayIDs { + let allCompleted = habits.allSatisfy { $0.completedDayIDs.contains(dayID) } + if allCompleted { + perfectDayIDs.insert(dayID) + } + } + + guard !perfectDayIDs.isEmpty else { return 0 } + + // Convert to dates and sort + let sortedDates = perfectDayIDs.compactMap { dayFormatter.date(from: $0) }.sorted() + guard !sortedDates.isEmpty else { return 0 } + + var longest = 1 + var current = 1 + + for i in 1.. Int { + let habits = arc.habits ?? [] + guard !habits.isEmpty else { return 0 } + + let arcDayIDs = generateDayIDs(from: arc.startDate, days: arc.durationDays) + + var count = 0 + for dayID in arcDayIDs { + let allCompleted = habits.allSatisfy { $0.completedDayIDs.contains(dayID) } + if allCompleted { + count += 1 + } + } + + return count + } + + /// Returns completion rate for each day in a specific arc (for heatmap display). + func dailyCompletionRates(for arc: RitualArc) -> [(date: Date, rate: Double)] { + let habits = arc.habits ?? [] + guard !habits.isEmpty else { return [] } + + var results: [(date: Date, rate: Double)] = [] + let start = calendar.startOfDay(for: arc.startDate) + + for dayOffset in 0.. [Milestone] { let currentDay = ritualDayIndex(for: ritual) diff --git a/Andromida/App/Views/Rituals/RitualDetailView.swift b/Andromida/App/Views/Rituals/RitualDetailView.swift index dddc545..f3440bb 100644 --- a/Andromida/App/Views/Rituals/RitualDetailView.swift +++ b/Andromida/App/Views/Rituals/RitualDetailView.swift @@ -12,6 +12,7 @@ struct RitualDetailView: View { @State private var showingDeleteConfirmation = false @State private var showingEndArcConfirmation = false @State private var showingStartArcConfirmation = false + @State private var selectedArcForDetail: RitualArc? init(store: RitualStore, categoryStore: CategoryStore, ritual: Ritual) { self.store = store @@ -43,6 +44,21 @@ struct RitualDetailView: View { (ritual.arcs ?? []).filter { !$0.isActive }.sorted { $0.startDate > $1.startDate } } + private var arcComparisonInfo: (text: String, isAhead: Bool, isBehind: Bool)? { + guard let comparison = store.arcComparison(for: ritual) else { return nil } + let delta = comparison.delta + let arcNum = comparison.previousArcNumber + let day = comparison.dayIndex + + if delta > 0 { + return (String(localized: "\(delta)% ahead of Arc \(arcNum) at day \(day)"), true, false) + } else if delta < 0 { + return (String(localized: "\(abs(delta))% behind Arc \(arcNum) at day \(day)"), false, true) + } else { + return (String(localized: "Same as Arc \(arcNum) at day \(day)"), false, false) + } + } + var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: Design.Spacing.large) { @@ -125,6 +141,9 @@ struct RitualDetailView: View { .sheet(isPresented: $showingEditSheet) { RitualEditSheet(store: store, categoryStore: categoryStore, ritual: ritual) } + .sheet(item: $selectedArcForDetail) { arc in + ArcDetailSheet(store: store, arc: arc, ritual: ritual) + } .alert(String(localized: "Delete Ritual?"), isPresented: $showingDeleteConfirmation) { Button(String(localized: "Cancel"), role: .cancel) {} Button(String(localized: "Delete"), role: .destructive) { @@ -380,81 +399,105 @@ struct RitualDetailView: View { let possibleCheckIns = habits.count * arc.durationDays let completionRate = possibleCheckIns > 0 ? Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100) : 0 - return HStack { - VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { - Text(String(localized: "Arc \(arc.arcNumber)")) - .font(.subheadline.bold()) - .foregroundStyle(AppTextColors.primary) + return Button { + selectedArcForDetail = arc + } label: { + HStack { + VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { + Text(String(localized: "Arc \(arc.arcNumber)")) + .font(.subheadline.bold()) + .foregroundStyle(AppTextColors.primary) + + Text("\(dateFormatter.string(from: arc.startDate)) – \(dateFormatter.string(from: arc.endDate))") + .font(.caption) + .foregroundStyle(AppTextColors.tertiary) + } - Text("\(dateFormatter.string(from: arc.startDate)) – \(dateFormatter.string(from: arc.endDate))") - .font(.caption) - .foregroundStyle(AppTextColors.tertiary) - } - - Spacer() - - VStack(alignment: .trailing, spacing: Design.Spacing.xSmall) { - Text("\(completionRate)%") - .font(.subheadline.bold()) - .foregroundStyle(completionRate >= 70 ? AppStatus.success : AppTextColors.secondary) - - Text(String(localized: "\(arc.durationDays) days")) + Spacer() + + VStack(alignment: .trailing, spacing: Design.Spacing.xSmall) { + Text("\(completionRate)%") + .font(.subheadline.bold()) + .foregroundStyle(completionRate >= 70 ? AppStatus.success : AppTextColors.secondary) + + Text(String(localized: "\(arc.durationDays) days")) + .font(.caption) + .foregroundStyle(AppTextColors.tertiary) + } + + Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(AppTextColors.tertiary) } + .padding(Design.Spacing.medium) + .background(AppSurface.card) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) } - .padding(Design.Spacing.medium) - .background(AppSurface.card) - .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + .buttonStyle(.plain) } // 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) + VStack(alignment: .leading, spacing: Design.Spacing.small) { + 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) } - .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) + + // 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) } - .foregroundStyle(AppStatus.success) - .padding(.horizontal, Design.Spacing.medium) - .padding(.vertical, Design.Spacing.small) - .background(AppStatus.success.opacity(Design.Opacity.subtle)) - .clipShape(.capsule) + + Spacer() } - // Ritual streak - if ritualStreak > 0 { + // Arc comparison (only shown if there's a previous arc) + if let comparison = arcComparisonInfo { HStack(spacing: Design.Spacing.xSmall) { - Image(systemName: "flame.fill") + Image(systemName: comparison.isAhead ? "arrow.up.right" : + comparison.isBehind ? "arrow.down.right" : "equal") .font(.caption) - Text(String(localized: "\(ritualStreak)-day streak")) + Text(comparison.text) .font(.caption) } - .foregroundStyle(AppStatus.success) - .padding(.horizontal, Design.Spacing.medium) - .padding(.vertical, Design.Spacing.small) - .background(AppStatus.success.opacity(Design.Opacity.subtle)) - .clipShape(.capsule) + .foregroundStyle(comparison.isAhead ? AppStatus.success : + comparison.isBehind ? AppStatus.warning : AppTextColors.secondary) } - - Spacer() } } } diff --git a/Andromida/App/Views/Rituals/Sheets/ArcDetailSheet.swift b/Andromida/App/Views/Rituals/Sheets/ArcDetailSheet.swift new file mode 100644 index 0000000..10bad7b --- /dev/null +++ b/Andromida/App/Views/Rituals/Sheets/ArcDetailSheet.swift @@ -0,0 +1,340 @@ +// +// ArcDetailSheet.swift +// Andromida +// +// A detailed view of a completed arc showing per-habit breakdown, +// streak info, perfect days, and calendar history. +// + +import SwiftUI +import Bedrock + +/// Wrapper struct to make Date identifiable for sheet presentation +private struct ArcIdentifiableDate: Identifiable { + let id = UUID() + let date: Date +} + +/// Sheet displaying detailed analytics for a completed arc. +struct ArcDetailSheet: View { + @Bindable var store: RitualStore + let arc: RitualArc + let ritual: Ritual + @Environment(\.dismiss) private var dismiss + @State private var selectedDateItem: ArcIdentifiableDate? + + private let calendar = Calendar.current + + private var overallCompletionRate: Int { + let habits = arc.habits ?? [] + let totalCheckIns = habits.reduce(0) { $0 + $1.completedDayIDs.count } + let possibleCheckIns = habits.count * arc.durationDays + return possibleCheckIns > 0 ? Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100) : 0 + } + + private var habitRates: [(habit: ArcHabit, rate: Double)] { + store.habitCompletionRates(for: arc) + } + + private var longestStreak: Int { + store.longestStreak(for: arc) + } + + private var perfectDays: Int { + store.perfectDaysCount(for: arc) + } + + /// Returns the months that overlap with this arc's date range + private var monthsInArc: [Date] { + let startMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: arc.startDate)) ?? arc.startDate + let endMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: arc.endDate)) ?? arc.endDate + + var months: [Date] = [] + var current = startMonth + + while current <= endMonth { + months.append(current) + current = calendar.date(byAdding: .month, value: 1, to: current) ?? current + } + + return months + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: Design.Spacing.large) { + // Header + headerSection + + // Stats overview + statsSection + + // Habit breakdown + habitBreakdownSection + + // Calendar history + calendarSection + } + .padding(Design.Spacing.large) + } + .background(AppSurface.primary) + .navigationTitle(String(localized: "Arc \(arc.arcNumber) Details")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(String(localized: "Done")) { + dismiss() + } + } + } + .sheet(item: $selectedDateItem) { item in + HistoryDayDetailSheet( + date: item.date, + completions: arcHabitCompletions(for: item.date), + store: store + ) + } + } + } + + /// Returns habit completions for a specific date within this arc + private func arcHabitCompletions(for date: Date) -> [HabitCompletion] { + let habits = arc.habits ?? [] + let dayFormatter = DateFormatter() + dayFormatter.dateFormat = "yyyy-MM-dd" + let dayID = dayFormatter.string(from: date) + + return habits.map { habit in + HabitCompletion( + habit: habit, + ritualTitle: ritual.title, + isCompleted: habit.completedDayIDs.contains(dayID) + ) + } + } + + /// Returns completion rate for a date within this arc + private func arcCompletionRate(for date: Date) -> Double { + let habits = arc.habits ?? [] + guard !habits.isEmpty else { return 0 } + + // Only return rate if date is within arc range + guard arc.contains(date: date) else { return 0 } + + let dayFormatter = DateFormatter() + dayFormatter.dateFormat = "yyyy-MM-dd" + let dayID = dayFormatter.string(from: date) + + let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count + return Double(completed) / Double(habits.count) + } + + // MARK: - Header Section + + private var headerSection: some View { + VStack(alignment: .leading, spacing: Design.Spacing.small) { + HStack(spacing: Design.Spacing.medium) { + Image(systemName: ritual.iconName) + .font(.title2) + .foregroundStyle(AppAccent.primary) + .frame(width: 44, height: 44) + .background(AppAccent.primary.opacity(0.1)) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + + VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { + Text(ritual.title) + .font(.headline) + .foregroundStyle(AppTextColors.primary) + + Text(dateRangeString) + .font(.subheadline) + .foregroundStyle(AppTextColors.secondary) + } + + Spacer() + } + } + } + + private var dateRangeString: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return "\(formatter.string(from: arc.startDate)) – \(formatter.string(from: arc.endDate))" + } + + // MARK: - Stats Section + + private var statsSection: some View { + VStack(spacing: Design.Spacing.medium) { + HStack(spacing: Design.Spacing.medium) { + statCard( + title: String(localized: "Completion"), + value: "\(overallCompletionRate)%", + icon: "chart.pie.fill", + color: overallCompletionRate >= 70 ? AppStatus.success : AppAccent.primary + ) + + statCard( + title: String(localized: "Duration"), + value: "\(arc.durationDays)", + subtitle: String(localized: "days"), + icon: "calendar", + color: AppAccent.primary + ) + } + + HStack(spacing: Design.Spacing.medium) { + statCard( + title: String(localized: "Best Streak"), + value: "\(longestStreak)", + subtitle: String(localized: "days"), + icon: "flame.fill", + color: longestStreak > 0 ? AppStatus.success : AppTextColors.secondary + ) + + statCard( + title: String(localized: "Perfect Days"), + value: "\(perfectDays)", + subtitle: String(localized: "of \(arc.durationDays)"), + icon: "star.fill", + color: perfectDays > 0 ? AppStatus.success : AppTextColors.secondary + ) + } + } + } + + private func statCard( + title: String, + value: String, + subtitle: String? = nil, + icon: String, + color: Color + ) -> some View { + VStack(alignment: .leading, spacing: Design.Spacing.small) { + HStack(spacing: Design.Spacing.xSmall) { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(color) + Text(title) + .font(.caption) + .foregroundStyle(AppTextColors.secondary) + } + + HStack(alignment: .firstTextBaseline, spacing: Design.Spacing.xSmall) { + Text(value) + .font(.title2.bold()) + .foregroundStyle(AppTextColors.primary) + + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(AppTextColors.tertiary) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Design.Spacing.medium) + .background(AppSurface.card) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + } + + // MARK: - Habit Breakdown Section + + private var habitBreakdownSection: some View { + VStack(alignment: .leading, spacing: Design.Spacing.medium) { + Text(String(localized: "Habit Breakdown")) + .font(.headline) + .foregroundStyle(AppTextColors.primary) + + VStack(spacing: Design.Spacing.small) { + ForEach(habitRates, id: \.habit.id) { item in + habitRow(habit: item.habit, rate: item.rate) + } + } + } + } + + private func habitRow(habit: ArcHabit, rate: Double) -> some View { + HStack(spacing: Design.Spacing.medium) { + Image(systemName: habit.symbolName) + .font(.body) + .foregroundStyle(AppAccent.primary) + .frame(width: 32, height: 32) + .background(AppAccent.primary.opacity(0.1)) + .clipShape(.circle) + + VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { + Text(habit.title) + .font(.subheadline) + .foregroundStyle(AppTextColors.primary) + + // Progress bar + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(AppSurface.tertiary) + .frame(height: 4) + + RoundedRectangle(cornerRadius: 2) + .fill(rateColor(rate)) + .frame(width: geometry.size.width * rate, height: 4) + } + } + .frame(height: 4) + } + + Spacer() + + Text("\(Int(rate * 100))%") + .font(.subheadline.bold()) + .foregroundStyle(rateColor(rate)) + } + .padding(Design.Spacing.medium) + .background(AppSurface.card) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + } + + private func rateColor(_ rate: Double) -> Color { + switch rate { + case 0.7...: return AppStatus.success + case 0.4..<0.7: return AppStatus.warning + default: return AppTextColors.secondary + } + } + + // MARK: - Calendar Section + + private var calendarSection: some View { + VStack(alignment: .leading, spacing: Design.Spacing.medium) { + Text(String(localized: "Daily Progress")) + .font(.headline) + .foregroundStyle(AppTextColors.primary) + + // Show month calendars for the arc's date range + ForEach(monthsInArc, id: \.self) { month in + HistoryMonthView( + month: month, + selectedRitual: ritual, + completionRate: { date, _ in + arcCompletionRate(for: date) + }, + onDayTapped: { date in + // Only allow tapping days within the arc range + if arc.contains(date: date) { + selectedDateItem = ArcIdentifiableDate(date: date) + } + } + ) + } + } + } +} + +#Preview { + ArcDetailSheet( + store: RitualStore.preview, + arc: RitualStore.preview.rituals.first!.latestArc!, + ritual: RitualStore.preview.rituals.first! + ) +}