From 262adf8c7cbdae458ad95b7c67a88c83a34ababd Mon Sep 17 00:00:00 2001 From: michaelmvh Date: Tue, 1 Sep 2026 23:43:45 -0700 Subject: [PATCH] Add configurable break allowances Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BlockedProfileDraft.swift | 40 +- .../BlockedProfileFormSections.swift | 485 ++++++++++++++++-- Foqos/Components/Debug/ProfileDebugCard.swift | 10 + Foqos/Models/BlockedProfileSessions.swift | 124 ++++- Foqos/Models/BlockedProfiles.swift | 126 ++++- Foqos/Models/Shared.swift | 293 ++++++++++- Foqos/Models/Timers/BreakTimerActivity.swift | 19 +- Foqos/Utils/SessionTimeCalculator.swift | 4 +- Foqos/Utils/StrategyManager.swift | 5 +- Foqos/Views/ActiveProfileSessionView.swift | 27 + Foqos/Views/BlockedProfileView.swift | 2 +- Foqos/Views/DebugView.swift | 6 + .../GuidedBlockedProfileCreationView.swift | 12 +- README.md | 10 +- foqosTests/BlockedProfileDraftTests.swift | 11 + foqosTests/BreakAllowanceTests.swift | 342 ++++++++++++ foqosTests/SessionTimeCalculatorTests.swift | 2 +- 17 files changed, 1427 insertions(+), 91 deletions(-) create mode 100644 foqosTests/BreakAllowanceTests.swift diff --git a/Foqos/Components/BlockedProfileView/BlockedProfileDraft.swift b/Foqos/Components/BlockedProfileView/BlockedProfileDraft.swift index ee599595..e5b09b7d 100644 --- a/Foqos/Components/BlockedProfileView/BlockedProfileDraft.swift +++ b/Foqos/Components/BlockedProfileView/BlockedProfileDraft.swift @@ -9,7 +9,12 @@ final class BlockedProfileDraft: ObservableObject { @Published var enableReminder: Bool @Published var enableBreaks: Bool @Published var breakTimeInMinutes: Int - @Published var allowMultipleBreaks: Bool + @Published var breakAllowanceMode: BreakAllowanceMode + @Published var breakCountLimit: Int + @Published var isBreakCountUnlimited: Bool + @Published var breakResetHour: Int + @Published var breakResetMinute: Int + @Published var breakResetPolicy: BreakResetPolicy @Published var enableStrictMode: Bool @Published var enableBlockAppInstallation: Bool @Published var reminderTimeInMinutes: Int @@ -49,7 +54,12 @@ final class BlockedProfileDraft: ObservableObject { enableLiveActivity = profile?.enableLiveActivity ?? false enableBreaks = profile?.enableBreaks ?? false breakTimeInMinutes = profile?.breakTimeInMinutes ?? 15 - allowMultipleBreaks = profile?.allowMultipleBreaks ?? false + breakAllowanceMode = profile?.breakAllowanceMode ?? .perBreak + breakCountLimit = profile?.breakCountLimit ?? 1 + isBreakCountUnlimited = profile?.isBreakCountUnlimited ?? false + breakResetHour = profile?.breakResetHour ?? 0 + breakResetMinute = profile?.breakResetMinute ?? 0 + breakResetPolicy = profile?.breakResetPolicy ?? .daily enableStrictMode = profile?.enableStrictMode ?? false enableBlockAppInstallation = profile?.enableBlockAppInstallation ?? false enableAllowMode = profile?.enableAllowMode ?? false @@ -130,7 +140,13 @@ final class BlockedProfileDraft: ObservableObject { customReminderMessage: customReminderMessage, enableBreaks: enableTimedBreaksToSave, breakTimeInMinutes: breakTimeInMinutes, - allowMultipleBreaks: enableTimedBreaksToSave && allowMultipleBreaks, + allowMultipleBreaks: enableTimedBreaksToSave && permitsMultipleBreaksPerPeriod, + breakAllowanceMode: breakAllowanceMode, + breakCountLimit: breakCountLimit, + isBreakCountUnlimited: isBreakCountUnlimited, + breakResetHour: breakResetHour, + breakResetMinute: breakResetMinute, + breakResetPolicy: breakResetPolicy, enableStrictMode: enableStrictMode, enableBlockAppInstallation: enableBlockAppInstallation, enableAllowMode: enableAllowMode, @@ -161,7 +177,13 @@ final class BlockedProfileDraft: ObservableObject { customReminderMessage: customReminderMessage, enableBreaks: enableTimedBreaksToSave, breakTimeInMinutes: breakTimeInMinutes, - allowMultipleBreaks: enableTimedBreaksToSave && allowMultipleBreaks, + allowMultipleBreaks: enableTimedBreaksToSave && permitsMultipleBreaksPerPeriod, + breakAllowanceMode: breakAllowanceMode, + breakCountLimit: breakCountLimit, + isBreakCountUnlimited: isBreakCountUnlimited, + breakResetHour: breakResetHour, + breakResetMinute: breakResetMinute, + breakResetPolicy: breakResetPolicy, enableStrictMode: enableStrictMode, enableBlockAppInstallation: enableBlockAppInstallation, enableAllowMode: enableAllowMode, @@ -183,7 +205,6 @@ final class BlockedProfileDraft: ObservableObject { private func enforceStrategyPolicies() { if !selectedStrategyAllowsTimedBreaks { enableBreaks = false - allowMultipleBreaks = false } if !selectedStrategySupportsAllowMode && enableAllowMode { @@ -192,6 +213,15 @@ final class BlockedProfileDraft: ObservableObject { } } + private var permitsMultipleBreaksPerPeriod: Bool { + switch breakAllowanceMode { + case .perBreak: + return isBreakCountUnlimited || breakCountLimit > 1 + case .cumulative: + return true + } + } + private func ensureSavedStartSettings() { guard !askForStartSettings, strategyData == nil, let settingsKind = StrategyStartSettingsKind(strategy: selectedStrategy) diff --git a/Foqos/Components/BlockedProfileView/BlockedProfileFormSections.swift b/Foqos/Components/BlockedProfileView/BlockedProfileFormSections.swift index dcdfb501..3319b392 100644 --- a/Foqos/Components/BlockedProfileView/BlockedProfileFormSections.swift +++ b/Foqos/Components/BlockedProfileView/BlockedProfileFormSections.swift @@ -313,93 +313,486 @@ struct BlockedProfileBreaksFields: View { @EnvironmentObject private var themeManager: ThemeManager @ObservedObject var draft: BlockedProfileDraft + var profile: BlockedProfiles? = nil var disabled: Bool var showsSeparators: Bool = false - private let minimumBreakDurationInMinutes = 5 - private let maximumBreakDurationInMinutes = 60 + @State private var showingCustomBreakLimit = false + @State private var showingResetConfirmation = false @ViewBuilder var body: some View { - if draft.selectedStrategyAllowsTimedBreaks { - CustomToggle( - title: "Allow Timed Breaks", - description: - "Take a break during your session. The break will automatically end after the selected duration.", - isOn: $draft.enableBreaks, - isDisabled: disabled - ) + Group { + if draft.selectedStrategyAllowsTimedBreaks { + CustomToggle( + title: "Allow Timed Breaks", + description: + "Take a break during your session. The break will automatically end after the selected duration.", + isOn: $draft.enableBreaks, + isDisabled: disabled + ) - if draft.enableBreaks { - ProfileFieldDivider(isVisible: showsSeparators) + if draft.enableBreaks { + ProfileFieldDivider(isVisible: showsSeparators) - breakDurationPicker + allowanceModePicker - ProfileFieldDivider(isVisible: showsSeparators) + ProfileFieldDivider(isVisible: showsSeparators) - CustomToggle( - title: "Allow Multiple Breaks", - description: "Take multiple breaks until your total break duration is used.", - isOn: $draft.allowMultipleBreaks, - isDisabled: disabled + durationRow + + if draft.breakAllowanceMode == .perBreak { + ProfileFieldDivider(isVisible: showsSeparators) + + breakLimitRow + } + + if showsResetControls { + ProfileFieldDivider(isVisible: showsSeparators) + + resetPolicyPicker + + if draft.breakResetPolicy == .daily { + ProfileFieldDivider(isVisible: showsSeparators) + + DatePicker( + "Reset Time", + selection: breakResetTimeBinding, + displayedComponents: .hourAndMinute + ) + .disabled(disabled) + } else if profile != nil { + ProfileFieldDivider(isVisible: showsSeparators) + + Button("Reset Break Usage Now") { + showingResetConfirmation = true + } + .disabled(disabled) + } + + Text(resetDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } else { + ProfileFieldNotice( + title: "Breaks are off for Temporary Access", + message: + "This strategy already gives short opens for blocked apps and categories, so timed breaks are not needed for this profile." ) } - } else { - ProfileFieldNotice( - title: "Breaks are off for Temporary Access", - message: - "This strategy already gives short opens for blocked apps and categories, so timed breaks are not needed for this profile." - ) + } + .navigationDestination(isPresented: $showingCustomBreakLimit) { + BreakCountPickerView(breakCountLimit: $draft.breakCountLimit) { + draft.isBreakCountUnlimited = false + } + } + .alert("Reset Break Usage?", isPresented: $showingResetConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Reset") { + if let profile { + SharedData.resetBreakAllowanceUsage(for: profile.id) + } + } + } message: { + Text("This immediately restores the full configured break allowance.") } } - private var breakDurationPicker: some View { - VStack(alignment: .leading, spacing: 10) { + private var allowanceModePicker: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Allowance Mode") + .font(.subheadline) + .fontWeight(.semibold) + .padding(.bottom, 4) + + selectionButton( + title: "Per Break", + description: "Each break receives the configured duration.", + isSelected: draft.breakAllowanceMode == .perBreak + ) { + draft.breakAllowanceMode = .perBreak + } + + Divider() + + selectionButton( + title: "Shared Budget", + description: "All breaks consume one pool of time.", + isSelected: draft.breakAllowanceMode == .cumulative + ) { + draft.breakAllowanceMode = .cumulative + } + } + .disabled(disabled) + } + + private var durationRow: some View { + NavigationLink { + BreakDurationSelectionView( + mode: draft.breakAllowanceMode, + durationInMinutes: $draft.breakTimeInMinutes + ) + } label: { HStack { - Text("Break Duration") + Text(draft.breakAllowanceMode == .perBreak ? "Duration Per Break" : "Total Break Budget") Spacer() Text(DateFormatters.formatMinutes(draft.breakTimeInMinutes)) - .fontWeight(.semibold) .foregroundStyle(.secondary) .contentTransition(.numericText()) } + } + .disabled(disabled) + } + + private var breakLimitRow: some View { + HStack { + Text(breakLimitTitle) + + Spacer() + + Menu { + ForEach([1, 3, 5, 10], id: \.self) { count in + Button { + draft.breakCountLimit = count + draft.isBreakCountUnlimited = false + } label: { + menuLabel( + title: "\(count)", + isSelected: !draft.isBreakCountUnlimited && draft.breakCountLimit == count + ) + } + } + + Divider() + + Button { + showingCustomBreakLimit = true + } label: { + Label("Custom…", systemImage: "number") + } + + Button { + draft.isBreakCountUnlimited = true + } label: { + menuLabel(title: "Unlimited", isSelected: draft.isBreakCountUnlimited) + } + } label: { + HStack(spacing: 4) { + Text(breakLimitValue) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + } + .foregroundStyle(themeManager.themeColor) + } + .disabled(disabled) + } + } - Slider( - value: breakDurationBinding, - in: Double(minimumBreakDurationInMinutes)...Double(maximumBreakDurationInMinutes), - step: 5 - ) - .tint(themeManager.themeColor) - .accessibilityValue(DateFormatters.formatMinutes(draft.breakTimeInMinutes)) + private var resetPolicyPicker: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Reset Schedule") + .font(.subheadline) + .fontWeight(.semibold) + .padding(.bottom, 4) + + selectionButton( + title: "Daily", + description: "Restore the allowance at a selected local time.", + isSelected: draft.breakResetPolicy == .daily + ) { + draft.breakResetPolicy = .daily + } - HStack { - Text("5m") - Spacer() - Text("1h") + Divider() + + selectionButton( + title: "Never", + description: "Preserve usage until it is manually reset.", + isSelected: draft.breakResetPolicy == .never + ) { + draft.breakResetPolicy = .never } - .font(.caption2) - .foregroundStyle(.secondary) } .disabled(disabled) } - private var breakDurationBinding: Binding { + private func selectionButton( + title: String, + description: String, + isSelected: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? themeManager.themeColor : Color.secondary) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .foregroundStyle(.primary) + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + } + + Spacer() + } + .padding(.vertical, 8) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + @ViewBuilder + private func menuLabel(title: String, isSelected: Bool) -> some View { + if isSelected { + Label(title, systemImage: "checkmark") + } else { + Text(title) + } + } + + private var breakLimitTitle: String { + draft.breakResetPolicy == .daily ? "Breaks Per Day" : "Total Break Limit" + } + + private var breakLimitValue: String { + draft.isBreakCountUnlimited ? "Unlimited" : "\(draft.breakCountLimit)" + } + + private var showsResetControls: Bool { + draft.breakAllowanceMode == .cumulative || !draft.isBreakCountUnlimited + } + + private var resetDescription: String { + switch draft.breakResetPolicy { + case .daily: + return "The allowance renews every day at the selected local time." + case .never: + return "Used break time or break counts remain consumed until manually reset." + } + } + + private var breakResetTimeBinding: Binding { Binding( - get: { Double(draft.breakTimeInMinutes) }, - set: { draft.breakTimeInMinutes = Int($0) } + get: { + Calendar.current.date( + bySettingHour: draft.breakResetHour, + minute: draft.breakResetMinute, + second: 0, + of: Date() + ) ?? Date() + }, + set: { date in + draft.breakResetHour = Calendar.current.component(.hour, from: date) + draft.breakResetMinute = Calendar.current.component(.minute, from: date) + } ) } } +private struct BreakDurationSelectionView: View { + @Environment(\.dismiss) private var dismiss + + let mode: BreakAllowanceMode + @Binding var durationInMinutes: Int + + var body: some View { + List { + Section("Recommended") { + ForEach(durationPresets, id: \.self) { duration in + Button { + durationInMinutes = duration + dismiss() + } label: { + HStack { + Text(DateFormatters.formatMinutes(duration)) + .foregroundStyle(.primary) + Spacer() + if durationInMinutes == duration { + Image(systemName: "checkmark") + } + } + } + } + } + + Section { + NavigationLink { + BreakDurationPickerView( + title: mode == .perBreak ? "Duration Per Break" : "Total Break Budget", + durationInMinutes: $durationInMinutes + ) + } label: { + HStack { + Text("Custom Duration") + Spacer() + if !durationPresets.contains(durationInMinutes) { + Text(DateFormatters.formatMinutes(durationInMinutes)) + .foregroundStyle(.secondary) + } + } + } + } footer: { + if durationInMinutes > 4 * 60 { + Text("Long breaks leave blocked apps available for most or all of the allowance period.") + } else { + Text("Custom durations can range from 5 minutes to 24 hours.") + } + } + } + .navigationTitle(mode == .perBreak ? "Break Duration" : "Break Budget") + } + + private var durationPresets: [Int] { + switch mode { + case .perBreak: + return [5, 10, 15, 30, 45, 60] + case .cumulative: + return [15, 30, 45, 60, 120, 240, 480, 720, 1_440] + } + } +} + +private struct BreakCountPickerView: View { + @Environment(\.dismiss) private var dismiss + + @Binding var breakCountLimit: Int + let onSave: () -> Void + + @State private var selectedCount: Int + + init(breakCountLimit: Binding, onSave: @escaping () -> Void) { + _breakCountLimit = breakCountLimit + self.onSave = onSave + _selectedCount = State(initialValue: min(max(breakCountLimit.wrappedValue, 1), 100)) + } + + var body: some View { + Form { + Section { + Stepper(value: $selectedCount, in: 1...100) { + HStack { + Text("Break Limit") + Spacer() + Text("\(selectedCount)") + .foregroundStyle(.secondary) + } + } + } footer: { + Text("Choose how many breaks are available before the allowance resets.") + } + } + .navigationTitle("Custom Break Limit") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + breakCountLimit = selectedCount + onSave() + dismiss() + } + } + } + } +} + +private struct BreakDurationPickerView: View { + @Environment(\.dismiss) private var dismiss + + let title: String + @Binding var durationInMinutes: Int + + @State private var hours: Int + @State private var minutes: Int + + init(title: String, durationInMinutes: Binding) { + self.title = title + _durationInMinutes = durationInMinutes + + let initialDuration = min(max(durationInMinutes.wrappedValue, 5), 24 * 60) + _hours = State(initialValue: initialDuration / 60) + _minutes = State(initialValue: initialDuration % 60 / 5 * 5) + } + + var body: some View { + VStack(spacing: 12) { + HStack(spacing: 0) { + durationColumn(title: "Hours", selection: $hours, values: Array(0...24)) + durationColumn(title: "Minutes", selection: $minutes, values: availableMinutes) + } + .frame(height: 190) + + if selectedDurationInMinutes < 5 { + Text("Choose a duration of at least 5 minutes.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding(.horizontal) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + durationInMinutes = selectedDurationInMinutes + dismiss() + } + .disabled(selectedDurationInMinutes < 5) + } + } + .onChange(of: hours) { _, newValue in + if newValue == 24 { + minutes = 0 + } + } + } + + private var selectedDurationInMinutes: Int { + hours * 60 + minutes + } + + private var availableMinutes: [Int] { + hours == 24 ? [0] : Array(stride(from: 0, through: 55, by: 5)) + } + + private func durationColumn( + title: String, + selection: Binding, + values: [Int] + ) -> some View { + VStack(spacing: 0) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + + Picker(title, selection: selection) { + ForEach(values, id: \.self) { value in + Text("\(value)").tag(value) + } + } + .pickerStyle(.wheel) + } + .frame(maxWidth: .infinity) + } +} + struct BlockedProfileBreaksSection: View { @ObservedObject var draft: BlockedProfileDraft + var profile: BlockedProfiles? = nil var disabled: Bool var body: some View { Section("Breaks") { - BlockedProfileBreaksFields(draft: draft, disabled: disabled) + BlockedProfileBreaksFields(draft: draft, profile: profile, disabled: disabled) } } } diff --git a/Foqos/Components/Debug/ProfileDebugCard.swift b/Foqos/Components/Debug/ProfileDebugCard.swift index d856e26c..661a8f42 100644 --- a/Foqos/Components/Debug/ProfileDebugCard.swift +++ b/Foqos/Components/Debug/ProfileDebugCard.swift @@ -24,6 +24,16 @@ struct ProfileDebugCard: View { DebugRow(label: "Enable Breaks", value: "\(profile.enableBreaks)") DebugRow(label: "Break Time (minutes)", value: "\(profile.breakTimeInMinutes)") DebugRow(label: "Allow Multiple Breaks", value: "\(profile.allowMultipleBreaks)") + DebugRow(label: "Break Allowance Mode", value: profile.breakAllowanceMode.title) + DebugRow( + label: "Break Count Limit", + value: profile.resolvedBreakCountLimit.map { "\($0)" } ?? "Unlimited" + ) + DebugRow( + label: "Break Reset Time", + value: String(format: "%02d:%02d", profile.breakResetHour, profile.breakResetMinute) + ) + DebugRow(label: "Break Reset Policy", value: profile.breakResetPolicy.title) DebugRow(label: "Enable Strict Mode", value: "\(profile.enableStrictMode)") DebugRow(label: "Enable Allow Mode", value: "\(profile.enableAllowMode)") DebugRow( diff --git a/Foqos/Models/BlockedProfileSessions.swift b/Foqos/Models/BlockedProfileSessions.swift index 78590eaa..7854d27e 100644 --- a/Foqos/Models/BlockedProfileSessions.swift +++ b/Foqos/Models/BlockedProfileSessions.swift @@ -25,17 +25,35 @@ class BlockedProfileSession { } var isBreakAvailable: Bool { + isBreakAvailable(at: Date()) + } + + func isBreakAvailable(at date: Date) -> Bool { guard blockedProfile.enableBreaks == true, blockedProfile.allowsTimedBreaks else { return false } - if blockedProfile.allowMultipleBreaks { - return remainingBreakAllowance() > 0 + if isBreakActive { + return true } - return breakEndTime == nil + let usage = breakAllowanceUsage(at: date) + switch blockedProfile.breakAllowanceMode { + case .perBreak: + if blockedProfile.breakAllowanceModeRawValue == nil, + !blockedProfile.allowMultipleBreaks, + breakEndTime != nil + { + return false + } + return blockedProfile.resolvedBreakCountLimit.map { + usage.breaksStarted < $0 + } ?? true + case .cumulative: + return remainingBreakAllowance(at: date) > 0 + } } var isBreakActive: Bool { @@ -60,12 +78,12 @@ class BlockedProfileSession { /// Break time this session consumed, across every break it contained. /// - /// `breakStartTime` and `breakEndTime` only ever describe the most recent break, because - /// `startBreak(at:)` clears them whenever multiple breaks are allowed, so reporting has to + /// `breakStartTime` and `breakEndTime` only ever describe the most recent break when a + /// profile permits repeated breaks, so reporting has to /// read the accumulator and fall back to the timestamps for single-break sessions where it /// stays zero. If a session ends during a break, its end time closes that final partial break /// because the accumulator is only updated by `endBreak(at:)`. Deliberately independent of - /// the profile's current `allowMultipleBreaks` value, so toggling that setting cannot rewrite + /// the profile's current allowance configuration, so toggling settings cannot rewrite /// sessions that are already recorded. var totalBreakDuration: TimeInterval { let accumulatedDuration = max(0, usedBreakDurationInSeconds) @@ -110,24 +128,64 @@ class BlockedProfileSession { } func usedBreakDurationIncludingActiveBreak(at date: Date = Date()) -> TimeInterval { - if blockedProfile.allowMultipleBreaks { - return min( - totalBreakAllowanceInSeconds, - usedBreakDurationInSeconds + activeBreakElapsedTime(at: date) - ) + if blockedProfile.permitsMultipleBreaksPerPeriod { + return usedBreakDurationInSeconds + activeBreakElapsedTime(at: date) } return completedSingleBreakDuration(at: date) } func remainingBreakAllowance(at date: Date = Date()) -> TimeInterval { - max(0, totalBreakAllowanceInSeconds - usedBreakDurationIncludingActiveBreak(at: date)) + switch blockedProfile.breakAllowanceMode { + case .perBreak: + return max(0, totalBreakAllowanceInSeconds - activeBreakElapsedTime(at: date)) + case .cumulative: + let usageDate = isBreakActive ? breakStartTime ?? date : date + let persistedUsage = breakAllowanceUsage(at: usageDate).usedDurationInSeconds + let completedUsage = + blockedProfile.breakAllowanceModeRawValue == nil + ? max(persistedUsage, usedBreakDurationInSeconds) : persistedUsage + return max( + 0, + totalBreakAllowanceInSeconds - completedUsage - activeBreakElapsedTime(at: date) + ) + } } - func startBreak(at date: Date = Date()) { + func remainingBreakCount(at date: Date = Date()) -> Int? { + guard blockedProfile.breakAllowanceMode == .perBreak, + let breakCountLimit = blockedProfile.resolvedBreakCountLimit + else { + return nil + } + + return max(0, breakCountLimit - breakAllowanceUsage(at: date).breaksStarted) + } + + @discardableResult + func startBreak(at date: Date = Date()) -> Bool { + guard !isBreakActive else { + return false + } + + guard + SharedData.beginBreak( + for: blockedProfile.id, + mode: blockedProfile.breakAllowanceMode, + breakCountLimit: blockedProfile.resolvedBreakCountLimit, + totalAllowanceInSeconds: totalBreakAllowanceInSeconds, + at: date, + resetHour: blockedProfile.breakResetHour, + resetMinute: blockedProfile.breakResetMinute, + resetPolicy: blockedProfile.breakResetPolicy + ) + else { + return false + } + let breakStartTime = date - if blockedProfile.allowMultipleBreaks { + if blockedProfile.permitsMultipleBreaksPerPeriod { SharedData.resetBreak() self.breakStartTime = nil self.breakEndTime = nil @@ -135,25 +193,49 @@ class BlockedProfileSession { SharedData.setBreakStartTime(date: breakStartTime) self.breakStartTime = breakStartTime + return true } func endBreak(at date: Date = Date()) { let breakEndTime = date - if blockedProfile.allowMultipleBreaks { - let completedDuration = activeBreakElapsedTime(at: breakEndTime) - let updatedUsedDuration = min( - totalBreakAllowanceInSeconds, - usedBreakDurationInSeconds + completedDuration + if blockedProfile.permitsMultipleBreaksPerPeriod { + let availableDuration = remainingBreakAllowance(at: breakStartTime ?? breakEndTime) + let completedDuration = min( + availableDuration, + activeBreakElapsedTime(at: breakEndTime) ) + let updatedUsedDuration = usedBreakDurationInSeconds + completedDuration usedBreakDurationInSeconds = updatedUsedDuration SharedData.setUsedBreakDurationInSeconds(updatedUsedDuration) } + if blockedProfile.breakAllowanceMode == .cumulative, let breakStartTime { + SharedData.recordCumulativeBreakDuration( + for: blockedProfile.id, + breakStart: breakStartTime, + breakEnd: breakEndTime, + totalAllowanceInSeconds: totalBreakAllowanceInSeconds, + resetHour: blockedProfile.breakResetHour, + resetMinute: blockedProfile.breakResetMinute, + resetPolicy: blockedProfile.breakResetPolicy + ) + } + SharedData.setBreakEndTime(date: breakEndTime) self.breakEndTime = breakEndTime } + private func breakAllowanceUsage(at date: Date) -> BreakAllowanceUsage { + SharedData.breakAllowanceUsage( + for: blockedProfile.id, + at: date, + resetHour: blockedProfile.breakResetHour, + resetMinute: blockedProfile.breakResetMinute, + resetPolicy: blockedProfile.breakResetPolicy + ) + } + private func completedSingleBreakDuration(at date: Date) -> TimeInterval { guard let breakStartTime else { return 0 @@ -187,6 +269,10 @@ class BlockedProfileSession { func endSession() { let endTime = Date() + if isBreakActive { + endBreak(at: endTime) + } + BlockingSessionLifecycleRegistry.sessionDidEnd( BlockingSessionLifecycleContext( profile: BlockedProfiles.getSnapshot(for: blockedProfile), diff --git a/Foqos/Models/BlockedProfiles.swift b/Foqos/Models/BlockedProfiles.swift index 9bece949..189699d0 100644 --- a/Foqos/Models/BlockedProfiles.swift +++ b/Foqos/Models/BlockedProfiles.swift @@ -21,6 +21,12 @@ class BlockedProfiles { var enableBreaks: Bool = false var breakTimeInMinutes: Int = 15 var allowMultipleBreaks: Bool = false + var breakAllowanceModeRawValue: String? + var breakCountLimit: Int = 1 + var isBreakCountUnlimited: Bool = false + var breakResetHour: Int = 0 + var breakResetMinute: Int = 0 + var breakResetPolicyRawValue: String? var enableStrictMode: Bool = false var enableBlockAppInstallation: Bool = false var enableAllowMode: Bool = false @@ -69,6 +75,48 @@ class BlockedProfiles { return StrategyManager.getStrategyFromId(id: strategyId).allowsTimedBreaks } + var breakAllowanceMode: BreakAllowanceMode { + get { + guard let breakAllowanceModeRawValue, + let mode = BreakAllowanceMode(rawValue: breakAllowanceModeRawValue) + else { + return allowMultipleBreaks ? .cumulative : .perBreak + } + return mode + } + set { + breakAllowanceModeRawValue = newValue.rawValue + allowMultipleBreaks = newValue == .cumulative || permitsMultipleBreaksPerPeriod + } + } + + var resolvedBreakCountLimit: Int? { + isBreakCountUnlimited ? nil : max(1, breakCountLimit) + } + + var permitsMultipleBreaksPerPeriod: Bool { + switch breakAllowanceMode { + case .perBreak: + return resolvedBreakCountLimit.map { $0 > 1 } ?? true + case .cumulative: + return true + } + } + + var breakResetPolicy: BreakResetPolicy { + get { + guard let breakResetPolicyRawValue, + let policy = BreakResetPolicy(rawValue: breakResetPolicyRawValue) + else { + return .daily + } + return policy + } + set { + breakResetPolicyRawValue = newValue.rawValue + } + } + var shouldAskForStartSettings: Bool { askForStartSettings || strategyData == nil } @@ -113,6 +161,12 @@ class BlockedProfiles { enableBreaks: Bool = false, breakTimeInMinutes: Int = 15, allowMultipleBreaks: Bool = false, + breakAllowanceMode: BreakAllowanceMode? = nil, + breakCountLimit: Int = 1, + isBreakCountUnlimited: Bool = false, + breakResetHour: Int = 0, + breakResetMinute: Int = 0, + breakResetPolicy: BreakResetPolicy = .daily, enableStrictMode: Bool = false, enableBlockAppInstallation: Bool = false, enableAllowMode: Bool = false, @@ -144,6 +198,16 @@ class BlockedProfiles { self.enableBreaks = enableBreaks self.breakTimeInMinutes = breakTimeInMinutes self.allowMultipleBreaks = allowMultipleBreaks + self.breakAllowanceModeRawValue = breakAllowanceMode?.rawValue + self.breakCountLimit = max(1, breakCountLimit) + self.isBreakCountUnlimited = isBreakCountUnlimited + self.breakResetHour = min(max(breakResetHour, 0), 23) + self.breakResetMinute = min(max(breakResetMinute, 0), 59) + self.breakResetPolicyRawValue = breakResetPolicy.rawValue + if let breakAllowanceMode { + self.allowMultipleBreaks = + breakAllowanceMode == .cumulative || isBreakCountUnlimited || breakCountLimit > 1 + } self.enableStrictMode = enableStrictMode self.enableBlockAppInstallation = enableBlockAppInstallation self.enableAllowMode = enableAllowMode @@ -216,6 +280,12 @@ class BlockedProfiles { enableBreaks: Bool? = nil, breakTimeInMinutes: Int? = nil, allowMultipleBreaks: Bool? = nil, + breakAllowanceMode: BreakAllowanceMode? = nil, + breakCountLimit: Int? = nil, + isBreakCountUnlimited: Bool? = nil, + breakResetHour: Int? = nil, + breakResetMinute: Int? = nil, + breakResetPolicy: BreakResetPolicy? = nil, enableStrictMode: Bool? = nil, enableBlockAppInstallation: Bool? = nil, enableAllowMode: Bool? = nil, @@ -281,6 +351,33 @@ class BlockedProfiles { profile.allowMultipleBreaks = newAllowMultipleBreaks } + if let newBreakAllowanceMode = breakAllowanceMode { + profile.breakAllowanceMode = newBreakAllowanceMode + } + + if let newBreakCountLimit = breakCountLimit { + profile.breakCountLimit = max(1, newBreakCountLimit) + } + + if let newIsBreakCountUnlimited = isBreakCountUnlimited { + profile.isBreakCountUnlimited = newIsBreakCountUnlimited + } + + if let newBreakResetHour = breakResetHour { + profile.breakResetHour = min(max(newBreakResetHour, 0), 23) + } + + if let newBreakResetMinute = breakResetMinute { + profile.breakResetMinute = min(max(newBreakResetMinute, 0), 59) + } + + if let newBreakResetPolicy = breakResetPolicy { + profile.breakResetPolicy = newBreakResetPolicy + } + + profile.allowMultipleBreaks = + profile.breakAllowanceMode == .cumulative || profile.permitsMultipleBreaksPerPeriod + if let newEnableStrictMode = enableStrictMode { profile.enableStrictMode = newEnableStrictMode } @@ -368,6 +465,7 @@ class BlockedProfiles { // Delete the snapshot deleteSnapshot(for: profile) + SharedData.resetBreakAllowanceUsage(for: profile.id) // Remove the schedule restrictions DeviceActivityCenterUtil.removeScheduleTimerActivities(for: profile) @@ -397,6 +495,12 @@ class BlockedProfiles { enableBreaks: profile.enableBreaks, breakTimeInMinutes: profile.breakTimeInMinutes, allowMultipleBreaks: profile.allowMultipleBreaks, + breakAllowanceModeRawValue: profile.breakAllowanceMode.rawValue, + breakCountLimit: profile.breakCountLimit, + isBreakCountUnlimited: profile.isBreakCountUnlimited, + breakResetHour: profile.breakResetHour, + breakResetMinute: profile.breakResetMinute, + breakResetPolicyRawValue: profile.breakResetPolicy.rawValue, enableStrictMode: profile.enableStrictMode, enableBlockAppInstallation: profile.enableBlockAppInstallation, enableAllowMode: profile.enableAllowMode, @@ -457,6 +561,12 @@ class BlockedProfiles { enableBreaks: Bool = false, breakTimeInMinutes: Int = 15, allowMultipleBreaks: Bool = false, + breakAllowanceMode: BreakAllowanceMode? = nil, + breakCountLimit: Int = 1, + isBreakCountUnlimited: Bool = false, + breakResetHour: Int = 0, + breakResetMinute: Int = 0, + breakResetPolicy: BreakResetPolicy = .daily, enableStrictMode: Bool = false, enableBlockAppInstallation: Bool = false, enableAllowMode: Bool = false, @@ -484,6 +594,12 @@ class BlockedProfiles { enableBreaks: enableBreaks, breakTimeInMinutes: breakTimeInMinutes, allowMultipleBreaks: allowMultipleBreaks, + breakAllowanceMode: breakAllowanceMode, + breakCountLimit: breakCountLimit, + isBreakCountUnlimited: isBreakCountUnlimited, + breakResetHour: breakResetHour, + breakResetMinute: breakResetMinute, + breakResetPolicy: breakResetPolicy, enableStrictMode: enableStrictMode, enableBlockAppInstallation: enableBlockAppInstallation, enableAllowMode: enableAllowMode, @@ -528,6 +644,12 @@ class BlockedProfiles { enableBreaks: source.enableBreaks, breakTimeInMinutes: source.breakTimeInMinutes, allowMultipleBreaks: source.allowMultipleBreaks, + breakAllowanceMode: source.breakAllowanceMode, + breakCountLimit: source.breakCountLimit, + isBreakCountUnlimited: source.isBreakCountUnlimited, + breakResetHour: source.breakResetHour, + breakResetMinute: source.breakResetMinute, + breakResetPolicy: source.breakResetPolicy, enableStrictMode: source.enableStrictMode, enableBlockAppInstallation: source.enableBlockAppInstallation, enableAllowMode: source.enableAllowMode, @@ -557,7 +679,7 @@ class BlockedProfiles { } let newDomains = domains + [domain] - try updateProfile(profile, in: context, domains: newDomains) + _ = try updateProfile(profile, in: context, domains: newDomains) } static func removeDomain(from profile: BlockedProfiles, context: ModelContext, domain: String) @@ -568,6 +690,6 @@ class BlockedProfiles { } let newDomains = domains.filter { $0 != domain } - try updateProfile(profile, in: context, domains: newDomains) + _ = try updateProfile(profile, in: context, domains: newDomains) } } diff --git a/Foqos/Models/Shared.swift b/Foqos/Models/Shared.swift index b4e6e086..e2c084ec 100644 --- a/Foqos/Models/Shared.swift +++ b/Foqos/Models/Shared.swift @@ -1,6 +1,42 @@ import FamilyControls import Foundation +enum BreakAllowanceMode: String, CaseIterable, Codable { + case perBreak + case cumulative + + var title: String { + switch self { + case .perBreak: + return "Per Break" + case .cumulative: + return "Shared Daily Budget" + } + } +} + +enum BreakResetPolicy: String, CaseIterable, Codable { + case daily + case never + + var title: String { + switch self { + case .daily: + return "Daily" + case .never: + return "Never" + } + } +} + +struct BreakAllowanceUsage: Codable, Equatable { + var periodStart: Date + var breaksStarted: Int + var usedDurationInSeconds: TimeInterval + var lastRecordedBreakStart: Date? = nil + var resetPolicyRawValue: String? = nil +} + enum SharedData { private static let suite = UserDefaults( suiteName: "group.dev.ambitionsoftware.foqos" @@ -11,6 +47,7 @@ enum SharedData { case profileSnapshots case activeScheduleSession case completedScheduleSessions + case breakAllowanceUsage } // MARK: – Serializable snapshot of a profile (no sessions) @@ -30,6 +67,12 @@ enum SharedData { var enableBreaks: Bool var breakTimeInMinutes: Int = 15 var allowMultipleBreaks: Bool? = nil + var breakAllowanceModeRawValue: String? = nil + var breakCountLimit: Int? = nil + var isBreakCountUnlimited: Bool? = nil + var breakResetHour: Int? = nil + var breakResetMinute: Int? = nil + var breakResetPolicyRawValue: String? = nil var enableStrictMode: Bool var enableBlockAppInstallation: Bool = false var enableAllowMode: Bool @@ -52,6 +95,36 @@ enum SharedData { var disableBackgroundStops: Bool? var enableEmergencyUnblock: Bool? + + var breakAllowanceMode: BreakAllowanceMode { + if let breakAllowanceModeRawValue, + let mode = BreakAllowanceMode(rawValue: breakAllowanceModeRawValue) + { + return mode + } + return allowMultipleBreaks == true ? .cumulative : .perBreak + } + + var resolvedBreakCountLimit: Int? { + isBreakCountUnlimited == true ? nil : max(1, breakCountLimit ?? 1) + } + + var resolvedBreakResetHour: Int { + min(max(breakResetHour ?? 0, 0), 23) + } + + var resolvedBreakResetMinute: Int { + min(max(breakResetMinute ?? 0, 0), 59) + } + + var breakResetPolicy: BreakResetPolicy { + guard let breakResetPolicyRawValue, + let policy = BreakResetPolicy(rawValue: breakResetPolicyRawValue) + else { + return .daily + } + return policy + } } // MARK: – Serializable snapshot of a session (no profile object) @@ -88,6 +161,213 @@ enum SharedData { } } + private static var breakAllowanceUsageByProfile: [String: BreakAllowanceUsage] { + get { + guard let data = suite.data(forKey: Key.breakAllowanceUsage.rawValue) else { return [:] } + return (try? JSONDecoder().decode([String: BreakAllowanceUsage].self, from: data)) ?? [:] + } + set { + guard let data = try? JSONEncoder().encode(newValue) else { return } + suite.set(data, forKey: Key.breakAllowanceUsage.rawValue) + } + } + + static func breakAllowanceUsage( + for profileID: UUID, + at date: Date = Date(), + resetHour: Int, + resetMinute: Int, + resetPolicy: BreakResetPolicy = .daily, + calendar: Calendar = .current + ) -> BreakAllowanceUsage { + let periodStart = + resetPolicy == .daily + ? breakAllowancePeriodStart( + containing: date, + resetHour: resetHour, + resetMinute: resetMinute, + calendar: calendar + ) : Date(timeIntervalSinceReferenceDate: 0) + let key = profileID.uuidString + var allUsage = breakAllowanceUsageByProfile + + if var usage = allUsage[key] { + let storedPolicy = + usage.resetPolicyRawValue.flatMap(BreakResetPolicy.init(rawValue:)) ?? .daily + + if storedPolicy != resetPolicy { + usage.periodStart = periodStart + usage.resetPolicyRawValue = resetPolicy.rawValue + allUsage[key] = usage + breakAllowanceUsageByProfile = allUsage + return usage + } + + if resetPolicy == .never || usage.periodStart == periodStart { + return usage + } + + if usage.periodStart > periodStart { + return BreakAllowanceUsage( + periodStart: periodStart, + breaksStarted: 0, + usedDurationInSeconds: 0, + resetPolicyRawValue: resetPolicy.rawValue + ) + } + } + + let usage = BreakAllowanceUsage( + periodStart: periodStart, + breaksStarted: 0, + usedDurationInSeconds: 0, + resetPolicyRawValue: resetPolicy.rawValue + ) + allUsage[key] = usage + breakAllowanceUsageByProfile = allUsage + return usage + } + + static func beginBreak( + for profileID: UUID, + mode: BreakAllowanceMode, + breakCountLimit: Int?, + totalAllowanceInSeconds: TimeInterval, + at date: Date = Date(), + resetHour: Int, + resetMinute: Int, + resetPolicy: BreakResetPolicy = .daily, + calendar: Calendar = .current + ) -> Bool { + var usage = breakAllowanceUsage( + for: profileID, + at: date, + resetHour: resetHour, + resetMinute: resetMinute, + resetPolicy: resetPolicy, + calendar: calendar + ) + + switch mode { + case .perBreak: + guard breakCountLimit.map({ usage.breaksStarted < $0 }) ?? true else { + return false + } + if breakCountLimit != nil { + usage.breaksStarted += 1 + } + case .cumulative: + guard usage.usedDurationInSeconds < totalAllowanceInSeconds else { + return false + } + } + + var allUsage = breakAllowanceUsageByProfile + allUsage[profileID.uuidString] = usage + breakAllowanceUsageByProfile = allUsage + return true + } + + static func recordCumulativeBreakDuration( + for profileID: UUID, + breakStart: Date, + breakEnd: Date, + totalAllowanceInSeconds: TimeInterval, + resetHour: Int, + resetMinute: Int, + resetPolicy: BreakResetPolicy = .daily, + calendar: Calendar = .current + ) { + guard breakEnd > breakStart else { + return + } + + let breakPeriodStart = + resetPolicy == .daily + ? breakAllowancePeriodStart( + containing: breakEnd, + resetHour: resetHour, + resetMinute: resetMinute, + calendar: calendar + ) : Date(timeIntervalSinceReferenceDate: 0) + let key = profileID.uuidString + var allUsage = breakAllowanceUsageByProfile + let storedUsage = allUsage[key] + + guard storedUsage?.periodStart ?? breakPeriodStart <= breakPeriodStart else { + return + } + + var usage: BreakAllowanceUsage + if let storedUsage, + storedUsage.periodStart == breakPeriodStart, + (storedUsage.resetPolicyRawValue.flatMap(BreakResetPolicy.init(rawValue:)) ?? .daily) + == resetPolicy + { + usage = storedUsage + } else { + usage = BreakAllowanceUsage( + periodStart: breakPeriodStart, + breaksStarted: 0, + usedDurationInSeconds: 0, + resetPolicyRawValue: resetPolicy.rawValue + ) + } + + guard usage.lastRecordedBreakStart != breakStart else { + return + } + + let durationStart = max(breakStart, breakPeriodStart) + usage.usedDurationInSeconds = min( + totalAllowanceInSeconds, + usage.usedDurationInSeconds + breakEnd.timeIntervalSince(durationStart) + ) + usage.lastRecordedBreakStart = breakStart + allUsage[key] = usage + breakAllowanceUsageByProfile = allUsage + } + + static func resetBreakAllowanceUsage(for profileID: UUID) { + var allUsage = breakAllowanceUsageByProfile + allUsage.removeValue(forKey: profileID.uuidString) + breakAllowanceUsageByProfile = allUsage + } + + static func breakAllowancePeriodStart( + containing date: Date, + resetHour: Int, + resetMinute: Int, + calendar: Calendar = .current + ) -> Date { + let startOfDay = calendar.startOfDay(for: date) + let resetComponents = DateComponents( + hour: min(max(resetHour, 0), 23), + minute: min(max(resetMinute, 0), 59) + ) + let resetToday = + calendar.nextDate( + after: startOfDay.addingTimeInterval(-1), + matching: resetComponents, + matchingPolicy: .nextTime, + repeatedTimePolicy: .first, + direction: .forward + ) ?? startOfDay + + if date >= resetToday { + return resetToday + } + + let previousDay = calendar.date(byAdding: .day, value: -1, to: startOfDay) ?? startOfDay + return calendar.nextDate( + after: previousDay.addingTimeInterval(-1), + matching: resetComponents, + matchingPolicy: .nextTime, + repeatedTimePolicy: .first, + direction: .forward + ) ?? previousDay + } + static func snapshot(for profileID: String) -> ProfileSnapshot? { profileSnapshots[profileID] } @@ -192,17 +472,16 @@ enum SharedData { activeSharedSession?.usedBreakDurationInSeconds = duration } - static func endBreak(date: Date, allowMultipleBreaks: Bool, totalAllowanceInSeconds: TimeInterval) - { + static func endBreak( + date: Date, + allowsMultipleBreaks: Bool + ) { guard var session = activeSharedSession else { return } - if allowMultipleBreaks, let breakStartTime = session.breakStartTime { + if allowsMultipleBreaks, let breakStartTime = session.breakStartTime { let activeBreakDuration = max(0, date.timeIntervalSince(breakStartTime)) let existingUsedDuration = session.usedBreakDurationInSeconds ?? 0 - session.usedBreakDurationInSeconds = min( - totalAllowanceInSeconds, - existingUsedDuration + activeBreakDuration - ) + session.usedBreakDurationInSeconds = existingUsedDuration + activeBreakDuration } session.breakEndTime = date diff --git a/Foqos/Models/Timers/BreakTimerActivity.swift b/Foqos/Models/Timers/BreakTimerActivity.swift index 5694e74a..45382623 100644 --- a/Foqos/Models/Timers/BreakTimerActivity.swift +++ b/Foqos/Models/Timers/BreakTimerActivity.swift @@ -37,7 +37,9 @@ class BreakTimerActivity: TimerActivity { appBlocker.deactivateRestrictionsForBreak(for: profile) if activeSession.breakStartTime == nil || activeSession.breakEndTime != nil { - if profile.allowMultipleBreaks == true { + if profile.breakAllowanceMode == .cumulative + || (profile.resolvedBreakCountLimit.map { $0 > 1 } ?? true) + { SharedData.resetBreak() } @@ -70,10 +72,21 @@ class BreakTimerActivity: TimerActivity { // Set the break end time let now = Date() + if profile.breakAllowanceMode == .cumulative, let breakStart = activeSession.breakStartTime { + SharedData.recordCumulativeBreakDuration( + for: profile.id, + breakStart: breakStart, + breakEnd: now, + totalAllowanceInSeconds: TimeInterval(profile.breakTimeInMinutes * 60), + resetHour: profile.resolvedBreakResetHour, + resetMinute: profile.resolvedBreakResetMinute, + resetPolicy: profile.breakResetPolicy + ) + } SharedData.endBreak( date: now, - allowMultipleBreaks: profile.allowMultipleBreaks == true, - totalAllowanceInSeconds: TimeInterval(profile.breakTimeInMinutes * 60) + allowsMultipleBreaks: profile.breakAllowanceMode == .cumulative + || (profile.resolvedBreakCountLimit.map { $0 > 1 } ?? true) ) } } diff --git a/Foqos/Utils/SessionTimeCalculator.swift b/Foqos/Utils/SessionTimeCalculator.swift index 667e6fee..fbcb98d4 100644 --- a/Foqos/Utils/SessionTimeCalculator.swift +++ b/Foqos/Utils/SessionTimeCalculator.swift @@ -59,7 +59,7 @@ enum SessionTimeCalculator { } if let breakStartTime = session.breakStartTime, session.isBreakActive { - if session.blockedProfile.allowMultipleBreaks { + if session.blockedProfile.breakAllowanceMode == .cumulative { let remainingAllowanceAtBreakStart = session.remainingBreakAllowance(at: breakStartTime) return breakStartTime.addingTimeInterval(remainingAllowanceAtBreakStart) } @@ -115,7 +115,7 @@ enum SessionTimeCalculator { for session: BlockedProfileSession, at date: Date ) -> TimeInterval { - if session.blockedProfile.allowMultipleBreaks { + if session.blockedProfile.permitsMultipleBreaksPerPeriod { return session.usedBreakDurationIncludingActiveBreak(at: date) } diff --git a/Foqos/Utils/StrategyManager.swift b/Foqos/Utils/StrategyManager.swift index 3b2a7d9b..3dec2b4a 100644 --- a/Foqos/Utils/StrategyManager.swift +++ b/Foqos/Utils/StrategyManager.swift @@ -532,7 +532,10 @@ class StrategyManager: ObservableObject { return } - session.startBreak() + guard session.startBreak() else { + errorMessage = "Your break allowance is used up until the next daily reset." + return + } ActiveProfileSyncStore.publish(session: session) appBlocker.deactivateRestrictionsForBreak( for: BlockedProfiles.getSnapshot(for: session.blockedProfile)) diff --git a/Foqos/Views/ActiveProfileSessionView.swift b/Foqos/Views/ActiveProfileSessionView.swift index 9fde6c97..88fce2d8 100644 --- a/Foqos/Views/ActiveProfileSessionView.swift +++ b/Foqos/Views/ActiveProfileSessionView.swift @@ -272,6 +272,13 @@ struct ActiveProfileSessionView: View { } } + if let breakAllowanceStatus { + Label(breakAllowanceStatus, systemImage: "cup.and.heat.waves") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + } + Text(focusMessage) .font(.subheadline) .foregroundStyle(.secondary) @@ -288,6 +295,26 @@ struct ActiveProfileSessionView: View { .frame(maxWidth: .infinity, alignment: .leading) } + private var breakAllowanceStatus: String? { + guard profile.enableBreaks, + let session = strategyManager.activeSession, + session.blockedProfile.id == profile.id + else { + return nil + } + + switch profile.breakAllowanceMode { + case .perBreak: + guard let remainingCount = session.remainingBreakCount() else { + return "Unlimited breaks" + } + return remainingCount == 1 ? "1 break remaining" : "\(remainingCount) breaks remaining" + case .cumulative: + let remainingMinutes = Int(ceil(session.remainingBreakAllowance() / 60)) + return "\(DateFormatters.formatMinutes(remainingMinutes)) of break time remaining" + } + } + private var actionSection: some View { VStack(spacing: 12) { if isCountdownExpired { diff --git a/Foqos/Views/BlockedProfileView.swift b/Foqos/Views/BlockedProfileView.swift index 6b47ece6..2995bae7 100644 --- a/Foqos/Views/BlockedProfileView.swift +++ b/Foqos/Views/BlockedProfileView.swift @@ -122,7 +122,7 @@ struct BlockedProfileView: View { disabled: isBlocking ) - BlockedProfileBreaksSection(draft: draft, disabled: isBlocking) + BlockedProfileBreaksSection(draft: draft, profile: profile, disabled: isBlocking) BlockedProfileStrictSafeguardsSection(draft: draft, disabled: isBlocking) diff --git a/Foqos/Views/DebugView.swift b/Foqos/Views/DebugView.swift index 1e9f9f1c..7640d5f8 100644 --- a/Foqos/Views/DebugView.swift +++ b/Foqos/Views/DebugView.swift @@ -203,6 +203,12 @@ struct DebugView: View { markdown += "- **Breaks:** \(profile.enableBreaks ? "Enabled" : "Disabled")\n" markdown += "- **Allow Multiple Breaks:** \(profile.allowMultipleBreaks ? "Enabled" : "Disabled")\n" + markdown += "- **Break Allowance Mode:** \(profile.breakAllowanceMode.title)\n" + markdown += + "- **Break Count Limit:** \(profile.resolvedBreakCountLimit.map { "\($0)" } ?? "Unlimited")\n" + markdown += + "- **Break Reset Time:** \(String(format: "%02d:%02d", profile.breakResetHour, profile.breakResetMinute))\n" + markdown += "- **Break Reset Policy:** \(profile.breakResetPolicy.title)\n" markdown += "- **Strict Mode:** \(profile.enableStrictMode ? "Enabled" : "Disabled")\n" markdown += "- **Prevent App Installation:** \(profile.enableBlockAppInstallation ? "Enabled" : "Disabled")\n" diff --git a/Foqos/Views/GuidedBlockedProfileCreationView.swift b/Foqos/Views/GuidedBlockedProfileCreationView.swift index 584e5b33..0207433a 100644 --- a/Foqos/Views/GuidedBlockedProfileCreationView.swift +++ b/Foqos/Views/GuidedBlockedProfileCreationView.swift @@ -523,11 +523,17 @@ private struct GuidedProfileReviewContent: View { return "Disabled" } - if draft.allowMultipleBreaks { - return "\(draft.breakTimeInMinutes) minutes, reusable" + if draft.breakAllowanceMode == .cumulative { + let reset = draft.breakResetPolicy == .daily ? "daily" : "no automatic reset" + return "\(DateFormatters.formatMinutes(draft.breakTimeInMinutes)) budget, \(reset)" } - return "\(draft.breakTimeInMinutes) minutes" + let count = + draft.isBreakCountUnlimited ? "unlimited" : "\(draft.breakCountLimit)" + let reset = + draft.isBreakCountUnlimited + ? "" : draft.breakResetPolicy == .daily ? ", daily" : ", no automatic reset" + return "\(count) × \(DateFormatters.formatMinutes(draft.breakTimeInMinutes))\(reset)" } private var safeguardsSummary: String { diff --git a/README.md b/README.md index e3d560f9..b929b7f5 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Foqos is not affiliated with Brick, Unpluq, Blok, or Opal. - NFC tag blocking for physical start and stop flows - QR code and barcode blocking, including generated profile QR codes - Timer-based sessions that end automatically after a chosen duration -- Optional reusable break allowance that can be split across multiple breaks in a session +- Configurable daily breaks with per-break limits or a shared time budget and custom reset time - Physical unlock rules for profiles that should only stop with approved tags or codes - Temporary Access strategies for limited, short opens without ending the full session - Pause Timer strategies for a short pause before blocking resumes @@ -49,6 +49,14 @@ Foqos is not affiliated with Brick, Unpluq, Blok, or Opal. - Widgets and App Intents for faster profile control - Local-first privacy with no cloud sync or analytics +### Configurable Break Allowances + +Profiles with timed breaks can use either a per-break allowance or a shared daily budget. +Per-break allowances apply the selected duration to every break and can limit the number of +breaks to 1–100 or allow unlimited breaks. Shared budgets let any number of breaks consume one +pool of time. Both modes support durations from 5 minutes to 24 hours. Allowances can reset every +day at a configured local time or preserve their usage until manually reset. + ## Blocking Strategies Foqos includes several ways to start and stop a blocking session. All strategies are implemented in `Foqos/Models/Strategies/` and registered in `Foqos/Utils/StrategyManager.swift`. diff --git a/foqosTests/BlockedProfileDraftTests.swift b/foqosTests/BlockedProfileDraftTests.swift index d3f790be..af56dbe3 100644 --- a/foqosTests/BlockedProfileDraftTests.swift +++ b/foqosTests/BlockedProfileDraftTests.swift @@ -34,4 +34,15 @@ final class BlockedProfileDraftTests: XCTestCase { XCTAssertTrue(draft.enableAllowMode) XCTAssertTrue(draft.selectedStrategySupportsAllowMode) } + + func testLoadingProfilePreservesNeverResetPolicy() { + let profile = BlockedProfiles( + name: "Focus", + breakResetPolicy: .never + ) + + let draft = BlockedProfileDraft(profile: profile) + + XCTAssertEqual(draft.breakResetPolicy, .never) + } } diff --git a/foqosTests/BreakAllowanceTests.swift b/foqosTests/BreakAllowanceTests.swift new file mode 100644 index 00000000..1352f2b5 --- /dev/null +++ b/foqosTests/BreakAllowanceTests.swift @@ -0,0 +1,342 @@ +import XCTest + +@testable import foqos + +final class BreakAllowanceTests: XCTestCase { + func testDailyPeriodUsesConfiguredResetTime() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(identifier: "America/Los_Angeles")) + let beforeReset = try XCTUnwrap( + calendar.date(from: DateComponents(year: 2026, month: 9, day: 1, hour: 3, minute: 59)) + ) + let afterReset = try XCTUnwrap( + calendar.date(from: DateComponents(year: 2026, month: 9, day: 1, hour: 4)) + ) + + let beforePeriod = SharedData.breakAllowancePeriodStart( + containing: beforeReset, + resetHour: 4, + resetMinute: 0, + calendar: calendar + ) + let afterPeriod = SharedData.breakAllowancePeriodStart( + containing: afterReset, + resetHour: 4, + resetMinute: 0, + calendar: calendar + ) + + XCTAssertEqual(calendar.component(.day, from: beforePeriod), 31) + XCTAssertEqual(calendar.component(.hour, from: beforePeriod), 4) + XCTAssertEqual(calendar.component(.day, from: afterPeriod), 1) + XCTAssertEqual(calendar.component(.hour, from: afterPeriod), 4) + } + + func testPerBreakModeLimitsBreakCountUntilNextReset() { + let profile = BlockedProfiles( + name: "Focus", + blockingStrategyId: ManualBlockingStrategy.id, + enableBreaks: true, + breakTimeInMinutes: 15, + breakAllowanceMode: .perBreak, + breakCountLimit: 2, + breakResetHour: 4 + ) + defer { SharedData.resetBreakAllowanceUsage(for: profile.id) } + + let periodStart = SharedData.breakAllowancePeriodStart( + containing: Date(), + resetHour: 4, + resetMinute: 0 + ) + let firstBreak = periodStart.addingTimeInterval(60) + let session = BlockedProfileSession( + tag: ManualBlockingStrategy.id, + blockedProfile: profile + ) + + XCTAssertTrue(session.startBreak(at: firstBreak)) + XCTAssertEqual(session.remainingBreakCount(at: firstBreak), 1) + session.endBreak(at: firstBreak.addingTimeInterval(5 * 60)) + XCTAssertTrue(session.startBreak(at: firstBreak.addingTimeInterval(10 * 60))) + XCTAssertEqual(session.remainingBreakCount(at: firstBreak.addingTimeInterval(10 * 60)), 0) + session.endBreak(at: firstBreak.addingTimeInterval(15 * 60)) + XCTAssertFalse(session.isBreakAvailable(at: firstBreak.addingTimeInterval(20 * 60))) + XCTAssertFalse(session.startBreak(at: firstBreak.addingTimeInterval(20 * 60))) + let secondSession = BlockedProfileSession( + tag: ManualBlockingStrategy.id, + blockedProfile: profile + ) + XCTAssertFalse(secondSession.isBreakAvailable(at: firstBreak.addingTimeInterval(20 * 60))) + + let nextPeriod = Calendar.current.date(byAdding: .day, value: 1, to: periodStart)! + XCTAssertTrue(secondSession.isBreakAvailable(at: nextPeriod.addingTimeInterval(60))) + } + + func testPerBreakModeProvidesFullDurationForEveryBreak() { + let profile = BlockedProfiles( + name: "Focus", + blockingStrategyId: ManualBlockingStrategy.id, + enableBreaks: true, + breakTimeInMinutes: 15, + breakAllowanceMode: .perBreak, + breakCountLimit: 3 + ) + defer { SharedData.resetBreakAllowanceUsage(for: profile.id) } + let session = BlockedProfileSession( + tag: ManualBlockingStrategy.id, + blockedProfile: profile + ) + let firstBreak = Date() + + XCTAssertTrue(session.startBreak(at: firstBreak)) + session.endBreak(at: firstBreak.addingTimeInterval(5 * 60)) + XCTAssertTrue(session.startBreak(at: firstBreak.addingTimeInterval(10 * 60))) + + XCTAssertEqual( + session.remainingBreakAllowance(at: firstBreak.addingTimeInterval(12 * 60)), + 13 * 60, + accuracy: 0.1 + ) + } + + func testCumulativeModeSharesBudgetAcrossSessionsAndResetsDaily() { + let profile = BlockedProfiles( + name: "Focus", + blockingStrategyId: ManualBlockingStrategy.id, + enableBreaks: true, + breakTimeInMinutes: 15, + breakAllowanceMode: .cumulative, + breakResetHour: 4 + ) + defer { SharedData.resetBreakAllowanceUsage(for: profile.id) } + let periodStart = SharedData.breakAllowancePeriodStart( + containing: Date(), + resetHour: 4, + resetMinute: 0 + ) + let firstBreak = periodStart.addingTimeInterval(60) + let firstSession = BlockedProfileSession( + tag: ManualBlockingStrategy.id, + blockedProfile: profile + ) + + XCTAssertTrue(firstSession.startBreak(at: firstBreak)) + firstSession.endBreak(at: firstBreak.addingTimeInterval(5 * 60)) + + let secondSession = BlockedProfileSession( + tag: ManualBlockingStrategy.id, + blockedProfile: profile + ) + XCTAssertEqual( + secondSession.remainingBreakAllowance(at: firstBreak.addingTimeInterval(10 * 60)), + 10 * 60, + accuracy: 0.1 + ) + + let nextPeriod = Calendar.current.date(byAdding: .day, value: 1, to: periodStart)! + XCTAssertEqual( + secondSession.remainingBreakAllowance(at: nextPeriod.addingTimeInterval(60)), + 15 * 60, + accuracy: 0.1 + ) + } + + func testCumulativeModeSupportsTwentyFourHourBudget() { + let profile = BlockedProfiles( + name: "Focus", + blockingStrategyId: ManualBlockingStrategy.id, + enableBreaks: true, + breakTimeInMinutes: 24 * 60, + breakAllowanceMode: .cumulative + ) + defer { SharedData.resetBreakAllowanceUsage(for: profile.id) } + let session = BlockedProfileSession( + tag: ManualBlockingStrategy.id, + blockedProfile: profile + ) + + XCTAssertEqual( + session.remainingBreakAllowance(), + 24 * 60 * 60, + accuracy: 0.1 + ) + } + + func testCumulativeBreakSpanningResetChargesNewPeriod() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(identifier: "America/Los_Angeles")) + let profile = BlockedProfiles( + name: "Focus", + blockingStrategyId: ManualBlockingStrategy.id, + enableBreaks: true, + breakTimeInMinutes: 15, + breakAllowanceMode: .cumulative, + breakResetHour: 4 + ) + defer { SharedData.resetBreakAllowanceUsage(for: profile.id) } + let breakStart = try XCTUnwrap( + calendar.date(from: DateComponents(year: 2026, month: 9, day: 1, hour: 3, minute: 55)) + ) + let afterReset = try XCTUnwrap( + calendar.date(from: DateComponents(year: 2026, month: 9, day: 1, hour: 4, minute: 5)) + ) + let breakEnd = try XCTUnwrap( + calendar.date(from: DateComponents(year: 2026, month: 9, day: 1, hour: 4, minute: 10)) + ) + let session = BlockedProfileSession( + tag: ManualBlockingStrategy.id, + blockedProfile: profile + ) + + XCTAssertTrue(session.startBreak(at: breakStart)) + XCTAssertEqual(session.remainingBreakAllowance(at: afterReset), 5 * 60, accuracy: 0.1) + session.endBreak(at: breakEnd) + + let nextSession = BlockedProfileSession( + tag: ManualBlockingStrategy.id, + blockedProfile: profile + ) + XCTAssertEqual( + nextSession.remainingBreakAllowance(at: breakEnd), + 5 * 60, + accuracy: 0.1 + ) + } + + func testPerBreakModeCanRemainExhaustedUntilManualReset() { + let profile = BlockedProfiles( + name: "Focus", + blockingStrategyId: ManualBlockingStrategy.id, + enableBreaks: true, + breakAllowanceMode: .perBreak, + breakCountLimit: 1, + breakResetPolicy: .never + ) + defer { SharedData.resetBreakAllowanceUsage(for: profile.id) } + let firstSession = BlockedProfileSession( + tag: ManualBlockingStrategy.id, + blockedProfile: profile + ) + let firstBreak = Date() + + XCTAssertTrue(firstSession.startBreak(at: firstBreak)) + firstSession.endBreak(at: firstBreak.addingTimeInterval(5 * 60)) + + let secondSession = BlockedProfileSession( + tag: ManualBlockingStrategy.id, + blockedProfile: profile + ) + let laterDate = Calendar.current.date(byAdding: .day, value: 30, to: firstBreak)! + XCTAssertFalse(secondSession.isBreakAvailable(at: laterDate)) + + SharedData.resetBreakAllowanceUsage(for: profile.id) + + XCTAssertTrue(secondSession.isBreakAvailable(at: laterDate)) + } + + func testCumulativeModeCanPreserveUsageWithoutAutomaticReset() { + let profile = BlockedProfiles( + name: "Focus", + blockingStrategyId: ManualBlockingStrategy.id, + enableBreaks: true, + breakTimeInMinutes: 15, + breakAllowanceMode: .cumulative, + breakResetPolicy: .never + ) + defer { SharedData.resetBreakAllowanceUsage(for: profile.id) } + let firstSession = BlockedProfileSession( + tag: ManualBlockingStrategy.id, + blockedProfile: profile + ) + let firstBreak = Date() + + XCTAssertTrue(firstSession.startBreak(at: firstBreak)) + firstSession.endBreak(at: firstBreak.addingTimeInterval(5 * 60)) + + let secondSession = BlockedProfileSession( + tag: ManualBlockingStrategy.id, + blockedProfile: profile + ) + let laterDate = Calendar.current.date(byAdding: .day, value: 30, to: firstBreak)! + XCTAssertEqual( + secondSession.remainingBreakAllowance(at: laterDate), + 10 * 60, + accuracy: 0.1 + ) + } + + func testCumulativeBreakRecordingIsIdempotent() { + let profileID = UUID() + defer { SharedData.resetBreakAllowanceUsage(for: profileID) } + let breakStart = Date() + let breakEnd = breakStart.addingTimeInterval(5 * 60) + + SharedData.recordCumulativeBreakDuration( + for: profileID, + breakStart: breakStart, + breakEnd: breakEnd, + totalAllowanceInSeconds: 15 * 60, + resetHour: 0, + resetMinute: 0 + ) + SharedData.recordCumulativeBreakDuration( + for: profileID, + breakStart: breakStart, + breakEnd: breakEnd, + totalAllowanceInSeconds: 15 * 60, + resetHour: 0, + resetMinute: 0 + ) + + XCTAssertEqual( + SharedData.breakAllowanceUsage( + for: profileID, + at: breakEnd, + resetHour: 0, + resetMinute: 0 + ).usedDurationInSeconds, + 5 * 60, + accuracy: 0.1 + ) + } + + func testHistoricalUsageReadDoesNotReplaceCurrentPeriod() { + let profileID = UUID() + defer { SharedData.resetBreakAllowanceUsage(for: profileID) } + let currentPeriod = SharedData.breakAllowancePeriodStart( + containing: Date(), + resetHour: 0, + resetMinute: 0 + ) + let previousPeriod = Calendar.current.date(byAdding: .day, value: -1, to: currentPeriod)! + + XCTAssertTrue( + SharedData.beginBreak( + for: profileID, + mode: .perBreak, + breakCountLimit: 1, + totalAllowanceInSeconds: 15 * 60, + at: currentPeriod.addingTimeInterval(60), + resetHour: 0, + resetMinute: 0 + ) + ) + _ = SharedData.breakAllowanceUsage( + for: profileID, + at: previousPeriod.addingTimeInterval(60), + resetHour: 0, + resetMinute: 0 + ) + + XCTAssertEqual( + SharedData.breakAllowanceUsage( + for: profileID, + at: currentPeriod.addingTimeInterval(120), + resetHour: 0, + resetMinute: 0 + ).breaksStarted, + 1 + ) + } +} diff --git a/foqosTests/SessionTimeCalculatorTests.swift b/foqosTests/SessionTimeCalculatorTests.swift index 57ce32b6..b55136b1 100644 --- a/foqosTests/SessionTimeCalculatorTests.swift +++ b/foqosTests/SessionTimeCalculatorTests.swift @@ -234,7 +234,7 @@ final class SessionTimeCalculatorTests: XCTestCase { XCTAssertEqual(session.remainingBreakAllowance(), 0, accuracy: 0.1) } - func testReusableBreakAllowanceResetsForNewSession() { + func testReusableBreakAllowanceStartsAvailable() { let profile = makeProfile( strategyId: ManualBlockingStrategy.id, enableBreaks: true,