Files
Reflect/Feels Watch App/WatchDataProvider.swift
Trey t 224c00423a Add Apple Watch companion app with complications and WCSession sync
- Add watchOS app target with mood voting UI (5 mood buttons)
- Add WidgetKit complications (circular, corner, inline, rectangular)
- Add WatchConnectivityManager for bidirectional sync between iOS and watch
- iOS app acts as central coordinator - all mood logging flows through MoodLogger
- Watch votes send to iPhone via WCSession, iPhone logs and notifies watch back
- Widget votes use openAppWhenRun=true to run MoodLogger in main app process
- Add #if !os(watchOS) guards to Mood.swift and Random.swift for compatibility
- Update SKStoreReviewController to AppStore.requestReview (iOS 18 deprecation fix)
- Watch reads user's moodImages preference from GroupUserDefaults for emoji style

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 17:19:17 -06:00

173 lines
5.6 KiB
Swift

//
// WatchDataProvider.swift
// Feels Watch App
//
// Data provider for Apple Watch with read/write access.
// Uses App Group container shared with main iOS app.
//
import Foundation
import SwiftData
import WidgetKit
import os.log
/// Data provider for Apple Watch with read/write access
/// Uses its own ModelContainer to avoid SwiftData conflicts
@MainActor
final class WatchDataProvider {
static let shared = WatchDataProvider()
private static let logger = Logger(subsystem: "com.tt.ifeel.watchkitapp", category: "WatchDataProvider")
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 watch data access
private func createContainer() -> ModelContainer {
let schema = Schema([MoodEntryModel.self])
// Try to use shared app group container
do {
let storeURL = try getStoreURL()
let configuration = ModelConfiguration(
schema: schema,
url: storeURL,
cloudKitDatabase: .none // Watch doesn't sync directly
)
return try ModelContainer(for: schema, configurations: [configuration])
} catch {
Self.logger.warning("Falling back to in-memory storage: \(error.localizedDescription)")
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: "WatchDataProvider", 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
func getData(startDate: Date, endDate: Date) -> [MoodEntryModel] {
let descriptor = FetchDescriptor<MoodEntryModel>(
predicate: #Predicate { entry in
entry.forDate >= startDate && entry.forDate <= endDate
},
sortBy: [SortDescriptor(\.forDate, order: .reverse)]
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Get the current streak count
func getCurrentStreak() -> Int {
let yearAgo = Calendar.current.date(byAdding: .day, value: -365, to: Date())!
let entries = getData(startDate: yearAgo, endDate: Date())
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 from the watch
func addMood(_ mood: Mood, forDate date: Date) {
// Delete existing entry for this date if present
if let existing = getEntry(byDate: date) {
modelContext.delete(existing)
try? modelContext.save()
}
let entry = MoodEntryModel(
forDate: date,
mood: mood,
entryType: .watch
)
modelContext.insert(entry)
do {
try modelContext.save()
Self.logger.info("Saved mood \(mood.rawValue) for \(date)")
// Refresh watch complications immediately
WidgetCenter.shared.reloadAllTimelines()
// Note: WCSession notification is handled by ContentView
// iOS app coordinates all side effects when it receives the mood
} catch {
Self.logger.error("Failed to save mood: \(error.localizedDescription)")
}
}
/// Invalidate cached container
func invalidateCache() {
_container = nil
}
}