From 82c23c6cc3e6b59770cbd2d0a6d025683a4e5646 Mon Sep 17 00:00:00 2001 From: Ryan Casler Date: Sun, 4 May 2025 15:46:22 -0500 Subject: [PATCH 1/4] Add Dynamic Island support with Live Activities for fasting timer --- FastPath/FastPathApp.swift | 11 + FastPath/Features/FastingFeature.swift | 79 ++++++ .../FastingActivityAttributes.swift | 38 +++ .../LiveActivity/FastingLiveActivity.swift | 224 ++++++++++++++++++ FastPath/Services/LiveActivityService.swift | 109 +++++++++ FastPath/Views/FastingView.swift | 15 +- ai_specs/DynamicIslandFeature.md | 67 ++++++ 7 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 FastPath/LiveActivity/FastingActivityAttributes.swift create mode 100644 FastPath/LiveActivity/FastingLiveActivity.swift create mode 100644 FastPath/Services/LiveActivityService.swift create mode 100644 ai_specs/DynamicIslandFeature.md diff --git a/FastPath/FastPathApp.swift b/FastPath/FastPathApp.swift index fd14575..fd601c7 100644 --- a/FastPath/FastPathApp.swift +++ b/FastPath/FastPathApp.swift @@ -7,6 +7,7 @@ import SwiftUI import ComposableArchitecture +import WidgetKit @main struct FastPathApp: App { @@ -15,6 +16,16 @@ struct FastPathApp: App { reducer: { AppFeature() } ) + init() { + // Register for Live Activities if available + #if canImport(ActivityKit) + if #available(iOS 16.1, *) { + // Ensure WidgetKit is aware of our Live Activity + WidgetCenter.shared.reloadAllTimelines() + } + #endif + } + var body: some Scene { WindowGroup { AppView(store: store) diff --git a/FastPath/Features/FastingFeature.swift b/FastPath/Features/FastingFeature.swift index 17d1d68..015d476 100644 --- a/FastPath/Features/FastingFeature.swift +++ b/FastPath/Features/FastingFeature.swift @@ -7,6 +7,7 @@ import Foundation import ComposableArchitecture +import ActivityKit @Reducer struct FastingFeature { @@ -19,6 +20,8 @@ struct FastingFeature { var currentElapsedTime: TimeInterval = 0 var fastingGoal: FastingGoal? var showingGoalPicker: Bool = false + var liveActivityEnabled: Bool = true + var hasActiveLiveActivity: Bool = false var isFasting: Bool { activeRecord != nil @@ -50,6 +53,7 @@ struct FastingFeature { case selectPredefinedGoal(FastingGoal) case setCustomGoal(TimeInterval) case clearGoal + case toggleLiveActivity(Bool) // Internal actions case timerTick @@ -61,6 +65,9 @@ struct FastingFeature { case recordDeleted(UUID) case fastingGoalLoaded(FastingGoal?) case fastingGoalSaved(FastingGoal?) + case startLiveActivity(FastingRecord, FastingGoal) + case updateLiveActivity(TimeInterval, TimeInterval, Bool) + case stopLiveActivity // Navigation case showHistory @@ -119,6 +126,11 @@ struct FastingFeature { try await databaseClient.save(newRecord) await send(.fastingRecordSaved(newRecord)) + // Start Live Activity if goal is set and Live Activities are enabled + if let goal = state.fastingGoal, state.liveActivityEnabled { + await send(.startLiveActivity(newRecord, goal)) + } + // Start timer for await _ in self.clock.timer(interval: .seconds(1)) { await send(.timerTick) @@ -134,6 +146,11 @@ struct FastingFeature { try await databaseClient.update(record) await send(.fastingStopped(record)) + // Stop Live Activity if one is active + if state.hasActiveLiveActivity { + await send(.stopLiveActivity) + } + // Refresh history let history = await databaseClient.getAllRecords() await send(.fastingHistoryLoaded(history)) @@ -157,6 +174,19 @@ struct FastingFeature { case .timerTick: guard let startTime = state.activeRecord?.startTime else { return .none } state.currentElapsedTime = Date().timeIntervalSince(startTime) + + // Update Live Activity if one is active and a goal is set + if state.hasActiveLiveActivity, let goal = state.fastingGoal { + let remainingTime = state.remainingTimeToGoal ?? 0 + let goalReached = state.hasReachedGoal + + return .send(.updateLiveActivity( + state.currentElapsedTime, + remainingTime, + goalReached + )) + } + return .none case .historyButtonTapped: @@ -217,6 +247,55 @@ struct FastingFeature { // Nothing to do here, state is already updated return .none + case .toggleLiveActivity(let enabled): + state.liveActivityEnabled = enabled + + // If disabling and there's an active Live Activity, stop it + if !enabled && state.hasActiveLiveActivity { + return .send(.stopLiveActivity) + } + + // If enabling and there's an active fast with a goal, start a Live Activity + if enabled, let record = state.activeRecord, let goal = state.fastingGoal { + return .send(.startLiveActivity(record, goal)) + } + + return .none + + case let .startLiveActivity(record, goal): + // Only proceed if Live Activities are enabled and supported + guard state.liveActivityEnabled, ActivityAuthorizationInfo().areActivitiesEnabled else { + return .none + } + + return .run { _ in + let success = LiveActivityService.shared.startFastingActivity( + fastId: record.id, + startTime: record.startTime, + goalDuration: goal.targetDuration, + goalName: goal.name + ) + + if success { + await MainActor.run { state.hasActiveLiveActivity = true } + } + } + + case let .updateLiveActivity(elapsedTime, remainingTime, goalReached): + return .run { _ in + LiveActivityService.shared.updateActivity( + elapsedTime: elapsedTime, + remainingTime: remainingTime, + goalReached: goalReached + ) + } + + case .stopLiveActivity: + state.hasActiveLiveActivity = false + return .run { _ in + LiveActivityService.shared.stopCurrentActivity() + } + case .showHistory: // This will be handled by the parent reducer for navigation return .none diff --git a/FastPath/LiveActivity/FastingActivityAttributes.swift b/FastPath/LiveActivity/FastingActivityAttributes.swift new file mode 100644 index 0000000..1108a82 --- /dev/null +++ b/FastPath/LiveActivity/FastingActivityAttributes.swift @@ -0,0 +1,38 @@ +// +// FastingActivityAttributes.swift +// FastPath +// +// Created on 5/4/25. +// + +import Foundation +import ActivityKit + +/// Defines the attributes for the fasting Live Activity +struct FastingActivityAttributes: ActivityAttributes { + public typealias FastingStatus = ContentState + + /// The unique identifier for the fasting session + let fastId: UUID + + /// The start time of the fast + let startTime: Date + + /// The target duration in seconds + let goalDuration: TimeInterval + + /// The name of the fasting goal (e.g., "16:8 Intermittent Fasting") + let goalName: String? + + /// Content state that can be updated while the Live Activity is running + public struct ContentState: Codable, Hashable { + /// The current elapsed time in seconds + var elapsedTime: TimeInterval + + /// The remaining time until the goal is reached in seconds + var remainingTime: TimeInterval + + /// Whether the goal has been reached + var goalReached: Bool + } +} diff --git a/FastPath/LiveActivity/FastingLiveActivity.swift b/FastPath/LiveActivity/FastingLiveActivity.swift new file mode 100644 index 0000000..7a128fc --- /dev/null +++ b/FastPath/LiveActivity/FastingLiveActivity.swift @@ -0,0 +1,224 @@ +// +// FastingLiveActivity.swift +// FastPath +// +// Created on 5/4/25. +// + +import SwiftUI +import ActivityKit +import WidgetKit + +struct FastingLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: FastingActivityAttributes.self) { context in + // Lock screen/banner UI + FastingLiveActivityView(context: context) + } dynamicIsland: { context in + DynamicIsland { + // Expanded UI + DynamicIslandExpandedRegion(.leading) { + Label { + Text("Fasting") + .font(.headline) + } icon: { + Image(systemName: "timer") + .foregroundStyle(.indigo) + } + .font(.headline) + } + + DynamicIslandExpandedRegion(.trailing) { + if context.state.goalReached { + Label { + Text("Goal Reached!") + .font(.headline) + .foregroundStyle(.green) + } icon: { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } else { + Label { + Text(formatTimeInterval(context.state.remainingTime)) + .font(.headline) + .monospacedDigit() + } icon: { + Image(systemName: "hourglass") + .foregroundStyle(.orange) + } + } + } + + DynamicIslandExpandedRegion(.center) { + Text(context.attributes.goalName ?? "Fasting Goal") + .font(.headline) + .lineLimit(1) + } + + DynamicIslandExpandedRegion(.bottom) { + HStack { + VStack(alignment: .leading) { + Text("Elapsed") + .font(.caption2) + .foregroundStyle(.secondary) + Text(formatTimeInterval(context.state.elapsedTime)) + .font(.system(.body, design: .rounded)) + .monospacedDigit() + } + + Spacer() + + if !context.state.goalReached { + VStack(alignment: .trailing) { + Text("Remaining") + .font(.caption2) + .foregroundStyle(.secondary) + Text(formatTimeInterval(context.state.remainingTime)) + .font(.system(.body, design: .rounded)) + .monospacedDigit() + } + } else { + VStack(alignment: .trailing) { + Text("Goal") + .font(.caption2) + .foregroundStyle(.secondary) + Text(formatTimeInterval(context.attributes.goalDuration)) + .font(.system(.body, design: .rounded)) + .monospacedDigit() + } + } + } + .padding(.top, 4) + } + } compactLeading: { + Image(systemName: "timer.circle.fill") + .foregroundStyle(.indigo) + } compactTrailing: { + if context.state.goalReached { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + Text(formatCompactTime(context.state.remainingTime)) + .font(.system(.body, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.orange) + } + } minimal: { + if context.state.goalReached { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + Image(systemName: "timer.circle.fill") + .foregroundStyle(.indigo) + } + } + } + } + + // Helper function to format time interval for display + private func formatTimeInterval(_ interval: TimeInterval) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute, .second] + formatter.zeroFormattingBehavior = .pad + formatter.unitsStyle = .positional + + return formatter.string(from: interval) ?? "00:00:00" + } + + // Helper function to format time interval for compact display + private func formatCompactTime(_ interval: TimeInterval) -> String { + if interval >= 3600 { // More than 1 hour + let hours = Int(interval) / 3600 + let minutes = (Int(interval) % 3600) / 60 + return String(format: "%d:%02d", hours, minutes) + } else { + let minutes = Int(interval) / 60 + let seconds = Int(interval) % 60 + return String(format: "%d:%02d", minutes, seconds) + } + } +} + +struct FastingLiveActivityView: View { + let context: ActivityViewContext + + var body: some View { + VStack { + HStack { + Label { + Text("FastPath") + .font(.headline) + } icon: { + Image(systemName: "timer.circle.fill") + .foregroundStyle(.indigo) + } + + Spacer() + + if context.state.goalReached { + Label { + Text("Goal Reached!") + .font(.headline) + .foregroundStyle(.green) + } icon: { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } else { + Text(context.attributes.goalName ?? "Fasting Goal") + .font(.headline) + } + } + .padding(.bottom, 4) + + HStack { + VStack(alignment: .leading) { + Text("Elapsed") + .font(.caption) + .foregroundStyle(.secondary) + Text(formatTimeInterval(context.state.elapsedTime)) + .font(.system(.title3, design: .rounded)) + .monospacedDigit() + } + + Spacer() + + if !context.state.goalReached { + VStack(alignment: .trailing) { + Text("Remaining") + .font(.caption) + .foregroundStyle(.secondary) + Text(formatTimeInterval(context.state.remainingTime)) + .font(.system(.title3, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.orange) + } + } else { + VStack(alignment: .trailing) { + Text("Completed") + .font(.caption) + .foregroundStyle(.secondary) + Text(formatTimeInterval(context.attributes.goalDuration)) + .font(.system(.title3, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.green) + } + } + } + } + .padding() + .activityBackgroundTint(Color.white.opacity(0.9)) + .activitySystemActionForegroundColor(Color.black) + } + + // Helper function to format time interval for display + private func formatTimeInterval(_ interval: TimeInterval) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute, .second] + formatter.zeroFormattingBehavior = .pad + formatter.unitsStyle = .positional + + return formatter.string(from: interval) ?? "00:00:00" + } +} diff --git a/FastPath/Services/LiveActivityService.swift b/FastPath/Services/LiveActivityService.swift new file mode 100644 index 0000000..38e61dd --- /dev/null +++ b/FastPath/Services/LiveActivityService.swift @@ -0,0 +1,109 @@ +// +// LiveActivityService.swift +// FastPath +// +// Created on 5/4/25. +// + +import Foundation +import ActivityKit + +/// Service for managing Live Activities for the fasting timer +class LiveActivityService { + // Singleton instance + static let shared = LiveActivityService() + + private init() {} + + /// The current active fasting activity + private var currentActivity: Activity? + + /// Start a new Live Activity for a fasting session + /// - Parameters: + /// - fastId: The unique identifier for the fast + /// - startTime: The start time of the fast + /// - goalDuration: The target duration in seconds + /// - goalName: Optional name for the goal + /// - Returns: Whether the activity was successfully started + @discardableResult + func startFastingActivity(fastId: UUID, startTime: Date, goalDuration: TimeInterval, goalName: String? = nil) -> Bool { + // Check if Live Activities are supported on this device + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + print("Live Activities are not supported on this device") + return false + } + + // Stop any existing activity + stopCurrentActivity() + + // Create the initial content state + let initialContentState = FastingActivityAttributes.FastingStatus( + elapsedTime: 0, + remainingTime: goalDuration, + goalReached: false + ) + + // Create the activity attributes + let activityAttributes = FastingActivityAttributes( + fastId: fastId, + startTime: startTime, + goalDuration: goalDuration, + goalName: goalName + ) + + do { + // Start the Live Activity + let activity = try Activity.request( + attributes: activityAttributes, + contentState: initialContentState, + pushType: nil + ) + currentActivity = activity + print("Started Live Activity with ID: \(activity.id)") + return true + } catch { + print("Error starting Live Activity: \(error.localizedDescription)") + return false + } + } + + /// Update the current Live Activity with new elapsed and remaining times + /// - Parameters: + /// - elapsedTime: The current elapsed time in seconds + /// - remainingTime: The remaining time until the goal is reached in seconds + /// - goalReached: Whether the goal has been reached + func updateActivity(elapsedTime: TimeInterval, remainingTime: TimeInterval, goalReached: Bool) { + guard let activity = currentActivity else { return } + + // Create the updated content state + let updatedContentState = FastingActivityAttributes.FastingStatus( + elapsedTime: elapsedTime, + remainingTime: max(0, remainingTime), // Ensure remaining time is not negative + goalReached: goalReached + ) + + // Update the activity + Task { + await activity.update(using: updatedContentState) + } + } + + /// Stop the current Live Activity + func stopCurrentActivity() { + guard let activity = currentActivity else { return } + + // End the activity + Task { + await activity.end( + using: activity.contentState, + dismissalPolicy: .immediate + ) + currentActivity = nil + } + } + + /// Check if there's an active Live Activity + var hasActiveActivity: Bool { + return currentActivity != nil + } +} diff --git a/FastPath/Views/FastingView.swift b/FastPath/Views/FastingView.swift index 5b28da2..3af6d00 100644 --- a/FastPath/Views/FastingView.swift +++ b/FastPath/Views/FastingView.swift @@ -121,7 +121,20 @@ struct FastingView: View { .foregroundColor(.blue) } } - .padding(.bottom) + .padding(.bottom, 8) + + // Live Activity toggle (iOS 16.1+ only) + if #available(iOS 16.1, *) { + Toggle(isOn: viewStore.binding(get: \.liveActivityEnabled, send: { .toggleLiveActivity($0) })) { + HStack { + Image(systemName: "rectangle.inset.filled.and.person.filled") + Text("Show in Dynamic Island") + Spacer() + } + } + .padding(.horizontal) + .padding(.bottom) + } } .onAppear { viewStore.send(.loadInitialState) diff --git a/ai_specs/DynamicIslandFeature.md b/ai_specs/DynamicIslandFeature.md new file mode 100644 index 0000000..401e330 --- /dev/null +++ b/ai_specs/DynamicIslandFeature.md @@ -0,0 +1,67 @@ +# Functional Requirements - Dynamic Island Fast Timer Feature + +**Project:** Intermittent Fasting Tracker (iOS) +**Feature Version:** 1.0 +**Date:** May 4, 2025 +**Author:** [Your Name/Company Name] + +**1. Overview** + +This document outlines the specific functional requirements for integrating the active fasting timer with the iOS Dynamic Island and Live Activities feature. This aims to provide users with at-a-glance information about their remaining fast time without needing to open the app. This feature depends on the "Daily Fast Goal" feature being implemented and a goal being set by the user. + +**2. Functional Requirements** + +* **DI-001: Start Live Activity** + * **Description:** Initiate a Live Activity visible in the Dynamic Island when a fast starts and a goal is set. + * **Details:** + * A Live Activity shall automatically start when the user taps the "Start Fast" button (FR-001) *only if* a Daily Fast Goal (FR-007 / DFG-001) has been previously set by the user. + * If no Daily Fast Goal is set, no Live Activity should be started. + +* **DI-002: Dynamic Island Content (Compact/Minimal)** + * **Description:** Display concise remaining fast time in the compact Dynamic Island view. + * **Details:** + * When the Live Activity is active and the app is not in the foreground, the compact Dynamic Island presentation should display: + * A relevant icon (e.g., a simple timer or fasting symbol). + * The remaining time until the Daily Fast Goal is met (counting down, e.g., HH:MM or MM:SS format). + * The time display must update dynamically (at least every minute, adhering to system limitations/recommendations for Live Activity updates). + +* **DI-003: Dynamic Island Content (Expanded/Long-Look)** + * **Description:** Display more detailed information in the expanded Dynamic Island view and on the Lock Screen. + * **Details:** + * When the Live Activity is expanded (e.g., via user interaction or on the Lock Screen), it should display: + * A clear title like "Fasting Goal". + * The remaining time until the Daily Fast Goal is met (counting down, e.g., HH:MM:SS format). + * Optionally, the total elapsed time of the current fast (counting up). + * Optionally, the target goal time (e.g., "Goal: 16 hours"). + * The time displays must update dynamically (at least every minute, adhering to system update frequency guidelines). + +* **DI-004: Goal Reached State** + * **Description:** Update the Live Activity when the fasting goal duration is achieved. + * **Details:** + * When the countdown timer reaches zero (goal met), the Live Activity display (both compact and expanded) should update to indicate completion. + * Examples: Change text to "Goal Reached!", display a checkmark icon, briefly animate. + * The activity should likely persist for a short, configurable duration after the goal is reached or until the fast is manually stopped, allowing the user to see the completion status. + +* **DI-005: End Live Activity (Fast Stop)** + * **Description:** Terminate the Live Activity when the fast is manually stopped. + * **Details:** + * When the user taps the "Stop Fast" button (FR-002) in the app, the associated Live Activity must be immediately ended and removed from the Dynamic Island and Lock Screen. + +* **DI-006: Handling Goal Changes (Optional - Consider Complexity)** + * **Description:** Define behavior if the user changes their Daily Fast Goal while a fast (and Live Activity) is active. + * **Details (Option A - Simpler):** The Live Activity continues counting down to the *original* goal set when the fast started. The change only applies to future fasts. + * **Details (Option B - More Complex):** The Live Activity updates dynamically to reflect the *new* goal, recalculating the remaining time. (Requires careful state management). + * **Decision:** Specify which option (A or B) should be implemented. Option A is recommended for initial implementation simplicity. + +* **DI-007: Handling App Termination** + * **Description:** Ensure the Live Activity persists or correctly reflects state if the app is terminated. + * **Details:** + * The Live Activity should ideally persist even if the app is manually terminated by the user, continuing the countdown based on the start time and goal duration (requires background processing capabilities allowed for Live Activities). + * Upon app relaunch, the app state and the Live Activity state must be synchronized. + +**3. Non-Functional Requirements (Related)** + +* **NFR-UI-DI:** The Dynamic Island presentation should be visually clean, conform to Apple's HIG for Live Activities, and provide clear information. Use system fonts and appropriate iconography. +* **NFR-Perf-DI:** Live Activity updates should be efficient to minimize battery consumption, adhering to Apple's update frequency recommendations. +* **NFR-Compat-DI:** Requires iOS 16.1 or later (or the minimum version supporting Live Activities). The feature should gracefully handle being run on older iOS versions where Dynamic Island/Live Activities are unavailable (i.e., the app should function correctly without this specific UI element). + From f0f95de01f952274834b14010e608fbcdcf9dfcc Mon Sep 17 00:00:00 2001 From: Ryan Casler Date: Sun, 4 May 2025 18:31:09 -0500 Subject: [PATCH 2/4] Refactor Live Activity state management with dedicated action and reducer --- FastPath/Features/FastingFeature.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/FastPath/Features/FastingFeature.swift b/FastPath/Features/FastingFeature.swift index 015d476..45f0a2d 100644 --- a/FastPath/Features/FastingFeature.swift +++ b/FastPath/Features/FastingFeature.swift @@ -68,6 +68,7 @@ struct FastingFeature { case startLiveActivity(FastingRecord, FastingGoal) case updateLiveActivity(TimeInterval, TimeInterval, Bool) case stopLiveActivity + case setLiveActivityActive(Bool) // New action for updating Live Activity state // Navigation case showHistory @@ -268,7 +269,7 @@ struct FastingFeature { return .none } - return .run { _ in + return .run { send in let success = LiveActivityService.shared.startFastingActivity( fastId: record.id, startTime: record.startTime, @@ -276,8 +277,9 @@ struct FastingFeature { goalName: goal.name ) + // If successful, send an action to update the state if success { - await MainActor.run { state.hasActiveLiveActivity = true } + await send(.setLiveActivityActive(true)) } } @@ -296,6 +298,10 @@ struct FastingFeature { LiveActivityService.shared.stopCurrentActivity() } + case let .setLiveActivityActive(isActive): + state.hasActiveLiveActivity = isActive + return .none + case .showHistory: // This will be handled by the parent reducer for navigation return .none From 7312439b1331bca641996a20d0dfdb0859c7bbae Mon Sep 17 00:00:00 2001 From: Ryan Casler Date: Sun, 4 May 2025 18:36:48 -0500 Subject: [PATCH 3/4] Fix race conditions by capturing state values before async operations --- FastPath/Features/FastingFeature.swift | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/FastPath/Features/FastingFeature.swift b/FastPath/Features/FastingFeature.swift index 45f0a2d..cb32b37 100644 --- a/FastPath/Features/FastingFeature.swift +++ b/FastPath/Features/FastingFeature.swift @@ -122,16 +122,18 @@ struct FastingFeature { case .startFastButtonTapped: let newRecord = FastingRecord(startTime: Date()) state.activeRecord = newRecord - + let goal = state.fastingGoal + let liveActivityEnabled = state.liveActivityEnabled + return .run { send in try await databaseClient.save(newRecord) await send(.fastingRecordSaved(newRecord)) - + // Start Live Activity if goal is set and Live Activities are enabled - if let goal = state.fastingGoal, state.liveActivityEnabled { + if let goal = goal, liveActivityEnabled { await send(.startLiveActivity(newRecord, goal)) } - + // Start timer for await _ in self.clock.timer(interval: .seconds(1)) { await send(.timerTick) @@ -142,22 +144,23 @@ struct FastingFeature { guard var record = state.activeRecord else { return .none } record.endTime = Date() state.activeRecord = nil - + let wasLiveActivityActive = state.hasActiveLiveActivity + return .run { send in try await databaseClient.update(record) await send(.fastingStopped(record)) - - // Stop Live Activity if one is active - if state.hasActiveLiveActivity { + + // Stop Live Activity if one was active + if wasLiveActivityActive { await send(.stopLiveActivity) } - + // Refresh history let history = await databaseClient.getAllRecords() await send(.fastingHistoryLoaded(history)) } - case let .fastingRecordSaved(record): + case .fastingRecordSaved(let record): // Update state if needed if state.activeRecord?.id == record.id { state.activeRecord = record From f693f4767ce256cafcf0deb63bb43e024029188e Mon Sep 17 00:00:00 2001 From: Ryan Casler Date: Sun, 4 May 2025 18:43:32 -0500 Subject: [PATCH 4/4] Add Info.plist with LiveActivities support and update project settings --- FastPath.xcodeproj/project.pbxproj | 15 +++++++++++++++ FastPath/Info.plist | 8 ++++++++ 2 files changed, 23 insertions(+) create mode 100644 FastPath/Info.plist diff --git a/FastPath.xcodeproj/project.pbxproj b/FastPath.xcodeproj/project.pbxproj index 51df2c0..fbb3a95 100644 --- a/FastPath.xcodeproj/project.pbxproj +++ b/FastPath.xcodeproj/project.pbxproj @@ -33,9 +33,22 @@ 94534D982DC7F53800A36199 /* FastPathUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FastPathUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 94501FBB2DC832AA0039A34E /* Exceptions for "FastPath" folder in "FastPath" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 94534D7F2DC7F53600A36199 /* FastPath */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 94534D822DC7F53600A36199 /* FastPath */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 94501FBB2DC832AA0039A34E /* Exceptions for "FastPath" folder in "FastPath" target */, + ); path = FastPath; sourceTree = ""; }; @@ -404,6 +417,7 @@ DEVELOPMENT_TEAM = BL5P84P7K7; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = FastPath/Info.plist; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -442,6 +456,7 @@ DEVELOPMENT_TEAM = BL5P84P7K7; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = FastPath/Info.plist; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; diff --git a/FastPath/Info.plist b/FastPath/Info.plist new file mode 100644 index 0000000..2716686 --- /dev/null +++ b/FastPath/Info.plist @@ -0,0 +1,8 @@ + + + + + NSSupportsLiveActivities + + +