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:
2026-06-12 12:35:52 -04:00
parent 0c03e35ea9
commit e6c87baeba
45 changed files with 3749 additions and 206 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# Xcode user state
xcuserdata/
*.xcuserstate
# Build artifacts
DerivedData/
build/
# Screen recordings / reference material
*.MP4
*.mp4

View File

@@ -390,6 +390,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Severed/Severed.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4A9XHUS87Q; DEVELOPMENT_TEAM = 4A9XHUS87Q;
@@ -418,13 +419,12 @@
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
SDKROOT = auto; SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7"; TARGETED_DEVICE_FAMILY = "1,2";
XROS_DEPLOYMENT_TARGET = 26.0;
}; };
name = Debug; name = Debug;
}; };
@@ -433,6 +433,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Severed/Severed.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4A9XHUS87Q; DEVELOPMENT_TEAM = 4A9XHUS87Q;
@@ -461,13 +462,12 @@
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
SDKROOT = auto; SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7"; TARGETED_DEVICE_FAMILY = "1,2";
XROS_DEPLOYMENT_TARGET = 26.0;
}; };
name = Release; name = Release;
}; };
@@ -486,11 +486,11 @@
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Severed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Severed"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Severed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Severed";
XROS_DEPLOYMENT_TARGET = 26.0; XROS_DEPLOYMENT_TARGET = 26.0;
}; };
@@ -511,11 +511,11 @@
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Severed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Severed"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Severed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Severed";
XROS_DEPLOYMENT_TARGET = 26.0; XROS_DEPLOYMENT_TARGET = 26.0;
}; };
@@ -535,11 +535,11 @@
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = Severed; TEST_TARGET_NAME = Severed;
XROS_DEPLOYMENT_TARGET = 26.0; XROS_DEPLOYMENT_TARGET = 26.0;
}; };
@@ -559,11 +559,11 @@
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = Severed; TEST_TARGET_NAME = Severed;
XROS_DEPLOYMENT_TARGET = 26.0; XROS_DEPLOYMENT_TARGET = 26.0;
}; };

View File

@@ -1,66 +0,0 @@
//
// ContentView.swift
// Severed
//
// Created by Brendan Chen on 2025.08.09.
//
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
var body: some View {
NavigationSplitView {
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
} label: {
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
}
}
.onDelete(perform: deleteItems)
}
#if os(macOS)
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
#endif
.toolbar {
#if os(iOS)
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
#endif
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
} detail: {
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
}
}
}
}
#Preview {
ContentView()
.modelContainer(for: Item.self, inMemory: true)
}

View File

@@ -1,18 +0,0 @@
//
// Item.swift
// Severed
//
// Created by Brendan Chen on 2025.08.09.
//
import Foundation
import SwiftData
@Model
final class Item {
var timestamp: Date
init(timestamp: Date) {
self.timestamp = timestamp
}
}

View 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
}
}

View 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))"
}
}

View 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
}
}

View 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)
}
}

View 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 (95 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)
}
}

View 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"
}
}
}

View 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)
),
]
),
]
}

View 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: ", ")
}
}

View File

@@ -0,0 +1,45 @@
//
// LaunchConfiguration.swift
// Severed
//
import Foundation
/// Launch-argument configuration used by UI tests: in-memory storage, mocked
/// Screen Time authorization, forced onboarding state, and seeded scenarios.
struct LaunchConfiguration: Equatable {
enum SeedScenario: String {
/// One actively blocking rule ("Work Time") and one upcoming rule ("Sleep").
case standard
/// An actively blocking Hard Mode rule ("Locked In") plus an upcoming rule.
case hardModeActive = "hard-mode-active"
}
var isUITesting = false
/// Forces the onboarding-completed flag at launch. Nil leaves stored state alone.
var onboardingCompleted: Bool?
var seedScenario: SeedScenario?
static let uiTestingFlag = "-ui-testing"
static let onboardingCompletedFlag = "-onboarding-completed"
static let onboardingRequiredFlag = "-onboarding-required"
static let seedScenarioPrefix = "-seed-scenario="
static func parse(arguments: [String]) -> LaunchConfiguration {
var config = LaunchConfiguration()
config.isUITesting = arguments.contains(uiTestingFlag)
if arguments.contains(onboardingCompletedFlag) {
config.onboardingCompleted = true
} else if arguments.contains(onboardingRequiredFlag) {
config.onboardingCompleted = false
}
if let seedArgument = arguments.first(where: { $0.hasPrefix(seedScenarioPrefix) }) {
config.seedScenario = SeedScenario(
rawValue: String(seedArgument.dropFirst(seedScenarioPrefix.count))
)
}
return config
}
static let current = parse(arguments: ProcessInfo.processInfo.arguments)
}

View File

@@ -0,0 +1,43 @@
//
// RuleEnforcer.swift
// Severed
//
import Foundation
import Observation
/// Turns the current set of rules into shield state: schedule rules with an
/// active, un-paused window are shielded; everything else is cleared.
///
/// Time-limit and open-limit rules need a DeviceActivity monitor extension to
/// react to usage thresholds in the background; they are persisted and shown
/// in the UI but not yet enforced here.
@Observable
final class RuleEnforcer {
private(set) var blockingRuleIDs: Set<UUID> = []
private let shields: ShieldApplying
init(shields: ShieldApplying) {
self.shields = shields
}
/// Recomputes shields from scratch. Call on launch, on any rule change,
/// and periodically while the app is visible. Also expires stale pauses.
func refresh(rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current) {
var active: Set<UUID> = []
for rule in rules {
if let pausedUntil = rule.pausedUntil, pausedUntil <= now {
rule.pausedUntil = nil
}
guard rule.kind == .schedule,
rule.status(at: now, calendar: calendar).isActive
else { continue }
active.insert(rule.id)
shields.applyShield(
ruleID: rule.id, selectionData: rule.selectionData, mode: rule.selectionMode
)
}
shields.clearShields(except: active)
blockingRuleIDs = active
}
}

View File

@@ -0,0 +1,65 @@
//
// SampleRules.swift
// Severed
//
import Foundation
import SwiftData
/// Builds deterministic rules for UI-test scenarios, positioned relative to
/// "now" so an active window is genuinely active whenever the test runs.
enum SampleRules {
static func seed(
_ scenario: LaunchConfiguration.SeedScenario,
into context: ModelContext,
now: Date = .now,
calendar: Calendar = .current
) {
switch scenario {
case .standard:
context.insert(activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar))
context.insert(upcomingRule(named: "Sleep", now: now, calendar: calendar))
case .hardModeActive:
context.insert(activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar))
context.insert(upcomingRule(named: "Sleep", now: now, calendar: calendar))
}
}
/// A schedule rule whose window started up to an hour ago and runs for
/// several hours, clamped so it never crosses midnight accidentally.
static func activeRule(
named name: String, hardMode: Bool, now: Date, calendar: Calendar = .current
) -> BlockingRule {
let nowMinutes = minutesIntoDay(of: now, calendar: calendar)
let start = max(0, nowMinutes - 60)
let end = min(24 * 60 - 1, nowMinutes + 6 * 60)
return BlockingRule(
name: name,
hardMode: hardMode,
days: Weekday.everyDay,
startMinutes: start,
endMinutes: end
)
}
/// A schedule rule starting a couple of hours from now (possibly wrapping
/// past midnight, which simply makes it start tomorrow).
static func upcomingRule(
named name: String, now: Date, calendar: Calendar = .current
) -> BlockingRule {
let nowMinutes = minutesIntoDay(of: now, calendar: calendar)
let start = (nowMinutes + 2 * 60) % (24 * 60)
let end = (start + 8 * 60) % (24 * 60)
return BlockingRule(
name: name,
days: Weekday.everyDay,
startMinutes: start,
endMinutes: end
)
}
private static func minutesIntoDay(of date: Date, calendar: Calendar) -> Int {
let components = calendar.dateComponents([.hour, .minute], from: date)
return (components.hour ?? 0) * 60 + (components.minute ?? 0)
}
}

View File

@@ -0,0 +1,85 @@
//
// ScreenTimeAuthorization.swift
// Severed
//
import FamilyControls
import Foundation
import Observation
enum ScreenTimeAuthorizationStatus: Equatable, Sendable {
case notDetermined
case denied
case approved
}
/// Abstracts FamilyControls authorization so views and tests never touch
/// `AuthorizationCenter` directly.
protocol AuthorizationProviding {
var currentStatus: ScreenTimeAuthorizationStatus { get }
func requestAuthorization() async throws
}
/// Real Screen Time authorization via FamilyControls.
struct FamilyControlsAuthorizationProvider: AuthorizationProviding {
var currentStatus: ScreenTimeAuthorizationStatus {
switch AuthorizationCenter.shared.authorizationStatus {
case .approved: .approved
case .denied: .denied
case .notDetermined: .notDetermined
@unknown default: .notDetermined
}
}
func requestAuthorization() async throws {
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
}
}
/// In-memory provider for unit and UI tests.
final class MockAuthorizationProvider: AuthorizationProviding {
var status: ScreenTimeAuthorizationStatus
var requestShouldFail: Bool
init(status: ScreenTimeAuthorizationStatus = .notDetermined, requestShouldFail: Bool = false) {
self.status = status
self.requestShouldFail = requestShouldFail
}
var currentStatus: ScreenTimeAuthorizationStatus { status }
func requestAuthorization() async throws {
if requestShouldFail {
status = .denied
throw FamilyControlsError.authorizationCanceled
}
status = .approved
}
}
/// Observable authorization state for the UI.
@Observable
final class ScreenTimeAuthorization {
private(set) var status: ScreenTimeAuthorizationStatus
private(set) var lastRequestFailed = false
private let provider: AuthorizationProviding
init(provider: AuthorizationProviding) {
self.provider = provider
self.status = provider.currentStatus
}
func refresh() {
status = provider.currentStatus
}
func request() async {
do {
try await provider.requestAuthorization()
lastRequestFailed = false
} catch {
lastRequestFailed = true
}
refresh()
}
}

View File

@@ -0,0 +1,108 @@
//
// ShieldController.swift
// Severed
//
import FamilyControls
import Foundation
import ManagedSettings
/// Applies and clears app shields for rules. One implementation talks to
/// ManagedSettings; the mock records calls for tests.
protocol ShieldApplying: AnyObject {
func applyShield(ruleID: UUID, selectionData: Data?, mode: SelectionMode)
/// Clears every shield except those for the given rule IDs. Covers rules
/// that were deleted or expired while the app was not running.
func clearShields(except activeRuleIDs: Set<UUID>)
}
/// Real shield enforcement via per-rule `ManagedSettingsStore`s. Store names
/// are tracked in UserDefaults because ManagedSettings cannot enumerate them.
final class ManagedSettingsShieldController: ShieldApplying {
private static let trackedIDsKey = "shieldedRuleIDs"
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func applyShield(ruleID: UUID, selectionData: Data?, mode: SelectionMode) {
let store = store(for: ruleID)
let selection = AppSelectionCodec.decode(selectionData)
switch mode {
case .block:
store.shield.applications =
selection.applicationTokens.isEmpty ? nil : selection.applicationTokens
store.shield.applicationCategories =
selection.categoryTokens.isEmpty ? nil : .specific(selection.categoryTokens)
store.shield.webDomains =
selection.webDomainTokens.isEmpty ? nil : selection.webDomainTokens
case .allowOnly:
store.shield.applicationCategories = .all(except: selection.applicationTokens)
store.shield.webDomainCategories = .all(except: selection.webDomainTokens)
}
track(ruleID: ruleID)
}
func clearShields(except activeRuleIDs: Set<UUID>) {
for ruleID in trackedIDs.subtracting(activeRuleIDs) {
store(for: ruleID).clearAllSettings()
untrack(ruleID: ruleID)
}
}
private func store(for ruleID: UUID) -> ManagedSettingsStore {
ManagedSettingsStore(named: ManagedSettingsStore.Name("rule-\(ruleID.uuidString)"))
}
private var trackedIDs: Set<UUID> {
Set((defaults.stringArray(forKey: Self.trackedIDsKey) ?? []).compactMap(UUID.init))
}
private func track(ruleID: UUID) {
let ids = trackedIDs.union([ruleID])
defaults.set(ids.map(\.uuidString).sorted(), forKey: Self.trackedIDsKey)
}
private func untrack(ruleID: UUID) {
let ids = trackedIDs.subtracting([ruleID])
defaults.set(ids.map(\.uuidString).sorted(), forKey: Self.trackedIDsKey)
}
}
/// Records shield operations without touching the system. Used by tests and
/// UI-test launches.
final class MockShieldController: ShieldApplying {
private(set) var shieldedRuleIDs: Set<UUID> = []
private(set) var appliedModes: [UUID: SelectionMode] = [:]
func applyShield(ruleID: UUID, selectionData: Data?, mode: SelectionMode) {
shieldedRuleIDs.insert(ruleID)
appliedModes[ruleID] = mode
}
func clearShields(except activeRuleIDs: Set<UUID>) {
shieldedRuleIDs.formIntersection(activeRuleIDs)
appliedModes = appliedModes.filter { activeRuleIDs.contains($0.key) }
}
}
/// Encodes/decodes `FamilyActivitySelection` for persistence on the rule model.
enum AppSelectionCodec {
static func encode(_ selection: FamilyActivitySelection) -> Data? {
try? JSONEncoder().encode(selection)
}
static func decode(_ data: Data?) -> FamilyActivitySelection {
guard let data,
let selection = try? JSONDecoder().decode(FamilyActivitySelection.self, from: data)
else { return FamilyActivitySelection() }
return selection
}
static func count(of selection: FamilyActivitySelection) -> Int {
selection.applicationTokens.count
+ selection.categoryTokens.count
+ selection.webDomainTokens.count
}
}

