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.
298 lines
10 KiB
Swift
298 lines
10 KiB
Swift
//
|
|
// RuleEditorView.swift
|
|
// Severed
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
/// The rule editor used both for creation (Hold to Commit) and editing
|
|
/// (Done / Disable Rule / Delete Rule). Sections adapt to the rule kind,
|
|
/// mirroring the reference app's "In the Zone" and "Time Keeper" editors.
|
|
struct RuleEditorView: View {
|
|
enum Mode: Equatable {
|
|
case create
|
|
case edit(isEnabled: Bool)
|
|
}
|
|
|
|
let mode: Mode
|
|
@State var draft: RuleDraft
|
|
var onBack: () -> Void
|
|
var onCommit: (RuleDraft) -> Void
|
|
var onToggleEnabled: (() -> Void)?
|
|
var onDelete: (() -> Void)?
|
|
|
|
@State private var showingRename = false
|
|
@State private var showingAppPicker = false
|
|
@State private var renameText = ""
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
ScrollView {
|
|
VStack(spacing: 18) {
|
|
sections
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 10)
|
|
.padding(.bottom, 24)
|
|
}
|
|
footer
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 16)
|
|
}
|
|
.foregroundStyle(.white)
|
|
.background(Theme.background)
|
|
.alert("Rule Name", isPresented: $showingRename) {
|
|
TextField("Name", text: $renameText)
|
|
Button("OK") {
|
|
let trimmed = renameText.trimmingCharacters(in: .whitespaces)
|
|
if !trimmed.isEmpty {
|
|
draft.name = trimmed
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
}
|
|
.sheet(isPresented: $showingAppPicker) {
|
|
AppSelectionSheet(draft: $draft)
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private var header: some View {
|
|
HStack {
|
|
CircleIconButton(systemImage: "chevron.left", action: onBack)
|
|
.accessibilityIdentifier("editorBackButton")
|
|
Spacer()
|
|
CircleIconButton(systemImage: "pencil") {
|
|
renameText = draft.name
|
|
showingRename = true
|
|
}
|
|
.accessibilityIdentifier("renameButton")
|
|
}
|
|
.overlay(
|
|
Text(draft.name)
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.lineLimit(1)
|
|
.padding(.horizontal, 56)
|
|
.accessibilityIdentifier("ruleEditorTitle")
|
|
)
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 18)
|
|
}
|
|
|
|
// MARK: - Sections
|
|
|
|
@ViewBuilder
|
|
private var sections: some View {
|
|
switch draft.kind {
|
|
case .schedule:
|
|
timeWindowSection
|
|
DayOfWeekPicker(days: $draft.days)
|
|
appsSection(header: "Apps are blocked")
|
|
hardModeSection
|
|
case .timeLimit:
|
|
appsSection(header: "When I use")
|
|
budgetSection(
|
|
title: "For this long",
|
|
value: "\(draft.dailyLimitMinutes)m",
|
|
stepperID: "dailyLimitStepper",
|
|
onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) },
|
|
onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) }
|
|
)
|
|
DayOfWeekPicker(days: $draft.days)
|
|
blockUntilSection
|
|
hardModeSection
|
|
case .openLimit:
|
|
appsSection(header: "When I open")
|
|
budgetSection(
|
|
title: "More than",
|
|
value: "\(draft.maxOpens) opens",
|
|
stepperID: "maxOpensStepper",
|
|
onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) },
|
|
onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) }
|
|
)
|
|
DayOfWeekPicker(days: $draft.days)
|
|
blockUntilSection
|
|
hardModeSection
|
|
}
|
|
}
|
|
|
|
private var timeWindowSection: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
sectionHeader(systemImage: "calendar", title: "During this time")
|
|
VStack(spacing: 0) {
|
|
timeRow(label: "From", minutes: $draft.startMinutes, identifier: "fromTimePicker")
|
|
Divider().background(.white.opacity(0.06)).padding(.leading, 16)
|
|
timeRow(label: "To", minutes: $draft.endMinutes, identifier: "toTimePicker")
|
|
}
|
|
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
|
}
|
|
}
|
|
|
|
private func timeRow(label: String, minutes: Binding<Int>, identifier: String) -> some View {
|
|
HStack {
|
|
Text(label)
|
|
.font(.system(size: 15, weight: .medium))
|
|
Spacer()
|
|
DatePicker("", selection: timeBinding(minutes), displayedComponents: .hourAndMinute)
|
|
.labelsHidden()
|
|
.colorScheme(.dark)
|
|
.accessibilityIdentifier(identifier)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
private func appsSection(header: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
sectionHeader(systemImage: "shield.fill", title: header)
|
|
Button {
|
|
showingAppPicker = true
|
|
} label: {
|
|
HStack {
|
|
Text("Selected Apps")
|
|
.font(.system(size: 15, weight: .medium))
|
|
Spacer()
|
|
Text(draft.selectionCount == 1 ? "1 App" : "\(draft.selectionCount) Apps")
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(Theme.textSecondary)
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(Theme.textTertiary)
|
|
}
|
|
.padding(16)
|
|
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityIdentifier("selectedAppsRow")
|
|
}
|
|
}
|
|
|
|
private func budgetSection(
|
|
title: String,
|
|
value: String,
|
|
stepperID: String,
|
|
onIncrement: @escaping () -> Void,
|
|
onDecrement: @escaping () -> Void
|
|
) -> some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(.system(size: 15, weight: .medium))
|
|
Text("Daily")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Theme.textTertiary)
|
|
}
|
|
Spacer()
|
|
Text(value)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.accessibilityIdentifier("\(stepperID)Value")
|
|
Stepper("", onIncrement: onIncrement, onDecrement: onDecrement)
|
|
.labelsHidden()
|
|
.accessibilityIdentifier(stepperID)
|
|
}
|
|
.padding(16)
|
|
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
|
}
|
|
|
|
private var blockUntilSection: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
sectionHeader(systemImage: "shield.fill", title: "Then block app")
|
|
HStack {
|
|
Text("Until")
|
|
.font(.system(size: 15, weight: .medium))
|
|
Spacer()
|
|
Text("Tomorrow")
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(Theme.textSecondary)
|
|
}
|
|
.padding(16)
|
|
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
|
}
|
|
}
|
|
|
|
private var hardModeSection: some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Hard Mode")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
Text("No unblocks allowed")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Theme.textTertiary)
|
|
}
|
|
Spacer()
|
|
Toggle("", isOn: $draft.hardMode)
|
|
.labelsHidden()
|
|
.tint(Theme.accent)
|
|
.accessibilityIdentifier("hardModeToggle")
|
|
}
|
|
.padding(16)
|
|
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
|
}
|
|
|
|
private func sectionHeader(systemImage: String, title: String) -> some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(Theme.textSecondary)
|
|
Text(title)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
}
|
|
.padding(.leading, 4)
|
|
}
|
|
|
|
// MARK: - Footer
|
|
|
|
@ViewBuilder
|
|
private var footer: some View {
|
|
switch mode {
|
|
case .create:
|
|
HoldToCommitButton {
|
|
onCommit(draft)
|
|
}
|
|
case .edit(let isEnabled):
|
|
VStack(spacing: 12) {
|
|
Button {
|
|
onCommit(draft)
|
|
} label: {
|
|
Label("Done", systemImage: "checkmark")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(.black)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 54)
|
|
.background(.white, in: RoundedRectangle(cornerRadius: 27))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityIdentifier("doneButton")
|
|
|
|
HStack(spacing: 24) {
|
|
Button(isEnabled ? "Disable Rule" : "Enable Rule") {
|
|
onToggleEnabled?()
|
|
}
|
|
.accessibilityIdentifier("toggleEnabledButton")
|
|
|
|
Button("Delete Rule") {
|
|
onDelete?()
|
|
}
|
|
.accessibilityIdentifier("deleteRuleButton")
|
|
}
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(Theme.destructive)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func timeBinding(_ minutes: Binding<Int>) -> Binding<Date> {
|
|
Binding {
|
|
let dayStart = Calendar.current.startOfDay(for: .now)
|
|
return Calendar.current.date(byAdding: .minute, value: minutes.wrappedValue, to: dayStart)
|
|
?? .now
|
|
} set: { newDate in
|
|
let components = Calendar.current.dateComponents([.hour, .minute], from: newDate)
|
|
minutes.wrappedValue = (components.hour ?? 0) * 60 + (components.minute ?? 0)
|
|
}
|
|
}
|
|
}
|