Andromida/Andromida/App/Views/Rituals/ArcDetailView.swift

346 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// ArcDetailView.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
}
/// View displaying detailed analytics for a completed arc.
/// Presented via NavigationLink push from RitualDetailView.
struct ArcDetailView: View {
@Bindable var store: RitualStore
let arc: RitualArc
let ritual: Ritual
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var selectedDateItem: ArcIdentifiableDate?
private let calendar = Calendar.current
/// Grid columns for month calendars - 2 columns on regular width when multiple months
private var monthColumns: [GridItem] {
AdaptiveColumns.columns(
compactCount: 1,
regularCount: monthsInArc.count > 1 ? 2 : 1,
spacing: Design.Spacing.large,
horizontalSizeClass: horizontalSizeClass
)
}
/// Grid columns for habit breakdown - 2 columns on regular width when multiple habits
private var habitColumns: [GridItem] {
AdaptiveColumns.columns(
compactCount: 1,
regularCount: habitRates.count > 1 ? 2 : 1,
spacing: Design.Spacing.medium,
horizontalSizeClass: horizontalSizeClass
)
}
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 {
ScrollView {
VStack(alignment: .leading, spacing: Design.Spacing.large) {
// Header
headerSection
// Stats overview
statsSection
// Habit breakdown
habitBreakdownSection
// Calendar history
calendarSection
}
.padding(Design.Spacing.large)
.adaptiveContentWidth()
}
.background(LinearGradient(
colors: [AppSurface.primary, AppSurface.secondary],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.navigationTitle(String(localized: "Arc \(arc.arcNumber) Details"))
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $selectedDateItem) { item in
HistoryDayDetailSheet(
date: item.date,
ritual: ritual,
store: store
)
}
}
/// 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 dayID = RitualAnalytics.dayIdentifier(for: 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)
LazyVGrid(columns: habitColumns, alignment: .leading, spacing: Design.Spacing.small) {
ForEach(habitRates, id: \.habit.id) { item in
habitRow(habit: item.habit, rate: item.rate)
.frame(maxHeight: .infinity, alignment: .top)
}
}
}
}
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 - 2 columns on iPad
LazyVGrid(columns: monthColumns, alignment: .leading, spacing: Design.Spacing.large) {
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)
}
}
)
.frame(maxHeight: .infinity, alignment: .top)
}
}
}
}
}
#Preview {
NavigationStack {
ArcDetailView(
store: RitualStore.preview,
arc: RitualStore.preview.rituals.first!.latestArc!,
ritual: RitualStore.preview.rituals.first!
)
}
}