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

This commit is contained in:
Matt Bruce 2026-01-27 19:50:04 -06:00
parent 9c5fe23488
commit 6f146fbeff
10 changed files with 146 additions and 105 deletions

View File

@ -79,6 +79,7 @@
Assets.xcassets, Assets.xcassets,
Shared/Configuration/AppIdentifiers.swift, Shared/Configuration/AppIdentifiers.swift,
Shared/Services/RitualAnalytics.swift, Shared/Services/RitualAnalytics.swift,
Shared/Theme/RitualsTheme.swift,
); );
target = EAC04D2E2F298D9B007F87EA /* AndromidaWidgetExtension */; target = EAC04D2E2F298D9B007F87EA /* AndromidaWidgetExtension */;
}; };

View File

@ -171,12 +171,7 @@ final class RitualStore: RitualStoreProviding {
/// Filters based on time of day: morning (before 11am), midday (11am-2pm), afternoon (2pm-5pm), /// Filters based on time of day: morning (before 11am), midday (11am-2pm), afternoon (2pm-5pm),
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown. /// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
func ritualsForToday() -> [Ritual] { func ritualsForToday() -> [Ritual] {
let currentPeriod = TimeOfDay.current() RitualAnalytics.ritualsActive(on: Date(), from: currentRituals)
return currentRituals.filter { ritual in
guard let arc = ritual.currentArc, arc.contains(date: Date()) else { return false }
return ritual.timeOfDay == .anytime || ritual.timeOfDay == currentPeriod
}
} }
/// Groups current rituals by time of day for display /// Groups current rituals by time of day for display
@ -295,25 +290,7 @@ final class RitualStore: RitualStoreProviding {
/// Calculates the current streak (consecutive perfect days ending today or yesterday) /// Calculates the current streak (consecutive perfect days ending today or yesterday)
func currentStreak() -> Int { func currentStreak() -> Int {
let perfect = perfectDays() RitualAnalytics.calculateCurrentStreak(rituals: rituals, calendar: calendar)
guard !perfect.isEmpty else { return 0 }
var streak = 0
var checkDate = calendar.startOfDay(for: Date())
// Check if today is a perfect day
if perfect.contains(dayIdentifier(for: checkDate)) {
streak = 1
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
}
// Count consecutive days backwards
while perfect.contains(dayIdentifier(for: checkDate)) {
streak += 1
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
}
return streak
} }
/// Calculates the longest streak of consecutive perfect days /// Calculates the longest streak of consecutive perfect days
@ -917,25 +894,16 @@ final class RitualStore: RitualStoreProviding {
/// Returns the completion rate for a specific date, optionally filtered by ritual. /// Returns the completion rate for a specific date, optionally filtered by ritual.
/// This correctly queries habits from arcs that were active on that date. /// This correctly queries habits from arcs that were active on that date.
func completionRate(for date: Date, ritual: Ritual? = nil) -> Double { func completionRate(for date: Date, ritual: Ritual? = nil) -> Double {
let dayID = dayIdentifier(for: date)
let habits: [ArcHabit]
if let ritual = ritual { if let ritual = ritual {
// Get habits from the arc that was active on this date let dayID = dayIdentifier(for: date)
if let arc = ritual.arc(for: date) { guard let arc = ritual.arc(for: date) else { return 0 }
habits = arc.habits ?? [] let habits = arc.habits ?? []
} else {
return 0
}
} else {
// Get all habits from all arcs that were active on this date
habits = habitsActive(on: date)
}
guard !habits.isEmpty else { return 0 } guard !habits.isEmpty else { return 0 }
let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count
return Double(completed) / Double(habits.count) return Double(completed) / Double(habits.count)
} else {
return RitualAnalytics.overallCompletionRate(on: date, from: rituals)
}
} }
/// Returns all dates that have any habit activity. /// Returns all dates that have any habit activity.

View File

@ -10,7 +10,7 @@ struct TodayNoRitualsForTimeView: View {
} }
private var nextRituals: [Ritual] { private var nextRituals: [Ritual] {
// Find rituals scheduled for later time periods // Find rituals scheduled for later time periods TODAY
store.currentRituals.filter { ritual in store.currentRituals.filter { ritual in
guard let arc = ritual.currentArc, arc.contains(date: Date()) else { return false } guard let arc = ritual.currentArc, arc.contains(date: Date()) else { return false }
return ritual.timeOfDay != .anytime && ritual.timeOfDay > currentTimePeriod return ritual.timeOfDay != .anytime && ritual.timeOfDay > currentTimePeriod
@ -18,18 +18,7 @@ struct TodayNoRitualsForTimeView: View {
} }
private var nextRitualTomorrow: Ritual? { private var nextRitualTomorrow: Ritual? {
let calendar = Calendar.current RitualAnalytics.nextUpcomingRitual(from: store.currentRituals)
let tomorrowDate = calendar.startOfDay(
for: calendar.date(byAdding: .day, value: 1, to: Date()) ?? Date()
)
return store.currentRituals
.filter { ritual in
guard let arc = ritual.currentArc, arc.contains(date: tomorrowDate) else { return false }
return ritual.timeOfDay != .anytime
}
.sorted { $0.timeOfDay < $1.timeOfDay }
.first
} }
var body: some View { var body: some View {

View File

@ -80,4 +80,28 @@ enum RitualAnalytics {
let completedCount = allTodayHabits.filter { $0.completedDayIDs.contains(dayID) }.count let completedCount = allTodayHabits.filter { $0.completedDayIDs.contains(dayID) }.count
return Double(completedCount) / Double(allTodayHabits.count) return Double(completedCount) / Double(allTodayHabits.count)
} }
/// Finds the next upcoming ritual (either later today or tomorrow).
static func nextUpcomingRitual(from rituals: [Ritual], currentDate: Date = Date(), calendar: Calendar = .current) -> Ritual? {
let currentTimePeriod = TimeOfDay.current(for: currentDate)
// 1. Try to find a ritual later TODAY
let laterToday = rituals.filter { ritual in
guard let _ = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: currentDate) }) else { return false }
return ritual.timeOfDay != .anytime && ritual.timeOfDay > currentTimePeriod
}
.sorted { $0.timeOfDay < $1.timeOfDay }
.first
if let laterToday { return laterToday }
// 2. Try to find a ritual TOMORROW
let tomorrowDate = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate)
return rituals.filter { ritual in
guard let _ = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: tomorrowDate) }) else { return false }
return ritual.timeOfDay != .anytime
}
.sorted { $0.timeOfDay < $1.timeOfDay }
.first
}
} }

