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

This commit is contained in:
Matt Bruce 2026-01-26 17:43:18 -06:00
parent e514f60ded
commit 00dd8e9f12
4 changed files with 641 additions and 56 deletions

View File

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

View File

@ -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<String> {
var ids: Set<String> = []
let start = calendar.startOfDay(for: startDate)
for i in 0..<days {
if let date = calendar.date(byAdding: .day, value: i, to: start) {
ids.insert(dayIdentifier(for: date))
}
}
return ids
}
/// Returns completion rates for each habit in a ritual's current arc.
func habitCompletionRates(for ritual: Ritual) -> [(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<String> = []
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..<sortedDates.count {
let prev = sortedDates[i - 1]
let curr = sortedDates[i]
if let nextDay = calendar.date(byAdding: .day, value: 1, to: prev),
calendar.isDate(nextDay, inSameDayAs: curr) {
current += 1
longest = max(longest, current)
} else {
current = 1
}
}
return longest
}
/// Returns the count of perfect days (100% completion) within a specific arc.
func perfectDaysCount(for arc: RitualArc) -> 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..<arc.durationDays {
guard let date = calendar.date(byAdding: .day, value: dayOffset, to: start) else { continue }
let dayID = dayIdentifier(for: date)
let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count
let rate = Double(completed) / Double(habits.count)
results.append((date, rate))
}
return results
}
/// Returns milestones for a ritual with achievement status.
func milestonesAchieved(for ritual: Ritual) -> [Milestone] {
let currentDay = ritualDayIndex(for: ritual)

View File

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

View File

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