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:
178
Severed/Views/Rules/RuleDetailSheet.swift
Normal file
178
Severed/Views/Rules/RuleDetailSheet.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user