View File

@@ -2,13 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>SchemeUserState</key> <key>com.apple.developer.family-controls</key>
<dict> <true/>
<key>Severed.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict> </dict>
</plist> </plist>

View File

@@ -5,28 +5,57 @@
// Created by Brendan Chen on 2025.08.09. // Created by Brendan Chen on 2025.08.09.
// //
import SwiftUI
import SwiftData import SwiftData
import SwiftUI
@main @main
struct SeveredApp: App { struct SeveredApp: App {
var sharedModelContainer: ModelContainer = { private let container: ModelContainer
let schema = Schema([ @State private var authorization: ScreenTimeAuthorization
Item.self, @State private var enforcer: RuleEnforcer
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
init() {
let config = LaunchConfiguration.current
if let onboardingCompleted = config.onboardingCompleted {
UserDefaults.standard.set(onboardingCompleted, forKey: "hasCompletedOnboarding")
}
let schema = Schema([BlockingRule.self])
let modelConfiguration = ModelConfiguration(
schema: schema, isStoredInMemoryOnly: config.isUITesting
)
do { do {
return try ModelContainer(for: schema, configurations: [modelConfiguration]) container = try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch { } catch {
fatalError("Could not create ModelContainer: \(error)") fatalError("Could not create ModelContainer: \(error)")
} }
}()
if let scenario = config.seedScenario {
SampleRules.seed(scenario, into: container.mainContext)
}
let authProvider: AuthorizationProviding =
config.isUITesting
? MockAuthorizationProvider(
status: config.onboardingCompleted == false ? .notDetermined : .approved
)
: FamilyControlsAuthorizationProvider()
_authorization = State(initialValue: ScreenTimeAuthorization(provider: authProvider))
let shields: ShieldApplying =
config.isUITesting ? MockShieldController() : ManagedSettingsShieldController()
_enforcer = State(initialValue: RuleEnforcer(shields: shields))
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() RootView()
.environment(authorization)
.environment(enforcer)
.preferredColorScheme(.dark)
.tint(Theme.accent)
} }
.modelContainer(sharedModelContainer) .modelContainer(container)
} }
} }

View File

@@ -0,0 +1,203 @@
//
// AppsHomeView.swift
// Severed
//
import SwiftData
import SwiftUI
/// The app's home screen, modeled on the reference "Apps" tab: what's blocked
/// right now, then the Rules carousel with "+ New".
struct AppsHomeView: View {
@Environment(\.modelContext) private var modelContext
@Environment(RuleEnforcer.self) private var enforcer
@Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule]
@State private var detailRule: BlockingRule?
@State private var showingNewRule = false
@State private var unblockCandidate: BlockingRule?
@State private var hardModeBlockedAttempt = false
var body: some View {
NavigationStack {
TimelineView(.periodic(from: .now, by: 30)) { timeline in
ScrollView {
VStack(alignment: .leading, spacing: 28) {
blockedAppsSection(now: timeline.date)
rulesSection(now: timeline.date)
}
.padding(.horizontal, 20)
.padding(.top, 8)
}
}
.background(Theme.background)
.navigationTitle("Apps")
.toolbarColorScheme(.dark, for: .navigationBar)
}
.sheet(item: $detailRule) { rule in
RuleDetailSheet(rule: rule)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showingNewRule) {
NewRuleSheet()
.presentationDragIndicator(.visible)
}
.confirmationDialog(
"Unblock \(unblockCandidate?.name ?? "")?",
isPresented: Binding(
get: { unblockCandidate != nil },
set: { if !$0 { unblockCandidate = nil } }
),
titleVisibility: .visible
) {
Button("Unblock", role: .destructive) {
if let rule = unblockCandidate {
RulePolicy.unblock(rule)
refreshEnforcement()
}
unblockCandidate = nil
}
} message: {
Text("Blocking resumes with the rule's next window.")
}
.alert("Hard Mode is on", isPresented: $hardModeBlockedAttempt) {
Button("OK", role: .cancel) {}
} message: {
Text("This block can't be lifted until it ends.")
}
.task {
await enforcementLoop()
}
.onChange(of: ruleChangeToken) {
refreshEnforcement()
}
}
// MARK: - Blocked Apps
@ViewBuilder
private func blockedAppsSection(now: Date) -> some View {
let blocking = rules.filter { $0.status(at: now).isActive }
VStack(alignment: .leading, spacing: 14) {
Text("Blocked Apps")
.font(.system(size: 17, weight: .semibold))
if blocking.isEmpty {
Text("Nothing is blocked right now.")
.font(.system(size: 14))
.foregroundStyle(Theme.textTertiary)
.accessibilityIdentifier("nothingBlockedLabel")
} else {
HStack(spacing: 16) {
ForEach(blocking) { rule in
blockedTile(for: rule, now: now)
}
}
}
}
}
private func blockedTile(for rule: BlockingRule, now: Date) -> some View {
Button {
if RulePolicy.canUnblock(rule, at: now) {
unblockCandidate = rule
} else {
hardModeBlockedAttempt = true
}
} label: {
VStack(spacing: 6) {
Image(systemName: rule.hardMode ? "lock.fill" : "lock.open.fill")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
.frame(width: 58, height: 58)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.strokeBorder(Theme.accent.opacity(0.6), lineWidth: 1)
)
Text("Unblock")
.font(.system(size: 12))
.foregroundStyle(Theme.textSecondary)
}
}
.buttonStyle(.plain)
.accessibilityIdentifier("blockedTile-\(rule.name)")
}
// MARK: - Rules
private func rulesSection(now: Date) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("Rules")
.font(.system(size: 17, weight: .semibold))
Spacer()
Button {
showingNewRule = true
} label: {
Label("New", systemImage: "plus")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.accent)
}
.accessibilityIdentifier("newRuleButton")
}
if rules.isEmpty {
emptyRulesCard
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 14) {
ForEach(rules) { rule in
Button {
detailRule = rule
} label: {
RuleCardView(rule: rule, now: now)
}
.buttonStyle(.plain)
.accessibilityIdentifier("ruleCard-\(rule.name)")
}
}
}
}
}
}
private var emptyRulesCard: some View {
VStack(alignment: .leading, spacing: 8) {
Text("No rules yet")
.font(.system(size: 16, weight: .semibold))
Text("Create a rule to block distracting apps on a schedule.")
.font(.system(size: 13))
.foregroundStyle(Theme.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 22))
.accessibilityElement(children: .combine)
.accessibilityIdentifier("emptyRulesCard")
}
// MARK: - Enforcement
/// Changes whenever any rule's blocking-relevant state changes.
private var ruleChangeToken: String {
rules.map {
"\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.startMinutes)|\($0.endMinutes)|"
+ "\($0.dayNumbers)|\($0.pausedUntil?.timeIntervalSince1970 ?? 0)"
}
.joined(separator: ",")
}
private func refreshEnforcement() {
enforcer.refresh(rules: rules)
}
/// Keeps shields in sync while the app is open, so windows that begin or
/// end while the user is looking at the screen take effect promptly.
private func enforcementLoop() async {
while !Task.isCancelled {
let allRules = (try? modelContext.fetch(FetchDescriptor<BlockingRule>())) ?? []
enforcer.refresh(rules: allRules)
try? await Task.sleep(for: .seconds(30))
}
}
}

View File

@@ -0,0 +1,90 @@
//
// 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)
}
}

View File

@@ -0,0 +1,61 @@
//
// DayOfWeekPicker.swift
// Severed
//
import SwiftUI
/// "On these days:" seven circular toggles (S M T W T F S) with a
/// human-readable summary, as in the reference rule editors.
struct DayOfWeekPicker: View {
@Binding var days: Set<Weekday>
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("On these days:")
.font(.system(size: 15, weight: .semibold))
Spacer()
Text(days.summary)
.font(.system(size: 14))
.foregroundStyle(Theme.textSecondary)
}
HStack(spacing: 8) {
ForEach(Weekday.displayOrder, id: \.self) { day in
dayToggle(day)
}
}
}
.padding(16)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
private func dayToggle(_ day: Weekday) -> some View {
let isOn = days.contains(day)
return Button {
if isOn {
days.remove(day)
} else {
days.insert(day)
}
} label: {
Text(day.shortLabel)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(isOn ? .black : Theme.textSecondary)
.frame(maxWidth: .infinity)
.aspectRatio(1, contentMode: .fit)
.background(isOn ? Color.white : Theme.surfaceElevated, in: Circle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("dayToggle-\(day.rawValue)")
.accessibilityLabel(day.abbreviation)
.accessibilityAddTraits(isOn ? .isSelected : [])
}
}
#Preview {
@Previewable @State var days = Weekday.weekdays
DayOfWeekPicker(days: $days)
.padding()
.background(Theme.background)
}

View File

@@ -0,0 +1,54 @@
//
// HoldToCommitButton.swift
// Severed
//
import SwiftUI
/// The deliberate-friction commit button: the user must press and hold for a
/// second to save a rule, mirroring the reference app's "Hold to Commit".
struct HoldToCommitButton: View {
var title = "Hold to Commit"
var holdDuration: Double = 1.0
let action: () -> Void
@State private var progress: Double = 0
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 28)
.fill(Theme.surfaceElevated)
GeometryReader { proxy in
RoundedRectangle(cornerRadius: 28)
.fill(Theme.commitGradient)
.opacity(0.85)
.frame(width: proxy.size.width * progress)
.animation(.linear(duration: 0.1), value: progress == 0)
}
Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
}
.frame(height: 56)
.clipShape(RoundedRectangle(cornerRadius: 28))
.contentShape(RoundedRectangle(cornerRadius: 28))
.onLongPressGesture(minimumDuration: holdDuration) {
progress = 0
action()
} onPressingChanged: { pressing in
withAnimation(.linear(duration: pressing ? holdDuration : 0.2)) {
progress = pressing ? 1 : 0
}
}
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier("holdToCommitButton")
.accessibilityLabel(title)
}
}
#Preview {
HoldToCommitButton { }
.padding()
.background(Theme.background)
}

View File

