- Fix ExtensionDataProvider <= boundary to < in date queries (prevented cross-day leaks) - Replace force-unwraps with guards and add error logging in DataControllerGET and ExtensionDataProvider - Route DayViewViewModel update/delete through MoodLogger.shared (was duplicating side effects) - Add data listeners to InsightsViewModel and YearViewModel for cross-tab refresh - Add HealthKitManager.deleteMood(for:) for single-date cleanup - Add SharedModelContainer.isUsingInMemoryFallback flag with critical logging - Add analytics events: entryDeleted, allDataCleared, duplicatesRemoved, storageFallbackActivated Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
291 lines
10 KiB
Swift
291 lines
10 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.88oakapps.feels.debug"
|
|
#else
|
|
cloudKitContainerID = "iCloud.com.88oakapps.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)
|
|
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
|
|
}
|
|
}
|