Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions ios/ancla-app/app-view-model.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 {
Expand Down Expand Up @@ -204,6 +207,7 @@ final class AppViewModel {
prepareDraftForNewMode()
prepareDraftForNewSchedule()
_ = syncScheduledSessions()
refreshScheduleNotifications()
}

func requestAuthorization() async {
Expand Down Expand Up @@ -698,6 +702,11 @@ final class AppViewModel {
}
}

func handleSceneDidBecomeActive() {
_ = syncScheduledSessions()
refreshScheduleNotifications()
}

func isActionInProgress(_ action: AppActionID) -> Bool {
isBusy && activeAction == action
}
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion ios/ancla-app/content-view.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,6 +90,11 @@ struct ContentView: View {
anchorNameDraft = pairedTag.displayName
}
}
.onChange(of: scenePhase) { _, phase in
if phase == .active {
viewModel.handleSceneDidBecomeActive()
}
}
}
}

Expand Down Expand Up @@ -193,7 +199,7 @@ struct ContentView: View {
detail: anchorDetail
)

if let anchorPreviewTag {
if anchorPreviewTag != nil {
surfaceDivider

surfaceRow(
Expand Down
162 changes: 162 additions & 0 deletions ios/ancla-app/schedule-notification-service.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Error>) in
center.add(request) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
}
}
}
}
9 changes: 9 additions & 0 deletions ios/ancla-lite/ancla-lite-support.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ enum AutomatedTestConfig {
static var usesSimulatedNFC: Bool {
!simulatedStickerHashes.isEmpty
}

static var isRunningTests: Bool {
ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
}
}

struct LocalSnapshotStore: AppSnapshotStore {
Expand Down Expand Up @@ -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 {}
}
5 changes: 5 additions & 0 deletions ios/ancla-shared/ancla-dependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,8 @@ protocol Shielding {
protocol StickerPairing {
func scanSticker() async throws -> String
}

@MainActor
protocol ScheduleNotifying {
func refresh(for snapshot: AppSnapshot, now: Date) async
}
Loading