refactor: rename app from Severed to OpenAppLock

Full rename ahead of App Store submission: project, targets, bundle
identifiers (dev.bchen.Severed -> dev.bchen.OpenAppLock), entitlements
file, app entry point, user-facing onboarding strings, test helpers,
and docs. Verified: all 62 unit tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-12 18:05:06 -04:00
parent b8473383d3
commit 1d5e79ba7c
42 changed files with 145 additions and 145 deletions

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,85 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,65 @@
//
// RulePolicy.swift
// OpenAppLock
//
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
// OpenAppLock
//
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
// OpenAppLock
//
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,96 @@
//
// BlockingRule.swift
// OpenAppLock
//
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
/// Engage Screen Time's adult-website filter while this rule is blocking.
/// Inline default so existing stores migrate cleanly.
var blockAdultContent: Bool = false
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,
blockAdultContent: 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.blockAdultContent = blockAdultContent
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,98 @@
//
// RuleDraft.swift
// OpenAppLock
//
import Foundation
/// Value-type working copy of a rule used by the editors, so cancelling an
/// edit never touches the persisted model. Hashable so it can drive
/// `navigationDestination(item:)`.
struct RuleDraft: Hashable {
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 blockAdultContent: 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.blockAdultContent = 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.blockAdultContent = rule.blockAdultContent
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.blockAdultContent = blockAdultContent
rule.selectionMode = selectionMode
rule.selectionData = selectionData
rule.selectionCount = selectionCount
}
func makeRule() -> BlockingRule {
let rule = BlockingRule(name: name, kind: kind)
apply(to: rule)
return rule
}
/// Trims the name and falls back to the kind's default when it is empty,
/// so a cleared name field can never produce an unnamed rule.
func sanitized() -> RuleDraft {
var copy = self
let trimmed = name.trimmingCharacters(in: .whitespaces)
copy.name = trimmed.isEmpty ? kind.defaultRuleName : trimmed
return copy
}
var schedule: RuleSchedule {
RuleSchedule(startMinutes: startMinutes, endMinutes: endMinutes, days: days)
}
}

View File

@@ -0,0 +1,64 @@
//
// RuleKind.swift
// OpenAppLock
//
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
// OpenAppLock
//
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
// OpenAppLock
//
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,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

@@ -0,0 +1,59 @@
//
// OpenAppLockApp.swift
// OpenAppLock
//
// Created by Brendan Chen on 2025.08.09.
//
import SwiftData
import SwiftUI
@main
struct OpenAppLockApp: App {
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 {
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 {
RootView()
.environment(authorization)
.environment(enforcer)
}
.modelContainer(container)
}
}

View File

@@ -0,0 +1,45 @@
//
// LaunchConfiguration.swift
// OpenAppLock
//
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,46 @@
//
// RuleEnforcer.swift
// OpenAppLock
//
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,
blockAdultContent: rule.blockAdultContent
)
}
shields.clearShields(except: active)
blockingRuleIDs = active
}
}

View File

