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>
74 lines
2.6 KiB
Swift
74 lines
2.6 KiB
Swift
//
|
|
// RuleStatus.swift
|
|
// Severed
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// The live state of a rule at a moment in time. Derived, never stored.
|
|
enum RuleStatus: Equatable, Sendable {
|
|
case disabled
|
|
/// Enabled but no days selected, so it never fires.
|
|
case dormant
|
|
/// Currently blocking; ends at the associated date.
|
|
case active(until: Date)
|
|
/// The user unblocked the current window; blocking resumes at the next window.
|
|
case paused(until: Date)
|
|
case upcoming(startsAt: Date)
|
|
|
|
var isActive: Bool {
|
|
if case .active = self { return true }
|
|
return false
|
|
}
|
|
|
|
/// Short status label shown on rule cards and detail sheets:
|
|
/// "6h left", "Starts in 22h", "Paused", "Disabled".
|
|
func label(relativeTo now: Date) -> String {
|
|
switch self {
|
|
case .disabled: "Disabled"
|
|
case .dormant: "No days selected"
|
|
case .paused: "Paused"
|
|
case .active(let until): "\(Self.countdown(from: now, to: until)) left"
|
|
case .upcoming(let start): "Starts in \(Self.countdown(from: now, to: start))"
|
|
}
|
|
}
|
|
|
|
/// Compact countdown matching the reference app: minutes under an hour,
|
|
/// hours (rounded up) under two days, then days.
|
|
static func countdown(from now: Date, to target: Date) -> String {
|
|
let minutes = max(1, Int(ceil(target.timeIntervalSince(now) / 60)))
|
|
guard minutes >= 60 else { return "\(minutes)m" }
|
|
let hours = (minutes + 59) / 60
|
|
guard hours >= 48 else { return "\(hours)h" }
|
|
return "\(hours / 24)d"
|
|
}
|
|
}
|
|
|
|
extension BlockingRule {
|
|
/// Live status of this rule. Schedule rules derive it from their time window;
|
|
/// time/open-limit rules report upcoming based on their enabled days only
|
|
/// (their blocking is triggered by usage, not the clock).
|
|
func status(at now: Date = .now, calendar: Calendar = .current) -> RuleStatus {
|
|
guard isEnabled else { return .disabled }
|
|
guard !days.isEmpty else { return .dormant }
|
|
|
|
guard kind == .schedule else {
|
|
guard let next = schedule.nextStart(after: now, calendar: calendar) else {
|
|
return .dormant
|
|
}
|
|
return .upcoming(startsAt: next)
|
|
}
|
|
|
|
if let window = schedule.activeWindow(containing: now, calendar: calendar) {
|
|
if let pausedUntil, pausedUntil > now {
|
|
return .paused(until: min(pausedUntil, window.end))
|
|
}
|
|
return .active(until: window.end)
|
|
}
|
|
if let next = schedule.nextStart(after: now, calendar: calendar) {
|
|
return .upcoming(startsAt: next)
|
|
}
|
|
return .dormant
|
|
}
|
|
}
|