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:
203
Severed/Views/Apps/AppsHomeView.swift
Normal file
203
Severed/Views/Apps/AppsHomeView.swift
Normal file
@@ -0,0 +1,203 @@
|
||||
//
|
||||
// AppsHomeView.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
/// The app's home screen, modeled on the reference "Apps" tab: what's blocked
|
||||
/// right now, then the Rules carousel with "+ New".
|
||||
struct AppsHomeView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(RuleEnforcer.self) private var enforcer
|
||||
@Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule]
|
||||
|
||||
@State private var detailRule: BlockingRule?
|
||||
@State private var showingNewRule = false
|
||||
@State private var unblockCandidate: BlockingRule?
|
||||
@State private var hardModeBlockedAttempt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
TimelineView(.periodic(from: .now, by: 30)) { timeline in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
blockedAppsSection(now: timeline.date)
|
||||
rulesSection(now: timeline.date)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.background(Theme.background)
|
||||
.navigationTitle("Apps")
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
}
|
||||
.sheet(item: $detailRule) { rule in
|
||||
RuleDetailSheet(rule: rule)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $showingNewRule) {
|
||||
NewRuleSheet()
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Unblock \(unblockCandidate?.name ?? "")?",
|
||||
isPresented: Binding(
|
||||
get: { unblockCandidate != nil },
|
||||
set: { if !$0 { unblockCandidate = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Unblock", role: .destructive) {
|
||||
if let rule = unblockCandidate {
|
||||
RulePolicy.unblock(rule)
|
||||
refreshEnforcement()
|
||||
}
|
||||
unblockCandidate = nil
|
||||
}
|
||||
} message: {
|
||||
Text("Blocking resumes with the rule's next window.")
|
||||
}
|
||||
.alert("Hard Mode is on", isPresented: $hardModeBlockedAttempt) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This block can't be lifted until it ends.")
|
||||
}
|
||||
.task {
|
||||
await enforcementLoop()
|
||||
}
|
||||
.onChange(of: ruleChangeToken) {
|
||||
refreshEnforcement()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Blocked Apps
|
||||
|
||||
@ViewBuilder
|
||||
private func blockedAppsSection(now: Date) -> some View {
|
||||
let blocking = rules.filter { $0.status(at: now).isActive }
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Blocked Apps")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
if blocking.isEmpty {
|
||||
Text("Nothing is blocked right now.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.accessibilityIdentifier("nothingBlockedLabel")
|
||||
} else {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(blocking) { rule in
|
||||
blockedTile(for: rule, now: now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func blockedTile(for rule: BlockingRule, now: Date) -> some View {
|
||||
Button {
|
||||
if RulePolicy.canUnblock(rule, at: now) {
|
||||
unblockCandidate = rule
|
||||
} else {
|
||||
hardModeBlockedAttempt = true
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: rule.hardMode ? "lock.fill" : "lock.open.fill")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.frame(width: 58, height: 58)
|
||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.strokeBorder(Theme.accent.opacity(0.6), lineWidth: 1)
|
||||
)
|
||||
Text("Unblock")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("blockedTile-\(rule.name)")
|
||||
}
|
||||
|
||||
// MARK: - Rules
|
||||
|
||||
private func rulesSection(now: Date) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Text("Rules")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
Spacer()
|
||||
Button {
|
||||
showingNewRule = true
|
||||
} label: {
|
||||
Label("New", systemImage: "plus")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
}
|
||||
.accessibilityIdentifier("newRuleButton")
|
||||
}
|
||||
if rules.isEmpty {
|
||||
emptyRulesCard
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 14) {
|
||||
ForEach(rules) { rule in
|
||||
Button {
|
||||
detailRule = rule
|
||||
} label: {
|
||||
RuleCardView(rule: rule, now: now)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("ruleCard-\(rule.name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyRulesCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("No rules yet")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
Text("Create a rule to block distracting apps on a schedule.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 22))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("emptyRulesCard")
|
||||
}
|
||||
|
||||
// MARK: - Enforcement
|
||||
|
||||
/// Changes whenever any rule's blocking-relevant state changes.
|
||||
private var ruleChangeToken: String {
|
||||
rules.map {
|
||||
"\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.startMinutes)|\($0.endMinutes)|"
|
||||
+ "\($0.dayNumbers)|\($0.pausedUntil?.timeIntervalSince1970 ?? 0)"
|
||||
}
|
||||
.joined(separator: ",")
|
||||
}
|
||||
|
||||
private func refreshEnforcement() {
|
||||
enforcer.refresh(rules: rules)
|
||||
}
|
||||
|
||||
/// Keeps shields in sync while the app is open, so windows that begin or
|
||||
/// end while the user is looking at the screen take effect promptly.
|
||||
private func enforcementLoop() async {
|
||||
while !Task.isCancelled {
|
||||
let allRules = (try? modelContext.fetch(FetchDescriptor<BlockingRule>())) ?? []
|
||||
enforcer.refresh(rules: allRules)
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
}
|
||||
}
|
||||
}
|
||||
90
Severed/Views/Apps/RuleCardView.swift
Normal file
90
Severed/Views/Apps/RuleCardView.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// RuleCardView.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A rule tile in the home screen's Rules carousel: icon pair, live status,
|
||||
/// name, and the block summary. Active rules glow green.
|
||||
struct RuleCardView: View {
|
||||
let rule: BlockingRule
|
||||
let now: Date
|
||||
|
||||
private var status: RuleStatus { rule.status(at: now) }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
RuleIconPair(kind: rule.kind, isActive: status.isActive)
|
||||
statusView
|
||||
Text(rule.name)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 5) {
|
||||
Text(rule.selectionMode.displayName)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
Text(blockSummary)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 172, alignment: .leading)
|
||||
.background(cardBackground, in: RoundedRectangle(cornerRadius: 22))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22)
|
||||
.strokeBorder(
|
||||
status.isActive ? Theme.accent.opacity(0.35) : .white.opacity(0.08),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusView: some View {
|
||||
switch status {
|
||||
case .active:
|
||||
Text(status.label(relativeTo: now))
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Theme.accent, in: Capsule())
|
||||
.accessibilityIdentifier("ruleStatus-\(rule.name)")
|
||||
case .paused, .disabled, .dormant, .upcoming:
|
||||
Text(statusText)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.padding(.vertical, 5)
|
||||
.accessibilityIdentifier("ruleStatus-\(rule.name)")
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
switch rule.kind {
|
||||
case .schedule:
|
||||
status.label(relativeTo: now)
|
||||
case .timeLimit:
|
||||
rule.isEnabled ? "\(rule.dailyLimitMinutes)m / day" : "Disabled"
|
||||
case .openLimit:
|
||||
rule.isEnabled ? "\(rule.maxOpens) opens / day" : "Disabled"
|
||||
}
|
||||
}
|
||||
|
||||
private var blockSummary: String {
|
||||
rule.selectionCount == 1 ? "· 1 app" : "· \(rule.selectionCount) apps"
|
||||
}
|
||||
|
||||
private var cardBackground: some ShapeStyle {
|
||||
if status.isActive {
|
||||
return AnyShapeStyle(
|
||||
LinearGradient(
|
||||
colors: [Theme.activeCardTop, Theme.activeCardBottom],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
return AnyShapeStyle(Theme.surface)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user