250 lines
8.4 KiB
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
|
|
)
|
|
}
|