Andromida/Andromida/App/Views/Rituals/RitualDetailView.swift

507 lines
20 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.

import SwiftUI
import Bedrock
struct RitualDetailView: View {
@Bindable var store: RitualStore
@Bindable var categoryStore: CategoryStore
@Environment(\.dismiss) private var dismiss
private let ritual: Ritual
@State private var showingEditSheet = false
@State private var showingDeleteConfirmation = false
@State private var showingEndArcConfirmation = false
@State private var showingStartArcConfirmation = false
init(store: RitualStore, categoryStore: CategoryStore, ritual: Ritual) {
self.store = store
self.categoryStore = categoryStore
self.ritual = ritual
}
private var daysRemaining: Int {
store.daysRemaining(for: ritual)
}
private var ritualStreak: Int {
store.streakForRitual(ritual)
}
private var milestones: [Milestone] {
store.milestonesAchieved(for: ritual)
}
private var habitRates: [(habit: ArcHabit, rate: Double)] {
store.habitCompletionRates(for: ritual)
}
private var hasMultipleArcs: Bool {
(ritual.arcs ?? []).count > 1
}
private var completedArcs: [RitualArc] {
(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) {
// Header with icon
headerSection
if ritual.hasActiveArc {
// Active arc content
activeArcContent
} else {
// Past ritual - show summary and restart option
pastRitualContent
}
// Arc history (if multiple arcs exist)
if hasMultipleArcs || !ritual.hasActiveArc {
arcHistorySection
}
// Notes
if !ritual.notes.isEmpty {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
SectionHeaderView(title: String(localized: "Notes"))
Text(ritual.notes)
.font(.body)
.foregroundStyle(AppTextColors.secondary)
}
}
// Habits (only show for active rituals)
if ritual.hasActiveArc {
habitsSection
}
}
.padding(Design.Spacing.large)
.adaptiveContentWidth()
}
.background(LinearGradient(
colors: [AppSurface.primary, AppSurface.secondary],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.navigationTitle(String(localized: "Ritual"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
showingEditSheet = true
} label: {
Label(String(localized: "Edit"), systemImage: "pencil")
}
if ritual.hasActiveArc {
Button {
showingEndArcConfirmation = true
} label: {
Label(String(localized: "End Arc"), systemImage: "stop.circle")
}
} else {
Button {
showingStartArcConfirmation = true
} label: {
Label(String(localized: "Start New Arc"), systemImage: "arrow.clockwise.circle")
}
}
Divider()
Button(role: .destructive) {
showingDeleteConfirmation = true
} label: {
Label(String(localized: "Delete"), systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
.foregroundStyle(AppAccent.primary)
}
}
}
.sheet(isPresented: $showingEditSheet) {
RitualEditSheet(store: store, categoryStore: categoryStore, ritual: ritual)
}
.alert(String(localized: "Delete Ritual?"), isPresented: $showingDeleteConfirmation) {
Button(String(localized: "Cancel"), role: .cancel) {}
Button(String(localized: "Delete"), role: .destructive) {
store.deleteRitual(ritual)
dismiss()
}
} message: {
Text(String(localized: "This will permanently remove \"\(ritual.title)\" and all its completion history. This cannot be undone."))
}
.alert(String(localized: "End Arc?"), isPresented: $showingEndArcConfirmation) {
Button(String(localized: "Cancel"), role: .cancel) {}
Button(String(localized: "End Arc")) {
store.endArc(for: ritual)
}
} message: {
Text(String(localized: "This will end the current arc. You can start a new arc anytime from the Past tab."))
}
.alert(String(localized: "Start New Arc?"), isPresented: $showingStartArcConfirmation) {
Button(String(localized: "Cancel"), role: .cancel) {}
Button(String(localized: "Start")) {
store.startNewArc(for: ritual)
}
} message: {
Text(String(localized: "This will start a new arc for this ritual with the same habits. You can modify habits after starting."))
}
}
// MARK: - Header Section
private var headerSection: some View {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: ritual.iconName)
.font(.system(size: Design.BaseFontSize.largeTitle))
.foregroundStyle(ritual.hasActiveArc ? AppAccent.primary : AppTextColors.secondary)
.frame(width: 56, height: 56)
.background((ritual.hasActiveArc ? AppAccent.primary : AppTextColors.secondary).opacity(0.1))
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text(ritual.title)
.font(.title2)
.foregroundStyle(AppTextColors.primary)
.bold()
Text(ritual.theme)
.font(.subheadline)
.foregroundStyle(AppTextColors.secondary)
}
Spacer()
}
.accessibilityElement(children: .combine)
}
// MARK: - Active Arc Content
private var activeArcContent: some View {
VStack(alignment: .leading, spacing: Design.Spacing.large) {
// Progress stats
RitualProgressStatsView(
currentDay: store.ritualDayIndex(for: ritual),
totalDays: ritual.durationDays,
habitsCompleted: ritual.habits.filter { store.isHabitCompletedToday($0) }.count,
habitsTotal: ritual.habits.count,
daysRemaining: store.daysRemaining(for: ritual),
progress: store.ritualProgress(for: ritual)
)
// Status badges
statusBadges
// Time remaining and streak info
timeAndStreakSection
// Milestones
RitualMilestonesView(milestones: milestones)
// Habit performance
if !habitRates.isEmpty {
HabitPerformanceView(habitRates: habitRates)
}
}
}
// MARK: - Past Ritual Content
private var pastRitualContent: some View {
VStack(alignment: .leading, spacing: Design.Spacing.large) {
// Status badges
statusBadges
// Summary card
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
HStack {
Image(systemName: "clock.arrow.circlepath")
.foregroundStyle(AppTextColors.secondary)
Text(String(localized: "This ritual is not currently active"))
.font(.subheadline)
.foregroundStyle(AppTextColors.secondary)
}
if let lastArc = ritual.latestArc {
let habits = lastArc.habits ?? []
let totalCheckIns = habits.reduce(0) { $0 + $1.completedDayIDs.count }
let possibleCheckIns = habits.count * lastArc.durationDays
let completionRate = possibleCheckIns > 0 ? Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100) : 0
Text(String(localized: "Last arc completed with \(completionRate)% habit completion over \(lastArc.durationDays) days."))
.font(.caption)
.foregroundStyle(AppTextColors.tertiary)
}
Button {
showingStartArcConfirmation = true
} label: {
Label(String(localized: "Start New Arc"), systemImage: "arrow.clockwise.circle")
}
.buttonStyle(.borderedProminent)
.tint(AppAccent.primary)
}
.padding(Design.Spacing.large)
.background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
}
// MARK: - Habits Section
private var habitsSection: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
SectionHeaderView(
title: String(localized: "Habits"),
subtitle: String(localized: "Tap to check in")
)
VStack(spacing: Design.Spacing.medium) {
ForEach(store.habits(for: ritual)) { habit in
TodayHabitRowView(
title: habit.title,
symbolName: habit.symbolName,
isCompleted: store.isHabitCompletedToday(habit),
action: { store.toggleHabitCompletion(habit) }
)
}
}
}
}
// MARK: - Status Badges
private var statusBadges: some View {
HStack(spacing: Design.Spacing.medium) {
// Current arc indicator
if let arc = ritual.currentArc {
Text(String(localized: "Arc \(arc.arcNumber)"))
.font(.caption.bold())
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xSmall)
.background(AppAccent.primary.opacity(0.2))
.clipShape(.capsule)
.foregroundStyle(AppAccent.primary)
} else {
Text(String(localized: "No active arc"))
.font(.caption)
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xSmall)
.background(AppTextColors.tertiary.opacity(0.2))
.clipShape(.capsule)
.foregroundStyle(AppTextColors.tertiary)
}
// Time of day badge
timeOfDayBadge
// Category badge (if set)
if !ritual.category.isEmpty {
let badgeColor = categoryStore.color(for: ritual.category)
Text(ritual.category)
.font(.caption)
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xSmall)
.background(badgeColor.opacity(0.15))
.clipShape(.capsule)
.foregroundStyle(badgeColor)
}
Spacer()
}
}
private var timeOfDayBadge: some View {
HStack(spacing: 2) {
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: ritual.timeOfDay.symbolName)
Text(ritual.timeOfDay.displayName)
}
Text(" : ")
Text(ritual.timeOfDay.timeRange)
}
.font(.caption2)
.foregroundStyle(timeOfDayColor)
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xSmall)
.background(timeOfDayColor.opacity(0.15))
.clipShape(.capsule)
}
private var timeOfDayColor: Color {
switch ritual.timeOfDay {
case .morning:
return Color.orange
case .midday:
return Color.yellow
case .afternoon:
return Color.orange.opacity(0.8)
case .evening:
return Color.purple
case .night:
return Color.indigo
case .anytime:
return AppTextColors.secondary
}
}
// MARK: - Arc History Section
private var arcHistorySection: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
SectionHeaderView(
title: String(localized: "Arc History"),
subtitle: (ritual.arcs ?? []).isEmpty ? nil : String(localized: "\((ritual.arcs ?? []).count) total")
)
if completedArcs.isEmpty {
Text(String(localized: "No completed arcs yet."))
.font(.caption)
.foregroundStyle(AppTextColors.tertiary)
} else {
VStack(spacing: Design.Spacing.small) {
ForEach(completedArcs) { arc in
arcHistoryRow(arc)
}
}
}
}
}
private func arcHistoryRow(_ arc: RitualArc) -> some View {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
let habits = arc.habits ?? []
let totalCheckIns = habits.reduce(0) { $0 + $1.completedDayIDs.count }
let possibleCheckIns = habits.count * arc.durationDays
let completionRate = possibleCheckIns > 0 ? Int(Double(totalCheckIns) / Double(possibleCheckIns) * 100) : 0
return NavigationLink {
ArcDetailView(store: store, arc: arc, ritual: ritual)
} 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)
}
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))
}
.buttonStyle(.plain)
}
// MARK: - Time and Streak Section
private var timeAndStreakSection: some View {
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)
}
// 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)
}
Spacer()
}
// Arc comparison (only shown if there's a previous arc)
if let comparison = arcComparisonInfo {
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: comparison.isAhead ? "arrow.up.right" :
comparison.isBehind ? "arrow.down.right" : "equal")
.font(.caption)
Text(comparison.text)
.font(.caption)
}
.foregroundStyle(comparison.isAhead ? AppStatus.success :
comparison.isBehind ? AppStatus.warning : AppTextColors.secondary)
}
}
}
}
#Preview {
NavigationStack {
RitualDetailView(store: RitualStore.preview, categoryStore: CategoryStore.preview, ritual: RitualStore.preview.rituals.first!)
}
}