From 3aac2004d2066302344c10f1f221bb14c8cecac6 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 12 Jun 2026 12:35:52 -0400 Subject: [PATCH] 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. --- .gitignore | 11 + Severed.xcodeproj/project.pbxproj | 28 +- Severed/ContentView.swift | 66 ---- Severed/Item.swift | 18 - Severed/Logic/RulePolicy.swift | 65 ++++ Severed/Logic/RuleSchedule.swift | 89 +++++ Severed/Logic/RuleStatus.swift | 73 ++++ Severed/Models/BlockingRule.swift | 91 +++++ Severed/Models/RuleDraft.swift | 84 +++++ Severed/Models/RuleKind.swift | 64 ++++ Severed/Models/RulePreset.swift | 98 ++++++ Severed/Models/Weekday.swift | 65 ++++ Severed/Services/LaunchConfiguration.swift | 45 +++ Severed/Services/RuleEnforcer.swift | 43 +++ Severed/Services/SampleRules.swift | 65 ++++ .../Services/ScreenTimeAuthorization.swift | 85 +++++ Severed/Services/ShieldController.swift | 108 ++++++ .../Severed.entitlements | 10 +- Severed/SeveredApp.swift | 49 ++- Severed/Views/Apps/AppsHomeView.swift | 203 +++++++++++ Severed/Views/Apps/RuleCardView.swift | 90 +++++ .../Views/Components/DayOfWeekPicker.swift | 61 ++++ .../Views/Components/HoldToCommitButton.swift | 54 +++ Severed/Views/Onboarding/OnboardingView.swift | 139 ++++++++ Severed/Views/RootView.swift | 32 ++ Severed/Views/Rules/AppSelectionSheet.swift | 94 ++++++ Severed/Views/Rules/NewRuleSheet.swift | 157 +++++++++ Severed/Views/Rules/RuleDetailSheet.swift | 178 ++++++++++ Severed/Views/Rules/RuleEditorView.swift | 297 ++++++++++++++++ Severed/Views/Theme.swift | 73 ++++ SeveredTests/LaunchSupportTests.swift | 97 ++++++ SeveredTests/RuleEnforcerTests.swift | 113 +++++++ SeveredTests/RuleModelTests.swift | 148 ++++++++ SeveredTests/RulePolicyTests.swift | 81 +++++ SeveredTests/RuleScheduleTests.swift | 121 +++++++ SeveredTests/RuleStatusTests.swift | 112 +++++++ SeveredTests/SeveredTests.swift | 16 - SeveredTests/TestSupport.swift | 36 ++ SeveredUITests/OnboardingUITests.swift | 31 ++ SeveredUITests/RuleCreationUITests.swift | 99 ++++++ SeveredUITests/RuleManagementUITests.swift | 128 +++++++ SeveredUITests/SeveredUITests.swift | 41 --- .../SeveredUITestsLaunchTests.swift | 33 -- SeveredUITests/UITestSupport.swift | 48 +++ docs/RULES_FEATURE_SPEC.md | 316 ++++++++++++++++++ 45 files changed, 3749 insertions(+), 206 deletions(-) create mode 100644 .gitignore delete mode 100644 Severed/ContentView.swift delete mode 100644 Severed/Item.swift create mode 100644 Severed/Logic/RulePolicy.swift create mode 100644 Severed/Logic/RuleSchedule.swift create mode 100644 Severed/Logic/RuleStatus.swift create mode 100644 Severed/Models/BlockingRule.swift create mode 100644 Severed/Models/RuleDraft.swift create mode 100644 Severed/Models/RuleKind.swift create mode 100644 Severed/Models/RulePreset.swift create mode 100644 Severed/Models/Weekday.swift create mode 100644 Severed/Services/LaunchConfiguration.swift create mode 100644 Severed/Services/RuleEnforcer.swift create mode 100644 Severed/Services/SampleRules.swift create mode 100644 Severed/Services/ScreenTimeAuthorization.swift create mode 100644 Severed/Services/ShieldController.swift rename Severed.xcodeproj/xcuserdata/bchendev.xcuserdatad/xcschemes/xcschememanagement.plist => Severed/Severed.entitlements (54%) create mode 100644 Severed/Views/Apps/AppsHomeView.swift create mode 100644 Severed/Views/Apps/RuleCardView.swift create mode 100644 Severed/Views/Components/DayOfWeekPicker.swift create mode 100644 Severed/Views/Components/HoldToCommitButton.swift create mode 100644 Severed/Views/Onboarding/OnboardingView.swift create mode 100644 Severed/Views/RootView.swift create mode 100644 Severed/Views/Rules/AppSelectionSheet.swift create mode 100644 Severed/Views/Rules/NewRuleSheet.swift create mode 100644 Severed/Views/Rules/RuleDetailSheet.swift create mode 100644 Severed/Views/Rules/RuleEditorView.swift create mode 100644 Severed/Views/Theme.swift create mode 100644 SeveredTests/LaunchSupportTests.swift create mode 100644 SeveredTests/RuleEnforcerTests.swift create mode 100644 SeveredTests/RuleModelTests.swift create mode 100644 SeveredTests/RulePolicyTests.swift create mode 100644 SeveredTests/RuleScheduleTests.swift create mode 100644 SeveredTests/RuleStatusTests.swift delete mode 100644 SeveredTests/SeveredTests.swift create mode 100644 SeveredTests/TestSupport.swift create mode 100644 SeveredUITests/OnboardingUITests.swift create mode 100644 SeveredUITests/RuleCreationUITests.swift create mode 100644 SeveredUITests/RuleManagementUITests.swift delete mode 100644 SeveredUITests/SeveredUITests.swift delete mode 100644 SeveredUITests/SeveredUITestsLaunchTests.swift create mode 100644 SeveredUITests/UITestSupport.swift create mode 100644 docs/RULES_FEATURE_SPEC.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11d8786 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Xcode user state +xcuserdata/ +*.xcuserstate + +# Build artifacts +DerivedData/ +build/ + +# Screen recordings / reference material +*.MP4 +*.mp4 diff --git a/Severed.xcodeproj/project.pbxproj b/Severed.xcodeproj/project.pbxproj index 9425841..7fb224d 100644 --- a/Severed.xcodeproj/project.pbxproj +++ b/Severed.xcodeproj/project.pbxproj @@ -390,6 +390,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Severed/Severed.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 4A9XHUS87Q; @@ -418,13 +419,12 @@ REGISTER_APP_GROUPS = YES; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; - XROS_DEPLOYMENT_TARGET = 26.0; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -433,6 +433,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Severed/Severed.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 4A9XHUS87Q; @@ -461,13 +462,12 @@ REGISTER_APP_GROUPS = YES; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; - XROS_DEPLOYMENT_TARGET = 26.0; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; @@ -486,11 +486,11 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = NO; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Severed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Severed"; XROS_DEPLOYMENT_TARGET = 26.0; }; @@ -511,11 +511,11 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = NO; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Severed.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Severed"; XROS_DEPLOYMENT_TARGET = 26.0; }; @@ -535,11 +535,11 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = NO; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Severed; XROS_DEPLOYMENT_TARGET = 26.0; }; @@ -559,11 +559,11 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = NO; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Severed; XROS_DEPLOYMENT_TARGET = 26.0; }; diff --git a/Severed/ContentView.swift b/Severed/ContentView.swift deleted file mode 100644 index 9f72b24..0000000 --- a/Severed/ContentView.swift +++ /dev/null @@ -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) -} diff --git a/Severed/Item.swift b/Severed/Item.swift deleted file mode 100644 index c9db1f0..0000000 --- a/Severed/Item.swift +++ /dev/null @@ -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 - } -} diff --git a/Severed/Logic/RulePolicy.swift b/Severed/Logic/RulePolicy.swift new file mode 100644 index 0000000..d6dfe53 --- /dev/null +++ b/Severed/Logic/RulePolicy.swift @@ -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 + } +} diff --git a/Severed/Logic/RuleSchedule.swift b/Severed/Logic/RuleSchedule.swift new file mode 100644 index 0000000..bb72f67 --- /dev/null +++ b/Severed/Logic/RuleSchedule.swift @@ -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 + + 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))" + } +} diff --git a/Severed/Logic/RuleStatus.swift b/Severed/Logic/RuleStatus.swift new file mode 100644 index 0000000..71403d1 --- /dev/null +++ b/Severed/Logic/RuleStatus.swift @@ -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 + } +} diff --git a/Severed/Models/BlockingRule.swift b/Severed/Models/BlockingRule.swift new file mode 100644 index 0000000..209a28f --- /dev/null +++ b/Severed/Models/BlockingRule.swift @@ -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.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 { + get { Set(dayNumbers.compactMap(Weekday.init(rawValue:))) } + set { dayNumbers = newValue.map(\.rawValue).sorted() } + } + + var schedule: RuleSchedule { + RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days) + } +} diff --git a/Severed/Models/RuleDraft.swift b/Severed/Models/RuleDraft.swift new file mode 100644 index 0000000..c1002dc --- /dev/null +++ b/Severed/Models/RuleDraft.swift @@ -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 + 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) + } +} diff --git a/Severed/Models/RuleKind.swift b/Severed/Models/RuleKind.swift new file mode 100644 index 0000000..32c4d3b --- /dev/null +++ b/Severed/Models/RuleKind.swift @@ -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" + } + } +} diff --git a/Severed/Models/RulePreset.swift b/Severed/Models/RulePreset.swift new file mode 100644 index 0000000..7140626 --- /dev/null +++ b/Severed/Models/RulePreset.swift @@ -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 + 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) + ), + ] + ), + ] +} diff --git a/Severed/Models/Weekday.swift b/Severed/Models/Weekday.swift new file mode 100644 index 0000000..c9885ab --- /dev/null +++ b/Severed/Models/Weekday.swift @@ -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 = [.monday, .tuesday, .wednesday, .thursday, .friday] + static let weekends: Set = [.saturday, .sunday] + static let everyDay: Set = 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 { + /// 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: ", ") + } +} diff --git a/Severed/Services/LaunchConfiguration.swift b/Severed/Services/LaunchConfiguration.swift new file mode 100644 index 0000000..86ec0b5 --- /dev/null +++ b/Severed/Services/LaunchConfiguration.swift @@ -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) +} diff --git a/Severed/Services/RuleEnforcer.swift b/Severed/Services/RuleEnforcer.swift new file mode 100644 index 0000000..fbd6c99 --- /dev/null +++ b/Severed/Services/RuleEnforcer.swift @@ -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 = [] + 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 = [] + 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 + } +} diff --git a/Severed/Services/SampleRules.swift b/Severed/Services/SampleRules.swift new file mode 100644 index 0000000..6411c1d --- /dev/null +++ b/Severed/Services/SampleRules.swift @@ -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) + } +} diff --git a/Severed/Services/ScreenTimeAuthorization.swift b/Severed/Services/ScreenTimeAuthorization.swift new file mode 100644 index 0000000..0eb7af4 --- /dev/null +++ b/Severed/Services/ScreenTimeAuthorization.swift @@ -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() + } +} diff --git a/Severed/Services/ShieldController.swift b/Severed/Services/ShieldController.swift new file mode 100644 index 0000000..104b575 --- /dev/null +++ b/Severed/Services/ShieldController.swift @@ -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) +} + +/// 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) { + 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 { + 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 = [] + 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) { + 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 + } +} diff --git a/Severed.xcodeproj/xcuserdata/bchendev.xcuserdatad/xcschemes/xcschememanagement.plist b/Severed/Severed.entitlements similarity index 54% rename from Severed.xcodeproj/xcuserdata/bchendev.xcuserdatad/xcschemes/xcschememanagement.plist rename to Severed/Severed.entitlements index d2e708e..e048c21 100644 --- a/Severed.xcodeproj/xcuserdata/bchendev.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Severed/Severed.entitlements @@ -2,13 +2,7 @@ - SchemeUserState - - Severed.xcscheme_^#shared#^_ - - orderHint - 0 - - + com.apple.developer.family-controls + diff --git a/Severed/SeveredApp.swift b/Severed/SeveredApp.swift index 3cca8bb..c67594a 100644 --- a/Severed/SeveredApp.swift +++ b/Severed/SeveredApp.swift @@ -5,28 +5,57 @@ // Created by Brendan Chen on 2025.08.09. // -import SwiftUI import SwiftData +import SwiftUI @main struct SeveredApp: App { - var sharedModelContainer: ModelContainer = { - let schema = Schema([ - Item.self, - ]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + private let container: ModelContainer + @State private var authorization: ScreenTimeAuthorization + @State private var enforcer: RuleEnforcer + init() { + let config = LaunchConfiguration.current + + if let onboardingCompleted = config.onboardingCompleted { + UserDefaults.standard.set(onboardingCompleted, forKey: "hasCompletedOnboarding") + } + + let schema = Schema([BlockingRule.self]) + let modelConfiguration = ModelConfiguration( + schema: schema, isStoredInMemoryOnly: config.isUITesting + ) do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) + container = try ModelContainer(for: schema, configurations: [modelConfiguration]) } catch { fatalError("Could not create ModelContainer: \(error)") } - }() + + if let scenario = config.seedScenario { + SampleRules.seed(scenario, into: container.mainContext) + } + + let authProvider: AuthorizationProviding = + config.isUITesting + ? MockAuthorizationProvider( + status: config.onboardingCompleted == false ? .notDetermined : .approved + ) + : FamilyControlsAuthorizationProvider() + _authorization = State(initialValue: ScreenTimeAuthorization(provider: authProvider)) + + let shields: ShieldApplying = + config.isUITesting ? MockShieldController() : ManagedSettingsShieldController() + _enforcer = State(initialValue: RuleEnforcer(shields: shields)) + } var body: some Scene { WindowGroup { - ContentView() + RootView() + .environment(authorization) + .environment(enforcer) + .preferredColorScheme(.dark) + .tint(Theme.accent) } - .modelContainer(sharedModelContainer) + .modelContainer(container) } } diff --git a/Severed/Views/Apps/AppsHomeView.swift b/Severed/Views/Apps/AppsHomeView.swift new file mode 100644 index 0000000..44063e1 --- /dev/null +++ b/Severed/Views/Apps/AppsHomeView.swift @@ -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())) ?? [] + enforcer.refresh(rules: allRules) + try? await Task.sleep(for: .seconds(30)) + } + } +} diff --git a/Severed/Views/Apps/RuleCardView.swift b/Severed/Views/Apps/RuleCardView.swift new file mode 100644 index 0000000..6d0dad6 --- /dev/null +++ b/Severed/Views/Apps/RuleCardView.swift @@ -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) + } +} diff --git a/Severed/Views/Components/DayOfWeekPicker.swift b/Severed/Views/Components/DayOfWeekPicker.swift new file mode 100644 index 0000000..e7fb765 --- /dev/null +++ b/Severed/Views/Components/DayOfWeekPicker.swift @@ -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 + + 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) +} diff --git a/Severed/Views/Components/HoldToCommitButton.swift b/Severed/Views/Components/HoldToCommitButton.swift new file mode 100644 index 0000000..b90f3e2 --- /dev/null +++ b/Severed/Views/Components/HoldToCommitButton.swift @@ -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) +} diff --git a/Severed/Views/Onboarding/OnboardingView.swift b/Severed/Views/Onboarding/OnboardingView.swift new file mode 100644 index 0000000..8751692 --- /dev/null +++ b/Severed/Views/Onboarding/OnboardingView.swift @@ -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) + } +} diff --git a/Severed/Views/RootView.swift b/Severed/Views/RootView.swift new file mode 100644 index 0000000..60ae78b --- /dev/null +++ b/Severed/Views/RootView.swift @@ -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() + } + } + } +} diff --git a/Severed/Views/Rules/AppSelectionSheet.swift b/Severed/Views/Rules/AppSelectionSheet.swift new file mode 100644 index 0000000..44df02a --- /dev/null +++ b/Severed/Views/Rules/AppSelectionSheet.swift @@ -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) { + 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() + } +} diff --git a/Severed/Views/Rules/NewRuleSheet.swift b/Severed/Views/Rules/NewRuleSheet.swift new file mode 100644 index 0000000..b43f5d6 --- /dev/null +++ b/Severed/Views/Rules/NewRuleSheet.swift @@ -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)") + } +} diff --git a/Severed/Views/Rules/RuleDetailSheet.swift b/Severed/Views/Rules/RuleDetailSheet.swift new file mode 100644 index 0000000..ae90fa6 --- /dev/null +++ b/Severed/Views/Rules/RuleDetailSheet.swift @@ -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") + } + } +} diff --git a/Severed/Views/Rules/RuleEditorView.swift b/Severed/Views/Rules/RuleEditorView.swift new file mode 100644 index 0000000..c86e462 --- /dev/null +++ b/Severed/Views/Rules/RuleEditorView.swift @@ -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, 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) -> Binding { + 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) + } + } +} diff --git a/Severed/Views/Theme.swift b/Severed/Views/Theme.swift new file mode 100644 index 0000000..4d25cd1 --- /dev/null +++ b/Severed/Views/Theme.swift @@ -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) + ) + } +} diff --git a/SeveredTests/LaunchSupportTests.swift b/SeveredTests/LaunchSupportTests.swift new file mode 100644 index 0000000..b186e93 --- /dev/null +++ b/SeveredTests/LaunchSupportTests.swift @@ -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) + } +} diff --git a/SeveredTests/RuleEnforcerTests.swift b/SeveredTests/RuleEnforcerTests.swift new file mode 100644 index 0000000..076b67f --- /dev/null +++ b/SeveredTests/RuleEnforcerTests.swift @@ -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) + } +} diff --git a/SeveredTests/RuleModelTests.swift b/SeveredTests/RuleModelTests.swift new file mode 100644 index 0000000..be01af0 --- /dev/null +++ b/SeveredTests/RuleModelTests.swift @@ -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()) + #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().summary == "Never") + } + + @Test("Custom sets list days in display order") + func customSets() { + let days: Set = [.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"]) + } +} diff --git a/SeveredTests/RulePolicyTests.swift b/SeveredTests/RulePolicyTests.swift new file mode 100644 index 0000000..884f21d --- /dev/null +++ b/SeveredTests/RulePolicyTests.swift @@ -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) + } +} diff --git a/SeveredTests/RuleScheduleTests.swift b/SeveredTests/RuleScheduleTests.swift new file mode 100644 index 0000000..e1d9ebd --- /dev/null +++ b/SeveredTests/RuleScheduleTests.swift @@ -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") + } +} diff --git a/SeveredTests/RuleStatusTests.swift b/SeveredTests/RuleStatusTests.swift new file mode 100644 index 0000000..c4cc676 --- /dev/null +++ b/SeveredTests/RuleStatusTests.swift @@ -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") + } +} diff --git a/SeveredTests/SeveredTests.swift b/SeveredTests/SeveredTests.swift deleted file mode 100644 index a22b3ac..0000000 --- a/SeveredTests/SeveredTests.swift +++ /dev/null @@ -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. - } - -} diff --git a/SeveredTests/TestSupport.swift b/SeveredTests/TestSupport.swift new file mode 100644 index 0000000..e63345b --- /dev/null +++ b/SeveredTests/TestSupport.swift @@ -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) +} diff --git a/SeveredUITests/OnboardingUITests.swift b/SeveredUITests/OnboardingUITests.swift new file mode 100644 index 0000000..76e0358 --- /dev/null +++ b/SeveredUITests/OnboardingUITests.swift @@ -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) + } +} diff --git a/SeveredUITests/RuleCreationUITests.swift b/SeveredUITests/RuleCreationUITests.swift new file mode 100644 index 0000000..c473308 --- /dev/null +++ b/SeveredUITests/RuleCreationUITests.swift @@ -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) + } +} diff --git a/SeveredUITests/RuleManagementUITests.swift b/SeveredUITests/RuleManagementUITests.swift new file mode 100644 index 0000000..93de54d --- /dev/null +++ b/SeveredUITests/RuleManagementUITests.swift @@ -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) + } +} diff --git a/SeveredUITests/SeveredUITests.swift b/SeveredUITests/SeveredUITests.swift deleted file mode 100644 index 1997273..0000000 --- a/SeveredUITests/SeveredUITests.swift +++ /dev/null @@ -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() - } - } -} diff --git a/SeveredUITests/SeveredUITestsLaunchTests.swift b/SeveredUITests/SeveredUITestsLaunchTests.swift deleted file mode 100644 index 1f30eca..0000000 --- a/SeveredUITests/SeveredUITestsLaunchTests.swift +++ /dev/null @@ -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) - } -} diff --git a/SeveredUITests/UITestSupport.swift b/SeveredUITests/UITestSupport.swift new file mode 100644 index 0000000..3438f38 --- /dev/null +++ b/SeveredUITests/UITestSupport.swift @@ -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 + } +} diff --git a/docs/RULES_FEATURE_SPEC.md b/docs/RULES_FEATURE_SPEC.md new file mode 100644 index 0000000..202f72a --- /dev/null +++ b/docs/RULES_FEATURE_SPEC.md @@ -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-")`); 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").