@@ -0,0 +1,139 @@
//
// OnboardingView.swift
// Severed
//
import SwiftUI
/// Two-step onboarding: a welcome screen, then the Screen Time permission
/// request. Onboarding only completes once authorization is approved the
/// app cannot block anything without it.
struct OnboardingView: View {
@Environment(ScreenTimeAuthorization.self) private var authorization
@Environment(\.openURL) private var openURL
let onComplete: () -> Void
private enum Step {
case welcome
case permission
}
@State private var step = Step.welcome
@State private var isRequesting = false
var body: some View {
VStack(spacing: 0) {
Spacer()
switch step {
case .welcome: welcome
case .permission: permission
}
Spacer()
footer
}
.padding(.horizontal, 28)
.padding(.bottom, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundStyle(.white)
.background(Theme.background)
}
private var welcome: some View {
VStack(spacing: 18) {
Image(systemName: "scissors")
.font(.system(size: 56, weight: .semibold))
.foregroundStyle(Theme.accent)
Text("Severed")
.font(.system(size: 38, weight: .bold))
Text("Block your most distracting apps with rules that keep you honest — on a schedule, with no way out when you choose Hard Mode.")
.font(.system(size: 16))
.foregroundStyle(Theme.textSecondary)
.multilineTextAlignment(.center)
}
}
private var permission: some View {
VStack(spacing: 24) {
Image(systemName: "hourglass")
.font(.system(size: 48, weight: .semibold))
.foregroundStyle(Theme.accent)
Text("Allow Screen Time Access")
.font(.system(size: 28, weight: .bold))
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: 14) {
bullet("shield.fill", "Severed uses Apple's Screen Time framework to block the apps you choose.")
bullet("hand.raised.fill", "Your app activity stays on this device and is never collected.")
bullet("gearshape.fill", "You can change this anytime in Settings.")
}
if authorization.status == .denied || authorization.lastRequestFailed {
VStack(spacing: 10) {
Text("Screen Time access was declined. Severed can't block apps without it.")
.font(.system(size: 13))
.foregroundStyle(Theme.destructive)
.multilineTextAlignment(.center)
.accessibilityIdentifier("permissionDeniedLabel")
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
openURL(url)
}
}
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("openSettingsButton")
}
}
}
}
private func bullet(_ systemImage: String, _ text: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(width: 22)
Text(text)
.font(.system(size: 15))
.foregroundStyle(Theme.textSecondary)
}
}
@ViewBuilder
private var footer: some View {
switch step {
case .welcome:
pillButton("Continue", identifier: "onboardingContinueButton") {
step = .permission
}
case .permission:
pillButton(
isRequesting ? "Requesting…" : "Allow Screen Time Access",
identifier: "allowScreenTimeButton"
) {
guard !isRequesting else { return }
isRequesting = true
Task {
await authorization.request()
isRequesting = false
if authorization.status == .approved {
onComplete()
}
}
}
}
}
private func pillButton(
_ title: String, identifier: String, action: @escaping () -> Void
) -> some View {
Button(action: action) {
Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.black)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(.white, in: RoundedRectangle(cornerRadius: 27))
}
.buttonStyle(.plain)
.accessibilityIdentifier(identifier)
}
}

View File

@@ -0,0 +1,32 @@
//
// RootView.swift
// Severed
//
import SwiftUI
/// Gates the app on onboarding: until the user has walked through the welcome
/// and Screen Time permission steps, nothing else is reachable.
struct RootView: View {
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@Environment(ScreenTimeAuthorization.self) private var authorization
@Environment(\.scenePhase) private var scenePhase
var body: some View {
Group {
if hasCompletedOnboarding {
AppsHomeView()
} else {
OnboardingView {
hasCompletedOnboarding = true
}
}
}
.onChange(of: scenePhase) { _, phase in
// Pick up permission changes made in Settings while we were backgrounded.
if phase == .active {
authorization.refresh()
}
}
}
}

View File

@@ -0,0 +1,94 @@
//
// AppSelectionSheet.swift
// Severed
//
import FamilyControls
import SwiftUI
/// App/category/website selection for a rule. Wraps Apple's
/// `FamilyActivityPicker` (the system-sanctioned way to choose apps without
/// seeing their identities) and adds the reference app's Block / Allow Only
/// mode switch.
struct AppSelectionSheet: View {
@Binding var draft: RuleDraft
@Environment(\.dismiss) private var dismiss
@State private var selection: FamilyActivitySelection
@State private var mode: SelectionMode
init(draft: Binding<RuleDraft>) {
self._draft = draft
self._selection = State(
initialValue: AppSelectionCodec.decode(draft.wrappedValue.selectionData)
)
self._mode = State(initialValue: draft.wrappedValue.selectionMode)
}
var body: some View {
VStack(spacing: 0) {
HStack {
CircleIconButton(systemImage: "chevron.left") { dismiss() }
.accessibilityIdentifier("selectionBackButton")
Spacer()
CircleIconButton(systemImage: "checkmark", tint: .black) { save() }
.background(Theme.accent, in: Circle())
.accessibilityIdentifier("confirmSelectionButton")
}
.overlay(
Text("Selected")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
)
.padding(.horizontal, 20)
.padding(.top, 18)
Picker("Mode", selection: $mode) {
ForEach(SelectionMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 20)
.padding(.top, 16)
.accessibilityIdentifier("selectionModePicker")
FamilyActivityPicker(selection: $selection)
.padding(.top, 4)
VStack(spacing: 10) {
Text(selectionSummary)
.font(.system(size: 14))
.foregroundStyle(Theme.textSecondary)
.accessibilityIdentifier("selectionCountLabel")
Button {
save()
} label: {
Text("Save")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.black)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(.white, in: RoundedRectangle(cornerRadius: 27))
}
.buttonStyle(.plain)
.accessibilityIdentifier("saveSelectionButton")
}
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
.background(Theme.background)
}
private var selectionSummary: String {
let count = AppSelectionCodec.count(of: selection)
return count == 1 ? "1 App Selected" : "\(count) Apps Selected"
}
private func save() {
draft.selectionData = AppSelectionCodec.encode(selection)
draft.selectionCount = AppSelectionCodec.count(of: selection)
draft.selectionMode = mode
dismiss()
}
}

View File

@@ -0,0 +1,157 @@
//
// NewRuleSheet.swift
// Severed
//
import SwiftData
import SwiftUI
/// "New Rule": the three rule-type cards up top, then the preset gallery.
/// Picking either one morphs the sheet into the editor; committing saves the
/// rule and closes the sheet.
struct NewRuleSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@State private var pendingDraft: RuleDraft?
var body: some View {
Group {
if let pendingDraft {
RuleEditorView(
mode: .create,
draft: pendingDraft,
onBack: { self.pendingDraft = nil },
onCommit: { draft in
modelContext.insert(draft.makeRule())
dismiss()
}
)
} else {
chooser
}
}
.background(Theme.background)
}
private var chooser: some View {
VStack(spacing: 0) {
HStack {
CircleIconButton(systemImage: "xmark") { dismiss() }
.accessibilityIdentifier("closeNewRuleButton")
Spacer()
}
.overlay(
Text("New Rule")
.font(.system(size: 18, weight: .semibold))
)
.padding(.horizontal, 20)
.padding(.top, 18)
ScrollView {
VStack(alignment: .leading, spacing: 26) {
HStack(spacing: 10) {
ForEach(RuleKind.allCases, id: \.self) { kind in
kindCard(kind)
}
}
ForEach(RulePresetSection.all) { section in
presetSection(section)
}
}
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 30)
}
}
.foregroundStyle(.white)
}
private func kindCard(_ kind: RuleKind) -> some View {
Button {
pendingDraft = RuleDraft(kind: kind)
} label: {
VStack(spacing: 8) {
Image(systemName: kind.symbolName)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Theme.accent)
.frame(height: 26)
Text(kind.displayName)
.font(.system(size: 14, weight: .semibold))
Text(kind.exampleText)
.font(.system(size: 11))
.foregroundStyle(Theme.textTertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
.buttonStyle(.plain)
.accessibilityIdentifier("ruleKind-\(kind.rawValue)")
}
private func presetSection(_ section: RulePresetSection) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(section.title)
.font(.system(size: 18, weight: .bold))
Text(section.subtitle)
.font(.system(size: 13))
.foregroundStyle(Theme.textSecondary)
LazyVGrid(
columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible())],
spacing: 12
) {
ForEach(section.presets) { preset in
presetCard(preset)
}
}
.padding(.top, 12)
}
}
private func presetCard(_ preset: RulePreset) -> some View {
Button {
pendingDraft = RuleDraft(preset: preset)
} label: {
VStack(alignment: .leading, spacing: 4) {
HStack {
RuleIconPair(kind: .schedule)
Spacer()
}
Spacer()
Image(systemName: preset.symbolName)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white.opacity(0.8))
.padding(.bottom, 8)
Text(preset.schedule.timeRangeLabel)
.font(.system(size: 12))
.foregroundStyle(Theme.textSecondary)
Text(preset.name)
.font(.system(size: 16, weight: .semibold))
HStack {
Text("Block")
.font(.system(size: 12))
.foregroundStyle(Theme.textSecondary)
Spacer()
Image(systemName: "plus.circle.fill")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.85))
}
}
.padding(14)
.frame(height: 190)
.background(
LinearGradient(
colors: [preset.gradientTop, preset.gradientBottom],
startPoint: .top, endPoint: .bottom
),
in: RoundedRectangle(cornerRadius: 20)
)
.overlay(
RoundedRectangle(cornerRadius: 20)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("preset-\(preset.id)")
}
}

View File

