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:
Trey t
2025-12-21 17:19:17 -06:00
parent d902694cdd
commit 224c00423a
20 changed files with 1148 additions and 57 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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()
}

View 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>

View 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>

View 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))
}

View 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()
}
}
}

View 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()
}
}
}
}

View 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
}
}