// // RuleEditorView.swift // Severed // import SwiftUI /// The rule editor used both for creation (Hold to Commit) and editing /// (Done / Disable Rule / Delete Rule). Sections adapt to the rule kind, /// mirroring the reference app's "In the Zone" and "Time Keeper" editors. struct RuleEditorView: View { enum Mode: Equatable { case create case edit(isEnabled: Bool) } let mode: Mode @State var draft: RuleDraft /// True when pushed inside a NavigationStack (New Rule flow): the editor /// then uses native navigation chrome (system back button, swipe-back) /// instead of its custom header. var embedsInNavigationStack = false var onBack: () -> Void = {} 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 { if embedsInNavigationStack { core .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.hidden, for: .navigationBar) .toolbar { ToolbarItem(placement: .principal) { Text(draft.name) .font(.system(size: 18, weight: .semibold)) .foregroundStyle(.white) .lineLimit(1) .accessibilityIdentifier("ruleEditorTitle") } ToolbarItem(placement: .topBarTrailing) { Button { renameText = draft.name showingRename = true } label: { Image(systemName: "pencil") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) } .accessibilityIdentifier("renameButton") } } } else { core } } private var core: some View { VStack(spacing: 0) { if !embedsInNavigationStack { header } ScrollView { VStack(spacing: 18) { sections } .padding(.horizontal, 20) .padding(.top, 10) .padding(.bottom, 24) } footer .padding(.horizontal, 20) .padding(.bottom, 16) } .foregroundStyle(.white) .background(Theme.background) .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) .presentationDragIndicator(.visible) } } // MARK: - Header private var header: some View { HStack { CircleIconButton(systemImage: "chevron.left", action: onBack) .accessibilityIdentifier("editorBackButton") Spacer() CircleIconButton(systemImage: "pencil") { renameText = draft.name showingRename = true } .accessibilityIdentifier("renameButton") } .overlay( Text(draft.name) .font(.system(size: 18, weight: .semibold)) .lineLimit(1) .padding(.horizontal, 56) .accessibilityIdentifier("ruleEditorTitle") ) .padding(.horizontal, 20) .padding(.top, 18) } // MARK: - Sections @ViewBuilder private var sections: some View { switch draft.kind { case .schedule: timeWindowSection DayOfWeekPicker(days: $draft.days) appsSection(header: "Apps are blocked") hardModeSection adultContentSection case .timeLimit: appsSection(header: "When I use") budgetSection( title: "For this long", value: "\(draft.dailyLimitMinutes)m", stepperID: "dailyLimitStepper", onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) }, onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) } ) DayOfWeekPicker(days: $draft.days) blockUntilSection hardModeSection adultContentSection case .openLimit: appsSection(header: "When I open") budgetSection( title: "More than", value: "\(draft.maxOpens) opens", stepperID: "maxOpensStepper", onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) }, onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) } ) DayOfWeekPicker(days: $draft.days) blockUntilSection hardModeSection adultContentSection } } private var timeWindowSection: some View { VStack(alignment: .leading, spacing: 10) { sectionHeader(systemImage: "calendar", title: "During this time") VStack(spacing: 0) { timeRow(label: "From", minutes: $draft.startMinutes, identifier: "fromTimePicker") Divider().background(.white.opacity(0.06)).padding(.leading, 16) timeRow(label: "To", minutes: $draft.endMinutes, identifier: "toTimePicker") } .background(Theme.surface, in: RoundedRectangle(cornerRadius: 18)) } } private func timeRow(label: String, minutes: Binding, identifier: String) -> some View { HStack { Text(label) .font(.system(size: 15, weight: .medium)) Spacer() DatePicker("", selection: timeBinding(minutes), displayedComponents: .hourAndMinute) .labelsHidden() .colorScheme(.dark) .accessibilityIdentifier(identifier) } .padding(.horizontal, 16) .padding(.vertical, 8) } private func appsSection(header: String) -> some View { VStack(alignment: .leading, spacing: 10) { sectionHeader(systemImage: "shield.fill", title: header) Button { showingAppPicker = true } label: { HStack { Text("Selected Apps") .font(.system(size: 15, weight: .medium)) Spacer() Text(draft.selectionCount == 1 ? "1 App" : "\(draft.selectionCount) Apps") .font(.system(size: 15)) .foregroundStyle(Theme.textSecondary) Image(systemName: "chevron.right") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Theme.textTertiary) } .padding(16) .background(Theme.surface, in: RoundedRectangle(cornerRadius: 18)) } .buttonStyle(.plain) .accessibilityIdentifier("selectedAppsRow") } } private func budgetSection( title: String, value: String, stepperID: String, onIncrement: @escaping () -> Void, onDecrement: @escaping () -> Void ) -> some View { HStack { VStack(alignment: .leading, spacing: 2) { Text(title) .font(.system(size: 15, weight: .medium)) Text("Daily") .font(.system(size: 12)) .foregroundStyle(Theme.textTertiary) } Spacer() Text(value) .font(.system(size: 15, weight: .semibold)) .accessibilityIdentifier("\(stepperID)Value") Stepper("", onIncrement: onIncrement, onDecrement: onDecrement) .labelsHidden() .accessibilityIdentifier(stepperID) } .padding(16) .background(Theme.surface, in: RoundedRectangle(cornerRadius: 18)) } private var blockUntilSection: some View { VStack(alignment: .leading, spacing: 10) { sectionHeader(systemImage: "shield.fill", title: "Then block app") HStack { Text("Until") .font(.system(size: 15, weight: .medium)) Spacer() Text("Tomorrow") .font(.system(size: 15)) .foregroundStyle(Theme.textSecondary) } .padding(16) .background(Theme.surface, in: RoundedRectangle(cornerRadius: 18)) } } private var hardModeSection: some View { HStack { VStack(alignment: .leading, spacing: 2) { Text("Hard Mode") .font(.system(size: 15, weight: .semibold)) Text("No unblocks allowed") .font(.system(size: 12)) .foregroundStyle(Theme.textTertiary) } Spacer() Toggle("", isOn: $draft.hardMode) .labelsHidden() .tint(Theme.accent) .accessibilityIdentifier("hardModeToggle") } .padding(16) .background(Theme.surface, in: RoundedRectangle(cornerRadius: 18)) } private var adultContentSection: some View { HStack { VStack(alignment: .leading, spacing: 2) { Text("Block Adult Content") .font(.system(size: 15, weight: .semibold)) Text("Filter adult websites while this rule is active") .font(.system(size: 12)) .foregroundStyle(Theme.textTertiary) } Spacer() Toggle("", isOn: $draft.blockAdultContent) .labelsHidden() .tint(Theme.accent) .accessibilityIdentifier("adultContentToggle") } .padding(16) .background(Theme.surface, in: RoundedRectangle(cornerRadius: 18)) } private func sectionHeader(systemImage: String, title: String) -> some View { HStack(spacing: 8) { Image(systemName: systemImage) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Theme.textSecondary) Text(title) .font(.system(size: 15, weight: .semibold)) } .padding(.leading, 4) } // MARK: - Footer @ViewBuilder private var footer: some View { switch mode { case .create: HoldToCommitButton { onCommit(draft) } case .edit(let isEnabled): VStack(spacing: 12) { Button { onCommit(draft) } label: { Label("Done", systemImage: "checkmark") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(.black) .frame(maxWidth: .infinity) .frame(height: 54) .background(.white, in: RoundedRectangle(cornerRadius: 27)) } .buttonStyle(.plain) .accessibilityIdentifier("doneButton") HStack(spacing: 24) { Button(isEnabled ? "Disable Rule" : "Enable Rule") { onToggleEnabled?() } .accessibilityIdentifier("toggleEnabledButton") Button("Delete Rule") { onDelete?() } .accessibilityIdentifier("deleteRuleButton") } .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Theme.destructive) } } } 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) } } }