Replace the wide BlockingRule/RuleDraft "god struct" — where every option existed for every kind — with a RuleConfiguration sum type that carries only the options each kind actually has: .schedule(ScheduleConfig: window + selectionMode + blockAdultContent) .timeLimit(TimeLimitConfig: dailyLimitMinutes) .openLimit(OpenLimitConfig: maxOpens) Name, days, Hard Mode, app list, and pause stay common to all kinds. This makes illegal states unrepresentable: Block / Allow Only and Block Adult Content are now structurally Schedule-only. User-visible behavior change: the Time Limit and Open Limit editors no longer offer a Block Adult Content toggle, and their detail sheets drop the "Adult websites" row — those never made sense for a usage budget. Limit rules are always Block and never engage the web-content filter. BlockingRule keeps flat columns as raw persistence behind a computed `configuration` bridge (lowest SwiftData risk; the cross-process RuleSnapshot wire format and the Screen Time extensions are untouched). Logic, the editors, and the detail sheet all switch on the sum type. Spec (§1, §3.5/§3.6, §5.2) updated first; tests reworked to the new API with new structural-guarantee unit tests and a UI test asserting the Time Limit editor/detail omit adult content. Full suite green (180 tests). Co-Authored-By: Claude <noreply@anthropic.com>
137 lines
5.0 KiB
Swift
137 lines
5.0 KiB
Swift
//
|
|
// RuleDetailSheet.swift
|
|
// OpenAppLock
|
|
//
|
|
|
|
import SwiftData
|
|
import SwiftUI
|
|
|
|
/// Rule summary presented as a plain sheet: inline title with a live status
|
|
/// caption, the rule's facts as labeled rows, and "Edit Rule" — which pushes
|
|
/// the editor. A hard-locked rule shows a lock notice instead.
|
|
struct RuleDetailSheet: View {
|
|
let rule: BlockingRule
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Environment(RuleEnforcer.self) private var enforcer
|
|
@State private var isEditing = false
|
|
@State private var pendingDeletion = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
TimelineView(.periodic(from: .now, by: 30)) { timeline in
|
|
detailList(now: timeline.date)
|
|
}
|
|
.navigationDestination(isPresented: $isEditing) {
|
|
RuleEditorView(
|
|
mode: .edit(isEnabled: rule.isEnabled),
|
|
draft: RuleDraft(rule: rule),
|
|
onCommit: { draft in
|
|
draft.apply(to: rule)
|
|
isEditing = false
|
|
},
|
|
onToggleEnabled: {
|
|
rule.isEnabled.toggle()
|
|
rule.pausedUntil = nil
|
|
isEditing = false
|
|
},
|
|
onDelete: {
|
|
pendingDeletion = true
|
|
dismiss()
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.onDisappear {
|
|
if pendingDeletion {
|
|
modelContext.delete(rule)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func detailList(now: Date) -> some View {
|
|
let usage = enforcer.usage(for: rule, at: now)
|
|
let status = rule.status(at: now, usage: usage)
|
|
return List {
|
|
Section {
|
|
detailRows
|
|
}
|
|
Section {
|
|
if RulePolicy.canEdit(rule, usage: usage, at: now) {
|
|
Button {
|
|
isEditing = true
|
|
} label: {
|
|
Label("Edit Rule", systemImage: "pencil")
|
|
}
|
|
.accessibilityIdentifier("editRuleButton")
|
|
} else {
|
|
Label(
|
|
"Hard Mode is on — this rule is locked until the block ends.",
|
|
systemImage: "lock.fill"
|
|
)
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityIdentifier("hardModeLockedNotice")
|
|
}
|
|
}
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
Button("Close", systemImage: "xmark") {
|
|
dismiss()
|
|
}
|
|
.accessibilityIdentifier("closeDetailButton")
|
|
}
|
|
ToolbarItem(placement: .principal) {
|
|
VStack(spacing: 1) {
|
|
Text(rule.name)
|
|
.font(.headline)
|
|
.accessibilityIdentifier("detailRuleName")
|
|
Text("\(rule.kind.displayName), \(rule.statusLabel(for: status, relativeTo: now))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityIdentifier("detailStatusLabel")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var detailRows: some View {
|
|
switch rule.configuration {
|
|
case .schedule(let config):
|
|
row("During this time", rule.schedule.timeRangeLabel)
|
|
row("On these days", rule.days.summary)
|
|
row(config.selectionMode.displayName, appCountLabel)
|
|
// Adult websites is a Schedule-only option (see spec §1).
|
|
row("Adult websites", config.blockAdultContent ? "Blocked" : "Allowed")
|
|
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
|
case .timeLimit(let config):
|
|
row("When I use", appCountLabel)
|
|
row("For this long", "\(config.dailyLimitMinutes)m daily")
|
|
row("On these days", rule.days.summary)
|
|
row("Then block until", "Tomorrow")
|
|
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
|
case .openLimit(let config):
|
|
row("When I open", appCountLabel)
|
|
row("More than", "\(config.maxOpens) opens daily")
|
|
row("On these days", rule.days.summary)
|
|
row("Then block until", "Tomorrow")
|
|
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
|
}
|
|
}
|
|
|
|
private var appCountLabel: String {
|
|
guard let list = rule.appList else { return "No apps" }
|
|
return "\(list.name) · \(list.appCountLabel)"
|
|
}
|
|
|
|
private func row(_ label: String, _ value: String) -> some View {
|
|
LabeledContent(label, value: value)
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityIdentifier("detailRow-\(label)")
|
|
}
|
|
}
|