Files
OpenAppLock/Severed/Views/Rules/NewRuleSheet.swift
Brendan Chen 3aac2004d2 feat: clone Opal's rules feature with hard block enforcement
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.
2026-06-12 12:35:52 -04:00

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