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,91 @@
//
// 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)
}
}

View File

@@ -0,0 +1,84 @@
//
// RuleDraft.swift
// Severed
//
import Foundation
/// Value-type working copy of a rule used by the editors, so cancelling an
/// edit never touches the persisted model.
struct RuleDraft: Equatable {
var name: String
var kind: RuleKind
var days: Set<Weekday>
var startMinutes: Int
var endMinutes: Int
var dailyLimitMinutes: Int
var maxOpens: Int
var hardMode: Bool
var selectionMode: SelectionMode
var selectionData: Data?
var selectionCount: Int
/// A fresh draft for a new rule of the given kind, using the reference
/// app's defaults (95 weekdays schedule, 45m/day, 5 opens/day).
init(kind: RuleKind) {
self.name = kind.defaultRuleName
self.kind = kind
self.days = Weekday.weekdays
self.startMinutes = 9 * 60
self.endMinutes = 17 * 60
self.dailyLimitMinutes = 45
self.maxOpens = 5
self.hardMode = false
self.selectionMode = .block
self.selectionData = nil
self.selectionCount = 0
}
init(rule: BlockingRule) {
self.name = rule.name
self.kind = rule.kind
self.days = rule.days
self.startMinutes = rule.startMinutes
self.endMinutes = rule.endMinutes
self.dailyLimitMinutes = rule.dailyLimitMinutes
self.maxOpens = rule.maxOpens
self.hardMode = rule.hardMode
self.selectionMode = rule.selectionMode
self.selectionData = rule.selectionData
self.selectionCount = rule.selectionCount
}
init(preset: RulePreset) {
self.init(kind: .schedule)
self.name = preset.name
self.days = preset.days
self.startMinutes = preset.startMinutes
self.endMinutes = preset.endMinutes
}
func apply(to rule: BlockingRule) {
rule.name = name
rule.kind = kind
rule.days = days
rule.startMinutes = startMinutes
rule.endMinutes = endMinutes
rule.dailyLimitMinutes = dailyLimitMinutes
rule.maxOpens = maxOpens
rule.hardMode = hardMode
rule.selectionMode = selectionMode
rule.selectionData = selectionData
rule.selectionCount = selectionCount
}
func makeRule() -> BlockingRule {
let rule = BlockingRule(name: name, kind: kind)
apply(to: rule)
return rule
}
var schedule: RuleSchedule {
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
}
}

View File

@@ -0,0 +1,64 @@
//
// RuleKind.swift
// Severed
//
import Foundation
/// The three kinds of blocking rules, mirroring the reference app's "New Rule" sheet.
enum RuleKind: String, Codable, CaseIterable, Sendable {
/// Block selected apps during a recurring time window.
case schedule
/// Block selected apps after a daily usage budget is spent.
case timeLimit
/// Block selected apps after a number of opens per day.
case openLimit
var displayName: String {
switch self {
case .schedule: "Schedule"
case .timeLimit: "Time Limit"
case .openLimit: "Open Limit"
}
}
var exampleText: String {
switch self {
case .schedule: "e.g. 9-5, Daily"
case .timeLimit: "e.g. 45m/day"
case .openLimit: "e.g. 5 opens/day"
}
}
var symbolName: String {
switch self {
case .schedule: "calendar"
case .timeLimit: "hourglass"
case .openLimit: "lock.fill"
}
}
/// Default name given to a brand-new rule of this kind (Opal: "In the Zone", "Time Keeper").
var defaultRuleName: String {
switch self {
case .schedule: "In the Zone"
case .timeLimit: "Time Keeper"
case .openLimit: "Gate Keeper"
}
}
}
/// How the rule's app selection is interpreted.
enum SelectionMode: String, Codable, CaseIterable, Sendable {
/// Block the selected apps; everything else stays available.
case block
/// Block everything except the selected apps.
case allowOnly
var displayName: String {
switch self {
case .block: "Block"
case .allowOnly: "Allow Only"
}
}
}

View File

