From c99538565c1117ba047e9215e3eef15021db95a1 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 12 Jun 2026 13:34:52 -0400 Subject: [PATCH] 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) Co-Authored-By: Claude --- Severed/Models/BlockingRule.swift | 5 ++ Severed/Models/RuleDraft.swift | 9 +++- Severed/Services/RuleEnforcer.swift | 5 +- Severed/Services/ShieldController.swift | 19 +++++-- Severed/Views/Rules/NewRuleSheet.swift | 28 +++++----- Severed/Views/Rules/RuleDetailSheet.swift | 6 +++ Severed/Views/Rules/RuleEditorView.swift | 62 ++++++++++++++++++++++- SeveredTests/RuleEnforcerTests.swift | 13 +++++ SeveredTests/RuleModelTests.swift | 3 ++ SeveredUITests/RuleCreationUITests.swift | 35 +++++++++++++ docs/RULES_FEATURE_SPEC.md | 23 +++++++-- 11 files changed, 182 insertions(+), 26 deletions(-) diff --git a/Severed/Models/BlockingRule.swift b/Severed/Models/BlockingRule.swift index 209a28f..28b67e1 100644 --- a/Severed/Models/BlockingRule.swift +++ b/Severed/Models/BlockingRule.swift @@ -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 diff --git a/Severed/Models/RuleDraft.swift b/Severed/Models/RuleDraft.swift index c1002dc..cee7f8e 100644 --- a/Severed/Models/RuleDraft.swift +++ b/Severed/Models/RuleDraft.swift @@ -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 @@ -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 diff --git a/Severed/Services/RuleEnforcer.swift b/Severed/Services/RuleEnforcer.swift index fbd6c99..7d745c6 100644 --- a/Severed/Services/RuleEnforcer.swift +++ b/Severed/Services/RuleEnforcer.swift @@ -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) diff --git a/Severed/Services/ShieldController.swift b/Severed/Services/ShieldController.swift index 104b575..77b8282 100644 --- a/Severed/Services/ShieldController.swift +++ b/Severed/Services/ShieldController.swift @@ -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) @@ -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 = [] 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) { shieldedRuleIDs.formIntersection(activeRuleIDs) appliedModes = appliedModes.filter { activeRuleIDs.contains($0.key) } + appliedAdultContentFlags = appliedAdultContentFlags.filter { + activeRuleIDs.contains($0.key) + } } } diff --git a/Severed/Views/Rules/NewRuleSheet.swift b/Severed/Views/Rules/NewRuleSheet.swift index b43f5d6..2ec7310 100644 --- a/Severed/Views/Rules/NewRuleSheet.swift +++ b/Severed/Views/Rules/NewRuleSheet.swift @@ -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) } diff --git a/Severed/Views/Rules/RuleDetailSheet.swift b/Severed/Views/Rules/RuleDetailSheet.swift index ae90fa6..ef380bf 100644 --- a/Severed/Views/Rules/RuleDetailSheet.swift +++ b/Severed/Views/Rules/RuleDetailSheet.swift @@ -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") } } diff --git a/Severed/Views/Rules/RuleEditorView.swift b/Severed/Views/Rules/RuleEditorView.swift index c86e462..d07f587 100644 --- a/Severed/Views/Rules/RuleEditorView.swift +++ b/Severed/Views/Rules/RuleEditorView.swift @@ -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) diff --git a/SeveredTests/RuleEnforcerTests.swift b/SeveredTests/RuleEnforcerTests.swift index 076b67f..d3bdf02 100644 --- a/SeveredTests/RuleEnforcerTests.swift +++ b/SeveredTests/RuleEnforcerTests.swift @@ -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) + } } diff --git a/SeveredTests/RuleModelTests.swift b/SeveredTests/RuleModelTests.swift index be01af0..60ac1fe 100644 --- a/SeveredTests/RuleModelTests.swift +++ b/SeveredTests/RuleModelTests.swift @@ -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) } diff --git a/SeveredUITests/RuleCreationUITests.swift b/SeveredUITests/RuleCreationUITests.swift index c473308..e57179e 100644 --- a/SeveredUITests/RuleCreationUITests.swift +++ b/SeveredUITests/RuleCreationUITests.swift @@ -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() diff --git a/docs/RULES_FEATURE_SPEC.md b/docs/RULES_FEATURE_SPEC.md index 202f72a..7090ccf 100644 --- a/docs/RULES_FEATURE_SPEC.md +++ b/docs/RULES_FEATURE_SPEC.md @@ -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:00–06: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