@@ -0,0 +1,65 @@
//
// SampleRules.swift
// OpenAppLock
//
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
// OpenAppLock
//
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,121 @@
//
// ShieldController.swift
// OpenAppLock
//
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, blockAdultContent: Bool
)
/// 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, blockAdultContent: Bool
) {
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)
}
// Screen Time's "Limit Adult Websites" filter for the rule's lifetime.
store.webContent.blockedByFilter = blockAdultContent ? .auto() : nil
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] = [:]
private(set) var appliedAdultContentFlags: [UUID: Bool] = [:]
func applyShield(
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
) {
shieldedRuleIDs.insert(ruleID)
appliedModes[ruleID] = mode
appliedAdultContentFlags[ruleID] = blockAdultContent
}
func clearShields(except activeRuleIDs: Set<UUID>) {
shieldedRuleIDs.formIntersection(activeRuleIDs)
appliedModes = appliedModes.filter { activeRuleIDs.contains($0.key) }
appliedAdultContentFlags = appliedAdultContentFlags.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,216 @@
//
// AppsHomeView.swift
// OpenAppLock
//
import SwiftData
import SwiftUI
/// Home screen using plain iOS components: a List with a "Blocked Apps"
/// section (what's blocking right now) and a "Rules" section listing every
/// rule with its live status. "+" in the toolbar creates a new rule.
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
ruleList(now: timeline.date)
}
.navigationTitle("Apps")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("New Rule", systemImage: "plus") {
showingNewRule = true
}
.accessibilityIdentifier("newRuleButton")
}
}
}
.sheet(item: $detailRule) { rule in
RuleDetailSheet(rule: rule)
}
.sheet(isPresented: $showingNewRule) {
NewRuleSheet()
}
.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()
}
}
private func ruleList(now: Date) -> some View {
List {
blockedSection(now: now)
rulesSection(now: now)
}
}
// MARK: - Blocked Apps
@ViewBuilder
private func blockedSection(now: Date) -> some View {
let blocking = rules.filter { $0.status(at: now).isActive }
Section {
if blocking.isEmpty {
Text("Nothing is blocked right now.")
.foregroundStyle(.secondary)
.accessibilityIdentifier("nothingBlockedLabel")
} else {
ForEach(blocking) { rule in
blockedRow(for: rule, now: now)
}
}
} header: {
Text("Blocked Apps").textCase(nil)
}
}
private func blockedRow(for rule: BlockingRule, now: Date) -> some View {
Button {
if RulePolicy.canUnblock(rule, at: now) {
unblockCandidate = rule
} else {
hardModeBlockedAttempt = true
}
} label: {
HStack {
Image(systemName: rule.hardMode ? "lock.fill" : "lock.open.fill")
.foregroundStyle(rule.hardMode ? .red : .secondary)
.frame(width: 28)
Text(rule.name)
.foregroundStyle(Color.primary)
Spacer()
Text("Unblock")
.foregroundStyle(.tint)
}
}
.accessibilityIdentifier("blockedTile-\(rule.name)")
}
// MARK: - Rules
@ViewBuilder
private func rulesSection(now: Date) -> some View {
Section {
if rules.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("No rules yet")
.font(.headline)
Text("Create a rule to block distracting apps on a schedule.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("emptyRulesCard")
} else {
ForEach(rules) { rule in
ruleRow(for: rule, now: now)
}
}
} header: {
Text("Rules").textCase(nil)
}
}
private func ruleRow(for rule: BlockingRule, now: Date) -> some View {
let status = rule.status(at: now)
return Button {
detailRule = rule
} label: {
HStack {
Image(systemName: rule.kind.symbolName)
.foregroundStyle(.tint)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(rule.name)
.foregroundStyle(Color.primary)
Text(blockSummary(for: rule))
.font(.caption)
.foregroundStyle(Color.secondary)
}
Spacer()
Text(statusText(for: rule, status: status, now: now))
.font(.subheadline)
.foregroundStyle(status.isActive ? .green : .secondary)
.accessibilityIdentifier("ruleStatus-\(rule.name)")
}
}
.accessibilityIdentifier("ruleCard-\(rule.name)")
}
private func blockSummary(for rule: BlockingRule) -> String {
let apps = rule.selectionCount == 1 ? "1 app" : "\(rule.selectionCount) apps"
return "\(rule.selectionMode.displayName) · \(apps)"
}
private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> 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"
}
}
// MARK: - Enforcement
/// Changes whenever any rule's blocking-relevant state changes.
private var ruleChangeToken: String {
rules.map {
"\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.blockAdultContent)|"
+ "\($0.startMinutes)|\($0.endMinutes)|\($0.dayNumbers)|"
+ "\($0.selectionCount)|\($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,64 @@
//
// DayOfWeekPicker.swift
// OpenAppLock
//
import SwiftUI
/// Seven circular day toggles (S M T W T F S) using system colors, meant to
/// sit inside a Form/List row. The day-set summary is shown by the enclosing
/// section header.
struct DayOfWeekPicker: View {
@Binding var days: Set<Weekday>
var body: some View {
HStack(spacing: 0) {
ForEach(Weekday.displayOrder, id: \.self) { day in
dayToggle(day)
}
}
}
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(.subheadline.weight(.semibold))
.foregroundStyle(isOn ? Color.white : Color.secondary)
.frame(width: 38, height: 38)
.background(
isOn ? AnyShapeStyle(.tint) : AnyShapeStyle(Color(.tertiarySystemFill)),
in: Circle()
)
// Each cell takes an equal share of the row and at least a
// 44pt-tall hit area, so the whole strip is comfortably tappable.
.frame(maxWidth: .infinity, minHeight: 44)
.contentShape(Rectangle())
}
.buttonStyle(.borderless)
.accessibilityIdentifier("dayToggle-\(day.rawValue)")
.accessibilityLabel(day.abbreviation)
.accessibilityAddTraits(isOn ? .isSelected : [])
}
}
#Preview {
@Previewable @State var days = Weekday.weekdays
Form {
Section {
DayOfWeekPicker(days: $days)
} header: {
HStack {
Text("On these days").textCase(nil)
Spacer()
Text(days.summary).textCase(nil)
}
}
}
}

