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 = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
1C0DAB51279DB0FB003B1F21 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */; };
|
1C0DAB51279DB0FB003B1F21 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; };
|
||||||
1C0DAB52279DB0FB003B1F22 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */; };
|
1C0DAB52279DB0FB003B1F22 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; };
|
||||||
1C2618FA2795E41D00FDC148 /* Charts in Frameworks */ = {isa = PBXBuildFile; productRef = 1C2618F92795E41D00FDC148 /* Charts */; };
|
1C2618FA2795E41D00FDC148 /* Charts in Frameworks */ = {isa = PBXBuildFile; productRef = 1C2618F92795E41D00FDC148 /* Charts */; };
|
||||||
1C747CC9279F06EB00762CBD /* CloudKitSyncMonitor in Frameworks */ = {isa = PBXBuildFile; productRef = 1C747CC8279F06EB00762CBD /* CloudKitSyncMonitor */; };
|
1C747CC9279F06EB00762CBD /* CloudKitSyncMonitor in Frameworks */ = {isa = PBXBuildFile; productRef = 1C747CC8279F06EB00762CBD /* CloudKitSyncMonitor */; };
|
||||||
1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB4D09F28787D8A00902A56 /* StoreKit.framework */; };
|
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, ); }; };
|
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 */; };
|
1CD90B6C278C7F78001C4FEA /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B6B278C7F78001C4FEA /* CloudKit.framework */; };
|
||||||
1CD90B6E278C7F8B001C4FEA /* 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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -45,6 +47,13 @@
|
|||||||
remoteGlobalIDString = 1CD90B44278C7E7A001C4FEA;
|
remoteGlobalIDString = 1CD90B44278C7E7A001C4FEA;
|
||||||
remoteInfo = FeelsWidgetExtension;
|
remoteInfo = FeelsWidgetExtension;
|
||||||
};
|
};
|
||||||
|
51F6DCE106234B68B4F88529 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 1CD90AE6278C7DDF001C4FEA /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = B1DB9E6543DE4A009DB00916;
|
||||||
|
remoteInfo = "Feels Watch App";
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@@ -59,10 +68,21 @@
|
|||||||
name = "Embed Foundation Extensions";
|
name = "Embed Foundation Extensions";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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 */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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>"; };
|
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; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -115,11 +138,21 @@
|
|||||||
);
|
);
|
||||||
target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */;
|
target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */;
|
||||||
};
|
};
|
||||||
|
2166CE8AA7264FC2B4BFAAAC /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Models/Mood.swift,
|
||||||
|
Models/MoodEntryModel.swift,
|
||||||
|
Random.swift,
|
||||||
|
);
|
||||||
|
target = B1DB9E6543DE4A009DB00916 /* Feels Watch App */;
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup 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>"; };
|
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 */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -165,19 +198,30 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
28189547ACED4EA2B5842F91 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
46F07FA9D330456697C9AC29 /* WidgetKit.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
1CD90AE5278C7DDF001C4FEA = {
|
1CD90AE5278C7DDF001C4FEA = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App/Feels Watch App.entitlements */,
|
||||||
|
B60015D02A064FF582E232FD /* Feels Watch App/Feels Watch AppDebug.entitlements */,
|
||||||
1CB4D09E28787B3C00902A56 /* Configuration.storekit */,
|
1CB4D09E28787B3C00902A56 /* Configuration.storekit */,
|
||||||
1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */,
|
1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */,
|
||||||
1CD90B6A278C7F75001C4FEA /* Feels (iOS).entitlements */,
|
1CD90B6A278C7F75001C4FEA /* Feels (iOS).entitlements */,
|
||||||
1CD90B70278C8000001C4FEA /* Feels (iOS)Dev.entitlements */,
|
1CD90B70278C8000001C4FEA /* Feels (iOS)Dev.entitlements */,
|
||||||
1CD90B6D278C7F89001C4FEA /* FeelsWidgetExtension.entitlements */,
|
1CD90B6D278C7F89001C4FEA /* FeelsWidgetExtension.entitlements */,
|
||||||
1CD90B6F278C8000001C4FEA /* FeelsWidgetExtensionDev.entitlements */,
|
1CD90B6F278C8000001C4FEA /* FeelsWidgetExtensionDev.entitlements */,
|
||||||
1CD90B69278C7F65001C4FEA /* Feels--iOS--Info.plist */,
|
1CD90B69278C7F65001C4FEA /* Feels--iOS--Info.plist */,
|
||||||
|
579031D619ED4B989145EEB1 /* Feels Watch App */,
|
||||||
1C00073D2EE9388A009C9ED5 /* Shared */,
|
1C00073D2EE9388A009C9ED5 /* Shared */,
|
||||||
1C0009922EE938FC009C9ED5 /* FeelsWidget2 */,
|
1C0009922EE938FC009C9ED5 /* FeelsWidget2 */,
|
||||||
1CD90AFC278C7DE0001C4FEA /* macOS */,
|
1CD90AFC278C7DE0001C4FEA /* macOS */,
|
||||||
@@ -191,6 +235,7 @@
|
|||||||
1CD90AF6278C7DE0001C4FEA /* Products */ = {
|
1CD90AF6278C7DE0001C4FEA /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */,
|
||||||
1CD90AF5278C7DE0001C4FEA /* iFeels.app */,
|
1CD90AF5278C7DE0001C4FEA /* iFeels.app */,
|
||||||
1CD90AFB278C7DE0001C4FEA /* Feels.app */,
|
1CD90AFB278C7DE0001C4FEA /* Feels.app */,
|
||||||
1CD90B02278C7DE0001C4FEA /* Tests iOS.xctest */,
|
1CD90B02278C7DE0001C4FEA /* Tests iOS.xctest */,
|
||||||
@@ -248,11 +293,13 @@
|
|||||||
1CD90AF2278C7DE0001C4FEA /* Frameworks */,
|
1CD90AF2278C7DE0001C4FEA /* Frameworks */,
|
||||||
1CD90AF3278C7DE0001C4FEA /* Resources */,
|
1CD90AF3278C7DE0001C4FEA /* Resources */,
|
||||||
1CD90B5A278C7E7A001C4FEA /* Embed Foundation Extensions */,
|
1CD90B5A278C7E7A001C4FEA /* Embed Foundation Extensions */,
|
||||||
|
87A714924E734CD8948F0CD0 /* Embed Watch Content */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
1CD90B55278C7E7A001C4FEA /* PBXTargetDependency */,
|
1CD90B55278C7E7A001C4FEA /* PBXTargetDependency */,
|
||||||
|
CB28ED3402234638800683C9 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
1C00073D2EE9388A009C9ED5 /* Shared */,
|
1C00073D2EE9388A009C9ED5 /* Shared */,
|
||||||
@@ -341,6 +388,28 @@
|
|||||||
productReference = 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */;
|
productReference = 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */;
|
||||||
productType = "com.apple.product-type.app-extension";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -368,6 +437,9 @@
|
|||||||
1CD90B44278C7E7A001C4FEA = {
|
1CD90B44278C7E7A001C4FEA = {
|
||||||
CreatedOnToolsVersion = 13.2.1;
|
CreatedOnToolsVersion = 13.2.1;
|
||||||
};
|
};
|
||||||
|
B1DB9E6543DE4A009DB00916 = {
|
||||||
|
CreatedOnToolsVersion = 15.0;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 1CD90AE9278C7DDF001C4FEA /* Build configuration list for PBXProject "Feels" */;
|
buildConfigurationList = 1CD90AE9278C7DDF001C4FEA /* Build configuration list for PBXProject "Feels" */;
|
||||||
@@ -394,6 +466,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
1CD90AF4278C7DE0001C4FEA /* Feels (iOS) */,
|
1CD90AF4278C7DE0001C4FEA /* Feels (iOS) */,
|
||||||
|
B1DB9E6543DE4A009DB00916 /* Feels Watch App */,
|
||||||
1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */,
|
1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */,
|
||||||
1CD90AFA278C7DE0001C4FEA /* Feels (macOS) */,
|
1CD90AFA278C7DE0001C4FEA /* Feels (macOS) */,
|
||||||
1CD90B01278C7DE0001C4FEA /* Tests iOS */,
|
1CD90B01278C7DE0001C4FEA /* Tests iOS */,
|
||||||
@@ -403,11 +476,18 @@
|
|||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
05596FBF3C384AC4A2DC09B9 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
1CD90AF3278C7DE0001C4FEA /* Resources */ = {
|
1CD90AF3278C7DE0001C4FEA /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
1C0DAB51279DB0FB003B1F21 /* Localizable.xcstrings in Resources */,
|
1C0DAB51279DB0FB003B1F21 /* Feels/Localizable.xcstrings in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -436,13 +516,20 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
1C0DAB52279DB0FB003B1F22 /* Localizable.xcstrings in Resources */,
|
1C0DAB52279DB0FB003B1F22 /* Feels/Localizable.xcstrings in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
0C4FBA03AAF5412783DD72AF /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
1CD90AF1278C7DE0001C4FEA /* Sources */ = {
|
1CD90AF1278C7DE0001C4FEA /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -500,11 +587,44 @@
|
|||||||
target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */;
|
target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */;
|
||||||
targetProxy = 1CD90B54278C7E7A001C4FEA /* PBXContainerItemProxy */;
|
targetProxy = 1CD90B54278C7E7A001C4FEA /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
CB28ED3402234638800683C9 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = B1DB9E6543DE4A009DB00916 /* Feels Watch App */;
|
||||||
|
targetProxy = 51F6DCE106234B68B4F88529 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration 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 */ = {
|
1CD90B20278C7DE0001C4FEA /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -899,9 +1019,49 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList 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" */ = {
|
1CD90AE9278C7DDF001C4FEA /* Build configuration list for PBXProject "Feels" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import AppIntents
|
|||||||
struct VoteMoodIntent: AppIntent {
|
struct VoteMoodIntent: AppIntent {
|
||||||
static var title: LocalizedStringResource = "Vote Mood"
|
static var title: LocalizedStringResource = "Vote Mood"
|
||||||
static var description = IntentDescription("Record your mood for today")
|
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")
|
@Parameter(title: "Mood")
|
||||||
var moodValue: Int
|
var moodValue: Int
|
||||||
@@ -32,30 +34,23 @@ struct VoteMoodIntent: AppIntent {
|
|||||||
let mood = Mood(rawValue: moodValue) ?? .average
|
let mood = Mood(rawValue: moodValue) ?? .average
|
||||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
|
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
|
||||||
|
|
||||||
// Widget uses simplified mood logging since it can't access HealthKitManager/TipsManager
|
// This code runs in the main app process (openAppWhenRun = true)
|
||||||
// Full side effects (HealthKit sync, TipKit) will run when main app opens via MoodLogger
|
// 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)
|
WidgetDataProvider.shared.add(mood: mood, forDate: votingDate, entryType: .widget)
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
#endif
|
||||||
|
|
||||||
// Store last voted date
|
// Store last voted date
|
||||||
let dateString = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: votingDate))
|
let dateString = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: votingDate))
|
||||||
GroupUserDefaults.groupDefaults.set(dateString, forKey: UserDefaultsStore.Keys.lastVotedDate.rawValue)
|
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()
|
return .result()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func calculateCurrentStreak() -> Int {
|
|
||||||
// Use WidgetDataProvider for read operations
|
|
||||||
return WidgetDataProvider.shared.getCurrentStreak()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Vote Widget Provider
|
// MARK: - Vote Widget Provider
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import WidgetKit
|
||||||
import os.log
|
import os.log
|
||||||
|
|
||||||
/// Lightweight read-only data provider for widgets
|
/// Lightweight read-only data provider for widgets
|
||||||
@@ -182,6 +183,17 @@ final class WidgetDataProvider {
|
|||||||
)
|
)
|
||||||
|
|
||||||
modelContext.insert(entry)
|
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
|
// Initialize Live Activity scheduler
|
||||||
LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
|
LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
|
||||||
|
|
||||||
|
// Initialize Watch Connectivity for cross-device widget updates
|
||||||
|
_ = WatchConnectivityManager.shared
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
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 {
|
var color: Color {
|
||||||
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
||||||
return moodTint.color(forMood: self)
|
return moodTint.color(forMood: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var allValues: [Mood] {
|
|
||||||
return [Mood.horrible, Mood.bad, Mood.average, Mood.good, Mood.great].reversed()
|
|
||||||
}
|
|
||||||
|
|
||||||
var icon: Image {
|
var icon: Image {
|
||||||
let moodImages: MoodImagable.Type = UserDefaultsStore.moodMoodImagable()
|
let moodImages: MoodImagable.Type = UserDefaultsStore.moodMoodImagable()
|
||||||
return moodImages.icon(forMood: self)
|
return moodImages.icon(forMood: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
var graphic: Image {
|
var graphic: Image {
|
||||||
switch self {
|
switch self {
|
||||||
|
|
||||||
case .horrible:
|
case .horrible:
|
||||||
return Image("HorribleGraphic", bundle: .main)
|
return Image("HorribleGraphic", bundle: .main)
|
||||||
case .bad:
|
case .bad:
|
||||||
@@ -91,6 +92,7 @@ enum Mood: Int {
|
|||||||
return Image("MissingGraphic", bundle: .main)
|
return Image("MissingGraphic", bundle: .main)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Mood: Identifiable {
|
extension Mood: Identifiable {
|
||||||
|
|||||||
@@ -27,16 +27,16 @@ enum EntryType: Int, Codable {
|
|||||||
|
|
||||||
@Model
|
@Model
|
||||||
final class MoodEntryModel {
|
final class MoodEntryModel {
|
||||||
// Primary attributes
|
// Primary attributes - CloudKit requires default values
|
||||||
var forDate: Date
|
var forDate: Date = Date()
|
||||||
var moodValue: Int
|
var moodValue: Int = 0
|
||||||
var timestamp: Date
|
var timestamp: Date = Date()
|
||||||
var weekDay: Int
|
var weekDay: Int = 1
|
||||||
var entryType: Int
|
var entryType: Int = 0
|
||||||
|
|
||||||
// Metadata
|
// Metadata - CloudKit requires default values
|
||||||
var canEdit: Bool
|
var canEdit: Bool = true
|
||||||
var canDelete: Bool
|
var canDelete: Bool = true
|
||||||
|
|
||||||
// Journal & Media (NEW)
|
// Journal & Media (NEW)
|
||||||
var notes: String?
|
var notes: String?
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ final class MoodLogger {
|
|||||||
|
|
||||||
// 7. Reload widgets
|
// 7. Reload widgets
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
|
||||||
|
// 8. Notify watch to refresh complications
|
||||||
|
WatchConnectivityManager.shared.notifyWatchToReload()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate the current mood streak
|
/// Calculate the current mood streak
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ class Random {
|
|||||||
return newValue
|
return newValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !os(watchOS)
|
||||||
static func createTotalPerc(fromEntries entries: [MoodEntryModel]) -> [MoodMetrics] {
|
static func createTotalPerc(fromEntries entries: [MoodEntryModel]) -> [MoodMetrics] {
|
||||||
let filteredEntries = entries.filter({
|
let filteredEntries = entries.filter({
|
||||||
return ![.missing, .placeholder].contains($0.mood)
|
return ![.missing, .placeholder].contains($0.mood)
|
||||||
@@ -100,13 +101,15 @@ class Random {
|
|||||||
|
|
||||||
return returnData
|
return returnData
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !os(watchOS)
|
||||||
struct RoundedCorner: Shape {
|
struct RoundedCorner: Shape {
|
||||||
|
|
||||||
var radius: CGFloat = .infinity
|
var radius: CGFloat = .infinity
|
||||||
var corners: UIRectCorner = .allCorners
|
var corners: UIRectCorner = .allCorners
|
||||||
|
|
||||||
func path(in rect: CGRect) -> Path {
|
func path(in rect: CGRect) -> Path {
|
||||||
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
|
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
|
||||||
return Path(path.cgPath)
|
return Path(path.cgPath)
|
||||||
@@ -117,7 +120,7 @@ extension View {
|
|||||||
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||||||
clipShape( RoundedCorner(radius: radius, corners: corners) )
|
clipShape( RoundedCorner(radius: radius, corners: corners) )
|
||||||
}
|
}
|
||||||
|
|
||||||
func snapshot() -> UIImage {
|
func snapshot() -> UIImage {
|
||||||
let controller = UIHostingController(rootView: self)
|
let controller = UIHostingController(rootView: self)
|
||||||
let view = controller.view
|
let view = controller.view
|
||||||
@@ -129,7 +132,7 @@ extension View {
|
|||||||
view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
|
view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func asImage(size: CGSize) -> UIImage {
|
func asImage(size: CGSize) -> UIImage {
|
||||||
let controller = UIHostingController(rootView: self)
|
let controller = UIHostingController(rootView: self)
|
||||||
controller.view.bounds = CGRect(origin: .zero, size: size)
|
controller.view.bounds = CGRect(origin: .zero, size: size)
|
||||||
@@ -156,7 +159,7 @@ extension Color {
|
|||||||
blue: .random(in: 0...1)
|
blue: .random(in: 0...1)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func lighter(by amount: CGFloat = 0.2) -> Self { Self(UIColor(self).lighter(by: amount)) }
|
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)) }
|
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 font = UIFont.systemFont(ofSize: 100) // you can change your font size here
|
||||||
let stringAttributes = [NSAttributedString.Key.font: font]
|
let stringAttributes = [NSAttributedString.Key.font: font]
|
||||||
let imageSize = nsString.size(withAttributes: stringAttributes)
|
let imageSize = nsString.size(withAttributes: stringAttributes)
|
||||||
|
|
||||||
UIGraphicsBeginImageContextWithOptions(imageSize, false, 0) // begin image context
|
UIGraphicsBeginImageContextWithOptions(imageSize, false, 0) // begin image context
|
||||||
UIColor.clear.set() // clear background
|
UIColor.clear.set() // clear background
|
||||||
UIRectFill(CGRect(origin: CGPoint(), size: imageSize)) // set rect size
|
UIRectFill(CGRect(origin: CGPoint(), size: imageSize)) // set rect size
|
||||||
nsString.draw(at: CGPoint.zero, withAttributes: stringAttributes) // draw text within rect
|
nsString.draw(at: CGPoint.zero, withAttributes: stringAttributes) // draw text within rect
|
||||||
let image = UIGraphicsGetImageFromCurrentImageContext() // create image from context
|
let image = UIGraphicsGetImageFromCurrentImageContext() // create image from context
|
||||||
UIGraphicsEndImageContext() // end image context
|
UIGraphicsEndImageContext() // end image context
|
||||||
|
|
||||||
return image ?? UIImage()
|
return image ?? UIImage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension UIColor {
|
extension UIColor {
|
||||||
|
|
||||||
func lighter(by percentage: CGFloat = 10.0) -> UIColor {
|
func lighter(by percentage: CGFloat = 10.0) -> UIColor {
|
||||||
return self.adjust(by: abs(percentage))
|
return self.adjust(by: abs(percentage))
|
||||||
}
|
}
|
||||||
|
|
||||||
func darker(by percentage: CGFloat = 10.0) -> UIColor {
|
func darker(by percentage: CGFloat = 10.0) -> UIColor {
|
||||||
return self.adjust(by: -abs(percentage))
|
return self.adjust(by: -abs(percentage))
|
||||||
}
|
}
|
||||||
|
|
||||||
func adjust(by percentage: CGFloat) -> UIColor {
|
func adjust(by percentage: CGFloat) -> UIColor {
|
||||||
var alpha, hue, saturation, brightness, red, green, blue, white : CGFloat
|
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)
|
(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
|
let multiplier = percentage / 100.0
|
||||||
|
|
||||||
if self.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) {
|
if self.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) {
|
||||||
let newBrightness: CGFloat = max(min(brightness + multiplier*brightness, 1.0), 0.0)
|
let newBrightness: CGFloat = max(min(brightness + multiplier*brightness, 1.0), 0.0)
|
||||||
return UIColor(hue: hue, saturation: saturation, brightness: newBrightness, alpha: alpha)
|
return UIColor(hue: hue, saturation: saturation, brightness: newBrightness, alpha: alpha)
|
||||||
@@ -209,10 +212,11 @@ extension UIColor {
|
|||||||
let newWhite: CGFloat = (white + multiplier*white)
|
let newWhite: CGFloat = (white + multiplier*white)
|
||||||
return UIColor(white: newWhite, alpha: alpha)
|
return UIColor(white: newWhite, alpha: alpha)
|
||||||
}
|
}
|
||||||
|
|
||||||
return self
|
return self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
extension Bundle {
|
extension Bundle {
|
||||||
var appName: String {
|
var appName: String {
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ final class ReviewRequestManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Request the review - iOS decides whether to actually show it
|
// Request the review - iOS decides whether to actually show it
|
||||||
SKStoreReviewController.requestReview(in: windowScene)
|
AppStore.requestReview(in: windowScene)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Debug / Testing
|
// 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