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,178 @@
//
// RuleDetailSheet.swift
// Severed
//
import SwiftData
import SwiftUI
/// The card-style rule summary shown when tapping a rule: icon pair, live
/// status, the key facts, and "Edit Rule" which morphs the sheet into the
/// editor, as in the reference app. A hard-locked rule shows a lock notice
/// instead of the edit button.
struct RuleDetailSheet: View {
let rule: BlockingRule
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@State private var isEditing = false
@State private var pendingDeletion = false
var body: some View {
TimelineView(.periodic(from: .now, by: 30)) { timeline in
if isEditing {
RuleEditorView(
mode: .edit(isEnabled: rule.isEnabled),
draft: RuleDraft(rule: rule),
onBack: { isEditing = false },
onCommit: { draft in
draft.apply(to: rule)
isEditing = false
},
onToggleEnabled: {
rule.isEnabled.toggle()
rule.pausedUntil = nil
isEditing = false
},
onDelete: {
pendingDeletion = true
dismiss()
}
)
} else {
detail(now: timeline.date)
}
}
.background(Theme.background)
.onDisappear {
if pendingDeletion {
modelContext.delete(rule)
}
}
}
private func detail(now: Date) -> some View {
let status = rule.status(at: now)
return VStack(spacing: 0) {
HStack {
CircleIconButton(systemImage: "xmark") { dismiss() }
.accessibilityIdentifier("closeDetailButton")
Spacer()
}
.padding(.horizontal, 20)
.padding(.top, 18)
ScrollView {
VStack(spacing: 24) {
RuleIconPair(kind: rule.kind, isActive: status.isActive)
VStack(spacing: 6) {
Text("\(rule.kind.displayName), \(status.label(relativeTo: now))")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.textSecondary)
.accessibilityIdentifier("detailStatusLabel")
Text(rule.name)
.font(.system(size: 26, weight: .bold))
.accessibilityIdentifier("detailRuleName")
}
detailRows
}
.padding(.horizontal, 20)
.padding(.top, 8)
}
footer(now: now)
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
.foregroundStyle(.white)
}
private var detailRows: some View {
VStack(spacing: 0) {
switch rule.kind {
case .schedule:
row("During this time", rule.schedule.timeRangeLabel)
divider
row("On these days", rule.days.summary)
divider
row(rule.selectionMode.displayName, appCountLabel)
divider
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .timeLimit:
row("When I use", appCountLabel)
divider
row("For this long", "\(rule.dailyLimitMinutes)m daily")
divider
row("On these days", rule.days.summary)
divider
row("Then block until", "Tomorrow")
divider
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .openLimit:
row("When I open", appCountLabel)
divider
row("More than", "\(rule.maxOpens) opens daily")
divider
row("On these days", rule.days.summary)
divider
row("Then block until", "Tomorrow")
divider
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
}
}
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
private var appCountLabel: String {
rule.selectionCount == 1 ? "1 App" : "\(rule.selectionCount) Apps"
}
private func row(_ label: String, _ value: String) -> some View {
HStack {
Text(label)
.font(.system(size: 15, weight: .medium))
Spacer()
Text(value)
.font(.system(size: 15))
.foregroundStyle(Theme.textSecondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("detailRow-\(label)")
}
private var divider: some View {
Divider().background(.white.opacity(0.06)).padding(.leading, 16)
}
@ViewBuilder
private func footer(now: Date) -> some View {
if RulePolicy.canEdit(rule, at: now) {
Button {
isEditing = true
} label: {
Label("Edit Rule", systemImage: "pencil")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.black)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(.white, in: RoundedRectangle(cornerRadius: 27))
}
.buttonStyle(.plain)
.accessibilityIdentifier("editRuleButton")
} else {
HStack(spacing: 10) {
Image(systemName: "lock.fill")
Text("Hard Mode is on — this rule is locked until the block ends.")
.font(.system(size: 14, weight: .medium))
}
.foregroundStyle(Theme.textSecondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
.accessibilityElement(children: .combine)
.accessibilityIdentifier("hardModeLockedNotice")
}
}
}