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.
This commit is contained in:
2026-06-12 12:35:52 -04:00
parent c760aa696f
commit 3aac2004d2
45 changed files with 3749 additions and 206 deletions

View File

@@ -0,0 +1,65 @@
//
// SampleRules.swift
// Severed
//
import Foundation
import SwiftData
/// Builds deterministic rules for UI-test scenarios, positioned relative to
/// "now" so an active window is genuinely active whenever the test runs.
enum SampleRules {
static func seed(
_ scenario: LaunchConfiguration.SeedScenario,
into context: ModelContext,
now: Date = .now,
calendar: Calendar = .current
) {
switch scenario {
case .standard:
context.insert(activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar))
context.insert(upcomingRule(named: "Sleep", now: now, calendar: calendar))
case .hardModeActive:
context.insert(activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar))
context.insert(upcomingRule(named: "Sleep", now: now, calendar: calendar))
}
}
/// A schedule rule whose window started up to an hour ago and runs for
/// several hours, clamped so it never crosses midnight accidentally.
static func activeRule(
named name: String, hardMode: Bool, now: Date, calendar: Calendar = .current
) -> BlockingRule {
let nowMinutes = minutesIntoDay(of: now, calendar: calendar)
let start = max(0, nowMinutes - 60)
let end = min(24 * 60 - 1, nowMinutes + 6 * 60)
return BlockingRule(
name: name,
hardMode: hardMode,
days: Weekday.everyDay,
startMinutes: start,
endMinutes: end
)
}
/// A schedule rule starting a couple of hours from now (possibly wrapping
/// past midnight, which simply makes it start tomorrow).
static func upcomingRule(
named name: String, now: Date, calendar: Calendar = .current
) -> BlockingRule {
let nowMinutes = minutesIntoDay(of: now, calendar: calendar)
let start = (nowMinutes + 2 * 60) % (24 * 60)
let end = (start + 8 * 60) % (24 * 60)
return BlockingRule(
name: name,
days: Weekday.everyDay,
startMinutes: start,
endMinutes: end
)
}
private static func minutesIntoDay(of date: Date, calendar: Calendar) -> Int {
let components = calendar.dateComponents([.hour, .minute], from: date)
return (components.hour ?? 0) * 60 + (components.minute ?? 0)
}
}