View File

@ -11,4 +11,5 @@ struct WidgetEntry: TimelineEntry {
let currentTimeOfDay: String let currentTimeOfDay: String
let currentTimeOfDaySymbol: String let currentTimeOfDaySymbol: String
let currentTimeOfDayRange: String let currentTimeOfDayRange: String
let nextRitualInfo: String?
} }

View File

@ -17,7 +17,8 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
weeklyTrend: [0.5, 0.7, 0.6, 0.9, 0.8, 0.75, 0.0], weeklyTrend: [0.5, 0.7, 0.6, 0.9, 0.8, 0.75, 0.0],
currentTimeOfDay: "Morning", currentTimeOfDay: "Morning",
currentTimeOfDaySymbol: "sunrise.fill", currentTimeOfDaySymbol: "sunrise.fill",
currentTimeOfDayRange: "Before 11am" currentTimeOfDayRange: "Before 11am",
nextRitualInfo: "Next: Drink Water (Midday)"
) )
} }
@ -85,6 +86,27 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
// Streak calculation // Streak calculation
let streak = RitualAnalytics.calculateCurrentStreak(rituals: rituals) let streak = RitualAnalytics.calculateCurrentStreak(rituals: rituals)
// Next ritual info
var nextRitualString: String? = nil
if let nextRitual = RitualAnalytics.nextUpcomingRitual(from: rituals, currentDate: today) {
let isTomorrow = !Calendar.current.isDate(nextRitual.startDate, inSameDayAs: today)
if isTomorrow {
let format = String(localized: "Next ritual: Tomorrow %@ (%@)")
nextRitualString = String.localizedStringWithFormat(
format,
nextRitual.timeOfDay.displayName,
nextRitual.timeOfDay.timeRange
)
} else {
let format = String(localized: "Next ritual: %@ (%@)")
nextRitualString = String.localizedStringWithFormat(
format,
nextRitual.timeOfDay.displayName,
nextRitual.timeOfDay.timeRange
)
}
}
return WidgetEntry( return WidgetEntry(
date: today, date: today,
configuration: configuration, configuration: configuration,
@ -94,7 +116,8 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
weeklyTrend: [], weeklyTrend: [],
currentTimeOfDay: timeOfDay.displayName, currentTimeOfDay: timeOfDay.displayName,
currentTimeOfDaySymbol: timeOfDay.symbolName, currentTimeOfDaySymbol: timeOfDay.symbolName,
currentTimeOfDayRange: timeOfDay.timeRange currentTimeOfDayRange: timeOfDay.timeRange,
nextRitualInfo: nextRitualString
) )
} catch { } catch {
// Return a default entry instead of placeholder(in: .preview) // Return a default entry instead of placeholder(in: .preview)
@ -107,7 +130,8 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
weeklyTrend: [], weeklyTrend: [],
currentTimeOfDay: "Today", currentTimeOfDay: "Today",
currentTimeOfDaySymbol: "clock.fill", currentTimeOfDaySymbol: "clock.fill",
currentTimeOfDayRange: "" currentTimeOfDayRange: "",
nextRitualInfo: nil
) )
} }
} }

