diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index 5adf698..b014e6a 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -123,7 +123,11 @@ final class RitualStore: RitualStoreProviding { } func toggleHabitCompletion(_ habit: ArcHabit) { - let dayID = dayIdentifier(for: Date()) + toggleHabitCompletion(habit, date: Date()) + } + + func toggleHabitCompletion(_ habit: ArcHabit, date: Date = Date()) { + let dayID = dayIdentifier(for: date) let wasCompleted = habit.completedDayIDs.contains(dayID) if wasCompleted { diff --git a/Andromida/App/Views/History/HistoryDayDetailSheet.swift b/Andromida/App/Views/History/HistoryDayDetailSheet.swift index 0d211c0..36cfad6 100644 --- a/Andromida/App/Views/History/HistoryDayDetailSheet.swift +++ b/Andromida/App/Views/History/HistoryDayDetailSheet.swift @@ -11,7 +11,7 @@ import Bedrock /// A sheet showing habit completion details for a specific day. struct HistoryDayDetailSheet: View { let date: Date - let completions: [HabitCompletion] + let ritual: Ritual? let store: RitualStore @Environment(\.dismiss) private var dismiss @@ -20,6 +20,14 @@ struct HistoryDayDetailSheet: View { formatter.dateStyle = .full return formatter.string(from: date) } + + private var isToday: Bool { + Calendar.current.isDateInToday(date) + } + + private var completions: [HabitCompletion] { + store.habitCompletions(for: date, ritual: ritual) + } private var completionRate: Double { guard !completions.isEmpty else { return 0 } @@ -222,7 +230,7 @@ struct HistoryDayDetailSheet: View { } private func habitRow(_ completion: HabitCompletion) -> some View { - HStack(spacing: Design.Spacing.medium) { + let content = HStack(spacing: Design.Spacing.medium) { Image(systemName: completion.habit.symbolName) .foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary) .frame(width: AppMetrics.Size.iconMedium) @@ -237,13 +245,26 @@ struct HistoryDayDetailSheet: View { .foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary) } .padding(.vertical, Design.Spacing.small) + + if isToday { + return AnyView( + Button { + store.toggleHabitCompletion(completion.habit, date: date) + } label: { + content + } + .buttonStyle(.plain) + ) + } else { + return AnyView(content) + } } } #Preview { HistoryDayDetailSheet( date: Date(), - completions: [], + ritual: nil, store: RitualStore.preview ) } diff --git a/Andromida/App/Views/History/HistoryView.swift b/Andromida/App/Views/History/HistoryView.swift index 6a45540..5463ae7 100644 --- a/Andromida/App/Views/History/HistoryView.swift +++ b/Andromida/App/Views/History/HistoryView.swift @@ -83,11 +83,6 @@ struct HistoryView: View { var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: Design.Spacing.large) { - if horizontalSizeClass == .regular { - Text(String(localized: "History")).styled(.heroBold) - .padding(.bottom, Design.Spacing.small) - } - // Ritual filter picker ritualPicker @@ -160,7 +155,7 @@ struct HistoryView: View { .sheet(item: $selectedDateItem) { item in HistoryDayDetailSheet( date: item.date, - completions: store.habitCompletions(for: item.date, ritual: selectedRitual), + ritual: selectedRitual, store: store ) } diff --git a/Andromida/App/Views/Insights/InsightsView.swift b/Andromida/App/Views/Insights/InsightsView.swift index c3acb68..abba7e8 100644 --- a/Andromida/App/Views/Insights/InsightsView.swift +++ b/Andromida/App/Views/Insights/InsightsView.swift @@ -30,11 +30,6 @@ struct InsightsView: View { NavigationStack { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: Design.Spacing.large) { - if horizontalSizeClass == .regular { - Text(String(localized: "Insights")).styled(.heroBold) - .padding(.bottom, Design.Spacing.small) - } - // Grid with drag-and-drop support in edit mode LazyVGrid(columns: columns, spacing: Design.Spacing.medium) { ForEach(orderedCards) { card in diff --git a/Andromida/App/Views/Rituals/ArcDetailView.swift b/Andromida/App/Views/Rituals/ArcDetailView.swift index e300da4..0fb6eca 100644 --- a/Andromida/App/Views/Rituals/ArcDetailView.swift +++ b/Andromida/App/Views/Rituals/ArcDetailView.swift @@ -114,7 +114,7 @@ struct ArcDetailView: View { .sheet(item: $selectedDateItem) { item in HistoryDayDetailSheet( date: item.date, - completions: arcHabitCompletions(for: item.date), + ritual: ritual, store: store ) } diff --git a/Andromida/App/Views/Rituals/RitualsView.swift b/Andromida/App/Views/Rituals/RitualsView.swift index 52fab82..f5ceb23 100644 --- a/Andromida/App/Views/Rituals/RitualsView.swift +++ b/Andromida/App/Views/Rituals/RitualsView.swift @@ -37,11 +37,6 @@ struct RitualsView: View { var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: Design.Spacing.large) { - if horizontalSizeClass == .regular { - Text(String(localized: "Rituals")).styled(.heroBold) - .padding(.bottom, Design.Spacing.small) - } - // Segmented picker Picker(String(localized: "View"), selection: $selectedTab) { ForEach(RitualsTab.allCases, id: \.self) { tab in diff --git a/Andromida/App/Views/Today/TodayView.swift b/Andromida/App/Views/Today/TodayView.swift index 5587730..f810eb3 100644 --- a/Andromida/App/Views/Today/TodayView.swift +++ b/Andromida/App/Views/Today/TodayView.swift @@ -39,11 +39,6 @@ struct TodayView: View { var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: Design.Spacing.large) { - if horizontalSizeClass == .regular { - TodayHeaderView(dateText: store.todayDisplayString) - .padding(.bottom, Design.Spacing.small) - } - if todayRituals.isEmpty { if hasRitualsButNotNow { // Has active rituals but none for current time of day diff --git a/AndromidaTests/RitualStoreTests.swift b/AndromidaTests/RitualStoreTests.swift index a435c0d..601e019 100644 --- a/AndromidaTests/RitualStoreTests.swift +++ b/AndromidaTests/RitualStoreTests.swift @@ -25,6 +25,24 @@ struct RitualStoreTests { store.toggleHabitCompletion(habit) #expect(store.isHabitCompletedToday(habit) == true) } + + @MainActor + @Test func toggleHabitCompletionForSpecificDate() throws { + let store = makeStore() + store.createQuickRitual() + + guard let habit = store.activeRitual?.habits.first else { + throw TestError.missingHabit + } + + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date())! + store.toggleHabitCompletion(habit, date: yesterday) + + let completions = store.habitCompletions(for: yesterday) + let completion = completions.first { $0.habit.id == habit.id } + #expect(completion?.isCompleted == true) + #expect(store.isHabitCompletedToday(habit) == false) + } @MainActor @Test func arcRenewalCreatesNewArc() throws { diff --git a/AndromidaWidget/Views/AndromidaWidgetView.swift b/AndromidaWidget/Views/AndromidaWidgetView.swift index 8489a79..6c3a750 100644 --- a/AndromidaWidget/Views/AndromidaWidgetView.swift +++ b/AndromidaWidget/Views/AndromidaWidgetView.swift @@ -1,5 +1,6 @@ import SwiftUI import WidgetKit +import Bedrock struct AndromidaWidgetView: View { var entry: WidgetEntry @@ -23,6 +24,83 @@ struct AndromidaWidgetView: View { } } +struct LargeWidgetView: View { + let entry: WidgetEntry + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.large) { + HStack { + VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { + Text(String(localized: "Today's Progress")) + .styled(.heading, emphasis: .custom(AppTextColors.primary)) + Text("\(entry.currentStreak) day streak") + .styled(.subheading, emphasis: .custom(AppAccent.primary)) + } + Spacer() + ZStack { + Circle() + .stroke(AppTextColors.primary.opacity(0.1), lineWidth: 6) + Circle() + .trim(from: 0, to: entry.completionRate) + .stroke(AppAccent.primary, style: StrokeStyle(lineWidth: 6, lineCap: .round)) + .rotationEffect(.degrees(-90)) + Text("\(Int(entry.completionRate * 100))%") + .styled(.captionEmphasis, emphasis: .custom(AppTextColors.primary)) + } + .frame(width: 50, height: 50) + } + .padding(.top, Design.Spacing.small) + + Divider() + .background(AppTextColors.primary.opacity(0.2)) + + if entry.nextHabits.isEmpty { + WidgetEmptyStateView( + iconSize: .section, + title: String(localized: "No rituals scheduled for \(entry.currentTimeOfDay.lowercased())."), + subtitle: entry.currentTimeOfDay, + symbolName: entry.currentTimeOfDaySymbol, + timeRange: entry.currentTimeOfDayRange, + nextRitual: entry.nextRitualInfo, + isCompact: false + ) + } else { + Text(String(localized: "Habits")) + .styled(.captionEmphasis, emphasis: .custom(AppTextColors.secondary)) + + VStack(spacing: Design.Spacing.medium) { + ForEach(entry.nextHabits) { habit in + HStack(spacing: Design.Spacing.medium) { + Image(systemName: habit.symbolName) + .foregroundColor(AppAccent.primary) + .font(.system(size: 18)) + .frame(width: 24) + + VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { + Text(habit.title) + .styled(.subheading, emphasis: .custom(AppTextColors.primary)) + Text(habit.ritualTitle) + .styled(.caption, emphasis: .custom(AppTextColors.tertiary)) + } + Spacer() + + Image(systemName: habit.isCompleted ? "checkmark.circle.fill" : "circle") + .foregroundColor(habit.isCompleted ? .green : AppTextColors.primary.opacity(0.2)) + .font(.system(size: 20)) + } + } + } + } + + Spacer() + } + .padding(Design.Spacing.large) + .containerBackground(for: .widget) { + AppSurface.primary + } + } +} + // MARK: - Branding Colors Helper extension Color { static let brandingPrimary = Color(red: 0.12, green: 0.09, blue: 0.08) diff --git a/AndromidaWidget/Views/Components/...swift b/AndromidaWidget/Views/Components/...swift deleted file mode 100644 index fc4db36..0000000 --- a/AndromidaWidget/Views/Components/...swift +++ /dev/null @@ -1,80 +0,0 @@ -import SwiftUI -import WidgetKit -import Bedrock - -struct LargeWidgetView: View { - let entry: WidgetEntry - - var body: some View { - VStack(alignment: .leading, spacing: Design.Spacing.large) { - HStack { - VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { - Text(String(localized: "Today's Progress")) - .styled(.heading, emphasis: .custom(AppTextColors.primary)) - Text("\(entry.currentStreak) day streak") - .styled(.subheading, emphasis: .custom(AppAccent.primary)) - } - Spacer() - ZStack { - Circle() - .stroke(AppTextColors.primary.opacity(0.1), lineWidth: 6) - Circle() - .trim(from: 0, to: entry.completionRate) - .stroke(AppAccent.primary, style: StrokeStyle(lineWidth: 6, lineCap: .round)) - .rotationEffect(.degrees(-90)) - Text("\(Int(entry.completionRate * 100))%") - .styled(.captionEmphasis, emphasis: .custom(AppTextColors.primary)) - } - .frame(width: 50, height: 50) - } - .padding(.top, Design.Spacing.small) - - Divider() - .background(AppTextColors.primary.opacity(0.2)) - - if entry.nextHabits.isEmpty { - WidgetEmptyStateView( - iconSize: .section, - title: String(localized: "No rituals scheduled for \(entry.currentTimeOfDay.lowercased())."), - subtitle: entry.currentTimeOfDay, - symbolName: entry.currentTimeOfDaySymbol, - timeRange: entry.currentTimeOfDayRange, - nextRitual: entry.nextRitualInfo, - isCompact: false - ) - } else { - Text(String(localized: "Habits")) - .styled(.captionEmphasis, emphasis: .custom(AppTextColors.secondary)) - - VStack(spacing: Design.Spacing.medium) { - ForEach(entry.nextHabits) { habit in - HStack(spacing: Design.Spacing.medium) { - Image(systemName: habit.symbolName) - .foregroundColor(AppAccent.primary) - .font(.system(size: 18)) - .frame(width: 24) - - VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { - Text(habit.title) - .styled(.subheading, emphasis: .custom(AppTextColors.primary)) - Text(habit.ritualTitle) - .styled(.caption, emphasis: .custom(AppTextColors.tertiary)) - } - Spacer() - - Image(systemName: habit.isCompleted ? "checkmark.circle.fill" : "circle") - .foregroundColor(habit.isCompleted ? .green : AppTextColors.primary.opacity(0.2)) - .font(.system(size: 20)) - } - } - } - } - - Spacer() - } - .padding(Design.Spacing.large) - .containerBackground(for: .widget) { - AppSurface.primary - } - } -}