Replace the wide BlockingRule/RuleDraft "god struct" — where every option existed for every kind — with a RuleConfiguration sum type that carries only the options each kind actually has: .schedule(ScheduleConfig: window + selectionMode + blockAdultContent) .timeLimit(TimeLimitConfig: dailyLimitMinutes) .openLimit(OpenLimitConfig: maxOpens) Name, days, Hard Mode, app list, and pause stay common to all kinds. This makes illegal states unrepresentable: Block / Allow Only and Block Adult Content are now structurally Schedule-only. User-visible behavior change: the Time Limit and Open Limit editors no longer offer a Block Adult Content toggle, and their detail sheets drop the "Adult websites" row — those never made sense for a usage budget. Limit rules are always Block and never engage the web-content filter. BlockingRule keeps flat columns as raw persistence behind a computed `configuration` bridge (lowest SwiftData risk; the cross-process RuleSnapshot wire format and the Screen Time extensions are untouched). Logic, the editors, and the detail sheet all switch on the sum type. Spec (§1, §3.5/§3.6, §5.2) updated first; tests reworked to the new API with new structural-guarantee unit tests and a UI test asserting the Time Limit editor/detail omit adult content. Full suite green (180 tests).
101 lines
3.9 KiB
Swift
101 lines
3.9 KiB
Swift
//
|
|
// LimitEnforcement.swift
|
|
// OpenAppLock
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Shared reactions to Screen Time events for limit rules, driven by the
|
|
/// snapshot store and usage ledger. The DeviceActivity monitor and shield
|
|
/// extensions call these; keeping the logic here makes it unit-testable from
|
|
/// the app target.
|
|
struct LimitEnforcement {
|
|
let snapshots: RuleSnapshotStore
|
|
let ledger: UsageLedger
|
|
let shields: ShieldApplying
|
|
|
|
/// Midnight (or monitoring start): fresh budgets. Open-limit rules are
|
|
/// proactively shielded on enabled days so the shield can count opens;
|
|
/// time-limit rules start the day unshielded.
|
|
func handleDayStart(ruleID: UUID, now: Date = .now, calendar: Calendar = .current) {
|
|
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
|
|
!snapshot.isPaused(at: now)
|
|
else { return }
|
|
let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar)
|
|
switch snapshot.kind {
|
|
case .schedule:
|
|
break
|
|
case .openLimit:
|
|
if snapshot.isScheduledToday(at: now, calendar: calendar) {
|
|
shield(snapshot)
|
|
} else {
|
|
shields.clearShield(ruleID: ruleID)
|
|
}
|
|
case .timeLimit:
|
|
if snapshot.limitReached(given: usage),
|
|
snapshot.isScheduledToday(at: now, calendar: calendar) {
|
|
shield(snapshot)
|
|
} else {
|
|
shields.clearShield(ruleID: ruleID)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A cumulative usage checkpoint fired for a time-limit rule.
|
|
func handleUsageMinutes(
|
|
_ minutes: Int, ruleID: UUID, now: Date = .now, calendar: Calendar = .current
|
|
) {
|
|
ledger.recordMinutesUsed(minutes, for: ruleID, onDayContaining: now, calendar: calendar)
|
|
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
|
|
snapshot.kind == .timeLimit,
|
|
!snapshot.isPaused(at: now),
|
|
snapshot.isScheduledToday(at: now, calendar: calendar)
|
|
else { return }
|
|
let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar)
|
|
if snapshot.limitReached(given: usage) {
|
|
shield(snapshot)
|
|
}
|
|
}
|
|
|
|
/// The wall-clock session granted by an "Open" press ended; the shield
|
|
/// returns so the next open costs another press.
|
|
func handleOpenSessionEnded(
|
|
ruleID: UUID, now: Date = .now, calendar: Calendar = .current
|
|
) {
|
|
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
|
|
snapshot.kind == .openLimit,
|
|
!snapshot.isPaused(at: now),
|
|
snapshot.isScheduledToday(at: now, calendar: calendar)
|
|
else { return }
|
|
shield(snapshot)
|
|
}
|
|
|
|
/// "Open" pressed on the shield. Spends one open and lifts the rule's
|
|
/// shield when the budget allows; returns whether a session was granted.
|
|
@discardableResult
|
|
func handleOpenRequest(
|
|
ruleID: UUID, now: Date = .now, calendar: Calendar = .current
|
|
) -> Bool {
|
|
guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled,
|
|
snapshot.kind == .openLimit,
|
|
!snapshot.isPaused(at: now)
|
|
else { return false }
|
|
let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar)
|
|
guard !snapshot.limitReached(given: usage) else { return false }
|
|
ledger.recordOpen(for: ruleID, onDayContaining: now, calendar: calendar)
|
|
shields.clearShield(ruleID: ruleID)
|
|
return true
|
|
}
|
|
|
|
private func shield(_ snapshot: RuleSnapshot) {
|
|
shields.applyShield(
|
|
ruleID: snapshot.id,
|
|
selectionData: snapshot.selectionData,
|
|
// Limit rules are always Block and never engage the adult-content
|
|
// filter — those are Schedule-only options.
|
|
mode: .block,
|
|
blockAdultContent: false
|
|
)
|
|
}
|
|
}
|