Files
OpenAppLock/Severed/Views/Rules/RuleEditorView.swift
Brendan Chen fb219aa8d8 refactor: re-skin UI with native iOS components
Replace the Opal-style custom presentation with the bare iOS design
language, keeping the backend, flows, and accessibility identifiers:

- Home: NavigationStack + List; rules now presented as list rows with
  kind icon, block summary, and trailing live status; '+' in the toolbar
- New Rule: plain list of rule types and presets; editor still pushed
  via navigationDestination
- Editor: native Form (DatePicker rows, day circles with summary in the
  section header, toggle rows with footers, stepper rows); create
  commits with a prominent 'Add Rule' button replacing Hold to Commit;
  edit uses toolbar Done plus red Disable/Delete rows
- Detail: sheet with inline title + status caption, LabeledContent
  rows; Edit Rule pushes the editor natively
- Default color scheme: drop forced dark mode and custom tint; system
  appearance and accent throughout
- Delete Theme, HoldToCommitButton, RuleCardView and custom chrome
- Use concrete Color.primary/secondary in button row labels (the
  hierarchical styles resolve against the button tint)
- Tests: 93 passing; only delta is holdToCommitButton press →
  commitRuleButton tap in 5 call sites
- Spec: add §6 mapping the original presentation to the native one

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 15:14:01 -04:00

263 lines
8.4 KiB
Swift

//
// RuleEditorView.swift
// Severed
//
import SwiftUI
/// The rule editor as a plain Form, always pushed inside a NavigationStack
/// (New Rule flow and detail editing both push it). Creation commits with a
/// prominent "Add Rule" button; editing uses a toolbar Done plus red
/// Disable/Delete rows.
struct RuleEditorView: View {
enum Mode: Equatable {
case create
case edit(isEnabled: Bool)
}
let mode: Mode
@State var draft: RuleDraft
var onCommit: (RuleDraft) -> Void
var onToggleEnabled: (() -> Void)?
var onDelete: (() -> Void)?
@State private var showingRename = false
@State private var showingAppPicker = false
@State private var renameText = ""
var body: some View {
Form {
sections
if case .edit(let isEnabled) = mode {
Section {
Button(isEnabled ? "Disable Rule" : "Enable Rule") {
onToggleEnabled?()
}
.foregroundStyle(.red)
.accessibilityIdentifier("toggleEnabledButton")
Button("Delete Rule", role: .destructive) {
onDelete?()
}
.accessibilityIdentifier("deleteRuleButton")
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Text(draft.name)
.font(.headline)
.lineLimit(1)
.accessibilityIdentifier("ruleEditorTitle")
}
ToolbarItem(placement: .topBarTrailing) {
Button("Rename", systemImage: "pencil") {
renameText = draft.name
showingRename = true
}
.accessibilityIdentifier("renameButton")
}
if case .edit = mode {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
onCommit(draft)
}
.accessibilityIdentifier("doneButton")
}
}
}
.safeAreaInset(edge: .bottom) {
if mode == .create {
Button {
onCommit(draft)
} label: {
Text("Add Rule")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.horizontal)
.padding(.bottom, 8)
.accessibilityIdentifier("commitRuleButton")
}
}
.alert("Rule Name", isPresented: $showingRename) {
TextField("Name", text: $renameText)
Button("OK") {
let trimmed = renameText.trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty {
draft.name = trimmed
}
}
Button("Cancel", role: .cancel) {}
}
.sheet(isPresented: $showingAppPicker) {
AppSelectionSheet(draft: $draft)
}
}
// MARK: - Sections
@ViewBuilder
private var sections: some View {
switch draft.kind {
case .schedule:
Section {
DatePicker(
"From",
selection: timeBinding($draft.startMinutes),
displayedComponents: .hourAndMinute
)
.accessibilityIdentifier("fromTimePicker")
DatePicker(
"To",
selection: timeBinding($draft.endMinutes),
displayedComponents: .hourAndMinute
)
.accessibilityIdentifier("toTimePicker")
} header: {
Text("During this time").textCase(nil)
}
daysSection
Section {
selectedAppsRow
} header: {
Text("Apps are blocked").textCase(nil)
}
toggleSections
case .timeLimit:
Section {
selectedAppsRow
} header: {
Text("When I use").textCase(nil)
}
Section {
budgetRow(
value: "\(draft.dailyLimitMinutes)m",
stepperID: "dailyLimitStepper",
onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) },
onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) }
)
} header: {
Text("For this long").textCase(nil)
}
daysSection
blockUntilSection
toggleSections
case .openLimit:
Section {
selectedAppsRow
} header: {
Text("When I open").textCase(nil)
}
Section {
budgetRow(
value: "\(draft.maxOpens) opens",
stepperID: "maxOpensStepper",
onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) },
onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) }
)
} header: {
Text("More than").textCase(nil)
}
daysSection
blockUntilSection
toggleSections
}
}
private var daysSection: some View {
Section {
DayOfWeekPicker(days: $draft.days)
} header: {
HStack {
Text("On these days").textCase(nil)
Spacer()
Text(draft.days.summary).textCase(nil)
}
}
}
private var blockUntilSection: some View {
Section {
LabeledContent("Until", value: "Tomorrow")
} header: {
Text("Then block app").textCase(nil)
}
}
@ViewBuilder
private var toggleSections: some View {
Section {
HStack {
Text("Hard Mode")
Spacer()
Toggle("", isOn: $draft.hardMode)
.labelsHidden()
.accessibilityIdentifier("hardModeToggle")
}
} footer: {
Text("No unblocks allowed while the rule is blocking.")
}
Section {
HStack {
Text("Block Adult Content")
Spacer()
Toggle("", isOn: $draft.blockAdultContent)
.labelsHidden()
.accessibilityIdentifier("adultContentToggle")
}
} footer: {
Text("Filter adult websites while this rule is active.")
}
}
private var selectedAppsRow: some View {
Button {
showingAppPicker = true
} label: {
HStack {
Text("Selected Apps")
.foregroundStyle(Color.primary)
Spacer()
Text(draft.selectionCount == 1 ? "1 App" : "\(draft.selectionCount) Apps")
.foregroundStyle(Color.secondary)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.accessibilityIdentifier("selectedAppsRow")
}
private func budgetRow(
value: String,
stepperID: String,
onIncrement: @escaping () -> Void,
onDecrement: @escaping () -> Void
) -> some View {
HStack {
Text("Daily")
Spacer()
Text(value)
.foregroundStyle(.secondary)
.accessibilityIdentifier("\(stepperID)Value")
Stepper("", onIncrement: onIncrement, onDecrement: onDecrement)
.labelsHidden()
.accessibilityIdentifier(stepperID)
}
}
private func timeBinding(_ minutes: Binding<Int>) -> Binding<Date> {
Binding {
let dayStart = Calendar.current.startOfDay(for: .now)
return Calendar.current.date(byAdding: .minute, value: minutes.wrappedValue, to: dayStart)
?? .now
} set: { newDate in
let components = Calendar.current.dateComponents([.hour, .minute], from: newDate)
minutes.wrappedValue = (components.hour ?? 0) * 60 + (components.minute ?? 0)
}
}
}