View File

@ -10,58 +10,62 @@ struct LargeWidgetView: View {
HStack { HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text(String(localized: "Today's Progress")) Text(String(localized: "Today's Progress"))
.styled(.heading, emphasis: .custom(.white)) .styled(.heading, emphasis: .custom(AppTextColors.primary))
Text("\(entry.currentStreak) day streak") Text("\(entry.currentStreak) day streak")
.styled(.subheading, emphasis: .custom(Color.brandingAccent)) .styled(.subheading, emphasis: .custom(AppAccent.primary))
} }
Spacer() Spacer()
ZStack { ZStack {
Circle() Circle()
.stroke(Color.white.opacity(0.1), lineWidth: 6) .stroke(AppTextColors.primary.opacity(0.1), lineWidth: 6)
Circle() Circle()
.trim(from: 0, to: entry.completionRate) .trim(from: 0, to: entry.completionRate)
.stroke(Color.brandingAccent, style: StrokeStyle(lineWidth: 6, lineCap: .round)) .stroke(AppAccent.primary, style: StrokeStyle(lineWidth: 6, lineCap: .round))
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
Text("\(Int(entry.completionRate * 100))%") Text("\(Int(entry.completionRate * 100))%")
.styled(.captionEmphasis, emphasis: .custom(.white)) .styled(.captionEmphasis, emphasis: .custom(AppTextColors.primary))
} }
.frame(width: 50, height: 50) .frame(width: 50, height: 50)
} }
.padding(.top, Design.Spacing.small)
Divider() Divider()
.background(Color.white.opacity(0.2)) .background(AppTextColors.primary.opacity(0.2))
if entry.nextHabits.isEmpty { if entry.nextHabits.isEmpty {
Spacer() Spacer()
WidgetEmptyStateView( WidgetEmptyStateView(
iconSize: .section,
title: String(localized: "No rituals scheduled for \(entry.currentTimeOfDay.lowercased())."), title: String(localized: "No rituals scheduled for \(entry.currentTimeOfDay.lowercased())."),
subtitle: entry.currentTimeOfDay, subtitle: entry.currentTimeOfDay,
symbolName: entry.currentTimeOfDaySymbol, symbolName: entry.currentTimeOfDaySymbol,
timeRange: entry.currentTimeOfDayRange timeRange: entry.currentTimeOfDayRange,
nextRitual: entry.nextRitualInfo,
isCompact: false
) )
Spacer() Spacer()
} else { } else {
Text(String(localized: "Habits")) Text(String(localized: "Habits"))
.styled(.captionEmphasis, emphasis: .custom(.white.opacity(0.7))) .styled(.captionEmphasis, emphasis: .custom(AppTextColors.secondary))
VStack(spacing: Design.Spacing.medium) { VStack(spacing: Design.Spacing.medium) {
ForEach(entry.nextHabits) { habit in ForEach(entry.nextHabits) { habit in
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
Image(systemName: habit.symbolName) Image(systemName: habit.symbolName)
.foregroundColor(Color.brandingAccent) .foregroundColor(AppAccent.primary)
.font(.system(size: 18)) .font(.system(size: 18))
.frame(width: 24) .frame(width: 24)
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text(habit.title) Text(habit.title)
.styled(.subheading, emphasis: .custom(.white)) .styled(.subheading, emphasis: .custom(AppTextColors.primary))
Text(habit.ritualTitle) Text(habit.ritualTitle)
.styled(.caption, emphasis: .custom(.white.opacity(0.5))) .styled(.caption, emphasis: .custom(AppTextColors.tertiary))
} }
Spacer() Spacer()
Image(systemName: habit.isCompleted ? "checkmark.circle.fill" : "circle") Image(systemName: habit.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(habit.isCompleted ? .green : .white.opacity(0.2)) .foregroundColor(habit.isCompleted ? .green : AppTextColors.primary.opacity(0.2))
.font(.system(size: 20)) .font(.system(size: 20))
} }
} }
@ -72,7 +76,7 @@ struct LargeWidgetView: View {
} }
.padding(Design.Spacing.large) .padding(Design.Spacing.large)
.containerBackground(for: .widget) { .containerBackground(for: .widget) {
Color.brandingPrimary AppSurface.primary
} }
} }
} }

