// // ExtensionDataProvider.swift // Reflect // // Unified data provider for Widget and Watch extensions. // - Watch: Uses CloudKit for automatic sync with iPhone // - Widget: Uses local App Group storage (widgets can't use CloudKit) // // Add this file to: ReflectWidgetExtension, Reflect Watch App // import Foundation import SwiftData import WidgetKit import os.log /// Unified data provider for Widget and Watch extensions /// - Watch: Uses CloudKit for automatic sync with iPhone (no WCSession needed for data) /// - Widget: Uses local App Group storage (widgets can't use CloudKit) @MainActor final class ExtensionDataProvider { static let shared = ExtensionDataProvider() private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.88oakapps.reflect", category: "ExtensionDataProvider") private var _container: ModelContainer? private var container: ModelContainer { if let existing = _container { return existing } let newContainer = createContainer() _container = newContainer return newContainer } /// Creates the ModelContainer for extension data access private func createContainer() -> ModelContainer { let schema = Schema([MoodEntryModel.self]) // Try to use shared app group container do { let storeURL = try getStoreURL() #if os(watchOS) // Watch uses CloudKit for automatic sync with iPhone let cloudKitContainerID: String #if DEBUG cloudKitContainerID = "iCloud.com.88oakapps.reflect.debug" #else cloudKitContainerID = "iCloud.com.88oakapps.reflect" #endif let configuration = ModelConfiguration( schema: schema, url: storeURL, cloudKitDatabase: .private(cloudKitContainerID) ) Self.logger.info("Watch using CloudKit container: \(cloudKitContainerID)") #else // Widget uses local storage only (can't use CloudKit) let configuration = ModelConfiguration( schema: schema, url: storeURL, cloudKitDatabase: .none ) #endif return try ModelContainer(for: schema, configurations: [configuration]) } catch { Self.logger.warning("Falling back to in-memory storage: \(error.localizedDescription)") // Fall back to in-memory storage let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) do { return try ModelContainer(for: schema, configurations: [config]) } catch { Self.logger.critical("Failed to create ModelContainer: \(error.localizedDescription)") preconditionFailure("Unable to create ModelContainer: \(error)") } } } private func getStoreURL() throws -> URL { let appGroupID = Constants.currentGroupShareId guard let containerURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupID ) else { throw NSError(domain: "ExtensionDataProvider", code: 1, userInfo: [NSLocalizedDescriptionKey: "App Group not available"]) } #if DEBUG return containerURL.appendingPathComponent("Reflect-Debug.store") #else return containerURL.appendingPathComponent("Reflect.store") #endif } private var modelContext: ModelContext { container.mainContext } private init() {} // MARK: - Read Operations /// Get a single entry for a specific date func getEntry(byDate date: Date) -> MoodEntryModel? { let startDate = Calendar.current.startOfDay(for: date) guard let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate) else { Self.logger.error("Failed to calculate end date for getEntry") return nil } var descriptor = FetchDescriptor( predicate: #Predicate { entry in entry.forDate >= startDate && entry.forDate < endDate }, sortBy: [SortDescriptor(\.forDate, order: .forward)] ) descriptor.fetchLimit = 1 do { return try modelContext.fetch(descriptor).first } catch { Self.logger.error("Failed to fetch entry: \(error.localizedDescription)") return nil } } /// Get today's mood entry func getTodayEntry() -> MoodEntryModel? { getEntry(byDate: Date()) } /// Get entries within a date range, optionally filtered by weekdays /// - Parameters: /// - startDate: Start of the date range /// - endDate: End of the date range /// - includedDays: Weekdays to include (1=Sunday, 7=Saturday). Empty = all days. func getData(startDate: Date, endDate: Date, includedDays: [Int] = []) -> [MoodEntryModel] { let weekDays = includedDays.isEmpty ? [1, 2, 3, 4, 5, 6, 7] : includedDays let descriptor = FetchDescriptor( predicate: #Predicate { entry in entry.forDate >= startDate && entry.forDate <= endDate && weekDays.contains(entry.weekDay) }, sortBy: [SortDescriptor(\.forDate, order: .forward)] ) do { return try modelContext.fetch(descriptor) } catch { Self.logger.error("Failed to fetch entries: \(error.localizedDescription)") return [] } } /// Get the earliest entry in the database var earliestEntry: MoodEntryModel? { var descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.forDate, order: .forward)] ) descriptor.fetchLimit = 1 do { return try modelContext.fetch(descriptor).first } catch { Self.logger.error("Failed to fetch earliest entry: \(error.localizedDescription)") return nil } } /// Get the latest entry in the database var latestEntry: MoodEntryModel? { var descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.forDate, order: .reverse)] ) descriptor.fetchLimit = 1 do { return try modelContext.fetch(descriptor).first } catch { Self.logger.error("Failed to fetch latest entry: \(error.localizedDescription)") return nil } } /// Get the current streak count /// - Parameter includedDays: Weekdays to include in streak calculation (empty = all days) func getCurrentStreak(includedDays: [Int] = []) -> Int { guard let yearAgo = Calendar.current.date(byAdding: .day, value: -365, to: Date()) else { Self.logger.error("Failed to calculate year-ago date for streak") return 0 } let entries = getData( startDate: yearAgo, endDate: Date(), includedDays: includedDays ).sorted { $0.forDate > $1.forDate } var streak = 0 var currentDate = Calendar.current.startOfDay(for: Date()) for entry in entries { let entryDate = Calendar.current.startOfDay(for: entry.forDate) if entryDate == currentDate && entry.mood != .missing && entry.mood != .placeholder { streak += 1 guard let previousDate = Calendar.current.date(byAdding: .day, value: -1, to: currentDate) else { Self.logger.error("Failed to calculate previous date in streak") break } currentDate = previousDate } else if entryDate < currentDate { break } } return streak } // MARK: - Write Operations /// Add a new mood entry /// - Parameters: /// - mood: The mood to record /// - date: The date for the entry /// - entryType: The source of the entry (widget, watch, etc.) func add(mood: Mood, forDate date: Date, entryType: EntryType) { // Delete ALL existing entries for this date (handles duplicates) let existing = getAllEntries(byDate: date) for entry in existing { modelContext.delete(entry) } if !existing.isEmpty { do { try modelContext.save() } catch { Self.logger.error("Failed to save after deleting existing entries: \(error.localizedDescription)") } } let entry = MoodEntryModel( forDate: date, mood: mood, entryType: entryType ) modelContext.insert(entry) do { try modelContext.save() Self.logger.info("Saved mood \(mood.rawValue) for \(date)") // Refresh all widgets/complications immediately WidgetCenter.shared.reloadAllTimelines() } catch { Self.logger.error("Failed to save mood: \(error.localizedDescription)") } } /// Get ALL entries for a specific date (not just the first one) func getAllEntries(byDate date: Date) -> [MoodEntryModel] { let startDate = Calendar.current.startOfDay(for: date) guard let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate) else { Self.logger.error("Failed to calculate end date for getAllEntries") return [] } let descriptor = FetchDescriptor( predicate: #Predicate { entry in entry.forDate >= startDate && entry.forDate < endDate }, sortBy: [SortDescriptor(\.forDate, order: .forward)] ) do { return try modelContext.fetch(descriptor) } catch { Self.logger.error("Failed to fetch all entries: \(error.localizedDescription)") return [] } } /// Invalidate cached container (call when data might have changed) func invalidateCache() { _container = nil } }