// // DataControllerProtocol.swift // Feels // // Protocol defining the data access interface for mood entries. // Enables dependency injection and testability. // import Foundation import SwiftData // MARK: - Data Controller Protocol /// Protocol defining read-only data access for mood entries /// Used by widgets and other components that only need to read data @MainActor protocol MoodDataReading { /// Get a single entry for a specific date func getEntry(byDate date: Date) -> MoodEntryModel? /// Get entries within a date range, filtered by weekdays func getData(startDate: Date, endDate: Date, includedDays: [Int]) -> [MoodEntryModel] /// Get all data organized by year and month func splitIntoYearMonth(includedDays: [Int]) -> [Int: [Int: [MoodEntryModel]]] /// The earliest entry in the database var earliestEntry: MoodEntryModel? { get } /// The latest entry in the database var latestEntry: MoodEntryModel? { get } } /// Protocol defining write operations for mood entries @MainActor protocol MoodDataWriting { /// Add a new mood entry func add(mood: Mood, forDate date: Date, entryType: EntryType) /// Update the mood for an existing entry @discardableResult func update(entryDate: Date, withMood mood: Mood) -> Bool /// Update notes for an entry @discardableResult func updateNotes(forDate date: Date, notes: String?) -> Bool /// Update photo for an entry @discardableResult func updatePhoto(forDate date: Date, photoID: UUID?) -> Bool /// Fill in missing dates with placeholder entries func fillInMissingDates() /// Fix incorrect weekday values func fixWrongWeekdays() } /// Protocol for data deletion operations @MainActor protocol MoodDataDeleting { /// Clear all entries from the database func clearDB() /// Delete entries from the last N days func deleteLast(numberOfEntries: Int) } /// Protocol for persistence operations @MainActor protocol MoodDataPersisting { /// Save pending changes func save() /// Save and notify listeners func saveAndRunDataListeners() /// Add a listener for data changes func addNewDataListener(closure: @escaping (() -> Void)) } /// Combined protocol for full data controller functionality @MainActor protocol DataControlling: MoodDataReading, MoodDataWriting, MoodDataDeleting, MoodDataPersisting {} // MARK: - DataController Conformance extension DataController: DataControlling {} // MARK: - Widget Data Provider /// Lightweight read-only data provider for widgets /// Uses UserDefaults-cached data when possible to avoid SwiftData overhead @MainActor final class WidgetDataProvider: MoodDataReading { static let shared = WidgetDataProvider() private var _container: ModelContainer? private var container: ModelContainer { if let existing = _container { return existing } let newContainer = SharedModelContainer.createWithFallback(useCloudKit: true) _container = newContainer return newContainer } private var modelContext: ModelContext { container.mainContext } private init() {} // MARK: - MoodDataReading 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 } 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)) ?? [] } func splitIntoYearMonth(includedDays: [Int]) -> [Int: [Int: [MoodEntryModel]]] { let data = getData( startDate: Date(timeIntervalSince1970: 0), endDate: Date(), includedDays: includedDays ).sorted { $0.forDate < $1.forDate } guard let earliest = data.first, let latest = data.last else { return [:] } let calendar = Calendar.current let earliestYear = calendar.component(.year, from: earliest.forDate) let latestYear = calendar.component(.year, from: latest.forDate) var result = [Int: [Int: [MoodEntryModel]]]() for year in earliestYear...latestYear { var monthData = [Int: [MoodEntryModel]]() for month in 1...12 { var components = DateComponents() components.year = year components.month = month components.day = 1 guard let startOfMonth = calendar.date(from: components) else { continue } let items = getData( startDate: startOfMonth, endDate: startOfMonth.endOfMonth, includedDays: [1, 2, 3, 4, 5, 6, 7] ) if !items.isEmpty { monthData[month] = items } } result[year] = monthData } return result } var earliestEntry: MoodEntryModel? { var descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.forDate, order: .forward)] ) descriptor.fetchLimit = 1 return try? modelContext.fetch(descriptor).first } 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 } }