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,
Shared/Configuration/AppIdentifiers.swift,
Shared/Services/RitualAnalytics.swift,
Shared/Theme/RitualsTheme.swift,
);
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),
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
func ritualsForToday() -> [Ritual] {
let currentPeriod = TimeOfDay.current()
return currentRituals.filter { ritual in
guard let arc = ritual.currentArc, arc.contains(date: Date()) else { return false }
return ritual.timeOfDay == .anytime || ritual.timeOfDay == currentPeriod
}
RitualAnalytics.ritualsActive(on: Date(), from: currentRituals)
}
/// 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)
func currentStreak() -> Int {
let perfect = perfectDays()
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
RitualAnalytics.calculateCurrentStreak(rituals: rituals, calendar: calendar)
}
/// 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.
/// This correctly queries habits from arcs that were active on that date.
func completionRate(for date: Date, ritual: Ritual? = nil) -> Double {
let dayID = dayIdentifier(for: date)
let habits: [ArcHabit]
if let ritual = ritual {
// Get habits from the arc that was active on this date
if let arc = ritual.arc(for: date) {
habits = arc.habits ?? []
} else {
return 0
}
let dayID = dayIdentifier(for: date)
guard let arc = ritual.arc(for: date) else { return 0 }
let habits = arc.habits ?? []
guard !habits.isEmpty else { return 0 }
let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count
return Double(completed) / Double(habits.count)
} else {
// Get all habits from all arcs that were active on this date
habits = habitsActive(on: date)
return RitualAnalytics.overallCompletionRate(on: date, from: rituals)
}
guard !habits.isEmpty else { return 0 }
let completed = habits.filter { $0.completedDayIDs.contains(dayID) }.count
return Double(completed) / Double(habits.count)
}
/// Returns all dates that have any habit activity.

View File

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

View File

@ -80,4 +80,28 @@ enum RitualAnalytics {
let completedCount = allTodayHabits.filter { $0.completedDayIDs.contains(dayID) }.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 currentTimeOfDaySymbol: 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],
currentTimeOfDay: "Morning",
currentTimeOfDaySymbol: "sunrise.fill",
currentTimeOfDayRange: "Before 11am"
currentTimeOfDayRange: "Before 11am",
nextRitualInfo: "Next: Drink Water (Midday)"
)
}
@ -85,6 +86,27 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
// Streak calculation
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(
date: today,
configuration: configuration,
@ -94,7 +116,8 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
weeklyTrend: [],
currentTimeOfDay: timeOfDay.displayName,
currentTimeOfDaySymbol: timeOfDay.symbolName,
currentTimeOfDayRange: timeOfDay.timeRange
currentTimeOfDayRange: timeOfDay.timeRange,
nextRitualInfo: nextRitualString
)
} catch {
// Return a default entry instead of placeholder(in: .preview)
@ -107,7 +130,8 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
weeklyTrend: [],
currentTimeOfDay: "Today",
currentTimeOfDaySymbol: "clock.fill",
currentTimeOfDayRange: ""
currentTimeOfDayRange: "",
nextRitualInfo: nil
)
}
}

View File

@ -10,58 +10,62 @@ struct LargeWidgetView: View {
HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text(String(localized: "Today's Progress"))
.styled(.heading, emphasis: .custom(.white))
.styled(.heading, emphasis: .custom(AppTextColors.primary))
Text("\(entry.currentStreak) day streak")
.styled(.subheading, emphasis: .custom(Color.brandingAccent))
.styled(.subheading, emphasis: .custom(AppAccent.primary))
}
Spacer()
ZStack {
Circle()
.stroke(Color.white.opacity(0.1), lineWidth: 6)
.stroke(AppTextColors.primary.opacity(0.1), lineWidth: 6)
Circle()
.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))
Text("\(Int(entry.completionRate * 100))%")
.styled(.captionEmphasis, emphasis: .custom(.white))
.styled(.captionEmphasis, emphasis: .custom(AppTextColors.primary))
}
.frame(width: 50, height: 50)
}
.padding(.top, Design.Spacing.small)
Divider()
.background(Color.white.opacity(0.2))
.background(AppTextColors.primary.opacity(0.2))
if entry.nextHabits.isEmpty {
Spacer()
WidgetEmptyStateView(
iconSize: .section,
title: String(localized: "No rituals scheduled for \(entry.currentTimeOfDay.lowercased())."),
subtitle: entry.currentTimeOfDay,
symbolName: entry.currentTimeOfDaySymbol,
timeRange: entry.currentTimeOfDayRange
timeRange: entry.currentTimeOfDayRange,
nextRitual: entry.nextRitualInfo,
isCompact: false
)
Spacer()
} else {
Text(String(localized: "Habits"))
.styled(.captionEmphasis, emphasis: .custom(.white.opacity(0.7)))
.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(Color.brandingAccent)
.foregroundColor(AppAccent.primary)
.font(.system(size: 18))
.frame(width: 24)
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text(habit.title)
.styled(.subheading, emphasis: .custom(.white))
.styled(.subheading, emphasis: .custom(AppTextColors.primary))
Text(habit.ritualTitle)
.styled(.caption, emphasis: .custom(.white.opacity(0.5)))
.styled(.caption, emphasis: .custom(AppTextColors.tertiary))
}
Spacer()
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))
}
}
@ -72,7 +76,7 @@ struct LargeWidgetView: View {
}
.padding(Design.Spacing.large)
.containerBackground(for: .widget) {
Color.brandingPrimary
AppSurface.primary
}
}
}

