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.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-12 12:35:52 -04:00
parent 0c03e35ea9
commit e6c87baeba
45 changed files with 3749 additions and 206 deletions

View File

@@ -0,0 +1,94 @@
//
// AppSelectionSheet.swift
// Severed
//
import FamilyControls
import SwiftUI
/// App/category/website selection for a rule. Wraps Apple's
/// `FamilyActivityPicker` (the system-sanctioned way to choose apps without
/// seeing their identities) and adds the reference app's Block / Allow Only
/// mode switch.
struct AppSelectionSheet: View {
@Binding var draft: RuleDraft
@Environment(\.dismiss) private var dismiss
@State private var selection: FamilyActivitySelection
@State private var mode: SelectionMode
init(draft: Binding<RuleDraft>) {
self._draft = draft
self._selection = State(
initialValue: AppSelectionCodec.decode(draft.wrappedValue.selectionData)
)
self._mode = State(initialValue: draft.wrappedValue.selectionMode)
}
var body: some View {
VStack(spacing: 0) {
HStack {
CircleIconButton(systemImage: "chevron.left") { dismiss() }
.accessibilityIdentifier("selectionBackButton")
Spacer()
CircleIconButton(systemImage: "checkmark", tint: .black) { save() }
.background(Theme.accent, in: Circle())
.accessibilityIdentifier("confirmSelectionButton")
}
.overlay(
Text("Selected")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
)
.padding(.horizontal, 20)
.padding(.top, 18)
Picker("Mode", selection: $mode) {
ForEach(SelectionMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 20)
.padding(.top, 16)
.accessibilityIdentifier("selectionModePicker")
FamilyActivityPicker(selection: $selection)
.padding(.top, 4)
VStack(spacing: 10) {
Text(selectionSummary)
.font(.system(size: 14))
.foregroundStyle(Theme.textSecondary)
.accessibilityIdentifier("selectionCountLabel")
Button {
save()
} label: {
Text("Save")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.black)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(.white, in: RoundedRectangle(cornerRadius: 27))
}
.buttonStyle(.plain)
.accessibilityIdentifier("saveSelectionButton")
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
.background(Theme.background)
}
private var selectionSummary: String {
let count = AppSelectionCodec.count(of: selection)
return count == 1 ? "1 App Selected" : "\(count) Apps Selected"
}
private func save() {
draft.selectionData = AppSelectionCodec.encode(selection)
draft.selectionCount = AppSelectionCodec.count(of: selection)
draft.selectionMode = mode
dismiss()
}
}