View File

@ -11,27 +11,27 @@ struct MediumWidgetView: View {
VStack(spacing: Design.Spacing.medium) { VStack(spacing: Design.Spacing.medium) {
ZStack { ZStack {
Circle() Circle()
.stroke(Color.white.opacity(0.1), lineWidth: 8) .stroke(AppTextColors.primary.opacity(0.1), lineWidth: 8)
Circle() Circle()
.trim(from: 0, to: entry.completionRate) .trim(from: 0, to: entry.completionRate)
.stroke(Color.brandingAccent, style: StrokeStyle(lineWidth: 8, lineCap: .round)) .stroke(AppAccent.primary, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
VStack(spacing: 0) { VStack(spacing: 0) {
Text("\(Int(entry.completionRate * 100))%") Text("\(Int(entry.completionRate * 100))%")
.styled(.heading, emphasis: .custom(.white)) .styled(.heading, emphasis: .custom(AppTextColors.primary))
Text(String(localized: "Today")) Text(String(localized: "Today"))
.styled(.caption, emphasis: .custom(.white.opacity(0.7))) .styled(.caption, emphasis: .custom(AppTextColors.secondary))
} }
} }
.frame(width: 72, height: 72) .frame(width: 72, height: 72)
HStack(spacing: Design.Spacing.xSmall) { HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "flame.fill") Image(systemName: "flame.fill")
.foregroundColor(Color.brandingAccent) .foregroundColor(AppAccent.primary)
.font(.system(size: 14)) .font(.system(size: 14))
Text("\(entry.currentStreak)d") Text("\(entry.currentStreak)d")
.styled(.captionEmphasis, emphasis: .custom(.white)) .styled(.captionEmphasis, emphasis: .custom(AppTextColors.primary))
} }
} }
.frame(width: 110) .frame(width: 110)
@ -40,25 +40,28 @@ struct MediumWidgetView: View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) { VStack(alignment: .leading, spacing: Design.Spacing.medium) {
if entry.nextHabits.isEmpty { if entry.nextHabits.isEmpty {
WidgetEmptyStateView( WidgetEmptyStateView(
iconSize: .card,
title: String(localized: "No rituals now"), title: String(localized: "No rituals now"),
subtitle: entry.currentTimeOfDay, subtitle: entry.currentTimeOfDay,
symbolName: entry.currentTimeOfDaySymbol, symbolName: entry.currentTimeOfDaySymbol,
timeRange: entry.currentTimeOfDayRange timeRange: entry.currentTimeOfDayRange,
nextRitual: entry.nextRitualInfo,
isCompact: true
) )
} else { } else {
Text(String(localized: "Next Habits")) Text(String(localized: "Next Habits"))
.styled(.captionEmphasis, emphasis: .custom(.white.opacity(0.7))) .styled(.captionEmphasis, emphasis: .custom(AppTextColors.secondary))
VStack(alignment: .leading, spacing: Design.Spacing.small) { VStack(alignment: .leading, spacing: Design.Spacing.small) {
ForEach(entry.nextHabits.prefix(3)) { habit in ForEach(entry.nextHabits.prefix(3)) { habit in
HStack(spacing: Design.Spacing.small) { HStack(spacing: Design.Spacing.small) {
Image(systemName: habit.isCompleted ? "checkmark.circle.fill" : habit.symbolName) Image(systemName: habit.isCompleted ? "checkmark.circle.fill" : habit.symbolName)
.foregroundColor(habit.isCompleted ? .green : Color.brandingAccent) .foregroundColor(habit.isCompleted ? .green : AppAccent.primary)
.font(.system(size: 14)) .font(.system(size: 14))
.frame(width: 20) .frame(width: 20)
Text(habit.title) Text(habit.title)
.styled(.subheading, emphasis: .custom(.white)) .styled(.subheading, emphasis: .custom(AppTextColors.primary))
.lineLimit(1) .lineLimit(1)
} }
} }
@ -73,7 +76,7 @@ struct MediumWidgetView: View {
Spacer() Spacer()
} }
.containerBackground(for: .widget) { .containerBackground(for: .widget) {
Color.brandingPrimary AppSurface.primary
} }
} }
} }

View File

@ -9,30 +9,30 @@ struct SmallWidgetView: View {
VStack(spacing: Design.Spacing.small) { VStack(spacing: Design.Spacing.small) {
ZStack { ZStack {
Circle() Circle()
.stroke(Color.white.opacity(0.1), lineWidth: 8) .stroke(AppTextColors.primary.opacity(0.1), lineWidth: 8)
Circle() Circle()
.trim(from: 0, to: entry.completionRate) .trim(from: 0, to: entry.completionRate)
.stroke(Color.brandingAccent, style: StrokeStyle(lineWidth: 8, lineCap: .round)) .stroke(AppAccent.primary, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
VStack(spacing: 0) { VStack(spacing: 0) {
Text("\(Int(entry.completionRate * 100))%") Text("\(Int(entry.completionRate * 100))%")
.styled(.heading, emphasis: .custom(.white)) .styled(.heading, emphasis: .custom(AppTextColors.primary))
Text(String(localized: "Today")) Text(String(localized: "Today"))
.styled(.caption, emphasis: .custom(.white.opacity(0.7))) .styled(.caption, emphasis: .custom(AppTextColors.secondary))
} }
} }
.frame(width: 80, height: 80) .frame(width: 80, height: 80)
HStack(spacing: Design.Spacing.xSmall) { HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "flame.fill") Image(systemName: "flame.fill")
.foregroundColor(Color.brandingAccent) .foregroundColor(AppAccent.primary)
Text("\(entry.currentStreak) day streak") Text("\(entry.currentStreak) day streak")
.styled(.captionEmphasis, emphasis: .custom(.white)) .styled(.captionEmphasis, emphasis: .custom(AppTextColors.primary))
} }
} }
.containerBackground(for: .widget) { .containerBackground(for: .widget) {
Color.brandingPrimary AppSurface.primary
} }
} }
} }

