Rebuild the app around recurring screen-time blocking rules modeled on Opal's My Apps tab: - Onboarding with FamilyControls Screen Time authorization - Apps home with Blocked Apps tiles and live rule cards - New Rule sheet: Schedule/Time Limit/Open Limit types + preset gallery - Rule editors: time windows (incl. overnight), day picker, app selection via FamilyActivityPicker (Block/Allow Only), rename, Hold to Commit - Hard Mode: active hard rules cannot be edited, disabled, deleted, or unblocked until their window ends; soft rules pause until next window - Shield enforcement through per-rule ManagedSettingsStore + RuleEnforcer - 73 unit tests (Swift Testing) + 16 UI tests (XCUITest) with launch-arg harness for in-memory storage, mocked authorization, seeded scenarios - docs/RULES_FEATURE_SPEC.md: spec derived from the reference recording Known gap: background window transitions and time/open-limit thresholds need a DeviceActivityMonitor extension; shields currently sync while the app is running. Co-Authored-By: Claude <noreply@anthropic.com>
158 lines
5.3 KiB
Swift
158 lines
5.3 KiB
Swift
//
|
|
// NewRuleSheet.swift
|
|
// Severed
|
|
//
|
|
|
|
import SwiftData
|
|
import SwiftUI
|
|
|
|
/// "New Rule": the three rule-type cards up top, then the preset gallery.
|
|
/// Picking either one morphs the sheet into the editor; committing saves the
|
|
/// rule and closes the sheet.
|
|
struct NewRuleSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.modelContext) private var modelContext
|
|
@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
|
|
}
|
|
}
|
|
.background(Theme.background)
|
|
}
|
|
|
|
private var chooser: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
CircleIconButton(systemImage: "xmark") { dismiss() }
|
|
.accessibilityIdentifier("closeNewRuleButton")
|
|
Spacer()
|
|
}
|
|
.overlay(
|
|
Text("New Rule")
|
|
.font(.system(size: 18, weight: .semibold))
|
|
)
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 18)
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 26) {
|
|
HStack(spacing: 10) {
|
|
ForEach(RuleKind.allCases, id: \.self) { kind in
|
|
kindCard(kind)
|
|
}
|
|
}
|
|
ForEach(RulePresetSection.all) { section in
|
|
presetSection(section)
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 20)
|
|
.padding(.bottom, 30)
|
|
}
|
|
}
|
|
.foregroundStyle(.white)
|
|
}
|
|
|
|
private func kindCard(_ kind: RuleKind) -> some View {
|
|
Button {
|
|
pendingDraft = RuleDraft(kind: kind)
|
|
} label: {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: kind.symbolName)
|
|
.font(.system(size: 20, weight: .semibold))
|
|
.foregroundStyle(Theme.accent)
|
|
.frame(height: 26)
|
|
Text(kind.displayName)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
Text(kind.exampleText)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Theme.textTertiary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 18)
|
|
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityIdentifier("ruleKind-\(kind.rawValue)")
|
|
}
|
|
|
|
private func presetSection(_ section: RulePresetSection) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(section.title)
|
|
.font(.system(size: 18, weight: .bold))
|
|
Text(section.subtitle)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(Theme.textSecondary)
|
|
LazyVGrid(
|
|
columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible())],
|
|
spacing: 12
|
|
) {
|
|
ForEach(section.presets) { preset in
|
|
presetCard(preset)
|
|
}
|
|
}
|
|
.padding(.top, 12)
|
|
}
|
|
}
|
|
|
|
private func presetCard(_ preset: RulePreset) -> some View {
|
|
Button {
|
|
pendingDraft = RuleDraft(preset: preset)
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
RuleIconPair(kind: .schedule)
|
|
Spacer()
|
|
}
|
|
Spacer()
|
|
Image(systemName: preset.symbolName)
|
|
.font(.system(size: 22, weight: .semibold))
|
|
.foregroundStyle(.white.opacity(0.8))
|
|
.padding(.bottom, 8)
|
|
Text(preset.schedule.timeRangeLabel)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Theme.textSecondary)
|
|
Text(preset.name)
|
|
.font(.system(size: 16, weight: .semibold))
|
|
HStack {
|
|
Text("Block")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Theme.textSecondary)
|
|
Spacer()
|
|
Image(systemName: "plus.circle.fill")
|
|
.font(.system(size: 22))
|
|
.foregroundStyle(.white.opacity(0.85))
|
|
}
|
|
}
|
|
.padding(14)
|
|
.frame(height: 190)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [preset.gradientTop, preset.gradientBottom],
|
|
startPoint: .top, endPoint: .bottom
|
|
),
|
|
in: RoundedRectangle(cornerRadius: 20)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityIdentifier("preset-\(preset.id)")
|
|
}
|
|
}
|