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>
This commit is contained in:
172
Feels Watch App/WatchDataProvider.swift
Normal file
172
Feels Watch App/WatchDataProvider.swift
Normal file
@@ -0,0 +1,172 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user