@@ -0,0 +1,178 @@
//
// RuleDetailSheet.swift
// Severed
//
import SwiftData
import SwiftUI
/// The card-style rule summary shown when tapping a rule: icon pair, live
/// status, the key facts, and "Edit Rule" which morphs the sheet into the
/// editor, as in the reference app. A hard-locked rule shows a lock notice
/// instead of the edit button.
struct RuleDetailSheet: View {
let rule: BlockingRule
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@State private var isEditing = false
@State private var pendingDeletion = false
var body: some View {
TimelineView(.periodic(from: .now, by: 30)) { timeline in
if isEditing {
RuleEditorView(
mode: .edit(isEnabled: rule.isEnabled),
draft: RuleDraft(rule: rule),
onBack: { isEditing = false },
onCommit: { draft in
draft.apply(to: rule)
isEditing = false
},
onToggleEnabled: {
rule.isEnabled.toggle()
rule.pausedUntil = nil
isEditing = false
},
onDelete: {
pendingDeletion = true
dismiss()
}
)
} else {
detail(now: timeline.date)
}
}
.background(Theme.background)
.onDisappear {
if pendingDeletion {
modelContext.delete(rule)
}
}
}
private func detail(now: Date) -> some View {
let status = rule.status(at: now)
return VStack(spacing: 0) {
HStack {
CircleIconButton(systemImage: "xmark") { dismiss() }
.accessibilityIdentifier("closeDetailButton")
Spacer()
}
.padding(.horizontal, 20)
.padding(.top, 18)
ScrollView {
VStack(spacing: 24) {
RuleIconPair(kind: rule.kind, isActive: status.isActive)
VStack(spacing: 6) {
Text("\(rule.kind.displayName), \(status.label(relativeTo: now))")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.textSecondary)
.accessibilityIdentifier("detailStatusLabel")
Text(rule.name)
.font(.system(size: 26, weight: .bold))
.accessibilityIdentifier("detailRuleName")
}
detailRows
}
.padding(.horizontal, 20)
.padding(.top, 8)
}
footer(now: now)
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
.foregroundStyle(.white)
}
private var detailRows: some View {
VStack(spacing: 0) {
switch rule.kind {
case .schedule:
row("During this time", rule.schedule.timeRangeLabel)
divider
row("On these days", rule.days.summary)
divider
row(rule.selectionMode.displayName, appCountLabel)
divider
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .timeLimit:
row("When I use", appCountLabel)
divider
row("For this long", "\(rule.dailyLimitMinutes)m daily")
divider
row("On these days", rule.days.summary)
divider
row("Then block until", "Tomorrow")
divider
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .openLimit:
row("When I open", appCountLabel)
divider
row("More than", "\(rule.maxOpens) opens daily")
divider
row("On these days", rule.days.summary)
divider
row("Then block until", "Tomorrow")
divider
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
}
}
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
private var appCountLabel: String {
rule.selectionCount == 1 ? "1 App" : "\(rule.selectionCount) Apps"
}
private func row(_ label: String, _ value: String) -> some View {
HStack {
Text(label)
.font(.system(size: 15, weight: .medium))
Spacer()
Text(value)
.font(.system(size: 15))
.foregroundStyle(Theme.textSecondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("detailRow-\(label)")
}
private var divider: some View {
Divider().background(.white.opacity(0.06)).padding(.leading, 16)
}
@ViewBuilder
private func footer(now: Date) -> some View {
if RulePolicy.canEdit(rule, at: now) {
Button {
isEditing = true
} label: {
Label("Edit Rule", systemImage: "pencil")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.black)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(.white, in: RoundedRectangle(cornerRadius: 27))
}
.buttonStyle(.plain)
.accessibilityIdentifier("editRuleButton")
} else {
HStack(spacing: 10) {
Image(systemName: "lock.fill")
Text("Hard Mode is on — this rule is locked until the block ends.")
.font(.system(size: 14, weight: .medium))
}
.foregroundStyle(Theme.textSecondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
.accessibilityElement(children: .combine)
.accessibilityIdentifier("hardModeLockedNotice")
}
}
}

View File

@@ -0,0 +1,297 @@
//
// RuleEditorView.swift
// Severed
//
import SwiftUI
/// The rule editor used both for creation (Hold to Commit) and editing
/// (Done / Disable Rule / Delete Rule). Sections adapt to the rule kind,
/// mirroring the reference app's "In the Zone" and "Time Keeper" editors.
struct RuleEditorView: View {
enum Mode: Equatable {
case create
case edit(isEnabled: Bool)
}
let mode: Mode
@State var draft: RuleDraft
var onBack: () -> Void
var onCommit: (RuleDraft) -> Void
var onToggleEnabled: (() -> Void)?
var onDelete: (() -> Void)?
@State private var showingRename = false
@State private var showingAppPicker = false
@State private var renameText = ""
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(spacing: 18) {
sections
}
.padding(.horizontal, 20)
.padding(.top, 10)
.padding(.bottom, 24)
}
footer
.padding(.horizontal, 20)
.padding(.bottom, 16)
}
.foregroundStyle(.white)
.background(Theme.background)
.alert("Rule Name", isPresented: $showingRename) {
TextField("Name", text: $renameText)
Button("OK") {
let trimmed = renameText.trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty {
draft.name = trimmed
}
}
Button("Cancel", role: .cancel) {}
}
.sheet(isPresented: $showingAppPicker) {
AppSelectionSheet(draft: $draft)
.presentationDragIndicator(.visible)
}
}
// MARK: - Header
private var header: some View {
HStack {
CircleIconButton(systemImage: "chevron.left", action: onBack)
.accessibilityIdentifier("editorBackButton")
Spacer()
CircleIconButton(systemImage: "pencil") {
renameText = draft.name
showingRename = true
}
.accessibilityIdentifier("renameButton")
}
.overlay(
Text(draft.name)
.font(.system(size: 18, weight: .semibold))
.lineLimit(1)
.padding(.horizontal, 56)
.accessibilityIdentifier("ruleEditorTitle")
)
.padding(.horizontal, 20)
.padding(.top, 18)
}
// MARK: - Sections
@ViewBuilder
private var sections: some View {
switch draft.kind {
case .schedule:
timeWindowSection
DayOfWeekPicker(days: $draft.days)
appsSection(header: "Apps are blocked")
hardModeSection
case .timeLimit:
appsSection(header: "When I use")
budgetSection(
title: "For this long",
value: "\(draft.dailyLimitMinutes)m",
stepperID: "dailyLimitStepper",
onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) },
onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) }
)
DayOfWeekPicker(days: $draft.days)
blockUntilSection
hardModeSection
case .openLimit:
appsSection(header: "When I open")
budgetSection(
title: "More than",
value: "\(draft.maxOpens) opens",
stepperID: "maxOpensStepper",
onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) },
onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) }
)
DayOfWeekPicker(days: $draft.days)
blockUntilSection
hardModeSection
}
}
private var timeWindowSection: some View {
VStack(alignment: .leading, spacing: 10) {
sectionHeader(systemImage: "calendar", title: "During this time")
VStack(spacing: 0) {
timeRow(label: "From", minutes: $draft.startMinutes, identifier: "fromTimePicker")
Divider().background(.white.opacity(0.06)).padding(.leading, 16)
timeRow(label: "To", minutes: $draft.endMinutes, identifier: "toTimePicker")
}
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
}
private func timeRow(label: String, minutes: Binding<Int>, identifier: String) -> some View {
HStack {
Text(label)
.font(.system(size: 15, weight: .medium))
Spacer()
DatePicker("", selection: timeBinding(minutes), displayedComponents: .hourAndMinute)
.labelsHidden()
.colorScheme(.dark)
.accessibilityIdentifier(identifier)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
private func appsSection(header: String) -> some View {
VStack(alignment: .leading, spacing: 10) {
sectionHeader(systemImage: "shield.fill", title: header)
Button {
showingAppPicker = true
} label: {
HStack {
Text("Selected Apps")
.font(.system(size: 15, weight: .medium))
Spacer()
Text(draft.selectionCount == 1 ? "1 App" : "\(draft.selectionCount) Apps")
.font(.system(size: 15))
.foregroundStyle(Theme.textSecondary)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.textTertiary)
}
.padding(16)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
.buttonStyle(.plain)
.accessibilityIdentifier("selectedAppsRow")
}
}
private func budgetSection(
title: String,
value: String,
stepperID: String,
onIncrement: @escaping () -> Void,
onDecrement: @escaping () -> Void
) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: 15, weight: .medium))
Text("Daily")
.font(.system(size: 12))
.foregroundStyle(Theme.textTertiary)
}
Spacer()
Text(value)
.font(.system(size: 15, weight: .semibold))
.accessibilityIdentifier("\(stepperID)Value")
Stepper("", onIncrement: onIncrement, onDecrement: onDecrement)
.labelsHidden()
.accessibilityIdentifier(stepperID)
}
.padding(16)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
private var blockUntilSection: some View {
VStack(alignment: .leading, spacing: 10) {
sectionHeader(systemImage: "shield.fill", title: "Then block app")
HStack {
Text("Until")
.font(.system(size: 15, weight: .medium))
Spacer()
Text("Tomorrow")
.font(.system(size: 15))
.foregroundStyle(Theme.textSecondary)
}
.padding(16)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
}
private var hardModeSection: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Hard Mode")
.font(.system(size: 15, weight: .semibold))
Text("No unblocks allowed")
.font(.system(size: 12))
.foregroundStyle(Theme.textTertiary)
}
Spacer()
Toggle("", isOn: $draft.hardMode)
.labelsHidden()
.tint(Theme.accent)
.accessibilityIdentifier("hardModeToggle")
}
.padding(16)
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
}
private func sectionHeader(systemImage: String, title: String) -> some View {
HStack(spacing: 8) {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.textSecondary)
Text(title)
.font(.system(size: 15, weight: .semibold))
}
.padding(.leading, 4)
}
// MARK: - Footer
@ViewBuilder
private var footer: some View {
switch mode {
case .create:
HoldToCommitButton {
onCommit(draft)
}
case .edit(let isEnabled):
VStack(spacing: 12) {
Button {
onCommit(draft)
} label: {
Label("Done", systemImage: "checkmark")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.black)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(.white, in: RoundedRectangle(cornerRadius: 27))
}
.buttonStyle(.plain)
.accessibilityIdentifier("doneButton")
HStack(spacing: 24) {
Button(isEnabled ? "Disable Rule" : "Enable Rule") {
onToggleEnabled?()
}
.accessibilityIdentifier("toggleEnabledButton")
Button("Delete Rule") {
onDelete?()
}
.accessibilityIdentifier("deleteRuleButton")
}
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.destructive)
}
}
}
private func timeBinding(_ minutes: Binding<Int>) -> Binding<Date> {
Binding {
let dayStart = Calendar.current.startOfDay(for: .now)
return Calendar.current.date(byAdding: .minute, value: minutes.wrappedValue, to: dayStart)
?? .now
} set: { newDate in
let components = Calendar.current.dateComponents([.hour, .minute], from: newDate)
minutes.wrappedValue = (components.hour ?? 0) * 60 + (components.minute ?? 0)
}
}
}

73
Severed/Views/Theme.swift Normal file
View File

@@ -0,0 +1,73 @@
//
// Theme.swift
// Severed
//
import SwiftUI
/// Dark, green-tinted palette matching the reference app.
enum Theme {
static let background = Color(red: 0.03, green: 0.05, blue: 0.04)
static let surface = Color.white.opacity(0.07)
static let surfaceElevated = Color.white.opacity(0.12)
static let accent = Color(red: 0.66, green: 0.88, blue: 0.50)
static let activeCardTop = Color(red: 0.10, green: 0.20, blue: 0.12)
static let activeCardBottom = Color(red: 0.05, green: 0.10, blue: 0.06)
static let textSecondary = Color.white.opacity(0.6)
static let textTertiary = Color.white.opacity(0.4)
static let destructive = Color(red: 1.0, green: 0.32, blue: 0.36)
static let commitGradient = LinearGradient(
colors: [
Color(red: 0.35, green: 0.65, blue: 0.85),
Color(red: 0.45, green: 0.75, blue: 0.55),
Color(red: 0.85, green: 0.80, blue: 0.45),
],
startPoint: .leading, endPoint: .trailing
)
}
/// Circular chrome button used in sheet headers (, , ).
struct CircleIconButton: View {
let systemImage: String
var tint: Color = .white
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: systemImage)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 38, height: 38)
.background(Theme.surfaceElevated, in: Circle())
}
.buttonStyle(.plain)
}
}
/// The "calendar shield" icon pair shown on rule cards, details, and presets.
struct RuleIconPair: View {
let kind: RuleKind
var isActive = false
var body: some View {
HStack(spacing: 8) {
iconTile(systemImage: kind.symbolName, tinted: isActive)
Image(systemName: "arrow.right")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Theme.textTertiary)
iconTile(systemImage: "shield.fill", tinted: isActive)
}
}
private func iconTile(systemImage: String, tinted: Bool) -> some View {
Image(systemName: systemImage)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(tinted ? Theme.accent : .white.opacity(0.7))
.frame(width: 32, height: 32)
.background(
(tinted ? Theme.accent.opacity(0.18) : Theme.surfaceElevated),
in: RoundedRectangle(cornerRadius: 9)
)
}
}

View File

@@ -0,0 +1,97 @@
//
// LaunchSupportTests.swift
// SeveredTests
//
import Foundation
import Testing
@testable import Severed
@MainActor
@Suite("Launch configuration parsing")
struct LaunchConfigurationTests {
@Test("Defaults are production settings")
func defaults() {
let config = LaunchConfiguration.parse(arguments: ["SeveredApp"])
#expect(!config.isUITesting)
#expect(config.onboardingCompleted == nil)
#expect(config.seedScenario == nil)
}
@Test("UI testing flags are recognized")
func uiTestingFlags() {
let config = LaunchConfiguration.parse(arguments: [
"SeveredApp", "-ui-testing", "-onboarding-completed", "-seed-scenario=standard",
])
#expect(config.isUITesting)
#expect(config.onboardingCompleted == true)
#expect(config.seedScenario == .standard)
}
@Test("Onboarding can be forced on")
func onboardingRequired() {
let config = LaunchConfiguration.parse(arguments: ["SeveredApp", "-onboarding-required"])
#expect(config.onboardingCompleted == false)
}
@Test("Unknown seed scenarios are ignored")
func unknownScenario() {
let config = LaunchConfiguration.parse(arguments: ["SeveredApp", "-seed-scenario=nope"])
#expect(config.seedScenario == nil)
}
}
@MainActor
@Suite("Seeded sample rules")
struct SampleRulesTests {
@Test(
"Seeded active rules are genuinely active at any time of day",
arguments: [(0, 30), (9, 0), (12, 30), (23, 50)]
)
func activeRuleIsActive(hour: Int, minute: Int) {
let now = date(2025, 1, 6, hour, minute)
let rule = SampleRules.activeRule(named: "Work Time", hardMode: false, now: now, calendar: utc)
#expect(rule.status(at: now, calendar: utc).isActive)
}
@Test(
"Seeded upcoming rules are not active but will start",
arguments: [(0, 30), (9, 0), (12, 30), (23, 50)]
)
func upcomingRuleIsUpcoming(hour: Int, minute: Int) {
let now = date(2025, 1, 6, hour, minute)
let rule = SampleRules.upcomingRule(named: "Sleep", now: now, calendar: utc)
let status = rule.status(at: now, calendar: utc)
#expect(!status.isActive)
if case .upcoming = status {} else {
Issue.record("Expected upcoming, got \(status)")
}
}
@Test("Hard mode scenario seeds a locked active rule")
func hardModeScenario() {
let now = date(2025, 1, 6, 12, 0)
let rule = SampleRules.activeRule(named: "Locked In", hardMode: true, now: now, calendar: utc)
#expect(RulePolicy.isHardLocked(rule, at: now, calendar: utc))
}
@Test("Mock authorization approves on request")
func mockAuthorization() async {
let provider = MockAuthorizationProvider()
let auth = ScreenTimeAuthorization(provider: provider)
#expect(auth.status == .notDetermined)
await auth.request()
#expect(auth.status == .approved)
#expect(!auth.lastRequestFailed)
}
@Test("Mock authorization can simulate denial")
func mockAuthorizationDenied() async {
let provider = MockAuthorizationProvider(requestShouldFail: true)
let auth = ScreenTimeAuthorization(provider: provider)
await auth.request()
#expect(auth.status == .denied)
#expect(auth.lastRequestFailed)
}
}

