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