Files
OpenAppLock/Severed/Views/Rules/RuleEditorView.swift
Brendan Chen fd1f5d758f feat: adult content filter toggle + native New Rule navigation
- Add Block Adult Content toggle to all three rule editors, persisted as
  BlockingRule.blockAdultContent (inline default for clean migration of
  existing stores) and surfaced in the detail sheet as an
  'Adult websites: Blocked/Allowed' row
- Engage Screen Time's adult-website filter
  (webContent.blockedByFilter = .auto()) alongside the rule's shield and
  clear it when the shield clears
- Replace the New Rule sheet's view-swap with a NavigationStack push
  (navigationDestination(item:)); the editor uses native chrome there
  (system back button, inline title, toolbar rename), enabling the push
  animation and edge-swipe back
- Tests: 93 passing (+2 unit: draft round-trip and enforcer forwarding;
  +3 UI: toggle-to-detail flow, default-allowed row, swipe-back as a
  behavioral proof of native navigation)
- Spec updated accordingly (editor sections, behavior, data model,
  navigation note)
2026-06-12 13:34:52 -04:00

356 lines
13 KiB
Swift

//
// 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<Int>, 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<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)
}
}
}