View File

@@ -0,0 +1,113 @@
//
// RuleEnforcerTests.swift
// SeveredTests
//
import Foundation
import Testing
@testable import Severed
@MainActor
@Suite("Rule enforcement → shields")
struct RuleEnforcerTests {
let mondayDuringWork = date(2025, 1, 6, 10, 0)
let mondayEvening = date(2025, 1, 6, 19, 0)
@Test("Active schedule rules are shielded; inactive ones are not")
func shieldsActiveRules() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let active = BlockingRule(name: "Work Time")
let weekendOnly = BlockingRule(name: "Weekend Zen", days: Weekday.weekends)
enforcer.refresh(rules: [active, weekendOnly], at: mondayDuringWork, calendar: utc)
#expect(shields.shieldedRuleIDs == [active.id])
#expect(enforcer.blockingRuleIDs == [active.id])
}
@Test("Disabled rules are never shielded")
func skipsDisabledRules() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let rule = BlockingRule(name: "Work Time", isEnabled: false)
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
#expect(shields.shieldedRuleIDs.isEmpty)
}
@Test("Paused rules are not shielded")
func skipsPausedRules() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let rule = BlockingRule(name: "Work Time")
RulePolicy.unblock(rule, at: mondayDuringWork, calendar: utc)
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
#expect(shields.shieldedRuleIDs.isEmpty)
}
@Test("Time-limit rules are not schedule-shielded")
func skipsTimeLimitRules() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit)
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
#expect(shields.shieldedRuleIDs.isEmpty)
}
@Test("Shields are cleared when a window ends")
func clearsShieldAfterWindow() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let rule = BlockingRule(name: "Work Time")
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
#expect(shields.shieldedRuleIDs == [rule.id])
enforcer.refresh(rules: [rule], at: mondayEvening, calendar: utc)
#expect(shields.shieldedRuleIDs.isEmpty)
#expect(enforcer.blockingRuleIDs.isEmpty)
}
@Test("Shields are cleared when a rule is deleted")
func clearsShieldAfterDeletion() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let rule = BlockingRule(name: "Work Time")
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
enforcer.refresh(rules: [], at: mondayDuringWork, calendar: utc)
#expect(shields.shieldedRuleIDs.isEmpty)
}
@Test("Expired pauses are cleaned up during refresh")
func clearsExpiredPause() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let rule = BlockingRule(name: "Work Time")
rule.pausedUntil = date(2025, 1, 6, 9, 30)
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
#expect(rule.pausedUntil == nil)
#expect(shields.shieldedRuleIDs == [rule.id])
}
@Test("The selection mode is forwarded to the shield layer")
func forwardsSelectionMode() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let rule = BlockingRule(name: "Focus", selectionMode: .allowOnly)
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
#expect(shields.appliedModes[rule.id] == .allowOnly)
}
}

View File

@@ -0,0 +1,148 @@
//
// RuleModelTests.swift
// SeveredTests
//
import Foundation
import SwiftData
import Testing
@testable import Severed
@MainActor
@Suite("BlockingRule model & persistence")
struct RuleModelTests {
@Test("Defaults match the reference app's new-rule defaults")
func defaults() {
let rule = BlockingRule(name: "In the Zone")
#expect(rule.kind == .schedule)
#expect(rule.isEnabled)
#expect(!rule.hardMode)
#expect(rule.selectionMode == .block)
#expect(rule.days == Weekday.weekdays)
#expect(rule.startMinutes == 9 * 60)
#expect(rule.endMinutes == 17 * 60)
#expect(rule.pausedUntil == nil)
}
@Test("Days survive the raw-storage round trip")
func daysRoundTrip() {
let rule = BlockingRule(name: "Test")
rule.days = [.sunday, .wednesday, .saturday]
#expect(rule.days == [.sunday, .wednesday, .saturday])
#expect(rule.dayNumbers == [1, 4, 7])
}
@Test("Kind and selection mode survive raw storage, with safe fallbacks")
func enumRoundTrip() {
let rule = BlockingRule(name: "Test", kind: .openLimit, selectionMode: .allowOnly)
#expect(rule.kind == .openLimit)
#expect(rule.selectionMode == .allowOnly)
rule.kindRaw = "garbage"
rule.selectionModeRaw = "garbage"
#expect(rule.kind == .schedule)
#expect(rule.selectionMode == .block)
}
@Test("Rules persist and fetch through SwiftData")
func persistence() throws {
let container = try ModelContainer(
for: BlockingRule.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let context = container.mainContext
let rule = BlockingRule(
name: "Deep Sleep", hardMode: true,
days: Weekday.everyDay, startMinutes: 22 * 60, endMinutes: 6 * 60
)
context.insert(rule)
try context.save()
let fetched = try context.fetch(FetchDescriptor<BlockingRule>())
#expect(fetched.count == 1)
let saved = try #require(fetched.first)
#expect(saved.name == "Deep Sleep")
#expect(saved.hardMode)
#expect(saved.days == Weekday.everyDay)
#expect(saved.schedule.crossesMidnight)
}
}
@MainActor
@Suite("RuleDraft")
struct RuleDraftTests {
@Test("New drafts use the kind's defaults")
func newDraftDefaults() {
let draft = RuleDraft(kind: .timeLimit)
#expect(draft.name == "Time Keeper")
#expect(draft.kind == .timeLimit)
#expect(draft.dailyLimitMinutes == 45)
#expect(draft.days == Weekday.weekdays)
#expect(!draft.hardMode)
}
@Test("Draft → rule → draft round-trips every field")
func roundTrip() {
var draft = RuleDraft(kind: .schedule)
draft.name = "Locked In"
draft.days = Weekday.everyDay
draft.startMinutes = 22 * 60
draft.endMinutes = 6 * 60
draft.hardMode = true
draft.selectionMode = .allowOnly
draft.selectionCount = 3
let rule = draft.makeRule()
#expect(RuleDraft(rule: rule) == draft)
}
@Test("Applying a draft updates an existing rule")
func applyToExisting() {
let rule = BlockingRule(name: "Old Name")
var draft = RuleDraft(rule: rule)
draft.name = "New Name"
draft.hardMode = true
draft.apply(to: rule)
#expect(rule.name == "New Name")
#expect(rule.hardMode)
}
@Test("Preset drafts copy the preset's schedule")
func presetDraft() throws {
let preset = try #require(
RulePresetSection.all
.flatMap(\.presets)
.first { $0.id == "deep-sleep" }
)
let draft = RuleDraft(preset: preset)
#expect(draft.name == "Deep Sleep")
#expect(draft.startMinutes == 22 * 60)
#expect(draft.endMinutes == 6 * 60)
#expect(draft.kind == .schedule)
}
}
@MainActor
@Suite("Weekday summaries")
struct WeekdayTests {
@Test("Named sets")
func namedSets() {
#expect(Weekday.weekdays.summary == "Weekdays")
#expect(Weekday.weekends.summary == "Weekends")
#expect(Weekday.everyDay.summary == "Every day")
#expect(Set<Weekday>().summary == "Never")
}
@Test("Custom sets list days in display order")
func customSets() {
let days: Set<Weekday> = [.friday, .monday, .wednesday]
#expect(days.summary == "Mon, Wed, Fri")
}
@Test("Picker display order starts on Sunday")
func displayOrder() {
#expect(Weekday.displayOrder.first == .sunday)
#expect(Weekday.displayOrder.count == 7)
#expect(Weekday.displayOrder.map(\.shortLabel) == ["S", "M", "T", "W", "T", "F", "S"])
}
}

View File

@@ -0,0 +1,81 @@
//
// RulePolicyTests.swift
// SeveredTests
//
import Foundation
import Testing
@testable import Severed
@MainActor
@Suite("Hard Mode policy")
struct RulePolicyTests {
let mondayDuringWork = date(2025, 1, 6, 10, 0)
let mondayEvening = date(2025, 1, 6, 19, 0)
func rule(hardMode: Bool) -> BlockingRule {
BlockingRule(name: "Work Time", hardMode: hardMode)
}
@Test("An active Hard Mode rule is locked")
func hardLockedWhileActive() {
let rule = rule(hardMode: true)
#expect(RulePolicy.isHardLocked(rule, at: mondayDuringWork, calendar: utc))
#expect(!RulePolicy.canEdit(rule, at: mondayDuringWork, calendar: utc))
#expect(!RulePolicy.canDisable(rule, at: mondayDuringWork, calendar: utc))
#expect(!RulePolicy.canDelete(rule, at: mondayDuringWork, calendar: utc))
#expect(!RulePolicy.canUnblock(rule, at: mondayDuringWork, calendar: utc))
#expect(!RulePolicy.canTurnOffHardMode(rule, at: mondayDuringWork, calendar: utc))
}
@Test("A Hard Mode rule unlocks once its window ends")
func unlockedOutsideWindow() {
let rule = rule(hardMode: true)
#expect(!RulePolicy.isHardLocked(rule, at: mondayEvening, calendar: utc))
#expect(RulePolicy.canEdit(rule, at: mondayEvening, calendar: utc))
#expect(RulePolicy.canDisable(rule, at: mondayEvening, calendar: utc))
#expect(RulePolicy.canDelete(rule, at: mondayEvening, calendar: utc))
#expect(RulePolicy.canTurnOffHardMode(rule, at: mondayEvening, calendar: utc))
}
@Test("A disabled Hard Mode rule is not locked")
func disabledRuleNotLocked() {
let rule = rule(hardMode: true)
rule.isEnabled = false
#expect(!RulePolicy.isHardLocked(rule, at: mondayDuringWork, calendar: utc))
#expect(RulePolicy.canEdit(rule, at: mondayDuringWork, calendar: utc))
}
@Test("Active non-Hard-Mode rules may be unblocked")
func softRuleUnblockable() {
let rule = rule(hardMode: false)
#expect(RulePolicy.canUnblock(rule, at: mondayDuringWork, calendar: utc))
}
@Test("Unblocking pauses the rule until its window ends")
func unblockPausesUntilWindowEnd() {
let rule = rule(hardMode: false)
let didUnblock = RulePolicy.unblock(rule, at: mondayDuringWork, calendar: utc)
#expect(didUnblock)
#expect(rule.pausedUntil == date(2025, 1, 6, 17, 0))
#expect(rule.status(at: mondayDuringWork, calendar: utc)
== .paused(until: date(2025, 1, 6, 17, 0)))
}
@Test("Unblocking a Hard Mode rule is refused and changes nothing")
func hardModeUnblockRefused() {
let rule = rule(hardMode: true)
let didUnblock = RulePolicy.unblock(rule, at: mondayDuringWork, calendar: utc)
#expect(!didUnblock)
#expect(rule.pausedUntil == nil)
#expect(rule.status(at: mondayDuringWork, calendar: utc).isActive)
}
@Test("Unblocking an inactive rule is refused")
func inactiveUnblockRefused() {
let rule = rule(hardMode: false)
#expect(!RulePolicy.unblock(rule, at: mondayEvening, calendar: utc))
#expect(rule.pausedUntil == nil)
}
}

View File

