diff --git a/Andromida.xcodeproj/project.pbxproj b/Andromida.xcodeproj/project.pbxproj index 5b4f52d..5ad6319 100644 --- a/Andromida.xcodeproj/project.pbxproj +++ b/Andromida.xcodeproj/project.pbxproj @@ -79,6 +79,7 @@ Assets.xcassets, Shared/Configuration/AppIdentifiers.swift, Shared/Services/RitualAnalytics.swift, + Shared/Theme/RitualsTheme.swift, ); target = EAC04D2E2F298D9B007F87EA /* AndromidaWidgetExtension */; }; diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index 2cadb25..5adf698 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -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. diff --git a/Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift b/Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift index b4c23c1..3484685 100644 --- a/Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift +++ b/Andromida/App/Views/Today/Components/TodayNoRitualsForTimeView.swift @@ -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 { diff --git a/Andromida/Shared/Services/RitualAnalytics.swift b/Andromida/Shared/Services/RitualAnalytics.swift index 774b83c..caf764e 100644 --- a/Andromida/Shared/Services/RitualAnalytics.swift +++ b/Andromida/Shared/Services/RitualAnalytics.swift @@ -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 + } } diff --git a/AndromidaWidget/Models/WidgetEntry.swift b/AndromidaWidget/Models/WidgetEntry.swift index 46dd9ca..9bcc2ef 100644 --- a/AndromidaWidget/Models/WidgetEntry.swift +++ b/AndromidaWidget/Models/WidgetEntry.swift @@ -11,4 +11,5 @@ struct WidgetEntry: TimelineEntry { let currentTimeOfDay: String let currentTimeOfDaySymbol: String let currentTimeOfDayRange: String + let nextRitualInfo: String? } diff --git a/AndromidaWidget/Providers/AndromidaWidgetProvider.swift b/AndromidaWidget/Providers/AndromidaWidgetProvider.swift index e2b3cd8..4631a70 100644 --- a/AndromidaWidget/Providers/AndromidaWidgetProvider.swift +++ b/AndromidaWidget/Providers/AndromidaWidgetProvider.swift @@ -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 ) } } diff --git a/AndromidaWidget/Views/Components/LargeWidgetView.swift b/AndromidaWidget/Views/Components/LargeWidgetView.swift index db7f24c..585b2e8 100644 --- a/AndromidaWidget/Views/Components/LargeWidgetView.swift +++ b/AndromidaWidget/Views/Components/LargeWidgetView.swift @@ -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 } } } diff --git a/AndromidaWidget/Views/Components/MediumWidgetView.swift b/AndromidaWidget/Views/Components/MediumWidgetView.swift index 53e5832..3028ac8 100644 --- a/AndromidaWidget/Views/Components/MediumWidgetView.swift +++ b/AndromidaWidget/Views/Components/MediumWidgetView.swift @@ -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 } } } diff --git a/AndromidaWidget/Views/Components/SmallWidgetView.swift b/AndromidaWidget/Views/Components/SmallWidgetView.swift index 1644d74..82b289f 100644 --- a/AndromidaWidget/Views/Components/SmallWidgetView.swift +++ b/AndromidaWidget/Views/Components/SmallWidgetView.swift @@ -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 } } } diff --git a/AndromidaWidget/Views/Components/WidgetEmptyStateView.swift b/AndromidaWidget/Views/Components/WidgetEmptyStateView.swift index 305cad0..926ebb9 100644 --- a/AndromidaWidget/Views/Components/WidgetEmptyStateView.swift +++ b/AndromidaWidget/Views/Components/WidgetEmptyStateView.swift @@ -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)) + } + } + } }