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:
91
Severed/Models/BlockingRule.swift
Normal file
91
Severed/Models/BlockingRule.swift
Normal file
@@ -0,0 +1,91 @@
|
||||
//
|
||||
// BlockingRule.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// A recurring screen-time blocking rule.
|
||||
///
|
||||
/// Times are stored as minutes from midnight so the schedule repeats cleanly and
|
||||
/// is independent of time zones at creation. A window whose end is at or before
|
||||
/// its start (e.g. 22:00 → 06:00) crosses midnight into the following day.
|
||||
@Model
|
||||
final class BlockingRule {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var name: String
|
||||
var kindRaw: String
|
||||
var isEnabled: Bool
|
||||
/// Hard block: while the rule is active it cannot be disabled, edited, or unblocked.
|
||||
var hardMode: Bool
|
||||
var selectionModeRaw: String
|
||||
/// Encoded `FamilyActivitySelection` (opaque tokens). Nil until the user picks apps.
|
||||
var selectionData: Data?
|
||||
/// Denormalized count of selected apps/categories/domains for display.
|
||||
var selectionCount: Int
|
||||
var dayNumbers: [Int]
|
||||
var startMinutes: Int
|
||||
var endMinutes: Int
|
||||
/// Daily usage budget for `.timeLimit` rules.
|
||||
var dailyLimitMinutes: Int
|
||||
/// Daily open budget for `.openLimit` rules.
|
||||
var maxOpens: Int
|
||||
/// When set, the rule's current window is suspended (user tapped Unblock).
|
||||
/// Cleared automatically once the date passes; never set while Hard Mode is active.
|
||||
var pausedUntil: Date?
|
||||
var createdAt: Date
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
kind: RuleKind = .schedule,
|
||||
isEnabled: Bool = true,
|
||||
hardMode: Bool = false,
|
||||
selectionMode: SelectionMode = .block,
|
||||
selectionData: Data? = nil,
|
||||
selectionCount: Int = 0,
|
||||
days: Set<Weekday> = Weekday.weekdays,
|
||||
startMinutes: Int = 9 * 60,
|
||||
endMinutes: Int = 17 * 60,
|
||||
dailyLimitMinutes: Int = 45,
|
||||
maxOpens: Int = 5,
|
||||
pausedUntil: Date? = nil,
|
||||
createdAt: Date = .now
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.kindRaw = kind.rawValue
|
||||
self.isEnabled = isEnabled
|
||||
self.hardMode = hardMode
|
||||
self.selectionModeRaw = selectionMode.rawValue
|
||||
self.selectionData = selectionData
|
||||
self.selectionCount = selectionCount
|
||||
self.dayNumbers = days.map(\.rawValue).sorted()
|
||||
self.startMinutes = startMinutes
|
||||
self.endMinutes = endMinutes
|
||||
self.dailyLimitMinutes = dailyLimitMinutes
|
||||
self.maxOpens = maxOpens
|
||||
self.pausedUntil = pausedUntil
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
var kind: RuleKind {
|
||||
get { RuleKind(rawValue: kindRaw) ?? .schedule }
|
||||
set { kindRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var selectionMode: SelectionMode {
|
||||
get { SelectionMode(rawValue: selectionModeRaw) ?? .block }
|
||||
set { selectionModeRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var days: Set<Weekday> {
|
||||
get { Set(dayNumbers.compactMap(Weekday.init(rawValue:))) }
|
||||
set { dayNumbers = newValue.map(\.rawValue).sorted() }
|
||||
}
|
||||
|
||||
var schedule: RuleSchedule {
|
||||
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
|
||||
}
|
||||
}
|
||||
84
Severed/Models/RuleDraft.swift
Normal file
84
Severed/Models/RuleDraft.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// RuleDraft.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Value-type working copy of a rule used by the editors, so cancelling an
|
||||
/// edit never touches the persisted model.
|
||||
struct RuleDraft: Equatable {
|
||||
var name: String
|
||||
var kind: RuleKind
|
||||
var days: Set<Weekday>
|
||||
var startMinutes: Int
|
||||
var endMinutes: Int
|
||||
var dailyLimitMinutes: Int
|
||||
var maxOpens: Int
|
||||
var hardMode: Bool
|
||||
var selectionMode: SelectionMode
|
||||
var selectionData: Data?
|
||||
var selectionCount: Int
|
||||
|
||||
/// A fresh draft for a new rule of the given kind, using the reference
|
||||
/// app's defaults (9–5 weekdays schedule, 45m/day, 5 opens/day).
|
||||
init(kind: RuleKind) {
|
||||
self.name = kind.defaultRuleName
|
||||
self.kind = kind
|
||||
self.days = Weekday.weekdays
|
||||
self.startMinutes = 9 * 60
|
||||
self.endMinutes = 17 * 60
|
||||
self.dailyLimitMinutes = 45
|
||||
self.maxOpens = 5
|
||||
self.hardMode = false
|
||||
self.selectionMode = .block
|
||||
self.selectionData = nil
|
||||
self.selectionCount = 0
|
||||
}
|
||||
|
||||
init(rule: BlockingRule) {
|
||||
self.name = rule.name
|
||||
self.kind = rule.kind
|
||||
self.days = rule.days
|
||||
self.startMinutes = rule.startMinutes
|
||||
self.endMinutes = rule.endMinutes
|
||||
self.dailyLimitMinutes = rule.dailyLimitMinutes
|
||||
self.maxOpens = rule.maxOpens
|
||||
self.hardMode = rule.hardMode
|
||||
self.selectionMode = rule.selectionMode
|
||||
self.selectionData = rule.selectionData
|
||||
self.selectionCount = rule.selectionCount
|
||||
}
|
||||
|
||||
init(preset: RulePreset) {
|
||||
self.init(kind: .schedule)
|
||||
self.name = preset.name
|
||||
self.days = preset.days
|
||||
self.startMinutes = preset.startMinutes
|
||||
self.endMinutes = preset.endMinutes
|
||||
}
|
||||
|
||||
func apply(to rule: BlockingRule) {
|
||||
rule.name = name
|
||||
rule.kind = kind
|
||||
rule.days = days
|
||||
rule.startMinutes = startMinutes
|
||||
rule.endMinutes = endMinutes
|
||||
rule.dailyLimitMinutes = dailyLimitMinutes
|
||||
rule.maxOpens = maxOpens
|
||||
rule.hardMode = hardMode
|
||||
rule.selectionMode = selectionMode
|
||||
rule.selectionData = selectionData
|
||||
rule.selectionCount = selectionCount
|
||||
}
|
||||
|
||||
func makeRule() -> BlockingRule {
|
||||
let rule = BlockingRule(name: name, kind: kind)
|
||||
apply(to: rule)
|
||||
return rule
|
||||
}
|
||||
|
||||
var schedule: RuleSchedule {
|
||||
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
|
||||
}
|
||||
}
|
||||
64
Severed/Models/RuleKind.swift
Normal file
64
Severed/Models/RuleKind.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// RuleKind.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The three kinds of blocking rules, mirroring the reference app's "New Rule" sheet.
|
||||
enum RuleKind: String, Codable, CaseIterable, Sendable {
|
||||
/// Block selected apps during a recurring time window.
|
||||
case schedule
|
||||
/// Block selected apps after a daily usage budget is spent.
|
||||
case timeLimit
|
||||
/// Block selected apps after a number of opens per day.
|
||||
case openLimit
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .schedule: "Schedule"
|
||||
case .timeLimit: "Time Limit"
|
||||
case .openLimit: "Open Limit"
|
||||
}
|
||||
}
|
||||
|
||||
var exampleText: String {
|
||||
switch self {
|
||||
case .schedule: "e.g. 9-5, Daily"
|
||||
case .timeLimit: "e.g. 45m/day"
|
||||
case .openLimit: "e.g. 5 opens/day"
|
||||
}
|
||||
}
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .schedule: "calendar"
|
||||
case .timeLimit: "hourglass"
|
||||
case .openLimit: "lock.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// Default name given to a brand-new rule of this kind (Opal: "In the Zone", "Time Keeper").
|
||||
var defaultRuleName: String {
|
||||
switch self {
|
||||
case .schedule: "In the Zone"
|
||||
case .timeLimit: "Time Keeper"
|
||||
case .openLimit: "Gate Keeper"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the rule's app selection is interpreted.
|
||||
enum SelectionMode: String, Codable, CaseIterable, Sendable {
|
||||
/// Block the selected apps; everything else stays available.
|
||||
case block
|
||||
/// Block everything except the selected apps.
|
||||
case allowOnly
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .block: "Block"
|
||||
case .allowOnly: "Allow Only"
|
||||
}
|
||||
}
|
||||
}
|
||||
98
Severed/Models/RulePreset.swift
Normal file
98
Severed/Models/RulePreset.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// RulePreset.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A suggested schedule rule shown in the New Rule sheet's preset gallery,
|
||||
/// mirroring the reference app's sections and timings.
|
||||
struct RulePreset: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let startMinutes: Int
|
||||
let endMinutes: Int
|
||||
let days: Set<Weekday>
|
||||
let symbolName: String
|
||||
/// Gradient stand-in for the reference app's photo backgrounds.
|
||||
let gradientTop: Color
|
||||
let gradientBottom: Color
|
||||
|
||||
var schedule: RuleSchedule {
|
||||
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
|
||||
}
|
||||
}
|
||||
|
||||
/// A titled group of presets ("Get More Done", "Sleep, Relax and Reset", …).
|
||||
struct RulePresetSection: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let presets: [RulePreset]
|
||||
|
||||
static let all: [RulePresetSection] = [
|
||||
RulePresetSection(
|
||||
id: "productivity",
|
||||
title: "Get More Done",
|
||||
subtitle: "Maximize your productivity while staying sane.",
|
||||
presets: [
|
||||
RulePreset(
|
||||
id: "work-time", name: "Work Time",
|
||||
startMinutes: 9 * 60, endMinutes: 17 * 60, days: Weekday.weekdays,
|
||||
symbolName: "briefcase.fill",
|
||||
gradientTop: Color(red: 0.16, green: 0.27, blue: 0.22),
|
||||
gradientBottom: Color(red: 0.05, green: 0.10, blue: 0.08)
|
||||
),
|
||||
RulePreset(
|
||||
id: "laser-focus", name: "Laser Focus",
|
||||
startMinutes: 14 * 60, endMinutes: 18 * 60, days: Weekday.weekdays,
|
||||
symbolName: "scope",
|
||||
gradientTop: Color(red: 0.13, green: 0.20, blue: 0.33),
|
||||
gradientBottom: Color(red: 0.04, green: 0.06, blue: 0.12)
|
||||
),
|
||||
]
|
||||
),
|
||||
RulePresetSection(
|
||||
id: "sleep",
|
||||
title: "Sleep, Relax and Reset",
|
||||
subtitle: "Sleep better, rise refreshed.",
|
||||
presets: [
|
||||
RulePreset(
|
||||
id: "wind-down", name: "Wind Down",
|
||||
startMinutes: 20 * 60, endMinutes: 22 * 60, days: Weekday.everyDay,
|
||||
symbolName: "moon.haze.fill",
|
||||
gradientTop: Color(red: 0.25, green: 0.18, blue: 0.33),
|
||||
gradientBottom: Color(red: 0.08, green: 0.05, blue: 0.12)
|
||||
),
|
||||
RulePreset(
|
||||
id: "deep-sleep", name: "Deep Sleep",
|
||||
startMinutes: 22 * 60, endMinutes: 6 * 60, days: Weekday.everyDay,
|
||||
symbolName: "moon.zzz.fill",
|
||||
gradientTop: Color(red: 0.10, green: 0.13, blue: 0.30),
|
||||
gradientBottom: Color(red: 0.02, green: 0.03, blue: 0.10)
|
||||
),
|
||||
]
|
||||
),
|
||||
RulePresetSection(
|
||||
id: "habits",
|
||||
title: "Build Healthy Habits",
|
||||
subtitle: "Spend time on important things.",
|
||||
presets: [
|
||||
RulePreset(
|
||||
id: "reading-time", name: "Reading Time",
|
||||
startMinutes: 19 * 60 + 45, endMinutes: 20 * 60 + 45, days: Weekday.everyDay,
|
||||
symbolName: "book.fill",
|
||||
gradientTop: Color(red: 0.33, green: 0.23, blue: 0.13),
|
||||
gradientBottom: Color(red: 0.12, green: 0.07, blue: 0.03)
|
||||
),
|
||||
RulePreset(
|
||||
id: "gym-time", name: "Gym Time",
|
||||
startMinutes: 17 * 60 + 30, endMinutes: 18 * 60 + 30, days: Weekday.everyDay,
|
||||
symbolName: "figure.run",
|
||||
gradientTop: Color(red: 0.13, green: 0.30, blue: 0.30),
|
||||
gradientBottom: Color(red: 0.03, green: 0.10, blue: 0.10)
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
}
|
||||
65
Severed/Models/Weekday.swift
Normal file
65
Severed/Models/Weekday.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// Weekday.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A day of the week, using `Calendar` weekday numbering (1 = Sunday … 7 = Saturday).
|
||||
enum Weekday: Int, CaseIterable, Codable, Hashable, Sendable {
|
||||
case sunday = 1
|
||||
case monday = 2
|
||||
case tuesday = 3
|
||||
case wednesday = 4
|
||||
case thursday = 5
|
||||
case friday = 6
|
||||
case saturday = 7
|
||||
|
||||
static let weekdays: Set<Weekday> = [.monday, .tuesday, .wednesday, .thursday, .friday]
|
||||
static let weekends: Set<Weekday> = [.saturday, .sunday]
|
||||
static let everyDay: Set<Weekday> = Set(Weekday.allCases)
|
||||
|
||||
/// Display order used by the day picker, matching the reference UI: S M T W T F S.
|
||||
static let displayOrder: [Weekday] = [
|
||||
.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday,
|
||||
]
|
||||
|
||||
/// Single-letter label for the circular day toggles.
|
||||
var shortLabel: String {
|
||||
switch self {
|
||||
case .sunday, .saturday: "S"
|
||||
case .monday: "M"
|
||||
case .tuesday, .thursday: "T"
|
||||
case .wednesday: "W"
|
||||
case .friday: "F"
|
||||
}
|
||||
}
|
||||
|
||||
/// Three-letter abbreviation used in custom day summaries ("Mon, Wed, Fri").
|
||||
var abbreviation: String {
|
||||
switch self {
|
||||
case .sunday: "Sun"
|
||||
case .monday: "Mon"
|
||||
case .tuesday: "Tue"
|
||||
case .wednesday: "Wed"
|
||||
case .thursday: "Thu"
|
||||
case .friday: "Fri"
|
||||
case .saturday: "Sat"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Set<Weekday> {
|
||||
/// Human-readable summary shown next to the day picker and in rule details:
|
||||
/// "Weekdays", "Weekends", "Every day", "Never", or a list like "Mon, Wed, Fri".
|
||||
var summary: String {
|
||||
if self == Weekday.everyDay { return "Every day" }
|
||||
if self == Weekday.weekdays { return "Weekdays" }
|
||||
if self == Weekday.weekends { return "Weekends" }
|
||||
if isEmpty { return "Never" }
|
||||
return Weekday.displayOrder
|
||||
.filter(contains)
|
||||
.map(\.abbreviation)
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user