Files
FamilyMealPlanner/MealMood/Services/CalendarService.swift
T
alexandrev-tibco e15ff93465 2.0: breakfast and snack meal types across the app
- 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
2026-07-12 18:00:50 +02:00

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
}
}