feat: clone Opal's rules feature with hard block enforcement

Rebuild the app around recurring screen-time blocking rules modeled on
Opal's My Apps tab:

- Onboarding with FamilyControls Screen Time authorization
- Apps home with Blocked Apps tiles and live rule cards
- New Rule sheet: Schedule/Time Limit/Open Limit types + preset gallery
- Rule editors: time windows (incl. overnight), day picker, app selection
  via FamilyActivityPicker (Block/Allow Only), rename, Hold to Commit
- Hard Mode: active hard rules cannot be edited, disabled, deleted, or
  unblocked until their window ends; soft rules pause until next window
- Shield enforcement through per-rule ManagedSettingsStore + RuleEnforcer
- 73 unit tests (Swift Testing) + 16 UI tests (XCUITest) with launch-arg
  harness for in-memory storage, mocked authorization, seeded scenarios
- docs/RULES_FEATURE_SPEC.md: spec derived from the reference recording

Known gap: background window transitions and time/open-limit thresholds
need a DeviceActivityMonitor extension; shields currently sync while the
app is running.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-12 12:35:52 -04:00
parent 0c03e35ea9
commit e6c87baeba
45 changed files with 3749 additions and 206 deletions

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
//
// RulePolicy.swift
// Severed
//
import Foundation
/// Gates every mutation of a rule. This is where Hard Mode is enforced:
/// while a hard-mode rule is actively blocking, nothing about it can be
/// weakened until the window ends.
enum RulePolicy {
/// True while the rule is actively blocking with Hard Mode on.
static func isHardLocked(
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
) -> Bool {
rule.hardMode && rule.status(at: now, calendar: calendar).isActive
}
static func canEdit(
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
) -> Bool {
!isHardLocked(rule, at: now, calendar: calendar)
}
static func canDisable(
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
) -> Bool {
!isHardLocked(rule, at: now, calendar: calendar)
}
static func canDelete(
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
) -> Bool {
!isHardLocked(rule, at: now, calendar: calendar)
}
/// Whether the user may lift the current block early ("Unblock").
/// Requires an active window and Hard Mode off.
static func canUnblock(
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
) -> Bool {
rule.status(at: now, calendar: calendar).isActive && !rule.hardMode
}
/// Hard Mode can always be turned on, but never off while the rule is
/// actively blocking that is the whole point of a hard block.
static func canTurnOffHardMode(
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
) -> Bool {
!isHardLocked(rule, at: now, calendar: calendar)
}
/// Pauses the rule's current window. Returns false (and changes nothing)
/// when unblocking is not allowed.
@discardableResult
static func unblock(
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
) -> Bool {
guard canUnblock(rule, at: now, calendar: calendar),
let window = rule.schedule.activeWindow(containing: now, calendar: calendar)
else { return false }
rule.pausedUntil = window.end
return true
}
}

View File

@@ -0,0 +1,89 @@
//
// RuleSchedule.swift
// Severed
//
import Foundation
/// The recurring time window of a rule, independent of any persistence.
///
/// A window whose end is at or before its start crosses midnight: 22:00 06:00
/// starts on an enabled day and ends the following morning. `start == end`
/// means a full 24-hour window.
struct RuleSchedule: Hashable, Sendable {
var startMinutes: Int
var endMinutes: Int
var days: Set<Weekday>
var crossesMidnight: Bool { endMinutes <= startMinutes }
var durationMinutes: Int {
crossesMidnight
? RuleSchedule.minutesPerDay - startMinutes + endMinutes
: endMinutes - startMinutes
}
/// The window containing `date`, if the schedule is active at that moment.
///
/// Checks today's window and, for midnight-crossing schedules, the window
/// that started yesterday. The day a window *starts* on is the day that
/// must be enabled.
func activeWindow(containing date: Date, calendar: Calendar = .current) -> DateInterval? {
for dayOffset in [0, -1] {
guard
let day = calendar.date(byAdding: .day, value: dayOffset, to: date),
let window = window(onDayContaining: day, calendar: calendar),
window.start <= date, date < window.end
else { continue }
return window
}
return nil
}
func isActive(at date: Date, calendar: Calendar = .current) -> Bool {
activeWindow(containing: date, calendar: calendar) != nil
}
/// The next moment the schedule will begin a window strictly after `date`.
func nextStart(after date: Date, calendar: Calendar = .current) -> Date? {
guard !days.isEmpty else { return nil }
for dayOffset in 0...7 {
guard
let day = calendar.date(byAdding: .day, value: dayOffset, to: date),
let window = window(onDayContaining: day, calendar: calendar),
window.start > date
else { continue }
return window.start
}
return nil
}
/// The window starting on the given day, or nil when that weekday is not enabled.
private func window(onDayContaining day: Date, calendar: Calendar) -> DateInterval? {
let dayStart = calendar.startOfDay(for: day)
guard
let weekday = Weekday(rawValue: calendar.component(.weekday, from: dayStart)),
days.contains(weekday),
let start = calendar.date(byAdding: .minute, value: startMinutes, to: dayStart),
let end = calendar.date(
byAdding: .minute, value: startMinutes + durationMinutes, to: dayStart
)
else { return nil }
return DateInterval(start: start, end: end)
}
private static let minutesPerDay = 24 * 60
}
extension RuleSchedule {
/// "09:00" style label for a minutes-from-midnight value, matching the reference UI.
static func timeLabel(forMinutes minutes: Int) -> String {
let clamped = ((minutes % (24 * 60)) + 24 * 60) % (24 * 60)
return String(format: "%02d:%02d", clamped / 60, clamped % 60)
}
/// "09:00 17:00" range label used by rule details and preset cards.
var timeRangeLabel: String {
"\(Self.timeLabel(forMinutes: startMinutes)) \(Self.timeLabel(forMinutes: endMinutes))"
}
}

