e15ff93465
- MealType gains breakfast/snack (chronological order drives slot/row order). - AppSettings.activeMealTypes: single source of truth for enabled meals, backed by new optional enabledMealTypesRaw with legacy mealWindows fallback — additive migration, keeps pre-2.0 installs and iCloud snapshots working. Legacy field kept coherent by the setter. time(for:) resolves per-meal event times (breakfast 8:00 / snack 17:00 defaults on old stores). - Replaced the 5 duplicated mealWindows→[MealType] derivations (slot sync, default plan creation, week calendar, export view) with activeMealTypes. - CalendarService: per-type event names and times. - Export styles: per-type accent colors and gradients. - Widget: generic meals list (N rows) instead of hardcoded lunch/dinner; compact families fall back to main meals when >2 are active. - Settings: 4 meal toggles (min 1) replace the 3-option picker. - Onboarding: meal step is now multi-select over the 4 types. - iCloud sync: enabledMealTypesRaw synced (optional, pre-2.0 compatible). - Localized breakfast/snack in all 6 languages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
148 lines
5.2 KiB
Swift
148 lines
5.2 KiB
Swift
import EventKit
|
|
import Foundation
|
|
|
|
@MainActor
|
|
final class CalendarService {
|
|
static let shared = CalendarService()
|
|
private let eventStore = EKEventStore()
|
|
|
|
private init() {}
|
|
|
|
func requestAccess() async -> Bool {
|
|
do {
|
|
return try await eventStore.requestFullAccessToEvents()
|
|
} catch {
|
|
CrashlyticsService.record(error, context: "calendar_access")
|
|
print("Calendar access error: \(error)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
func availableCalendars() -> [EKCalendar] {
|
|
eventStore.calendars(for: .event)
|
|
}
|
|
|
|
func createEvent(
|
|
slot: MealSlot,
|
|
dishName: String,
|
|
dishDescription: String?,
|
|
weekStartDate: Date,
|
|
settings: AppSettings
|
|
) -> String? {
|
|
guard let calendar = resolvedCalendar(for: settings) else {
|
|
print("Calendar sync skipped: no writable calendar available")
|
|
return nil
|
|
}
|
|
|
|
let event = EKEvent(eventStore: eventStore)
|
|
|
|
let prefix = settings.eventPrefix.isEmpty ? "" : "\(settings.eventPrefix) "
|
|
let mealNameKey = slot.mealTypeEnum.localizedKey
|
|
let mealName = localizedString(mealNameKey, language: settings.languageEnum.resolved())
|
|
event.title = "\(prefix)\(mealName): \(dishName)"
|
|
|
|
let slotDate = weekStartDate.addingDays(slot.dayOfWeek)
|
|
let slotTime = settings.time(for: slot.mealTypeEnum)
|
|
event.startDate = combineDateAndTime(date: slotDate, time: slotTime)
|
|
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
|
|
event.notes = dishDescription
|
|
event.calendar = calendar
|
|
|
|
if let reminder = settings.reminderMinutesBefore {
|
|
event.addAlarm(EKAlarm(relativeOffset: -TimeInterval(reminder * 60)))
|
|
}
|
|
|
|
do {
|
|
try eventStore.save(event, span: .thisEvent)
|
|
return event.eventIdentifier
|
|
} catch {
|
|
CrashlyticsService.record(error, context: "calendar_create")
|
|
print("Error saving event: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func updateEvent(
|
|
eventId: String,
|
|
slot: MealSlot,
|
|
dishName: String,
|
|
dishDescription: String?,
|
|
weekStartDate: Date,
|
|
settings: AppSettings
|
|
) -> String? {
|
|
guard let event = eventStore.event(withIdentifier: eventId) else {
|
|
// Event was deleted externally — create a fresh one
|
|
return createEvent(
|
|
slot: slot,
|
|
dishName: dishName,
|
|
dishDescription: dishDescription,
|
|
weekStartDate: weekStartDate,
|
|
settings: settings
|
|
)
|
|
}
|
|
|
|
let prefix = settings.eventPrefix.isEmpty ? "" : "\(settings.eventPrefix) "
|
|
let mealNameKey = slot.mealTypeEnum.localizedKey
|
|
let mealName = localizedString(mealNameKey, language: settings.languageEnum.resolved())
|
|
event.title = "\(prefix)\(mealName): \(dishName)"
|
|
|
|
let slotDate = weekStartDate.addingDays(slot.dayOfWeek)
|
|
let slotTime = settings.time(for: slot.mealTypeEnum)
|
|
event.startDate = combineDateAndTime(date: slotDate, time: slotTime)
|
|
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
|
|
event.notes = dishDescription
|
|
|
|
event.alarms?.forEach { event.removeAlarm($0) }
|
|
if let reminder = settings.reminderMinutesBefore {
|
|
event.addAlarm(EKAlarm(relativeOffset: -TimeInterval(reminder * 60)))
|
|
}
|
|
|
|
do {
|
|
try eventStore.save(event, span: .thisEvent)
|
|
return event.eventIdentifier
|
|
} catch {
|
|
CrashlyticsService.record(error, context: "calendar_update")
|
|
print("Error updating event: \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func deleteEvent(eventId: String) {
|
|
guard let event = eventStore.event(withIdentifier: eventId) else { return }
|
|
do {
|
|
try eventStore.remove(event, span: .thisEvent)
|
|
} catch {
|
|
CrashlyticsService.record(error, context: "calendar_delete")
|
|
print("Error deleting event: \(error)")
|
|
}
|
|
}
|
|
|
|
func updateEventsTime(slots: [MealSlot], weekStartDate: Date, settings: AppSettings) {
|
|
for slot in slots {
|
|
guard let eventId = slot.calendarEventId,
|
|
let event = eventStore.event(withIdentifier: eventId),
|
|
event.startDate >= Date() else { continue }
|
|
|
|
let slotDate = weekStartDate.addingDays(slot.dayOfWeek)
|
|
let newTime = settings.time(for: slot.mealTypeEnum)
|
|
event.startDate = combineDateAndTime(date: slotDate, time: newTime)
|
|
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
|
|
|
|
try? eventStore.save(event, span: .thisEvent)
|
|
}
|
|
}
|
|
|
|
private func resolvedCalendar(for settings: AppSettings) -> EKCalendar? {
|
|
if let calendarId = settings.calendarId,
|
|
let calendar = eventStore.calendar(withIdentifier: calendarId) {
|
|
return calendar
|
|
}
|
|
|
|
if let defaultCalendar = eventStore.defaultCalendarForNewEvents {
|
|
return defaultCalendar
|
|
}
|
|
|
|
return eventStore.calendars(for: .event).first
|
|
}
|
|
}
|