@@ -0,0 +1,121 @@
//
// RuleScheduleTests.swift
// SeveredTests
//
import Foundation
import Testing
@testable import Severed
@MainActor
@Suite("RuleSchedule window math")
struct RuleScheduleTests {
let nineToFiveWeekdays = RuleSchedule(
startMinutes: 9 * 60, endMinutes: 17 * 60, days: Weekday.weekdays
)
let overnight = RuleSchedule(
startMinutes: 22 * 60, endMinutes: 6 * 60, days: Weekday.everyDay
)
@Test("Active inside a same-day window")
func activeInsideWindow() {
let monday10am = date(2025, 1, 6, 10, 0)
let window = nineToFiveWeekdays.activeWindow(containing: monday10am, calendar: utc)
#expect(window?.start == date(2025, 1, 6, 9, 0))
#expect(window?.end == date(2025, 1, 6, 17, 0))
}
@Test("Inactive before the window starts and after it ends")
func inactiveOutsideWindow() {
#expect(!nineToFiveWeekdays.isActive(at: date(2025, 1, 6, 8, 59), calendar: utc))
#expect(!nineToFiveWeekdays.isActive(at: date(2025, 1, 6, 17, 0), calendar: utc))
}
@Test("Window start is inclusive, end is exclusive")
func boundaryInclusion() {
#expect(nineToFiveWeekdays.isActive(at: date(2025, 1, 6, 9, 0), calendar: utc))
#expect(!nineToFiveWeekdays.isActive(at: date(2025, 1, 6, 17, 0), calendar: utc))
}
@Test("Inactive on a disabled day")
func inactiveOnWeekend() {
#expect(!nineToFiveWeekdays.isActive(at: date(2025, 1, 11, 12, 0), calendar: utc))
}
@Test("Overnight window is active before and after midnight")
func overnightWindow() {
// Monday 23:30 inside Monday's 22:00 Tuesday 06:00 window.
let lateMonday = overnight.activeWindow(containing: date(2025, 1, 6, 23, 30), calendar: utc)
#expect(lateMonday?.start == date(2025, 1, 6, 22, 0))
#expect(lateMonday?.end == date(2025, 1, 7, 6, 0))
// Tuesday 02:00 still inside the window that started Monday.
let earlyTuesday = overnight.activeWindow(containing: date(2025, 1, 7, 2, 0), calendar: utc)
#expect(earlyTuesday?.start == date(2025, 1, 6, 22, 0))
#expect(earlyTuesday?.end == date(2025, 1, 7, 6, 0))
// The end boundary is exclusive: Tuesday 06:00 is outside Monday's window.
#expect(overnight.activeWindow(containing: date(2025, 1, 7, 6, 0), calendar: utc) == nil)
}
@Test("Overnight window belongs to the day it starts on")
func overnightDayOwnership() {
let mondayOnly = RuleSchedule(
startMinutes: 22 * 60, endMinutes: 6 * 60, days: [.monday]
)
// Tuesday 03:00 is inside Monday's window.
#expect(mondayOnly.isActive(at: date(2025, 1, 7, 3, 0), calendar: utc))
// Wednesday 03:00 would be Tuesday's window Tuesday is disabled.
#expect(!mondayOnly.isActive(at: date(2025, 1, 8, 3, 0), calendar: utc))
// Monday 23:00 is inside Monday's window.
#expect(mondayOnly.isActive(at: date(2025, 1, 6, 23, 0), calendar: utc))
}
@Test("Equal start and end means a 24-hour window")
func fullDayWindow() {
let allDay = RuleSchedule(startMinutes: 600, endMinutes: 600, days: [.monday])
#expect(allDay.durationMinutes == 24 * 60)
#expect(allDay.isActive(at: date(2025, 1, 7, 9, 59), calendar: utc))
#expect(!allDay.isActive(at: date(2025, 1, 7, 10, 0), calendar: utc))
}
@Test("Next start on the same day")
func nextStartSameDay() {
let next = nineToFiveWeekdays.nextStart(after: date(2025, 1, 6, 8, 0), calendar: utc)
#expect(next == date(2025, 1, 6, 9, 0))
}
@Test("Next start skips disabled days")
func nextStartSkipsWeekend() {
let next = nineToFiveWeekdays.nextStart(after: date(2025, 1, 11, 12, 0), calendar: utc)
#expect(next == date(2025, 1, 13, 9, 0))
}
@Test("Next start is strictly after the given moment")
func nextStartStrictlyAfter() {
let next = nineToFiveWeekdays.nextStart(after: date(2025, 1, 6, 9, 0), calendar: utc)
#expect(next == date(2025, 1, 7, 9, 0))
}
@Test("No days means no windows and no next start")
func emptyDays() {
let never = RuleSchedule(startMinutes: 540, endMinutes: 1020, days: [])
#expect(!never.isActive(at: date(2025, 1, 6, 10, 0), calendar: utc))
#expect(never.nextStart(after: date(2025, 1, 6, 10, 0), calendar: utc) == nil)
}
@Test("Duration handles normal and overnight windows")
func duration() {
#expect(nineToFiveWeekdays.durationMinutes == 8 * 60)
#expect(overnight.durationMinutes == 8 * 60)
}
@Test("Time labels are zero-padded 24h")
func timeLabels() {
#expect(RuleSchedule.timeLabel(forMinutes: 9 * 60) == "09:00")
#expect(RuleSchedule.timeLabel(forMinutes: 17 * 60 + 5) == "17:05")
#expect(RuleSchedule.timeLabel(forMinutes: 0) == "00:00")
#expect(nineToFiveWeekdays.timeRangeLabel == "09:00 17:00")
}
}

View File

@@ -0,0 +1,112 @@
//
// RuleStatusTests.swift
// SeveredTests
//
import Foundation
import Testing
@testable import Severed
@MainActor
@Suite("Rule status derivation and labels")
struct RuleStatusTests {
/// 09:0017:00 weekdays schedule rule, like the video's "Work Time".
func workTime(hardMode: Bool = false) -> BlockingRule {
BlockingRule(name: "Work Time", hardMode: hardMode)
}
@Test("Disabled rules report disabled regardless of the clock")
func disabled() {
let rule = workTime()
rule.isEnabled = false
#expect(rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc) == .disabled)
}
@Test("Enabled rule inside its window is active until the window ends")
func active() {
let status = workTime().status(at: date(2025, 1, 6, 10, 0), calendar: utc)
#expect(status == .active(until: date(2025, 1, 6, 17, 0)))
#expect(status.isActive)
}
@Test("Enabled rule outside its window is upcoming")
func upcoming() {
let status = workTime().status(at: date(2025, 1, 6, 18, 0), calendar: utc)
#expect(status == .upcoming(startsAt: date(2025, 1, 7, 9, 0)))
#expect(!status.isActive)
}
@Test("A rule with no days is dormant")
func dormant() {
let rule = workTime()
rule.days = []
#expect(rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc) == .dormant)
}
@Test("A paused rule reports paused until the window ends")
func paused() {
let rule = workTime()
rule.pausedUntil = date(2025, 1, 6, 17, 0)
let status = rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc)
#expect(status == .paused(until: date(2025, 1, 6, 17, 0)))
#expect(!status.isActive)
}
@Test("An expired pause no longer affects status")
func expiredPause() {
let rule = workTime()
rule.pausedUntil = date(2025, 1, 6, 9, 30)
let status = rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc)
#expect(status == .active(until: date(2025, 1, 6, 17, 0)))
}
@Test("Time-limit rules are never schedule-active")
func timeLimitNeverActive() {
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit)
let status = rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc)
#expect(!status.isActive)
if case .upcoming = status {} else {
Issue.record("Expected upcoming, got \(status)")
}
}
@Test("Active label matches the reference style: hours round up")
func activeLabel() {
// 11:28 17:00 is 5h32m; the reference app shows "6h left".
let status = workTime().status(at: date(2025, 1, 6, 11, 28), calendar: utc)
#expect(status.label(relativeTo: date(2025, 1, 6, 11, 28)) == "6h left")
}
@Test("Upcoming label matches the reference style")
func upcomingLabel() {
// Friday 11:28 Saturday 09:00 is 21h32m; the reference shows "Starts in 22h".
let weekend = BlockingRule(name: "Weekend Zen", days: Weekday.weekends)
let friday = date(2025, 1, 10, 11, 28)
#expect(weekend.status(at: friday, calendar: utc).label(relativeTo: friday) == "Starts in 22h")
}
@Test("Countdown formatting tiers", arguments: [
(30, "30m"), (59, "59m"), (60, "1h"), (90, "2h"),
(6 * 60, "6h"), (47 * 60, "47h"), (49 * 60, "2d"), (75 * 60, "3d"),
])
func countdownTiers(minutes: Int, expected: String) {
let now = date(2025, 1, 6, 0, 0)
let target = utc.date(byAdding: .minute, value: minutes, to: now)!
#expect(RuleStatus.countdown(from: now, to: target) == expected)
}
@Test("Countdown never reads below one minute")
func countdownFloor() {
let now = date(2025, 1, 6, 0, 0)
#expect(RuleStatus.countdown(from: now, to: now.addingTimeInterval(5)) == "1m")
}
@Test("Static labels")
func staticLabels() {
let now = date(2025, 1, 6, 0, 0)
#expect(RuleStatus.disabled.label(relativeTo: now) == "Disabled")
#expect(RuleStatus.dormant.label(relativeTo: now) == "No days selected")
#expect(RuleStatus.paused(until: now).label(relativeTo: now) == "Paused")
}
}

View File

@@ -1,16 +0,0 @@
//
// SeveredTests.swift
// SeveredTests
//
// Created by Brendan Chen on 2025.08.09.
//
import Testing
struct SeveredTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
}
}

View File

@@ -0,0 +1,36 @@
//
// TestSupport.swift
// SeveredTests
//
import Foundation
@testable import Severed
/// Fixed UTC gregorian calendar so schedule math is deterministic regardless
/// of the machine running the tests.
let utc: Calendar = {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC")!
return calendar
}()
/// Builds a date in the UTC test calendar. 2025-01-06 is a Monday; the tests
/// use that week (Jan 612, 2025) as their anchor.
func date(
_ year: Int, _ month: Int, _ day: Int, _ hour: Int = 0, _ minute: Int = 0
) -> Date {
utc.date(
from: DateComponents(year: year, month: month, day: day, hour: hour, minute: minute)
)!
}
/// Monday of the anchor week.
enum AnchorWeek {
static let monday = (year: 2025, month: 1, day: 6)
static let tuesday = (year: 2025, month: 1, day: 7)
static let wednesday = (year: 2025, month: 1, day: 8)
static let saturday = (year: 2025, month: 1, day: 11)
static let sunday = (year: 2025, month: 1, day: 12)
static let nextMonday = (year: 2025, month: 1, day: 13)
}

View File

@@ -0,0 +1,31 @@
//
// OnboardingUITests.swift
// SeveredUITests
//
import XCTest
final class OnboardingUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testOnboardingWalksThroughPermissionToHome() throws {
let app = XCUIApplication.launchSevered(onboardingCompleted: false)
// Welcome step.
app.staticTexts["Severed"].waitToAppear()
app.buttons["onboardingContinueButton"].waitToAppear().tap()
// Permission step: granting (mocked) lands on the Apps home screen.
app.buttons["allowScreenTimeButton"].waitToAppear().tap()
app.buttons["newRuleButton"].waitToAppear()
XCTAssertTrue(app.staticTexts["Blocked Apps"].exists)
}
func testCompletedOnboardingIsSkipped() throws {
let app = XCUIApplication.launchSevered(onboardingCompleted: true)
app.buttons["newRuleButton"].waitToAppear()
XCTAssertFalse(app.buttons["onboardingContinueButton"].exists)
}
}

View File

