342 lines
12 KiB
Swift
342 lines
12 KiB
Swift
//
|
||
// 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
|
||
)
|
||
}
|
||
}
|
||
.presentationSizing(.form)
|
||
}
|
||
|
||
/// 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!
|
||
)
|
||
}
|