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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user