@@ -0,0 +1,98 @@
//
// RulePreset.swift
// Severed
//
import SwiftUI
/// A suggested schedule rule shown in the New Rule sheet's preset gallery,
/// mirroring the reference app's sections and timings.
struct RulePreset: Identifiable, Hashable, Sendable {
let id: String
let name: String
let startMinutes: Int
let endMinutes: Int
let days: Set<Weekday>
let symbolName: String
/// Gradient stand-in for the reference app's photo backgrounds.
let gradientTop: Color
let gradientBottom: Color
var schedule: RuleSchedule {
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
}
}
/// A titled group of presets ("Get More Done", "Sleep, Relax and Reset", ).
struct RulePresetSection: Identifiable, Hashable, Sendable {
let id: String
let title: String
let subtitle: String
let presets: [RulePreset]
static let all: [RulePresetSection] = [
RulePresetSection(
id: "productivity",
title: "Get More Done",
subtitle: "Maximize your productivity while staying sane.",
presets: [
RulePreset(
id: "work-time", name: "Work Time",
startMinutes: 9 * 60, endMinutes: 17 * 60, days: Weekday.weekdays,
symbolName: "briefcase.fill",
gradientTop: Color(red: 0.16, green: 0.27, blue: 0.22),
gradientBottom: Color(red: 0.05, green: 0.10, blue: 0.08)
),
RulePreset(
id: "laser-focus", name: "Laser Focus",
startMinutes: 14 * 60, endMinutes: 18 * 60, days: Weekday.weekdays,
symbolName: "scope",
gradientTop: Color(red: 0.13, green: 0.20, blue: 0.33),
gradientBottom: Color(red: 0.04, green: 0.06, blue: 0.12)
),
]
),
RulePresetSection(
id: "sleep",
title: "Sleep, Relax and Reset",
subtitle: "Sleep better, rise refreshed.",
presets: [
RulePreset(
id: "wind-down", name: "Wind Down",
startMinutes: 20 * 60, endMinutes: 22 * 60, days: Weekday.everyDay,
symbolName: "moon.haze.fill",
gradientTop: Color(red: 0.25, green: 0.18, blue: 0.33),
gradientBottom: Color(red: 0.08, green: 0.05, blue: 0.12)
),
RulePreset(
id: "deep-sleep", name: "Deep Sleep",
startMinutes: 22 * 60, endMinutes: 6 * 60, days: Weekday.everyDay,
symbolName: "moon.zzz.fill",
gradientTop: Color(red: 0.10, green: 0.13, blue: 0.30),
gradientBottom: Color(red: 0.02, green: 0.03, blue: 0.10)
),
]
),
RulePresetSection(
id: "habits",
title: "Build Healthy Habits",
subtitle: "Spend time on important things.",
presets: [
RulePreset(
id: "reading-time", name: "Reading Time",
startMinutes: 19 * 60 + 45, endMinutes: 20 * 60 + 45, days: Weekday.everyDay,
symbolName: "book.fill",
gradientTop: Color(red: 0.33, green: 0.23, blue: 0.13),
gradientBottom: Color(red: 0.12, green: 0.07, blue: 0.03)
),
RulePreset(
id: "gym-time", name: "Gym Time",
startMinutes: 17 * 60 + 30, endMinutes: 18 * 60 + 30, days: Weekday.everyDay,
symbolName: "figure.run",
gradientTop: Color(red: 0.13, green: 0.30, blue: 0.30),
gradientBottom: Color(red: 0.03, green: 0.10, blue: 0.10)
),
]
),
]
}

View File

@@ -0,0 +1,65 @@
//
// Weekday.swift
// Severed
//
import Foundation
/// A day of the week, using `Calendar` weekday numbering (1 = Sunday 7 = Saturday).
enum Weekday: Int, CaseIterable, Codable, Hashable, Sendable {
case sunday = 1
case monday = 2
case tuesday = 3
case wednesday = 4
case thursday = 5
case friday = 6
case saturday = 7
static let weekdays: Set<Weekday> = [.monday, .tuesday, .wednesday, .thursday, .friday]
static let weekends: Set<Weekday> = [.saturday, .sunday]
static let everyDay: Set<Weekday> = Set(Weekday.allCases)
/// Display order used by the day picker, matching the reference UI: S M T W T F S.
static let displayOrder: [Weekday] = [
.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday,
]
/// Single-letter label for the circular day toggles.
var shortLabel: String {
switch self {
case .sunday, .saturday: "S"
case .monday: "M"
case .tuesday, .thursday: "T"
case .wednesday: "W"
case .friday: "F"
}
}
/// Three-letter abbreviation used in custom day summaries ("Mon, Wed, Fri").
var abbreviation: String {
switch self {
case .sunday: "Sun"
case .monday: "Mon"
case .tuesday: "Tue"
case .wednesday: "Wed"
case .thursday: "Thu"
case .friday: "Fri"
case .saturday: "Sat"
}
}
}
extension Set<Weekday> {
/// Human-readable summary shown next to the day picker and in rule details:
/// "Weekdays", "Weekends", "Every day", "Never", or a list like "Mon, Wed, Fri".
var summary: String {
if self == Weekday.everyDay { return "Every day" }
if self == Weekday.weekdays { return "Weekdays" }
if self == Weekday.weekends { return "Weekends" }
if isEmpty { return "Never" }
return Weekday.displayOrder
.filter(contains)
.map(\.abbreviation)
.joined(separator: ", ")
}
}