View File

@@ -0,0 +1,73 @@
//
// RuleStatus.swift
// Severed
//
import Foundation
/// The live state of a rule at a moment in time. Derived, never stored.
enum RuleStatus: Equatable, Sendable {
case disabled
/// Enabled but no days selected, so it never fires.
case dormant
/// Currently blocking; ends at the associated date.
case active(until: Date)
/// The user unblocked the current window; blocking resumes at the next window.
case paused(until: Date)
case upcoming(startsAt: Date)
var isActive: Bool {
if case .active = self { return true }
return false
}
/// Short status label shown on rule cards and detail sheets:
/// "6h left", "Starts in 22h", "Paused", "Disabled".
func label(relativeTo now: Date) -> String {
switch self {
case .disabled: "Disabled"
case .dormant: "No days selected"
case .paused: "Paused"
case .active(let until): "\(Self.countdown(from: now, to: until)) left"
case .upcoming(let start): "Starts in \(Self.countdown(from: now, to: start))"
}
}
/// Compact countdown matching the reference app: minutes under an hour,
/// hours (rounded up) under two days, then days.
static func countdown(from now: Date, to target: Date) -> String {
let minutes = max(1, Int(ceil(target.timeIntervalSince(now) / 60)))
guard minutes >= 60 else { return "\(minutes)m" }
let hours = (minutes + 59) / 60
guard hours >= 48 else { return "\(hours)h" }
return "\(hours / 24)d"
}
}
extension BlockingRule {
/// Live status of this rule. Schedule rules derive it from their time window;
/// time/open-limit rules report upcoming based on their enabled days only
/// (their blocking is triggered by usage, not the clock).
func status(at now: Date = .now, calendar: Calendar = .current) -> RuleStatus {
guard isEnabled else { return .disabled }
guard !days.isEmpty else { return .dormant }
guard kind == .schedule else {
guard let next = schedule.nextStart(after: now, calendar: calendar) else {
return .dormant
}
return .upcoming(startsAt: next)
}
if let window = schedule.activeWindow(containing: now, calendar: calendar) {
if let pausedUntil, pausedUntil > now {
return .paused(until: min(pausedUntil, window.end))
}
return .active(until: window.end)
}
if let next = schedule.nextStart(after: now, calendar: calendar) {
return .upcoming(startsAt: next)
}
return .dormant
}
}

View File