@@ -0,0 +1,99 @@
//
// RuleCreationUITests.swift
// SeveredUITests
//
import XCTest
final class RuleCreationUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testCreateScheduleRuleFromTypeCard() throws {
let app = XCUIApplication.launchSevered()
app.element("emptyRulesCard").waitToAppear()
app.buttons["newRuleButton"].tap()
app.staticTexts["New Rule"].waitToAppear()
app.buttons["ruleKind-schedule"].waitToAppear().tap()
// Editor opens with the schedule defaults.
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].waitToAppear().label, "In the Zone")
XCTAssertTrue(app.staticTexts["During this time"].exists)
// Hold to commit saves the rule and returns home.
app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0)
app.buttons["ruleCard-In the Zone"].waitToAppear()
XCTAssertFalse(app.element("emptyRulesCard").exists)
}
func testCreateRuleFromPreset() throws {
let app = XCUIApplication.launchSevered()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["preset-work-time"].waitToAppear().tap()
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].waitToAppear().label, "Work Time")
app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0)
app.buttons["ruleCard-Work Time"].waitToAppear()
}
func testRenameRuleInEditor() throws {
let app = XCUIApplication.launchSevered()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap()
app.buttons["renameButton"].waitToAppear().tap()
let nameField = app.textFields.firstMatch.waitToAppear()
nameField.tap()
// Clear the prefilled name, then type the new one.
let deletions = String(repeating: XCUIKeyboardKey.delete.rawValue, count: 24)
nameField.typeText(deletions + "My Focus")
app.buttons["OK"].tap()
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].label, "My Focus")
app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0)
app.buttons["ruleCard-My Focus"].waitToAppear()
}
func testDayTogglesUpdateSummary() throws {
let app = XCUIApplication.launchSevered()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear().tap()
// Default is Weekdays; enabling Saturday and Sunday makes it Every day.
XCTAssertTrue(app.staticTexts["Weekdays"].waitToAppear().exists)
app.buttons["dayToggle-1"].tap()
app.buttons["dayToggle-7"].tap()
XCTAssertTrue(app.staticTexts["Every day"].waitToAppear().exists)
}
func testCreateTimeLimitRule() throws {
let app = XCUIApplication.launchSevered()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].waitToAppear().label, "Time Keeper")
XCTAssertTrue(app.staticTexts["When I use"].exists)
XCTAssertEqual(app.staticTexts["dailyLimitStepperValue"].label, "45m")
app.steppers["dailyLimitStepper"].buttons["Increment"].tap()
XCTAssertEqual(app.staticTexts["dailyLimitStepperValue"].label, "60m")
app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0)
app.buttons["ruleCard-Time Keeper"].waitToAppear()
}
func testNewRuleSheetShowsTypesAndPresets() throws {
let app = XCUIApplication.launchSevered()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-schedule"].waitToAppear()
XCTAssertTrue(app.buttons["ruleKind-timeLimit"].exists)
XCTAssertTrue(app.buttons["ruleKind-openLimit"].exists)
XCTAssertTrue(app.staticTexts["Get More Done"].exists)
XCTAssertTrue(app.buttons["preset-work-time"].exists)
XCTAssertTrue(app.buttons["preset-laser-focus"].exists)
}
}

View File

@@ -0,0 +1,128 @@
//
// RuleManagementUITests.swift
// SeveredUITests
//
import XCTest
/// Detail sheet, editing, disabling, deleting, and unblocking seeded with
/// one actively-blocking rule ("Work Time") and one upcoming rule ("Sleep").
final class RuleManagementUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testDetailShowsLiveStatusAndFacts() throws {
let app = XCUIApplication.launchSevered(seedScenario: "standard")
app.buttons["ruleCard-Work Time"].waitToAppear().tap()
XCTAssertEqual(app.staticTexts["detailRuleName"].waitToAppear().label, "Work Time")
XCTAssertTrue(app.staticTexts["detailStatusLabel"].label.contains("left"))
app.element("detailRow-During this time").waitToAppear()
XCTAssertTrue(app.element("detailRow-On these days").exists)
XCTAssertTrue(app.element("detailRow-Unblocks allowed").exists)
app.buttons["editRuleButton"].waitToAppear()
app.buttons["closeDetailButton"].tap()
app.buttons["newRuleButton"].waitToAppear()
}
func testEditRuleTogglesHardModeOn() throws {
let app = XCUIApplication.launchSevered(seedScenario: "standard")
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
app.buttons["editRuleButton"].waitToAppear().tap()
app.switches["hardModeToggle"].waitToAppear().tap()
app.buttons["doneButton"].tap()
// Back on the detail view, unblocks are no longer allowed.
let row = app.element("detailRow-Unblocks allowed").waitToAppear()
XCTAssertTrue(row.label.contains("No"), "Expected 'Unblocks allowed: No', got: \(row.label)")
}
func testDisableRule() throws {
let app = XCUIApplication.launchSevered(seedScenario: "standard")
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
app.buttons["editRuleButton"].waitToAppear().tap()
app.buttons["toggleEnabledButton"].waitToAppear().tap()
// The detail caption now reports the rule as disabled.
let status = app.staticTexts["detailStatusLabel"].waitToAppear()
XCTAssertTrue(status.label.contains("Disabled"), "Got: \(status.label)")
app.buttons["closeDetailButton"].tap()
let cardStatus = app.staticTexts["ruleStatus-Sleep"].waitToAppear()
XCTAssertEqual(cardStatus.label, "Disabled")
}
func testDeleteRuleRemovesCard() throws {
let app = XCUIApplication.launchSevered(seedScenario: "standard")
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
app.buttons["editRuleButton"].waitToAppear().tap()
app.buttons["deleteRuleButton"].waitToAppear().tap()
app.buttons["newRuleButton"].waitToAppear()
XCTAssertFalse(
app.buttons["ruleCard-Sleep"].waitForExistence(timeout: 2),
"Deleted rule's card should disappear"
)
XCTAssertTrue(app.buttons["ruleCard-Work Time"].exists)
}
func testUnblockActiveSoftRule() throws {
let app = XCUIApplication.launchSevered(seedScenario: "standard")
// The active rule surfaces in Blocked Apps; unblocking pauses it.
app.buttons["blockedTile-Work Time"].waitToAppear().tap()
app.sheets.buttons["Unblock"].waitToAppear().tap()
app.staticTexts["nothingBlockedLabel"].waitToAppear()
XCTAssertEqual(app.staticTexts["ruleStatus-Work Time"].waitToAppear().label, "Paused")
}
}
/// Hard block behavior seeded with an actively-blocking Hard Mode rule.
final class HardModeUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testHardLockedRuleCannotBeEdited() throws {
let app = XCUIApplication.launchSevered(seedScenario: "hard-mode-active")
app.buttons["ruleCard-Locked In"].waitToAppear().tap()
// The lock notice replaces Edit Rule entirely.
app.element("hardModeLockedNotice").waitToAppear()
XCTAssertFalse(app.buttons["editRuleButton"].exists)
XCTAssertTrue(app.element("detailRow-Unblocks allowed").label.contains("No"))
}
func testHardLockedRuleCannotBeUnblocked() throws {
let app = XCUIApplication.launchSevered(seedScenario: "hard-mode-active")
app.buttons["blockedTile-Locked In"].waitToAppear().tap()
// No unblock dialog just the refusal alert.
let alert = app.alerts["Hard Mode is on"].waitToAppear()
XCTAssertTrue(alert.staticTexts["This block can't be lifted until it ends."].exists)
alert.buttons["OK"].tap()
// Still blocked.
XCTAssertTrue(app.buttons["blockedTile-Locked In"].exists)
XCTAssertFalse(app.staticTexts["nothingBlockedLabel"].exists)
}
func testSoftRuleUnblockOfferedButHardRuleRefused() throws {
let app = XCUIApplication.launchSevered(seedScenario: "standard")
app.buttons["blockedTile-Work Time"].waitToAppear().tap()
// Soft rule: the confirmation dialog appears instead of the refusal alert.
app.sheets.buttons["Unblock"].waitToAppear()
XCTAssertFalse(app.alerts["Hard Mode is on"].exists)
}
}

View File

@@ -1,41 +0,0 @@
//
// SeveredUITests.swift
// SeveredUITests
//
// Created by Brendan Chen on 2025.08.09.
//
import XCTest
final class SeveredUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}

View File

@@ -1,33 +0,0 @@
//
// SeveredUITestsLaunchTests.swift
// SeveredUITests
//
// Created by Brendan Chen on 2025.08.09.
//
import XCTest
final class SeveredUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testLaunch() throws {
let app = XCUIApplication()
app.launch()
// Insert steps here to perform after app launch but before taking a screenshot,
// such as logging into a test account or navigating somewhere in the app
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}

View File

@@ -0,0 +1,48 @@
//
// UITestSupport.swift
// SeveredUITests
//
import XCTest
extension XCUIApplication {
/// Launches the app in UI-testing mode: in-memory storage, mocked Screen
/// Time authorization, and no shield side effects.
static func launchSevered(
onboardingCompleted: Bool = true,
seedScenario: String? = nil
) -> XCUIApplication {
let app = XCUIApplication()
var arguments = ["-ui-testing"]
arguments.append(onboardingCompleted ? "-onboarding-completed" : "-onboarding-required")
if let seedScenario {
arguments.append("-seed-scenario=\(seedScenario)")
}
app.launchArguments = arguments
app.launch()
return app
}
}
extension XCUIApplication {
/// Finds an element by accessibility identifier regardless of how SwiftUI
/// exposes it (Other, StaticText, Button, ).
func element(_ identifier: String) -> XCUIElement {
descendants(matching: .any).matching(identifier: identifier).firstMatch
}
}
extension XCUIElement {
/// Asserts the element appears within the timeout, then returns it.
@discardableResult
func waitToAppear(
timeout: TimeInterval = 5, file: StaticString = #filePath, line: UInt = #line
) -> XCUIElement {
XCTAssertTrue(
waitForExistence(timeout: timeout),
"Expected \(self) to exist within \(timeout)s",
file: file, line: line
)
return self
}
}

316
docs/RULES_FEATURE_SPEC.md Normal file
View File

