// // 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) -> Binding { 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) } } }