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:
BIN
Feels Watch App/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
Feels Watch App/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 936 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "watchos",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
Feels Watch App/Assets.xcassets/Contents.json
Normal file
6
Feels Watch App/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
191
Feels Watch App/ContentView.swift
Normal file
191
Feels Watch App/ContentView.swift
Normal file
@@ -0,0 +1,191 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// Feels Watch App
|
||||
//
|
||||
// Main voting interface for logging moods on Apple Watch.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WatchKit
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var showConfirmation = false
|
||||
@State private var selectedMood: Mood?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VStack(spacing: 8) {
|
||||
Text("How do you feel?")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
// Top row: Great, Good, Average
|
||||
HStack(spacing: 8) {
|
||||
MoodButton(mood: .great, action: { logMood(.great) })
|
||||
MoodButton(mood: .good, action: { logMood(.good) })
|
||||
MoodButton(mood: .average, action: { logMood(.average) })
|
||||
}
|
||||
|
||||
// Bottom row: Bad, Horrible
|
||||
HStack(spacing: 8) {
|
||||
MoodButton(mood: .bad, action: { logMood(.bad) })
|
||||
MoodButton(mood: .horrible, action: { logMood(.horrible) })
|
||||
}
|
||||
}
|
||||
.opacity(showConfirmation ? 0.3 : 1)
|
||||
|
||||
// Confirmation overlay
|
||||
if showConfirmation {
|
||||
ConfirmationView(mood: selectedMood)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func logMood(_ mood: Mood) {
|
||||
selectedMood = mood
|
||||
|
||||
// Haptic feedback
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
|
||||
let date = Date()
|
||||
|
||||
// Send to iPhone for centralized logging (iOS handles all side effects)
|
||||
// Also save locally as fallback and for immediate complication updates
|
||||
Task { @MainActor in
|
||||
// Always save locally for immediate complication display
|
||||
WatchDataProvider.shared.addMood(mood, forDate: date)
|
||||
|
||||
// Send to iPhone - it will handle HealthKit, Live Activity, etc.
|
||||
_ = WatchConnectivityManager.shared.sendMoodToPhone(mood: mood.rawValue, date: date)
|
||||
}
|
||||
|
||||
// Show confirmation
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showConfirmation = true
|
||||
}
|
||||
|
||||
// Hide confirmation after delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showConfirmation = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mood Button
|
||||
|
||||
struct MoodButton: View {
|
||||
let mood: Mood
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(mood.watchEmoji)
|
||||
.font(.system(size: 28))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background(mood.watchColor.opacity(0.3))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Confirmation View
|
||||
|
||||
struct ConfirmationView: View {
|
||||
let mood: Mood?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.green)
|
||||
|
||||
Text("Logged!")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
|
||||
if let mood = mood {
|
||||
Text(mood.watchEmoji)
|
||||
.font(.system(size: 24))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Watch Mood Image Provider
|
||||
|
||||
/// Provides the appropriate emoji based on user's selected mood image style
|
||||
enum WatchMoodImageStyle: Int {
|
||||
case fontAwesome = 0
|
||||
case emoji = 1
|
||||
case handEmoji = 2
|
||||
|
||||
static var current: WatchMoodImageStyle {
|
||||
// Use optional chaining for preview safety - App Group may not exist in canvas
|
||||
guard let defaults = UserDefaults(suiteName: Constants.currentGroupShareId) else {
|
||||
return .emoji
|
||||
}
|
||||
let rawValue = defaults.integer(forKey: "moodImages")
|
||||
return WatchMoodImageStyle(rawValue: rawValue) ?? .emoji
|
||||
}
|
||||
|
||||
func emoji(for mood: Mood) -> String {
|
||||
switch self {
|
||||
case .fontAwesome:
|
||||
// FontAwesome uses face icons - map to similar emoji
|
||||
switch mood {
|
||||
case .great: return "😁"
|
||||
case .good: return "🙂"
|
||||
case .average: return "😐"
|
||||
case .bad: return "🙁"
|
||||
case .horrible: return "😫"
|
||||
case .missing, .placeholder: return "❓"
|
||||
}
|
||||
case .emoji:
|
||||
switch mood {
|
||||
case .great: return "😀"
|
||||
case .good: return "🙂"
|
||||
case .average: return "😑"
|
||||
case .bad: return "😕"
|
||||
case .horrible: return "💩"
|
||||
case .missing, .placeholder: return "❓"
|
||||
}
|
||||
case .handEmoji:
|
||||
switch mood {
|
||||
case .great: return "🙏"
|
||||
case .good: return "👍"
|
||||
case .average: return "🖖"
|
||||
case .bad: return "👎"
|
||||
case .horrible: return "🖕"
|
||||
case .missing, .placeholder: return "❓"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Watch-Specific Mood Extensions
|
||||
|
||||
extension Mood {
|
||||
/// Emoji representation for watch display based on user's selected style
|
||||
var watchEmoji: String {
|
||||
WatchMoodImageStyle.current.emoji(for: self)
|
||||
}
|
||||
|
||||
/// Color for watch UI (simplified palette)
|
||||
var watchColor: Color {
|
||||
switch self {
|
||||
case .great: return .green
|
||||
case .good: return .mint
|
||||
case .average: return .yellow
|
||||
case .bad: return .orange
|
||||
case .horrible: return .red
|
||||
case .missing, .placeholder: return .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
10
Feels Watch App/Feels Watch App.entitlements
Normal file
10
Feels Watch App/Feels Watch App.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.tt.ifeel</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
10
Feels Watch App/Feels Watch AppDebug.entitlements
Normal file
10
Feels Watch App/Feels Watch AppDebug.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.tt.ifeelDebug</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
232
Feels Watch App/FeelsComplication.swift
Normal file
232
Feels Watch App/FeelsComplication.swift
Normal file
@@ -0,0 +1,232 @@
|
||||
//
|
||||
// FeelsComplication.swift
|
||||
// Feels Watch App
|
||||
//
|
||||
// WidgetKit complications for Apple Watch.
|
||||
//
|
||||
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Timeline Provider
|
||||
|
||||
struct FeelsTimelineProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> FeelsEntry {
|
||||
FeelsEntry(date: Date(), mood: nil, streak: 0)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (FeelsEntry) -> Void) {
|
||||
Task { @MainActor in
|
||||
let entry = createEntry()
|
||||
completion(entry)
|
||||
}
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<FeelsEntry>) -> Void) {
|
||||
Task { @MainActor in
|
||||
let entry = createEntry()
|
||||
|
||||
// Refresh at midnight for the next day
|
||||
let tomorrow = Calendar.current.startOfDay(
|
||||
for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!
|
||||
)
|
||||
|
||||
let timeline = Timeline(entries: [entry], policy: .after(tomorrow))
|
||||
completion(timeline)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func createEntry() -> FeelsEntry {
|
||||
let todayEntry = WatchDataProvider.shared.getTodayEntry()
|
||||
let streak = WatchDataProvider.shared.getCurrentStreak()
|
||||
|
||||
return FeelsEntry(
|
||||
date: Date(),
|
||||
mood: todayEntry?.mood,
|
||||
streak: streak
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline Entry
|
||||
|
||||
struct FeelsEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let mood: Mood?
|
||||
let streak: Int
|
||||
}
|
||||
|
||||
// MARK: - Complication Views
|
||||
|
||||
struct FeelsComplicationEntryView: View {
|
||||
var entry: FeelsEntry
|
||||
@Environment(\.widgetFamily) var family
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .accessoryCircular:
|
||||
CircularView(entry: entry)
|
||||
case .accessoryCorner:
|
||||
CornerView(entry: entry)
|
||||
case .accessoryInline:
|
||||
InlineView(entry: entry)
|
||||
case .accessoryRectangular:
|
||||
RectangularView(entry: entry)
|
||||
default:
|
||||
CircularView(entry: entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Circular Complication
|
||||
|
||||
struct CircularView: View {
|
||||
let entry: FeelsEntry
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
|
||||
if let mood = entry.mood {
|
||||
Text(mood.watchEmoji)
|
||||
.font(.system(size: 24))
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
Image(systemName: "face.smiling")
|
||||
.font(.system(size: 18))
|
||||
Text("Log")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Corner Complication
|
||||
|
||||
struct CornerView: View {
|
||||
let entry: FeelsEntry
|
||||
|
||||
var body: some View {
|
||||
if let mood = entry.mood {
|
||||
Text(mood.watchEmoji)
|
||||
.font(.system(size: 20))
|
||||
.widgetLabel {
|
||||
Text(mood.widgetDisplayName)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "face.smiling")
|
||||
.font(.system(size: 20))
|
||||
.widgetLabel {
|
||||
Text("Log mood")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Inline Complication
|
||||
|
||||
struct InlineView: View {
|
||||
let entry: FeelsEntry
|
||||
|
||||
var body: some View {
|
||||
if entry.streak > 0 {
|
||||
Label("\(entry.streak) day streak", systemImage: "flame.fill")
|
||||
} else if let mood = entry.mood {
|
||||
Text("\(mood.watchEmoji) \(mood.widgetDisplayName)")
|
||||
} else {
|
||||
Label("Log your mood", systemImage: "face.smiling")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rectangular Complication
|
||||
|
||||
struct RectangularView: View {
|
||||
let entry: FeelsEntry
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if let mood = entry.mood {
|
||||
Text(mood.watchEmoji)
|
||||
.font(.system(size: 28))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Today")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
Text(mood.widgetDisplayName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
|
||||
if entry.streak > 1 {
|
||||
Label("\(entry.streak) days", systemImage: "flame.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "face.smiling")
|
||||
.font(.system(size: 24))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Feels")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Text("Tap to log mood")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget Configuration
|
||||
|
||||
struct FeelsComplication: Widget {
|
||||
let kind: String = "FeelsComplication"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: FeelsTimelineProvider()) { entry in
|
||||
FeelsComplicationEntryView(entry: entry)
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
.configurationDisplayName("Feels")
|
||||
.description("See today's mood and streak.")
|
||||
.supportedFamilies([
|
||||
.accessoryCircular,
|
||||
.accessoryCorner,
|
||||
.accessoryInline,
|
||||
.accessoryRectangular
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview("Circular - Mood") {
|
||||
CircularView(entry: FeelsEntry(date: Date(), mood: .great, streak: 5))
|
||||
.previewContext(WidgetPreviewContext(family: .accessoryCircular))
|
||||
}
|
||||
|
||||
#Preview("Circular - Empty") {
|
||||
CircularView(entry: FeelsEntry(date: Date(), mood: nil, streak: 0))
|
||||
.previewContext(WidgetPreviewContext(family: .accessoryCircular))
|
||||
}
|
||||
|
||||
#Preview("Rectangular - Mood") {
|
||||
RectangularView(entry: FeelsEntry(date: Date(), mood: .good, streak: 7))
|
||||
.previewContext(WidgetPreviewContext(family: .accessoryRectangular))
|
||||
}
|
||||
|
||||
#Preview("Inline - Streak") {
|
||||
InlineView(entry: FeelsEntry(date: Date(), mood: .great, streak: 5))
|
||||
.previewContext(WidgetPreviewContext(family: .accessoryInline))
|
||||
}
|
||||
|
||||
#Preview("Corner - Mood") {
|
||||
CornerView(entry: FeelsEntry(date: Date(), mood: .average, streak: 3))
|
||||
.previewContext(WidgetPreviewContext(family: .accessoryCorner))
|
||||
}
|
||||
23
Feels Watch App/FeelsWatchApp.swift
Normal file
23
Feels Watch App/FeelsWatchApp.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// FeelsWatchApp.swift
|
||||
// Feels Watch App
|
||||
//
|
||||
// Entry point for the Apple Watch companion app.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct FeelsWatchApp: App {
|
||||
|
||||
init() {
|
||||
// Initialize Watch Connectivity for cross-device widget updates
|
||||
_ = WatchConnectivityManager.shared
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
88
Feels Watch App/WatchConnectivityManager.swift
Normal file
88
Feels Watch App/WatchConnectivityManager.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// WatchConnectivityManager.swift
|
||||
// Feels Watch App
|
||||
//
|
||||
// Watch-side connectivity - sends mood to iPhone for centralized logging.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
import WidgetKit
|
||||
import os.log
|
||||
|
||||
/// Watch-side connectivity manager
|
||||
/// Sends mood votes to iPhone for centralized logging
|
||||
final class WatchConnectivityManager: NSObject, ObservableObject {
|
||||
|
||||
static let shared = WatchConnectivityManager()
|
||||
|
||||
private static let logger = Logger(subsystem: "com.tt.ifeel.watchkitapp", category: "WatchConnectivity")
|
||||
|
||||
private var session: WCSession?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
|
||||
if WCSession.isSupported() {
|
||||
session = WCSession.default
|
||||
session?.delegate = self
|
||||
session?.activate()
|
||||
Self.logger.info("WCSession activated")
|
||||
} else {
|
||||
Self.logger.warning("WCSession not supported")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Watch → iOS
|
||||
|
||||
/// Send mood to iOS app for centralized logging
|
||||
func sendMoodToPhone(mood: Int, date: Date) -> Bool {
|
||||
guard let session = session,
|
||||
session.activationState == .activated else {
|
||||
Self.logger.warning("WCSession not ready")
|
||||
return false
|
||||
}
|
||||
|
||||
let message: [String: Any] = [
|
||||
"action": "logMood",
|
||||
"mood": mood,
|
||||
"date": date.timeIntervalSince1970
|
||||
]
|
||||
|
||||
// Use transferUserInfo for guaranteed delivery
|
||||
session.transferUserInfo(message)
|
||||
Self.logger.info("Sent mood \(mood) to iPhone")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
extension WatchConnectivityManager: WCSessionDelegate {
|
||||
|
||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||
if let error = error {
|
||||
Self.logger.error("WCSession activation failed: \(error.localizedDescription)")
|
||||
} else {
|
||||
Self.logger.info("WCSession activated: \(activationState.rawValue)")
|
||||
}
|
||||
}
|
||||
|
||||
// Receive reload notification from iOS
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
if userInfo["action"] as? String == "reloadWidgets" {
|
||||
Self.logger.info("Received reload notification from iPhone")
|
||||
Task { @MainActor in
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
if message["action"] as? String == "reloadWidgets" {
|
||||
Task { @MainActor in
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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