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:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
85
OpenAppLock/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
85
OpenAppLock/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
6
OpenAppLock/Assets.xcassets/Contents.json
Normal file
6
OpenAppLock/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
65
OpenAppLock/Logic/RulePolicy.swift
Normal file
65
OpenAppLock/Logic/RulePolicy.swift
Normal 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
|
||||
}
|
||||
}
|
||||
89
OpenAppLock/Logic/RuleSchedule.swift
Normal file
89
OpenAppLock/Logic/RuleSchedule.swift
Normal 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))"
|
||||
}
|
||||
}
|
||||
73
OpenAppLock/Logic/RuleStatus.swift
Normal file
73
OpenAppLock/Logic/RuleStatus.swift
Normal 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
|
||||
}
|
||||
}
|
||||
96
OpenAppLock/Models/BlockingRule.swift
Normal file
96
OpenAppLock/Models/BlockingRule.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
98
OpenAppLock/Models/RuleDraft.swift
Normal file
98
OpenAppLock/Models/RuleDraft.swift
Normal 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 (9–5 weekdays schedule, 45m/day, 5 opens/day).
|
||||
init(kind: RuleKind) {
|
||||
self.name = kind.defaultRuleName
|
||||
self.kind = kind
|
||||
self.days = Weekday.weekdays
|
||||
self.startMinutes = 9 * 60
|
||||
self.endMinutes = 17 * 60
|
||||
self.dailyLimitMinutes = 45
|
||||
self.maxOpens = 5
|
||||
self.hardMode = false
|
||||
self.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)
|
||||
}
|
||||
}
|
||||
64
OpenAppLock/Models/RuleKind.swift
Normal file
64
OpenAppLock/Models/RuleKind.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
98
OpenAppLock/Models/RulePreset.swift
Normal file
98
OpenAppLock/Models/RulePreset.swift
Normal 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)
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
}
|
||||
65
OpenAppLock/Models/Weekday.swift
Normal file
65
OpenAppLock/Models/Weekday.swift
Normal 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: ", ")
|
||||
}
|
||||
}
|
||||
8
OpenAppLock/OpenAppLock.entitlements
Normal file
8
OpenAppLock/OpenAppLock.entitlements
Normal 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>
|
||||
59
OpenAppLock/OpenAppLockApp.swift
Normal file
59
OpenAppLock/OpenAppLockApp.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
45
OpenAppLock/Services/LaunchConfiguration.swift
Normal file
45
OpenAppLock/Services/LaunchConfiguration.swift
Normal 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)
|
||||
}
|
||||
46
OpenAppLock/Services/RuleEnforcer.swift
Normal file
46
OpenAppLock/Services/RuleEnforcer.swift
Normal 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
|
||||
}
|
||||
}
|
||||
65
OpenAppLock/Services/SampleRules.swift
Normal file
65
OpenAppLock/Services/SampleRules.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
85
OpenAppLock/Services/ScreenTimeAuthorization.swift
Normal file
85
OpenAppLock/Services/ScreenTimeAuthorization.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
121
OpenAppLock/Services/ShieldController.swift
Normal file
121
OpenAppLock/Services/ShieldController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
216
OpenAppLock/Views/Apps/AppsHomeView.swift
Normal file
216
OpenAppLock/Views/Apps/AppsHomeView.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
64
OpenAppLock/Views/Components/DayOfWeekPicker.swift
Normal file
64
OpenAppLock/Views/Components/DayOfWeekPicker.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
OpenAppLock/Views/Onboarding/OnboardingView.swift
Normal file
128
OpenAppLock/Views/Onboarding/OnboardingView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
32
OpenAppLock/Views/RootView.swift
Normal file
32
OpenAppLock/Views/RootView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
90
OpenAppLock/Views/Rules/AppSelectionSheet.swift
Normal file
90
OpenAppLock/Views/Rules/AppSelectionSheet.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
111
OpenAppLock/Views/Rules/NewRuleSheet.swift
Normal file
111
OpenAppLock/Views/Rules/NewRuleSheet.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
134
OpenAppLock/Views/Rules/RuleDetailSheet.swift
Normal file
134
OpenAppLock/Views/Rules/RuleDetailSheet.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
253
OpenAppLock/Views/Rules/RuleEditorView.swift
Normal file
253
OpenAppLock/Views/Rules/RuleEditorView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user