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.
This commit is contained in:
65
Severed/Logic/RulePolicy.swift
Normal file
65
Severed/Logic/RulePolicy.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// RulePolicy.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Gates every mutation of a rule. This is where Hard Mode is enforced:
|
||||
/// while a hard-mode rule is actively blocking, nothing about it can be
|
||||
/// weakened until the window ends.
|
||||
enum RulePolicy {
|
||||
/// True while the rule is actively blocking with Hard Mode on.
|
||||
static func isHardLocked(
|
||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
rule.hardMode && rule.status(at: now, calendar: calendar).isActive
|
||||
}
|
||||
|
||||
static func canEdit(
|
||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
!isHardLocked(rule, at: now, calendar: calendar)
|
||||
}
|
||||
|
||||
static func canDisable(
|
||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
!isHardLocked(rule, at: now, calendar: calendar)
|
||||
}
|
||||
|
||||
static func canDelete(
|
||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
!isHardLocked(rule, at: now, calendar: calendar)
|
||||
}
|
||||
|
||||
/// Whether the user may lift the current block early ("Unblock").
|
||||
/// Requires an active window and Hard Mode off.
|
||||
static func canUnblock(
|
||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
rule.status(at: now, calendar: calendar).isActive && !rule.hardMode
|
||||
}
|
||||
|
||||
/// Hard Mode can always be turned on, but never off while the rule is
|
||||
/// actively blocking — that is the whole point of a hard block.
|
||||
static func canTurnOffHardMode(
|
||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
!isHardLocked(rule, at: now, calendar: calendar)
|
||||
}
|
||||
|
||||
/// Pauses the rule's current window. Returns false (and changes nothing)
|
||||
/// when unblocking is not allowed.
|
||||
@discardableResult
|
||||
static func unblock(
|
||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
guard canUnblock(rule, at: now, calendar: calendar),
|
||||
let window = rule.schedule.activeWindow(containing: now, calendar: calendar)
|
||||
else { return false }
|
||||
rule.pausedUntil = window.end
|
||||
return true
|
||||
}
|
||||
}
|
||||
89
Severed/Logic/RuleSchedule.swift
Normal file
89
Severed/Logic/RuleSchedule.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// RuleSchedule.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The recurring time window of a rule, independent of any persistence.
|
||||
///
|
||||
/// A window whose end is at or before its start crosses midnight: 22:00 → 06:00
|
||||
/// starts on an enabled day and ends the following morning. `start == end`
|
||||
/// means a full 24-hour window.
|
||||
struct RuleSchedule: Hashable, Sendable {
|
||||
var startMinutes: Int
|
||||
var endMinutes: Int
|
||||
var days: Set<Weekday>
|
||||
|
||||
var crossesMidnight: Bool { endMinutes <= startMinutes }
|
||||
|
||||
var durationMinutes: Int {
|
||||
crossesMidnight
|
||||
? RuleSchedule.minutesPerDay - startMinutes + endMinutes
|
||||
: endMinutes - startMinutes
|
||||
}
|
||||
|
||||
/// The window containing `date`, if the schedule is active at that moment.
|
||||
///
|
||||
/// Checks today's window and, for midnight-crossing schedules, the window
|
||||
/// that started yesterday. The day a window *starts* on is the day that
|
||||
/// must be enabled.
|
||||
func activeWindow(containing date: Date, calendar: Calendar = .current) -> DateInterval? {
|
||||
for dayOffset in [0, -1] {
|
||||
guard
|
||||
let day = calendar.date(byAdding: .day, value: dayOffset, to: date),
|
||||
let window = window(onDayContaining: day, calendar: calendar),
|
||||
window.start <= date, date < window.end
|
||||
else { continue }
|
||||
return window
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isActive(at date: Date, calendar: Calendar = .current) -> Bool {
|
||||
activeWindow(containing: date, calendar: calendar) != nil
|
||||
}
|
||||
|
||||
/// The next moment the schedule will begin a window strictly after `date`.
|
||||
func nextStart(after date: Date, calendar: Calendar = .current) -> Date? {
|
||||
guard !days.isEmpty else { return nil }
|
||||
for dayOffset in 0...7 {
|
||||
guard
|
||||
let day = calendar.date(byAdding: .day, value: dayOffset, to: date),
|
||||
let window = window(onDayContaining: day, calendar: calendar),
|
||||
window.start > date
|
||||
else { continue }
|
||||
return window.start
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The window starting on the given day, or nil when that weekday is not enabled.
|
||||
private func window(onDayContaining day: Date, calendar: Calendar) -> DateInterval? {
|
||||
let dayStart = calendar.startOfDay(for: day)
|
||||
guard
|
||||
let weekday = Weekday(rawValue: calendar.component(.weekday, from: dayStart)),
|
||||
days.contains(weekday),
|
||||
let start = calendar.date(byAdding: .minute, value: startMinutes, to: dayStart),
|
||||
let end = calendar.date(
|
||||
byAdding: .minute, value: startMinutes + durationMinutes, to: dayStart
|
||||
)
|
||||
else { return nil }
|
||||
return DateInterval(start: start, end: end)
|
||||
}
|
||||
|
||||
private static let minutesPerDay = 24 * 60
|
||||
}
|
||||
|
||||
extension RuleSchedule {
|
||||
/// "09:00" style label for a minutes-from-midnight value, matching the reference UI.
|
||||
static func timeLabel(forMinutes minutes: Int) -> String {
|
||||
let clamped = ((minutes % (24 * 60)) + 24 * 60) % (24 * 60)
|
||||
return String(format: "%02d:%02d", clamped / 60, clamped % 60)
|
||||
}
|
||||
|
||||
/// "09:00 – 17:00" range label used by rule details and preset cards.
|
||||
var timeRangeLabel: String {
|
||||
"\(Self.timeLabel(forMinutes: startMinutes)) – \(Self.timeLabel(forMinutes: endMinutes))"
|
||||
}
|
||||
}
|
||||
73
Severed/Logic/RuleStatus.swift
Normal file
73
Severed/Logic/RuleStatus.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user