- 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)
158 lines
5.4 KiB
Swift
158 lines
5.4 KiB
Swift
//
|
|
// NewRuleSheet.swift
|
|
// Severed
|
|
//
|
|
|
|
import SwiftData
|
|
import SwiftUI
|
|
|
|
/// "New Rule": the three rule-type cards up top, then the preset gallery.
|
|
/// Picking either one morphs the sheet into the editor; committing saves the
|
|
/// rule and closes the sheet.
|
|
struct NewRuleSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.modelContext) private var modelContext
|
|
@State private var pendingDraft: RuleDraft?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
chooser
|
|
.toolbar(.hidden, for: .navigationBar)
|
|
.navigationDestination(item: $pendingDraft) { draft in
|
|
RuleEditorView(
|
|
mode: .create,
|
|
draft: draft,
|
|
embedsInNavigationStack: true,
|
|
onCommit: { committed in
|
|
modelContext.insert(committed.makeRule())
|
|
dismiss()
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.background(Theme.background)
|
|
}
|
|
|
|
private var chooser: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
CircleIconButton(systemImage: "xmark") { dismiss() }
|
|
.accessibilityIdentifier("closeNewRuleButton")
|
|
Spacer()
|
|
}
|
|
.overlay(
|
|
Text("New Rule")
|
|
.font(.system(size: 18, weight: .semibold))
|
|
)
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 18)
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 26) {
|
|
HStack(spacing: 10) {
|
|
ForEach(RuleKind.allCases, id: \.self) { kind in
|
|
kindCard(kind)
|
|
}
|
|
}
|
|
ForEach(RulePresetSection.all) { section in
|
|
presetSection(section)
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 20)
|
|
.padding(.bottom, 30)
|
|
}
|
|
}
|
|
.foregroundStyle(.white)
|
|
}
|
|
|
|
private func kindCard(_ kind: RuleKind) -> some View {
|
|
Button {
|
|
pendingDraft = RuleDraft(kind: kind)
|
|
} label: {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: kind.symbolName)
|
|
.font(.system(size: 20, weight: .semibold))
|
|
.foregroundStyle(Theme.accent)
|
|
.frame(height: 26)
|
|
Text(kind.displayName)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
Text(kind.exampleText)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Theme.textTertiary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 18)
|
|
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityIdentifier("ruleKind-\(kind.rawValue)")
|
|
}
|
|
|
|
private func presetSection(_ section: RulePresetSection) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(section.title)
|
|
.font(.system(size: 18, weight: .bold))
|
|
Text(section.subtitle)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(Theme.textSecondary)
|
|
LazyVGrid(
|
|
columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible())],
|
|
spacing: 12
|
|
) {
|
|
ForEach(section.presets) { preset in
|
|
presetCard(preset)
|
|
}
|
|
}
|
|
.padding(.top, 12)
|
|
}
|
|
}
|
|
|
|
private func presetCard(_ preset: RulePreset) -> some View {
|
|
Button {
|
|
pendingDraft = RuleDraft(preset: preset)
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
RuleIconPair(kind: .schedule)
|
|
Spacer()
|
|
}
|
|
Spacer()
|
|
Image(systemName: preset.symbolName)
|
|
.font(.system(size: 22, weight: .semibold))
|
|
.foregroundStyle(.white.opacity(0.8))
|
|
.padding(.bottom, 8)
|
|
Text(preset.schedule.timeRangeLabel)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Theme.textSecondary)
|
|
Text(preset.name)
|
|
.font(.system(size: 16, weight: .semibold))
|
|
HStack {
|
|
Text("Block")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Theme.textSecondary)
|
|
Spacer()
|
|
Image(systemName: "plus.circle.fill")
|
|
.font(.system(size: 22))
|
|
.foregroundStyle(.white.opacity(0.85))
|
|
}
|
|
}
|
|
.padding(14)
|
|
.frame(height: 190)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [preset.gradientTop, preset.gradientBottom],
|
|
startPoint: .top, endPoint: .bottom
|
|
),
|
|
in: RoundedRectangle(cornerRadius: 20)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityIdentifier("preset-\(preset.id)")
|
|
}
|
|
}
|