Files
Reflect/Shared/Persisence/DataController.swift
Trey t c22d246865 Fix 25 audit issues: memory leaks, concurrency, performance, accessibility
Address findings from comprehensive audit across 5 workstreams:

- Memory: Token-based DataController listeners (prevent closure leaks),
  static DateFormatters, ImageCache observer cleanup, MotionManager
  reference counting, FoundationModels dedup guard
- Concurrency: Replace Task.detached with Task in FeelsApp (preserve
  MainActor isolation), wrap WatchConnectivity handler in MainActor
- Performance: Cache sortedGroupedData in DayViewViewModel, cache demo
  data in MonthView/YearView, remove broken ReduceMotionModifier
- Accessibility: VoiceOver support for LockScreen, DemoHeatmapCell
  labels, MonthCard button labels, InsightsView header traits,
  Smart Invert protection on neon headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:11:48 -06:00

109 lines
3.2 KiB
Swift

//
// DataController.swift
// Feels
//
// SwiftData controller replacing Core Data PersistenceController.
//
import SwiftData
import SwiftUI
import os.log
@MainActor
final class DataController: ObservableObject {
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.tt.feels", category: "DataController")
static let shared = DataController()
private(set) var container: ModelContainer
var modelContext: ModelContext {
container.mainContext
}
// Listeners for data changes (keeping existing pattern)
typealias DataListenerToken = UUID
private var dataListeners: [DataListenerToken: () -> Void] = [:]
// Computed properties for earliest/latest entries
var earliestEntry: MoodEntryModel? {
var descriptor = FetchDescriptor<MoodEntryModel>(
sortBy: [SortDescriptor(\.forDate, order: .forward)]
)
descriptor.fetchLimit = 1
return try? modelContext.fetch(descriptor).first
}
var latestEntry: MoodEntryModel? {
var descriptor = FetchDescriptor<MoodEntryModel>(
sortBy: [SortDescriptor(\.forDate, order: .reverse)]
)
descriptor.fetchLimit = 1
return try? modelContext.fetch(descriptor).first
}
init(container: ModelContainer? = nil) {
self.container = container ?? SharedModelContainer.createWithFallback(useCloudKit: true)
}
// MARK: - Listener Management
@discardableResult
func addNewDataListener(closure: @escaping (() -> Void)) -> DataListenerToken {
let token = DataListenerToken()
dataListeners[token] = closure
return token
}
func removeDataListener(token: DataListenerToken) {
dataListeners.removeValue(forKey: token)
}
@discardableResult
func saveAndRunDataListeners() -> Bool {
let success = save()
if success {
for closure in dataListeners.values {
closure()
}
}
return success
}
@discardableResult
func save() -> Bool {
guard modelContext.hasChanges else { return true }
do {
try modelContext.save()
return true
} catch {
Self.logger.error("Failed to save context, retrying: \(error.localizedDescription)")
do {
try modelContext.save()
return true
} catch {
Self.logger.critical("Failed to save context after retry: \(error.localizedDescription)")
return false
}
}
}
/// Refresh data from disk to pick up changes made by extensions (widget/watch).
/// Call this when app becomes active.
func refreshFromDisk() {
// SwiftData doesn't have a direct "refresh from disk" API.
// We achieve this by:
// 1. Rolling back any unsaved changes (ensures clean state)
// 2. Triggering listeners to re-fetch data (which will read from disk)
modelContext.rollback()
// Notify listeners to re-fetch their data
for closure in dataListeners.values {
closure()
}
Self.logger.debug("Refreshed data from disk")
}
}