From b3cd409687f1b9e65acbcf2b48b3b4328fa73201 Mon Sep 17 00:00:00 2001 From: Microck Date: Fri, 3 Apr 2026 04:37:12 +0000 Subject: [PATCH] feat: add scheduled sessions on iphone --- ios/ancla-app/app-view-model.swift | 290 +++++++++++++- ios/ancla-app/content-view.swift | 249 ++++++++++++ ios/ancla-app/schedule-editor-view.swift | 418 ++++++++++++++++++++ ios/ancla-core-tests/ancla-core-tests.swift | 128 ++++++ ios/ancla-shared/ancla-core.swift | 59 +++ ios/ancla-shared/ancla-models.swift | 106 ++++- ios/ancla-tests/app-view-model-tests.swift | 141 +++++++ 7 files changed, 1381 insertions(+), 10 deletions(-) create mode 100644 ios/ancla-app/schedule-editor-view.swift diff --git a/ios/ancla-app/app-view-model.swift b/ios/ancla-app/app-view-model.swift index 2c27a78..e92ba60 100644 --- a/ios/ancla-app/app-view-model.swift +++ b/ios/ancla-app/app-view-model.swift @@ -14,6 +14,8 @@ enum AppActionID: Equatable { case renameAnchor case removeAnchor case saveMode + case saveSchedule + case removeSchedule } enum ActionFeedbackTone { @@ -39,6 +41,13 @@ final class AppViewModel { var draftModeName = "Work block" var draftModeShouldBeDefault = false var draftModeIsStrict = false + var draftScheduleID: UUID? + var draftScheduleModeID: UUID? + var draftSchedulePairedTagID: UUID? + var draftScheduleWeekdayNumbers: [Int] = [] + var draftScheduleStartMinuteOfDay = 9 * 60 + var draftScheduleEndMinuteOfDay = 17 * 60 + var draftScheduleIsEnabled = true var draftTagName = "Desk anchor" var selectedModeID: UUID? var isPickerPresented = false @@ -52,6 +61,7 @@ final class AppViewModel { private let shieldingService: any Shielding private let stickerPairingService: any StickerPairing private let runtimeDiagnosticsProbe: any RuntimeDiagnosticsProbing + private let nowProvider: () -> Date init( buildVariant: AppBuildVariant = .current, @@ -59,10 +69,12 @@ final class AppViewModel { authorizationClient: (any AuthorizationClienting)? = nil, shieldingService: (any Shielding)? = nil, stickerPairingService: (any StickerPairing)? = nil, - runtimeDiagnosticsProbe: (any RuntimeDiagnosticsProbing)? = nil + runtimeDiagnosticsProbe: (any RuntimeDiagnosticsProbing)? = nil, + nowProvider: @escaping () -> Date = { .now } ) { self.buildVariant = buildVariant self.runtimeDiagnosticsProbe = runtimeDiagnosticsProbe ?? LiveRuntimeDiagnosticsProbe() + self.nowProvider = nowProvider switch buildVariant { case .full: @@ -164,6 +176,17 @@ final class AppViewModel { (selectedMode() ?? preferredMode())?.isStrict == true } + var scheduledPlansForDisplay: [ScheduledSessionPlan] { + AnclaCore.sortedScheduledPlans(snapshot.scheduledPlans, at: nowProvider()) + } + + var canSaveDraftSchedule: Bool { + draftScheduleModeID != nil + && draftSchedulePairedTagID != nil + && !draftScheduleWeekdayNumbers.isEmpty + && draftScheduleEndMinuteOfDay > draftScheduleStartMinuteOfDay + } + func load() { do { snapshot = AnclaCore.repairedSnapshot(try store.load()) @@ -179,7 +202,8 @@ final class AppViewModel { selectedModeID = preferredMode()?.id prepareDraftForNewMode() - refreshDiagnostics() + prepareDraftForNewSchedule() + _ = syncScheduledSessions() } func requestAuthorization() async { @@ -253,6 +277,37 @@ final class AppViewModel { } } + func saveScheduledPlan() async { + await runTask(action: .saveSchedule) { [self] in + guard canSaveDraftSchedule else { + throw ValidationError.invalidScheduledSession + } + + let weekdayNumbers = Array(Set(draftScheduleWeekdayNumbers)).sorted() + let schedule = ScheduledSessionPlan( + id: draftScheduleID ?? UUID(), + modeId: try draftScheduleModeID.orThrow(ValidationError.missingMode), + pairedTagId: try draftSchedulePairedTagID.orThrow(ValidationError.missingPairedTag), + weekdayNumbers: weekdayNumbers, + startMinuteOfDay: draftScheduleStartMinuteOfDay, + endMinuteOfDay: draftScheduleEndMinuteOfDay, + isEnabled: draftScheduleIsEnabled, + lastStartedDayKey: existingScheduledPlan()?.lastStartedDayKey, + lastEndedDayKey: existingScheduledPlan()?.lastEndedDayKey + ) + + if let index = snapshot.scheduledPlans.firstIndex(where: { $0.id == schedule.id }) { + snapshot.scheduledPlans[index] = schedule + } else { + snapshot.scheduledPlans.append(schedule) + } + + try persist() + prepareDraftForNewSchedule() + feedback = ActionFeedback(message: "Scheduled session saved.", tone: .success) + } + } + func prepareDraftForNewMode() { lastError = nil draftModeID = nil @@ -262,6 +317,17 @@ final class AppViewModel { draftModeIsStrict = false } + func prepareDraftForNewSchedule() { + lastError = nil + draftScheduleID = nil + draftScheduleModeID = selectedMode()?.id ?? preferredMode()?.id ?? snapshot.modes.first?.id + draftSchedulePairedTagID = snapshot.pairedTags.first?.id + draftScheduleWeekdayNumbers = [AnclaCore.weekdayNumber(for: nowProvider())] + draftScheduleStartMinuteOfDay = 9 * 60 + draftScheduleEndMinuteOfDay = 17 * 60 + draftScheduleIsEnabled = true + } + func prepareDraftForEditingMode(_ modeID: UUID) { lastError = nil guard let mode = snapshot.modes.first(where: { $0.id == modeID }) else { @@ -291,6 +357,66 @@ final class AppViewModel { } } + func prepareDraftForEditingScheduledPlan(_ planID: UUID) { + lastError = nil + guard let plan = snapshot.scheduledPlans.first(where: { $0.id == planID }) else { + lastError = ValidationError.missingScheduledSession.localizedDescription + return + } + + draftScheduleID = plan.id + draftScheduleModeID = plan.modeId + draftSchedulePairedTagID = plan.pairedTagId + draftScheduleWeekdayNumbers = plan.weekdayNumbers + draftScheduleStartMinuteOfDay = plan.startMinuteOfDay + draftScheduleEndMinuteOfDay = plan.endMinuteOfDay + draftScheduleIsEnabled = plan.isEnabled + } + + func deleteScheduledPlan(_ planID: UUID) async { + await runTask(action: .removeSchedule) { [self] in + guard let index = snapshot.scheduledPlans.firstIndex(where: { $0.id == planID }) else { + throw ValidationError.missingScheduledSession + } + + let deletedPlan = snapshot.scheduledPlans.remove(at: index) + if snapshot.activeSession?.scheduledPlanID == deletedPlan.id { + try releaseDeletedScheduledSession(planID: deletedPlan.id) + } else { + try persist() + } + + prepareDraftForNewSchedule() + feedback = ActionFeedback(message: "Scheduled session removed.", tone: .success) + } + } + + func toggleDraftScheduleWeekday(_ weekdayNumber: Int) { + if let index = draftScheduleWeekdayNumbers.firstIndex(of: weekdayNumber) { + draftScheduleWeekdayNumbers.remove(at: index) + } else { + draftScheduleWeekdayNumbers.append(weekdayNumber) + draftScheduleWeekdayNumbers.sort() + } + } + + func shiftDraftScheduleStart(by minutes: Int) { + draftScheduleStartMinuteOfDay = max(0, min(draftScheduleStartMinuteOfDay + minutes, max(0, draftScheduleEndMinuteOfDay - 15))) + } + + func shiftDraftScheduleEnd(by minutes: Int) { + draftScheduleEndMinuteOfDay = min(23 * 60 + 59, max(draftScheduleEndMinuteOfDay + minutes, draftScheduleStartMinuteOfDay + 15)) + } + + func useCurrentDraftScheduleWindow() { + let now = nowProvider() + let minutes = AnclaCore.minuteOfDay(for: now) + draftScheduleWeekdayNumbers = [AnclaCore.weekdayNumber(for: now)] + draftScheduleStartMinuteOfDay = max(0, minutes - 15) + draftScheduleEndMinuteOfDay = min(23 * 60 + 59, minutes + 60) + draftScheduleIsEnabled = true + } + func pairSticker() async { await runTask(action: .pairAnchor) { [self] in let uidHash = try await stickerPairingService.scanSticker() @@ -439,6 +565,8 @@ final class AppViewModel { snapshot.modes[0].isDefault = true } + snapshot.scheduledPlans.removeAll { $0.modeId == deletedMode.id } + if snapshot.activeSession?.modeId == deletedMode.id { shieldingService.clear() snapshot.activeSession = nil @@ -473,6 +601,7 @@ final class AppViewModel { } let removedTag = snapshot.pairedTags.remove(at: index) + snapshot.scheduledPlans.removeAll { $0.pairedTagId == removedTag.id } if snapshot.activeSession?.pairedTagId == removedTag.id { shieldingService.clear() @@ -500,6 +629,10 @@ final class AppViewModel { snapshot.pairedTags.first(where: { $0.id == tagID }) } + func scheduledPlan(_ planID: UUID) -> ScheduledSessionPlan? { + snapshot.scheduledPlans.first(where: { $0.id == planID }) + } + func preferredMode() -> BlockMode? { AnclaCore.preferredMode(in: snapshot) } @@ -559,14 +692,86 @@ final class AppViewModel { } func refreshFromHeader() { - refreshDiagnostics() - feedback = ActionFeedback(message: "Status refreshed.", tone: .neutral) + let changed = syncScheduledSessions() + if !changed { + feedback = ActionFeedback(message: "Status refreshed.", tone: .neutral) + } } func isActionInProgress(_ action: AppActionID) -> Bool { isBusy && activeAction == action } + @discardableResult + func syncScheduledSessions() -> Bool { + let now = nowProvider() + let dayKey = AnclaCore.dayKey(for: now) + var didChange = false + + if let activeSession = snapshot.activeSession, + let scheduledPlanID = activeSession.scheduledPlanID { + if let plan = snapshot.scheduledPlans.first(where: { $0.id == scheduledPlanID }), + !AnclaCore.scheduledPlanIsActive(plan, at: now), + let pairedTag = AnclaCore.pairedTag(for: activeSession.pairedTagId, in: snapshot), + let mode = snapshot.modes.first(where: { $0.id == activeSession.modeId }), + let index = snapshot.scheduledPlans.firstIndex(where: { $0.id == scheduledPlanID }), + activeSession.state == .armed || activeSession.state == .mismatchedTag + { + snapshot.scheduledPlans[index].lastEndedDayKey = dayKey + try? completeRelease( + activeSession: activeSession, + mode: mode, + pairedTag: pairedTag, + releaseMethod: .schedule, + releasedAt: now + ) + if lastError == nil { + feedback = ActionFeedback(message: "\"\(mode.name)\" ended on schedule.", tone: .neutral) + } + didChange = true + } + } + + if !AnclaCore.activeSessionIsBlocking(snapshot) { + for index in snapshot.scheduledPlans.indices { + let plan = snapshot.scheduledPlans[index] + guard AnclaCore.scheduledPlanIsActive(plan, at: now) else { + continue + } + guard plan.lastStartedDayKey != dayKey else { + continue + } + guard + let mode = snapshot.modes.first(where: { $0.id == plan.modeId }), + let pairedTag = AnclaCore.pairedTag(for: plan.pairedTagId, in: snapshot) + else { + continue + } + + do { + try armScheduledSession( + plan: plan, + mode: mode, + pairedTag: pairedTag, + armedAt: now, + dayKey: dayKey + ) + if lastError == nil { + feedback = ActionFeedback(message: "\"\(mode.name)\" started on schedule.", tone: .success) + } + didChange = true + } catch { + lastError = error.localizedDescription + feedback = ActionFeedback(message: error.localizedDescription, tone: .error) + } + break + } + } + + refreshDiagnostics() + return didChange + } + private func persist() throws { try store.save(snapshot) } @@ -594,13 +799,36 @@ final class AppViewModel { try persist() } + private func armScheduledSession( + plan: ScheduledSessionPlan, + mode: BlockMode, + pairedTag: PairedTag, + armedAt: Date, + dayKey: String + ) throws { + try shieldingService.apply(mode: mode) + snapshot.activeSession = AnchorSession( + pairedTagId: pairedTag.id, + modeId: mode.id, + state: .armed, + armedAt: armedAt, + scheduledPlanID: plan.id + ) + if let index = snapshot.scheduledPlans.firstIndex(where: { $0.id == plan.id }) { + snapshot.scheduledPlans[index].lastStartedDayKey = dayKey + snapshot.scheduledPlans[index].lastEndedDayKey = nil + } + selectedModeID = mode.id + try persist() + } + private func completeRelease( activeSession: AnchorSession, mode: BlockMode, pairedTag: PairedTag, - releaseMethod: SessionReleaseMethod + releaseMethod: SessionReleaseMethod, + releasedAt: Date = .now ) throws { - let releasedAt = Date.now shieldingService.clear() let releasedSession = AnchorSession( id: activeSession.id, @@ -608,7 +836,8 @@ final class AppViewModel { modeId: activeSession.modeId, state: .released, armedAt: activeSession.armedAt, - releasedAt: releasedAt + releasedAt: releasedAt, + scheduledPlanID: activeSession.scheduledPlanID ) snapshot.activeSession = releasedSession snapshot = AnclaCore.recordHistory( @@ -638,6 +867,36 @@ final class AppViewModel { || !selection.webDomainTokens.isEmpty } + private func existingScheduledPlan() -> ScheduledSessionPlan? { + guard let draftScheduleID else { + return nil + } + + return snapshot.scheduledPlans.first(where: { $0.id == draftScheduleID }) + } + + private func releaseDeletedScheduledSession(planID: UUID) throws { + guard + let activeSession = snapshot.activeSession, + activeSession.scheduledPlanID == planID, + let pairedTag = AnclaCore.pairedTag(for: activeSession.pairedTagId, in: snapshot), + let mode = snapshot.modes.first(where: { $0.id == activeSession.modeId }) + else { + snapshot.activeSession = nil + shieldingService.clear() + try persist() + return + } + + try completeRelease( + activeSession: activeSession, + mode: mode, + pairedTag: pairedTag, + releaseMethod: .schedule, + releasedAt: nowProvider() + ) + } + private func automationAdjustedEnvironment( _ environment: RuntimeEnvironmentSnapshot ) -> RuntimeEnvironmentSnapshot { @@ -682,7 +941,7 @@ final class AppViewModel { isBusy = false activeAction = nil - refreshDiagnostics() + _ = syncScheduledSessions() } } @@ -696,6 +955,8 @@ enum ValidationError: LocalizedError { case mismatchedTagOnArm case mismatchedTag case sessionNotArmed + case missingScheduledSession + case invalidScheduledSession var errorDescription: String? { switch self { @@ -717,6 +978,19 @@ enum ValidationError: LocalizedError { return "That anchor does not match the release anchor for this session." case .sessionNotArmed: return "Start a session before attempting release." + case .missingScheduledSession: + return "Create a scheduled session before editing it." + case .invalidScheduledSession: + return "Pick a mode, pick an anchor, choose at least one day, and keep the end time after the start time." + } + } +} + +private extension Optional { + func orThrow(_ error: some Error) throws -> Wrapped { + guard let self else { + throw error } + return self } } diff --git a/ios/ancla-app/content-view.swift b/ios/ancla-app/content-view.swift index 04619fe..5ad62d1 100644 --- a/ios/ancla-app/content-view.swift +++ b/ios/ancla-app/content-view.swift @@ -20,6 +20,7 @@ struct ContentView: View { private let bottomActionBarClearance: CGFloat = 132 @State private var isModeEditorPresented = false + @State private var isScheduleEditorPresented = false @State private var isShortcutGuidesPresented = false @State private var renamingAnchorID: UUID? @State private var anchorNameDraft = "" @@ -55,6 +56,13 @@ struct ContentView: View { ) .presentationBackground(.clear) } + .sheet(isPresented: $isScheduleEditorPresented) { + ScheduleEditorView( + viewModel: viewModel, + isEditingSchedule: viewModel.draftScheduleID != nil + ) + .presentationBackground(.clear) + } .sheet(isPresented: renameAnchorPresented) { renameAnchorSheet .presentationBackground(.clear) @@ -70,6 +78,12 @@ struct ContentView: View { .task { viewModel.refreshDiagnostics() } + .task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 30_000_000_000) + _ = viewModel.syncScheduledSessions() + } + } .onChange(of: renamingAnchorID) { _, tagID in if let tagID, let pairedTag = viewModel.pairedTag(tagID) { anchorNameDraft = pairedTag.displayName @@ -259,6 +273,44 @@ struct ContentView: View { surfaceDivider + sectionBlock( + title: "Scheduled sessions", + content: { + VStack(spacing: 12) { + if viewModel.scheduledPlansForDisplay.isEmpty { + informativeRow( + title: "No schedules saved", + detail: scheduledSessionsEmptyDetail, + accentColor: AnclaTheme.primaryText, + highlight: false, + trailingSymbol: "calendar.badge.plus" + ) + } else { + ForEach(viewModel.scheduledPlansForDisplay) { plan in + scheduledPlanCard(plan) + } + } + + Button { + viewModel.prepareDraftForNewSchedule() + isScheduleEditorPresented = true + } label: { + actionRow( + icon: "calendar.badge.plus", + title: "Create schedule", + detail: "Auto-start a saved mode on selected weekdays and still require a paired anchor for early release.", + isLoading: false + ) + } + .buttonStyle(AnclaPressableButtonStyle()) + .disabled(viewModel.isBusy || !canCreateScheduledPlan) + .opacity(viewModel.isBusy || !canCreateScheduledPlan ? 0.65 : 1) + } + } + ) + + surfaceDivider + sectionBlock( title: "Emergency unbricks", content: { @@ -439,6 +491,52 @@ struct ContentView: View { } } + private func scheduledPlanCard(_ plan: ScheduledSessionPlan) -> some View { + VStack(spacing: 12) { + informativeRow( + title: scheduledPlanTitle(for: plan), + detail: scheduledPlanDetail(for: plan), + accentColor: scheduledPlanAccent(for: plan), + highlight: isScheduledPlanActive(plan), + trailingText: scheduledPlanBadge(for: plan) + ) + + Button { + viewModel.prepareDraftForEditingScheduledPlan(plan.id) + isScheduleEditorPresented = true + } label: { + actionRow( + icon: "square.and.pencil", + title: "Edit schedule", + detail: "Adjust the weekdays, window, mode, or release anchor.", + isLoading: false + ) + } + .buttonStyle(AnclaPressableButtonStyle()) + .disabled(viewModel.isBusy) + + Button { + Task { await viewModel.deleteScheduledPlan(plan.id) } + } label: { + actionRow( + icon: "trash", + title: "Remove schedule", + detail: removeScheduleDetail(for: plan), + isLoading: viewModel.isActionInProgress(.removeSchedule), + isDestructive: true + ) + } + .buttonStyle( + AnclaPressableButtonStyle( + background: AnclaTheme.panelInteractive, + pressedBackground: AnclaTheme.panelRaised, + stroke: AnclaTheme.errorText.opacity(0.32) + ) + ) + .disabled(viewModel.isBusy) + } + } + private func surface( title: String, @ViewBuilder content: () -> Content @@ -1092,6 +1190,8 @@ struct ContentView: View { return "Released via \(entry.pairedTagName)" case .emergencyUnbrick: return "Emergency unbrick for \(entry.pairedTagName)" + case .schedule: + return "Ended on schedule for \(entry.pairedTagName)" } } @@ -1132,6 +1232,10 @@ struct ContentView: View { private var sessionWaitingDetail: String { if let activePairedTag = viewModel.activePairedTag { + if viewModel.snapshot.activeSession?.scheduledPlanID != nil { + return "This scheduled session is active now. \(activePairedTag.displayName) is still the early release path. \(emergencyCountSentence)" + } + if viewModel.currentModeIsStrict { return "Strict mode is active. \(activePairedTag.displayName) is the only release path. \(emergencyCountSentence)" } @@ -1231,6 +1335,151 @@ struct ContentView: View { return "This mode uses stronger, more committed copy and a native-Apple-app checklist so the easy bypasses are harder to ignore." } + + private var canCreateScheduledPlan: Bool { + !viewModel.modesForDisplay.isEmpty && !viewModel.pairedTagsForDisplay.isEmpty + } + + private var scheduledSessionsEmptyDetail: String { + if !canCreateScheduledPlan { + return "Pair at least one anchor and save at least one mode before adding a schedule." + } + + return "Schedules can auto-start saved modes on chosen weekdays and still keep a paired anchor as the manual release key." + } + + private func scheduledPlanTitle(for plan: ScheduledSessionPlan) -> String { + let modeName = viewModel.snapshot.modes.first(where: { $0.id == plan.modeId })?.name ?? "Missing mode" + return "\(modeName) • \(scheduledPlanDaysLabel(for: plan))" + } + + private func scheduledPlanDetail(for plan: ScheduledSessionPlan) -> String { + let anchorName = viewModel.pairedTag(plan.pairedTagId)?.displayName ?? "Missing anchor" + var details = [scheduledPlanTimeLabel(for: plan)] + + if isScheduledPlanActive(plan) { + details.append("Running now") + } else if let nextStart = nextScheduledStartLabel(for: plan) { + details.append(nextStart) + } + + details.append("Release early with \(anchorName)") + return details.joined(separator: " • ") + } + + private func scheduledPlanBadge(for plan: ScheduledSessionPlan) -> String { + if isScheduledPlanActive(plan) { + return "Active" + } + + if !plan.isEnabled { + return "Off" + } + + return nextScheduledStartLabel(for: plan) == nil ? "On" : "Upcoming" + } + + private func scheduledPlanAccent(for plan: ScheduledSessionPlan) -> Color { + if isScheduledPlanActive(plan) { + return AnclaTheme.warningText + } + + return plan.isEnabled ? AnclaTheme.primaryText : AnclaTheme.secondaryText + } + + private func removeScheduleDetail(for plan: ScheduledSessionPlan) -> String { + if isScheduledPlanActive(plan) { + return "Remove this schedule and release the active scheduled session from this iPhone." + } + + return "Remove this saved recurring schedule from this iPhone." + } + + private func isScheduledPlanActive(_ plan: ScheduledSessionPlan) -> Bool { + viewModel.snapshot.activeSession?.scheduledPlanID == plan.id && viewModel.canReleaseActiveSession + } + + private func scheduledPlanDaysLabel(for plan: ScheduledSessionPlan) -> String { + let names = plan.weekdayNumbers.compactMap(weekdayShortName) + guard !names.isEmpty else { + return "No days" + } + + if names.count == 7 { + return "Every day" + } + + return names.joined(separator: ", ") + } + + private func weekdayShortName(_ weekdayNumber: Int) -> String? { + switch weekdayNumber { + case 1: + return "Sun" + case 2: + return "Mon" + case 3: + return "Tue" + case 4: + return "Wed" + case 5: + return "Thu" + case 6: + return "Fri" + case 7: + return "Sat" + default: + return nil + } + } + + private func scheduledPlanTimeLabel(for plan: ScheduledSessionPlan) -> String { + "\(formattedScheduleTime(plan.startMinuteOfDay)) - \(formattedScheduleTime(plan.endMinuteOfDay))" + } + + private func formattedScheduleTime(_ minutes: Int) -> String { + let hours = minutes / 60 + let remainder = minutes % 60 + let isPM = hours >= 12 + let displayHour = ((hours + 11) % 12) + 1 + return "\(displayHour):" + String(format: "%02d", remainder) + (isPM ? " PM" : " AM") + } + + private func nextScheduledStartLabel(for plan: ScheduledSessionPlan) -> String? { + guard plan.isEnabled, !plan.weekdayNumbers.isEmpty else { + return nil + } + + let calendar = Calendar.current + let now = Date() + let startOfToday = calendar.startOfDay(for: now) + + for dayOffset in 0..<7 { + guard let candidateDay = calendar.date(byAdding: .day, value: dayOffset, to: startOfToday) else { + continue + } + + let weekday = calendar.component(.weekday, from: candidateDay) + guard plan.weekdayNumbers.contains(weekday) else { + continue + } + + let todayMinutes = calendar.dateComponents([.hour, .minute], from: now) + let currentMinuteOfDay = (todayMinutes.hour ?? 0) * 60 + (todayMinutes.minute ?? 0) + if dayOffset == 0 && currentMinuteOfDay >= plan.startMinuteOfDay { + continue + } + + guard let start = calendar.date(byAdding: .minute, value: plan.startMinuteOfDay, to: candidateDay) else { + continue + } + + let weekdayLabel = dayOffset == 0 ? "Today" : weekdayShortName(weekday) ?? start.formatted(.dateTime.weekday(.abbreviated)) + return "\(weekdayLabel) at \(start.formatted(date: .omitted, time: .shortened))" + } + + return nil + } } private extension View { diff --git a/ios/ancla-app/schedule-editor-view.swift b/ios/ancla-app/schedule-editor-view.swift new file mode 100644 index 0000000..74f3d8f --- /dev/null +++ b/ios/ancla-app/schedule-editor-view.swift @@ -0,0 +1,418 @@ +import SwiftUI + +struct ScheduleEditorView: View { + @Bindable var viewModel: AppViewModel + let isEditingSchedule: Bool + + @Environment(\.dismiss) private var dismiss + + private let weekdays: [(number: Int, short: String, long: String)] = [ + (1, "S", "Sunday"), + (2, "M", "Monday"), + (3, "T", "Tuesday"), + (4, "W", "Wednesday"), + (5, "T", "Thursday"), + (6, "F", "Friday"), + (7, "S", "Saturday"), + ] + + var body: some View { + ZStack(alignment: .top) { + AnclaTheme.background + .ignoresSafeArea() + + VStack(spacing: 0) { + Capsule(style: .continuous) + .fill(AnclaTheme.tertiaryText.opacity(0.7)) + .frame(width: 40, height: 4) + .padding(.top, 16) + + HStack { + Button("Cancel") { + dismiss() + } + .font(.ancla(14, weight: .medium)) + .foregroundStyle(AnclaTheme.secondaryText) + .padding(.horizontal, 14) + .frame(height: 38) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(AnclaTheme.panelInteractive) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(AnclaTheme.panelStroke.opacity(0.75), lineWidth: 1) + ) + ) + + Spacer() + + Text(isEditingSchedule ? "Edit Schedule" : "New Schedule") + .font(.ancla(18, weight: .bold)) + .foregroundStyle(AnclaTheme.primaryText) + + Spacer() + + Button("Save") { + Task { + await viewModel.saveScheduledPlan() + if viewModel.lastError == nil { + dismiss() + } + } + } + .font(.ancla(14, weight: .semibold)) + .foregroundStyle(AnclaTheme.ctaText) + .padding(.horizontal, 14) + .frame(height: 38) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(AnclaTheme.ctaFill) + ) + .overlay { + if viewModel.isActionInProgress(.saveSchedule) { + ProgressView() + .tint(AnclaTheme.ctaText) + } + } + .disabled(viewModel.isBusy || !viewModel.canSaveDraftSchedule) + .opacity(viewModel.isBusy || !viewModel.canSaveDraftSchedule ? 0.55 : 1) + } + .padding(.horizontal, 32) + .padding(.top, 24) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + sectionLabel("MODE") + .padding(.top, 48) + + Text("Pick the mode this schedule should start automatically on iPhone.") + .font(.ancla(14)) + .foregroundStyle(AnclaTheme.secondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 20) + + VStack(spacing: 12) { + ForEach(viewModel.modesForDisplay) { mode in + selectionCard( + title: mode.name, + detail: viewModel.selectionSummary(for: mode), + isSelected: viewModel.draftScheduleModeID == mode.id + ) { + viewModel.draftScheduleModeID = mode.id + } + } + } + .padding(.top, 22) + + divider + .padding(.top, 28) + + sectionLabel("ANCHOR") + .padding(.top, 40) + + Text("Pick the paired anchor that can release this scheduled session early.") + .font(.ancla(14)) + .foregroundStyle(AnclaTheme.secondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 20) + + VStack(spacing: 12) { + ForEach(viewModel.pairedTagsForDisplay) { pairedTag in + selectionCard( + title: pairedTag.displayName, + detail: "Scheduled sessions will still bind to this anchor for manual release.", + isSelected: viewModel.draftSchedulePairedTagID == pairedTag.id + ) { + viewModel.draftSchedulePairedTagID = pairedTag.id + } + } + } + .padding(.top, 22) + + divider + .padding(.top, 28) + + sectionLabel("DAYS") + .padding(.top, 40) + + Text("Choose the weekdays when this schedule should run.") + .font(.ancla(14)) + .foregroundStyle(AnclaTheme.secondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 20) + + HStack(spacing: 10) { + ForEach(weekdays, id: \.number) { weekday in + Button(weekday.short) { + viewModel.toggleDraftScheduleWeekday(weekday.number) + } + .font(.ancla(15, weight: .medium)) + .foregroundStyle( + viewModel.draftScheduleWeekdayNumbers.contains(weekday.number) + ? AnclaTheme.primaryText + : AnclaTheme.secondaryText + ) + .frame(maxWidth: .infinity) + .frame(height: 42) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill( + viewModel.draftScheduleWeekdayNumbers.contains(weekday.number) + ? AnclaTheme.panelRaised + : AnclaTheme.panelInteractive + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke( + viewModel.draftScheduleWeekdayNumbers.contains(weekday.number) + ? AnclaTheme.accentStroke.opacity(0.55) + : AnclaTheme.panelStroke.opacity(0.75), + lineWidth: 1 + ) + ) + ) + .accessibilityLabel(weekday.long) + } + } + .padding(.top, 22) + + divider + .padding(.top, 28) + + sectionLabel("TIME") + .padding(.top, 40) + + Text("Set when the schedule should start and end. Use the current window shortcut if you want it active right away.") + .font(.ancla(14)) + .foregroundStyle(AnclaTheme.secondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 20) + + Button { + viewModel.useCurrentDraftScheduleWindow() + } label: { + HStack { + Image(systemName: "clock.badge") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AnclaTheme.primaryText) + + Text("Use current time window") + .font(.ancla(15, weight: .medium)) + .foregroundStyle(AnclaTheme.primaryText) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AnclaTheme.tertiaryText) + } + .padding(.horizontal, 16) + .frame(height: 52) + } + .buttonStyle(AnclaPressableButtonStyle()) + .padding(.top, 22) + + timeControl( + title: "Start", + value: formattedTime(viewModel.draftScheduleStartMinuteOfDay), + earlierLabel: "Start earlier", + laterLabel: "Start later", + onEarlier: { viewModel.shiftDraftScheduleStart(by: -15) }, + onLater: { viewModel.shiftDraftScheduleStart(by: 15) } + ) + .padding(.top, 16) + + timeControl( + title: "End", + value: formattedTime(viewModel.draftScheduleEndMinuteOfDay), + earlierLabel: "End earlier", + laterLabel: "End later", + onEarlier: { viewModel.shiftDraftScheduleEnd(by: -15) }, + onLater: { viewModel.shiftDraftScheduleEnd(by: 15) } + ) + .padding(.top, 12) + + divider + .padding(.top, 28) + + sectionLabel("STATUS") + .padding(.top, 40) + + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 6) { + Text("Enabled") + .font(.ancla(16)) + .foregroundStyle(AnclaTheme.primaryText) + + Text("Disabled schedules stay saved but do not auto-start.") + .font(.ancla(12)) + .foregroundStyle(AnclaTheme.tertiaryText) + } + + Spacer() + + Toggle("", isOn: $viewModel.draftScheduleIsEnabled) + .labelsHidden() + .tint(AnclaTheme.ctaFill) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(AnclaTheme.panelInteractive) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(AnclaTheme.panelStroke.opacity(0.75), lineWidth: 1) + ) + ) + .padding(.top, 22) + + if let lastError = viewModel.lastError { + Text(lastError) + .font(.ancla(12, weight: .medium)) + .foregroundStyle(AnclaTheme.errorText) + .padding(.top, 18) + } + + HStack(spacing: 14) { + Rectangle() + .fill(AnclaTheme.panelStroke.opacity(0.4)) + .frame(height: 1) + + AnclaMark(color: AnclaTheme.tertiaryText.opacity(0.8), size: 10) + .opacity(0.55) + + Rectangle() + .fill(AnclaTheme.panelStroke.opacity(0.4)) + .frame(height: 1) + } + .padding(.top, 60) + } + .padding(.horizontal, 32) + .padding(.bottom, 36) + } + } + } + .preferredColorScheme(.dark) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) + } + + private func sectionLabel(_ title: String) -> some View { + Text(title) + .font(.ancla(10, weight: .semibold)) + .tracking(2) + .foregroundStyle(AnclaTheme.tertiaryText) + } + + private var divider: some View { + Rectangle() + .fill(AnclaTheme.panelStroke.opacity(0.6)) + .frame(height: 1) + } + + private func selectionCard( + title: String, + detail: String, + isSelected: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(alignment: .center, spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.ancla(15, weight: .medium)) + .foregroundStyle(AnclaTheme.primaryText) + + Text(detail) + .font(.ancla(12)) + .foregroundStyle(AnclaTheme.secondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer(minLength: 0) + + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(isSelected ? AnclaTheme.accentFill : AnclaTheme.tertiaryText) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(isSelected ? AnclaTheme.panelRaised : AnclaTheme.panelInteractive) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke( + isSelected ? AnclaTheme.accentStroke.opacity(0.55) : AnclaTheme.panelStroke.opacity(0.75), + lineWidth: 1 + ) + ) + ) + } + .buttonStyle(.plain) + } + + private func timeControl( + title: String, + value: String, + earlierLabel: String, + laterLabel: String, + onEarlier: @escaping () -> Void, + onLater: @escaping () -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.ancla(12, weight: .medium)) + .foregroundStyle(AnclaTheme.tertiaryText) + + HStack(spacing: 12) { + Button(earlierLabel, action: onEarlier) + .font(.ancla(13, weight: .medium)) + .foregroundStyle(AnclaTheme.secondaryText) + .frame(width: 92, height: 42) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(AnclaTheme.panelInteractive) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(AnclaTheme.panelStroke.opacity(0.75), lineWidth: 1) + ) + ) + + Text(value) + .font(.anclaMono(18)) + .foregroundStyle(AnclaTheme.primaryText) + .frame(maxWidth: .infinity) + .frame(height: 42) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(AnclaTheme.panelRaised) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(AnclaTheme.panelStroke.opacity(0.75), lineWidth: 1) + ) + ) + + Button(laterLabel, action: onLater) + .font(.ancla(13, weight: .medium)) + .foregroundStyle(AnclaTheme.secondaryText) + .frame(width: 92, height: 42) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(AnclaTheme.panelInteractive) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(AnclaTheme.panelStroke.opacity(0.75), lineWidth: 1) + ) + ) + } + } + } + + private func formattedTime(_ minutes: Int) -> String { + let hours = minutes / 60 + let remainder = minutes % 60 + let isPM = hours >= 12 + let displayHour = ((hours + 11) % 12) + 1 + return "\(displayHour):" + String(format: "%02d", remainder) + (isPM ? " PM" : " AM") + } +} diff --git a/ios/ancla-core-tests/ancla-core-tests.swift b/ios/ancla-core-tests/ancla-core-tests.swift index 23c19c1..5a70b44 100644 --- a/ios/ancla-core-tests/ancla-core-tests.swift +++ b/ios/ancla-core-tests/ancla-core-tests.swift @@ -238,6 +238,134 @@ final class AnclaCoreTests: XCTestCase { XCTAssertEqual(recent[0].releaseMethod, .anchor) } + func testScheduledPlanIsActiveRequiresEnabledMatchingWeekdayAndWindow() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let mondayMorning = Date(timeIntervalSince1970: 1_710_150_000) + let mondayWeekday = AnclaCore.weekdayNumber(for: mondayMorning, calendar: calendar) + + let activePlan = ScheduledSessionPlan( + modeId: UUID(), + pairedTagId: UUID(), + weekdayNumbers: [mondayWeekday], + startMinuteOfDay: 8 * 60, + endMinuteOfDay: 10 * 60, + isEnabled: true + ) + XCTAssertTrue(AnclaCore.scheduledPlanIsActive(activePlan, at: mondayMorning, calendar: calendar)) + + let disabledPlan = ScheduledSessionPlan( + modeId: UUID(), + pairedTagId: UUID(), + weekdayNumbers: [mondayWeekday], + startMinuteOfDay: 8 * 60, + endMinuteOfDay: 10 * 60, + isEnabled: false + ) + XCTAssertFalse(AnclaCore.scheduledPlanIsActive(disabledPlan, at: mondayMorning, calendar: calendar)) + + let wrongWeekdayPlan = ScheduledSessionPlan( + modeId: UUID(), + pairedTagId: UUID(), + weekdayNumbers: [((mondayWeekday % 7) + 1)], + startMinuteOfDay: 8 * 60, + endMinuteOfDay: 10 * 60, + isEnabled: true + ) + XCTAssertFalse(AnclaCore.scheduledPlanIsActive(wrongWeekdayPlan, at: mondayMorning, calendar: calendar)) + + let expiredPlan = ScheduledSessionPlan( + modeId: UUID(), + pairedTagId: UUID(), + weekdayNumbers: [mondayWeekday], + startMinuteOfDay: 6 * 60, + endMinuteOfDay: 8 * 60, + isEnabled: true + ) + XCTAssertFalse(AnclaCore.scheduledPlanIsActive(expiredPlan, at: mondayMorning, calendar: calendar)) + } + + func testSortedScheduledPlansPlacesActiveThenEnabledThenDisabled() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let now = Date(timeIntervalSince1970: 1_710_150_000) + let weekday = AnclaCore.weekdayNumber(for: now, calendar: calendar) + + let activePlan = ScheduledSessionPlan( + modeId: UUID(), + pairedTagId: UUID(), + weekdayNumbers: [weekday], + startMinuteOfDay: 8 * 60, + endMinuteOfDay: 10 * 60, + isEnabled: true + ) + let laterEnabledPlan = ScheduledSessionPlan( + modeId: UUID(), + pairedTagId: UUID(), + weekdayNumbers: [weekday], + startMinuteOfDay: 12 * 60, + endMinuteOfDay: 14 * 60, + isEnabled: true + ) + let disabledPlan = ScheduledSessionPlan( + modeId: UUID(), + pairedTagId: UUID(), + weekdayNumbers: [weekday], + startMinuteOfDay: 7 * 60, + endMinuteOfDay: 9 * 60, + isEnabled: false + ) + + let sorted = AnclaCore.sortedScheduledPlans( + [disabledPlan, laterEnabledPlan, activePlan], + at: now, + calendar: calendar + ) + + XCTAssertEqual(sorted.map(\.id), [activePlan.id, laterEnabledPlan.id, disabledPlan.id]) + } + + func testSnapshotAndSessionDecodeScheduleDefaultsAndLegacyAnchorFallback() throws { + let pairedTagID = UUID(uuidString: "00000000-0000-0000-0000-000000000011")! + let modeID = UUID(uuidString: "00000000-0000-0000-0000-000000000022")! + + let encodedSnapshot = """ + { + "isAuthorized": true, + "pairedTag": { + "id": "\(pairedTagID.uuidString)", + "uidHash": "legacy-hash", + "displayName": "Desk anchor", + "createdAt": "2026-04-03T10:00:00Z" + }, + "modes": [ + { + "id": "\(modeID.uuidString)", + "name": "Work", + "selectionData": "", + "isDefault": true + } + ], + "activeSession": { + "id": "00000000-0000-0000-0000-000000000033", + "pairedTagId": "\(pairedTagID.uuidString)", + "modeId": "\(modeID.uuidString)", + "state": "armed", + "armedAt": "2026-04-03T10:10:00Z" + } + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(AppSnapshot.self, from: encodedSnapshot) + + XCTAssertEqual(snapshot.pairedTags.count, 1) + XCTAssertEqual(snapshot.pairedTags.first?.displayName, "Desk anchor") + XCTAssertTrue(snapshot.scheduledPlans.isEmpty) + XCTAssertNil(snapshot.activeSession?.scheduledPlanID) + } + func testRuntimeDiagnosticsFlagMissingStorageBeforeAnythingElse() { let diagnostics = AnclaCore.runtimeDiagnostics( snapshot: AppSnapshot(), diff --git a/ios/ancla-shared/ancla-core.swift b/ios/ancla-shared/ancla-core.swift index 2c0d035..12943e8 100644 --- a/ios/ancla-shared/ancla-core.swift +++ b/ios/ancla-shared/ancla-core.swift @@ -1,6 +1,20 @@ import Foundation enum AnclaCore { + static func minuteOfDay(for date: Date, calendar: Calendar = .current) -> Int { + let components = calendar.dateComponents([.hour, .minute], from: date) + return (components.hour ?? 0) * 60 + (components.minute ?? 0) + } + + static func weekdayNumber(for date: Date, calendar: Calendar = .current) -> Int { + calendar.component(.weekday, from: date) + } + + static func dayKey(for date: Date, calendar: Calendar = .current) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) + } + static func sortedModes(_ modes: [BlockMode]) -> [BlockMode] { modes.sorted { lhs, rhs in if lhs.isDefault != rhs.isDefault { @@ -15,6 +29,51 @@ enum AnclaCore { snapshot.modes.first(where: \.isDefault) ?? snapshot.modes.first } + static func sortedScheduledPlans( + _ plans: [ScheduledSessionPlan], + at date: Date = .now, + calendar: Calendar = .current + ) -> [ScheduledSessionPlan] { + plans.sorted { lhs, rhs in + let lhsIsActive = scheduledPlanIsActive(lhs, at: date, calendar: calendar) + let rhsIsActive = scheduledPlanIsActive(rhs, at: date, calendar: calendar) + if lhsIsActive != rhsIsActive { + return lhsIsActive + } + + if lhs.isEnabled != rhs.isEnabled { + return lhs.isEnabled + } + + if lhs.startMinuteOfDay != rhs.startMinuteOfDay { + return lhs.startMinuteOfDay < rhs.startMinuteOfDay + } + + return lhs.id.uuidString < rhs.id.uuidString + } + } + + static func scheduledPlanIsActive( + _ plan: ScheduledSessionPlan, + at date: Date, + calendar: Calendar = .current + ) -> Bool { + guard plan.isEnabled else { + return false + } + + guard plan.endMinuteOfDay > plan.startMinuteOfDay else { + return false + } + + guard plan.weekdayNumbers.contains(weekdayNumber(for: date, calendar: calendar)) else { + return false + } + + let minutes = minuteOfDay(for: date, calendar: calendar) + return minutes >= plan.startMinuteOfDay && minutes < plan.endMinuteOfDay + } + static func repairedSnapshot(_ snapshot: AppSnapshot) -> AppSnapshot { guard !snapshot.modes.isEmpty else { return snapshot diff --git a/ios/ancla-shared/ancla-models.swift b/ios/ancla-shared/ancla-models.swift index 603bfab..12b48ba 100644 --- a/ios/ancla-shared/ancla-models.swift +++ b/ios/ancla-shared/ancla-models.swift @@ -58,6 +58,40 @@ struct BlockMode: Codable, Equatable, Identifiable { } } +struct ScheduledSessionPlan: Codable, Equatable, Identifiable { + let id: UUID + var modeId: UUID + var pairedTagId: UUID + var weekdayNumbers: [Int] + var startMinuteOfDay: Int + var endMinuteOfDay: Int + var isEnabled: Bool + var lastStartedDayKey: String? + var lastEndedDayKey: String? + + init( + id: UUID = UUID(), + modeId: UUID, + pairedTagId: UUID, + weekdayNumbers: [Int], + startMinuteOfDay: Int, + endMinuteOfDay: Int, + isEnabled: Bool = true, + lastStartedDayKey: String? = nil, + lastEndedDayKey: String? = nil + ) { + self.id = id + self.modeId = modeId + self.pairedTagId = pairedTagId + self.weekdayNumbers = Array(Set(weekdayNumbers)).sorted() + self.startMinuteOfDay = startMinuteOfDay + self.endMinuteOfDay = endMinuteOfDay + self.isEnabled = isEnabled + self.lastStartedDayKey = lastStartedDayKey + self.lastEndedDayKey = lastEndedDayKey + } +} + enum AnchorSessionState: String, Codable { case idle case armed @@ -66,12 +100,23 @@ enum AnchorSessionState: String, Codable { } struct AnchorSession: Codable, Equatable, Identifiable { + private enum CodingKeys: String, CodingKey { + case id + case pairedTagId + case modeId + case state + case armedAt + case releasedAt + case scheduledPlanID + } + let id: UUID let pairedTagId: UUID let modeId: UUID var state: AnchorSessionState let armedAt: Date var releasedAt: Date? + var scheduledPlanID: UUID? init( id: UUID = UUID(), @@ -79,7 +124,8 @@ struct AnchorSession: Codable, Equatable, Identifiable { modeId: UUID, state: AnchorSessionState, armedAt: Date = .now, - releasedAt: Date? = nil + releasedAt: Date? = nil, + scheduledPlanID: UUID? = nil ) { self.id = id self.pairedTagId = pairedTagId @@ -87,12 +133,25 @@ struct AnchorSession: Codable, Equatable, Identifiable { self.state = state self.armedAt = armedAt self.releasedAt = releasedAt + self.scheduledPlanID = scheduledPlanID + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + pairedTagId = try container.decode(UUID.self, forKey: .pairedTagId) + modeId = try container.decode(UUID.self, forKey: .modeId) + state = try container.decode(AnchorSessionState.self, forKey: .state) + armedAt = try container.decode(Date.self, forKey: .armedAt) + releasedAt = try container.decodeIfPresent(Date.self, forKey: .releasedAt) + scheduledPlanID = try container.decodeIfPresent(UUID.self, forKey: .scheduledPlanID) } } enum SessionReleaseMethod: String, Codable { case anchor case emergencyUnbrick + case schedule } struct SessionHistoryEntry: Codable, Equatable, Identifiable { @@ -134,12 +193,24 @@ struct SessionHistoryEntry: Codable, Equatable, Identifiable { } struct AppSnapshot: Codable, Equatable { + private enum CodingKeys: String, CodingKey { + case isAuthorized + case pairedTag + case pairedTags + case modes + case activeSession + case sessionHistory + case emergencyUnbricksRemaining + case scheduledPlans + } + var isAuthorized = false var pairedTags: [PairedTag] = [] var modes: [BlockMode] = [] var activeSession: AnchorSession? var sessionHistory: [SessionHistoryEntry] = [] var emergencyUnbricksRemaining = 5 + var scheduledPlans: [ScheduledSessionPlan] = [] init( isAuthorized: Bool = false, @@ -148,7 +219,8 @@ struct AppSnapshot: Codable, Equatable { modes: [BlockMode] = [], activeSession: AnchorSession? = nil, sessionHistory: [SessionHistoryEntry] = [], - emergencyUnbricksRemaining: Int = 5 + emergencyUnbricksRemaining: Int = 5, + scheduledPlans: [ScheduledSessionPlan] = [] ) { self.isAuthorized = isAuthorized self.pairedTags = pairedTags.isEmpty ? (pairedTag.map { [$0] } ?? []) : pairedTags @@ -156,6 +228,36 @@ struct AppSnapshot: Codable, Equatable { self.activeSession = activeSession self.sessionHistory = sessionHistory self.emergencyUnbricksRemaining = emergencyUnbricksRemaining + self.scheduledPlans = scheduledPlans + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + isAuthorized = try container.decodeIfPresent(Bool.self, forKey: .isAuthorized) ?? false + let decodedPairedTags = try container.decodeIfPresent([PairedTag].self, forKey: .pairedTags) + if let decodedPairedTags, !decodedPairedTags.isEmpty { + pairedTags = decodedPairedTags + } else if let pairedTag = try container.decodeIfPresent(PairedTag.self, forKey: .pairedTag) { + pairedTags = [pairedTag] + } else { + pairedTags = [] + } + modes = try container.decodeIfPresent([BlockMode].self, forKey: .modes) ?? [] + activeSession = try container.decodeIfPresent(AnchorSession.self, forKey: .activeSession) + sessionHistory = try container.decodeIfPresent([SessionHistoryEntry].self, forKey: .sessionHistory) ?? [] + emergencyUnbricksRemaining = try container.decodeIfPresent(Int.self, forKey: .emergencyUnbricksRemaining) ?? 5 + scheduledPlans = try container.decodeIfPresent([ScheduledSessionPlan].self, forKey: .scheduledPlans) ?? [] + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(isAuthorized, forKey: .isAuthorized) + try container.encode(pairedTags, forKey: .pairedTags) + try container.encode(modes, forKey: .modes) + try container.encodeIfPresent(activeSession, forKey: .activeSession) + try container.encode(sessionHistory, forKey: .sessionHistory) + try container.encode(emergencyUnbricksRemaining, forKey: .emergencyUnbricksRemaining) + try container.encode(scheduledPlans, forKey: .scheduledPlans) } var pairedTag: PairedTag? { diff --git a/ios/ancla-tests/app-view-model-tests.swift b/ios/ancla-tests/app-view-model-tests.swift index 5cf251d..5306598 100644 --- a/ios/ancla-tests/app-view-model-tests.swift +++ b/ios/ancla-tests/app-view-model-tests.swift @@ -570,6 +570,147 @@ final class AppViewModelTests: XCTestCase { XCTAssertEqual(viewModel.lastError, ValidationError.noTargetsSelected.errorDescription) XCTAssertTrue(viewModel.snapshot.modes.isEmpty) } + + func testSaveScheduledPlanPersistsModeAnchorDaysAndWindow() async throws { + let mode = BlockMode(name: "Locked down", selectionData: Data(), isDefault: true, isStrict: true) + let pairedTag = PairedTag(uidHash: "desk-hash", displayName: "Desk anchor") + let viewModel = AppViewModel( + buildVariant: .sideloadLite, + store: InMemorySnapshotStore( + snapshot: AppSnapshot( + isAuthorized: true, + pairedTags: [pairedTag], + modes: [mode] + ) + ), + shieldingService: FakeShieldingService(), + stickerPairingService: FakeStickerPairingService() + ) + + viewModel.draftScheduleModeID = mode.id + viewModel.draftSchedulePairedTagID = pairedTag.id + viewModel.draftScheduleWeekdayNumbers = [2, 4, 6] + viewModel.draftScheduleStartMinuteOfDay = 8 * 60 + 30 + viewModel.draftScheduleEndMinuteOfDay = 11 * 60 + await viewModel.saveScheduledPlan() + + let savedPlan = try XCTUnwrap(viewModel.snapshot.scheduledPlans.first) + XCTAssertNil(viewModel.lastError) + XCTAssertEqual(savedPlan.modeId, mode.id) + XCTAssertEqual(savedPlan.pairedTagId, pairedTag.id) + XCTAssertEqual(savedPlan.weekdayNumbers, [2, 4, 6]) + XCTAssertEqual(savedPlan.startMinuteOfDay, 8 * 60 + 30) + XCTAssertEqual(savedPlan.endMinuteOfDay, 11 * 60) + XCTAssertTrue(savedPlan.isEnabled) + } + + func testUseCurrentDraftScheduleWindowMatchesProvidedClock() { + let now = Date(timeIntervalSince1970: 1_710_150_000) + let viewModel = AppViewModel( + buildVariant: .sideloadLite, + store: InMemorySnapshotStore(), + stickerPairingService: FakeStickerPairingService(), + nowProvider: { now } + ) + + viewModel.useCurrentDraftScheduleWindow() + + let minute = AnclaCore.minuteOfDay(for: now) + XCTAssertEqual(viewModel.draftScheduleWeekdayNumbers, [AnclaCore.weekdayNumber(for: now)]) + XCTAssertEqual(viewModel.draftScheduleStartMinuteOfDay, max(0, minute - 15)) + XCTAssertEqual(viewModel.draftScheduleEndMinuteOfDay, min(23 * 60 + 59, minute + 60)) + XCTAssertTrue(viewModel.draftScheduleIsEnabled) + } + + func testSyncScheduledSessionsStartsMatchingPlanAutomatically() throws { + let now = Date(timeIntervalSince1970: 1_710_150_000) + let mode = BlockMode(name: "Focus", selectionData: Data(), isDefault: true) + let pairedTag = PairedTag(uidHash: "desk-hash", displayName: "Desk anchor") + let weekday = AnclaCore.weekdayNumber(for: now) + let plan = ScheduledSessionPlan( + modeId: mode.id, + pairedTagId: pairedTag.id, + weekdayNumbers: [weekday], + startMinuteOfDay: AnclaCore.minuteOfDay(for: now) - 5, + endMinuteOfDay: AnclaCore.minuteOfDay(for: now) + 30 + ) + let shielding = FakeShieldingService() + let viewModel = AppViewModel( + buildVariant: .sideloadLite, + store: InMemorySnapshotStore( + snapshot: AppSnapshot( + isAuthorized: true, + pairedTags: [pairedTag], + modes: [mode] + ) + ), + shieldingService: shielding, + stickerPairingService: FakeStickerPairingService(), + nowProvider: { now } + ) + + viewModel.snapshot.scheduledPlans = [plan] + let changed = viewModel.syncScheduledSessions() + + XCTAssertTrue(changed) + XCTAssertEqual(viewModel.snapshot.activeSession?.state, .armed) + XCTAssertEqual(viewModel.snapshot.activeSession?.scheduledPlanID, plan.id) + XCTAssertEqual(viewModel.snapshot.activeSession?.pairedTagId, pairedTag.id) + XCTAssertEqual(viewModel.snapshot.activeSession?.modeId, mode.id) + XCTAssertEqual(viewModel.snapshot.scheduledPlans.first?.lastStartedDayKey, AnclaCore.dayKey(for: now)) + XCTAssertEqual(shielding.appliedModeIDs, [mode.id]) + } + + func testSyncScheduledSessionsEndsExpiredScheduledSessionWithScheduleHistory() throws { + var currentTime = Date(timeIntervalSince1970: 1_710_150_000) + let mode = BlockMode(name: "Focus", selectionData: Data(), isDefault: true) + let pairedTag = PairedTag(uidHash: "desk-hash", displayName: "Desk anchor") + let dayKey = AnclaCore.dayKey(for: currentTime) + let plan = ScheduledSessionPlan( + modeId: mode.id, + pairedTagId: pairedTag.id, + weekdayNumbers: [AnclaCore.weekdayNumber(for: currentTime)], + startMinuteOfDay: AnclaCore.minuteOfDay(for: currentTime) - 120, + endMinuteOfDay: AnclaCore.minuteOfDay(for: currentTime) - 30, + isEnabled: true, + lastStartedDayKey: dayKey + ) + currentTime = Date(timeIntervalSince1970: 1_710_145_500) + let activeSession = AnchorSession( + pairedTagId: pairedTag.id, + modeId: mode.id, + state: .armed, + armedAt: currentTime.addingTimeInterval(-3_600), + scheduledPlanID: plan.id + ) + let shielding = FakeShieldingService() + let viewModel = AppViewModel( + buildVariant: .sideloadLite, + store: InMemorySnapshotStore( + snapshot: AppSnapshot( + isAuthorized: true, + pairedTags: [pairedTag], + modes: [mode], + activeSession: activeSession, + scheduledPlans: [plan] + ) + ), + shieldingService: shielding, + stickerPairingService: FakeStickerPairingService(), + nowProvider: { currentTime } + ) + + currentTime = Date(timeIntervalSince1970: 1_710_150_000) + let changed = viewModel.syncScheduledSessions() + + XCTAssertTrue(changed) + XCTAssertEqual(viewModel.snapshot.activeSession?.state, .released) + XCTAssertEqual(viewModel.snapshot.activeSession?.scheduledPlanID, plan.id) + XCTAssertEqual(viewModel.snapshot.sessionHistory.count, 1) + XCTAssertEqual(viewModel.snapshot.sessionHistory.first?.releaseMethod, .schedule) + XCTAssertEqual(viewModel.snapshot.scheduledPlans.first?.lastEndedDayKey, dayKey) + XCTAssertEqual(shielding.clearCallCount, 1) + } } private final class InMemorySnapshotStore: AppSnapshotStore {