376 lines
13 KiB
Swift
376 lines
13 KiB
Swift
import SwiftUI
|
|
import Bedrock
|
|
|
|
struct RitualsView: View {
|
|
@Bindable var store: RitualStore
|
|
@State private var selectedTab: RitualsTab = .current
|
|
@State private var showingPresetLibrary = false
|
|
@State private var showingCreateRitual = false
|
|
@State private var ritualToDelete: Ritual?
|
|
@State private var ritualToRestart: Ritual?
|
|
|
|
enum RitualsTab: String, CaseIterable {
|
|
case current
|
|
case past
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .current: return String(localized: "Current")
|
|
case .past: return String(localized: "Past")
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView(.vertical, showsIndicators: false) {
|
|
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
|
// Segmented picker
|
|
Picker(String(localized: "View"), selection: $selectedTab) {
|
|
ForEach(RitualsTab.allCases, id: \.self) { tab in
|
|
Text(tab.displayName).tag(tab)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding(.horizontal, Design.Spacing.small)
|
|
|
|
switch selectedTab {
|
|
case .current:
|
|
currentRitualsContent
|
|
case .past:
|
|
pastRitualsContent
|
|
}
|
|
}
|
|
.padding(Design.Spacing.large)
|
|
}
|
|
.background(LinearGradient(
|
|
colors: [AppSurface.primary, AppSurface.secondary],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
))
|
|
.onAppear {
|
|
store.refresh()
|
|
}
|
|
.navigationTitle(String(localized: "Rituals"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Menu {
|
|
Button {
|
|
showingCreateRitual = true
|
|
} label: {
|
|
Label(String(localized: "Create New"), systemImage: "plus.circle")
|
|
}
|
|
|
|
Button {
|
|
showingPresetLibrary = true
|
|
} label: {
|
|
Label(String(localized: "Browse Presets"), systemImage: "sparkles.rectangle.stack")
|
|
}
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
.foregroundStyle(AppAccent.primary)
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingPresetLibrary) {
|
|
PresetLibrarySheet(store: store)
|
|
}
|
|
.sheet(isPresented: $showingCreateRitual) {
|
|
RitualEditSheet(store: store, ritual: nil)
|
|
}
|
|
.alert(String(localized: "Delete Ritual?"), isPresented: .init(
|
|
get: { ritualToDelete != nil },
|
|
set: { if !$0 { ritualToDelete = nil } }
|
|
)) {
|
|
Button(String(localized: "Cancel"), role: .cancel) {
|
|
ritualToDelete = nil
|
|
}
|
|
Button(String(localized: "Delete"), role: .destructive) {
|
|
if let ritual = ritualToDelete {
|
|
store.deleteRitual(ritual)
|
|
}
|
|
ritualToDelete = nil
|
|
}
|
|
} message: {
|
|
Text(String(localized: "This will permanently remove this ritual and all its completion history. This cannot be undone."))
|
|
}
|
|
.alert(String(localized: "Start New Arc?"), isPresented: .init(
|
|
get: { ritualToRestart != nil },
|
|
set: { if !$0 { ritualToRestart = nil } }
|
|
)) {
|
|
Button(String(localized: "Cancel"), role: .cancel) {
|
|
ritualToRestart = nil
|
|
}
|
|
Button(String(localized: "Start")) {
|
|
if let ritual = ritualToRestart {
|
|
store.startNewArc(for: ritual)
|
|
}
|
|
ritualToRestart = nil
|
|
}
|
|
} message: {
|
|
Text(String(localized: "This will start a new arc for this ritual with the same habits. You can modify habits after starting."))
|
|
}
|
|
}
|
|
|
|
// MARK: - Current Tab Content
|
|
|
|
@ViewBuilder
|
|
private var currentRitualsContent: some View {
|
|
let groupedRituals = store.currentRitualsGroupedByTime()
|
|
|
|
if groupedRituals.isEmpty {
|
|
currentEmptyState
|
|
} else {
|
|
ForEach(groupedRituals, id: \.timeOfDay) { group in
|
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
|
// Time of day header
|
|
HStack(spacing: Design.Spacing.small) {
|
|
Image(systemName: group.timeOfDay.symbolName)
|
|
.foregroundStyle(AppAccent.primary)
|
|
Text(group.timeOfDay.displayName)
|
|
.font(.subheadline)
|
|
.bold()
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
}
|
|
.padding(.top, Design.Spacing.small)
|
|
|
|
ForEach(group.rituals) { ritual in
|
|
currentRitualRow(for: ritual)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Past Tab Content
|
|
|
|
@ViewBuilder
|
|
private var pastRitualsContent: some View {
|
|
if store.pastRituals.isEmpty {
|
|
pastEmptyState
|
|
} else {
|
|
VStack(spacing: Design.Spacing.medium) {
|
|
ForEach(store.pastRituals) { ritual in
|
|
pastRitualRow(for: ritual)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Empty States
|
|
|
|
private var currentEmptyState: some View {
|
|
VStack(spacing: Design.Spacing.large) {
|
|
Image(systemName: "sparkles")
|
|
.font(.system(size: Design.BaseFontSize.largeTitle * 2))
|
|
.foregroundStyle(AppAccent.primary)
|
|
|
|
Text(String(localized: "No Active Rituals"))
|
|
.font(.headline)
|
|
.foregroundStyle(AppTextColors.primary)
|
|
|
|
Text(String(localized: "Create a custom ritual or browse our preset library to get started."))
|
|
.font(.subheadline)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
.multilineTextAlignment(.center)
|
|
|
|
HStack(spacing: Design.Spacing.medium) {
|
|
Button {
|
|
showingCreateRitual = true
|
|
} label: {
|
|
Label(String(localized: "Create"), systemImage: "plus")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(AppAccent.primary)
|
|
|
|
Button {
|
|
showingPresetLibrary = true
|
|
} label: {
|
|
Label(String(localized: "Presets"), systemImage: "sparkles.rectangle.stack")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
|
|
if !store.pastRituals.isEmpty {
|
|
Text(String(localized: "Or restart a past ritual from the Past tab."))
|
|
.font(.caption)
|
|
.foregroundStyle(AppTextColors.tertiary)
|
|
.padding(.top, Design.Spacing.small)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, Design.Spacing.xxxLarge)
|
|
}
|
|
|
|
private var pastEmptyState: some View {
|
|
VStack(spacing: Design.Spacing.large) {
|
|
Image(systemName: "clock.arrow.circlepath")
|
|
.font(.system(size: Design.BaseFontSize.largeTitle * 2))
|
|
.foregroundStyle(AppTextColors.tertiary)
|
|
|
|
Text(String(localized: "No Past Rituals"))
|
|
.font(.headline)
|
|
.foregroundStyle(AppTextColors.primary)
|
|
|
|
Text(String(localized: "Rituals that have ended will appear here. You can restart them anytime."))
|
|
.font(.subheadline)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, Design.Spacing.xxxLarge)
|
|
}
|
|
|
|
// MARK: - Ritual Rows
|
|
|
|
private func currentRitualRow(for ritual: Ritual) -> some View {
|
|
NavigationLink {
|
|
RitualDetailView(store: store, ritual: ritual)
|
|
} label: {
|
|
RitualCardView(
|
|
title: ritual.title,
|
|
theme: ritual.theme,
|
|
dayLabel: store.ritualDayLabel(for: ritual),
|
|
completionSummary: store.completionSummary(for: ritual),
|
|
iconName: ritual.iconName,
|
|
timeOfDay: ritual.timeOfDay,
|
|
hasActiveArc: true
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contextMenu {
|
|
Button {
|
|
store.endArc(for: ritual)
|
|
} label: {
|
|
Label(String(localized: "End Arc"), systemImage: "stop.circle")
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button(role: .destructive) {
|
|
ritualToDelete = ritual
|
|
} label: {
|
|
Label(String(localized: "Delete"), systemImage: "trash")
|
|
}
|
|
}
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
Button(role: .destructive) {
|
|
ritualToDelete = ritual
|
|
} label: {
|
|
Label(String(localized: "Delete"), systemImage: "trash")
|
|
}
|
|
|
|
Button {
|
|
store.endArc(for: ritual)
|
|
} label: {
|
|
Label(String(localized: "End"), systemImage: "stop.circle")
|
|
}
|
|
.tint(AppAccent.secondary)
|
|
}
|
|
}
|
|
|
|
private func pastRitualRow(for ritual: Ritual) -> some View {
|
|
NavigationLink {
|
|
RitualDetailView(store: store, ritual: ritual)
|
|
} label: {
|
|
pastRitualCardView(for: ritual)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contextMenu {
|
|
Button {
|
|
ritualToRestart = ritual
|
|
} label: {
|
|
Label(String(localized: "Start New Arc"), systemImage: "arrow.clockwise.circle")
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button(role: .destructive) {
|
|
ritualToDelete = ritual
|
|
} label: {
|
|
Label(String(localized: "Delete"), systemImage: "trash")
|
|
}
|
|
}
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
Button(role: .destructive) {
|
|
ritualToDelete = ritual
|
|
} label: {
|
|
Label(String(localized: "Delete"), systemImage: "trash")
|
|
}
|
|
}
|
|
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
|
Button {
|
|
ritualToRestart = ritual
|
|
} label: {
|
|
Label(String(localized: "Restart"), systemImage: "arrow.clockwise.circle")
|
|
}
|
|
.tint(AppStatus.success)
|
|
}
|
|
}
|
|
|
|
private func pastRitualCardView(for ritual: Ritual) -> some View {
|
|
HStack(spacing: Design.Spacing.medium) {
|
|
// Icon
|
|
Image(systemName: ritual.iconName)
|
|
.font(.title2)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
.frame(width: 40, height: 40)
|
|
.background(AppSurface.secondary)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
|
|
|
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
|
Text(ritual.title)
|
|
.font(.headline)
|
|
.foregroundStyle(AppTextColors.primary)
|
|
|
|
Text(ritual.theme)
|
|
.font(.caption)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
|
|
if let lastArc = ritual.latestArc {
|
|
HStack(spacing: Design.Spacing.xSmall) {
|
|
Image(systemName: "calendar")
|
|
Text(formattedEndDate(lastArc.endDate))
|
|
}
|
|
.font(.caption2)
|
|
.foregroundStyle(AppTextColors.tertiary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Arc count badge
|
|
if ritual.completedArcCount > 0 {
|
|
Text("\(ritual.completedArcCount) arc\(ritual.completedArcCount == 1 ? "" : "s")")
|
|
.font(.caption2)
|
|
.foregroundStyle(AppTextColors.secondary)
|
|
.padding(.horizontal, Design.Spacing.small)
|
|
.padding(.vertical, Design.Spacing.xSmall)
|
|
.background(AppSurface.secondary)
|
|
.clipShape(.capsule)
|
|
}
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundStyle(AppTextColors.tertiary)
|
|
}
|
|
.padding(Design.Spacing.medium)
|
|
.background(AppSurface.card)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
|
.opacity(Design.Opacity.heavy)
|
|
}
|
|
|
|
private func formattedEndDate(_ date: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .medium
|
|
formatter.timeStyle = .none
|
|
return String(localized: "Ended \(formatter.string(from: date))")
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
RitualsView(store: RitualStore.preview)
|
|
}
|
|
}
|