// // WidgetDataProvider.swift // FeelsWidget // // Lightweight read-only data provider for widgets. // Uses its own ModelContainer to avoid conflicts with the main app. // import Foundation import SwiftData import os.log /// Lightweight read-only data provider for widgets /// Uses its own ModelContainer to avoid SwiftData conflicts with main app @MainActor final class WidgetDataProvider { static let shared = WidgetDataProvider() private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.tt.ifeel", category: "WidgetDataProvider") 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 widget data access private func createContainer() -> ModelContainer { let schema = Schema([MoodEntryModel.self]) // Try to use shared app group container do { let storeURL = try getStoreURL() let configuration = ModelConfiguration( schema: schema, url: storeURL, cloudKitDatabase: .none ) 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: "WidgetDataProvider", code: 1, userInfo: [NSLocalizedDescriptionKey: "App Group not available"]) } #if DEBUG return containerURL.appendingPathComponent("Feels-Debug.store") #else return containerURL.appendingPathComponent("Feels.store") #endif } private var modelContext: ModelContext { container.mainContext } private init() {} // MARK: - Data Access /// Get a single entry for a specific date func getEntry(byDate date: Date) -> MoodEntryModel? { let startDate = Calendar.current.startOfDay(for: date) let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)! var descriptor = FetchDescriptor( predicate: #Predicate { entry in entry.forDate >= startDate && entry.forDate <= endDate }, sortBy: [SortDescriptor(\.forDate, order: .forward)] ) descriptor.fetchLimit = 1 return try? modelContext.fetch(descriptor).first } /// Get entries within a date range, filtered by weekdays 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)] ) return (try? modelContext.fetch(descriptor)) ?? [] } /// Get the earliest entry in the database var earliestEntry: MoodEntryModel? { var descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.forDate, order: .forward)] ) descriptor.fetchLimit = 1 return try? modelContext.fetch(descriptor).first } /// Get the latest entry in the database var latestEntry: MoodEntryModel? { var descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.forDate, order: .reverse)] ) descriptor.fetchLimit = 1 return try? modelContext.fetch(descriptor).first } // MARK: - Widget-Specific Helpers /// Get today's mood entry func getTodayEntry() -> MoodEntryModel? { getEntry(byDate: Date()) } /// Get the current streak count func getCurrentStreak() -> Int { let entries = getData( startDate: Calendar.current.date(byAdding: .day, value: -365, to: Date())!, endDate: Date(), includedDays: [1, 2, 3, 4, 5, 6, 7] ).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 currentDate = Calendar.current.date(byAdding: .day, value: -1, to: currentDate)! } else if entryDate < currentDate { break } } return streak } /// Invalidate cached container (call when data might have changed) func invalidateCache() { _container = nil } // MARK: - Write Operations /// Add a new mood entry (simplified version for widget use) func add(mood: Mood, forDate date: Date, entryType: EntryType) { // Delete existing entry for this date if present if let existing = getEntry(byDate: date) { modelContext.delete(existing) try? modelContext.save() } let entry = MoodEntryModel( forDate: date, mood: mood, entryType: entryType ) modelContext.insert(entry) try? modelContext.save() } }