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,203 @@
//
// AppsHomeView.swift
// Severed
//
import SwiftData
import SwiftUI
/// The app's home screen, modeled on the reference "Apps" tab: what's blocked
/// right now, then the Rules carousel with "+ New".
struct AppsHomeView: View {
@Environment(\.modelContext) private var modelContext
@Environment(RuleEnforcer.self) private var enforcer
@Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule]
@State private var detailRule: BlockingRule?
@State private var showingNewRule = false
@State private var unblockCandidate: BlockingRule?
@State private var hardModeBlockedAttempt = false
var body: some View {
NavigationStack {
TimelineView(.periodic(from: .now, by: 30)) { timeline in
ScrollView {
VStack(alignment: .leading, spacing: 28) {
blockedAppsSection(now: timeline.date)
rulesSection(now: timeline.date)
}
.padding(.horizontal, 20)
.padding(.top, 8)
}
}
.background(Theme.background)
.navigationTitle("Apps")
.toolbarColorScheme(.dark, for: .navigationBar)
}
.sheet(item: $detailRule) { rule in
RuleDetailSheet(rule: rule)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showingNewRule) {
NewRuleSheet()
.presentationDragIndicator(.visible)
}
.confirmationDialog(
"Unblock \(unblockCandidate?.name ?? "")?",
isPresented: Binding(
get: { unblockCandidate != nil },
set: { if !$0 { unblockCandidate = nil } }
),
titleVisibility: .visible
) {
Button("Unblock", role: .destructive) {
if let rule = unblockCandidate {
RulePolicy.unblock(rule)
refreshEnforcement()
}
unblockCandidate = nil
}
} message: {
Text("Blocking resumes with the rule's next window.")
}
.alert("Hard Mode is on", isPresented: $hardModeBlockedAttempt) {
Button("OK", role: .cancel) {}
} message: {
Text("This block can't be lifted until it ends.")
}
.task {
await enforcementLoop()
}
.onChange(of: ruleChangeToken) {
refreshEnforcement()
}
}
// MARK: - Blocked Apps
@ViewBuilder
private func blockedAppsSection(now: Date) -> some View {
let blocking = rules.filter { $0.status(at: now).isActive }
VStack(alignment: .leading, spacing: 14) {
Text("Blocked Apps")
.font(.system(size: 17, weight: .semibold))
if blocking.isEmpty {
Text("Nothing is blocked right now.")
.font(.system(size: 14))
.foregroundStyle(Theme.textTertiary)
.accessibilityIdentifier("nothingBlockedLabel")
} else {
HStack(spacing: 16) {
ForEach(blocking) { rule in
blockedTile(for: rule, now: now)
}
}
}
}
}
private func blockedTile(for rule: BlockingRule, now: Date) -> some View {
Button {
if RulePolicy.canUnblock(rule, at: now) {
unblockCandidate = rule
} else {
hardModeBlockedAttempt = true
}
} label: {
VStack(spacing: 6) {
Image(systemName: rule.hardMode ? "lock.fill" : "lock.open.fill")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
.frame(width: 58, height: 58)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.strokeBorder(Theme.accent.opacity(0.6), lineWidth: 1)
)
Text("Unblock")
.font(.system(size: 12))
.foregroundStyle(Theme.textSecondary)
}
}
.buttonStyle(.plain)
.accessibilityIdentifier("blockedTile-\(rule.name)")
}
// MARK: - Rules
private func rulesSection(now: Date) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("Rules")
.font(.system(size: 17, weight: .semibold))
Spacer()
Button {
showingNewRule = true
} label: {
Label("New", systemImage: "plus")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.accent)
}
.accessibilityIdentifier("newRuleButton")
}
if rules.isEmpty {
emptyRulesCard
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 14) {
ForEach(rules) { rule in
Button {
detailRule = rule
} label: {
RuleCardView(rule: rule, now: now)
}
.buttonStyle(.plain)
.accessibilityIdentifier("ruleCard-\(rule.name)")
}
}
}
}
}
}
private var emptyRulesCard: some View {
VStack(alignment: .leading, spacing: 8) {
Text("No rules yet")
.font(.system(size: 16, weight: .semibold))
Text("Create a rule to block distracting apps on a schedule.")
.font(.system(size: 13))
.foregroundStyle(Theme.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 22))
.accessibilityElement(children: .combine)
.accessibilityIdentifier("emptyRulesCard")
}
// MARK: - Enforcement
/// Changes whenever any rule's blocking-relevant state changes.
private var ruleChangeToken: String {
rules.map {
"\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.startMinutes)|\($0.endMinutes)|"
+ "\($0.dayNumbers)|\($0.pausedUntil?.timeIntervalSince1970 ?? 0)"
}
.joined(separator: ",")
}
private func refreshEnforcement() {
enforcer.refresh(rules: rules)
}
/// Keeps shields in sync while the app is open, so windows that begin or
/// end while the user is looking at the screen take effect promptly.
private func enforcementLoop() async {
while !Task.isCancelled {
let allRules = (try? modelContext.fetch(FetchDescriptor<BlockingRule>())) ?? []
enforcer.refresh(rules: allRules)
try? await Task.sleep(for: .seconds(30))
}
}
}

View File

@@ -0,0 +1,90 @@
//
// RuleCardView.swift
// Severed
//
import SwiftUI
/// A rule tile in the home screen's Rules carousel: icon pair, live status,
/// name, and the block summary. Active rules glow green.
struct RuleCardView: View {
let rule: BlockingRule
let now: Date
private var status: RuleStatus { rule.status(at: now) }
var body: some View {
VStack(alignment: .leading, spacing: 12) {
RuleIconPair(kind: rule.kind, isActive: status.isActive)
statusView
Text(rule.name)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
HStack(spacing: 5) {
Text(rule.selectionMode.displayName)
.foregroundStyle(Theme.textSecondary)
Text(blockSummary)
.foregroundStyle(Theme.textTertiary)
}
.font(.system(size: 13))
}
.padding(16)
.frame(width: 172, alignment: .leading)
.background(cardBackground, in: RoundedRectangle(cornerRadius: 22))
.overlay(
RoundedRectangle(cornerRadius: 22)
.strokeBorder(
status.isActive ? Theme.accent.opacity(0.35) : .white.opacity(0.08),
lineWidth: 1
)
)
}
@ViewBuilder
private var statusView: some View {
switch status {
case .active:
Text(status.label(relativeTo: now))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.black)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Theme.accent, in: Capsule())
.accessibilityIdentifier("ruleStatus-\(rule.name)")
case .paused, .disabled, .dormant, .upcoming:
Text(statusText)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.textSecondary)
.padding(.vertical, 5)
.accessibilityIdentifier("ruleStatus-\(rule.name)")
}
}
private var statusText: String {
switch rule.kind {
case .schedule:
status.label(relativeTo: now)
case .timeLimit:
rule.isEnabled ? "\(rule.dailyLimitMinutes)m / day" : "Disabled"
case .openLimit:
rule.isEnabled ? "\(rule.maxOpens) opens / day" : "Disabled"
}
}
private var blockSummary: String {
rule.selectionCount == 1 ? "· 1 app" : "· \(rule.selectionCount) apps"
}
private var cardBackground: some ShapeStyle {
if status.isActive {
return AnyShapeStyle(
LinearGradient(
colors: [Theme.activeCardTop, Theme.activeCardBottom],
startPoint: .top, endPoint: .bottom
)
)
}
return AnyShapeStyle(Theme.surface)
}
}