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
|
var isEnabled: Bool
|
||||||
/// Hard block: while the rule is active it cannot be disabled, edited, or unblocked.
|
/// Hard block: while the rule is active it cannot be disabled, edited, or unblocked.
|
||||||
var hardMode: Bool
|
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
|
var selectionModeRaw: String
|
||||||
/// Encoded `FamilyActivitySelection` (opaque tokens). Nil until the user picks apps.
|
/// Encoded `FamilyActivitySelection` (opaque tokens). Nil until the user picks apps.
|
||||||
var selectionData: Data?
|
var selectionData: Data?
|
||||||
@@ -42,6 +45,7 @@ final class BlockingRule {
|
|||||||
kind: RuleKind = .schedule,
|
kind: RuleKind = .schedule,
|
||||||
isEnabled: Bool = true,
|
isEnabled: Bool = true,
|
||||||
hardMode: Bool = false,
|
hardMode: Bool = false,
|
||||||
|
blockAdultContent: Bool = false,
|
||||||
selectionMode: SelectionMode = .block,
|
selectionMode: SelectionMode = .block,
|
||||||
selectionData: Data? = nil,
|
selectionData: Data? = nil,
|
||||||
selectionCount: Int = 0,
|
selectionCount: Int = 0,
|
||||||
@@ -58,6 +62,7 @@ final class BlockingRule {
|
|||||||
self.kindRaw = kind.rawValue
|
self.kindRaw = kind.rawValue
|
||||||
self.isEnabled = isEnabled
|
self.isEnabled = isEnabled
|
||||||
self.hardMode = hardMode
|
self.hardMode = hardMode
|
||||||
|
self.blockAdultContent = blockAdultContent
|
||||||
self.selectionModeRaw = selectionMode.rawValue
|
self.selectionModeRaw = selectionMode.rawValue
|
||||||
self.selectionData = selectionData
|
self.selectionData = selectionData
|
||||||
self.selectionCount = selectionCount
|
self.selectionCount = selectionCount
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Value-type working copy of a rule used by the editors, so cancelling an
|
/// Value-type working copy of a rule used by the editors, so cancelling an
|
||||||
/// edit never touches the persisted model.
|
/// edit never touches the persisted model. Hashable so it can drive
|
||||||
struct RuleDraft: Equatable {
|
/// `navigationDestination(item:)`.
|
||||||
|
struct RuleDraft: Hashable {
|
||||||
var name: String
|
var name: String
|
||||||
var kind: RuleKind
|
var kind: RuleKind
|
||||||
var days: Set<Weekday>
|
var days: Set<Weekday>
|
||||||
@@ -16,6 +17,7 @@ struct RuleDraft: Equatable {
|
|||||||
var dailyLimitMinutes: Int
|
var dailyLimitMinutes: Int
|
||||||
var maxOpens: Int
|
var maxOpens: Int
|
||||||
var hardMode: Bool
|
var hardMode: Bool
|
||||||
|
var blockAdultContent: Bool
|
||||||
var selectionMode: SelectionMode
|
var selectionMode: SelectionMode
|
||||||
var selectionData: Data?
|
var selectionData: Data?
|
||||||
var selectionCount: Int
|
var selectionCount: Int
|
||||||
@@ -31,6 +33,7 @@ struct RuleDraft: Equatable {
|
|||||||
self.dailyLimitMinutes = 45
|
self.dailyLimitMinutes = 45
|
||||||
self.maxOpens = 5
|
self.maxOpens = 5
|
||||||
self.hardMode = false
|
self.hardMode = false
|
||||||
|
self.blockAdultContent = false
|
||||||
self.selectionMode = .block
|
self.selectionMode = .block
|
||||||
self.selectionData = nil
|
self.selectionData = nil
|
||||||
self.selectionCount = 0
|
self.selectionCount = 0
|
||||||
@@ -45,6 +48,7 @@ struct RuleDraft: Equatable {
|
|||||||
self.dailyLimitMinutes = rule.dailyLimitMinutes
|
self.dailyLimitMinutes = rule.dailyLimitMinutes
|
||||||
self.maxOpens = rule.maxOpens
|
self.maxOpens = rule.maxOpens
|
||||||
self.hardMode = rule.hardMode
|
self.hardMode = rule.hardMode
|
||||||
|
self.blockAdultContent = rule.blockAdultContent
|
||||||
self.selectionMode = rule.selectionMode
|
self.selectionMode = rule.selectionMode
|
||||||
self.selectionData = rule.selectionData
|
self.selectionData = rule.selectionData
|
||||||
self.selectionCount = rule.selectionCount
|
self.selectionCount = rule.selectionCount
|
||||||
@@ -67,6 +71,7 @@ struct RuleDraft: Equatable {
|
|||||||
rule.dailyLimitMinutes = dailyLimitMinutes
|
rule.dailyLimitMinutes = dailyLimitMinutes
|
||||||
rule.maxOpens = maxOpens
|
rule.maxOpens = maxOpens
|
||||||
rule.hardMode = hardMode
|
rule.hardMode = hardMode
|
||||||
|
rule.blockAdultContent = blockAdultContent
|
||||||
rule.selectionMode = selectionMode
|
rule.selectionMode = selectionMode
|
||||||
rule.selectionData = selectionData
|
rule.selectionData = selectionData
|
||||||
rule.selectionCount = selectionCount
|
rule.selectionCount = selectionCount
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ final class RuleEnforcer {
|
|||||||
else { continue }
|
else { continue }
|
||||||
active.insert(rule.id)
|
active.insert(rule.id)
|
||||||
shields.applyShield(
|
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)
|
shields.clearShields(except: active)
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import ManagedSettings
|
|||||||
/// Applies and clears app shields for rules. One implementation talks to
|
/// Applies and clears app shields for rules. One implementation talks to
|
||||||
/// ManagedSettings; the mock records calls for tests.
|
/// ManagedSettings; the mock records calls for tests.
|
||||||
protocol ShieldApplying: AnyObject {
|
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
|
/// Clears every shield except those for the given rule IDs. Covers rules
|
||||||
/// that were deleted or expired while the app was not running.
|
/// that were deleted or expired while the app was not running.
|
||||||
func clearShields(except activeRuleIDs: Set<UUID>)
|
func clearShields(except activeRuleIDs: Set<UUID>)
|
||||||
@@ -26,7 +28,9 @@ final class ManagedSettingsShieldController: ShieldApplying {
|
|||||||
self.defaults = defaults
|
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 store = store(for: ruleID)
|
||||||
let selection = AppSelectionCodec.decode(selectionData)
|
let selection = AppSelectionCodec.decode(selectionData)
|
||||||
switch mode {
|
switch mode {
|
||||||
@@ -41,6 +45,8 @@ final class ManagedSettingsShieldController: ShieldApplying {
|
|||||||
store.shield.applicationCategories = .all(except: selection.applicationTokens)
|
store.shield.applicationCategories = .all(except: selection.applicationTokens)
|
||||||
store.shield.webDomainCategories = .all(except: selection.webDomainTokens)
|
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)
|
track(ruleID: ruleID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,15 +81,22 @@ final class ManagedSettingsShieldController: ShieldApplying {
|
|||||||
final class MockShieldController: ShieldApplying {
|
final class MockShieldController: ShieldApplying {
|
||||||
private(set) var shieldedRuleIDs: Set<UUID> = []
|
private(set) var shieldedRuleIDs: Set<UUID> = []
|
||||||
private(set) var appliedModes: [UUID: SelectionMode] = [:]
|
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)
|
shieldedRuleIDs.insert(ruleID)
|
||||||
appliedModes[ruleID] = mode
|
appliedModes[ruleID] = mode
|
||||||
|
appliedAdultContentFlags[ruleID] = blockAdultContent
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearShields(except activeRuleIDs: Set<UUID>) {
|
func clearShields(except activeRuleIDs: Set<UUID>) {
|
||||||
shieldedRuleIDs.formIntersection(activeRuleIDs)
|
shieldedRuleIDs.formIntersection(activeRuleIDs)
|
||||||
appliedModes = appliedModes.filter { activeRuleIDs.contains($0.key) }
|
appliedModes = appliedModes.filter { activeRuleIDs.contains($0.key) }
|
||||||
|
appliedAdultContentFlags = appliedAdultContentFlags.filter {
|
||||||
|
activeRuleIDs.contains($0.key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,19 +15,19 @@ struct NewRuleSheet: View {
|
|||||||
@State private var pendingDraft: RuleDraft?
|
@State private var pendingDraft: RuleDraft?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
NavigationStack {
|
||||||
if let pendingDraft {
|
chooser
|
||||||
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
|
.navigationDestination(item: $pendingDraft) { draft in
|
||||||
RuleEditorView(
|
RuleEditorView(
|
||||||
mode: .create,
|
mode: .create,
|
||||||
draft: pendingDraft,
|
draft: draft,
|
||||||
onBack: { self.pendingDraft = nil },
|
embedsInNavigationStack: true,
|
||||||
onCommit: { draft in
|
onCommit: { committed in
|
||||||
modelContext.insert(draft.makeRule())
|
modelContext.insert(committed.makeRule())
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
chooser
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Theme.background)
|
.background(Theme.background)
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ struct RuleDetailSheet: View {
|
|||||||
divider
|
divider
|
||||||
row(rule.selectionMode.displayName, appCountLabel)
|
row(rule.selectionMode.displayName, appCountLabel)
|
||||||
divider
|
divider
|
||||||
|
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
|
||||||
|
divider
|
||||||
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
||||||
case .timeLimit:
|
case .timeLimit:
|
||||||
row("When I use", appCountLabel)
|
row("When I use", appCountLabel)
|
||||||
@@ -107,6 +109,8 @@ struct RuleDetailSheet: View {
|
|||||||
divider
|
divider
|
||||||
row("Then block until", "Tomorrow")
|
row("Then block until", "Tomorrow")
|
||||||
divider
|
divider
|
||||||
|
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
|
||||||
|
divider
|
||||||
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
||||||
case .openLimit:
|
case .openLimit:
|
||||||
row("When I open", appCountLabel)
|
row("When I open", appCountLabel)
|
||||||
@@ -117,6 +121,8 @@ struct RuleDetailSheet: View {
|
|||||||
divider
|
divider
|
||||||
row("Then block until", "Tomorrow")
|
row("Then block until", "Tomorrow")
|
||||||
divider
|
divider
|
||||||
|
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
|
||||||
|
divider
|
||||||
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ struct RuleEditorView: View {
|
|||||||
|
|
||||||
let mode: Mode
|
let mode: Mode
|
||||||
@State var draft: RuleDraft
|
@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 onCommit: (RuleDraft) -> Void
|
||||||
var onToggleEnabled: (() -> Void)?
|
var onToggleEnabled: (() -> Void)?
|
||||||
var onDelete: (() -> Void)?
|
var onDelete: (() -> Void)?
|
||||||
@@ -26,8 +30,40 @@ struct RuleEditorView: View {
|
|||||||
@State private var renameText = ""
|
@State private var renameText = ""
|
||||||
|
|
||||||
var body: some View {
|
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) {
|
VStack(spacing: 0) {
|
||||||
|
if !embedsInNavigationStack {
|
||||||
header
|
header
|
||||||
|
}
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 18) {
|
VStack(spacing: 18) {
|
||||||
sections
|
sections
|
||||||
@@ -92,6 +128,7 @@ struct RuleEditorView: View {
|
|||||||
DayOfWeekPicker(days: $draft.days)
|
DayOfWeekPicker(days: $draft.days)
|
||||||
appsSection(header: "Apps are blocked")
|
appsSection(header: "Apps are blocked")
|
||||||
hardModeSection
|
hardModeSection
|
||||||
|
adultContentSection
|
||||||
case .timeLimit:
|
case .timeLimit:
|
||||||
appsSection(header: "When I use")
|
appsSection(header: "When I use")
|
||||||
budgetSection(
|
budgetSection(
|
||||||
@@ -104,6 +141,7 @@ struct RuleEditorView: View {
|
|||||||
DayOfWeekPicker(days: $draft.days)
|
DayOfWeekPicker(days: $draft.days)
|
||||||
blockUntilSection
|
blockUntilSection
|
||||||
hardModeSection
|
hardModeSection
|
||||||
|
adultContentSection
|
||||||
case .openLimit:
|
case .openLimit:
|
||||||
appsSection(header: "When I open")
|
appsSection(header: "When I open")
|
||||||
budgetSection(
|
budgetSection(
|
||||||
@@ -116,6 +154,7 @@ struct RuleEditorView: View {
|
|||||||
DayOfWeekPicker(days: $draft.days)
|
DayOfWeekPicker(days: $draft.days)
|
||||||
blockUntilSection
|
blockUntilSection
|
||||||
hardModeSection
|
hardModeSection
|
||||||
|
adultContentSection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +271,25 @@ struct RuleEditorView: View {
|
|||||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
.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 {
|
private func sectionHeader(systemImage: String, title: String) -> some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: systemImage)
|
Image(systemName: systemImage)
|
||||||
|
|||||||
@@ -110,4 +110,17 @@ struct RuleEnforcerTests {
|
|||||||
|
|
||||||
#expect(shields.appliedModes[rule.id] == .allowOnly)
|
#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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ struct RuleModelTests {
|
|||||||
#expect(rule.kind == .schedule)
|
#expect(rule.kind == .schedule)
|
||||||
#expect(rule.isEnabled)
|
#expect(rule.isEnabled)
|
||||||
#expect(!rule.hardMode)
|
#expect(!rule.hardMode)
|
||||||
|
#expect(!rule.blockAdultContent)
|
||||||
#expect(rule.selectionMode == .block)
|
#expect(rule.selectionMode == .block)
|
||||||
#expect(rule.days == Weekday.weekdays)
|
#expect(rule.days == Weekday.weekdays)
|
||||||
#expect(rule.startMinutes == 9 * 60)
|
#expect(rule.startMinutes == 9 * 60)
|
||||||
@@ -89,10 +90,12 @@ struct RuleDraftTests {
|
|||||||
draft.startMinutes = 22 * 60
|
draft.startMinutes = 22 * 60
|
||||||
draft.endMinutes = 6 * 60
|
draft.endMinutes = 6 * 60
|
||||||
draft.hardMode = true
|
draft.hardMode = true
|
||||||
|
draft.blockAdultContent = true
|
||||||
draft.selectionMode = .allowOnly
|
draft.selectionMode = .allowOnly
|
||||||
draft.selectionCount = 3
|
draft.selectionCount = 3
|
||||||
|
|
||||||
let rule = draft.makeRule()
|
let rule = draft.makeRule()
|
||||||
|
#expect(rule.blockAdultContent)
|
||||||
#expect(RuleDraft(rule: rule) == draft)
|
#expect(RuleDraft(rule: rule) == draft)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,41 @@ final class RuleCreationUITests: XCTestCase {
|
|||||||
app.buttons["ruleCard-Time Keeper"].waitToAppear()
|
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 {
|
func testNewRuleSheetShowsTypesAndPresets() throws {
|
||||||
let app = XCUIApplication.launchSevered()
|
let app = XCUIApplication.launchSevered()
|
||||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ Presented from "+ New". Full-height sheet, scrollable.
|
|||||||
and a circular "+" button bottom-right. Tapping anywhere opens the
|
and a circular "+" button bottom-right. Tapping anywhere opens the
|
||||||
Schedule editor pre-filled with the preset's name/times/days.
|
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
|
### 3.4 Rule Editor — Schedule type
|
||||||
|
|
||||||
Sheet with: ‹ back (top-left), centered **rule name** as title, ✎ pencil
|
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.
|
- Row: `Selected Apps` → `N Apps ›` — pushes the App Picker.
|
||||||
4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing
|
4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing
|
||||||
toggle.
|
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
|
- Creating: full-width gradient pill "**Hold to Commit**" — a press-and-hold
|
||||||
interaction (deliberate friction) that fills, then saves and dismisses to
|
interaction (deliberate friction) that fills, then saves and dismisses to
|
||||||
the Apps screen where the new card appears.
|
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 ⌃⌄`
|
4. **🛡 Then block app** — row `Until` with stepper value `Tomorrow ⌃⌄`
|
||||||
(reset point — e.g. tomorrow/next morning).
|
(reset point — e.g. tomorrow/next morning).
|
||||||
5. **Hard Mode** toggle — same as Schedule.
|
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
|
### 3.6 Rule Editor — Open Limit type
|
||||||
|
|
||||||
Not demoed beyond its card. Spec by analogy: "When I open [apps]" /
|
Not demoed beyond its card. Spec by analogy: "When I open [apps]" /
|
||||||
"More than `N opens ⌃⌄` (Daily)" / day picker / "Then block until …" /
|
"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)
|
### 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
|
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,
|
day and deactivates at `To` (windows crossing midnight, e.g. 22:00–06:00,
|
||||||
must be supported — Deep Sleep preset does this).
|
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
|
surface in the "Blocked Apps" row on the Apps screen; the card turns green
|
||||||
with a "Xh left" pill.
|
with a "Xh left" pill.
|
||||||
3. **Unblocking** — with Hard Mode off, the user may unblock mid-window
|
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 kind: RuleKind
|
||||||
var isEnabled: Bool
|
var isEnabled: Bool
|
||||||
var hardMode: Bool
|
var hardMode: Bool
|
||||||
|
var blockAdultContent: Bool // webContent.blockedByFilter = .auto(...)
|
||||||
var selectionMode: SelectionMode
|
var selectionMode: SelectionMode
|
||||||
var selectionData: Data // encoded FamilyActivitySelection
|
var selectionData: Data // encoded FamilyActivitySelection
|
||||||
var days: [Int] // 1...7, Calendar weekday numbers
|
var days: [Int] // 1...7, Calendar weekday numbers
|
||||||
|
|||||||
Reference in New Issue
Block a user