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
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1C0DAB51279DB0FB003B1F21 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */; };
|
||||
1C0DAB52279DB0FB003B1F22 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */; };
|
||||
1C0DAB51279DB0FB003B1F21 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; };
|
||||
1C0DAB52279DB0FB003B1F22 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; };
|
||||
1C2618FA2795E41D00FDC148 /* Charts in Frameworks */ = {isa = PBXBuildFile; productRef = 1C2618F92795E41D00FDC148 /* Charts */; };
|
||||
1C747CC9279F06EB00762CBD /* CloudKitSyncMonitor in Frameworks */ = {isa = PBXBuildFile; productRef = 1C747CC8279F06EB00762CBD /* CloudKitSyncMonitor */; };
|
||||
1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB4D09F28787D8A00902A56 /* StoreKit.framework */; };
|
||||
@@ -21,6 +21,8 @@
|
||||
1CD90B56278C7E7A001C4FEA /* FeelsWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
1CD90B6C278C7F78001C4FEA /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B6B278C7F78001C4FEA /* CloudKit.framework */; };
|
||||
1CD90B6E278C7F8B001C4FEA /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B6B278C7F78001C4FEA /* CloudKit.framework */; };
|
||||
46F07FA9D330456697C9AC29 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B47278C7E7A001C4FEA /* WidgetKit.framework */; };
|
||||
69674916178A409ABDEA4126 /* Feels Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -45,6 +47,13 @@
|
||||
remoteGlobalIDString = 1CD90B44278C7E7A001C4FEA;
|
||||
remoteInfo = FeelsWidgetExtension;
|
||||
};
|
||||
51F6DCE106234B68B4F88529 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 1CD90AE6278C7DDF001C4FEA /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = B1DB9E6543DE4A009DB00916;
|
||||
remoteInfo = "Feels Watch App";
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -59,10 +68,21 @@
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
87A714924E734CD8948F0CD0 /* Embed Watch Content */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
69674916178A409ABDEA4126 /* Feels Watch App.app in Embed Watch Content */,
|
||||
);
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = "Feels/Localizable.xcstrings"; sourceTree = "<group>"; };
|
||||
1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Feels/Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
1CB4D09E28787B3C00902A56 /* Configuration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Configuration.storekit; sourceTree = "<group>"; };
|
||||
1CB4D09F28787D8A00902A56 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.5.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; };
|
||||
1CD90AF5278C7DE0001C4FEA /* iFeels.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iFeels.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -83,6 +103,9 @@
|
||||
1CD90B6D278C7F89001C4FEA /* FeelsWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FeelsWidgetExtension.entitlements; sourceTree = "<group>"; };
|
||||
1CD90B6F278C8000001C4FEA /* FeelsWidgetExtensionDev.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = FeelsWidgetExtensionDev.entitlements; sourceTree = "<group>"; };
|
||||
1CD90B70278C8000001C4FEA /* Feels (iOS)Dev.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = "Feels (iOS)Dev.entitlements"; sourceTree = "<group>"; };
|
||||
1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Feels Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B60015D02A064FF582E232FD /* Feels Watch App/Feels Watch AppDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch AppDebug.entitlements"; sourceTree = "<group>"; };
|
||||
B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App/Feels Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch App.entitlements"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -115,11 +138,21 @@
|
||||
);
|
||||
target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */;
|
||||
};
|
||||
2166CE8AA7264FC2B4BFAAAC /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Models/Mood.swift,
|
||||
Models/MoodEntryModel.swift,
|
||||
Random.swift,
|
||||
);
|
||||
target = B1DB9E6543DE4A009DB00916 /* Feels Watch App */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
1C00073D2EE9388A009C9ED5 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (1C000C162EE93AE3009C9ED5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = "<group>"; };
|
||||
1C00073D2EE9388A009C9ED5 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2166CE8AA7264FC2B4BFAAAC /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 1C000C162EE93AE3009C9ED5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = "<group>"; };
|
||||
1C0009922EE938FC009C9ED5 /* FeelsWidget2 */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = FeelsWidget2; sourceTree = "<group>"; };
|
||||
579031D619ED4B989145EEB1 /* Feels Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Feels Watch App"; sourceTree = "<group>"; };
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -165,19 +198,30 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
28189547ACED4EA2B5842F91 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
46F07FA9D330456697C9AC29 /* WidgetKit.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
1CD90AE5278C7DDF001C4FEA = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App/Feels Watch App.entitlements */,
|
||||
B60015D02A064FF582E232FD /* Feels Watch App/Feels Watch AppDebug.entitlements */,
|
||||
1CB4D09E28787B3C00902A56 /* Configuration.storekit */,
|
||||
1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */,
|
||||
1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */,
|
||||
1CD90B6A278C7F75001C4FEA /* Feels (iOS).entitlements */,
|
||||
1CD90B70278C8000001C4FEA /* Feels (iOS)Dev.entitlements */,
|
||||
1CD90B6D278C7F89001C4FEA /* FeelsWidgetExtension.entitlements */,
|
||||
1CD90B6F278C8000001C4FEA /* FeelsWidgetExtensionDev.entitlements */,
|
||||
1CD90B69278C7F65001C4FEA /* Feels--iOS--Info.plist */,
|
||||
579031D619ED4B989145EEB1 /* Feels Watch App */,
|
||||
1C00073D2EE9388A009C9ED5 /* Shared */,
|
||||
1C0009922EE938FC009C9ED5 /* FeelsWidget2 */,
|
||||
1CD90AFC278C7DE0001C4FEA /* macOS */,
|
||||
@@ -191,6 +235,7 @@
|
||||
1CD90AF6278C7DE0001C4FEA /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */,
|
||||
1CD90AF5278C7DE0001C4FEA /* iFeels.app */,
|
||||
1CD90AFB278C7DE0001C4FEA /* Feels.app */,
|
||||
1CD90B02278C7DE0001C4FEA /* Tests iOS.xctest */,
|
||||
@@ -248,11 +293,13 @@
|
||||
1CD90AF2278C7DE0001C4FEA /* Frameworks */,
|
||||
1CD90AF3278C7DE0001C4FEA /* Resources */,
|
||||
1CD90B5A278C7E7A001C4FEA /* Embed Foundation Extensions */,
|
||||
87A714924E734CD8948F0CD0 /* Embed Watch Content */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
1CD90B55278C7E7A001C4FEA /* PBXTargetDependency */,
|
||||
CB28ED3402234638800683C9 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
1C00073D2EE9388A009C9ED5 /* Shared */,
|
||||
@@ -341,6 +388,28 @@
|
||||
productReference = 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
B1DB9E6543DE4A009DB00916 /* Feels Watch App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 1B7D3790BF564C5392D480B2 /* Build configuration list for PBXNativeTarget "Feels Watch App" */;
|
||||
buildPhases = (
|
||||
0C4FBA03AAF5412783DD72AF /* Sources */,
|
||||
28189547ACED4EA2B5842F91 /* Frameworks */,
|
||||
05596FBF3C384AC4A2DC09B9 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
579031D619ED4B989145EEB1 /* Feels Watch App */,
|
||||
);
|
||||
name = "Feels Watch App";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "Feels Watch App";
|
||||
productReference = 1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -368,6 +437,9 @@
|
||||
1CD90B44278C7E7A001C4FEA = {
|
||||
CreatedOnToolsVersion = 13.2.1;
|
||||
};
|
||||
B1DB9E6543DE4A009DB00916 = {
|
||||
CreatedOnToolsVersion = 15.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 1CD90AE9278C7DDF001C4FEA /* Build configuration list for PBXProject "Feels" */;
|
||||
@@ -394,6 +466,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
1CD90AF4278C7DE0001C4FEA /* Feels (iOS) */,
|
||||
B1DB9E6543DE4A009DB00916 /* Feels Watch App */,
|
||||
1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */,
|
||||
1CD90AFA278C7DE0001C4FEA /* Feels (macOS) */,
|
||||
1CD90B01278C7DE0001C4FEA /* Tests iOS */,
|
||||
@@ -403,11 +476,18 @@
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
05596FBF3C384AC4A2DC09B9 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1CD90AF3278C7DE0001C4FEA /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1C0DAB51279DB0FB003B1F21 /* Localizable.xcstrings in Resources */,
|
||||
1C0DAB51279DB0FB003B1F21 /* Feels/Localizable.xcstrings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -436,13 +516,20 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1C0DAB52279DB0FB003B1F22 /* Localizable.xcstrings in Resources */,
|
||||
1C0DAB52279DB0FB003B1F22 /* Feels/Localizable.xcstrings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
0C4FBA03AAF5412783DD72AF /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1CD90AF1278C7DE0001C4FEA /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -500,11 +587,44 @@
|
||||
target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */;
|
||||
targetProxy = 1CD90B54278C7E7A001C4FEA /* PBXContainerItemProxy */;
|
||||
};
|
||||
CB28ED3402234638800683C9 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = B1DB9E6543DE4A009DB00916 /* Feels Watch App */;
|
||||
targetProxy = 51F6DCE106234B68B4F88529 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
1AA0E790DCE44476924A23BB /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Feels Watch App/Feels Watch AppDebug.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Feels;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.tt.ifeelDebug;
|
||||
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.ifeelDebug.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1CD90B20278C7DE0001C4FEA /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -899,9 +1019,49 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
67FBFEE92D1D4F8BBFBF7B1D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Feels Watch App/Feels Watch App.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Feels;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.tt.ifeel;
|
||||
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.tt.ifeel.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
1B7D3790BF564C5392D480B2 /* Build configuration list for PBXNativeTarget "Feels Watch App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1AA0E790DCE44476924A23BB /* Debug */,
|
||||
67FBFEE92D1D4F8BBFBF7B1D /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
1CD90AE9278C7DDF001C4FEA /* Build configuration list for PBXProject "Feels" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
@@ -14,7 +14,9 @@ import AppIntents
|
||||
struct VoteMoodIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Vote Mood"
|
||||
static var description = IntentDescription("Record your mood for today")
|
||||
static var openAppWhenRun: Bool { false }
|
||||
|
||||
// Run in main app process - enables full MoodLogger with watch sync
|
||||
static var openAppWhenRun: Bool { true }
|
||||
|
||||
@Parameter(title: "Mood")
|
||||
var moodValue: Int
|
||||
@@ -32,30 +34,23 @@ struct VoteMoodIntent: AppIntent {
|
||||
let mood = Mood(rawValue: moodValue) ?? .average
|
||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
|
||||
|
||||
// Widget uses simplified mood logging since it can't access HealthKitManager/TipsManager
|
||||
// Full side effects (HealthKit sync, TipKit) will run when main app opens via MoodLogger
|
||||
// This code runs in the main app process (openAppWhenRun = true)
|
||||
// Use conditional compilation for widget extension to compile
|
||||
#if !WIDGET_EXTENSION
|
||||
// Main app: use MoodLogger for all side effects including watch sync
|
||||
MoodLogger.shared.logMood(mood, for: votingDate, entryType: .widget)
|
||||
#else
|
||||
// Widget extension compilation path (never executed at runtime)
|
||||
WidgetDataProvider.shared.add(mood: mood, forDate: votingDate, entryType: .widget)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
#endif
|
||||
|
||||
// Store last voted date
|
||||
let dateString = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: votingDate))
|
||||
GroupUserDefaults.groupDefaults.set(dateString, forKey: UserDefaultsStore.Keys.lastVotedDate.rawValue)
|
||||
|
||||
// Update Live Activity
|
||||
let streak = calculateCurrentStreak()
|
||||
LiveActivityManager.shared.updateActivity(streak: streak, mood: mood)
|
||||
LiveActivityScheduler.shared.scheduleForNextDay()
|
||||
|
||||
// Reload widget timeline
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: "FeelsVoteWidget")
|
||||
|
||||
return .result()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func calculateCurrentStreak() -> Int {
|
||||
// Use WidgetDataProvider for read operations
|
||||
return WidgetDataProvider.shared.getCurrentStreak()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Vote Widget Provider
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import WidgetKit
|
||||
import os.log
|
||||
|
||||
/// Lightweight read-only data provider for widgets
|
||||
@@ -182,6 +183,17 @@ final class WidgetDataProvider {
|
||||
)
|
||||
|
||||
modelContext.insert(entry)
|
||||
try? modelContext.save()
|
||||
|
||||
do {
|
||||
try modelContext.save()
|
||||
|
||||
// Refresh all widgets immediately
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
|
||||
// Note: WatchConnectivity is not available in widget extensions
|
||||
// The watch will pick up the data on its next timeline refresh
|
||||
} catch {
|
||||
// Silently fail for widget context
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ struct FeelsApp: App {
|
||||
|
||||
// Initialize Live Activity scheduler
|
||||
LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
|
||||
|
||||
// Initialize Watch Connectivity for cross-device widget updates
|
||||
_ = WatchConnectivityManager.shared
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
@@ -58,23 +58,24 @@ enum Mood: Int {
|
||||
}
|
||||
}
|
||||
|
||||
static var allValues: [Mood] {
|
||||
return [Mood.horrible, Mood.bad, Mood.average, Mood.good, Mood.great].reversed()
|
||||
}
|
||||
|
||||
#if !os(watchOS)
|
||||
var color: Color {
|
||||
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
||||
return moodTint.color(forMood: self)
|
||||
}
|
||||
|
||||
static var allValues: [Mood] {
|
||||
return [Mood.horrible, Mood.bad, Mood.average, Mood.good, Mood.great].reversed()
|
||||
}
|
||||
|
||||
|
||||
var icon: Image {
|
||||
let moodImages: MoodImagable.Type = UserDefaultsStore.moodMoodImagable()
|
||||
return moodImages.icon(forMood: self)
|
||||
}
|
||||
|
||||
|
||||
var graphic: Image {
|
||||
switch self {
|
||||
|
||||
|
||||
case .horrible:
|
||||
return Image("HorribleGraphic", bundle: .main)
|
||||
case .bad:
|
||||
@@ -91,6 +92,7 @@ enum Mood: Int {
|
||||
return Image("MissingGraphic", bundle: .main)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
extension Mood: Identifiable {
|
||||
|
||||
@@ -27,16 +27,16 @@ enum EntryType: Int, Codable {
|
||||
|
||||
@Model
|
||||
final class MoodEntryModel {
|
||||
// Primary attributes
|
||||
var forDate: Date
|
||||
var moodValue: Int
|
||||
var timestamp: Date
|
||||
var weekDay: Int
|
||||
var entryType: Int
|
||||
// Primary attributes - CloudKit requires default values
|
||||
var forDate: Date = Date()
|
||||
var moodValue: Int = 0
|
||||
var timestamp: Date = Date()
|
||||
var weekDay: Int = 1
|
||||
var entryType: Int = 0
|
||||
|
||||
// Metadata
|
||||
var canEdit: Bool
|
||||
var canDelete: Bool
|
||||
// Metadata - CloudKit requires default values
|
||||
var canEdit: Bool = true
|
||||
var canDelete: Bool = true
|
||||
|
||||
// Journal & Media (NEW)
|
||||
var notes: String?
|
||||
|
||||
@@ -66,6 +66,9 @@ final class MoodLogger {
|
||||
|
||||
// 7. Reload widgets
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
|
||||
// 8. Notify watch to refresh complications
|
||||
WatchConnectivityManager.shared.notifyWatchToReload()
|
||||
}
|
||||
|
||||
/// Calculate the current mood streak
|
||||
|
||||
@@ -79,6 +79,7 @@ class Random {
|
||||
return newValue
|
||||
}
|
||||
|
||||
#if !os(watchOS)
|
||||
static func createTotalPerc(fromEntries entries: [MoodEntryModel]) -> [MoodMetrics] {
|
||||
let filteredEntries = entries.filter({
|
||||
return ![.missing, .placeholder].contains($0.mood)
|
||||
@@ -100,13 +101,15 @@ class Random {
|
||||
|
||||
return returnData
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(watchOS)
|
||||
struct RoundedCorner: Shape {
|
||||
|
||||
|
||||
var radius: CGFloat = .infinity
|
||||
var corners: UIRectCorner = .allCorners
|
||||
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
|
||||
return Path(path.cgPath)
|
||||
@@ -117,7 +120,7 @@ extension View {
|
||||
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||||
clipShape( RoundedCorner(radius: radius, corners: corners) )
|
||||
}
|
||||
|
||||
|
||||
func snapshot() -> UIImage {
|
||||
let controller = UIHostingController(rootView: self)
|
||||
let view = controller.view
|
||||
@@ -129,7 +132,7 @@ extension View {
|
||||
view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func asImage(size: CGSize) -> UIImage {
|
||||
let controller = UIHostingController(rootView: self)
|
||||
controller.view.bounds = CGRect(origin: .zero, size: size)
|
||||
@@ -156,7 +159,7 @@ extension Color {
|
||||
blue: .random(in: 0...1)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
public func lighter(by amount: CGFloat = 0.2) -> Self { Self(UIColor(self).lighter(by: amount)) }
|
||||
public func darker(by amount: CGFloat = 0.2) -> Self { Self(UIColor(self).darker(by: amount)) }
|
||||
}
|
||||
@@ -167,34 +170,34 @@ extension String {
|
||||
let font = UIFont.systemFont(ofSize: 100) // you can change your font size here
|
||||
let stringAttributes = [NSAttributedString.Key.font: font]
|
||||
let imageSize = nsString.size(withAttributes: stringAttributes)
|
||||
|
||||
|
||||
UIGraphicsBeginImageContextWithOptions(imageSize, false, 0) // begin image context
|
||||
UIColor.clear.set() // clear background
|
||||
UIRectFill(CGRect(origin: CGPoint(), size: imageSize)) // set rect size
|
||||
nsString.draw(at: CGPoint.zero, withAttributes: stringAttributes) // draw text within rect
|
||||
let image = UIGraphicsGetImageFromCurrentImageContext() // create image from context
|
||||
UIGraphicsEndImageContext() // end image context
|
||||
|
||||
|
||||
return image ?? UIImage()
|
||||
}
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
|
||||
|
||||
func lighter(by percentage: CGFloat = 10.0) -> UIColor {
|
||||
return self.adjust(by: abs(percentage))
|
||||
}
|
||||
|
||||
|
||||
func darker(by percentage: CGFloat = 10.0) -> UIColor {
|
||||
return self.adjust(by: -abs(percentage))
|
||||
}
|
||||
|
||||
|
||||
func adjust(by percentage: CGFloat) -> UIColor {
|
||||
var alpha, hue, saturation, brightness, red, green, blue, white : CGFloat
|
||||
(alpha, hue, saturation, brightness, red, green, blue, white) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
let multiplier = percentage / 100.0
|
||||
|
||||
|
||||
if self.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) {
|
||||
let newBrightness: CGFloat = max(min(brightness + multiplier*brightness, 1.0), 0.0)
|
||||
return UIColor(hue: hue, saturation: saturation, brightness: newBrightness, alpha: alpha)
|
||||
@@ -209,10 +212,11 @@ extension UIColor {
|
||||
let newWhite: CGFloat = (white + multiplier*white)
|
||||
return UIColor(white: newWhite, alpha: alpha)
|
||||
}
|
||||
|
||||
|
||||
return self
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
extension Bundle {
|
||||
var appName: String {
|
||||
|
||||
@@ -125,7 +125,7 @@ final class ReviewRequestManager {
|
||||
}
|
||||
|
||||
// Request the review - iOS decides whether to actually show it
|
||||
SKStoreReviewController.requestReview(in: windowScene)
|
||||
AppStore.requestReview(in: windowScene)
|
||||
}
|
||||
|
||||
// MARK: - Debug / Testing
|
||||
|
||||
166
Shared/Services/WatchConnectivityManager.swift
Normal file
166
Shared/Services/WatchConnectivityManager.swift
Normal file
@@ -0,0 +1,166 @@
|
||||
//
|
||||
// WatchConnectivityManager.swift
|
||||
// Feels
|
||||
//
|
||||
// Central coordinator for Watch Connectivity.
|
||||
// iOS app is the hub - all mood logging flows through here.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
import WidgetKit
|
||||
import os.log
|
||||
|
||||
/// Manages Watch Connectivity between iOS and watchOS
|
||||
/// iOS app acts as the central coordinator for all mood logging
|
||||
final class WatchConnectivityManager: NSObject, ObservableObject {
|
||||
|
||||
static let shared = WatchConnectivityManager()
|
||||
|
||||
private static let logger = Logger(subsystem: "com.tt.ifeel", category: "WatchConnectivity")
|
||||
|
||||
private var session: WCSession?
|
||||
|
||||
/// Whether the paired device is currently reachable for immediate messaging
|
||||
var isReachable: Bool {
|
||||
session?.isReachable ?? false
|
||||
}
|
||||
|
||||
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 on this device")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iOS → Watch
|
||||
|
||||
#if os(iOS)
|
||||
/// Notify watch to reload its complications
|
||||
func notifyWatchToReload() {
|
||||
guard let session = session,
|
||||
session.activationState == .activated,
|
||||
session.isWatchAppInstalled else {
|
||||
return
|
||||
}
|
||||
|
||||
let message = ["action": "reloadWidgets"]
|
||||
session.transferUserInfo(message)
|
||||
Self.logger.info("Sent reload notification to watch")
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Watch → iOS
|
||||
|
||||
#if os(watchOS)
|
||||
/// Send mood to iOS app for centralized logging
|
||||
/// Returns true if message was sent, false if fallback to local storage is needed
|
||||
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 for logging")
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// 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 activation completed: \(activationState.rawValue)")
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {
|
||||
Self.logger.info("WCSession became inactive")
|
||||
}
|
||||
|
||||
func sessionDidDeactivate(_ session: WCSession) {
|
||||
Self.logger.info("WCSession deactivated, reactivating...")
|
||||
session.activate()
|
||||
}
|
||||
|
||||
// iOS receives mood from watch and logs it centrally
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
handleReceivedMessage(userInfo)
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
handleReceivedMessage(message)
|
||||
}
|
||||
|
||||
private func handleReceivedMessage(_ message: [String: Any]) {
|
||||
guard let action = message["action"] as? String else { return }
|
||||
|
||||
switch action {
|
||||
case "logMood":
|
||||
guard let moodRaw = message["mood"] as? Int,
|
||||
let mood = Mood(rawValue: moodRaw),
|
||||
let timestamp = message["date"] as? TimeInterval else {
|
||||
Self.logger.error("Invalid mood message format")
|
||||
return
|
||||
}
|
||||
|
||||
let date = Date(timeIntervalSince1970: timestamp)
|
||||
Self.logger.info("Received mood \(moodRaw) from watch, logging centrally")
|
||||
|
||||
Task { @MainActor in
|
||||
// Use MoodLogger for centralized logging with all side effects
|
||||
MoodLogger.shared.logMood(mood, for: date, entryType: .watch)
|
||||
}
|
||||
|
||||
case "reloadWidgets":
|
||||
Task { @MainActor in
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(watchOS)
|
||||
// Watch receives 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user