diff --git a/ios/ancla-app/app-view-model.swift b/ios/ancla-app/app-view-model.swift index e92ba60..71cca22 100644 --- a/ios/ancla-app/app-view-model.swift +++ b/ios/ancla-app/app-view-model.swift @@ -61,6 +61,7 @@ final class AppViewModel { private let shieldingService: any Shielding private let stickerPairingService: any StickerPairing private let runtimeDiagnosticsProbe: any RuntimeDiagnosticsProbing + private let scheduleNotificationService: any ScheduleNotifying private let nowProvider: () -> Date init( @@ -70,10 +71,12 @@ final class AppViewModel { shieldingService: (any Shielding)? = nil, stickerPairingService: (any StickerPairing)? = nil, runtimeDiagnosticsProbe: (any RuntimeDiagnosticsProbing)? = nil, + scheduleNotificationService: (any ScheduleNotifying)? = nil, nowProvider: @escaping () -> Date = { .now } ) { self.buildVariant = buildVariant self.runtimeDiagnosticsProbe = runtimeDiagnosticsProbe ?? LiveRuntimeDiagnosticsProbe() + self.scheduleNotificationService = scheduleNotificationService ?? LiveScheduleNotificationService.shared self.nowProvider = nowProvider switch buildVariant { @@ -204,6 +207,7 @@ final class AppViewModel { prepareDraftForNewMode() prepareDraftForNewSchedule() _ = syncScheduledSessions() + refreshScheduleNotifications() } func requestAuthorization() async { @@ -698,6 +702,11 @@ final class AppViewModel { } } + func handleSceneDidBecomeActive() { + _ = syncScheduledSessions() + refreshScheduleNotifications() + } + func isActionInProgress(_ action: AppActionID) -> Bool { isBusy && activeAction == action } @@ -774,6 +783,15 @@ final class AppViewModel { private func persist() throws { try store.save(snapshot) + refreshScheduleNotifications() + } + + private func refreshScheduleNotifications() { + let snapshot = snapshot + let now = nowProvider() + Task { @MainActor [scheduleNotificationService] in + await scheduleNotificationService.refresh(for: snapshot, now: now) + } } private func arm(mode: BlockMode) async throws { diff --git a/ios/ancla-app/content-view.swift b/ios/ancla-app/content-view.swift index 5ad62d1..f9103fe 100644 --- a/ios/ancla-app/content-view.swift +++ b/ios/ancla-app/content-view.swift @@ -15,6 +15,7 @@ private enum NextStep { struct ContentView: View { @Bindable var viewModel: AppViewModel + @Environment(\.scenePhase) private var scenePhase // Keep the final rows reachable above the fixed bottom action bar. private let bottomActionBarClearance: CGFloat = 132 @@ -89,6 +90,11 @@ struct ContentView: View { anchorNameDraft = pairedTag.displayName } } + .onChange(of: scenePhase) { _, phase in + if phase == .active { + viewModel.handleSceneDidBecomeActive() + } + } } } @@ -193,7 +199,7 @@ struct ContentView: View { detail: anchorDetail ) - if let anchorPreviewTag { + if anchorPreviewTag != nil { surfaceDivider surfaceRow( diff --git a/ios/ancla-app/schedule-notification-service.swift b/ios/ancla-app/schedule-notification-service.swift new file mode 100644 index 0000000..405d98f --- /dev/null +++ b/ios/ancla-app/schedule-notification-service.swift @@ -0,0 +1,162 @@ +import Foundation +import UserNotifications + +@MainActor +final class LiveScheduleNotificationService: ScheduleNotifying { + static let shared = LiveScheduleNotificationService() + + private let center = UNUserNotificationCenter.current() + private let identifierPrefix = "ancla.schedule." + private var requestedAuthorizationThisLaunch = false + + func refresh(for snapshot: AppSnapshot, now: Date) async { + guard !AutomatedTestConfig.isRunningTests else { + return + } + + let existingIdentifiers = await pendingScheduleIdentifiers() + if !existingIdentifiers.isEmpty { + center.removePendingNotificationRequests(withIdentifiers: existingIdentifiers) + center.removeDeliveredNotifications(withIdentifiers: existingIdentifiers) + } + + let plans = snapshot.scheduledPlans.filter { plan in + plan.isEnabled && plan.endMinuteOfDay > plan.startMinuteOfDay + } + guard !plans.isEmpty else { + return + } + + guard await canScheduleNotifications() else { + return + } + + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: now) + var requests: [UNNotificationRequest] = [] + + for dayOffset in 0..<7 { + guard let day = calendar.date(byAdding: .day, value: dayOffset, to: startOfToday) else { + continue + } + + let weekday = calendar.component(.weekday, from: day) + let dayKey = AnclaCore.dayKey(for: day, calendar: calendar) + + for plan in plans where plan.weekdayNumbers.contains(weekday) { + guard + let mode = snapshot.modes.first(where: { $0.id == plan.modeId }), + let pairedTag = AnclaCore.pairedTag(for: plan.pairedTagId, in: snapshot) + else { + continue + } + + if let startDate = date(on: day, minuteOfDay: plan.startMinuteOfDay, calendar: calendar), + startDate > now + { + requests.append( + notificationRequest( + identifier: "\(identifierPrefix)\(plan.id.uuidString).\(dayKey).start", + title: "\(mode.name) is scheduled now", + body: "Open Ancla to start the scheduled session. \(pairedTag.displayName) stays the release anchor.", + date: startDate, + calendar: calendar + ) + ) + } + + if let endDate = date(on: day, minuteOfDay: plan.endMinuteOfDay, calendar: calendar), + endDate > now + { + requests.append( + notificationRequest( + identifier: "\(identifierPrefix)\(plan.id.uuidString).\(dayKey).end", + title: "\(mode.name) schedule window ended", + body: "Open Ancla to sync the session state if this schedule was active.", + date: endDate, + calendar: calendar + ) + ) + } + } + } + + for request in requests { + try? await add(request) + } + } + + private func canScheduleNotifications() async -> Bool { + let settings = await notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + case .notDetermined: + guard !requestedAuthorizationThisLaunch else { + return false + } + requestedAuthorizationThisLaunch = true + return (try? await center.requestAuthorization(options: [.alert, .sound])) == true + case .denied: + return false + @unknown default: + return false + } + } + + private func notificationRequest( + identifier: String, + title: String, + body: String, + date: Date, + calendar: Calendar + ) -> UNNotificationRequest { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let components = calendar.dateComponents( + [.year, .month, .day, .hour, .minute], + from: date + ) + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) + return UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + } + + private func date(on day: Date, minuteOfDay: Int, calendar: Calendar) -> Date? { + calendar.date(byAdding: .minute, value: minuteOfDay, to: day) + } + + private func notificationSettings() async -> UNNotificationSettings { + await withCheckedContinuation { continuation in + center.getNotificationSettings { settings in + continuation.resume(returning: settings) + } + } + } + + private func pendingScheduleIdentifiers() async -> [String] { + await withCheckedContinuation { continuation in + center.getPendingNotificationRequests { requests in + continuation.resume( + returning: requests + .map(\.identifier) + .filter { $0.hasPrefix(self.identifierPrefix) } + ) + } + } + } + + private func add(_ request: UNNotificationRequest) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + center.add(request) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } + } +} diff --git a/ios/ancla-lite/ancla-lite-support.swift b/ios/ancla-lite/ancla-lite-support.swift index a2f3b94..c0cefef 100644 --- a/ios/ancla-lite/ancla-lite-support.swift +++ b/ios/ancla-lite/ancla-lite-support.swift @@ -40,6 +40,10 @@ enum AutomatedTestConfig { static var usesSimulatedNFC: Bool { !simulatedStickerHashes.isEmpty } + + static var isRunningTests: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + } } struct LocalSnapshotStore: AppSnapshotStore { @@ -105,3 +109,8 @@ final class LiteStickerPairingService: StickerPairing { return try await scanner.scanSticker() } } + +@MainActor +final class NoopScheduleNotificationService: ScheduleNotifying { + func refresh(for _: AppSnapshot, now _: Date) async {} +} diff --git a/ios/ancla-shared/ancla-dependencies.swift b/ios/ancla-shared/ancla-dependencies.swift index d251499..b217a68 100644 --- a/ios/ancla-shared/ancla-dependencies.swift +++ b/ios/ancla-shared/ancla-dependencies.swift @@ -20,3 +20,8 @@ protocol Shielding { protocol StickerPairing { func scanSticker() async throws -> String } + +@MainActor +protocol ScheduleNotifying { + func refresh(for snapshot: AppSnapshot, now: Date) async +}