View File

@ -11,27 +11,27 @@ struct MediumWidgetView: View {
VStack(spacing: Design.Spacing.medium) {
ZStack {
Circle()
.stroke(Color.white.opacity(0.1), lineWidth: 8)
.stroke(AppTextColors.primary.opacity(0.1), lineWidth: 8)
Circle()
.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))
VStack(spacing: 0) {
Text("\(Int(entry.completionRate * 100))%")
.styled(.heading, emphasis: .custom(.white))
.styled(.heading, emphasis: .custom(AppTextColors.primary))
Text(String(localized: "Today"))
.styled(.caption, emphasis: .custom(.white.opacity(0.7)))
.styled(.caption, emphasis: .custom(AppTextColors.secondary))
}
}
.frame(width: 72, height: 72)
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "flame.fill")
.foregroundColor(Color.brandingAccent)
.foregroundColor(AppAccent.primary)
.font(.system(size: 14))
Text("\(entry.currentStreak)d")
.styled(.captionEmphasis, emphasis: .custom(.white))
.styled(.captionEmphasis, emphasis: .custom(AppTextColors.primary))
}
}
.frame(width: 110)
@ -39,26 +39,29 @@ struct MediumWidgetView: View {
// Right side: Habits
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
if entry.nextHabits.isEmpty {
WidgetEmptyStateView(
title: String(localized: "No rituals now"),
subtitle: entry.currentTimeOfDay,
symbolName: entry.currentTimeOfDaySymbol,
timeRange: entry.currentTimeOfDayRange
)
WidgetEmptyStateView(
iconSize: .card,
title: String(localized: "No rituals now"),
subtitle: entry.currentTimeOfDay,
symbolName: entry.currentTimeOfDaySymbol,
timeRange: entry.currentTimeOfDayRange,
nextRitual: entry.nextRitualInfo,
isCompact: true
)
} else {
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) {
ForEach(entry.nextHabits.prefix(3)) { habit in
HStack(spacing: Design.Spacing.small) {
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))
.frame(width: 20)
Text(habit.title)
.styled(.subheading, emphasis: .custom(.white))
.styled(.subheading, emphasis: .custom(AppTextColors.primary))
.lineLimit(1)
}
}
@ -73,7 +76,7 @@ struct MediumWidgetView: View {
Spacer()
}
.containerBackground(for: .widget) {
Color.brandingPrimary
AppSurface.primary
}
}
}

View File

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

View File

@ -2,30 +2,57 @@ import SwiftUI
import Bedrock
struct WidgetEmptyStateView: View {
let iconSize: SymbolIcon.Size
let title: String
let subtitle: String
let symbolName: String
let timeRange: String
let nextRitual: String?
let isCompact: Bool
var body: some View {
VStack(spacing: Design.Spacing.small) {
SymbolIcon(symbolName, size: .hero, color: Color.brandingAccent.opacity(0.6))
VStack(spacing: Design.Spacing.xSmall) {
Text(title)
.styled(.subheading, emphasis: .custom(.white))
.multilineTextAlignment(.center)
if !timeRange.isEmpty {
Text(timeRange)
.styled(.caption, emphasis: .custom(.white.opacity(0.5)))
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."))
.styled(.caption, emphasis: .custom(.white.opacity(0.5)))
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) {
Text(title)
.styled(.subheading, emphasis: .custom(AppTextColors.primary))
.multilineTextAlignment(.center)
if !timeRange.isEmpty {
Text(timeRange)
.styled(.caption, emphasis: .custom(AppTextColors.tertiary))
}
}
}
}