Andromida/AndromidaWidget/Providers/AndromidaWidgetProvider.swift

198 lines
8.7 KiB
Swift

import WidgetKit
import SwiftUI
import SwiftData
import AppIntents
struct AndromidaWidgetProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> WidgetEntry {
WidgetEntry(
date: Date(),
configuration: ConfigurationAppIntent(),
completionRate: 0.75,
currentStreak: 5,
nextHabits: [
HabitEntry(id: UUID(), title: "Morning Meditation", symbolName: "figure.mind.and.body", ritualTitle: "Mindfulness", isCompleted: false),
HabitEntry(id: UUID(), title: "Drink Water", symbolName: "drop.fill", ritualTitle: "Health", isCompleted: true)
],
weeklyTrend: [0.5, 0.7, 0.6, 0.9, 0.8, 0.75, 0.0],
currentTimeOfDay: "Morning",
currentTimeOfDaySymbol: "sunrise.fill",
currentTimeOfDayRange: "Before 11am",
nextRitualInfo: "Next: Drink Water (Midday)"
)
}
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> WidgetEntry {
await fetchLatestData(for: configuration, at: Date())
}
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<WidgetEntry> {
// Create entries for each time-of-day boundary for the next 24 hours.
// This ensures the widget displays the correct rituals at each time period.
var entries: [WidgetEntry] = []
let calendar = Calendar.current
let now = Date()
// Create an entry for right now
let currentEntry = await fetchLatestData(for: configuration, at: now)
entries.append(currentEntry)
// Create entries at each upcoming time-of-day boundary
let boundaryDates = upcomingTimeOfDayBoundaries(from: now, count: 5)
for boundaryDate in boundaryDates {
let entry = await fetchLatestData(for: configuration, at: boundaryDate)
entries.append(entry)
}
// Request a new timeline after the last entry (roughly 24 hours)
let lastEntryDate = entries.last?.date ?? now
let nextRefresh = calendar.date(byAdding: .hour, value: 1, to: lastEntryDate) ?? lastEntryDate
return Timeline(entries: entries, policy: .after(nextRefresh))
}
/// Returns the next N time-of-day boundary dates.
/// Boundaries are at 11:00, 14:00, 17:00, 21:00, and 00:00.
private func upcomingTimeOfDayBoundaries(from startDate: Date, count: Int) -> [Date] {
let calendar = Calendar.current
let boundaryHours = [0, 11, 14, 17, 21] // midnight, morning end, midday end, afternoon end, evening end
var boundaries: [Date] = []
var checkDate = startDate
while boundaries.count < count {
let currentHour = calendar.component(.hour, from: checkDate)
let startOfDay = calendar.startOfDay(for: checkDate)
// Find the next boundary hour after the current hour
for hour in boundaryHours {
if hour > currentHour {
if let boundaryDate = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: startOfDay) {
boundaries.append(boundaryDate)
if boundaries.count >= count { break }
}
}
}
// Move to the next day for remaining boundaries
if let nextDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) {
checkDate = nextDay
} else {
break
}
}
return boundaries
}
@MainActor
private func fetchLatestData(for configuration: ConfigurationAppIntent, at targetDate: Date) -> WidgetEntry {
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
let configurationURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite")
let modelConfig = ModelConfiguration(schema: schema, url: configurationURL)
do {
let container = try ModelContainer(for: schema, configurations: [modelConfig])
let context = container.mainContext
let descriptor = FetchDescriptor<Ritual>()
let rituals = try context.fetch(descriptor)
// Use targetDate for time-of-day calculation, but today for completion data
let today = Calendar.current.startOfDay(for: targetDate)
let dayID = RitualAnalytics.dayIdentifier(for: today)
let timeOfDay = TimeOfDay.current(for: targetDate)
// Filter rituals for the target time of day
let activeRituals = rituals.filter { ritual in
guard ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) != nil else {
return false
}
return ritual.timeOfDay == .anytime || ritual.timeOfDay == timeOfDay
}
.sorted { lhs, rhs in
if lhs.timeOfDay != rhs.timeOfDay {
return lhs.timeOfDay < rhs.timeOfDay
}
return lhs.sortIndex < rhs.sortIndex
}
var visibleHabits: [HabitEntry] = []
for ritual in activeRituals {
if let arc = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) {
// Sort habits within each ritual by their sortIndex
let sortedHabits = (arc.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
for habit in sortedHabits {
visibleHabits.append(HabitEntry(
id: habit.id,
title: habit.title,
symbolName: habit.symbolName,
ritualTitle: ritual.title,
isCompleted: habit.completedDayIDs.contains(dayID)
))
}
}
}
// Calculate overall progress across ALL rituals for today
let overallRate = RitualAnalytics.overallCompletionRate(on: today, from: rituals)
// Next habits (limit to 4) - still filtered by current time of day for the list
let nextHabits = visibleHabits.prefix(4)
// Streak calculation
let streak = RitualAnalytics.calculateCurrentStreak(rituals: rituals)
// Next ritual info
var nextRitualString: String? = nil
if let nextRitual = RitualAnalytics.nextUpcomingRitual(from: rituals, currentDate: targetDate) {
let isTomorrow = !Calendar.current.isDate(nextRitual.startDate, inSameDayAs: targetDate)
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: targetDate,
configuration: configuration,
completionRate: overallRate,
currentStreak: streak,
nextHabits: Array(nextHabits),
weeklyTrend: [],
currentTimeOfDay: timeOfDay.displayName,
currentTimeOfDaySymbol: timeOfDay.symbolName,
currentTimeOfDayRange: timeOfDay.timeRange,
nextRitualInfo: nextRitualString
)
} catch {
// Return a default entry instead of placeholder(in: .preview)
return WidgetEntry(
date: Date(),
configuration: configuration,
completionRate: 0.0,
currentStreak: 0,
nextHabits: [],
weeklyTrend: [],
currentTimeOfDay: "Today",
currentTimeOfDaySymbol: "clock.fill",
currentTimeOfDayRange: "",
nextRitualInfo: nil
)
}
}
}