- 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)
356 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|