Compare commits
3 Commits
0a0d00f53a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 44e6086e29 | |||
| d005ed4fb1 | |||
| 32b1694e0a |
12
AGENTS.md
12
AGENTS.md
@@ -3,8 +3,8 @@
|
||||
OpenAppLock is an iOS Screen Time app: recurring **rules** that block selected
|
||||
apps (Schedule windows, Time Limits, Open Limits), with a **Hard Mode** that
|
||||
makes an active block impossible to lift, edit, or delete until it ends. The
|
||||
feature set is a clone of Opal's "Rules"; the presentation is bare native iOS
|
||||
(List/Form/NavigationStack, default color scheme).
|
||||
presentation is bare native iOS (List/Form/NavigationStack, default color
|
||||
scheme).
|
||||
|
||||
## Repo layout
|
||||
|
||||
@@ -39,9 +39,9 @@ OpenAppLockShieldAction/ ShieldAction extension: Open press spends an open,
|
||||
OpenAppLockTests/ Swift Testing unit suites (@MainActor — the app
|
||||
target defaults to MainActor isolation)
|
||||
OpenAppLockUITests/ XCUITest flows (see harness below)
|
||||
docs/RULES_FEATURE_SPEC.md Feature spec derived from the Opal reference
|
||||
recording; §6 maps it to the native presentation.
|
||||
Review/update this BEFORE behavior changes.
|
||||
docs/RULES_FEATURE_SPEC.md Feature spec for the rules behavior; §6 maps it
|
||||
to the native presentation. Review/update this
|
||||
BEFORE behavior changes.
|
||||
docs/SWIFT_GUIDELINES.md Swift coding/testing/patterns/security standards
|
||||
agents must follow on this project.
|
||||
```
|
||||
@@ -53,7 +53,7 @@ docs/SWIFT_GUIDELINES.md Swift coding/testing/patterns/security standards
|
||||
*starts* on. `start == end` = 24h window.
|
||||
- Status is always **derived** (`rule.status(at:calendar:)`), never stored:
|
||||
`disabled / dormant / active(until:) / paused(until:) / upcoming(startsAt:)`.
|
||||
Labels match the reference app ("6h left" rounds hours **up**).
|
||||
Countdown labels round hours **up** (e.g. "6h left").
|
||||
- **Hard Mode**: `RulePolicy` is the single gate — while a hard-mode rule is
|
||||
actively blocking, canEdit/canDisable/canDelete/canUnblock are all false.
|
||||
Soft rules can be "unblocked", which sets `pausedUntil` = window end (the
|
||||
|
||||
@@ -59,6 +59,17 @@ enum RulePolicy {
|
||||
!isHardLocked(rule, usage: usage, at: now, calendar: calendar)
|
||||
}
|
||||
|
||||
/// Whether *any* rule is currently a hard block — the condition that locks
|
||||
/// app-list editing and (when the user opts in) device app removal.
|
||||
static func isAnyHardLocked(
|
||||
rules: [BlockingRule], usageFor: (BlockingRule) -> RuleUsage? = { _ in nil },
|
||||
at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
rules.contains {
|
||||
isHardLocked($0, usage: usageFor($0), at: now, calendar: calendar)
|
||||
}
|
||||
}
|
||||
|
||||
/// App lists feed active shields, so while any hard-mode rule is actively
|
||||
/// blocking, no list may be edited or deleted — changing a list would be a
|
||||
/// back door out of the hard block. Creating new lists and picking lists
|
||||
@@ -67,9 +78,19 @@ enum RulePolicy {
|
||||
rules: [BlockingRule], usageFor: (BlockingRule) -> RuleUsage? = { _ in nil },
|
||||
at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
!rules.contains {
|
||||
isHardLocked($0, usage: usageFor($0), at: now, calendar: calendar)
|
||||
}
|
||||
!isAnyHardLocked(rules: rules, usageFor: usageFor, at: now, calendar: calendar)
|
||||
}
|
||||
|
||||
/// Whether the device's app removal should be denied right now: the user has
|
||||
/// turned on Uninstall Protection *and* a hard block is currently in force.
|
||||
/// Engaging this while a hard rule blocks makes the block harder to escape
|
||||
/// (the user can't delete the locked apps — or OpenAppLock itself).
|
||||
static func shouldDenyAppRemoval(
|
||||
rules: [BlockingRule], enabled: Bool,
|
||||
usageFor: (BlockingRule) -> RuleUsage? = { _ in nil },
|
||||
at now: Date = .now, calendar: Calendar = .current
|
||||
) -> Bool {
|
||||
enabled && isAnyHardLocked(rules: rules, usageFor: usageFor, at: now, calendar: calendar)
|
||||
}
|
||||
|
||||
/// Pauses the rule's current block. Returns false (and changes nothing)
|
||||
|
||||
@@ -33,7 +33,7 @@ enum RuleStatus: Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact countdown matching the reference app: minutes under an hour,
|
||||
/// Compact countdown: 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)))
|
||||
|
||||
@@ -8,6 +8,15 @@ import Foundation
|
||||
/// Strings for the home screen's Usage section. Used values clamp to the
|
||||
/// budget so overshoot (thresholds can fire late) never reads "50m of 45m".
|
||||
enum UsageDisplay {
|
||||
/// The usage subtitle prefixed with the rule's type, so the kind is clear
|
||||
/// without relying on an icon: "Time Limit · 18m of 45m used today".
|
||||
/// Schedule rules (no usage text) fall back to just the type name.
|
||||
static func typedSubtitle(for rule: BlockingRule, usage: RuleUsage) -> String {
|
||||
let usageText = subtitle(for: rule, usage: usage)
|
||||
guard !usageText.isEmpty else { return rule.kind.displayName }
|
||||
return "\(rule.kind.displayName) · \(usageText)"
|
||||
}
|
||||
|
||||
/// "18m of 45m used today" / "2 of 5 opens today".
|
||||
static func subtitle(for rule: BlockingRule, usage: RuleUsage) -> String {
|
||||
switch rule.configuration {
|
||||
|
||||
@@ -78,7 +78,7 @@ final class BlockingRule {
|
||||
self.dayNumbers = days.map(\.rawValue).sorted()
|
||||
self.pausedUntil = pausedUntil
|
||||
self.createdAt = createdAt
|
||||
// Raw per-kind columns start at the reference defaults, then the
|
||||
// Raw per-kind columns start at the default values, then the
|
||||
// configuration overwrites the ones that apply to its kind.
|
||||
self.kindRaw = configuration.kind.rawValue
|
||||
self.startMinutes = 9 * 60
|
||||
|
||||
@@ -25,8 +25,8 @@ struct RuleDraft: Hashable {
|
||||
|
||||
var kind: RuleKind { configuration.kind }
|
||||
|
||||
/// 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).
|
||||
/// A fresh draft for a new rule of the given kind, using the default
|
||||
/// values (9–5 weekdays schedule, 45m/day, 5 opens/day).
|
||||
init(kind: RuleKind) {
|
||||
self.name = kind.defaultRuleName
|
||||
self.days = Weekday.weekdays
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A suggested schedule rule shown in the New Rule sheet's preset gallery,
|
||||
/// mirroring the reference app's sections and timings.
|
||||
/// A suggested schedule rule shown in the New Rule sheet's preset gallery.
|
||||
struct RulePreset: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
@@ -14,7 +13,7 @@ struct RulePreset: Identifiable, Hashable, Sendable {
|
||||
let endMinutes: Int
|
||||
let days: Set<Weekday>
|
||||
let symbolName: String
|
||||
/// Gradient stand-in for the reference app's photo backgrounds.
|
||||
/// Gradient background shown behind each preset card.
|
||||
let gradientTop: Color
|
||||
let gradientBottom: Color
|
||||
|
||||
@@ -23,7 +22,7 @@ struct RulePreset: Identifiable, Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// A titled group of presets ("Get More Done", "Sleep, Relax and Reset", …).
|
||||
/// A titled group of presets ("Focus Time", "Rest & Recharge", …).
|
||||
struct RulePresetSection: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let title: String
|
||||
@@ -32,20 +31,20 @@ struct RulePresetSection: Identifiable, Hashable, Sendable {
|
||||
|
||||
static let all: [RulePresetSection] = [
|
||||
RulePresetSection(
|
||||
id: "productivity",
|
||||
title: "Get More Done",
|
||||
subtitle: "Maximize your productivity while staying sane.",
|
||||
id: "focus",
|
||||
title: "Focus Time",
|
||||
subtitle: "Protect your deep-work hours.",
|
||||
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)
|
||||
id: "morning-focus", name: "Morning Focus",
|
||||
startMinutes: 8 * 60, endMinutes: 11 * 60 + 30, days: Weekday.weekdays,
|
||||
symbolName: "sunrise.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: "laser-focus", name: "Laser Focus",
|
||||
startMinutes: 14 * 60, endMinutes: 18 * 60, days: Weekday.weekdays,
|
||||
id: "deep-work", name: "Deep Work",
|
||||
startMinutes: 13 * 60 + 30, endMinutes: 16 * 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)
|
||||
@@ -53,20 +52,20 @@ struct RulePresetSection: Identifiable, Hashable, Sendable {
|
||||
]
|
||||
),
|
||||
RulePresetSection(
|
||||
id: "sleep",
|
||||
title: "Sleep, Relax and Reset",
|
||||
subtitle: "Sleep better, rise refreshed.",
|
||||
id: "rest",
|
||||
title: "Rest & Recharge",
|
||||
subtitle: "Wind the day down on schedule.",
|
||||
presets: [
|
||||
RulePreset(
|
||||
id: "wind-down", name: "Wind Down",
|
||||
startMinutes: 20 * 60, endMinutes: 22 * 60, days: Weekday.everyDay,
|
||||
id: "evening-reset", name: "Evening Reset",
|
||||
startMinutes: 21 * 60, endMinutes: 23 * 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,
|
||||
id: "lights-out", name: "Lights Out",
|
||||
startMinutes: 23 * 60, endMinutes: 6 * 60 + 30, 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)
|
||||
@@ -74,21 +73,21 @@ struct RulePresetSection: Identifiable, Hashable, Sendable {
|
||||
]
|
||||
),
|
||||
RulePresetSection(
|
||||
id: "habits",
|
||||
title: "Build Healthy Habits",
|
||||
subtitle: "Spend time on important things.",
|
||||
id: "balance",
|
||||
title: "Healthy Balance",
|
||||
subtitle: "Make room for what matters.",
|
||||
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)
|
||||
id: "family-dinner", name: "Family Dinner",
|
||||
startMinutes: 18 * 60, endMinutes: 19 * 60 + 30, days: Weekday.everyDay,
|
||||
symbolName: "fork.knife",
|
||||
gradientTop: Color(red: 0.16, green: 0.27, blue: 0.22),
|
||||
gradientBottom: Color(red: 0.05, green: 0.10, blue: 0.08)
|
||||
),
|
||||
RulePreset(
|
||||
id: "gym-time", name: "Gym Time",
|
||||
startMinutes: 17 * 60 + 30, endMinutes: 18 * 60 + 30, days: Weekday.everyDay,
|
||||
symbolName: "figure.run",
|
||||
id: "screen-free-sunday", name: "Screen-Free Sunday",
|
||||
startMinutes: 9 * 60, endMinutes: 20 * 60, days: [.sunday],
|
||||
symbolName: "leaf.fill",
|
||||
gradientTop: Color(red: 0.13, green: 0.30, blue: 0.30),
|
||||
gradientBottom: Color(red: 0.03, green: 0.10, blue: 0.10)
|
||||
),
|
||||
|
||||
@@ -13,6 +13,7 @@ struct OpenAppLockApp: App {
|
||||
private let container: ModelContainer
|
||||
@State private var authorization: ScreenTimeAuthorization
|
||||
@State private var enforcer: RuleEnforcer
|
||||
@State private var settings: AppSettingsStore
|
||||
|
||||
init() {
|
||||
let config = LaunchConfiguration.current
|
||||
@@ -21,6 +22,15 @@ struct OpenAppLockApp: App {
|
||||
UserDefaults.standard.set(onboardingCompleted, forKey: "hasCompletedOnboarding")
|
||||
}
|
||||
|
||||
// The app-group suite persists across UI-test launches (unlike the
|
||||
// in-memory SwiftData store), so start each UI-test run from a clean
|
||||
// Uninstall Protection state.
|
||||
if config.isUITesting {
|
||||
AppSettingsStore.resetForTesting()
|
||||
}
|
||||
let appSettings = AppSettingsStore()
|
||||
_settings = State(initialValue: appSettings)
|
||||
|
||||
let schema = Schema([BlockingRule.self, AppList.self])
|
||||
let modelConfiguration = ModelConfiguration(
|
||||
schema: schema, isStoredInMemoryOnly: config.isUITesting
|
||||
@@ -58,7 +68,7 @@ struct OpenAppLockApp: App {
|
||||
_enforcer = State(
|
||||
initialValue: RuleEnforcer(
|
||||
shields: shields, usage: usageLedger, scheduler: scheduler,
|
||||
openSessions: openSessions))
|
||||
openSessions: openSessions, settings: appSettings))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
@@ -66,6 +76,7 @@ struct OpenAppLockApp: App {
|
||||
RootView()
|
||||
.environment(authorization)
|
||||
.environment(enforcer)
|
||||
.environment(settings)
|
||||
}
|
||||
.modelContainer(container)
|
||||
}
|
||||
|
||||
53
OpenAppLock/Services/AppSettings.swift
Normal file
53
OpenAppLock/Services/AppSettings.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// AppSettings.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Read access to the app-wide settings the enforcer consults. Injected into
|
||||
/// `RuleEnforcer` so the uninstall-protection decision has a single,
|
||||
/// test-mockable source of truth.
|
||||
protocol AppSettingsReading: AnyObject {
|
||||
/// Whether the user has opted into denying device app removal while a Hard
|
||||
/// Mode rule is actively blocking.
|
||||
var uninstallProtectionEnabled: Bool { get }
|
||||
}
|
||||
|
||||
/// The settings store, backed by the shared app-group defaults so the value
|
||||
/// persists across launches (and is reachable by the extensions in future).
|
||||
/// Observable so the Settings screen's toggle stays in sync.
|
||||
@Observable
|
||||
final class AppSettingsStore: AppSettingsReading {
|
||||
static let uninstallProtectionKey = "uninstallProtectionEnabled"
|
||||
|
||||
@ObservationIgnored private let defaults: UserDefaults
|
||||
|
||||
var uninstallProtectionEnabled: Bool {
|
||||
didSet {
|
||||
defaults.set(uninstallProtectionEnabled, forKey: Self.uninstallProtectionKey)
|
||||
}
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
// Property observers don't fire during init, so this load doesn't write back.
|
||||
self.uninstallProtectionEnabled = defaults.bool(forKey: Self.uninstallProtectionKey)
|
||||
}
|
||||
|
||||
/// Clears the persisted value — used by UI-test launches, since the
|
||||
/// app-group suite is not wiped between runs the way the SwiftData store is.
|
||||
static func resetForTesting(defaults: UserDefaults = AppGroup.defaults) {
|
||||
defaults.removeObject(forKey: uninstallProtectionKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory settings for unit tests.
|
||||
final class MockAppSettings: AppSettingsReading {
|
||||
var uninstallProtectionEnabled: Bool
|
||||
|
||||
init(uninstallProtectionEnabled: Bool = false) {
|
||||
self.uninstallProtectionEnabled = uninstallProtectionEnabled
|
||||
}
|
||||
}
|
||||
@@ -27,16 +27,21 @@ final class RuleEnforcer {
|
||||
/// Granted-open sessions, so a proactively-gated open-limit rule is left
|
||||
/// un-shielded while the user is inside a session they paid an open for.
|
||||
private let openSessions: OpenSessionReading
|
||||
/// App-wide settings (currently just Uninstall Protection) consulted on
|
||||
/// every refresh.
|
||||
private let settings: any AppSettingsReading
|
||||
|
||||
init(
|
||||
shields: ShieldApplying, usage: UsageReading = UsageLedger(),
|
||||
scheduler: RuleScheduler? = nil,
|
||||
openSessions: OpenSessionReading = OpenSessionStore()
|
||||
openSessions: OpenSessionReading = OpenSessionStore(),
|
||||
settings: any AppSettingsReading = AppSettingsStore()
|
||||
) {
|
||||
self.shields = shields
|
||||
self.usageReader = usage
|
||||
self.scheduler = scheduler
|
||||
self.openSessions = openSessions
|
||||
self.settings = settings
|
||||
}
|
||||
|
||||
/// The day's usage for a rule (nil for schedule rules, which don't track).
|
||||
@@ -84,6 +89,14 @@ final class RuleEnforcer {
|
||||
// "Blocked Apps" lists only rules whose budget/window is spent — not the
|
||||
// proactive open-limit gate, which surfaces under "Usage" instead.
|
||||
blockingRuleIDs = blocking
|
||||
// Uninstall Protection: deny device app removal while the user has opted
|
||||
// in and any Hard Mode rule is actively blocking.
|
||||
shields.setAppRemovalDenied(
|
||||
RulePolicy.shouldDenyAppRemoval(
|
||||
rules: rules,
|
||||
enabled: settings.uninstallProtectionEnabled,
|
||||
usageFor: { usage(for: $0, at: now, calendar: calendar) },
|
||||
at: now, calendar: calendar))
|
||||
scheduler?.sync(rules: rules, at: now)
|
||||
}
|
||||
|
||||
|
||||
154
OpenAppLock/Views/AppLists/AppListLibraryView.swift
Normal file
154
OpenAppLock/Views/AppLists/AppListLibraryView.swift
Normal file
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// AppListLibraryView.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
/// The reusable app-list library: the saved lists, an Edit affordance, the New
|
||||
/// List flow, swipe-to-delete, and the Hard Mode lock. Used in two modes:
|
||||
///
|
||||
/// - **Picker** (`selection` non-nil): each row shows a checkmark and tapping it
|
||||
/// selects the list and calls `onPick` (the rule editor uses this to dismiss).
|
||||
/// Creating a list selects it without dismissing.
|
||||
/// - **Management** (`selection` nil): no checkmark; tapping a row (when unlocked)
|
||||
/// opens it for editing. Used by Settings ▸ Manage App Lists.
|
||||
///
|
||||
/// In both modes editing and deletion are disabled while any Hard Mode rule is
|
||||
/// actively blocking — changing a list would be a back door out of the block.
|
||||
struct AppListLibraryView: View {
|
||||
/// Picker mode when non-nil; management mode when nil.
|
||||
var selection: Binding<AppList?>?
|
||||
/// Called after a row is tapped in picker mode (e.g. to dismiss the sheet).
|
||||
var onPick: (() -> Void)?
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(RuleEnforcer.self) private var enforcer
|
||||
@Query(sort: \AppList.createdAt) private var lists: [AppList]
|
||||
@Query private var rules: [BlockingRule]
|
||||
|
||||
@State private var editingList: AppList?
|
||||
@State private var creatingList = false
|
||||
@State private var deletionBlocked = false
|
||||
|
||||
private var isPicking: Bool { selection != nil }
|
||||
|
||||
/// While any hard-mode rule is actively blocking, lists are read-only.
|
||||
private var listsLocked: Bool {
|
||||
!RulePolicy.canEditAppLists(rules: rules, usageFor: { enforcer.usage(for: $0) })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
if lists.isEmpty {
|
||||
Text("No app lists yet. Create one to choose which apps a rule affects.")
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("emptyAppListsLabel")
|
||||
} else {
|
||||
ForEach(lists) { list in
|
||||
listRow(list)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Your App Lists").textCase(nil)
|
||||
} footer: {
|
||||
if listsLocked {
|
||||
Label(
|
||||
"Hard Mode is on — app lists are locked until the block ends.",
|
||||
systemImage: "lock.fill"
|
||||
)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("appListsLockedNotice")
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Button {
|
||||
creatingList = true
|
||||
} label: {
|
||||
Label("New List", systemImage: "plus")
|
||||
}
|
||||
.accessibilityIdentifier("newAppListButton")
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $creatingList) {
|
||||
AppListEditorView(list: nil) { created in
|
||||
selection?.wrappedValue = created
|
||||
creatingList = false
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $editingList) { list in
|
||||
AppListEditorView(list: list) { _ in
|
||||
editingList = nil
|
||||
}
|
||||
}
|
||||
.alert("This list is in use", isPresented: $deletionBlocked) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Remove it from the rules that use it before deleting.")
|
||||
}
|
||||
}
|
||||
|
||||
private func listRow(_ list: AppList) -> some View {
|
||||
HStack {
|
||||
Button {
|
||||
if isPicking {
|
||||
selection?.wrappedValue = list
|
||||
onPick?()
|
||||
} else if !listsLocked {
|
||||
editingList = list
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
if isPicking {
|
||||
Image(systemName: isSelected(list) ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(
|
||||
isSelected(list) ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.secondary)
|
||||
)
|
||||
.frame(width: 28)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(list.name)
|
||||
.foregroundStyle(Color.primary)
|
||||
Text(list.appCountLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("appListRow-\(list.name)")
|
||||
Spacer()
|
||||
if !listsLocked {
|
||||
Button("Edit") {
|
||||
editingList = list
|
||||
}
|
||||
.font(.subheadline)
|
||||
.accessibilityIdentifier("editAppListButton-\(list.name)")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.swipeActions {
|
||||
if !listsLocked {
|
||||
Button("Delete", role: .destructive) {
|
||||
delete(list)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isSelected(_ list: AppList) -> Bool {
|
||||
selection?.wrappedValue?.id == list.id
|
||||
}
|
||||
|
||||
private func delete(_ list: AppList) {
|
||||
guard !AppList.isInUse(list, context: modelContext) else {
|
||||
deletionBlocked = true
|
||||
return
|
||||
}
|
||||
if isSelected(list) {
|
||||
selection?.wrappedValue = nil
|
||||
}
|
||||
modelContext.delete(list)
|
||||
}
|
||||
}
|
||||
@@ -3,151 +3,31 @@
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
/// Chooses the app list a rule uses: saved lists (tap to select), an Edit
|
||||
/// affordance per list, and a "New List" flow. Lists are persisted directly,
|
||||
/// independent of any rule, so creating one here never depends on the rule
|
||||
/// draft being committed.
|
||||
/// Chooses the app list a rule uses. Wraps the shared `AppListLibraryView` in
|
||||
/// picker mode (checkmark + select-and-dismiss); the library handles the list
|
||||
/// rows, Edit/New flows, deletion, and the Hard Mode lock.
|
||||
struct AppListPickerSheet: View {
|
||||
@Binding var selected: AppList?
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(RuleEnforcer.self) private var enforcer
|
||||
@Query(sort: \AppList.createdAt) private var lists: [AppList]
|
||||
@Query private var rules: [BlockingRule]
|
||||
|
||||
@State private var editingList: AppList?
|
||||
@State private var creatingList = false
|
||||
@State private var deletionBlocked = false
|
||||
|
||||
/// While any hard-mode rule is actively blocking (by the clock or a spent
|
||||
/// limit budget), lists are read-only — editing one would be a back door
|
||||
/// out of the hard block.
|
||||
private var listsLocked: Bool {
|
||||
!RulePolicy.canEditAppLists(rules: rules, usageFor: { enforcer.usage(for: $0) })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
if lists.isEmpty {
|
||||
Text("No app lists yet. Create one to choose which apps this rule affects.")
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("emptyAppListsLabel")
|
||||
} else {
|
||||
ForEach(lists) { list in
|
||||
listRow(list)
|
||||
AppListLibraryView(selection: $selected, onPick: { dismiss() })
|
||||
.navigationTitle("App List")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close", systemImage: "xmark") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Your App Lists").textCase(nil)
|
||||
} footer: {
|
||||
if listsLocked {
|
||||
Label(
|
||||
"Hard Mode is on — app lists are locked until the block ends.",
|
||||
systemImage: "lock.fill"
|
||||
)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("appListsLockedNotice")
|
||||
.accessibilityIdentifier("closeAppListPickerButton")
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Button {
|
||||
creatingList = true
|
||||
} label: {
|
||||
Label("New List", systemImage: "plus")
|
||||
}
|
||||
.accessibilityIdentifier("newAppListButton")
|
||||
}
|
||||
}
|
||||
.navigationTitle("App List")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close", systemImage: "xmark") {
|
||||
dismiss()
|
||||
}
|
||||
.accessibilityIdentifier("closeAppListPickerButton")
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $creatingList) {
|
||||
AppListEditorView(list: nil) { created in
|
||||
selected = created
|
||||
creatingList = false
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $editingList) { list in
|
||||
AppListEditorView(list: list) { _ in
|
||||
editingList = nil
|
||||
}
|
||||
}
|
||||
.alert("This list is in use", isPresented: $deletionBlocked) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Remove it from the rules that use it before deleting.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func listRow(_ list: AppList) -> some View {
|
||||
HStack {
|
||||
Button {
|
||||
selected = list
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: isSelected(list) ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(
|
||||
isSelected(list) ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.secondary)
|
||||
)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(list.name)
|
||||
.foregroundStyle(Color.primary)
|
||||
Text(list.appCountLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("appListRow-\(list.name)")
|
||||
Spacer()
|
||||
if !listsLocked {
|
||||
Button("Edit") {
|
||||
editingList = list
|
||||
}
|
||||
.font(.subheadline)
|
||||
.accessibilityIdentifier("editAppListButton-\(list.name)")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.swipeActions {
|
||||
if !listsLocked {
|
||||
Button("Delete", role: .destructive) {
|
||||
delete(list)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isSelected(_ list: AppList) -> Bool {
|
||||
selected?.id == list.id
|
||||
}
|
||||
|
||||
private func delete(_ list: AppList) {
|
||||
guard !AppList.isInUse(list, context: modelContext) else {
|
||||
deletionBlocked = true
|
||||
return
|
||||
}
|
||||
if isSelected(list) {
|
||||
selected = nil
|
||||
}
|
||||
modelContext.delete(list)
|
||||
}
|
||||
}
|
||||
|
||||
extension AppList {
|
||||
|
||||
17
OpenAppLock/Views/AppLists/ManageAppListsView.swift
Normal file
17
OpenAppLock/Views/AppLists/ManageAppListsView.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// ManageAppListsView.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Standalone app-list management (Settings ▸ Manage App Lists): the same
|
||||
/// create / edit / delete flow the rule editor's picker uses, minus selection.
|
||||
/// Pushed inside the Settings tab's navigation stack.
|
||||
struct ManageAppListsView: View {
|
||||
var body: some View {
|
||||
AppListLibraryView()
|
||||
.navigationTitle("App Lists")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
//
|
||||
// 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, usage: enforcer.usage(for: 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)
|
||||
usageSection(now: now)
|
||||
rulesSection(now: now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Status with the day's usage folded in, so limit rules whose budget is
|
||||
/// spent count as actively blocking.
|
||||
private func liveStatus(for rule: BlockingRule, now: Date) -> RuleStatus {
|
||||
rule.status(at: now, usage: enforcer.usage(for: rule, at: now))
|
||||
}
|
||||
|
||||
// MARK: - Blocked Apps
|
||||
|
||||
@ViewBuilder
|
||||
private func blockedSection(now: Date) -> some View {
|
||||
let blocking = rules.filter { liveStatus(for: $0, now: 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, usage: enforcer.usage(for: rule, at: now), 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: - Usage
|
||||
|
||||
/// Live tracking for every limit rule scheduled today: how much of the
|
||||
/// daily budget is spent and what remains.
|
||||
@ViewBuilder
|
||||
private func usageSection(now: Date) -> some View {
|
||||
let tracked = rules.filter {
|
||||
$0.kind != .schedule && $0.isEnabled && $0.isScheduledToday(at: now)
|
||||
}
|
||||
if !tracked.isEmpty {
|
||||
Section {
|
||||
ForEach(tracked) { rule in
|
||||
usageRow(for: rule, now: now)
|
||||
}
|
||||
} header: {
|
||||
Text("Usage").textCase(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func usageRow(for rule: BlockingRule, now: Date) -> some View {
|
||||
let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage()
|
||||
let isPaused =
|
||||
if case .paused = liveStatus(for: rule, now: now) { true } else { false }
|
||||
return HStack {
|
||||
Image(systemName: rule.kind.symbolName)
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(rule.name)
|
||||
.foregroundStyle(Color.primary)
|
||||
Text(UsageDisplay.subtitle(for: rule, usage: usage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(UsageDisplay.remainingLabel(for: rule, usage: usage, isPaused: isPaused))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(
|
||||
rule.limitReached(given: usage) && !isPaused
|
||||
? AnyShapeStyle(Color.red) : AnyShapeStyle(Color.secondary)
|
||||
)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("usageRow-\(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 = liveStatus(for: rule, now: 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 {
|
||||
"\(rule.selectionMode.displayName) · \(rule.appList?.name ?? "No apps")"
|
||||
}
|
||||
|
||||
private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String {
|
||||
// Shared with the detail sheet: limit rules that aren't blocking show
|
||||
// their daily budget; everything else uses the live status label.
|
||||
rule.statusLabel(for: status, relativeTo: now)
|
||||
}
|
||||
|
||||
// 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.selectionModeRaw)|\($0.appList?.id.uuidString ?? "-")|"
|
||||
+ "\($0.appList?.selectionCount ?? 0)|"
|
||||
+ "\($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))
|
||||
}
|
||||
}
|
||||
}
|
||||
165
OpenAppLock/Views/Home/HomeView.swift
Normal file
165
OpenAppLock/Views/Home/HomeView.swift
Normal file
@@ -0,0 +1,165 @@
|
||||
//
|
||||
// HomeView.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
/// The Home tab: what's blocking right now, and live usage for today's limit
|
||||
/// rules. The rule list and rule creation live on the Rules tab.
|
||||
struct HomeView: View {
|
||||
@Environment(RuleEnforcer.self) private var enforcer
|
||||
@Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule]
|
||||
|
||||
@State private var unblockCandidate: BlockingRule?
|
||||
@State private var hardModeBlockedAttempt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
TimelineView(.periodic(from: .now, by: 30)) { timeline in
|
||||
homeList(now: timeline.date)
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
}
|
||||
.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, usage: enforcer.usage(for: rule))
|
||||
enforcer.refresh(rules: rules)
|
||||
}
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
||||
private func homeList(now: Date) -> some View {
|
||||
List {
|
||||
blockingSection(now: now)
|
||||
usageSection(now: now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Status with the day's usage folded in, so limit rules whose budget is
|
||||
/// spent count as actively blocking.
|
||||
private func liveStatus(for rule: BlockingRule, now: Date) -> RuleStatus {
|
||||
rule.status(at: now, usage: enforcer.usage(for: rule, at: now))
|
||||
}
|
||||
|
||||
// MARK: - Currently Blocking
|
||||
|
||||
@ViewBuilder
|
||||
private func blockingSection(now: Date) -> some View {
|
||||
let blocking = rules.filter { liveStatus(for: $0, now: now).isActive }
|
||||
Section {
|
||||
if blocking.isEmpty {
|
||||
Text("Nothing is blocking right now.")
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityIdentifier("nothingBlockedLabel")
|
||||
} else {
|
||||
ForEach(blocking) { rule in
|
||||
blockingRow(for: rule, now: now)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Currently Blocking").textCase(nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// A blocking rule: no leading icon. A limit rule shows its type + usage so
|
||||
/// the kind reads without an icon; a schedule rule shows just its name.
|
||||
/// Trailing affordance: a lock when Hard Mode (the block can't be lifted),
|
||||
/// otherwise an Unblock button.
|
||||
private func blockingRow(for rule: BlockingRule, now: Date) -> some View {
|
||||
let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage()
|
||||
return Button {
|
||||
if RulePolicy.canUnblock(rule, usage: enforcer.usage(for: rule, at: now), at: now) {
|
||||
unblockCandidate = rule
|
||||
} else {
|
||||
hardModeBlockedAttempt = true
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(rule.name)
|
||||
.foregroundStyle(Color.primary)
|
||||
if rule.kind != .schedule {
|
||||
Text(UsageDisplay.typedSubtitle(for: rule, usage: usage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if rule.hardMode {
|
||||
Image(systemName: "lock.fill")
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
Text("Unblock")
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("blockedTile-\(rule.name)")
|
||||
}
|
||||
|
||||
// MARK: - Usage
|
||||
|
||||
/// Live tracking for every limit rule scheduled today that is *not* already
|
||||
/// blocking. Once a budget is spent (the rule is actively blocking) the row
|
||||
/// moves up to "Currently Blocking"; a soft-unblocked rule (paused) stays
|
||||
/// here reading "Unblocked until tomorrow".
|
||||
@ViewBuilder
|
||||
private func usageSection(now: Date) -> some View {
|
||||
let tracked = rules.filter {
|
||||
$0.kind != .schedule && $0.isEnabled && $0.isScheduledToday(at: now)
|
||||
&& !liveStatus(for: $0, now: now).isActive
|
||||
}
|
||||
if !tracked.isEmpty {
|
||||
Section {
|
||||
ForEach(tracked) { rule in
|
||||
usageRow(for: rule, now: now)
|
||||
}
|
||||
} header: {
|
||||
Text("Usage").textCase(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func usageRow(for rule: BlockingRule, now: Date) -> some View {
|
||||
let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage()
|
||||
let isPaused =
|
||||
if case .paused = liveStatus(for: rule, now: now) { true } else { false }
|
||||
return HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(rule.name)
|
||||
.foregroundStyle(Color.primary)
|
||||
Text(UsageDisplay.typedSubtitle(for: rule, usage: usage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(UsageDisplay.remainingLabel(for: rule, usage: usage, isPaused: isPaused))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(
|
||||
rule.limitReached(given: usage) && !isPaused
|
||||
? AnyShapeStyle(Color.red) : AnyShapeStyle(Color.secondary)
|
||||
)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("usageRow-\(rule.name)")
|
||||
}
|
||||
}
|
||||
68
OpenAppLock/Views/MainTabView.swift
Normal file
68
OpenAppLock/Views/MainTabView.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// MainTabView.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
/// The post-onboarding shell: a three-tab layout (Home / Rules / Settings).
|
||||
///
|
||||
/// The app-level enforcement lifecycle lives here, not on any one tab, so it
|
||||
/// runs regardless of the selected tab: a 30 s `refresh` loop, a reconcile on
|
||||
/// any blocking-relevant rule change, and a reconcile whenever the app becomes
|
||||
/// active (so Uninstall Protection re-evaluates on every foreground).
|
||||
struct MainTabView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(RuleEnforcer.self) private var enforcer
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule]
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
HomeView()
|
||||
.tabItem { Label("Home", systemImage: "house") }
|
||||
RulesListView()
|
||||
.tabItem { Label("Rules", systemImage: "shield.lefthalf.filled") }
|
||||
SettingsView()
|
||||
.tabItem { Label("Settings", systemImage: "gearshape") }
|
||||
}
|
||||
.task {
|
||||
await enforcementLoop()
|
||||
}
|
||||
.onChange(of: ruleChangeToken) {
|
||||
refreshEnforcement()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active { refreshEnforcement() }
|
||||
}
|
||||
}
|
||||
|
||||
// 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.selectionModeRaw)|\($0.appList?.id.uuidString ?? "-")|"
|
||||
+ "\($0.appList?.selectionCount ?? 0)|"
|
||||
+ "\($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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ struct RootView: View {
|
||||
var body: some View {
|
||||
Group {
|
||||
if hasCompletedOnboarding {
|
||||
AppsHomeView()
|
||||
MainTabView()
|
||||
} else {
|
||||
OnboardingView {
|
||||
hasCompletedOnboarding = true
|
||||
|
||||
109
OpenAppLock/Views/Rules/RulesListView.swift
Normal file
109
OpenAppLock/Views/Rules/RulesListView.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// RulesListView.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
/// The Rules tab: every rule, grouped into Schedule / Time Limit / Open Limit
|
||||
/// sections. "+" creates a new rule; tapping a row opens its detail sheet.
|
||||
struct RulesListView: View {
|
||||
@Environment(RuleEnforcer.self) private var enforcer
|
||||
@Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule]
|
||||
|
||||
@State private var detailRule: BlockingRule?
|
||||
@State private var showingNewRule = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
TimelineView(.periodic(from: .now, by: 30)) { timeline in
|
||||
rulesList(now: timeline.date)
|
||||
}
|
||||
.navigationTitle("Rules")
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func rulesList(now: Date) -> some View {
|
||||
List {
|
||||
if rules.isEmpty {
|
||||
Section {
|
||||
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 {
|
||||
kindSection(.schedule, now: now)
|
||||
kindSection(.timeLimit, now: now)
|
||||
kindSection(.openLimit, now: now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One section per rule kind; empty kinds are omitted entirely.
|
||||
@ViewBuilder
|
||||
private func kindSection(_ kind: RuleKind, now: Date) -> some View {
|
||||
let kindRules = rules.filter { $0.kind == kind }
|
||||
if !kindRules.isEmpty {
|
||||
Section {
|
||||
ForEach(kindRules) { rule in
|
||||
ruleRow(for: rule, now: now)
|
||||
}
|
||||
} header: {
|
||||
Text(kind.displayName).textCase(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ruleRow(for rule: BlockingRule, now: Date) -> some View {
|
||||
let status = rule.status(at: now, usage: enforcer.usage(for: rule, 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(rule.statusLabel(for: status, relativeTo: now))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(status.isActive ? .green : .secondary)
|
||||
.accessibilityIdentifier("ruleStatus-\(rule.name)")
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("ruleCard-\(rule.name)")
|
||||
}
|
||||
|
||||
private func blockSummary(for rule: BlockingRule) -> String {
|
||||
"\(rule.selectionMode.displayName) · \(rule.appList?.name ?? "No apps")"
|
||||
}
|
||||
}
|
||||
63
OpenAppLock/Views/Settings/SettingsView.swift
Normal file
63
OpenAppLock/Views/Settings/SettingsView.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// SettingsView.swift
|
||||
// OpenAppLock
|
||||
//
|
||||
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
/// The Settings tab: device-level protection and app-list management.
|
||||
struct SettingsView: View {
|
||||
@Environment(RuleEnforcer.self) private var enforcer
|
||||
@Environment(AppSettingsStore.self) private var settings
|
||||
@Query private var rules: [BlockingRule]
|
||||
|
||||
/// Local mirror of the persisted setting; the explicit binding below writes
|
||||
/// it through to the store and re-enforces in one step.
|
||||
@State private var uninstallProtectionOn = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Toggle("Uninstall Protection", isOn: uninstallProtectionBinding)
|
||||
.accessibilityIdentifier("uninstallProtectionToggle")
|
||||
} header: {
|
||||
Text("Protection").textCase(nil)
|
||||
} footer: {
|
||||
Text(
|
||||
"While on, apps can't be deleted from this device whenever a "
|
||||
+ "Hard Mode rule is actively blocking — so the block can't be "
|
||||
+ "removed by uninstalling.")
|
||||
}
|
||||
Section {
|
||||
NavigationLink {
|
||||
ManageAppListsView()
|
||||
} label: {
|
||||
Label("Manage App Lists", systemImage: "square.stack.3d.up")
|
||||
}
|
||||
.accessibilityIdentifier("manageAppListsButton")
|
||||
} header: {
|
||||
Text("App Lists").textCase(nil)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
.onAppear {
|
||||
uninstallProtectionOn = settings.uninstallProtectionEnabled
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives the toggle's visual state from `@State` while persisting and
|
||||
/// re-enforcing on every change — so protection engages/lifts immediately.
|
||||
private var uninstallProtectionBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { uninstallProtectionOn },
|
||||
set: { newValue in
|
||||
uninstallProtectionOn = newValue
|
||||
settings.uninstallProtectionEnabled = newValue
|
||||
enforcer.refresh(rules: rules)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ struct RuleConfigurationTests {
|
||||
#expect(RuleConfiguration.openLimit(OpenLimitConfig()).kind == .openLimit)
|
||||
}
|
||||
|
||||
@Test("Defaults match the reference app's new-rule defaults")
|
||||
@Test("Defaults match the documented new-rule defaults")
|
||||
func defaults() {
|
||||
let schedule = RuleConfiguration.default(for: .schedule).scheduleConfig
|
||||
#expect(schedule?.startMinutes == 9 * 60)
|
||||
|
||||
@@ -269,3 +269,71 @@ struct OverlappingRuleEnforcementTests {
|
||||
#expect(shields.shieldedRuleIDs == [rule.id])
|
||||
}
|
||||
}
|
||||
|
||||
/// Uninstall Protection: `refresh` denies device app removal only while the
|
||||
/// user opted in *and* a Hard Mode rule is actively blocking.
|
||||
@MainActor
|
||||
@Suite("Uninstall protection enforcement")
|
||||
struct UninstallProtectionEnforcementTests {
|
||||
let mondayDuringWork = date(2025, 1, 6, 10, 0) // inside the default 09:00–17:00
|
||||
let mondayEvening = date(2025, 1, 6, 19, 0) // outside it
|
||||
|
||||
private func hardRule() -> BlockingRule {
|
||||
BlockingRule(name: "Locked In", hardMode: true)
|
||||
}
|
||||
|
||||
@Test("Disabled setting never denies app removal")
|
||||
func disabledSettingNeverDenies() {
|
||||
let shields = MockShieldController()
|
||||
let enforcer = RuleEnforcer(
|
||||
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: false))
|
||||
|
||||
enforcer.refresh(rules: [hardRule()], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
#expect(!shields.appRemovalDenied)
|
||||
}
|
||||
|
||||
@Test("Enabled setting denies removal while a hard rule is blocking")
|
||||
func deniesDuringHardBlock() {
|
||||
let shields = MockShieldController()
|
||||
let enforcer = RuleEnforcer(
|
||||
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: true))
|
||||
|
||||
enforcer.refresh(rules: [hardRule()], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
#expect(shields.appRemovalDenied)
|
||||
}
|
||||
|
||||
@Test("A soft rule does not deny removal even with the setting on")
|
||||
func softRuleDoesNotDeny() {
|
||||
let shields = MockShieldController()
|
||||
let enforcer = RuleEnforcer(
|
||||
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: true))
|
||||
|
||||
enforcer.refresh(rules: [BlockingRule(name: "Work Time")], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
#expect(!shields.appRemovalDenied)
|
||||
}
|
||||
|
||||
@Test("Denial lifts once the hard window ends")
|
||||
func liftsWhenWindowEnds() {
|
||||
let shields = MockShieldController()
|
||||
let enforcer = RuleEnforcer(
|
||||
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: true))
|
||||
let rule = hardRule()
|
||||
|
||||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||||
#expect(shields.appRemovalDenied)
|
||||
|
||||
enforcer.refresh(rules: [rule], at: mondayEvening, calendar: utc)
|
||||
#expect(!shields.appRemovalDenied)
|
||||
}
|
||||
|
||||
@Test("clearShields(except:) does not disturb the app-removal denial")
|
||||
func clearShieldsPreservesDenial() {
|
||||
let shields = MockShieldController()
|
||||
shields.setAppRemovalDenied(true)
|
||||
shields.clearShields(except: [])
|
||||
#expect(shields.appRemovalDenied)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import Testing
|
||||
@MainActor
|
||||
@Suite("BlockingRule model & persistence")
|
||||
struct RuleModelTests {
|
||||
@Test("Defaults match the reference app's new-rule defaults")
|
||||
@Test("Defaults match the documented new-rule defaults")
|
||||
func defaults() {
|
||||
let rule = BlockingRule(name: "In the Zone")
|
||||
#expect(rule.kind == .schedule)
|
||||
@@ -177,12 +177,12 @@ struct RuleDraftTests {
|
||||
let preset = try #require(
|
||||
RulePresetSection.all
|
||||
.flatMap(\.presets)
|
||||
.first { $0.id == "deep-sleep" }
|
||||
.first { $0.id == "lights-out" }
|
||||
)
|
||||
let draft = RuleDraft(preset: preset)
|
||||
#expect(draft.name == "Deep Sleep")
|
||||
#expect(draft.scheduleConfig.startMinutes == 22 * 60)
|
||||
#expect(draft.scheduleConfig.endMinutes == 6 * 60)
|
||||
#expect(draft.name == "Lights Out")
|
||||
#expect(draft.scheduleConfig.startMinutes == 23 * 60)
|
||||
#expect(draft.scheduleConfig.endMinutes == 6 * 60 + 30)
|
||||
#expect(draft.kind == .schedule)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,3 +79,60 @@ struct RulePolicyTests {
|
||||
#expect(rule.pausedUntil == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("Uninstall protection policy")
|
||||
struct UninstallProtectionPolicyTests {
|
||||
let mondayDuringWork = date(2025, 1, 6, 10, 0)
|
||||
let mondayEvening = date(2025, 1, 6, 19, 0)
|
||||
|
||||
func scheduleRule(hardMode: Bool) -> BlockingRule {
|
||||
BlockingRule(name: "Work Time", hardMode: hardMode)
|
||||
}
|
||||
|
||||
func hardLimitRule() -> BlockingRule {
|
||||
BlockingRule(
|
||||
name: "Time Keeper",
|
||||
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
|
||||
hardMode: true,
|
||||
days: Weekday.everyDay)
|
||||
}
|
||||
|
||||
@Test("App removal is only denied with the setting on AND a hard rule active")
|
||||
func deniedOnlyWhenEnabledAndHardLocked() {
|
||||
let hard = scheduleRule(hardMode: true)
|
||||
// Setting off: never deny, even with an active hard rule.
|
||||
#expect(
|
||||
!RulePolicy.shouldDenyAppRemoval(
|
||||
rules: [hard], enabled: false, at: mondayDuringWork, calendar: utc))
|
||||
// Setting on + active hard rule: deny.
|
||||
#expect(
|
||||
RulePolicy.shouldDenyAppRemoval(
|
||||
rules: [hard], enabled: true, at: mondayDuringWork, calendar: utc))
|
||||
// Setting on but the hard rule is outside its window: do not deny.
|
||||
#expect(
|
||||
!RulePolicy.shouldDenyAppRemoval(
|
||||
rules: [hard], enabled: true, at: mondayEvening, calendar: utc))
|
||||
}
|
||||
|
||||
@Test("A soft rule never triggers app-removal denial")
|
||||
func softRuleNeverDenies() {
|
||||
let soft = scheduleRule(hardMode: false)
|
||||
#expect(
|
||||
!RulePolicy.shouldDenyAppRemoval(
|
||||
rules: [soft], enabled: true, at: mondayDuringWork, calendar: utc))
|
||||
}
|
||||
|
||||
@Test("A spent hard-mode limit rule triggers denial; unspent does not")
|
||||
func spentHardLimitDenies() {
|
||||
let rule = hardLimitRule()
|
||||
#expect(
|
||||
RulePolicy.shouldDenyAppRemoval(
|
||||
rules: [rule], enabled: true, usageFor: { _ in RuleUsage(minutesUsed: 45) },
|
||||
at: mondayDuringWork, calendar: utc))
|
||||
#expect(
|
||||
!RulePolicy.shouldDenyAppRemoval(
|
||||
rules: [rule], enabled: true, usageFor: { _ in RuleUsage(minutesUsed: 10) },
|
||||
at: mondayDuringWork, calendar: utc))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import Testing
|
||||
@MainActor
|
||||
@Suite("Rule status derivation and labels")
|
||||
struct RuleStatusTests {
|
||||
/// 09:00–17:00 weekdays schedule rule, like the video's "Work Time".
|
||||
/// 09:00–17:00 weekdays schedule rule (the "Work Time" preset).
|
||||
func workTime(hardMode: Bool = false) -> BlockingRule {
|
||||
BlockingRule(name: "Work Time", hardMode: hardMode)
|
||||
}
|
||||
@@ -71,16 +71,16 @@ struct RuleStatusTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Active label matches the reference style: hours round up")
|
||||
@Test("Active label rounds hours up")
|
||||
func activeLabel() {
|
||||
// 11:28 → 17:00 is 5h32m; the reference app shows "6h left".
|
||||
// 11:28 → 17:00 is 5h32m; rounds up to "6h left".
|
||||
let status = workTime().status(at: date(2025, 1, 6, 11, 28), calendar: utc)
|
||||
#expect(status.label(relativeTo: date(2025, 1, 6, 11, 28)) == "6h left")
|
||||
}
|
||||
|
||||
@Test("Upcoming label matches the reference style")
|
||||
@Test("Upcoming label formats hours until start")
|
||||
func upcomingLabel() {
|
||||
// Friday 11:28 → Saturday 09:00 is 21h32m; the reference shows "Starts in 22h".
|
||||
// Friday 11:28 → Saturday 09:00 is 21h32m; rounds up to "Starts in 22h".
|
||||
let weekend = BlockingRule(name: "Weekend Zen", days: Weekday.weekends)
|
||||
let friday = date(2025, 1, 10, 11, 28)
|
||||
#expect(weekend.status(at: friday, calendar: utc).label(relativeTo: friday) == "Starts in 22h")
|
||||
|
||||
@@ -254,4 +254,14 @@ struct UsageDisplayTests {
|
||||
let over = RuleUsage(minutesUsed: 60)
|
||||
#expect(UsageDisplay.subtitle(for: timeRule, usage: over) == "45m of 45m used today")
|
||||
}
|
||||
|
||||
@Test("Typed subtitles prefix the rule kind so type is clear without the icon")
|
||||
func typedSubtitles() {
|
||||
#expect(
|
||||
UsageDisplay.typedSubtitle(for: timeRule, usage: RuleUsage(minutesUsed: 18))
|
||||
== "Time Limit · 18m of 45m used today")
|
||||
#expect(
|
||||
UsageDisplay.typedSubtitle(for: openRule, usage: RuleUsage(opensUsed: 2))
|
||||
== "Open Limit · 2 of 5 opens today")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
import XCTest
|
||||
|
||||
/// App lists: the editor's App List row, the picker (select / create / edit),
|
||||
/// rule-level Block vs Allow Only, and the Hard Mode list lockdown.
|
||||
/// rule-level Block vs Allow Only, and the Hard Mode list lockdown. The rule
|
||||
/// editor (and its app-list picker) is reached from the Rules tab.
|
||||
final class AppListUITests: XCTestCase {
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
@@ -14,6 +15,7 @@ final class AppListUITests: XCTestCase {
|
||||
|
||||
func testScheduleEditorOffersModeChoice() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||
|
||||
@@ -25,6 +27,7 @@ final class AppListUITests: XCTestCase {
|
||||
|
||||
func testLimitEditorsAreBlockOnly() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
|
||||
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
|
||||
@@ -46,6 +49,7 @@ final class AppListUITests: XCTestCase {
|
||||
|
||||
func testCreateAppListFlowSelectsNewList() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
|
||||
|
||||
@@ -79,6 +83,7 @@ final class AppListUITests: XCTestCase {
|
||||
|
||||
func testDetailShowsAppListName() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
app.goToRulesTab()
|
||||
app.buttons["ruleCard-Work Time"].waitToAppear().tap()
|
||||
|
||||
let row = app.element("detailRow-Block").waitToAppear()
|
||||
@@ -87,6 +92,7 @@ final class AppListUITests: XCTestCase {
|
||||
|
||||
func testHardModeSessionLocksAppListEditing() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
|
||||
app.goToRulesTab()
|
||||
|
||||
// "Sleep" is soft and editable even while "Locked In" hard-blocks.
|
||||
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||
@@ -103,6 +109,7 @@ final class AppListUITests: XCTestCase {
|
||||
|
||||
func testAppListsEditableWithoutHardSession() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
app.goToRulesTab()
|
||||
|
||||
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||
app.buttons["editRuleButton"].waitToAppear().tap()
|
||||
|
||||
@@ -17,15 +17,17 @@ final class OnboardingUITests: XCTestCase {
|
||||
app.staticTexts["OpenAppLock"].waitToAppear()
|
||||
app.buttons["onboardingContinueButton"].waitToAppear().tap()
|
||||
|
||||
// Permission step: granting (mocked) lands on the Apps home screen.
|
||||
// Permission step: granting (mocked) lands on the tabbed home screen.
|
||||
app.buttons["allowScreenTimeButton"].waitToAppear().tap()
|
||||
app.buttons["newRuleButton"].waitToAppear()
|
||||
XCTAssertTrue(app.staticTexts["Blocked Apps"].exists)
|
||||
app.tabBars.buttons["Home"].waitToAppear()
|
||||
XCTAssertTrue(app.tabBars.buttons["Rules"].exists)
|
||||
XCTAssertTrue(app.tabBars.buttons["Settings"].exists)
|
||||
XCTAssertTrue(app.staticTexts["Currently Blocking"].exists)
|
||||
}
|
||||
|
||||
func testCompletedOnboardingIsSkipped() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(onboardingCompleted: true)
|
||||
app.buttons["newRuleButton"].waitToAppear()
|
||||
app.tabBars.buttons["Home"].waitToAppear()
|
||||
XCTAssertFalse(app.buttons["onboardingContinueButton"].exists)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testCreateScheduleRuleFromTypeCard() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.element("emptyRulesCard").waitToAppear()
|
||||
|
||||
app.buttons["newRuleButton"].tap()
|
||||
@@ -33,17 +34,19 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testCreateRuleFromPreset() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
|
||||
app.buttons["preset-work-time"].waitToAppear().tap()
|
||||
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].waitToAppear().label, "Work Time")
|
||||
app.buttons["preset-morning-focus"].waitToAppear().tap()
|
||||
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].waitToAppear().label, "Morning Focus")
|
||||
|
||||
app.buttons["commitRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleCard-Work Time"].waitToAppear()
|
||||
app.buttons["ruleCard-Morning Focus"].waitToAppear()
|
||||
}
|
||||
|
||||
func testRenameRuleInEditor() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||
|
||||
@@ -63,6 +66,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testDayTogglesFillRowAndHaveLargeTapTargets() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||
|
||||
@@ -81,6 +85,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testDayTogglesUpdateSummary() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||
|
||||
@@ -93,6 +98,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testCreateTimeLimitRule() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
|
||||
|
||||
@@ -109,6 +115,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testAdultContentToggleFlowsToDetail() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||
|
||||
@@ -126,6 +133,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testAdultContentDefaultsToAllowed() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
app.goToRulesTab()
|
||||
app.buttons["ruleCard-Work Time"].waitToAppear().tap()
|
||||
let row = app.element("detailRow-Adult websites").waitToAppear()
|
||||
XCTAssertTrue(row.label.contains("Allowed"), "Got: \(row.label)")
|
||||
@@ -135,6 +143,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
/// not offer the toggle, and the rule's detail must not show the row.
|
||||
func testTimeLimitOmitsAdultContent() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
|
||||
|
||||
@@ -159,6 +168,7 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testEditorSupportsNativeSwipeBack() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||
app.staticTexts["ruleEditorTitle"].waitToAppear()
|
||||
@@ -174,13 +184,14 @@ final class RuleCreationUITests: XCTestCase {
|
||||
|
||||
func testNewRuleSheetShowsTypesAndPresets() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToRulesTab()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
|
||||
app.buttons["ruleKind-schedule"].waitToAppear()
|
||||
XCTAssertTrue(app.buttons["ruleKind-timeLimit"].exists)
|
||||
XCTAssertTrue(app.buttons["ruleKind-openLimit"].exists)
|
||||
XCTAssertTrue(app.staticTexts["Get More Done"].exists)
|
||||
XCTAssertTrue(app.buttons["preset-work-time"].exists)
|
||||
XCTAssertTrue(app.buttons["preset-laser-focus"].exists)
|
||||
XCTAssertTrue(app.staticTexts["Focus Time"].exists)
|
||||
XCTAssertTrue(app.buttons["preset-morning-focus"].exists)
|
||||
XCTAssertTrue(app.buttons["preset-deep-work"].exists)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import XCTest
|
||||
|
||||
/// Detail sheet, editing, disabling, deleting, and unblocking — seeded with
|
||||
/// one actively-blocking rule ("Work Time") and one upcoming rule ("Sleep").
|
||||
/// Rule cards live on the Rules tab; blocked tiles on the Home tab.
|
||||
final class RuleManagementUITests: XCTestCase {
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
@@ -14,6 +15,7 @@ final class RuleManagementUITests: XCTestCase {
|
||||
|
||||
func testDetailShowsLiveStatusAndFacts() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
app.goToRulesTab()
|
||||
|
||||
app.buttons["ruleCard-Work Time"].waitToAppear().tap()
|
||||
|
||||
@@ -30,6 +32,7 @@ final class RuleManagementUITests: XCTestCase {
|
||||
|
||||
func testEditRuleTogglesHardModeOn() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
app.goToRulesTab()
|
||||
|
||||
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||
app.buttons["editRuleButton"].waitToAppear().tap()
|
||||
@@ -44,6 +47,7 @@ final class RuleManagementUITests: XCTestCase {
|
||||
|
||||
func testDisableRule() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
app.goToRulesTab()
|
||||
|
||||
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||
app.buttons["editRuleButton"].waitToAppear().tap()
|
||||
@@ -65,6 +69,7 @@ final class RuleManagementUITests: XCTestCase {
|
||||
|
||||
func testDeleteRuleRemovesCard() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
app.goToRulesTab()
|
||||
|
||||
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||
app.buttons["editRuleButton"].waitToAppear().tap()
|
||||
@@ -84,11 +89,13 @@ final class RuleManagementUITests: XCTestCase {
|
||||
func testUnblockActiveSoftRule() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard")
|
||||
|
||||
// The active rule surfaces in Blocked Apps; unblocking pauses it.
|
||||
// The active rule surfaces in Currently Blocking (Home tab); unblocking pauses it.
|
||||
app.buttons["blockedTile-Work Time"].waitToAppear().tap()
|
||||
app.sheets.buttons["Unblock"].waitToAppear().tap()
|
||||
|
||||
app.staticTexts["nothingBlockedLabel"].waitToAppear()
|
||||
// The paused state shows on the rule's card over on the Rules tab.
|
||||
app.goToRulesTab()
|
||||
XCTAssertEqual(app.staticTexts["ruleStatus-Work Time"].waitToAppear().label, "Paused")
|
||||
}
|
||||
}
|
||||
@@ -101,6 +108,7 @@ final class HardModeUITests: XCTestCase {
|
||||
|
||||
func testHardLockedRuleCannotBeEdited() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
|
||||
app.goToRulesTab()
|
||||
|
||||
app.buttons["ruleCard-Locked In"].waitToAppear().tap()
|
||||
|
||||
@@ -113,6 +121,8 @@ final class HardModeUITests: XCTestCase {
|
||||
func testHardLockedRuleCannotBeUnblocked() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
|
||||
|
||||
// The hard rule shows a lock (not an Unblock button) in Currently Blocking;
|
||||
// tapping the row still explains why it can't be lifted.
|
||||
app.buttons["blockedTile-Locked In"].waitToAppear().tap()
|
||||
|
||||
// No unblock dialog — just the refusal alert.
|
||||
|
||||
67
OpenAppLockUITests/SettingsUITests.swift
Normal file
67
OpenAppLockUITests/SettingsUITests.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// SettingsUITests.swift
|
||||
// OpenAppLockUITests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
/// The Settings tab: the Uninstall Protection toggle and the Manage App Lists
|
||||
/// flow (which reuses the rule editor's app-list library, minus selection).
|
||||
final class SettingsUITests: XCTestCase {
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testUninstallProtectionToggleStartsOffAndFlips() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToSettingsTab()
|
||||
|
||||
let toggle = app.switches["uninstallProtectionToggle"].waitToAppear()
|
||||
XCTAssertEqual(toggle.value as? String, "0", "Uninstall Protection should default off")
|
||||
// `.tap()` lands on the element's center (over the label) and doesn't
|
||||
// reliably flip a SwiftUI switch — tap the control itself.
|
||||
toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.92, dy: 0.5)).tap()
|
||||
XCTAssertEqual(toggle.value as? String, "1", "Tapping should turn it on")
|
||||
}
|
||||
|
||||
func testManageAppListsCreateFlow() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock()
|
||||
app.goToSettingsTab()
|
||||
|
||||
app.element("manageAppListsButton").waitToAppear().tap()
|
||||
|
||||
// Fresh install: no lists yet — the same create flow as the rule editor.
|
||||
app.element("emptyAppListsLabel").waitToAppear()
|
||||
app.buttons["newAppListButton"].tap()
|
||||
|
||||
let nameField = app.textFields["appListNameField"].waitToAppear()
|
||||
nameField.tap()
|
||||
nameField.typeText("Distractions\n")
|
||||
|
||||
app.element("emptySelectionLabel").waitToAppear()
|
||||
app.buttons["editAppsButton"].tap()
|
||||
app.element("selectionCountLabel").waitToAppear()
|
||||
app.buttons["confirmSelectionButton"].tap()
|
||||
|
||||
app.buttons["saveAppListButton"].waitToAppear().tap()
|
||||
|
||||
// Saving returns to the management list with the new list present.
|
||||
app.element("appListRow-Distractions").waitToAppear()
|
||||
}
|
||||
|
||||
func testManageAppListsLockedDuringHardSession() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active")
|
||||
app.goToSettingsTab()
|
||||
|
||||
app.element("manageAppListsButton").waitToAppear().tap()
|
||||
|
||||
// The seeded "Distractions" list is visible but read-only while the
|
||||
// hard-mode rule is blocking — same lock as the rule editor's picker.
|
||||
app.element("appListRow-Distractions").waitToAppear()
|
||||
app.element("appListsLockedNotice").waitToAppear()
|
||||
XCTAssertFalse(
|
||||
app.buttons["editAppListButton-Distractions"].exists,
|
||||
"App lists must be read-only while a Hard Mode rule is blocking"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,16 @@ extension XCUIApplication {
|
||||
}
|
||||
}
|
||||
|
||||
extension XCUIApplication {
|
||||
/// Switches to the Home tab (Currently Blocking + Usage). Home is the
|
||||
/// default selection, so most Home-tab tests don't need to call this.
|
||||
func goToHomeTab() { tabBars.buttons["Home"].waitToAppear().tap() }
|
||||
/// Switches to the Rules tab (the rule list + New Rule button).
|
||||
func goToRulesTab() { tabBars.buttons["Rules"].waitToAppear().tap() }
|
||||
/// Switches to the Settings tab (Uninstall Protection + Manage App Lists).
|
||||
func goToSettingsTab() { tabBars.buttons["Settings"].waitToAppear().tap() }
|
||||
}
|
||||
|
||||
extension XCUIElement {
|
||||
/// Asserts the element appears within the timeout, then returns it.
|
||||
@discardableResult
|
||||
|
||||
@@ -5,35 +5,45 @@
|
||||
|
||||
import XCTest
|
||||
|
||||
/// The Usage section under Blocked Apps — seeded with limit rules at various
|
||||
/// The Usage section on the Home tab — seeded with limit rules at various
|
||||
/// budget states ("Time Keeper" 18m/45m, "Gate Keeper" 2/5 opens,
|
||||
/// "Doom Scroll" spent → blocked).
|
||||
/// "Doom Scroll" spent → moved to Currently Blocking).
|
||||
final class UsageUITests: XCTestCase {
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testUsageSectionShowsTimeAndOpenBudgets() throws {
|
||||
func testUsageSectionShowsTypeAndBudgets() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits")
|
||||
|
||||
XCTAssertTrue(app.staticTexts["Usage"].waitToAppear().exists)
|
||||
|
||||
// The row leads with the rule type, then the live usage and remaining.
|
||||
let timeRow = app.element("usageRow-Time Keeper").waitToAppear()
|
||||
XCTAssertTrue(timeRow.label.contains("Time Limit"), "Got: \(timeRow.label)")
|
||||
XCTAssertTrue(timeRow.label.contains("18m of 45m used today"), "Got: \(timeRow.label)")
|
||||
XCTAssertTrue(timeRow.label.contains("27m left"), "Got: \(timeRow.label)")
|
||||
|
||||
let openRow = app.element("usageRow-Gate Keeper").waitToAppear()
|
||||
XCTAssertTrue(openRow.label.contains("Open Limit"), "Got: \(openRow.label)")
|
||||
XCTAssertTrue(openRow.label.contains("2 of 5 opens today"), "Got: \(openRow.label)")
|
||||
XCTAssertTrue(openRow.label.contains("3 opens left"), "Got: \(openRow.label)")
|
||||
}
|
||||
|
||||
func testSpentBudgetShowsAsBlockedUntilTomorrow() throws {
|
||||
func testSpentBudgetMovesToCurrentlyBlocking() throws {
|
||||
let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits")
|
||||
|
||||
let spentRow = app.element("usageRow-Doom Scroll").waitToAppear()
|
||||
XCTAssertTrue(spentRow.label.contains("Blocked until tomorrow"), "Got: \(spentRow.label)")
|
||||
// A spent budget is a real block: the rule surfaces in Blocked Apps.
|
||||
XCTAssertTrue(app.buttons["blockedTile-Doom Scroll"].waitToAppear().exists)
|
||||
// A spent budget is a real block: the rule moves out of Usage and into
|
||||
// Currently Blocking, carrying its type + usage tracking.
|
||||
let tile = app.buttons["blockedTile-Doom Scroll"].waitToAppear()
|
||||
XCTAssertTrue(tile.label.contains("Time Limit"), "Got: \(tile.label)")
|
||||
XCTAssertTrue(tile.label.contains("30m of 30m used today"), "Got: \(tile.label)")
|
||||
|
||||
// It is no longer tracked under Usage.
|
||||
XCTAssertFalse(
|
||||
app.element("usageRow-Doom Scroll").exists,
|
||||
"A spent rule should leave the Usage section for Currently Blocking"
|
||||
)
|
||||
}
|
||||
|
||||
func testSpentBudgetCanBeUnblockedUntilTomorrow() throws {
|
||||
@@ -42,6 +52,7 @@ final class UsageUITests: XCTestCase {
|
||||
app.buttons["blockedTile-Doom Scroll"].waitToAppear().tap()
|
||||
app.sheets.buttons["Unblock"].waitToAppear().tap()
|
||||
|
||||
// Unblocked → paused (not blocking), so it drops back into Usage.
|
||||
app.staticTexts["nothingBlockedLabel"].waitToAppear()
|
||||
let row = app.element("usageRow-Doom Scroll").waitToAppear()
|
||||
XCTAssertTrue(row.label.contains("Unblocked until tomorrow"), "Got: \(row.label)")
|
||||
|
||||
@@ -26,8 +26,8 @@ enum RuleConfiguration: Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The default configuration for a brand-new rule of the given kind, using
|
||||
/// the reference app's defaults (9–5 schedule, 45m/day, 5 opens/day).
|
||||
/// The default configuration for a brand-new rule of the given kind
|
||||
/// (9–5 schedule, 45m/day, 5 opens/day).
|
||||
static func `default`(for kind: RuleKind) -> RuleConfiguration {
|
||||
switch kind {
|
||||
case .schedule: .schedule(ScheduleConfig())
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The three kinds of blocking rules, mirroring the reference app's "New Rule" sheet.
|
||||
/// The three kinds of blocking rules offered on the "New Rule" sheet.
|
||||
enum RuleKind: String, Codable, CaseIterable, Sendable {
|
||||
/// Block selected apps during a recurring time window.
|
||||
case schedule
|
||||
@@ -38,7 +38,7 @@ enum RuleKind: String, Codable, CaseIterable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Default name given to a brand-new rule of this kind (Opal: "In the Zone", "Time Keeper").
|
||||
/// Default name given to a brand-new rule of this kind (e.g. "In the Zone", "Time Keeper").
|
||||
var defaultRuleName: String {
|
||||
switch self {
|
||||
case .schedule: "In the Zone"
|
||||
|
||||
@@ -76,7 +76,7 @@ struct RuleSchedule: Hashable, Sendable {
|
||||
}
|
||||
|
||||
extension RuleSchedule {
|
||||
/// "09:00" style label for a minutes-from-midnight value, matching the reference UI.
|
||||
/// "09:00" style label for a minutes-from-midnight value.
|
||||
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)
|
||||
|
||||
@@ -19,6 +19,10 @@ protocol ShieldApplying: AnyObject {
|
||||
/// 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>)
|
||||
/// Engages or relinquishes the device-wide app-removal denial used by
|
||||
/// Uninstall Protection. Independent of per-rule shields: it lives on its
|
||||
/// own store so clearing rule shields never disturbs it.
|
||||
func setAppRemovalDenied(_ denied: Bool)
|
||||
}
|
||||
|
||||
/// Real shield enforcement via per-rule `ManagedSettingsStore`s. Store names
|
||||
@@ -65,6 +69,17 @@ final class ManagedSettingsShieldController: ShieldApplying {
|
||||
}
|
||||
}
|
||||
|
||||
func setAppRemovalDenied(_ denied: Bool) {
|
||||
// A dedicated store keeps this device-wide setting off the per-rule
|
||||
// stores, which get fully cleared when their shield lifts. Setting nil
|
||||
// (not false) relinquishes the constraint entirely when off.
|
||||
let store = ManagedSettingsStore(named: Self.uninstallProtectionStoreName)
|
||||
store.application.denyAppRemoval = denied ? true : nil
|
||||
}
|
||||
|
||||
private static let uninstallProtectionStoreName =
|
||||
ManagedSettingsStore.Name("uninstall-protection")
|
||||
|
||||
private func store(for ruleID: UUID) -> ManagedSettingsStore {
|
||||
ManagedSettingsStore(named: ManagedSettingsStore.Name("rule-\(ruleID.uuidString)"))
|
||||
}
|
||||
@@ -91,6 +106,10 @@ final class MockShieldController: ShieldApplying {
|
||||
private(set) var appliedModes: [UUID: SelectionMode] = [:]
|
||||
private(set) var appliedAdultContentFlags: [UUID: Bool] = [:]
|
||||
private(set) var appliedSelectionData: [UUID: Data?] = [:]
|
||||
/// The device-wide app-removal denial. Deliberately separate from the
|
||||
/// per-rule shield bookkeeping, mirroring the dedicated store in the real
|
||||
/// controller — `clearShields(except:)` must not disturb it.
|
||||
private(set) var appRemovalDenied = false
|
||||
|
||||
func applyShield(
|
||||
ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool
|
||||
@@ -115,6 +134,11 @@ final class MockShieldController: ShieldApplying {
|
||||
activeRuleIDs.contains($0.key)
|
||||
}
|
||||
appliedSelectionData = appliedSelectionData.filter { activeRuleIDs.contains($0.key) }
|
||||
// Note: appRemovalDenied is intentionally left untouched here.
|
||||
}
|
||||
|
||||
func setAppRemovalDenied(_ denied: Bool) {
|
||||
appRemovalDenied = denied
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ enum Weekday: Int, CaseIterable, Codable, Hashable, Sendable {
|
||||
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.
|
||||
/// Display order used by the day picker: S M T W T F S.
|
||||
static let displayOrder: [Weekday] = [
|
||||
.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday,
|
||||
]
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# OpenAppLock — "Rules" Feature Spec (derived from Opal screen recording)
|
||||
# OpenAppLock — "Rules" Feature Spec
|
||||
|
||||
Source: `ScreenRecording_06-12-2026 11-26-19_1.MP4` (iPhone, 4:29). The rules feature
|
||||
appears roughly between 1:00–3:00 of the recording, on Opal's "My Apps" tab.
|
||||
This spec describes what was observed, then maps it onto an implementation plan
|
||||
for OpenAppLock.
|
||||
This spec describes OpenAppLock's recurring app-blocking **rules** feature: the
|
||||
behavior the app implements, then how it maps onto the native iOS presentation
|
||||
(see §6).
|
||||
|
||||
---
|
||||
|
||||
@@ -11,24 +10,24 @@ for OpenAppLock.
|
||||
|
||||
A **Rule** is a recurring, automated app-blocking policy. Unlike a one-off
|
||||
block/timer session, a rule re-arms itself on a schedule. Three rule types are
|
||||
offered (Opal's "New Rule" sheet):
|
||||
offered, presented on the "New Rule" sheet:
|
||||
|
||||
| Type | Icon | Example shown | Semantics |
|
||||
|------|------|---------------|-----------|
|
||||
| **Schedule** | calendar grid | "e.g. 9-5, Daily" | Block selected apps during a daily time window on chosen days |
|
||||
| **Time Limit** | hourglass | "e.g. 45m/day" | After N minutes of cumulative use of selected apps per day, block them until a reset point |
|
||||
| **Open Limit** | padlock | "e.g. 5 opens/day" | After N opens of selected apps per day, block them (not demoed in video; inferred from card) |
|
||||
| **Open Limit** | padlock | "e.g. 5 opens/day" | After N opens of selected apps per day, block them |
|
||||
|
||||
**Common attributes** — present for *every* rule kind:
|
||||
|
||||
- **Name** — user-editable, free text (presets: "Work Time", "Weekend Zen", "Locked In", "Sleep", "Wind Down", "Deep Sleep", "Laser Focus", "Reading Time", "Gym Time"; defaults for new rules: "In the Zone" (schedule), "Time Keeper" (time limit))
|
||||
- **Name** — user-editable, free text (presets: "Morning Focus", "Deep Work", "Evening Reset", "Lights Out", "Family Dinner", "Screen-Free Sunday"; defaults for new rules: "In the Zone" (schedule), "Time Keeper" (time limit))
|
||||
- **Days of week** — 7-day toggle set, summarized as "Weekdays" / "Weekends" / "Every day" / custom
|
||||
- **App List** *(OpenAppLock refinement)* — each rule points to exactly one
|
||||
**App List**: a named, reusable selection of apps/categories/websites stored
|
||||
independently of any rule. When editing a rule the user picks an existing
|
||||
list or creates a new one; editing a list affects every rule that uses it.
|
||||
Deleting a list detaches it from its rules (they fall back to "no apps").
|
||||
- **Hard Mode** — boolean, PRO-gated in Opal; subtitle "No unblocks allowed". When off, the rule detail shows "Unblocks allowed: Yes"
|
||||
- **Hard Mode** — boolean; subtitle "No unblocks allowed". When off, the rule detail shows "Unblocks allowed: Yes"
|
||||
- **Enabled/disabled** — a rule can be disabled without deleting ("Disable Rule")
|
||||
|
||||
**Per-kind options** — each kind carries only the options that make sense for
|
||||
@@ -87,18 +86,20 @@ Dark theme throughout (near-black background, very dark green tint).
|
||||
2. **Blocked Apps** — section header; horizontal row of currently-blocked app
|
||||
icons. Each icon has a lock badge overlay and a teal/green rounded-rect
|
||||
outline; caption "Unblock" under the icon. Tapping unblocks (with friction
|
||||
if hard mode — not demoed).
|
||||
if hard mode).
|
||||
*(OpenAppLock)* Time/Open Limit rules whose budget is spent for the day
|
||||
also appear here, blocked until midnight.
|
||||
3. **Usage** *(OpenAppLock addition — not in the Opal video)* — a section
|
||||
directly below Blocked Apps showing live tracking for every enabled
|
||||
Time/Open Limit rule scheduled today:
|
||||
- Time Limit row: subtitle "18m of 45m used today", trailing "27m left";
|
||||
when spent: "Blocked until tomorrow" (red).
|
||||
- Open Limit row: subtitle "2 of 5 opens today", trailing "3 opens left";
|
||||
when spent: "Blocked until tomorrow" (red).
|
||||
Usage numbers come from the shared app-group ledger written by the
|
||||
DeviceActivity monitor and shield-action extensions.
|
||||
3. **Usage** *(OpenAppLock addition)* — a section
|
||||
showing live tracking for every enabled Time/Open Limit rule scheduled today
|
||||
**that is not currently blocking**. Each row leads its subtitle with the rule
|
||||
**type** so the kind is clear without relying on the icon:
|
||||
- Time Limit row: subtitle "Time Limit · 18m of 45m used today", trailing "27m left".
|
||||
- Open Limit row: subtitle "Open Limit · 2 of 5 opens today", trailing "3 opens left".
|
||||
A rule whose budget is **spent** (actively blocking) **moves out of Usage into
|
||||
the "Currently Blocking" section** (it shows its type + usage there instead);
|
||||
a *soft-unblocked* spent rule is paused (not blocking), so it returns to Usage
|
||||
reading "Unblocked until tomorrow". Usage numbers come from the shared app-group
|
||||
ledger written by the DeviceActivity monitor and shield-action extensions.
|
||||
3. **Rules** — header row: "Rules ›" (leading, tappable to a full list,
|
||||
presumably) and "**+ New**" (trailing, green tint) which opens the New Rule
|
||||
sheet.
|
||||
@@ -144,15 +145,15 @@ Presented from "+ New". Full-height sheet, scrollable.
|
||||
defaults.
|
||||
- **Preset gallery** — vertically scrolling sections, each with a bold header
|
||||
+ grey subtitle, containing a 2-up grid of photo-backed preset cards:
|
||||
- **Get More Done** — "Maximize your productivity while staying sane."
|
||||
- Work Time (Schedule 09:00–17:00, Block, weekdays)
|
||||
- Laser Focus (Schedule 14:00–18:00, Block)
|
||||
- **Sleep, Relax and Reset** — "Sleep better, rise refreshed."
|
||||
- Wind Down (Schedule 20:00–22:00, Block)
|
||||
- Deep Sleep (Schedule 22:00–06:00, Block)
|
||||
- **Build Healthy Habits** — "Spend time on important things."
|
||||
- Reading Time (Schedule ~19:45–20:45, Block)
|
||||
- Gym Time (Schedule 17:30–18:30, Block)
|
||||
- **Focus Time** — "Protect your deep-work hours."
|
||||
- Morning Focus (Schedule 08:00–11:30, Block, weekdays)
|
||||
- Deep Work (Schedule 13:30–16:00, Block, weekdays)
|
||||
- **Rest & Recharge** — "Wind the day down on schedule."
|
||||
- Evening Reset (Schedule 21:00–23:00, Block)
|
||||
- Lights Out (Schedule 23:00–06:30, Block)
|
||||
- **Healthy Balance** — "Make room for what matters."
|
||||
- Family Dinner (Schedule 18:00–19:30, Block)
|
||||
- Screen-Free Sunday (Schedule 09:00–20:00, Block, Sundays)
|
||||
- **Preset card** anatomy: full-bleed background photo, top row icon pair
|
||||
(type → shield), time range caption, name, "Block" + suggested app icons,
|
||||
and a circular "+" button bottom-right. Tapping anywhere opens the
|
||||
@@ -173,8 +174,7 @@ Sections (each an inset rounded group with a small icon + caption header):
|
||||
1. **📅 During this time**
|
||||
- Rows `From` / `To` with right-aligned time + stepper chevrons (`09:00 ⌃⌄`).
|
||||
- A dotted vertical line with ●/○ endpoints visually links From → To.
|
||||
- Tapping a row expands an inline wheel time picker (24h, demoed changing
|
||||
From 22:00 → 23:00).
|
||||
- Tapping a row expands an inline wheel time picker (24h).
|
||||
2. **On these days:** — trailing summary label ("Weekdays"/"Weekends"/custom);
|
||||
row of 7 circular toggles `S M T W T F S`; selected = filled white circle
|
||||
with black letter, unselected = dark circle.
|
||||
@@ -186,8 +186,8 @@ Sections (each an inset rounded group with a small icon + caption header):
|
||||
blocked" / "Only these apps are allowed" accordingly.
|
||||
4. **Hard Mode** `⚡PRO` badge — subtitle "No unblocks allowed"; trailing
|
||||
toggle.
|
||||
5. **Block Adult Content** *(OpenAppLock addition — not in the Opal video;
|
||||
**Schedule rules only**)* — subtitle "Filter adult websites while this rule
|
||||
5. **Block Adult Content** *(OpenAppLock addition; **Schedule rules only**)* —
|
||||
subtitle "Filter adult websites while this rule
|
||||
is active"; trailing toggle. Maps to Screen Time's web-content filter
|
||||
(`ManagedSettingsStore.webContent.blockedByFilter = .auto(...)`), applied
|
||||
and cleared together with the rule's shield. Surfaces in the rule detail
|
||||
@@ -215,7 +215,7 @@ Same chrome (back / title / rename). Sections:
|
||||
|
||||
### 3.6 Rule Editor — Open Limit type
|
||||
|
||||
Not demoed beyond its card. Spec by analogy: "When I open [apps]" /
|
||||
Spec by analogy with the other editors: "When I open [apps]" /
|
||||
"More than `N opens ⌃⌄` (Daily)" / day picker / "Then block until …" /
|
||||
Hard Mode / Hold to Commit. *(No Block Adult Content toggle — Schedule-only.)*
|
||||
|
||||
@@ -241,11 +241,10 @@ Full-height sheet:
|
||||
letting users add arbitrary domains.
|
||||
- Footer: "**N Apps Selected**" caption + white pill "**Save**" (+ "Cancel").
|
||||
|
||||
> Implementation note: Opal ships its own app categorization. On iOS, third
|
||||
> parties cannot enumerate installed apps; the system-sanctioned route is
|
||||
> `FamilyActivityPicker` (FamilyControls), which provides its own
|
||||
> category/app/website UI and returns opaque tokens. **v1 of OpenAppLock should
|
||||
> embed `FamilyActivityPicker`** instead of cloning Opal's custom picker.
|
||||
> Implementation note: On iOS, third parties cannot enumerate installed apps;
|
||||
> the system-sanctioned route is `FamilyActivityPicker` (FamilyControls), which
|
||||
> provides its own category/app/website UI and returns opaque tokens. **v1 of
|
||||
> OpenAppLock embeds `FamilyActivityPicker`** rather than a custom app picker.
|
||||
>
|
||||
> **App Lists (OpenAppLock):** the selection itself lives on a reusable
|
||||
> **App List** (`@Model AppList`: name + encoded `FamilyActivitySelection`).
|
||||
@@ -267,8 +266,8 @@ Full-height sheet:
|
||||
## 4. Behavioral spec
|
||||
|
||||
1. **Activation** — a Schedule rule becomes active at `From` on an enabled
|
||||
day and deactivates at `To` (windows crossing midnight, e.g. 22:00–06:00,
|
||||
must be supported — Deep Sleep preset does this).
|
||||
day and deactivates at `To` (windows crossing midnight, e.g. 23:00–06:30,
|
||||
must be supported — Lights Out preset does this).
|
||||
2. **While active** — the rule's app selection is shielded (and, when the
|
||||
rule's Block Adult Content toggle is on, Screen Time's adult-website
|
||||
filter is engaged for the same span); blocked apps also
|
||||
@@ -303,11 +302,11 @@ Full-height sheet:
|
||||
(which lists only rules whose budget is exhausted); it shows under "Usage"
|
||||
with its remaining opens.
|
||||
5. **Disable vs delete** — "Disable Rule" pauses scheduling but keeps the
|
||||
rule (card presumably shows disabled state). No delete flow was shown;
|
||||
add delete via swipe/long-press or a button in the editor.
|
||||
rule (the card shows a disabled state). Delete is offered from the rule
|
||||
editor's actions menu.
|
||||
6. **Commit friction** — creating/committing a rule uses press-and-hold
|
||||
("Hold to Commit"), echoing Opal's philosophy that *starting* a commitment
|
||||
should be deliberate. Editing uses a plain "Done".
|
||||
("Hold to Commit"), making the *start* of a commitment deliberate. Editing
|
||||
uses a plain "Done".
|
||||
7. **Live countdowns** — "Starts in 22h" / "6h left" update over time
|
||||
(minute granularity is fine).
|
||||
8. **Overlapping rules — strictest enforcement wins.** When several rules
|
||||
@@ -501,24 +500,41 @@ Xh", "Xh left") is **derived**, not stored.
|
||||
- All shared logic lives in `Shared/` (notably `LimitEnforcement`), unit
|
||||
tested from the app test target.
|
||||
|
||||
### 5.6 Out of scope (seen in video, not part of "rules")
|
||||
### 5.6 Out of scope (not part of "rules")
|
||||
|
||||
- Onboarding flow, paywall ("You know Opal works. Make it permanent."),
|
||||
Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?"
|
||||
friction screen), notification nudges ("Complete Your Setup").
|
||||
- Onboarding flow, paywall, Home tab gem/score UI, Timer tab (one-off focus
|
||||
sessions, "Leave Early?" friction screen), notification nudges ("Complete
|
||||
Your Setup").
|
||||
|
||||
---
|
||||
|
||||
## 6. Native UI re-skin (current presentation)
|
||||
|
||||
OpenAppLock has since replaced the Opal-style custom presentation with the bare
|
||||
OpenAppLock has since replaced its custom themed presentation with the bare
|
||||
iOS design language, keeping the backend (models, logic, services), the
|
||||
flows, and the accessibility identifiers intact. Sections 1–5 remain as the
|
||||
reference for *what* the feature does; presentation now maps as follows:
|
||||
spec for *what* the feature does; presentation now maps as follows.
|
||||
|
||||
After onboarding the app is a three-tab `TabView` (`MainTabView`), each tab its
|
||||
own `NavigationStack`:
|
||||
|
||||
```
|
||||
TabView: [Home] [Rules] [Settings]
|
||||
│ │ └── "Uninstall Protection" toggle + "Manage App Lists" ─▶ App List library (management mode)
|
||||
│ └── rules grouped into Schedule / Time Limit / Open Limit sections; "+" ─▶ New Rule sheet
|
||||
│ └── tap a rule row ─▶ Rule Detail sheet ─▶ "Edit Rule" ─▶ Rule Editor
|
||||
└── "Currently Blocking" section + "Usage" section
|
||||
```
|
||||
|
||||
The app-level **enforcement lifecycle** (the `enforcer.refresh` 30 s loop, the
|
||||
rule-change reconcile, and a scene-active reconcile) lives on `MainTabView`, so
|
||||
it runs regardless of the selected tab.
|
||||
|
||||
| Spec element | Native presentation |
|
||||
|---|---|
|
||||
| Apps home | `NavigationStack` + `List`; "Blocked Apps" and "Rules" sections; **rules are list rows** (kind icon, name, block summary, trailing live status — green when active); "+" toolbar button |
|
||||
| Home tab | `NavigationStack` + `List`. **"Currently Blocking"** section (renamed from "Blocked Apps") — the *rules* blocking right now: **no leading icon**; a Hard Mode rule shows a trailing `lock.fill` (the block can't be lifted), a soft rule shows a trailing "Unblock" button; tapping a hard row shows the "Hard Mode is on" alert, a soft row the unblock dialog. A limit rule whose budget is **spent** appears here (moved out of Usage) with a `<Type> · <usage>` subtitle. **"Usage"** section: every enabled limit rule scheduled today that is *not* currently blocking, each row a `<Type> · NN of MM used today` subtitle + trailing remaining/blocked label. |
|
||||
| Rules tab | `NavigationStack` + `List` split into **Schedule / Time Limit / Open Limit** sections (empty sections hidden); **rules are list rows** (leading kind icon, name, block summary, trailing live status — green when active); "+" toolbar button opens the New Rule sheet; tapping a row opens the Rule Detail sheet. |
|
||||
| Settings tab | `NavigationStack` + `Form`. **Uninstall Protection** toggle — while on, the device's app-removal is denied (`ManagedSettingsStore.application.denyAppRemoval`) whenever any Hard Mode rule is actively blocking. **Manage App Lists** pushes the shared App List library in management mode (create / edit / delete, honoring the Hard Mode lock — same flow as the rule editor's picker, minus selection). |
|
||||
| Rule detail | Sheet with inline nav title (name + "Schedule, 6h left" caption), `LabeledContent` rows, "Edit Rule" row pushes the editor; hard-locked rules show a lock row instead |
|
||||
| New Rule | `List` with a "Rule Type" section and preset sections as plain rows; editor pushed via `navigationDestination(item:)` |
|
||||
| Rule editor | Native `Form`: an inline **Name text field** at the top (no separate rename button; empty names fall back to the kind default), `DatePicker` rows, full-width day-circle row (≥44pt tap targets) with the summary in the section header, toggle rows with footers, stepper rows. Both modes commit via a **checkmark** in the navigation bar (labels: "Add Rule" / "Done"; replaces Hold to Commit). In edit mode an **ellipsis menu** ("Rule Actions") next to the checkmark holds Disable Rule and the destructive Delete Rule |
|
||||
@@ -526,3 +542,22 @@ reference for *what* the feature does; presentation now maps as follows:
|
||||
|
||||
Dropped custom components: `Theme`, `HoldToCommitButton`, `RuleCardView`,
|
||||
icon-pair/circle-button chrome.
|
||||
|
||||
### 6.1 Uninstall Protection (Settings)
|
||||
|
||||
A device-wide opt-in that makes Hard Mode harder to escape: while it is on **and**
|
||||
any Hard Mode rule is actively blocking, the user cannot delete apps from the
|
||||
device. `RulePolicy.shouldDenyAppRemoval(rules:enabled:usageFor:)` (= setting on
|
||||
AND any rule `isHardLocked`) is the single gate; `RuleEnforcer.refresh` applies it
|
||||
through `ShieldApplying.setAppRemovalDenied`, which sets
|
||||
`ManagedSettingsStore(named: "uninstall-protection").application.denyAppRemoval`
|
||||
(`true` to engage, `nil` to relinquish) on a **dedicated** store so per-rule
|
||||
shield clears never touch it. The setting persists in the app-group defaults
|
||||
(`uninstallProtectionEnabled`).
|
||||
|
||||
Enforced on the **foreground path only** for v1 (launch + 30 s loop + rule change
|
||||
+ scene-active). Known limitation: a Hard Mode window that *ends* while the app is
|
||||
closed leaves protection engaged until the app is next foregrounded — the safe
|
||||
failure direction for a locker. Background recompute in the monitor extension is a
|
||||
follow-up. Like all Screen Time behavior, the real device effect is only
|
||||
observable on a device (the simulator uses mock shields).
|
||||
|
||||
Reference in New Issue
Block a user