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.
95 lines
3.2 KiB
Swift
95 lines
3.2 KiB
Swift
//
|
|
// 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()
|
|
}
|
|
}
|