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 = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Severed/Severed.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 4A9XHUS87Q;
|
DEVELOPMENT_TEAM = 4A9XHUS87Q;
|
||||||
@@ -418,13 +419,12 @@
|
|||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -433,6 +433,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Severed/Severed.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 4A9XHUS87Q;
|
DEVELOPMENT_TEAM = 4A9XHUS87Q;
|
||||||
@@ -461,13 +462,12 @@
|
|||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@@ -486,11 +486,11 @@
|
|||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Severed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Severed";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Severed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Severed";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
@@ -511,11 +511,11 @@
|
|||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Severed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Severed";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Severed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Severed";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
@@ -535,11 +535,11 @@
|
|||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_TARGET_NAME = Severed;
|
TEST_TARGET_NAME = Severed;
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
@@ -559,11 +559,11 @@
|
|||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_TARGET_NAME = Severed;
|
TEST_TARGET_NAME = Severed;
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>SchemeUserState</key>
|
<key>com.apple.developer.family-controls</key>
|
||||||
<dict>
|
<true/>
|
||||||
<key>Severed.xcscheme_^#shared#^_</key>
|
|
||||||
<dict>
|
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>0</integer>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -5,28 +5,57 @@
|
|||||||
// Created by Brendan Chen on 2025.08.09.
|
// Created by Brendan Chen on 2025.08.09.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct SeveredApp: App {
|
struct SeveredApp: App {
|
||||||
var sharedModelContainer: ModelContainer = {
|
private let container: ModelContainer
|
||||||
let schema = Schema([
|
@State private var authorization: ScreenTimeAuthorization
|
||||||
Item.self,
|
@State private var enforcer: RuleEnforcer
|
||||||
])
|
|
||||||
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let config = LaunchConfiguration.current
|
||||||
|
|
||||||
|
if let onboardingCompleted = config.onboardingCompleted {
|
||||||
|
UserDefaults.standard.set(onboardingCompleted, forKey: "hasCompletedOnboarding")
|
||||||
|
}
|
||||||
|
|
||||||
|
let schema = Schema([BlockingRule.self])
|
||||||
|
let modelConfiguration = ModelConfiguration(
|
||||||
|
schema: schema, isStoredInMemoryOnly: config.isUITesting
|
||||||
|
)
|
||||||
do {
|
do {
|
||||||
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
container = try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||||
} catch {
|
} catch {
|
||||||
fatalError("Could not create ModelContainer: \(error)")
|
fatalError("Could not create ModelContainer: \(error)")
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
if let scenario = config.seedScenario {
|
||||||
|
SampleRules.seed(scenario, into: container.mainContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
let authProvider: AuthorizationProviding =
|
||||||
|
config.isUITesting
|
||||||
|
? MockAuthorizationProvider(
|
||||||
|
status: config.onboardingCompleted == false ? .notDetermined : .approved
|
||||||
|
)
|
||||||
|
: FamilyControlsAuthorizationProvider()
|
||||||
|
_authorization = State(initialValue: ScreenTimeAuthorization(provider: authProvider))
|
||||||
|
|
||||||
|
let shields: ShieldApplying =
|
||||||
|
config.isUITesting ? MockShieldController() : ManagedSettingsShieldController()
|
||||||
|
_enforcer = State(initialValue: RuleEnforcer(shields: shields))
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
RootView()
|
||||||
|
.environment(authorization)
|
||||||
|
.environment(enforcer)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
.tint(Theme.accent)
|
||||||
}
|
}
|
||||||
.modelContainer(sharedModelContainer)
|
.modelContainer(container)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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