View File

@@ -0,0 +1,128 @@
//
// OnboardingView.swift
// OpenAppLock
//
import SwiftUI
/// Two-step onboarding using system styling: 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()
}
private var welcome: some View {
VStack(spacing: 16) {
Image(systemName: "scissors")
.font(.system(size: 56))
.foregroundStyle(.tint)
Text("OpenAppLock")
.font(.largeTitle.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.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
private var permission: some View {
VStack(spacing: 24) {
Image(systemName: "hourglass")
.font(.system(size: 48))
.foregroundStyle(.tint)
Text("Allow Screen Time Access")
.font(.title.bold())
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: 14) {
bullet("shield.fill", "OpenAppLock 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. OpenAppLock can't block apps without it.")
.font(.footnote)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
.accessibilityIdentifier("permissionDeniedLabel")
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
openURL(url)
}
}
.accessibilityIdentifier("openSettingsButton")
}
}
}
}
private func bullet(_ systemImage: String, _ text: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
.foregroundStyle(.tint)
.frame(width: 24)
Text(text)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
@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)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.accessibilityIdentifier(identifier)
}
}

View File

@@ -0,0 +1,32 @@
//
// RootView.swift
// OpenAppLock
//
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,90 @@
//
// AppSelectionSheet.swift
// OpenAppLock
//
import FamilyControls
import SwiftUI
/// App/category/website selection wrapping Apple's `FamilyActivityPicker`,
/// presented as a plain sheet with a segmented Block / Allow Only control.
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 {
NavigationStack {
VStack(spacing: 0) {
Picker("Mode", selection: $mode) {
ForEach(SelectionMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.vertical, 8)
.accessibilityIdentifier("selectionModePicker")
FamilyActivityPicker(selection: $selection)
}
.navigationTitle("Selected")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
.accessibilityIdentifier("selectionBackButton")
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
save()
}
.accessibilityIdentifier("confirmSelectionButton")
}
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 8) {
Text(selectionSummary)
.font(.footnote)
.foregroundStyle(.secondary)
.accessibilityIdentifier("selectionCountLabel")
Button {
save()
} label: {
Text("Save")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.accessibilityIdentifier("saveSelectionButton")
}
.padding(.horizontal)
.padding(.bottom, 8)
}
}
}
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,111 @@
//
// NewRuleSheet.swift
// OpenAppLock
//
import SwiftData
import SwiftUI
/// "New Rule" as a plain list: a Rule Type section, then the preset sections.
/// Picking either pushes the editor; committing saves 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 {
NavigationStack {
List {
Section {
ForEach(RuleKind.allCases, id: \.self) { kind in
kindRow(kind)
}
} header: {
Text("Rule Type").textCase(nil)
}
ForEach(RulePresetSection.all) { section in
Section {
ForEach(section.presets) { preset in
presetRow(preset)
}
} header: {
VStack(alignment: .leading, spacing: 2) {
Text(section.title)
Text(section.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
.textCase(nil)
}
}
}
.navigationTitle("New Rule")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Close", systemImage: "xmark") {
dismiss()
}
.accessibilityIdentifier("closeNewRuleButton")
}
}
.navigationDestination(item: $pendingDraft) { draft in
RuleEditorView(
mode: .create,
draft: draft,
onCommit: { committed in
modelContext.insert(committed.makeRule())
dismiss()
}
)
}
}
}
private func kindRow(_ kind: RuleKind) -> some View {
Button {
pendingDraft = RuleDraft(kind: kind)
} label: {
HStack {
Image(systemName: kind.symbolName)
.foregroundStyle(.tint)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(kind.displayName)
.foregroundStyle(Color.primary)
Text(kind.exampleText)
.font(.caption)
.foregroundStyle(Color.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.accessibilityIdentifier("ruleKind-\(kind.rawValue)")
}
private func presetRow(_ preset: RulePreset) -> some View {
Button {
pendingDraft = RuleDraft(preset: preset)
} label: {
HStack {
Image(systemName: preset.symbolName)
.foregroundStyle(.tint)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(preset.name)
.foregroundStyle(Color.primary)
Text("\(preset.schedule.timeRangeLabel) · \(preset.days.summary)")
.font(.caption)
.foregroundStyle(Color.secondary)
}
Spacer()
Image(systemName: "plus.circle.fill")
.foregroundStyle(.tint)
}
}
.accessibilityIdentifier("preset-\(preset.id)")
}
}

View File

@@ -0,0 +1,134 @@
//
// RuleDetailSheet.swift
// OpenAppLock
//
import SwiftData
import SwiftUI
/// Rule summary presented as a plain sheet: inline title with a live status
/// caption, the rule's facts as labeled rows, and "Edit Rule" which pushes
/// the editor. A hard-locked rule shows a lock notice instead.
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 {
NavigationStack {
TimelineView(.periodic(from: .now, by: 30)) { timeline in
detailList(now: timeline.date)
}
.navigationDestination(isPresented: $isEditing) {
RuleEditorView(
mode: .edit(isEnabled: rule.isEnabled),
draft: RuleDraft(rule: rule),
onCommit: { draft in
draft.apply(to: rule)
isEditing = false
},
onToggleEnabled: {
rule.isEnabled.toggle()
rule.pausedUntil = nil
isEditing = false
},
onDelete: {
pendingDeletion = true
dismiss()
}
)
}
}
.onDisappear {
if pendingDeletion {
modelContext.delete(rule)
}
}
}
private func detailList(now: Date) -> some View {
let status = rule.status(at: now)
return List {
Section {
detailRows
}
Section {
if RulePolicy.canEdit(rule, at: now) {
Button {
isEditing = true
} label: {
Label("Edit Rule", systemImage: "pencil")
}
.accessibilityIdentifier("editRuleButton")
} else {
Label(
"Hard Mode is on — this rule is locked until the block ends.",
systemImage: "lock.fill"
)
.foregroundStyle(.secondary)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("hardModeLockedNotice")
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Close", systemImage: "xmark") {
dismiss()
}
.accessibilityIdentifier("closeDetailButton")
}
ToolbarItem(placement: .principal) {
VStack(spacing: 1) {
Text(rule.name)
.font(.headline)
.accessibilityIdentifier("detailRuleName")
Text("\(rule.kind.displayName), \(status.label(relativeTo: now))")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("detailStatusLabel")
}
}
}
}
@ViewBuilder
private var detailRows: some View {
switch rule.kind {
case .schedule:
row("During this time", rule.schedule.timeRangeLabel)
row("On these days", rule.days.summary)
row(rule.selectionMode.displayName, appCountLabel)
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .timeLimit:
row("When I use", appCountLabel)
row("For this long", "\(rule.dailyLimitMinutes)m daily")
row("On these days", rule.days.summary)
row("Then block until", "Tomorrow")
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
case .openLimit:
row("When I open", appCountLabel)
row("More than", "\(rule.maxOpens) opens daily")
row("On these days", rule.days.summary)
row("Then block until", "Tomorrow")
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
}
}
private var appCountLabel: String {
rule.selectionCount == 1 ? "1 App" : "\(rule.selectionCount) Apps"
}
private func row(_ label: String, _ value: String) -> some View {
LabeledContent(label, value: value)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("detailRow-\(label)")
}
}

