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:
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