Files
Reflect/Shared/Persisence/ExtensionDataProvider.swift
Trey t 00cbd476d4 Enable CloudKit sync for Watch app and fix WCSession handling
- Watch now uses CloudKit for data persistence (syncs automatically with iPhone)
- Added iCloud/CloudKit entitlements to Watch app (debug and release)
- Fixed WCSession delegate to handle messages with reply handler
- Watch UI now shows "Already Rated" screen after voting
- Invalidate LiveActivityScheduler cache when mood is logged
- WCSession now used only for immediate UI updates, not data persistence

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 11:01:33 -06:00

247 lines
8.5 KiB
Swift

//
// ExtensionDataProvider.swift
// Feels
//
// 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: FeelsWidgetExtension, Feels 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.tt.feels", 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.tt.feelsDebug"
#else
cloudKitContainerID = "iCloud.com.tt.feels"
#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("Feels-Debug.store")
#else
return containerURL.appendingPathComponent("Feels.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)
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
var descriptor = FetchDescriptor<MoodEntryModel>(
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 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)]
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Get the earliest entry in the database
var earliestEntry: MoodEntryModel? {
var descriptor = FetchDescriptor<MoodEntryModel>(
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<MoodEntryModel>(
sortBy: [SortDescriptor(\.forDate, order: .reverse)]
)
descriptor.fetchLimit = 1
return try? modelContext.fetch(descriptor).first
}
/// Get the current streak count
/// - Parameter includedDays: Weekdays to include in streak calculation (empty = all days)
func getCurrentStreak(includedDays: [Int] = []) -> Int {
let entries = getData(
startDate: Calendar.current.date(byAdding: .day, value: -365, to: Date())!,
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
currentDate = Calendar.current.date(byAdding: .day, value: -1, to: currentDate)!
} 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 {
try? modelContext.save()
}
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)
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
let descriptor = FetchDescriptor<MoodEntryModel>(
predicate: #Predicate { entry in
entry.forDate >= startDate && entry.forDate <= endDate
},
sortBy: [SortDescriptor(\.forDate, order: .forward)]
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Invalidate cached container (call when data might have changed)
func invalidateCache() {
_container = nil
}
}