Skip to content

Commit f3a9b57

Browse files
committed
Local Live Activity
1 parent 49dfdc6 commit f3a9b57

26 files changed

Lines changed: 1078 additions & 18 deletions

Modules/PandaModules/Sources/PandaModels/PrinterAttributes.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import ActivityKit
12
import Foundation
23
import SFSafeSymbols
34
import SwiftUI
@@ -18,8 +19,8 @@ public enum PrinterStatus: String, Codable, Hashable, Sendable {
1819
// MARK: - Printer Attributes
1920

2021
/// Shared data model for printer state display.
21-
/// Used by the main app, widgets, and cached state snapshots.
22-
public struct PrinterAttributes: Sendable {
22+
/// Used by the main app, widgets, Live Activity, and cached state snapshots.
23+
public struct PrinterAttributes: ActivityAttributes, Sendable {
2324
public var printerName: String
2425

2526
public init(printerName: String) {
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import ActivityKit
2+
import Foundation
3+
import PandaModels
4+
5+
/// Protocol for Live Activity management, enabling dependency injection and testing.
6+
public protocol LiveActivityManaging: Sendable {
7+
func startIfNeeded(contentState: PrinterAttributes.ContentState, printerName: String) async
8+
func update(contentState: PrinterAttributes.ContentState) async
9+
func endIfNeeded(contentState: PrinterAttributes.ContentState) async
10+
var isActivityActive: Bool { get }
11+
}
12+
13+
/// Manages the printer Live Activity lifecycle using local-only updates (no APNs).
14+
///
15+
/// Thread-safe and idempotent — safe to call from multiple widgets concurrently.
16+
/// The manager deduplicates updates by checking if content has meaningfully changed.
17+
/// Ensures only one Live Activity exists at a time by ending stale ones on start.
18+
public final class LiveActivityManager: LiveActivityManaging, @unchecked Sendable {
19+
public static let shared = LiveActivityManager()
20+
21+
private let staleDuration: TimeInterval = 120 // 2 minutes
22+
private let completedDismissalDuration: TimeInterval = 14400 // 4 hours
23+
24+
/// Track last sent state to avoid redundant updates.
25+
private var lastSentProgress: Int?
26+
private var lastSentStatus: PrinterStatus?
27+
private var lastSentRemainingMinutes: Int?
28+
29+
public init() {}
30+
31+
public var isActivityActive: Bool {
32+
!Activity<PrinterAttributes>.activities.isEmpty
33+
}
34+
35+
public func startIfNeeded(
36+
contentState: PrinterAttributes.ContentState,
37+
printerName: String
38+
) async {
39+
guard LiveActivitySettings.isEnabled else { return }
40+
guard contentState.status == .preparing || contentState.status == .printing else { return }
41+
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
42+
43+
// If an activity already exists, just update it — never create a duplicate
44+
if !Activity<PrinterAttributes>.activities.isEmpty {
45+
await update(contentState: contentState)
46+
return
47+
}
48+
49+
let attributes = PrinterAttributes(printerName: printerName)
50+
let content = ActivityContent(
51+
state: contentState,
52+
staleDate: Date.now.addingTimeInterval(staleDuration)
53+
)
54+
55+
do {
56+
_ = try Activity.request(
57+
attributes: attributes,
58+
content: content,
59+
pushType: nil
60+
)
61+
lastSentProgress = contentState.progress
62+
lastSentStatus = contentState.status
63+
lastSentRemainingMinutes = contentState.remainingMinutes
64+
} catch {
65+
// Activity request failed — user may have denied permission
66+
}
67+
}
68+
69+
public func update(contentState: PrinterAttributes.ContentState) async {
70+
let activities = Activity<PrinterAttributes>.activities
71+
guard let activity = activities.first else { return }
72+
73+
// Deduplicate: skip if nothing meaningful changed
74+
if contentState.progress == lastSentProgress,
75+
contentState.status == lastSentStatus,
76+
contentState.remainingMinutes == lastSentRemainingMinutes
77+
{
78+
return
79+
}
80+
81+
let content = ActivityContent(
82+
state: contentState,
83+
staleDate: Date.now.addingTimeInterval(staleDuration)
84+
)
85+
86+
await activity.update(content)
87+
88+
// End any extra activities that shouldn't exist (cleanup after app restart)
89+
for extra in activities.dropFirst() {
90+
await extra.end(content, dismissalPolicy: .immediate)
91+
}
92+
93+
lastSentProgress = contentState.progress
94+
lastSentStatus = contentState.status
95+
lastSentRemainingMinutes = contentState.remainingMinutes
96+
}
97+
98+
public func endIfNeeded(contentState: PrinterAttributes.ContentState) async {
99+
let activities = Activity<PrinterAttributes>.activities
100+
guard !activities.isEmpty else { return }
101+
102+
let shouldEnd: Bool
103+
let dismissalPolicy: ActivityUIDismissalPolicy
104+
105+
switch contentState.status {
106+
case .completed:
107+
shouldEnd = true
108+
dismissalPolicy = .after(Date.now.addingTimeInterval(completedDismissalDuration))
109+
case .cancelled:
110+
shouldEnd = true
111+
dismissalPolicy = .default
112+
case .idle:
113+
shouldEnd = true
114+
dismissalPolicy = .immediate
115+
case .preparing, .printing, .paused, .issue:
116+
shouldEnd = false
117+
dismissalPolicy = .default
118+
}
119+
120+
guard shouldEnd else { return }
121+
122+
let content = ActivityContent(
123+
state: contentState,
124+
staleDate: nil
125+
)
126+
127+
// End ALL activities, not just the first
128+
for activity in activities {
129+
await activity.end(content, dismissalPolicy: dismissalPolicy)
130+
}
131+
132+
lastSentProgress = nil
133+
lastSentStatus = nil
134+
lastSentRemainingMinutes = nil
135+
}
136+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Foundation
2+
import PandaModels
3+
4+
/// Manages Live Activity enabled/disabled state in shared UserDefaults.
5+
/// Accessible from both main app and widget extension.
6+
public enum LiveActivitySettings {
7+
private static let key = "liveActivity.enabled"
8+
9+
/// Whether Live Activities are enabled. Defaults to true (opt-out).
10+
public static var isEnabled: Bool {
11+
get { SharedSettings.sharedDefaults?.object(forKey: key) as? Bool ?? true }
12+
set { SharedSettings.sharedDefaults?.set(newValue, forKey: key) }
13+
}
14+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import Foundation
2+
import PandaModels
3+
@testable import PandaNotifications
4+
import Testing
5+
6+
struct LiveActivitySettingsTests {
7+
private let settingsKey = "liveActivity.enabled"
8+
9+
@Test("Defaults to enabled")
10+
func defaultsToEnabled() {
11+
SharedSettings.sharedDefaults?.removeObject(forKey: settingsKey)
12+
defer { SharedSettings.sharedDefaults?.removeObject(forKey: settingsKey) }
13+
14+
#expect(LiveActivitySettings.isEnabled == true)
15+
}
16+
17+
@Test("Persists disabled state")
18+
func persistsDisabled() {
19+
defer { SharedSettings.sharedDefaults?.removeObject(forKey: settingsKey) }
20+
21+
LiveActivitySettings.isEnabled = false
22+
#expect(LiveActivitySettings.isEnabled == false)
23+
}
24+
25+
@Test("Persists re-enabled state")
26+
func persistsReEnabled() {
27+
defer { SharedSettings.sharedDefaults?.removeObject(forKey: settingsKey) }
28+
29+
LiveActivitySettings.isEnabled = false
30+
LiveActivitySettings.isEnabled = true
31+
#expect(LiveActivitySettings.isEnabled == true)
32+
}
33+
}

PandaBeFree/App/AppDelegate.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,71 @@
1+
import BackgroundTasks
2+
import Networking
3+
import PandaModels
4+
import PandaNotifications
15
import UIKit
26

37
class AppDelegate: NSObject, UIApplicationDelegate {
48
static var orientationLock: UIInterfaceOrientationMask = .portrait
59

10+
static let bgTaskIdentifier = "com.pandabefree.liveactivity.refresh"
11+
12+
func application(
13+
_: UIApplication,
14+
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil
15+
) -> Bool {
16+
BGTaskScheduler.shared.register(
17+
forTaskWithIdentifier: Self.bgTaskIdentifier,
18+
using: nil
19+
) { task in
20+
guard let refreshTask = task as? BGAppRefreshTask else { return }
21+
Self.handleBackgroundRefresh(refreshTask)
22+
}
23+
return true
24+
}
25+
626
func application(
727
_: UIApplication,
828
supportedInterfaceOrientationsFor _: UIWindow?
929
) -> UIInterfaceOrientationMask {
1030
Self.orientationLock
1131
}
32+
33+
/// Schedule a background refresh task for Live Activity updates.
34+
static func scheduleLiveActivityRefresh() {
35+
let request = BGAppRefreshTaskRequest(identifier: bgTaskIdentifier)
36+
request.earliestBeginDate = Date.now.addingTimeInterval(15 * 60)
37+
try? BGTaskScheduler.shared.submit(request)
38+
}
39+
40+
private static func handleBackgroundRefresh(_ task: BGAppRefreshTask) {
41+
let fetchTask = Task {
42+
defer { task.setTaskCompleted(success: true) }
43+
44+
guard SharedSettings.hasConfiguration,
45+
LiveActivityManager.shared.isActivityActive
46+
else { return }
47+
48+
do {
49+
let snapshot = try await WidgetMQTTService.fetchSnapshot(
50+
ip: SharedSettings.printerIP,
51+
accessCode: SharedSettings.printerAccessCode,
52+
serial: SharedSettings.printerSerial
53+
)
54+
SharedSettings.cachedPrinterState = snapshot
55+
await LiveActivityManager.shared.update(contentState: snapshot.contentState)
56+
await LiveActivityManager.shared.endIfNeeded(contentState: snapshot.contentState)
57+
} catch {
58+
// MQTT fetch failed — leave Live Activity as-is (staleDate will handle UI)
59+
}
60+
61+
// Re-schedule if Live Activity is still active
62+
if LiveActivityManager.shared.isActivityActive {
63+
scheduleLiveActivityRefresh()
64+
}
65+
}
66+
67+
task.expirationHandler = {
68+
fetchTask.cancel()
69+
}
70+
}
1271
}

PandaBeFree/Info.plist

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,13 @@
3737
<string>PandaBeFree connects to your 3D printer on the local network to stream the camera feed.</string>
3838
<key>AppGroupIdentifier</key>
3939
<string>$(APP_GROUP_ID)</string>
40+
<key>NSSupportsLiveActivities</key>
41+
<true/>
42+
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
43+
<true/>
44+
<key>BGTaskSchedulerPermittedIdentifiers</key>
45+
<array>
46+
<string>com.pandabefree.liveactivity.refresh</string>
47+
</array>
4048
</dict>
4149
</plist>

PandaBeFree/ViewModels/DashboardViewModel.swift

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ final class DashboardViewModel {
6363

6464
private let mqttService: any MQTTServiceProtocol
6565
private let notificationScheduler: any NotificationScheduling
66+
private let liveActivityManager: any LiveActivityManaging
6667
private var wasConnected = false
6768
// nonisolated(unsafe) allows cancellation from deinit; Task.cancel() is thread-safe.
6869
// swiftformat:disable:next nonisolatedUnsafe
@@ -116,10 +117,12 @@ final class DashboardViewModel {
116117

117118
init(
118119
mqttService: any MQTTServiceProtocol = PandaMQTTService(),
119-
notificationScheduler: any NotificationScheduling = LocalNotificationScheduler.shared
120+
notificationScheduler: any NotificationScheduling = LocalNotificationScheduler.shared,
121+
liveActivityManager: any LiveActivityManaging = LiveActivityManager.shared
120122
) {
121123
self.mqttService = mqttService
122124
self.notificationScheduler = notificationScheduler
125+
self.liveActivityManager = liveActivityManager
123126
}
124127

125128
deinit {
@@ -239,6 +242,18 @@ final class DashboardViewModel {
239242
WidgetCenter.shared.reloadTimelines(ofKind: "PrintStateWidget")
240243
WidgetCenter.shared.reloadTimelines(ofKind: "AMSWidget")
241244
}
245+
246+
// Update Live Activity
247+
if self.printerState.lastUpdated != nil {
248+
let cs = self.printerState.contentState
249+
let printerName = SharedSettings.printerModel?.displayName ?? "3D Printer"
250+
let lam = self.liveActivityManager
251+
Task {
252+
await lam.startIfNeeded(contentState: cs, printerName: printerName)
253+
await lam.update(contentState: cs)
254+
await lam.endIfNeeded(contentState: cs)
255+
}
256+
}
242257
}
243258
}
244259

@@ -323,22 +338,33 @@ final class DashboardViewModel {
323338
wasConnected = true
324339
if hasReceivedInitialData {
325340
SharedSettings.cachedPrinterState = PrinterStateSnapshot(from: printerState)
341+
// Final Live Activity update before losing MQTT
342+
let cs = printerState.contentState
343+
let lam = liveActivityManager
344+
Task { await lam.update(contentState: cs) }
326345
}
327346
disconnectAll()
328347
WidgetCenter.shared.reloadAllTimelines()
348+
// Schedule background refresh for Live Activity updates
349+
if liveActivityManager.isActivityActive {
350+
AppDelegate.scheduleLiveActivityRefresh()
351+
}
329352
}
330353
case .active:
331354
appLog(.info, category: logCategory, "Scene phase → active (wasConnected: \(wasConnected))")
332355
WidgetCenter.shared.reloadTimelines(ofKind: "PrintStateWidget")
333356
WidgetCenter.shared.reloadTimelines(ofKind: "AMSWidget")
334-
// Reconcile notifications from cached state before MQTT reconnects
357+
// Reconcile notifications and Live Activity from cached state before MQTT reconnects
335358
if let cached = SharedSettings.cachedPrinterState {
359+
let lam = liveActivityManager
360+
let scheduler = notificationScheduler
336361
Task {
337362
let actions = NotificationEvaluator.evaluate(
338363
contentState: cached.contentState,
339364
amsUnits: cached.amsUnits
340365
)
341-
await notificationScheduler.execute(actions)
366+
await scheduler.execute(actions)
367+
await lam.update(contentState: cached.contentState)
342368
}
343369
}
344370
if wasConnected {

0 commit comments

Comments
 (0)