- 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 <noreply@anthropic.com>
185 lines
6.6 KiB
Swift
185 lines
6.6 KiB
Swift
//
|
|
// RuleDetailSheet.swift
|
|
// Severed
|
|
//
|
|
|
|
import SwiftData
|
|
import SwiftUI
|
|
|
|
/// The card-style rule summary shown when tapping a rule: icon pair, live
|
|
/// status, the key facts, and "Edit Rule" — which morphs the sheet into the
|
|
/// editor, as in the reference app. A hard-locked rule shows a lock notice
|
|
/// instead of the edit button.
|
|
struct RuleDetailSheet: View {
|
|
let rule: BlockingRule
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.modelContext) private var modelContext
|
|
@State private var isEditing = false
|
|
@State private var pendingDeletion = false
|
|
|
|
var body: some View {
|
|
TimelineView(.periodic(from: .now, by: 30)) { timeline in
|
|
if isEditing {
|
|
RuleEditorView(
|
|
mode: .edit(isEnabled: rule.isEnabled),
|
|
draft: RuleDraft(rule: rule),
|
|
onBack: { isEditing = false },
|
|
onCommit: { draft in
|
|
draft.apply(to: rule)
|
|
isEditing = false
|
|
},
|
|
onToggleEnabled: {
|
|
rule.isEnabled.toggle()
|
|
rule.pausedUntil = nil
|
|
isEditing = false
|
|
},
|
|
onDelete: {
|
|
pendingDeletion = true
|
|
dismiss()
|
|
}
|
|
)
|
|
} else {
|
|
detail(now: timeline.date)
|
|
}
|
|
}
|
|
.background(Theme.background)
|
|
.onDisappear {
|
|
if pendingDeletion {
|
|
modelContext.delete(rule)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func detail(now: Date) -> some View {
|
|
let status = rule.status(at: now)
|
|
return VStack(spacing: 0) {
|
|
HStack {
|
|
CircleIconButton(systemImage: "xmark") { dismiss() }
|
|
.accessibilityIdentifier("closeDetailButton")
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 18)
|
|
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
RuleIconPair(kind: rule.kind, isActive: status.isActive)
|
|
VStack(spacing: 6) {
|
|
Text("\(rule.kind.displayName), \(status.label(relativeTo: now))")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(Theme.textSecondary)
|
|
.accessibilityIdentifier("detailStatusLabel")
|
|
Text(rule.name)
|
|
.font(.system(size: 26, weight: .bold))
|
|
.accessibilityIdentifier("detailRuleName")
|
|
}
|
|
detailRows
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 8)
|
|
}
|
|
|
|
footer(now: now)
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 16)
|
|
}
|
|
.foregroundStyle(.white)
|
|
}
|
|
|
|
private var detailRows: some View {
|
|
VStack(spacing: 0) {
|
|
switch rule.kind {
|
|
case .schedule:
|
|
row("During this time", rule.schedule.timeRangeLabel)
|
|
divider
|
|
row("On these days", rule.days.summary)
|
|
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)
|
|
divider
|
|
row("For this long", "\(rule.dailyLimitMinutes)m daily")
|
|
divider
|
|
row("On these days", rule.days.summary)
|
|
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)
|
|
divider
|
|
row("More than", "\(rule.maxOpens) opens daily")
|
|
divider
|
|
row("On these days", rule.days.summary)
|
|
divider
|
|
row("Then block until", "Tomorrow")
|
|
divider
|
|
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
|
|
divider
|
|
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
|
}
|
|
}
|
|
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
|
}
|
|
|
|
private var appCountLabel: String {
|
|
rule.selectionCount == 1 ? "1 App" : "\(rule.selectionCount) Apps"
|
|
}
|
|
|
|
private func row(_ label: String, _ value: String) -> some View {
|
|
HStack {
|
|
Text(label)
|
|
.font(.system(size: 15, weight: .medium))
|
|
Spacer()
|
|
Text(value)
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(Theme.textSecondary)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityIdentifier("detailRow-\(label)")
|
|
}
|
|
|
|
private var divider: some View {
|
|
Divider().background(.white.opacity(0.06)).padding(.leading, 16)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func footer(now: Date) -> some View {
|
|
if RulePolicy.canEdit(rule, at: now) {
|
|
Button {
|
|
isEditing = true
|
|
} label: {
|
|
Label("Edit Rule", systemImage: "pencil")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(.black)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 54)
|
|
.background(.white, in: RoundedRectangle(cornerRadius: 27))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityIdentifier("editRuleButton")
|
|
} else {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "lock.fill")
|
|
Text("Hard Mode is on — this rule is locked until the block ends.")
|
|
.font(.system(size: 14, weight: .medium))
|
|
}
|
|
.foregroundStyle(Theme.textSecondary)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 16)
|
|
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityIdentifier("hardModeLockedNotice")
|
|
}
|
|
}
|
|
}
|