View File

@@ -0,0 +1,253 @@
//
// RuleEditorView.swift
// OpenAppLock
//
import SwiftUI
/// The rule editor as a plain Form, always pushed inside a NavigationStack
/// (New Rule flow and detail editing both push it). Both modes commit via a
/// checkmark confirmation button in the navigation bar; edit mode adds an
/// ellipsis "Rule Actions" menu (Disable/Enable, Delete) next to it.
struct RuleEditorView: View {
enum Mode: Equatable {
case create
case edit(isEnabled: Bool)
}
let mode: Mode
@State var draft: RuleDraft
var onCommit: (RuleDraft) -> Void
var onToggleEnabled: (() -> Void)?
var onDelete: (() -> Void)?
@State private var showingAppPicker = false
var body: some View {
Form {
nameSection
sections
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Text(draft.sanitized().name)
.font(.headline)
.lineLimit(1)
.accessibilityIdentifier("ruleEditorTitle")
}
if case .edit(let isEnabled) = mode {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button(isEnabled ? "Disable Rule" : "Enable Rule") {
onToggleEnabled?()
}
Button("Delete Rule", role: .destructive) {
onDelete?()
}
} label: {
Image(systemName: "ellipsis")
}
.accessibilityLabel("Rule Actions")
.accessibilityIdentifier("ruleActionsMenu")
}
}
ToolbarItem(placement: .confirmationAction) {
switch mode {
case .create:
Button(role: .confirm) {
onCommit(draft.sanitized())
} label: {
Image(systemName: "checkmark")
}
.accessibilityLabel("Add Rule")
.accessibilityIdentifier("commitRuleButton")
case .edit:
Button(role: .confirm) {
onCommit(draft.sanitized())
} label: {
Image(systemName: "checkmark")
}
.accessibilityLabel("Done")
.accessibilityIdentifier("doneButton")
}
}
}
.sheet(isPresented: $showingAppPicker) {
AppSelectionSheet(draft: $draft)
}
}
// MARK: - Sections
private var nameSection: some View {
Section {
TextField("Rule Name", text: $draft.name)
.submitLabel(.done)
.accessibilityIdentifier("ruleNameField")
} header: {
Text("Name").textCase(nil)
}
}
@ViewBuilder
private var sections: some View {
switch draft.kind {
case .schedule:
Section {
DatePicker(
"From",
selection: timeBinding($draft.startMinutes),
displayedComponents: .hourAndMinute
)
.accessibilityIdentifier("fromTimePicker")
DatePicker(
"To",
selection: timeBinding($draft.endMinutes),
displayedComponents: .hourAndMinute
)
.accessibilityIdentifier("toTimePicker")
} header: {
Text("During this time").textCase(nil)
}
daysSection
Section {
selectedAppsRow
} header: {
Text("Apps are blocked").textCase(nil)
}
toggleSections
case .timeLimit:
Section {
selectedAppsRow
} header: {
Text("When I use").textCase(nil)
}
Section {
budgetRow(
value: "\(draft.dailyLimitMinutes)m",
stepperID: "dailyLimitStepper",
onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) },
onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) }
)
} header: {
Text("For this long").textCase(nil)
}
daysSection
blockUntilSection
toggleSections
case .openLimit:
Section {
selectedAppsRow
} header: {
Text("When I open").textCase(nil)
}
Section {
budgetRow(
value: "\(draft.maxOpens) opens",
stepperID: "maxOpensStepper",
onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) },
onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) }
)
} header: {
Text("More than").textCase(nil)
}
daysSection
blockUntilSection
toggleSections
}
}
private var daysSection: some View {
Section {
DayOfWeekPicker(days: $draft.days)
} header: {
HStack {
Text("On these days").textCase(nil)
Spacer()
Text(draft.days.summary).textCase(nil)
}
}
}
private var blockUntilSection: some View {
Section {
LabeledContent("Until", value: "Tomorrow")
} header: {
Text("Then block app").textCase(nil)
}
}
@ViewBuilder
private var toggleSections: some View {
Section {
HStack {
Text("Hard Mode")
Spacer()
Toggle("", isOn: $draft.hardMode)
.labelsHidden()
.accessibilityIdentifier("hardModeToggle")
}
} footer: {
Text("No unblocks allowed while the rule is blocking.")
}
Section {
HStack {
Text("Block Adult Content")
Spacer()
Toggle("", isOn: $draft.blockAdultContent)
.labelsHidden()
.accessibilityIdentifier("adultContentToggle")
}
} footer: {
Text("Filter adult websites while this rule is active.")
}
}
private var selectedAppsRow: some View {
Button {
showingAppPicker = true
} label: {
HStack {
Text("Selected Apps")
.foregroundStyle(Color.primary)
Spacer()
Text(draft.selectionCount == 1 ? "1 App" : "\(draft.selectionCount) Apps")
.foregroundStyle(Color.secondary)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.accessibilityIdentifier("selectedAppsRow")
}
private func budgetRow(
value: String,
stepperID: String,
onIncrement: @escaping () -> Void,
onDecrement: @escaping () -> Void
) -> some View {
HStack {
Text("Daily")
Spacer()
Text(value)
.foregroundStyle(.secondary)
.accessibilityIdentifier("\(stepperID)Value")
Stepper("", onIncrement: onIncrement, onDecrement: onDecrement)
.labelsHidden()
.accessibilityIdentifier(stepperID)
}
}
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)
}
}
}