Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
49f3fb90a9
commit
baedb98b2b
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user