Andromida/Andromida/App/Views/History/HistoryDayDetailSheet.swift

250 lines
8.4 KiB
Swift

//
// HistoryDayDetailSheet.swift
// Andromida
//
// Detail sheet showing habit completions for a specific day.
//
import SwiftUI
import Bedrock
/// A sheet showing habit completion details for a specific day.
struct HistoryDayDetailSheet: View {
let date: Date
let completions: [HabitCompletion]
let store: RitualStore
@Environment(\.dismiss) private var dismiss
private var dateTitle: String {
let formatter = DateFormatter()
formatter.dateStyle = .full
return formatter.string(from: date)
}
private var completionRate: Double {
guard !completions.isEmpty else { return 0 }
let completed = completions.filter { $0.isCompleted }.count
return Double(completed) / Double(completions.count)
}
private var completedCount: Int {
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) {
VStack(alignment: .leading, spacing: Design.Spacing.large) {
// Summary header
summaryHeader
// Context badges (comparison, streak)
if !completions.isEmpty {
contextSection
}
// Habit list grouped by ritual
if completions.isEmpty {
emptyState
} else {
habitList
}
Spacer(minLength: Design.Spacing.xxxLarge)
}
.padding(Design.Spacing.large)
}
.scrollContentBackground(.hidden)
.background(AppSurface.primary)
.navigationTitle(shortDateTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "Done")) {
dismiss()
}
.foregroundStyle(AppAccent.primary)
}
}
}
.presentationBackground(AppSurface.primary)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
private var emptyState: some View {
VStack(spacing: Design.Spacing.medium) {
Image(systemName: "calendar.badge.clock")
.font(.largeTitle)
.foregroundStyle(AppTextColors.tertiary)
Text(String(localized: "No habits tracked"))
.font(.headline)
.foregroundStyle(AppTextColors.secondary)
Text(String(localized: "This day has no habit data recorded."))
.font(.subheadline)
.foregroundStyle(AppTextColors.tertiary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, Design.Spacing.xxxLarge)
}
private var shortDateTitle: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
private var summaryHeader: some View {
VStack(spacing: Design.Spacing.medium) {
ProgressRing(progress: completionRate, size: 80, lineWidth: 6, showPercentage: true)
Text("\(completedCount) of \(completions.count)")
.font(.title2)
.bold()
.foregroundStyle(AppTextColors.primary)
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()
return VStack(alignment: .leading, spacing: Design.Spacing.large) {
ForEach(sortedRituals, id: \.self) { ritualTitle in
if let habits = groupedByRitual[ritualTitle] {
ritualSection(title: ritualTitle, habits: habits)
}
}
}
}
private func ritualSection(title: String, habits: [HabitCompletion]) -> some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text(title)
.font(.headline)
.foregroundStyle(AppTextColors.primary)
VStack(spacing: Design.Spacing.xSmall) {
ForEach(habits) { completion in
habitRow(completion)
}
}
}
.padding(Design.Spacing.large)
.background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
private func habitRow(_ completion: HabitCompletion) -> some View {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: completion.habit.symbolName)
.foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary)
.frame(width: AppMetrics.Size.iconMedium)
Text(completion.habit.title)
.font(.subheadline)
.foregroundStyle(AppTextColors.primary)
Spacer()
Image(systemName: completion.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary)
}
.padding(.vertical, Design.Spacing.small)
}
}
#Preview {
HistoryDayDetailSheet(
date: Date(),
completions: [],
store: RitualStore.preview
)
}