Files
OpenAppLock/Severed/Models/BlockingRule.swift
Brendan Chen 3aac2004d2 feat: clone Opal's rules feature with hard block enforcement
Rebuild the app around recurring screen-time blocking rules modeled on
Opal's My Apps tab:

- Onboarding with FamilyControls Screen Time authorization
- Apps home with Blocked Apps tiles and live rule cards
- New Rule sheet: Schedule/Time Limit/Open Limit types + preset gallery
- Rule editors: time windows (incl. overnight), day picker, app selection
  via FamilyActivityPicker (Block/Allow Only), rename, Hold to Commit
- Hard Mode: active hard rules cannot be edited, disabled, deleted, or
  unblocked until their window ends; soft rules pause until next window
- Shield enforcement through per-rule ManagedSettingsStore + RuleEnforcer
- 73 unit tests (Swift Testing) + 16 UI tests (XCUITest) with launch-arg
  harness for in-memory storage, mocked authorization, seeded scenarios
- docs/RULES_FEATURE_SPEC.md: spec derived from the reference recording

Known gap: background window transitions and time/open-limit thresholds
need a DeviceActivityMonitor extension; shields currently sync while the
app is running.
2026-06-12 12:35:52 -04:00

92 lines
3.0 KiB
Swift

//
// BlockingRule.swift
// Severed
//
import Foundation
import SwiftData
/// A recurring screen-time blocking rule.
///
/// Times are stored as minutes from midnight so the schedule repeats cleanly and
/// is independent of time zones at creation. A window whose end is at or before
/// its start (e.g. 22:00 06:00) crosses midnight into the following day.
@Model
final class BlockingRule {
@Attribute(.unique) var id: UUID
var name: String
var kindRaw: String
var isEnabled: Bool
/// Hard block: while the rule is active it cannot be disabled, edited, or unblocked.
var hardMode: Bool
var selectionModeRaw: String
/// Encoded `FamilyActivitySelection` (opaque tokens). Nil until the user picks apps.
var selectionData: Data?
/// Denormalized count of selected apps/categories/domains for display.
var selectionCount: Int
var dayNumbers: [Int]
var startMinutes: Int
var endMinutes: Int
/// Daily usage budget for `.timeLimit` rules.
var dailyLimitMinutes: Int
/// Daily open budget for `.openLimit` rules.
var maxOpens: Int
/// When set, the rule's current window is suspended (user tapped Unblock).
/// Cleared automatically once the date passes; never set while Hard Mode is active.
var pausedUntil: Date?
var createdAt: Date
init(
id: UUID = UUID(),
name: String,
kind: RuleKind = .schedule,
isEnabled: Bool = true,
hardMode: Bool = false,
selectionMode: SelectionMode = .block,
selectionData: Data? = nil,
selectionCount: Int = 0,
days: Set<Weekday> = Weekday.weekdays,
startMinutes: Int = 9 * 60,
endMinutes: Int = 17 * 60,
dailyLimitMinutes: Int = 45,
maxOpens: Int = 5,
pausedUntil: Date? = nil,
createdAt: Date = .now
) {
self.id = id
self.name = name
self.kindRaw = kind.rawValue
self.isEnabled = isEnabled
self.hardMode = hardMode
self.selectionModeRaw = selectionMode.rawValue
self.selectionData = selectionData
self.selectionCount = selectionCount
self.dayNumbers = days.map(\.rawValue).sorted()
self.startMinutes = startMinutes
self.endMinutes = endMinutes
self.dailyLimitMinutes = dailyLimitMinutes
self.maxOpens = maxOpens
self.pausedUntil = pausedUntil
self.createdAt = createdAt
}
var kind: RuleKind {
get { RuleKind(rawValue: kindRaw) ?? .schedule }
set { kindRaw = newValue.rawValue }
}
var selectionMode: SelectionMode {
get { SelectionMode(rawValue: selectionModeRaw) ?? .block }
set { selectionModeRaw = newValue.rawValue }
}
var days: Set<Weekday> {
get { Set(dayNumbers.compactMap(Weekday.init(rawValue:))) }
set { dayNumbers = newValue.map(\.rawValue).sorted() }
}
var schedule: RuleSchedule {
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
}
}