- 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
254 lines
8.2 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|