@@ -0,0 +1,91 @@
//
// BlockingRule.swift
// Severed
//
import Foundation
import SwiftData
/// A recurring screen-time blocking rule.
///
/// Times are stored as minutes from midnight so the schedule repeats cleanly and
/// is independent of time zones at creation. A window whose end is at or before
/// its start (e.g. 22:00 06:00) crosses midnight into the following day.
@Model
final class BlockingRule {
@Attribute(.unique) var id: UUID
var name: String
var kindRaw: String
var isEnabled: Bool
/// Hard block: while the rule is active it cannot be disabled, edited, or unblocked.
var hardMode: Bool
var selectionModeRaw: String
/// Encoded `FamilyActivitySelection` (opaque tokens). Nil until the user picks apps.
var selectionData: Data?
/// Denormalized count of selected apps/categories/domains for display.
var selectionCount: Int
var dayNumbers: [Int]
var startMinutes: Int
var endMinutes: Int
/// Daily usage budget for `.timeLimit` rules.
var dailyLimitMinutes: Int
/// Daily open budget for `.openLimit` rules.
var maxOpens: Int
/// When set, the rule's current window is suspended (user tapped Unblock).
/// Cleared automatically once the date passes; never set while Hard Mode is active.
var pausedUntil: Date?
var createdAt: Date
init(
id: UUID = UUID(),
name: String,
kind: RuleKind = .schedule,
isEnabled: Bool = true,
hardMode: Bool = false,
selectionMode: SelectionMode = .block,
selectionData: Data? = nil,
selectionCount: Int = 0,
days: Set<Weekday> = Weekday.weekdays,
startMinutes: Int = 9 * 60,
endMinutes: Int = 17 * 60,
dailyLimitMinutes: Int = 45,
maxOpens: Int = 5,
pausedUntil: Date? = nil,
createdAt: Date = .now
) {
self.id = id
self.name = name
self.kindRaw = kind.rawValue
self.isEnabled = isEnabled
self.hardMode = hardMode
self.selectionModeRaw = selectionMode.rawValue
self.selectionData = selectionData
self.selectionCount = selectionCount
self.dayNumbers = days.map(\.rawValue).sorted()
self.startMinutes = startMinutes
self.endMinutes = endMinutes
self.dailyLimitMinutes = dailyLimitMinutes
self.maxOpens = maxOpens
self.pausedUntil = pausedUntil
self.createdAt = createdAt
}
var kind: RuleKind {
get { RuleKind(rawValue: kindRaw) ?? .schedule }
set { kindRaw = newValue.rawValue }
}
var selectionMode: SelectionMode {
get { SelectionMode(rawValue: selectionModeRaw) ?? .block }
set { selectionModeRaw = newValue.rawValue }
}
var days: Set<Weekday> {
get { Set(dayNumbers.compactMap(Weekday.init(rawValue:))) }
set { dayNumbers = newValue.map(\.rawValue).sorted() }
}
var schedule: RuleSchedule {
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
}
}

View File

@@ -0,0 +1,84 @@
//
// RuleDraft.swift
// Severed
//
import Foundation
/// Value-type working copy of a rule used by the editors, so cancelling an
/// edit never touches the persisted model.
struct RuleDraft: Equatable {
var name: String
var kind: RuleKind
var days: Set<Weekday>
var startMinutes: Int
var endMinutes: Int
var dailyLimitMinutes: Int
var maxOpens: Int
var hardMode: Bool
var selectionMode: SelectionMode
var selectionData: Data?
var selectionCount: Int
/// A fresh draft for a new rule of the given kind, using the reference
/// app's defaults (95 weekdays schedule, 45m/day, 5 opens/day).
init(kind: RuleKind) {
self.name = kind.defaultRuleName
self.kind = kind
self.days = Weekday.weekdays
self.startMinutes = 9 * 60
self.endMinutes = 17 * 60
self.dailyLimitMinutes = 45
self.maxOpens = 5
self.hardMode = false
self.selectionMode = .block
self.selectionData = nil
self.selectionCount = 0
}
init(rule: BlockingRule) {
self.name = rule.name
self.kind = rule.kind
self.days = rule.days
self.startMinutes = rule.startMinutes
self.endMinutes = rule.endMinutes
self.dailyLimitMinutes = rule.dailyLimitMinutes
self.maxOpens = rule.maxOpens
self.hardMode = rule.hardMode
self.selectionMode = rule.selectionMode
self.selectionData = rule.selectionData
self.selectionCount = rule.selectionCount
}
init(preset: RulePreset) {
self.init(kind: .schedule)
self.name = preset.name
self.days = preset.days
self.startMinutes = preset.startMinutes
self.endMinutes = preset.endMinutes
}
func apply(to rule: BlockingRule) {
rule.name = name
rule.kind = kind
rule.days = days
rule.startMinutes = startMinutes
rule.endMinutes = endMinutes
rule.dailyLimitMinutes = dailyLimitMinutes
rule.maxOpens = maxOpens
rule.hardMode = hardMode
rule.selectionMode = selectionMode
rule.selectionData = selectionData
rule.selectionCount = selectionCount
}
func makeRule() -> BlockingRule {
let rule = BlockingRule(name: name, kind: kind)
apply(to: rule)
return rule
}
var schedule: RuleSchedule {
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
}
}

View File

