Files
Reflect/Shared/Persisence/ExtensionDataProvider.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

291 lines
10 KiB
Swift

//
// 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<MoodEntryModel>(
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<MoodEntryModel>(
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<MoodEntryModel>(
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<MoodEntryModel>(
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<MoodEntryModel>(
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
}
}