feat: clone Opal's rules feature with hard block enforcement
Rebuild the app around recurring screen-time blocking rules modeled on Opal's My Apps tab: - Onboarding with FamilyControls Screen Time authorization - Apps home with Blocked Apps tiles and live rule cards - New Rule sheet: Schedule/Time Limit/Open Limit types + preset gallery - Rule editors: time windows (incl. overnight), day picker, app selection via FamilyActivityPicker (Block/Allow Only), rename, Hold to Commit - Hard Mode: active hard rules cannot be edited, disabled, deleted, or unblocked until their window ends; soft rules pause until next window - Shield enforcement through per-rule ManagedSettingsStore + RuleEnforcer - 73 unit tests (Swift Testing) + 16 UI tests (XCUITest) with launch-arg harness for in-memory storage, mocked authorization, seeded scenarios - docs/RULES_FEATURE_SPEC.md: spec derived from the reference recording Known gap: background window transitions and time/open-limit thresholds need a DeviceActivityMonitor extension; shields currently sync while the app is running.
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Xcode user state
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
|
||||
# Build artifacts
|
||||
DerivedData/
|
||||
build/
|
||||
|
||||
# Screen recordings / reference material
|
||||
*.MP4
|
||||
*.mp4
|
||||
@@ -390,6 +390,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Severed/Severed.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 4A9XHUS87Q;
|
||||
@@ -418,13 +419,12 @@
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -433,6 +433,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Severed/Severed.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 4A9XHUS87Q;
|
||||
@@ -461,13 +462,12 @@
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
@@ -486,11 +486,11 @@
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
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";
|
||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||
};
|
||||
@@ -511,11 +511,11 @@
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
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";
|
||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||
};
|
||||
@@ -535,11 +535,11 @@
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = Severed;
|
||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||
};
|
||||
@@ -559,11 +559,11 @@
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = Severed;
|
||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
65
Severed/Logic/RulePolicy.swift
Normal file
65
Severed/Logic/RulePolicy.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// RulePolicy.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Gates every mutation of a rule. This is where Hard Mode is enforced:
|
||||
/// while a hard-mode rule is actively blocking, nothing about it can be
|
||||
/// weakened until the window ends.
|
||||
enum RulePolicy {
|
||||
/// True while the rule is actively blocking with Hard Mode on.
|
||||
static func isHardLocked(
|
||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
rule.hardMode && rule.status(at: now, calendar: calendar).isActive
|
||||
}
|
||||
|
||||
static func canEdit(
|
||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
!isHardLocked(rule, at: now, calendar: calendar)
|
||||
}
|
||||
|
||||
static func canDisable(
|
||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
!isHardLocked(rule, at: now, calendar: calendar)
|
||||
}
|
||||
|
||||
static func canDelete(
|
||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
!isHardLocked(rule, at: now, calendar: calendar)
|
||||
}
|
||||
|
||||
/// Whether the user may lift the current block early ("Unblock").
|
||||
/// Requires an active window and Hard Mode off.
|
||||
static func canUnblock(
|
||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
rule.status(at: now, calendar: calendar).isActive && !rule.hardMode
|
||||
}
|
||||
|
||||
/// Hard Mode can always be turned on, but never off while the rule is
|
||||
/// actively blocking — that is the whole point of a hard block.
|
||||
static func canTurnOffHardMode(
|
||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
!isHardLocked(rule, at: now, calendar: calendar)
|
||||
}
|
||||
|
||||
/// Pauses the rule's current window. Returns false (and changes nothing)
|
||||
/// when unblocking is not allowed.
|
||||
@discardableResult
|
||||
static func unblock(
|
||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
guard canUnblock(rule, at: now, calendar: calendar),
|
||||
let window = rule.schedule.activeWindow(containing: now, calendar: calendar)
|
||||
else { return false }
|
||||
rule.pausedUntil = window.end
|
||||
return true
|
||||
}
|
||||
}
|
||||
89
Severed/Logic/RuleSchedule.swift
Normal file
89
Severed/Logic/RuleSchedule.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// RuleSchedule.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The recurring time window of a rule, independent of any persistence.
|
||||
///
|
||||
/// A window whose end is at or before its start crosses midnight: 22:00 → 06:00
|
||||
/// starts on an enabled day and ends the following morning. `start == end`
|
||||
/// means a full 24-hour window.
|
||||
struct RuleSchedule: Hashable, Sendable {
|
||||
var startMinutes: Int
|
||||
var endMinutes: Int
|
||||
var days: Set<Weekday>
|
||||
|
||||
var crossesMidnight: Bool { endMinutes <= startMinutes }
|
||||
|
||||
var durationMinutes: Int {
|
||||
crossesMidnight
|
||||
? RuleSchedule.minutesPerDay - startMinutes + endMinutes
|
||||
: endMinutes - startMinutes
|
||||
}
|
||||
|
||||
/// The window containing `date`, if the schedule is active at that moment.
|
||||
///
|
||||
/// Checks today's window and, for midnight-crossing schedules, the window
|
||||
/// that started yesterday. The day a window *starts* on is the day that
|
||||
/// must be enabled.
|
||||
func activeWindow(containing date: Date, calendar: Calendar = .current) -> DateInterval? {
|
||||
for dayOffset in [0, -1] {
|
||||
guard
|
||||
let day = calendar.date(byAdding: .day, value: dayOffset, to: date),
|
||||
let window = window(onDayContaining: day, calendar: calendar),
|
||||
window.start <= date, date < window.end
|
||||
else { continue }
|
||||
return window
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isActive(at date: Date, calendar: Calendar = .current) -> Bool {
|
||||
activeWindow(containing: date, calendar: calendar) != nil
|
||||
}
|
||||
|
||||
/// The next moment the schedule will begin a window strictly after `date`.
|
||||
func nextStart(after date: Date, calendar: Calendar = .current) -> Date? {
|
||||
guard !days.isEmpty else { return nil }
|
||||
for dayOffset in 0...7 {
|
||||
guard
|
||||
let day = calendar.date(byAdding: .day, value: dayOffset, to: date),
|
||||
let window = window(onDayContaining: day, calendar: calendar),
|
||||
window.start > date
|
||||
else { continue }
|
||||
return window.start
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The window starting on the given day, or nil when that weekday is not enabled.
|
||||
private func window(onDayContaining day: Date, calendar: Calendar) -> DateInterval? {
|
||||
let dayStart = calendar.startOfDay(for: day)
|
||||
guard
|
||||
let weekday = Weekday(rawValue: calendar.component(.weekday, from: dayStart)),
|
||||
days.contains(weekday),
|
||||
let start = calendar.date(byAdding: .minute, value: startMinutes, to: dayStart),
|
||||
let end = calendar.date(
|
||||
byAdding: .minute, value: startMinutes + durationMinutes, to: dayStart
|
||||
)
|
||||
else { return nil }
|
||||
return DateInterval(start: start, end: end)
|
||||
}
|
||||
|
||||
private static let minutesPerDay = 24 * 60
|
||||
}
|
||||
|
||||
extension RuleSchedule {
|
||||
/// "09:00" style label for a minutes-from-midnight value, matching the reference UI.
|
||||
static func timeLabel(forMinutes minutes: Int) -> String {
|
||||
let clamped = ((minutes % (24 * 60)) + 24 * 60) % (24 * 60)
|
||||
return String(format: "%02d:%02d", clamped / 60, clamped % 60)
|
||||
}
|
||||
|
||||
/// "09:00 – 17:00" range label used by rule details and preset cards.
|
||||
var timeRangeLabel: String {
|
||||
"\(Self.timeLabel(forMinutes: startMinutes)) – \(Self.timeLabel(forMinutes: endMinutes))"
|
||||
}
|
||||
}
|
||||
73
Severed/Logic/RuleStatus.swift
Normal file
73
Severed/Logic/RuleStatus.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// RuleStatus.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The live state of a rule at a moment in time. Derived, never stored.
|
||||
enum RuleStatus: Equatable, Sendable {
|
||||
case disabled
|
||||
/// Enabled but no days selected, so it never fires.
|
||||
case dormant
|
||||
/// Currently blocking; ends at the associated date.
|
||||
case active(until: Date)
|
||||
/// The user unblocked the current window; blocking resumes at the next window.
|
||||
case paused(until: Date)
|
||||
case upcoming(startsAt: Date)
|
||||
|
||||
var isActive: Bool {
|
||||
if case .active = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Short status label shown on rule cards and detail sheets:
|
||||
/// "6h left", "Starts in 22h", "Paused", "Disabled".
|
||||
func label(relativeTo now: Date) -> String {
|
||||
switch self {
|
||||
case .disabled: "Disabled"
|
||||
case .dormant: "No days selected"
|
||||
case .paused: "Paused"
|
||||
case .active(let until): "\(Self.countdown(from: now, to: until)) left"
|
||||
case .upcoming(let start): "Starts in \(Self.countdown(from: now, to: start))"
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact countdown matching the reference app: minutes under an hour,
|
||||
/// hours (rounded up) under two days, then days.
|
||||
static func countdown(from now: Date, to target: Date) -> String {
|
||||
let minutes = max(1, Int(ceil(target.timeIntervalSince(now) / 60)))
|
||||
guard minutes >= 60 else { return "\(minutes)m" }
|
||||
let hours = (minutes + 59) / 60
|
||||
guard hours >= 48 else { return "\(hours)h" }
|
||||
return "\(hours / 24)d"
|
||||
}
|
||||
}
|
||||
|
||||
extension BlockingRule {
|
||||
/// Live status of this rule. Schedule rules derive it from their time window;
|
||||
/// time/open-limit rules report upcoming based on their enabled days only
|
||||
/// (their blocking is triggered by usage, not the clock).
|
||||
func status(at now: Date = .now, calendar: Calendar = .current) -> RuleStatus {
|
||||
guard isEnabled else { return .disabled }
|
||||
guard !days.isEmpty else { return .dormant }
|
||||
|
||||
guard kind == .schedule else {
|
||||
guard let next = schedule.nextStart(after: now, calendar: calendar) else {
|
||||
return .dormant
|
||||
}
|
||||
return .upcoming(startsAt: next)
|
||||
}
|
||||
|
||||
if let window = schedule.activeWindow(containing: now, calendar: calendar) {
|
||||
if let pausedUntil, pausedUntil > now {
|
||||
return .paused(until: min(pausedUntil, window.end))
|
||||
}
|
||||
return .active(until: window.end)
|
||||
}
|
||||
if let next = schedule.nextStart(after: now, calendar: calendar) {
|
||||
return .upcoming(startsAt: next)
|
||||
}
|
||||
return .dormant
|
||||
}
|
||||
}
|
||||
91
Severed/Models/BlockingRule.swift
Normal file
91
Severed/Models/BlockingRule.swift
Normal file
@@ -0,0 +1,91 @@
|
||||
//
|
||||
// BlockingRule.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// A recurring screen-time blocking rule.
|
||||
///
|
||||
/// Times are stored as minutes from midnight so the schedule repeats cleanly and
|
||||
/// is independent of time zones at creation. A window whose end is at or before
|
||||
/// its start (e.g. 22:00 → 06:00) crosses midnight into the following day.
|
||||
@Model
|
||||
final class BlockingRule {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var name: String
|
||||
var kindRaw: String
|
||||
var isEnabled: Bool
|
||||
/// Hard block: while the rule is active it cannot be disabled, edited, or unblocked.
|
||||
var hardMode: Bool
|
||||
var selectionModeRaw: String
|
||||
/// Encoded `FamilyActivitySelection` (opaque tokens). Nil until the user picks apps.
|
||||
var selectionData: Data?
|
||||
/// Denormalized count of selected apps/categories/domains for display.
|
||||
var selectionCount: Int
|
||||
var dayNumbers: [Int]
|
||||
var startMinutes: Int
|
||||
var endMinutes: Int
|
||||
/// Daily usage budget for `.timeLimit` rules.
|
||||
var dailyLimitMinutes: Int
|
||||
/// Daily open budget for `.openLimit` rules.
|
||||
var maxOpens: Int
|
||||
/// When set, the rule's current window is suspended (user tapped Unblock).
|
||||
/// Cleared automatically once the date passes; never set while Hard Mode is active.
|
||||
var pausedUntil: Date?
|
||||
var createdAt: Date
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
kind: RuleKind = .schedule,
|
||||
isEnabled: Bool = true,
|
||||
hardMode: Bool = false,
|
||||
selectionMode: SelectionMode = .block,
|
||||
selectionData: Data? = nil,
|
||||
selectionCount: Int = 0,
|
||||
days: Set<Weekday> = Weekday.weekdays,
|
||||
startMinutes: Int = 9 * 60,
|
||||
endMinutes: Int = 17 * 60,
|
||||
dailyLimitMinutes: Int = 45,
|
||||
maxOpens: Int = 5,
|
||||
pausedUntil: Date? = nil,
|
||||
createdAt: Date = .now
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.kindRaw = kind.rawValue
|
||||
self.isEnabled = isEnabled
|
||||
self.hardMode = hardMode
|
||||
self.selectionModeRaw = selectionMode.rawValue
|
||||
self.selectionData = selectionData
|
||||
self.selectionCount = selectionCount
|
||||
self.dayNumbers = days.map(\.rawValue).sorted()
|
||||
self.startMinutes = startMinutes
|
||||
self.endMinutes = endMinutes
|
||||
self.dailyLimitMinutes = dailyLimitMinutes
|
||||
self.maxOpens = maxOpens
|
||||
self.pausedUntil = pausedUntil
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
var kind: RuleKind {
|
||||
get { RuleKind(rawValue: kindRaw) ?? .schedule }
|
||||
set { kindRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var selectionMode: SelectionMode {
|
||||
get { SelectionMode(rawValue: selectionModeRaw) ?? .block }
|
||||
set { selectionModeRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var days: Set<Weekday> {
|
||||
get { Set(dayNumbers.compactMap(Weekday.init(rawValue:))) }
|
||||
set { dayNumbers = newValue.map(\.rawValue).sorted() }
|
||||
}
|
||||
|
||||
var schedule: RuleSchedule {
|
||||
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
|
||||
}
|
||||
}
|
||||
84
Severed/Models/RuleDraft.swift
Normal file
84
Severed/Models/RuleDraft.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// RuleDraft.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Value-type working copy of a rule used by the editors, so cancelling an
|
||||
/// edit never touches the persisted model.
|
||||
struct RuleDraft: Equatable {
|
||||
var name: String
|
||||
var kind: RuleKind
|
||||
var days: Set<Weekday>
|
||||
var startMinutes: Int
|
||||
var endMinutes: Int
|
||||
var dailyLimitMinutes: Int
|
||||
var maxOpens: Int
|
||||
var hardMode: Bool
|
||||
var selectionMode: SelectionMode
|
||||
var selectionData: Data?
|
||||
var selectionCount: Int
|
||||
|
||||
/// A fresh draft for a new rule of the given kind, using the reference
|
||||
/// app's defaults (9–5 weekdays schedule, 45m/day, 5 opens/day).
|
||||
init(kind: RuleKind) {
|
||||
self.name = kind.defaultRuleName
|
||||
self.kind = kind
|
||||
self.days = Weekday.weekdays
|
||||
self.startMinutes = 9 * 60
|
||||
self.endMinutes = 17 * 60
|
||||
self.dailyLimitMinutes = 45
|
||||
self.maxOpens = 5
|
||||
self.hardMode = false
|
||||
self.selectionMode = .block
|
||||
self.selectionData = nil
|
||||
self.selectionCount = 0
|
||||
}
|
||||
|
||||
init(rule: BlockingRule) {
|
||||
self.name = rule.name
|
||||
self.kind = rule.kind
|
||||
self.days = rule.days
|
||||
self.startMinutes = rule.startMinutes
|
||||
self.endMinutes = rule.endMinutes
|
||||
self.dailyLimitMinutes = rule.dailyLimitMinutes
|
||||
self.maxOpens = rule.maxOpens
|
||||
self.hardMode = rule.hardMode
|
||||
self.selectionMode = rule.selectionMode
|
||||
self.selectionData = rule.selectionData
|
||||
self.selectionCount = rule.selectionCount
|
||||
}
|
||||
|
||||
init(preset: RulePreset) {
|
||||
self.init(kind: .schedule)
|
||||
self.name = preset.name
|
||||
self.days = preset.days
|
||||
self.startMinutes = preset.startMinutes
|
||||
self.endMinutes = preset.endMinutes
|
||||
}
|
||||
|
||||
func apply(to rule: BlockingRule) {
|
||||
rule.name = name
|
||||
rule.kind = kind
|
||||
rule.days = days
|
||||
rule.startMinutes = startMinutes
|
||||
rule.endMinutes = endMinutes
|
||||
rule.dailyLimitMinutes = dailyLimitMinutes
|
||||
rule.maxOpens = maxOpens
|
||||
rule.hardMode = hardMode
|
||||
rule.selectionMode = selectionMode
|
||||
rule.selectionData = selectionData
|
||||
rule.selectionCount = selectionCount
|
||||
}
|
||||
|
||||
func makeRule() -> BlockingRule {
|
||||
let rule = BlockingRule(name: name, kind: kind)
|
||||
apply(to: rule)
|
||||
return rule
|
||||
}
|
||||
|
||||
var schedule: RuleSchedule {
|
||||
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
|
||||
}
|
||||
}
|
||||
64
Severed/Models/RuleKind.swift
Normal file
64
Severed/Models/RuleKind.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// RuleKind.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The three kinds of blocking rules, mirroring the reference app's "New Rule" sheet.
|
||||
enum RuleKind: String, Codable, CaseIterable, Sendable {
|
||||
/// Block selected apps during a recurring time window.
|
||||
case schedule
|
||||
/// Block selected apps after a daily usage budget is spent.
|
||||
case timeLimit
|
||||
/// Block selected apps after a number of opens per day.
|
||||
case openLimit
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .schedule: "Schedule"
|
||||
case .timeLimit: "Time Limit"
|
||||
case .openLimit: "Open Limit"
|
||||
}
|
||||
}
|
||||
|
||||
var exampleText: String {
|
||||
switch self {
|
||||
case .schedule: "e.g. 9-5, Daily"
|
||||
case .timeLimit: "e.g. 45m/day"
|
||||
case .openLimit: "e.g. 5 opens/day"
|
||||
}
|
||||
}
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .schedule: "calendar"
|
||||
case .timeLimit: "hourglass"
|
||||
case .openLimit: "lock.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// Default name given to a brand-new rule of this kind (Opal: "In the Zone", "Time Keeper").
|
||||
var defaultRuleName: String {
|
||||
switch self {
|
||||
case .schedule: "In the Zone"
|
||||
case .timeLimit: "Time Keeper"
|
||||
case .openLimit: "Gate Keeper"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the rule's app selection is interpreted.
|
||||
enum SelectionMode: String, Codable, CaseIterable, Sendable {
|
||||
/// Block the selected apps; everything else stays available.
|
||||
case block
|
||||
/// Block everything except the selected apps.
|
||||
case allowOnly
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .block: "Block"
|
||||
case .allowOnly: "Allow Only"
|
||||
}
|
||||
}
|
||||
}
|
||||
98
Severed/Models/RulePreset.swift
Normal file
98
Severed/Models/RulePreset.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// RulePreset.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A suggested schedule rule shown in the New Rule sheet's preset gallery,
|
||||
/// mirroring the reference app's sections and timings.
|
||||
struct RulePreset: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let startMinutes: Int
|
||||
let endMinutes: Int
|
||||
let days: Set<Weekday>
|
||||
let symbolName: String
|
||||
/// Gradient stand-in for the reference app's photo backgrounds.
|
||||
let gradientTop: Color
|
||||
let gradientBottom: Color
|
||||
|
||||
var schedule: RuleSchedule {
|
||||
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
|
||||
}
|
||||
}
|
||||
|
||||
/// A titled group of presets ("Get More Done", "Sleep, Relax and Reset", …).
|
||||
struct RulePresetSection: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let presets: [RulePreset]
|
||||
|
||||
static let all: [RulePresetSection] = [
|
||||
RulePresetSection(
|
||||
id: "productivity",
|
||||
title: "Get More Done",
|
||||
subtitle: "Maximize your productivity while staying sane.",
|
||||
presets: [
|
||||
RulePreset(
|
||||
id: "work-time", name: "Work Time",
|
||||
startMinutes: 9 * 60, endMinutes: 17 * 60, days: Weekday.weekdays,
|
||||
symbolName: "briefcase.fill",
|
||||
gradientTop: Color(red: 0.16, green: 0.27, blue: 0.22),
|
||||
gradientBottom: Color(red: 0.05, green: 0.10, blue: 0.08)
|
||||
),
|
||||
RulePreset(
|
||||
id: "laser-focus", name: "Laser Focus",
|
||||
startMinutes: 14 * 60, endMinutes: 18 * 60, days: Weekday.weekdays,
|
||||
symbolName: "scope",
|
||||
gradientTop: Color(red: 0.13, green: 0.20, blue: 0.33),
|
||||
gradientBottom: Color(red: 0.04, green: 0.06, blue: 0.12)
|
||||
),
|
||||
]
|
||||
),
|
||||
RulePresetSection(
|
||||
id: "sleep",
|
||||
title: "Sleep, Relax and Reset",
|
||||
subtitle: "Sleep better, rise refreshed.",
|
||||
presets: [
|
||||
RulePreset(
|
||||
id: "wind-down", name: "Wind Down",
|
||||
startMinutes: 20 * 60, endMinutes: 22 * 60, days: Weekday.everyDay,
|
||||
symbolName: "moon.haze.fill",
|
||||
gradientTop: Color(red: 0.25, green: 0.18, blue: 0.33),
|
||||
gradientBottom: Color(red: 0.08, green: 0.05, blue: 0.12)
|
||||
),
|
||||
RulePreset(
|
||||
id: "deep-sleep", name: "Deep Sleep",
|
||||
startMinutes: 22 * 60, endMinutes: 6 * 60, days: Weekday.everyDay,
|
||||
symbolName: "moon.zzz.fill",
|
||||
gradientTop: Color(red: 0.10, green: 0.13, blue: 0.30),
|
||||
gradientBottom: Color(red: 0.02, green: 0.03, blue: 0.10)
|
||||
),
|
||||
]
|
||||
),
|
||||
RulePresetSection(
|
||||
id: "habits",
|
||||
title: "Build Healthy Habits",
|
||||
subtitle: "Spend time on important things.",
|
||||
presets: [
|
||||
RulePreset(
|
||||
id: "reading-time", name: "Reading Time",
|
||||
startMinutes: 19 * 60 + 45, endMinutes: 20 * 60 + 45, days: Weekday.everyDay,
|
||||
symbolName: "book.fill",
|
||||
gradientTop: Color(red: 0.33, green: 0.23, blue: 0.13),
|
||||
gradientBottom: Color(red: 0.12, green: 0.07, blue: 0.03)
|
||||
),
|
||||
RulePreset(
|
||||
id: "gym-time", name: "Gym Time",
|
||||
startMinutes: 17 * 60 + 30, endMinutes: 18 * 60 + 30, days: Weekday.everyDay,
|
||||
symbolName: "figure.run",
|
||||
gradientTop: Color(red: 0.13, green: 0.30, blue: 0.30),
|
||||
gradientBottom: Color(red: 0.03, green: 0.10, blue: 0.10)
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
}
|
||||
65
Severed/Models/Weekday.swift
Normal file
65
Severed/Models/Weekday.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// Weekday.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A day of the week, using `Calendar` weekday numbering (1 = Sunday … 7 = Saturday).
|
||||
enum Weekday: Int, CaseIterable, Codable, Hashable, Sendable {
|
||||
case sunday = 1
|
||||
case monday = 2
|
||||
case tuesday = 3
|
||||
case wednesday = 4
|
||||
case thursday = 5
|
||||
case friday = 6
|
||||
case saturday = 7
|
||||
|
||||
static let weekdays: Set<Weekday> = [.monday, .tuesday, .wednesday, .thursday, .friday]
|
||||
static let weekends: Set<Weekday> = [.saturday, .sunday]
|
||||
static let everyDay: Set<Weekday> = Set(Weekday.allCases)
|
||||
|
||||
/// Display order used by the day picker, matching the reference UI: S M T W T F S.
|
||||
static let displayOrder: [Weekday] = [
|
||||
.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday,
|
||||
]
|
||||
|
||||
/// Single-letter label for the circular day toggles.
|
||||
var shortLabel: String {
|
||||
switch self {
|
||||
case .sunday, .saturday: "S"
|
||||
case .monday: "M"
|
||||
case .tuesday, .thursday: "T"
|
||||
case .wednesday: "W"
|
||||
case .friday: "F"
|
||||
}
|
||||
}
|
||||
|
||||
/// Three-letter abbreviation used in custom day summaries ("Mon, Wed, Fri").
|
||||
var abbreviation: String {
|
||||
switch self {
|
||||
case .sunday: "Sun"
|
||||
case .monday: "Mon"
|
||||
case .tuesday: "Tue"
|
||||
case .wednesday: "Wed"
|
||||
case .thursday: "Thu"
|
||||
case .friday: "Fri"
|
||||
case .saturday: "Sat"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Set<Weekday> {
|
||||
/// Human-readable summary shown next to the day picker and in rule details:
|
||||
/// "Weekdays", "Weekends", "Every day", "Never", or a list like "Mon, Wed, Fri".
|
||||
var summary: String {
|
||||
if self == Weekday.everyDay { return "Every day" }
|
||||
if self == Weekday.weekdays { return "Weekdays" }
|
||||
if self == Weekday.weekends { return "Weekends" }
|
||||
if isEmpty { return "Never" }
|
||||
return Weekday.displayOrder
|
||||
.filter(contains)
|
||||
.map(\.abbreviation)
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
45
Severed/Services/LaunchConfiguration.swift
Normal file
45
Severed/Services/LaunchConfiguration.swift
Normal 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)
|
||||
}
|
||||
43
Severed/Services/RuleEnforcer.swift
Normal file
43
Severed/Services/RuleEnforcer.swift
Normal 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
|
||||
}
|
||||
}
|
||||
65
Severed/Services/SampleRules.swift
Normal file
65
Severed/Services/SampleRules.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
85
Severed/Services/ScreenTimeAuthorization.swift
Normal file
85
Severed/Services/ScreenTimeAuthorization.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
108
Severed/Services/ShieldController.swift
Normal file
108
Severed/Services/ShieldController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,7 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>Severed.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>com.apple.developer.family-controls</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -5,28 +5,57 @@
|
||||
// Created by Brendan Chen on 2025.08.09.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct SeveredApp: App {
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
let schema = Schema([
|
||||
Item.self,
|
||||
])
|
||||
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||
private let container: ModelContainer
|
||||
@State private var authorization: ScreenTimeAuthorization
|
||||
@State private var enforcer: RuleEnforcer
|
||||
|
||||
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 {
|
||||
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||
container = try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||
} catch {
|
||||
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 {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
RootView()
|
||||
.environment(authorization)
|
||||
.environment(enforcer)
|
||||
.preferredColorScheme(.dark)
|
||||
.tint(Theme.accent)
|
||||
}
|
||||
.modelContainer(sharedModelContainer)
|
||||
.modelContainer(container)
|
||||
}
|
||||
}
|
||||
|
||||
203
Severed/Views/Apps/AppsHomeView.swift
Normal file
203
Severed/Views/Apps/AppsHomeView.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
90
Severed/Views/Apps/RuleCardView.swift
Normal file
90
Severed/Views/Apps/RuleCardView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
61
Severed/Views/Components/DayOfWeekPicker.swift
Normal file
61
Severed/Views/Components/DayOfWeekPicker.swift
Normal 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)
|
||||
}
|
||||
54
Severed/Views/Components/HoldToCommitButton.swift
Normal file
54
Severed/Views/Components/HoldToCommitButton.swift
Normal 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)
|
||||
}
|
||||
139
Severed/Views/Onboarding/OnboardingView.swift
Normal file
139
Severed/Views/Onboarding/OnboardingView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
32
Severed/Views/RootView.swift
Normal file
32
Severed/Views/RootView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
94
Severed/Views/Rules/AppSelectionSheet.swift
Normal file
94
Severed/Views/Rules/AppSelectionSheet.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
157
Severed/Views/Rules/NewRuleSheet.swift
Normal file
157
Severed/Views/Rules/NewRuleSheet.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
178
Severed/Views/Rules/RuleDetailSheet.swift
Normal file
178
Severed/Views/Rules/RuleDetailSheet.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
297
Severed/Views/Rules/RuleEditorView.swift
Normal file
297
Severed/Views/Rules/RuleEditorView.swift
Normal 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
73
Severed/Views/Theme.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
97
SeveredTests/LaunchSupportTests.swift
Normal file
97
SeveredTests/LaunchSupportTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
113
SeveredTests/RuleEnforcerTests.swift
Normal file
113
SeveredTests/RuleEnforcerTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
148
SeveredTests/RuleModelTests.swift
Normal file
148
SeveredTests/RuleModelTests.swift
Normal 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"])
|
||||
}
|
||||
}
|
||||
81
SeveredTests/RulePolicyTests.swift
Normal file
81
SeveredTests/RulePolicyTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
121
SeveredTests/RuleScheduleTests.swift
Normal file
121
SeveredTests/RuleScheduleTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
112
SeveredTests/RuleStatusTests.swift
Normal file
112
SeveredTests/RuleStatusTests.swift
Normal 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:00–17: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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
36
SeveredTests/TestSupport.swift
Normal file
36
SeveredTests/TestSupport.swift
Normal 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 6–12, 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)
|
||||
}
|
||||
31
SeveredUITests/OnboardingUITests.swift
Normal file
31
SeveredUITests/OnboardingUITests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
99
SeveredUITests/RuleCreationUITests.swift
Normal file
99
SeveredUITests/RuleCreationUITests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
128
SeveredUITests/RuleManagementUITests.swift
Normal file
128
SeveredUITests/RuleManagementUITests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 it’s 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
48
SeveredUITests/UITestSupport.swift
Normal file
48
SeveredUITests/UITestSupport.swift
Normal 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
316
docs/RULES_FEATURE_SPEC.md
Normal 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:00–3: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:00–17:00, Block, weekdays)
|
||||
- Laser Focus (Schedule 14:00–18:00, Block)
|
||||
- **Sleep, Relax and Reset** — "Sleep better, rise refreshed."
|
||||
- Wind Down (Schedule 20:00–22:00, Block)
|
||||
- Deep Sleep (Schedule 22:00–06:00, Block)
|
||||
- **Build Healthy Habits** — "Spend time on important things."
|
||||
- Reading Time (Schedule ~19:45–20:45, Block)
|
||||
- Gym Time (Schedule 17:30–18: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:00–06: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").
|
||||
Reference in New Issue
Block a user