@@ -0,0 +1,64 @@
//
// RuleKind.swift
// Severed
//
import Foundation
/// The three kinds of blocking rules, mirroring the reference app's "New Rule" sheet.
enum RuleKind: String, Codable, CaseIterable, Sendable {
/// Block selected apps during a recurring time window.
case schedule
/// Block selected apps after a daily usage budget is spent.
case timeLimit
/// Block selected apps after a number of opens per day.
case openLimit
var displayName: String {
switch self {
case .schedule: "Schedule"
case .timeLimit: "Time Limit"
case .openLimit: "Open Limit"
}
}
var exampleText: String {
switch self {
case .schedule: "e.g. 9-5, Daily"
case .timeLimit: "e.g. 45m/day"
case .openLimit: "e.g. 5 opens/day"
}
}
var symbolName: String {
switch self {
case .schedule: "calendar"
case .timeLimit: "hourglass"
case .openLimit: "lock.fill"
}
}
/// Default name given to a brand-new rule of this kind (Opal: "In the Zone", "Time Keeper").
var defaultRuleName: String {
switch self {
case .schedule: "In the Zone"
case .timeLimit: "Time Keeper"
case .openLimit: "Gate Keeper"
}
}
}
/// How the rule's app selection is interpreted.
enum SelectionMode: String, Codable, CaseIterable, Sendable {
/// Block the selected apps; everything else stays available.
case block
/// Block everything except the selected apps.
case allowOnly
var displayName: String {
switch self {
case .block: "Block"
case .allowOnly: "Allow Only"
}
}
}

View File

@@ -0,0 +1,98 @@
//
// RulePreset.swift
// Severed
//
import SwiftUI
/// A suggested schedule rule shown in the New Rule sheet's preset gallery,
/// mirroring the reference app's sections and timings.
struct RulePreset: Identifiable, Hashable, Sendable {
let id: String
let name: String
let startMinutes: Int
let endMinutes: Int
let days: Set<Weekday>
let symbolName: String
/// Gradient stand-in for the reference app's photo backgrounds.
let gradientTop: Color
let gradientBottom: Color
var schedule: RuleSchedule {
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
}
}
/// A titled group of presets ("Get More Done", "Sleep, Relax and Reset", ).
struct RulePresetSection: Identifiable, Hashable, Sendable {
let id: String
let title: String
let subtitle: String
let presets: [RulePreset]
static let all: [RulePresetSection] = [
RulePresetSection(
id: "productivity",
title: "Get More Done",
subtitle: "Maximize your productivity while staying sane.",
presets: [
RulePreset(
id: "work-time", name: "Work Time",
startMinutes: 9 * 60, endMinutes: 17 * 60, days: Weekday.weekdays,
symbolName: "briefcase.fill",
gradientTop: Color(red: 0.16, green: 0.27, blue: 0.22),
gradientBottom: Color(red: 0.05, green: 0.10, blue: 0.08)
),
RulePreset(
id: "laser-focus", name: "Laser Focus",
startMinutes: 14 * 60, endMinutes: 18 * 60, days: Weekday.weekdays,
symbolName: "scope",
gradientTop: Color(red: 0.13, green: 0.20, blue: 0.33),
gradientBottom: Color(red: 0.04, green: 0.06, blue: 0.12)
),
]
),
RulePresetSection(
id: "sleep",
title: "Sleep, Relax and Reset",
subtitle: "Sleep better, rise refreshed.",
presets: [
RulePreset(
id: "wind-down", name: "Wind Down",
startMinutes: 20 * 60, endMinutes: 22 * 60, days: Weekday.everyDay,
symbolName: "moon.haze.fill",
gradientTop: Color(red: 0.25, green: 0.18, blue: 0.33),
gradientBottom: Color(red: 0.08, green: 0.05, blue: 0.12)
),
RulePreset(
id: "deep-sleep", name: "Deep Sleep",
startMinutes: 22 * 60, endMinutes: 6 * 60, days: Weekday.everyDay,
symbolName: "moon.zzz.fill",
gradientTop: Color(red: 0.10, green: 0.13, blue: 0.30),
gradientBottom: Color(red: 0.02, green: 0.03, blue: 0.10)
),
]
),
RulePresetSection(
id: "habits",
title: "Build Healthy Habits",
subtitle: "Spend time on important things.",
presets: [
RulePreset(
id: "reading-time", name: "Reading Time",
startMinutes: 19 * 60 + 45, endMinutes: 20 * 60 + 45, days: Weekday.everyDay,
symbolName: "book.fill",
gradientTop: Color(red: 0.33, green: 0.23, blue: 0.13),
gradientBottom: Color(red: 0.12, green: 0.07, blue: 0.03)
),
RulePreset(
id: "gym-time", name: "Gym Time",
startMinutes: 17 * 60 + 30, endMinutes: 18 * 60 + 30, days: Weekday.everyDay,
symbolName: "figure.run",
gradientTop: Color(red: 0.13, green: 0.30, blue: 0.30),
gradientBottom: Color(red: 0.03, green: 0.10, blue: 0.10)
),
]
),
]
}

