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)
This commit is contained in:
2026-06-12 13:34:52 -04:00
parent 3aac2004d2
commit fd1f5d758f
11 changed files with 182 additions and 26 deletions

View File

@@ -15,20 +15,20 @@ struct NewRuleSheet: View {
@State private var pendingDraft: RuleDraft?
var body: some View {
Group {
if let pendingDraft {
RuleEditorView(
mode: .create,
draft: pendingDraft,
onBack: { self.pendingDraft = nil },
onCommit: { draft in
modelContext.insert(draft.makeRule())
dismiss()
}
)
} else {
chooser
}
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)
}

View File

@@ -97,6 +97,8 @@ struct RuleDetailSheet: View {
divider
row(rule.selectionMode.displayName, appCountLabel)
divider
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
divider
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .timeLimit:
row("When I use", appCountLabel)
@@ -107,6 +109,8 @@ struct RuleDetailSheet: View {
divider
row("Then block until", "Tomorrow")
divider
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
divider
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .openLimit:
row("When I open", appCountLabel)
@@ -117,6 +121,8 @@ struct RuleDetailSheet: View {
divider
row("Then block until", "Tomorrow")
divider
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
divider
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
}
}

View File

@@ -16,7 +16,11 @@ struct RuleEditorView: View {
let mode: Mode
@State var draft: RuleDraft
var onBack: () -> Void
/// 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)?
@@ -26,8 +30,40 @@ struct RuleEditorView: View {
@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) {
header
if !embedsInNavigationStack {
header
}
ScrollView {
VStack(spacing: 18) {
sections
@@ -92,6 +128,7 @@ struct RuleEditorView: View {
DayOfWeekPicker(days: $draft.days)
appsSection(header: "Apps are blocked")
hardModeSection
adultContentSection
case .timeLimit:
appsSection(header: "When I use")
budgetSection(
@@ -104,6 +141,7 @@ struct RuleEditorView: View {
DayOfWeekPicker(days: $draft.days)
blockUntilSection
hardModeSection
adultContentSection
case .openLimit:
appsSection(header: "When I open")
budgetSection(
@@ -116,6 +154,7 @@ struct RuleEditorView: View {
DayOfWeekPicker(days: $draft.days)
blockUntilSection
hardModeSection
adultContentSection
}
}
@@ -232,6 +271,25 @@ struct RuleEditorView: View {
.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)