Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
9c5fe23488
commit
6f146fbeff
@ -79,6 +79,7 @@
|
||||
Assets.xcassets,
|
||||
Shared/Configuration/AppIdentifiers.swift,
|
||||
Shared/Services/RitualAnalytics.swift,
|
||||
Shared/Theme/RitualsTheme.swift,
|
||||
);
|
||||
target = EAC04D2E2F298D9B007F87EA /* AndromidaWidgetExtension */;
|
||||
};
|
||||
|
||||
@ -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
|
||||
}
|
||||
} else {
|
||||
// Get all habits from all arcs that were active on this date
|
||||
habits = habitsActive(on: date)
|
||||
}
|
||||
|
||||
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 {
|
||||
return RitualAnalytics.overallCompletionRate(on: date, from: rituals)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all dates that have any habit activity.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,4 +11,5 @@ struct WidgetEntry: TimelineEntry {
|
||||
let currentTimeOfDay: String
|
||||
let currentTimeOfDaySymbol: String
|
||||
let currentTimeOfDayRange: String
|
||||
let nextRitualInfo: String?
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
@ -40,25 +40,28 @@ struct MediumWidgetView: View {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
if entry.nextHabits.isEmpty {
|
||||
WidgetEmptyStateView(
|
||||
iconSize: .card,
|
||||
title: String(localized: "No rituals now"),
|
||||
subtitle: entry.currentTimeOfDay,
|
||||
symbolName: entry.currentTimeOfDaySymbol,
|
||||
timeRange: entry.currentTimeOfDayRange
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
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) {
|
||||
Text(title)
|
||||
.styled(.subheading, emphasis: .custom(.white))
|
||||
.styled(.subheading, emphasis: .custom(AppTextColors.primary))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if !timeRange.isEmpty {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user