View File

@@ -0,0 +1,65 @@
//
// Weekday.swift
// Severed
//
import Foundation
/// A day of the week, using `Calendar` weekday numbering (1 = Sunday 7 = Saturday).
enum Weekday: Int, CaseIterable, Codable, Hashable, Sendable {
case sunday = 1
case monday = 2
case tuesday = 3
case wednesday = 4
case thursday = 5
case friday = 6
case saturday = 7
static let weekdays: Set<Weekday> = [.monday, .tuesday, .wednesday, .thursday, .friday]
static let weekends: Set<Weekday> = [.saturday, .sunday]
static let everyDay: Set<Weekday> = Set(Weekday.allCases)
/// Display order used by the day picker, matching the reference UI: S M T W T F S.
static let displayOrder: [Weekday] = [
.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday,
]
/// Single-letter label for the circular day toggles.
var shortLabel: String {
switch self {
case .sunday, .saturday: "S"
case .monday: "M"
case .tuesday, .thursday: "T"
case .wednesday: "W"
case .friday: "F"
}
}
/// Three-letter abbreviation used in custom day summaries ("Mon, Wed, Fri").
var abbreviation: String {
switch self {
case .sunday: "Sun"
case .monday: "Mon"
case .tuesday: "Tue"
case .wednesday: "Wed"
case .thursday: "Thu"
case .friday: "Fri"
case .saturday: "Sat"
}
}
}
extension Set<Weekday> {
/// Human-readable summary shown next to the day picker and in rule details:
/// "Weekdays", "Weekends", "Every day", "Never", or a list like "Mon, Wed, Fri".
var summary: String {
if self == Weekday.everyDay { return "Every day" }
if self == Weekday.weekdays { return "Weekdays" }
if self == Weekday.weekends { return "Weekends" }
if isEmpty { return "Never" }
return Weekday.displayOrder
.filter(contains)
.map(\.abbreviation)
.joined(separator: ", ")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.family-controls</key>
<true/>
</dict>
</plist>

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
//
// RuleCardView.swift
// Severed
//
import SwiftUI
/// A rule tile in the home screen's Rules carousel: icon pair, live status,
/// name, and the block summary. Active rules glow green.
struct RuleCardView: View {
let rule: BlockingRule
let now: Date
private var status: RuleStatus { rule.status(at: now) }
var body: some View {
VStack(alignment: .leading, spacing: 12) {
RuleIconPair(kind: rule.kind, isActive: status.isActive)
statusView
Text(rule.name)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
HStack(spacing: 5) {
Text(rule.selectionMode.displayName)
.foregroundStyle(Theme.textSecondary)
Text(blockSummary)
.foregroundStyle(Theme.textTertiary)
}
.font(.system(size: 13))
}
.padding(16)
.frame(width: 172, alignment: .leading)
.background(cardBackground, in: RoundedRectangle(cornerRadius: 22))
.overlay(
RoundedRectangle(cornerRadius: 22)
.strokeBorder(
status.isActive ? Theme.accent.opacity(0.35) : .white.opacity(0.08),
lineWidth: 1
)
)
}
@ViewBuilder
private var statusView: some View {
switch status {
case .active:
Text(status.label(relativeTo: now))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.black)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Theme.accent, in: Capsule())
.accessibilityIdentifier("ruleStatus-\(rule.name)")
case .paused, .disabled, .dormant, .upcoming:
Text(statusText)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Theme.textSecondary)
.padding(.vertical, 5)
.accessibilityIdentifier("ruleStatus-\(rule.name)")
}
}
private var statusText: String {
switch rule.kind {
case .schedule:
status.label(relativeTo: now)
case .timeLimit:
rule.isEnabled ? "\(rule.dailyLimitMinutes)m / day" : "Disabled"
case .openLimit:
rule.isEnabled ? "\(rule.maxOpens) opens / day" : "Disabled"
}
}
private var blockSummary: String {
rule.selectionCount == 1 ? "· 1 app" : "· \(rule.selectionCount) apps"
}
private var cardBackground: some ShapeStyle {
if status.isActive {
return AnyShapeStyle(
LinearGradient(
colors: [Theme.activeCardTop, Theme.activeCardBottom],
startPoint: .top, endPoint: .bottom
)
)
}
return AnyShapeStyle(Theme.surface)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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