Files
OpenAppLock/OpenAppLock/Views/Rules/RuleDetailSheet.swift
Brendan Chen 783a8571a7 refactor: model rule options as a per-kind sum type
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>
2026-06-13 12:51:26 -04:00

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)")
}
}