Files
OpenAppLock/Severed/Views/Rules/RuleEditorView.swift
Brendan Chen b8473383d3 refactor: move rule editor actions into the navigation bar
- Both editor modes commit via a checkmark confirmation button
  (role: .confirm) in the navigation bar — accessibility labels
  'Add Rule' (create) and 'Done' (edit); replaces the bottom
  'Add Rule' button, so nothing floats over the form anymore
- Edit mode gains an ellipsis 'Rule Actions' menu next to the
  checkmark holding Disable/Enable Rule and destructive Delete Rule;
  the red form rows are removed
- Tests pin the checkmark to the navigation bar with its label and
  drive disable/delete through the menu; the swipe-to-reach
  workarounds are gone. 95 passing
- Spec §6 updated

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 16:29:32 -04:00

254 lines
8.2 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). Both modes commit via a
/// checkmark confirmation button in the navigation bar; edit mode adds an
/// ellipsis "Rule Actions" menu (Disable/Enable, Delete) next to it.
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 showingAppPicker = false
var body: some View {
Form {
nameSection
sections
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Text(draft.sanitized().name)
.font(.headline)
.lineLimit(1)
.accessibilityIdentifier("ruleEditorTitle")
}
if case .edit(let isEnabled) = mode {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button(isEnabled ? "Disable Rule" : "Enable Rule") {
onToggleEnabled?()
}
Button("Delete Rule", role: .destructive) {
onDelete?()
}
} label: {
Image(systemName: "ellipsis")
}
.accessibilityLabel("Rule Actions")
.accessibilityIdentifier("ruleActionsMenu")
}
}
ToolbarItem(placement: .confirmationAction) {
switch mode {
case .create:
Button(role: .confirm) {
onCommit(draft.sanitized())
} label: {
Image(systemName: "checkmark")
}
.accessibilityLabel("Add Rule")
.accessibilityIdentifier("commitRuleButton")
case .edit:
Button(role: .confirm) {
onCommit(draft.sanitized())
} label: {
Image(systemName: "checkmark")
}
.accessibilityLabel("Done")
.accessibilityIdentifier("doneButton")
}
}
}
.sheet(isPresented: $showingAppPicker) {
AppSelectionSheet(draft: $draft)
}
}
// MARK: - Sections
private var nameSection: some View {
Section {
TextField("Rule Name", text: $draft.name)
.submitLabel(.done)
.accessibilityIdentifier("ruleNameField")
} header: {
Text("Name").textCase(nil)
}
}
@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)
}
}
}