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

@@ -19,6 +19,9 @@ final class BlockingRule {
var isEnabled: Bool
/// Hard block: while the rule is active it cannot be disabled, edited, or unblocked.
var hardMode: Bool
/// Engage Screen Time's adult-website filter while this rule is blocking.
/// Inline default so existing stores migrate cleanly.
var blockAdultContent: Bool = false
var selectionModeRaw: String
/// Encoded `FamilyActivitySelection` (opaque tokens). Nil until the user picks apps.
var selectionData: Data?
@@ -42,6 +45,7 @@ final class BlockingRule {
kind: RuleKind = .schedule,
isEnabled: Bool = true,
hardMode: Bool = false,
blockAdultContent: Bool = false,
selectionMode: SelectionMode = .block,
selectionData: Data? = nil,
selectionCount: Int = 0,
@@ -58,6 +62,7 @@ final class BlockingRule {
self.kindRaw = kind.rawValue
self.isEnabled = isEnabled
self.hardMode = hardMode
self.blockAdultContent = blockAdultContent
self.selectionModeRaw = selectionMode.rawValue
self.selectionData = selectionData
self.selectionCount = selectionCount

View File

@@ -6,8 +6,9 @@
import Foundation
/// Value-type working copy of a rule used by the editors, so cancelling an
/// edit never touches the persisted model.
struct RuleDraft: Equatable {
/// edit never touches the persisted model. Hashable so it can drive
/// `navigationDestination(item:)`.
struct RuleDraft: Hashable {
var name: String
var kind: RuleKind
var days: Set<Weekday>
@@ -16,6 +17,7 @@ struct RuleDraft: Equatable {
var dailyLimitMinutes: Int
var maxOpens: Int
var hardMode: Bool
var blockAdultContent: Bool
var selectionMode: SelectionMode
var selectionData: Data?
var selectionCount: Int
@@ -31,6 +33,7 @@ struct RuleDraft: Equatable {
self.dailyLimitMinutes = 45
self.maxOpens = 5
self.hardMode = false
self.blockAdultContent = false
self.selectionMode = .block
self.selectionData = nil
self.selectionCount = 0
@@ -45,6 +48,7 @@ struct RuleDraft: Equatable {
self.dailyLimitMinutes = rule.dailyLimitMinutes
self.maxOpens = rule.maxOpens
self.hardMode = rule.hardMode
self.blockAdultContent = rule.blockAdultContent
self.selectionMode = rule.selectionMode
self.selectionData = rule.selectionData
self.selectionCount = rule.selectionCount
@@ -67,6 +71,7 @@ struct RuleDraft: Equatable {
rule.dailyLimitMinutes = dailyLimitMinutes
rule.maxOpens = maxOpens
rule.hardMode = hardMode
rule.blockAdultContent = blockAdultContent
rule.selectionMode = selectionMode
rule.selectionData = selectionData
rule.selectionCount = selectionCount

View File

@@ -34,7 +34,10 @@ final class RuleEnforcer {
else { continue }
active.insert(rule.id)
shields.applyShield(
ruleID: rule.id, selectionData: rule.selectionData, mode: rule.selectionMode
ruleID: rule.id,
selectionData: rule.selectionData,
mode: rule.selectionMode,
blockAdultContent: rule.blockAdultContent
)
}
shields.clearShields(except: active)

View File

@@ -10,7 +10,9 @@ import ManagedSettings
/// Applies and clears app shields for rules. One implementation talks to
/// ManagedSettings; the mock records calls for tests.
protocol ShieldApplying: AnyObject {
func applyShield(ruleID: UUID, selectionData: Data?, mode: SelectionMode)
func applyShield(
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
)
/// Clears every shield except those for the given rule IDs. Covers rules
/// that were deleted or expired while the app was not running.
func clearShields(except activeRuleIDs: Set<UUID>)
@@ -26,7 +28,9 @@ final class ManagedSettingsShieldController: ShieldApplying {
self.defaults = defaults
}
func applyShield(ruleID: UUID, selectionData: Data?, mode: SelectionMode) {
func applyShield(
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
) {
let store = store(for: ruleID)
let selection = AppSelectionCodec.decode(selectionData)
switch mode {
@@ -41,6 +45,8 @@ final class ManagedSettingsShieldController: ShieldApplying {
store.shield.applicationCategories = .all(except: selection.applicationTokens)
store.shield.webDomainCategories = .all(except: selection.webDomainTokens)
}
// Screen Time's "Limit Adult Websites" filter for the rule's lifetime.
store.webContent.blockedByFilter = blockAdultContent ? .auto() : nil
track(ruleID: ruleID)
}
@@ -75,15 +81,22 @@ final class ManagedSettingsShieldController: ShieldApplying {
final class MockShieldController: ShieldApplying {
private(set) var shieldedRuleIDs: Set<UUID> = []
private(set) var appliedModes: [UUID: SelectionMode] = [:]
private(set) var appliedAdultContentFlags: [UUID: Bool] = [:]
func applyShield(ruleID: UUID, selectionData: Data?, mode: SelectionMode) {
func applyShield(
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
) {
shieldedRuleIDs.insert(ruleID)
appliedModes[ruleID] = mode
appliedAdultContentFlags[ruleID] = blockAdultContent
}
func clearShields(except activeRuleIDs: Set<UUID>) {
shieldedRuleIDs.formIntersection(activeRuleIDs)
appliedModes = appliedModes.filter { activeRuleIDs.contains($0.key) }
appliedAdultContentFlags = appliedAdultContentFlags.filter {
activeRuleIDs.contains($0.key)
}
}
}

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)

View File

@@ -110,4 +110,17 @@ struct RuleEnforcerTests {
#expect(shields.appliedModes[rule.id] == .allowOnly)
}
@Test("The adult-content flag is forwarded to the shield layer")
func forwardsAdultContentFlag() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let filtered = BlockingRule(name: "Clean Mode", blockAdultContent: true)
let unfiltered = BlockingRule(name: "Plain")
enforcer.refresh(rules: [filtered, unfiltered], at: mondayDuringWork, calendar: utc)
#expect(shields.appliedAdultContentFlags[filtered.id] == true)
#expect(shields.appliedAdultContentFlags[unfiltered.id] == false)
}
}

View File

@@ -18,6 +18,7 @@ struct RuleModelTests {
#expect(rule.kind == .schedule)
#expect(rule.isEnabled)
#expect(!rule.hardMode)
#expect(!rule.blockAdultContent)
#expect(rule.selectionMode == .block)
#expect(rule.days == Weekday.weekdays)
#expect(rule.startMinutes == 9 * 60)
@@ -89,10 +90,12 @@ struct RuleDraftTests {
draft.startMinutes = 22 * 60
draft.endMinutes = 6 * 60
draft.hardMode = true
draft.blockAdultContent = true
draft.selectionMode = .allowOnly
draft.selectionCount = 3
let rule = draft.makeRule()
#expect(rule.blockAdultContent)
#expect(RuleDraft(rule: rule) == draft)
}

View File

@@ -85,6 +85,41 @@ final class RuleCreationUITests: XCTestCase {
app.buttons["ruleCard-Time Keeper"].waitToAppear()
}
func testAdultContentToggleFlowsToDetail() throws {
let app = XCUIApplication.launchSevered()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap()
app.switches["adultContentToggle"].waitToAppear().tap()
app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0)
app.buttons["ruleCard-In the Zone"].waitToAppear().tap()
let row = app.element("detailRow-Adult websites").waitToAppear()
XCTAssertTrue(row.label.contains("Blocked"), "Got: \(row.label)")
}
func testAdultContentDefaultsToAllowed() throws {
let app = XCUIApplication.launchSevered(seedScenario: "standard")
app.buttons["ruleCard-Work Time"].waitToAppear().tap()
let row = app.element("detailRow-Adult websites").waitToAppear()
XCTAssertTrue(row.label.contains("Allowed"), "Got: \(row.label)")
}
func testEditorSupportsNativeSwipeBack() throws {
let app = XCUIApplication.launchSevered()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap()
app.staticTexts["ruleEditorTitle"].waitToAppear()
// Native push navigation supports the edge-swipe back gesture.
let edge = app.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5))
let middle = app.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5))
edge.press(forDuration: 0.05, thenDragTo: middle)
app.buttons["ruleKind-schedule"].waitToAppear()
XCTAssertTrue(app.staticTexts["New Rule"].exists)
}
func testNewRuleSheetShowsTypesAndPresets() throws {
let app = XCUIApplication.launchSevered()
app.buttons["newRuleButton"].waitToAppear().tap()

View File

@@ -126,6 +126,11 @@ Presented from "+ New". Full-height sheet, scrollable.
and a circular "+" button bottom-right. Tapping anywhere opens the
Schedule editor pre-filled with the preset's name/times/days.
> **Navigation (Severed):** picking a rule type or preset **pushes** the
> editor inside the sheet via native SwiftUI navigation (`NavigationStack` +
> `navigationDestination(item:)`), so the system push animation and
> edge-swipe-back work; the editor keeps its custom header chrome.
### 3.4 Rule Editor — Schedule type
Sheet with: back (top-left), centered **rule name** as title, ✎ pencil
@@ -145,7 +150,13 @@ Sections (each an inset rounded group with a small icon + caption header):
- Row: `Selected Apps``N Apps ` — pushes the App Picker.
4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing
toggle.
5. **CTA**
5. **Block Adult Content** *(Severed addition — not in the Opal video)*
subtitle "Filter adult websites while this rule is active"; trailing
toggle. Maps to Screen Time's web-content filter
(`ManagedSettingsStore.webContent.blockedByFilter = .auto(...)`), applied
and cleared together with the rule's shield. Surfaces in the rule detail
as an "Adult websites | Blocked/Allowed" row.
6. **CTA**
- Creating: full-width gradient pill "**Hold to Commit**" — a press-and-hold
interaction (deliberate friction) that fills, then saves and dismisses to
the Apps screen where the new card appears.
@@ -163,13 +174,14 @@ Same chrome (back / title / rename). Sections:
4. **🛡 Then block app** — row `Until` with stepper value `Tomorrow ⌃⌄`
(reset point — e.g. tomorrow/next morning).
5. **Hard Mode** toggle — same as Schedule.
6. **Hold to Commit**.
6. **Block Adult Content** toggle — same as Schedule.
7. **Hold to Commit**.
### 3.6 Rule Editor — Open Limit type
Not demoed beyond its card. Spec by analogy: "When I open [apps]" /
"More than `N opens ⌃⌄` (Daily)" / day picker / "Then block until …" /
Hard Mode / Hold to Commit.
Hard Mode / Block Adult Content / Hold to Commit.
### 3.7 App Picker (shared component — also used in onboarding & timer)
@@ -207,7 +219,9 @@ Full-height sheet:
1. **Activation** — a Schedule rule becomes active at `From` on an enabled
day and deactivates at `To` (windows crossing midnight, e.g. 22:0006:00,
must be supported — Deep Sleep preset does this).
2. **While active** — the rule's app selection is shielded; blocked apps also
2. **While active** — the rule's app selection is shielded (and, when the
rule's Block Adult Content toggle is on, Screen Time's adult-website
filter is engaged for the same span); blocked apps also
surface in the "Blocked Apps" row on the Apps screen; the card turns green
with a "Xh left" pill.
3. **Unblocking** — with Hard Mode off, the user may unblock mid-window
@@ -260,6 +274,7 @@ enum SelectionMode: String, Codable { case block, allowOnly }
var kind: RuleKind
var isEnabled: Bool
var hardMode: Bool
var blockAdultContent: Bool // webContent.blockedByFilter = .auto(...)
var selectionMode: SelectionMode
var selectionData: Data // encoded FamilyActivitySelection
var days: [Int] // 1...7, Calendar weekday numbers