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

@@ -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)
}
}
}