View File

@ -2,30 +2,57 @@ import SwiftUI
import Bedrock import Bedrock
struct WidgetEmptyStateView: View { struct WidgetEmptyStateView: View {
let iconSize: SymbolIcon.Size
let title: String let title: String
let subtitle: String let subtitle: String
let symbolName: String let symbolName: String
let timeRange: String let timeRange: String
let nextRitual: String?
let isCompact: Bool
var body: some View { var body: some View {
VStack(spacing: Design.Spacing.small) { VStack(spacing: Design.Spacing.small) {
SymbolIcon(symbolName, size: .hero, color: Color.brandingAccent.opacity(0.6)) if isCompact {
HStack(alignment: .center, spacing: Design.Spacing.small){
iconView
titleView
}
} else {
iconView
titleView
}
if let nextRitual {
Text(nextRitual)
.styled(.caption, emphasis: .custom(AppTextColors.secondary))
.multilineTextAlignment(.center)
.padding(.top, Design.Spacing.small)
}
Text(String(localized: "Enjoy this moment. Your next ritual will appear when it's time."))
.styled(.caption, emphasis: .custom(AppTextColors.tertiary))
.multilineTextAlignment(.center)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, Design.Spacing.small)
}
.padding(.horizontal, Design.Spacing.small)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
var iconView: some View {
SymbolIcon(symbolName, size: iconSize, color: AppAccent.primary.opacity(0.6))
}
var titleView: some View {
VStack(spacing: Design.Spacing.xSmall) { VStack(spacing: Design.Spacing.xSmall) {
Text(title) Text(title)
.styled(.subheading, emphasis: .custom(.white)) .styled(.subheading, emphasis: .custom(AppTextColors.primary))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
if !timeRange.isEmpty { if !timeRange.isEmpty {
Text(timeRange) Text(timeRange)
.styled(.caption, emphasis: .custom(.white.opacity(0.5))) .styled(.caption, emphasis: .custom(AppTextColors.tertiary))
} }
} }
Text(String(localized: "Enjoy this moment."))
.styled(.caption, emphasis: .custom(.white.opacity(0.5)))
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }