198 lines
8.7 KiB
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 3) - still filtered by current time of day for the list
|
|
let nextHabits = visibleHabits.prefix(3)
|
|
|
|
// 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
|
|
)
|
|
}
|
|
}
|
|
}
|