Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
94e311ac47
commit
1689e7cec2
@ -34,6 +34,19 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
/// Ritual that needs renewal prompt (arc just completed)
|
/// Ritual that needs renewal prompt (arc just completed)
|
||||||
var ritualNeedingRenewal: Ritual?
|
var ritualNeedingRenewal: Ritual?
|
||||||
|
|
||||||
|
/// The current time of day, updated periodically. Observable for UI refresh.
|
||||||
|
private(set) var currentTimeOfDay: TimeOfDay = TimeOfDay.current()
|
||||||
|
|
||||||
|
/// Debug override for time of day (nil = use real time)
|
||||||
|
var debugTimeOfDayOverride: TimeOfDay? {
|
||||||
|
didSet {
|
||||||
|
updateCurrentTimeOfDay()
|
||||||
|
analyticsNeedsRefresh = true
|
||||||
|
insightCardsNeedRefresh = true
|
||||||
|
reloadRituals()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Rituals that have been dismissed for renewal this session
|
/// Rituals that have been dismissed for renewal this session
|
||||||
private var dismissedRenewalRituals: Set<PersistentIdentifier> = []
|
private var dismissedRenewalRituals: Set<PersistentIdentifier> = []
|
||||||
|
|
||||||
@ -103,10 +116,27 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Refreshes rituals and derived state for current date/time.
|
/// Refreshes rituals and derived state for current date/time.
|
||||||
func refresh() {
|
func refresh() {
|
||||||
|
updateCurrentTimeOfDay()
|
||||||
reloadRituals()
|
reloadRituals()
|
||||||
checkForCompletedArcs()
|
checkForCompletedArcs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates the current time of day and returns true if it changed.
|
||||||
|
@discardableResult
|
||||||
|
func updateCurrentTimeOfDay() -> Bool {
|
||||||
|
let newTimeOfDay = debugTimeOfDayOverride ?? TimeOfDay.current()
|
||||||
|
if newTimeOfDay != currentTimeOfDay {
|
||||||
|
currentTimeOfDay = newTimeOfDay
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the effective time of day (considering debug override).
|
||||||
|
func effectiveTimeOfDay() -> TimeOfDay {
|
||||||
|
debugTimeOfDayOverride ?? TimeOfDay.current()
|
||||||
|
}
|
||||||
|
|
||||||
/// Refreshes rituals if the last refresh was beyond the minimum interval.
|
/// Refreshes rituals if the last refresh was beyond the minimum interval.
|
||||||
func refreshIfNeeded(minimumInterval: TimeInterval = 5) {
|
func refreshIfNeeded(minimumInterval: TimeInterval = 5) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
@ -185,8 +215,17 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
/// Returns rituals appropriate for the current time of day that have active arcs covering today.
|
/// Returns rituals appropriate for the current time of day that have active arcs covering today.
|
||||||
/// Filters based on time of day: morning (before 11am), midday (11am-2pm), afternoon (2pm-5pm),
|
/// 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.
|
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
|
||||||
|
/// Uses the store's `currentTimeOfDay` which respects debug overrides.
|
||||||
func ritualsForToday() -> [Ritual] {
|
func ritualsForToday() -> [Ritual] {
|
||||||
RitualAnalytics.ritualsActive(on: Date(), from: currentRituals)
|
let today = Date()
|
||||||
|
let timeOfDay = effectiveTimeOfDay()
|
||||||
|
|
||||||
|
return currentRituals.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Groups current rituals by time of day for display
|
/// Groups current rituals by time of day for display
|
||||||
|
|||||||
@ -172,6 +172,10 @@ struct SettingsView: View {
|
|||||||
) {
|
) {
|
||||||
UserDefaults.standard.set(true, forKey: "debugForegroundRefreshNextForeground")
|
UserDefaults.standard.set(true, forKey: "debugForegroundRefreshNextForeground")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let ritualStore {
|
||||||
|
TimeOfDayDebugPicker(store: ritualStore)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@ -240,6 +244,85 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug Time of Day Picker
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// Debug picker for simulating different times of day
|
||||||
|
private struct TimeOfDayDebugPicker: View {
|
||||||
|
@Bindable var store: RitualStore
|
||||||
|
|
||||||
|
private var currentSelection: TimeOfDay? {
|
||||||
|
store.debugTimeOfDayOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
private var displayText: String {
|
||||||
|
if let override = store.debugTimeOfDayOverride {
|
||||||
|
return override.displayName
|
||||||
|
}
|
||||||
|
return String(localized: "Real Time (\(TimeOfDay.current().displayName))")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "clock.badge.questionmark")
|
||||||
|
.foregroundStyle(AppStatus.warning)
|
||||||
|
.frame(width: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(String(localized: "Simulate Time of Day"))
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(AppTextColors.primary)
|
||||||
|
|
||||||
|
Text(displayText)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
|
||||||
|
// Time of day options
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 80))], spacing: Design.Spacing.small) {
|
||||||
|
// Real time option
|
||||||
|
Button {
|
||||||
|
store.debugTimeOfDayOverride = nil
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: "Real"))
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(currentSelection == nil ? AppTextColors.inverse : AppTextColors.primary)
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(currentSelection == nil ? AppAccent.primary : AppSurface.secondary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.small))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
// Each time of day
|
||||||
|
ForEach(TimeOfDay.allCases, id: \.self) { time in
|
||||||
|
Button {
|
||||||
|
store.debugTimeOfDayOverride = time
|
||||||
|
} label: {
|
||||||
|
Text(time.displayName)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(currentSelection == time ? AppTextColors.inverse : AppTextColors.primary)
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(currentSelection == time ? AppAccent.primary : AppSurface.secondary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.small))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
SettingsView(store: SettingsStore.preview, ritualStore: nil)
|
SettingsView(store: SettingsStore.preview, ritualStore: nil)
|
||||||
|
|||||||
@ -5,10 +5,14 @@ struct TodayView: View {
|
|||||||
@Bindable var store: RitualStore
|
@Bindable var store: RitualStore
|
||||||
@Bindable var categoryStore: CategoryStore
|
@Bindable var categoryStore: CategoryStore
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
/// Rituals to show now based on current time of day
|
/// Rituals to show now based on current time of day.
|
||||||
|
/// Depends on `store.currentTimeOfDay` which is observable.
|
||||||
private var todayRituals: [Ritual] {
|
private var todayRituals: [Ritual] {
|
||||||
store.ritualsForToday()
|
// Access currentTimeOfDay to establish observation dependency
|
||||||
|
_ = store.currentTimeOfDay
|
||||||
|
return store.ritualsForToday()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether there are active rituals but none for the current time
|
/// Whether there are active rituals but none for the current time
|
||||||
@ -85,8 +89,17 @@ struct TodayView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
store.updateCurrentTimeOfDay()
|
||||||
store.refreshIfNeeded()
|
store.refreshIfNeeded()
|
||||||
}
|
}
|
||||||
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
|
// Check for time-of-day changes when app becomes active
|
||||||
|
if newPhase == .active {
|
||||||
|
if store.updateCurrentTimeOfDay() {
|
||||||
|
store.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func habitRows(for ritual: Ritual) -> [HabitRowModel] {
|
private func habitRows(for ritual: Ritual) -> [HabitRowModel] {
|
||||||
|
|||||||
@ -13,3 +13,96 @@ struct WidgetEntry: TimelineEntry {
|
|||||||
let currentTimeOfDayRange: String
|
let currentTimeOfDayRange: String
|
||||||
let nextRitualInfo: String?
|
let nextRitualInfo: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview Helpers
|
||||||
|
|
||||||
|
extension WidgetEntry {
|
||||||
|
/// Creates a preview entry for a specific time of day
|
||||||
|
static func preview(
|
||||||
|
timeOfDay: String,
|
||||||
|
symbol: String,
|
||||||
|
range: String,
|
||||||
|
habits: [HabitEntry] = [],
|
||||||
|
completionRate: Double = 0.65,
|
||||||
|
streak: Int = 7,
|
||||||
|
nextRitual: String? = nil
|
||||||
|
) -> WidgetEntry {
|
||||||
|
WidgetEntry(
|
||||||
|
date: Date(),
|
||||||
|
configuration: ConfigurationAppIntent(),
|
||||||
|
completionRate: completionRate,
|
||||||
|
currentStreak: streak,
|
||||||
|
nextHabits: habits,
|
||||||
|
weeklyTrend: [],
|
||||||
|
currentTimeOfDay: timeOfDay,
|
||||||
|
currentTimeOfDaySymbol: symbol,
|
||||||
|
currentTimeOfDayRange: range,
|
||||||
|
nextRitualInfo: nextRitual
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preview entries for each time of day
|
||||||
|
static let morningPreview = WidgetEntry.preview(
|
||||||
|
timeOfDay: "Morning",
|
||||||
|
symbol: "sunrise.fill",
|
||||||
|
range: "Before 11am",
|
||||||
|
habits: [
|
||||||
|
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),
|
||||||
|
HabitEntry(id: UUID(), title: "Take Vitamins", symbolName: "pill.fill", ritualTitle: "Health", isCompleted: false)
|
||||||
|
],
|
||||||
|
nextRitual: "Next: Midday Movement (11am – 2pm)"
|
||||||
|
)
|
||||||
|
|
||||||
|
static let middayPreview = WidgetEntry.preview(
|
||||||
|
timeOfDay: "Midday",
|
||||||
|
symbol: "sun.max.fill",
|
||||||
|
range: "11am – 2pm",
|
||||||
|
habits: [
|
||||||
|
HabitEntry(id: UUID(), title: "Midday Walk", symbolName: "figure.walk", ritualTitle: "Movement", isCompleted: false),
|
||||||
|
HabitEntry(id: UUID(), title: "Stretch Break", symbolName: "figure.flexibility", ritualTitle: "Movement", isCompleted: false)
|
||||||
|
],
|
||||||
|
nextRitual: "Next: Deep Work (2pm – 5pm)"
|
||||||
|
)
|
||||||
|
|
||||||
|
static let afternoonPreview = WidgetEntry.preview(
|
||||||
|
timeOfDay: "Afternoon",
|
||||||
|
symbol: "sun.haze.fill",
|
||||||
|
range: "2pm – 5pm",
|
||||||
|
habits: [
|
||||||
|
HabitEntry(id: UUID(), title: "Deep Focus Block", symbolName: "brain", ritualTitle: "Productivity", isCompleted: true),
|
||||||
|
HabitEntry(id: UUID(), title: "Clear Inbox", symbolName: "envelope.fill", ritualTitle: "Productivity", isCompleted: false)
|
||||||
|
],
|
||||||
|
nextRitual: "Next: Evening Wind-Down (5pm – 9pm)"
|
||||||
|
)
|
||||||
|
|
||||||
|
static let eveningPreview = WidgetEntry.preview(
|
||||||
|
timeOfDay: "Evening",
|
||||||
|
symbol: "sunset.fill",
|
||||||
|
range: "5pm – 9pm",
|
||||||
|
habits: [
|
||||||
|
HabitEntry(id: UUID(), title: "Evening Reflection", symbolName: "book.fill", ritualTitle: "Mindfulness", isCompleted: false),
|
||||||
|
HabitEntry(id: UUID(), title: "Gratitude Journal", symbolName: "heart.text.square.fill", ritualTitle: "Mindfulness", isCompleted: false)
|
||||||
|
],
|
||||||
|
nextRitual: "Next: Sleep Prep (After 9pm)"
|
||||||
|
)
|
||||||
|
|
||||||
|
static let nightPreview = WidgetEntry.preview(
|
||||||
|
timeOfDay: "Night",
|
||||||
|
symbol: "moon.stars.fill",
|
||||||
|
range: "After 9pm",
|
||||||
|
habits: [
|
||||||
|
HabitEntry(id: UUID(), title: "No Screens", symbolName: "iphone.slash", ritualTitle: "Sleep Prep", isCompleted: false),
|
||||||
|
HabitEntry(id: UUID(), title: "Read Book", symbolName: "book.closed.fill", ritualTitle: "Sleep Prep", isCompleted: false)
|
||||||
|
],
|
||||||
|
nextRitual: "Next: Morning Routine (Tomorrow)"
|
||||||
|
)
|
||||||
|
|
||||||
|
static let emptyPreview = WidgetEntry.preview(
|
||||||
|
timeOfDay: "Afternoon",
|
||||||
|
symbol: "sun.haze.fill",
|
||||||
|
range: "2pm – 5pm",
|
||||||
|
habits: [],
|
||||||
|
nextRitual: "Next: Evening Wind-Down (5pm – 9pm)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -23,17 +23,70 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> WidgetEntry {
|
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> WidgetEntry {
|
||||||
await fetchLatestData(for: configuration)
|
await fetchLatestData(for: configuration, at: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<WidgetEntry> {
|
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<WidgetEntry> {
|
||||||
let entry = await fetchLatestData(for: configuration)
|
// Create entries for each time-of-day boundary for the next 24 hours.
|
||||||
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date()
|
// This ensures the widget displays the correct rituals at each time period.
|
||||||
return Timeline(entries: [entry], policy: .after(nextUpdate))
|
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
|
@MainActor
|
||||||
private func fetchLatestData(for configuration: ConfigurationAppIntent) -> WidgetEntry {
|
private func fetchLatestData(for configuration: ConfigurationAppIntent, at targetDate: Date) -> WidgetEntry {
|
||||||
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
|
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
|
||||||
let configurationURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
|
let configurationURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
|
||||||
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite")
|
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite")
|
||||||
@ -47,21 +100,27 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
|
|||||||
let descriptor = FetchDescriptor<Ritual>()
|
let descriptor = FetchDescriptor<Ritual>()
|
||||||
let rituals = try context.fetch(descriptor)
|
let rituals = try context.fetch(descriptor)
|
||||||
|
|
||||||
let today = Date()
|
// 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 dayID = RitualAnalytics.dayIdentifier(for: today)
|
||||||
let timeOfDay = TimeOfDay.current(for: today)
|
let timeOfDay = TimeOfDay.current(for: targetDate)
|
||||||
|
|
||||||
// Match the app's logic for "Today" view
|
// Filter rituals for the target time of day
|
||||||
let todayRituals = RitualAnalytics.ritualsActive(on: today, from: rituals)
|
let activeRituals = rituals.filter { ritual in
|
||||||
.sorted { lhs, rhs in
|
guard ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) != nil else {
|
||||||
if lhs.timeOfDay != rhs.timeOfDay {
|
return false
|
||||||
return lhs.timeOfDay < rhs.timeOfDay
|
|
||||||
}
|
|
||||||
return lhs.sortIndex < rhs.sortIndex
|
|
||||||
}
|
}
|
||||||
|
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] = []
|
var visibleHabits: [HabitEntry] = []
|
||||||
for ritual in todayRituals {
|
for ritual in activeRituals {
|
||||||
if let arc = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) {
|
if let arc = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) {
|
||||||
// Sort habits within each ritual by their sortIndex
|
// Sort habits within each ritual by their sortIndex
|
||||||
let sortedHabits = (arc.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
|
let sortedHabits = (arc.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
|
||||||
@ -88,8 +147,8 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
|
|||||||
|
|
||||||
// Next ritual info
|
// Next ritual info
|
||||||
var nextRitualString: String? = nil
|
var nextRitualString: String? = nil
|
||||||
if let nextRitual = RitualAnalytics.nextUpcomingRitual(from: rituals, currentDate: today) {
|
if let nextRitual = RitualAnalytics.nextUpcomingRitual(from: rituals, currentDate: targetDate) {
|
||||||
let isTomorrow = !Calendar.current.isDate(nextRitual.startDate, inSameDayAs: today)
|
let isTomorrow = !Calendar.current.isDate(nextRitual.startDate, inSameDayAs: targetDate)
|
||||||
if isTomorrow {
|
if isTomorrow {
|
||||||
let format = String(localized: "Next ritual: Tomorrow %@ (%@)")
|
let format = String(localized: "Next ritual: Tomorrow %@ (%@)")
|
||||||
nextRitualString = String.localizedStringWithFormat(
|
nextRitualString = String.localizedStringWithFormat(
|
||||||
@ -108,7 +167,7 @@ struct AndromidaWidgetProvider: AppIntentTimelineProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return WidgetEntry(
|
return WidgetEntry(
|
||||||
date: today,
|
date: targetDate,
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
completionRate: overallRate,
|
completionRate: overallRate,
|
||||||
currentStreak: streak,
|
currentStreak: streak,
|
||||||
|
|||||||
@ -106,3 +106,51 @@ extension Color {
|
|||||||
static let brandingPrimary = Color(red: 0.12, green: 0.09, blue: 0.08)
|
static let brandingPrimary = Color(red: 0.12, green: 0.09, blue: 0.08)
|
||||||
static let brandingAccent = Color(red: 0.95, green: 0.60, blue: 0.45) // Matches the orange-ish accent in your app
|
static let brandingAccent = Color(red: 0.95, green: 0.60, blue: 0.45) // Matches the orange-ish accent in your app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews for Testing Time-of-Day Changes
|
||||||
|
|
||||||
|
#Preview("Morning", as: .systemMedium) {
|
||||||
|
AndromidaWidget()
|
||||||
|
} timeline: {
|
||||||
|
WidgetEntry.morningPreview
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Midday", as: .systemMedium) {
|
||||||
|
AndromidaWidget()
|
||||||
|
} timeline: {
|
||||||
|
WidgetEntry.middayPreview
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Afternoon", as: .systemMedium) {
|
||||||
|
AndromidaWidget()
|
||||||
|
} timeline: {
|
||||||
|
WidgetEntry.afternoonPreview
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Evening", as: .systemMedium) {
|
||||||
|
AndromidaWidget()
|
||||||
|
} timeline: {
|
||||||
|
WidgetEntry.eveningPreview
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Night", as: .systemMedium) {
|
||||||
|
AndromidaWidget()
|
||||||
|
} timeline: {
|
||||||
|
WidgetEntry.nightPreview
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Empty State", as: .systemMedium) {
|
||||||
|
AndromidaWidget()
|
||||||
|
} timeline: {
|
||||||
|
WidgetEntry.emptyPreview
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Large - All Times", as: .systemLarge) {
|
||||||
|
AndromidaWidget()
|
||||||
|
} timeline: {
|
||||||
|
WidgetEntry.morningPreview
|
||||||
|
WidgetEntry.middayPreview
|
||||||
|
WidgetEntry.afternoonPreview
|
||||||
|
WidgetEntry.eveningPreview
|
||||||
|
WidgetEntry.nightPreview
|
||||||
|
}
|
||||||
|
|||||||
1
PRD.md
1
PRD.md
@ -597,3 +597,4 @@ Andromida/
|
|||||||
| Version | Date | Description |
|
| Version | Date | Description |
|
||||||
|---------|------|-------------|
|
|---------|------|-------------|
|
||||||
| 1.0 | February 2026 | Initial PRD based on implemented features |
|
| 1.0 | February 2026 | Initial PRD based on implemented features |
|
||||||
|
| 1.1 | February 2026 | Fixed time-of-day refresh bug in Today view and Widget; added debug time simulation |
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user