507 lines
20 KiB
Swift
507 lines
20 KiB
Swift
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!)
|
||
}
|
||
}
|