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.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-12 12:35:52 -04:00
parent 0c03e35ea9
commit e6c87baeba
45 changed files with 3749 additions and 206 deletions

View File

@@ -0,0 +1,89 @@
//
// RuleSchedule.swift
// Severed
//
import Foundation
/// The recurring time window of a rule, independent of any persistence.
///
/// A window whose end is at or before its start crosses midnight: 22:00 06:00
/// starts on an enabled day and ends the following morning. `start == end`
/// means a full 24-hour window.
struct RuleSchedule: Hashable, Sendable {
var startMinutes: Int
var endMinutes: Int
var days: Set<Weekday>
var crossesMidnight: Bool { endMinutes <= startMinutes }
var durationMinutes: Int {
crossesMidnight
? RuleSchedule.minutesPerDay - startMinutes + endMinutes
: endMinutes - startMinutes
}
/// The window containing `date`, if the schedule is active at that moment.
///
/// Checks today's window and, for midnight-crossing schedules, the window
/// that started yesterday. The day a window *starts* on is the day that
/// must be enabled.
func activeWindow(containing date: Date, calendar: Calendar = .current) -> DateInterval? {
for dayOffset in [0, -1] {
guard
let day = calendar.date(byAdding: .day, value: dayOffset, to: date),
let window = window(onDayContaining: day, calendar: calendar),
window.start <= date, date < window.end
else { continue }
return window
}
return nil
}
func isActive(at date: Date, calendar: Calendar = .current) -> Bool {
activeWindow(containing: date, calendar: calendar) != nil
}
/// The next moment the schedule will begin a window strictly after `date`.
func nextStart(after date: Date, calendar: Calendar = .current) -> Date? {
guard !days.isEmpty else { return nil }
for dayOffset in 0...7 {
guard
let day = calendar.date(byAdding: .day, value: dayOffset, to: date),
let window = window(onDayContaining: day, calendar: calendar),
window.start > date
else { continue }
return window.start
}
return nil
}
/// The window starting on the given day, or nil when that weekday is not enabled.
private func window(onDayContaining day: Date, calendar: Calendar) -> DateInterval? {
let dayStart = calendar.startOfDay(for: day)
guard
let weekday = Weekday(rawValue: calendar.component(.weekday, from: dayStart)),
days.contains(weekday),
let start = calendar.date(byAdding: .minute, value: startMinutes, to: dayStart),
let end = calendar.date(
byAdding: .minute, value: startMinutes + durationMinutes, to: dayStart
)
else { return nil }
return DateInterval(start: start, end: end)
}
private static let minutesPerDay = 24 * 60
}
extension RuleSchedule {
/// "09:00" style label for a minutes-from-midnight value, matching the reference UI.
static func timeLabel(forMinutes minutes: Int) -> String {
let clamped = ((minutes % (24 * 60)) + 24 * 60) % (24 * 60)
return String(format: "%02d:%02d", clamped / 60, clamped % 60)
}
/// "09:00 17:00" range label used by rule details and preset cards.
var timeRangeLabel: String {
"\(Self.timeLabel(forMinutes: startMinutes)) \(Self.timeLabel(forMinutes: endMinutes))"
}
}