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>
91 lines
2.9 KiB
Swift
91 lines
2.9 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|