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>
90 lines
3.3 KiB
Swift
90 lines
3.3 KiB
Swift
//
|
||
// 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))"
|
||
}
|
||
}
|