@@ -0,0 +1,316 @@
# Severed — "Rules" Feature Spec (derived from Opal screen recording)
Source: `ScreenRecording_06-12-2026 11-26-19_1.MP4` (iPhone, 4:29). The rules feature
appears roughly between 1:003:00 of the recording, on Opal's "My Apps" tab.
This spec describes what was observed, then maps it onto an implementation plan
for Severed.
---
## 1. Concept
A **Rule** is a recurring, automated app-blocking policy. Unlike a one-off
block/timer session, a rule re-arms itself on a schedule. Three rule types are
offered (Opal's "New Rule" sheet):
| Type | Icon | Example shown | Semantics |
|------|------|---------------|-----------|
| **Schedule** | calendar grid | "e.g. 9-5, Daily" | Block selected apps during a daily time window on chosen days |
| **Time Limit** | hourglass | "e.g. 45m/day" | After N minutes of cumulative use of selected apps per day, block them until a reset point |
| **Open Limit** | padlock | "e.g. 5 opens/day" | After N opens of selected apps per day, block them (not demoed in video; inferred from card) |
Common attributes across all types:
- **Name** — user-editable, free text (presets: "Work Time", "Weekend Zen", "Locked In", "Sleep", "Wind Down", "Deep Sleep", "Laser Focus", "Reading Time", "Gym Time"; defaults for new rules: "In the Zone" (schedule), "Time Keeper" (time limit))
- **Days of week** — 7-day toggle set, summarized as "Weekdays" / "Weekends" / "Every day" / custom
- **App selection** — apps, categories, and websites; selection mode is either **Block** (block selected) or **Allow Only** (block everything except selected)
- **Hard Mode** — boolean, PRO-gated in Opal; subtitle "No unblocks allowed". When off, the rule detail shows "Unblocks allowed: Yes"
- **Enabled/disabled** — a rule can be disabled without deleting ("Disable Rule")
Derived status (drives card/detail UI):
- **Active** → countdown to window end: green pill "6h left"
- **Inactive** → countdown to next activation: "Starts in 22h" / "Starts in 11h"
---
## 2. Screen inventory & navigation map
```
Tab bar: [Home] [My Apps] [Timer]
Apps screen (large title "Apps")
├── "Blocked Apps" section
├── "Rules >" section ──ta p "+ New"──▶ New Rule sheet
│ │ ├── tap rule-type card ─▶ Rule Editor (blank/default)
│ │ └── tap preset card ───▶ Rule Editor (pre-filled)
│ └── tap rule card ─▶ Rule Detail sheet
│ └── "Edit Rule" ─▶ Rule Editor (edit mode)
│ └── "Selected Apps >" ─▶ App Picker
└── "Apps" section (folders: Distracting / Always Allowed / Never Allowed)
```
All rules UI is presented as **sheets stacked over the Apps screen** (dimmed,
blurred background; grabber at top; circular ✕ or button top-left). Nothing
navigates by push except within the sheet stack.
---
## 3. Screens in detail
### 3.1 Apps screen ("My Apps" tab)
Dark theme throughout (near-black background, very dark green tint).
1. **Large title** "Apps".
2. **Blocked Apps** — section header; horizontal row of currently-blocked app
icons. Each icon has a lock badge overlay and a teal/green rounded-rect
outline; caption "Unblock" under the icon. Tapping unblocks (with friction
if hard mode — not demoed).
3. **Rules** — header row: "Rules " (leading, tappable to a full list,
presumably) and "**+ New**" (trailing, green tint) which opens the New Rule
sheet.
- Content: horizontally scrolling row of **rule cards** (~2 visible).
- **Rule card** anatomy (rounded ~24pt corners):
- Top: icon pair — rule-type icon (calendar) → small arrow → shield icon.
Active rule: icons in color, card tinted dark green. Inactive: greyscale.
- Middle: status — active: green capsule pill "6h left"; inactive: plain
text "Starts in 22h".
- Bottom: rule **name** (semibold), then a sub-row "Block" + tiny cluster
of the blocked app icons.
4. **Apps** — section of folder-style groups: "Distracting (4 items)" showing
a 2×2 mini icon grid, "Always Allowed", "Never Allowed (Hidden)" with an
eye-slash glyph. (Out of scope for the rules clone but shares the app
selection model.)
### 3.2 Rule Detail sheet
Presented on tapping a rule card. Partial-height card sheet.
- Top-left: circular ✕ close button.
- Centered: icon pair (rule type → shield), then caption
"`Schedule, Starts in 22h`" (type + live status), then large title
("Weekend Zen").
- **Detail rows** (single inset rounded card, label left / value right):
| Label | Example value |
|---|---|
| During this time | `09:00 12:00` |
| On these days | `Weekends` |
| Block | `[app icons] 1 App` / `3 Apps` |
| Unblocks allowed | `Yes` (hidden/`No` when Hard Mode) |
- Bottom: full-width white pill button "**✎ Edit Rule**" → morphs the sheet
into the Rule Editor in edit mode.
### 3.3 New Rule sheet
Presented from "+ New". Full-height sheet, scrollable.
- Header: ✕ left, centered title "**New Rule**".
- **Rule type row** — 3 horizontally arranged cards (Schedule / Time Limit /
Open Limit), each: glyph, bold name, example caption ("e.g. 9-5, Daily",
"e.g. 45m/day", "e.g. 5 opens/day"). Tapping opens the matching editor with
defaults.
- **Preset gallery** — vertically scrolling sections, each with a bold header
+ grey subtitle, containing a 2-up grid of photo-backed preset cards:
- **Get More Done** — "Maximize your productivity while staying sane."
- Work Time (Schedule 09:0017:00, Block, weekdays)
- Laser Focus (Schedule 14:0018:00, Block)
- **Sleep, Relax and Reset** — "Sleep better, rise refreshed."
- Wind Down (Schedule 20:0022:00, Block)
- Deep Sleep (Schedule 22:0006:00, Block)
- **Build Healthy Habits** — "Spend time on important things."
- Reading Time (Schedule ~19:4520:45, Block)
- Gym Time (Schedule 17:3018:30, Block)
- **Preset card** anatomy: full-bleed background photo, top row icon pair
(type → shield), time range caption, name, "Block" + suggested app icons,
and a circular "+" button bottom-right. Tapping anywhere opens the
Schedule editor pre-filled with the preset's name/times/days.
### 3.4 Rule Editor — Schedule type
Sheet with: back (top-left), centered **rule name** as title, ✎ pencil
button (top-right) to rename.
Sections (each an inset rounded group with a small icon + caption header):
1. **📅 During this time**
- Rows `From` / `To` with right-aligned time + stepper chevrons (`09:00 ⌃⌄`).
- A dotted vertical line with ●/○ endpoints visually links From → To.
- Tapping a row expands an inline wheel time picker (24h, demoed changing
From 22:00 → 23:00).
2. **On these days:** — trailing summary label ("Weekdays"/"Weekends"/custom);
row of 7 circular toggles `S M T W T F S`; selected = filled white circle
with black letter, unselected = dark circle.
3. **🛡 Apps are blocked**
- Row: `Selected Apps``N Apps ` — pushes the App Picker.
4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing
toggle.
5. **CTA**
- Creating: full-width gradient pill "**Hold to Commit**" — a press-and-hold
interaction (deliberate friction) that fills, then saves and dismisses to
the Apps screen where the new card appears.
- Editing existing: "**✓ Done**" pill, plus a red text button
"**⏸ Disable Rule**" beneath it.
### 3.5 Rule Editor — Time Limit type ("Time Keeper")
Same chrome (back / title / rename). Sections:
1. **⏳ When I use** — row `This App``Select ` (app selection).
2. **For this long** — subtitle "Daily"; right-aligned value with stepper
`45m ⌃⌄`.
3. **On these days:** — identical day picker as Schedule.
4. **🛡 Then block app** — row `Until` with stepper value `Tomorrow ⌃⌄`
(reset point — e.g. tomorrow/next morning).
5. **Hard Mode** toggle — same as Schedule.
6. **Hold to Commit**.
### 3.6 Rule Editor — Open Limit type
Not demoed beyond its card. Spec by analogy: "When I open [apps]" /
"More than `N opens ⌃⌄` (Daily)" / day picker / "Then block until …" /
Hard Mode / Hold to Commit.
### 3.7 App Picker (shared component — also used in onboarding & timer)
Full-height sheet:
- Header: back, centered title "**Selected**", and a circular green **✓**
confirm button top-right.
- **Segmented control**: `Block` | `Allow Only`.
- Top rows: "**+ Add App or Website**", a "Suggested" horizontal row of app
icons (one-tap add), and a "Never Allowed — 0 Apps" row with footnote
"Never allowed Apps will also be blocked".
- Hint text: *Select apps/websites, tap ">" to expand*.
- **Category list** — each row: circular checkbox (tri-state: empty /
partially-selected count / checked), emoji glyph, category name, trailing
selected-count + chevron to expand into individual apps:
`All Apps & Categories, Social, Games, Entertainment, Creativity, Education,
Health & Fitness, Information & Reading, Productivity & Finance,
Shopping & Food`.
- **Search bar** pinned near bottom (with mic). Typing surfaces app matches
and website suggestions (e.g. typing "insta" offers `instagram.com`),
letting users add arbitrary domains.
- Footer: "**N Apps Selected**" caption + white pill "**Save**" (+ "Cancel").
> Implementation note: Opal ships its own app categorization. On iOS, third
> parties cannot enumerate installed apps; the system-sanctioned route is
> `FamilyActivityPicker` (FamilyControls), which provides its own
> category/app/website UI and returns opaque tokens. **v1 of Severed should
> embed `FamilyActivityPicker`** instead of cloning Opal's custom picker, and
> keep the `Block`/`Allow Only` segmented control as our own wrapper state.
---
## 4. Behavioral spec
1. **Activation** — a Schedule rule becomes active at `From` on an enabled
day and deactivates at `To` (windows crossing midnight, e.g. 22:0006:00,
must be supported — Deep Sleep preset does this).
2. **While active** — the rule's app selection is shielded; blocked apps also
surface in the "Blocked Apps" row on the Apps screen; the card turns green
with a "Xh left" pill.
3. **Unblocking** — with Hard Mode off, the user may unblock mid-window
("Unblocks allowed: Yes"). With Hard Mode on, no unblocks until the window
ends.
4. **Time-limit rules** — accumulate usage daily across the selected apps;
on crossing the threshold, shield until the `Until` reset point
(e.g. tomorrow), then reset the budget.
5. **Disable vs delete** — "Disable Rule" pauses scheduling but keeps the
rule (card presumably shows disabled state). No delete flow was shown;
add delete via swipe/long-press or a button in the editor.
6. **Commit friction** — creating/committing a rule uses press-and-hold
("Hold to Commit"), echoing Opal's philosophy that *starting* a commitment
should be deliberate. Editing uses a plain "Done".
7. **Live countdowns** — "Starts in 22h" / "6h left" update over time
(minute granularity is fine).
---
## 5. Implementation plan for Severed
### 5.1 Frameworks & capabilities
- **FamilyControls** — `AuthorizationCenter.shared.requestAuthorization(for: .individual)`;
`FamilyActivityPicker` + `FamilyActivitySelection` (app/category/web tokens).
- **ManagedSettings** — `ManagedSettingsStore` per rule
(`ManagedSettingsStore.Name("rule-<uuid>")`); set
`store.shield.applications` / `applicationCategories` / `webDomains`.
- **DeviceActivity** — `DeviceActivityCenter.startMonitoring` with a
`DeviceActivitySchedule(intervalStart:intervalEnd:repeats:)` per rule;
a **DeviceActivityMonitor app extension** applies/removes shields in
`intervalDidStart`/`intervalDidEnd`. Time-limit rules use
`DeviceActivityEvent(applications:threshold:)` +
`eventDidReachThreshold`.
- Requires the **Family Controls entitlement** (works in dev; distribution
needs Apple approval) and an App Group to share rule data with the monitor
extension.
### 5.2 Data model (SwiftData)
Replace the template `AppListProfile` with:
```swift
enum RuleKind: String, Codable { case schedule, timeLimit, openLimit }
enum SelectionMode: String, Codable { case block, allowOnly }
@Model final class BlockingRule {
var id: UUID
var name: String
var kind: RuleKind
var isEnabled: Bool
var hardMode: Bool
var selectionMode: SelectionMode
var selectionData: Data // encoded FamilyActivitySelection
var days: [Int] // 1...7, Calendar weekday numbers
// schedule
var startMinutes: Int // minutes from midnight, e.g. 540 = 09:00
var endMinutes: Int // may be < start (crosses midnight)
// time limit
var dailyLimitMinutes: Int?
var resetPoint: String? // "tomorrow" | "nextMorning"
// open limit
var maxOpens: Int?
var createdAt: Date
}
```
`FamilyActivitySelection` is `Codable` → store as `Data`. Status
("active", "starts in Xh", "Xh left") is **derived**, not stored.
### 5.3 View inventory
| View | Notes |
|---|---|
| `AppsView` (tab) | Sections: Blocked Apps, Rules carousel, (later) app folders |
| `RuleCardView` | Card per §3.1, active/inactive styling |
| `RuleDetailSheet` | §3.2, rows + Edit Rule |
| `NewRuleSheet` | §3.3, type cards + preset gallery (`RulePreset` static data) |
| `ScheduleRuleEditor` | §3.4 |
| `TimeLimitRuleEditor` | §3.5 |
| `DayOfWeekPicker` | 7 circle toggles + summary ("Weekdays"/"Weekends"/…) |
| `AppSelectionView` | wraps `FamilyActivityPicker`, Block/Allow Only segmented control |
| `HoldToCommitButton` | long-press progress fill, haptics, fires on completion |
| `RuleScheduler` (service) | translates `BlockingRule` ⇄ DeviceActivity monitoring |
| `ShieldController` (service) | applies/clears `ManagedSettingsStore` shields |
### 5.4 Suggested build order
1. Data model + Apps tab with Rules section (cards from seeded sample rules,
status derivation, detail sheet) — pure UI, no entitlements needed.
2. New Rule sheet + Schedule editor + day picker + Hold to Commit (CRUD into
SwiftData; Disable/Done editing path).
3. FamilyControls authorization + `FamilyActivityPicker` integration
("Selected Apps" row, "N Apps" counts, icon clusters via `Label(token:)`).
4. DeviceActivity monitor extension + ManagedSettings shields (real blocking,
incl. midnight-crossing windows).
5. Time Limit editor + threshold events; Open Limit last (needs shield
action extension + open counting).
6. Preset gallery content + polish (gradients, photos, haptics, live
countdown timers).
### 5.5 Out of scope (seen in video, not part of "rules")
- Onboarding flow, paywall ("You know Opal works. Make it permanent."),
Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?"
friction screen), notification nudges ("Complete Your Setup").