Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-27 22:01:38 -06:00
parent 49f3fb90a9
commit baedb98b2b
10 changed files with 127 additions and 106 deletions

View File

@ -123,7 +123,11 @@ final class RitualStore: RitualStoreProviding {
} }
func toggleHabitCompletion(_ habit: ArcHabit) { 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) let wasCompleted = habit.completedDayIDs.contains(dayID)
if wasCompleted { if wasCompleted {

View File

@ -11,7 +11,7 @@ import Bedrock
/// A sheet showing habit completion details for a specific day. /// A sheet showing habit completion details for a specific day.
struct HistoryDayDetailSheet: View { struct HistoryDayDetailSheet: View {
let date: Date let date: Date
let completions: [HabitCompletion] let ritual: Ritual?
let store: RitualStore let store: RitualStore
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@ -21,6 +21,14 @@ struct HistoryDayDetailSheet: View {
return formatter.string(from: date) 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 { private var completionRate: Double {
guard !completions.isEmpty else { return 0 } guard !completions.isEmpty else { return 0 }
let completed = completions.filter { $0.isCompleted }.count let completed = completions.filter { $0.isCompleted }.count
@ -222,7 +230,7 @@ struct HistoryDayDetailSheet: View {
} }
private func habitRow(_ completion: HabitCompletion) -> some 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) Image(systemName: completion.habit.symbolName)
.foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary) .foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary)
.frame(width: AppMetrics.Size.iconMedium) .frame(width: AppMetrics.Size.iconMedium)
@ -237,13 +245,26 @@ struct HistoryDayDetailSheet: View {
.foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary) .foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary)
} }
.padding(.vertical, Design.Spacing.small) .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 { #Preview {
HistoryDayDetailSheet( HistoryDayDetailSheet(
date: Date(), date: Date(),
completions: [], ritual: nil,
store: RitualStore.preview store: RitualStore.preview
) )
} }

View File

@ -83,11 +83,6 @@ struct HistoryView: View {
var body: some View { var body: some View {
ScrollView(.vertical, showsIndicators: false) { ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: Design.Spacing.large) { VStack(alignment: .leading, spacing: Design.Spacing.large) {
if horizontalSizeClass == .regular {
Text(String(localized: "History")).styled(.heroBold)
.padding(.bottom, Design.Spacing.small)
}
// Ritual filter picker // Ritual filter picker
ritualPicker ritualPicker
@ -160,7 +155,7 @@ struct HistoryView: View {
.sheet(item: $selectedDateItem) { item in .sheet(item: $selectedDateItem) { item in
HistoryDayDetailSheet( HistoryDayDetailSheet(
date: item.date, date: item.date,
completions: store.habitCompletions(for: item.date, ritual: selectedRitual), ritual: selectedRitual,
store: store store: store
) )
} }

View File

@ -30,11 +30,6 @@ struct InsightsView: View {
NavigationStack { NavigationStack {
ScrollView(.vertical, showsIndicators: false) { ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: Design.Spacing.large) { 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 // Grid with drag-and-drop support in edit mode
LazyVGrid(columns: columns, spacing: Design.Spacing.medium) { LazyVGrid(columns: columns, spacing: Design.Spacing.medium) {
ForEach(orderedCards) { card in ForEach(orderedCards) { card in

View File

@ -114,7 +114,7 @@ struct ArcDetailView: View {
.sheet(item: $selectedDateItem) { item in .sheet(item: $selectedDateItem) { item in
HistoryDayDetailSheet( HistoryDayDetailSheet(
date: item.date, date: item.date,
completions: arcHabitCompletions(for: item.date), ritual: ritual,
store: store store: store
) )
} }

View File

@ -37,11 +37,6 @@ struct RitualsView: View {
var body: some View { var body: some View {
ScrollView(.vertical, showsIndicators: false) { ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: Design.Spacing.large) { VStack(alignment: .leading, spacing: Design.Spacing.large) {
if horizontalSizeClass == .regular {
Text(String(localized: "Rituals")).styled(.heroBold)
.padding(.bottom, Design.Spacing.small)
}
// Segmented picker // Segmented picker
Picker(String(localized: "View"), selection: $selectedTab) { Picker(String(localized: "View"), selection: $selectedTab) {
ForEach(RitualsTab.allCases, id: \.self) { tab in ForEach(RitualsTab.allCases, id: \.self) { tab in

View File

@ -39,11 +39,6 @@ struct TodayView: View {
var body: some View { var body: some View {
ScrollView(.vertical, showsIndicators: false) { ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: Design.Spacing.large) { VStack(alignment: .leading, spacing: Design.Spacing.large) {
if horizontalSizeClass == .regular {
TodayHeaderView(dateText: store.todayDisplayString)
.padding(.bottom, Design.Spacing.small)
}
if todayRituals.isEmpty { if todayRituals.isEmpty {
if hasRitualsButNotNow { if hasRitualsButNotNow {
// Has active rituals but none for current time of day // Has active rituals but none for current time of day

View File

@ -26,6 +26,24 @@ struct RitualStoreTests {
#expect(store.isHabitCompletedToday(habit) == true) #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 @MainActor
@Test func arcRenewalCreatesNewArc() throws { @Test func arcRenewalCreatesNewArc() throws {
let store = makeStore() let store = makeStore()

View File

@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
import Bedrock
struct AndromidaWidgetView: View { struct AndromidaWidgetView: View {
var entry: WidgetEntry 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 // MARK: - Branding Colors Helper
extension Color { extension Color {
static let brandingPrimary = Color(red: 0.12, green: 0.09, blue: 0.08) static let brandingPrimary = Color(red: 0.12, green: 0.09, blue: 0.08)

View File

@ -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
}
}
}