5 Commits

Author SHA1 Message Date
44e6086e29 feat: replace the preset gallery with original schedules
Swap the six suggested rules for an original set so the preset offerings
are OpenAppLock's own:

- Focus Time: Morning Focus (08:00-11:30 weekdays), Deep Work (13:30-16:00 weekdays)
- Rest & Recharge: Evening Reset (21:00-23:00), Lights Out (23:00-06:30, crosses midnight)
- Healthy Balance: Family Dinner (18:00-19:30), Screen-Free Sunday (09:00-20:00, Sundays)

Update the spec preset gallery (sections 1, 3.3, 4) and the preset
unit/UI tests to the new ids, names, and times. Build green; the preset
draft unit test and the two preset UI tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:32:05 -04:00
d005ed4fb1 docs: remove references to the source app and recording
Prepare for open-sourcing: scrub the prior-art attribution from the spec,
agent guide, and code comments so the project documents its own behavior.

- Reword RULES_FEATURE_SPEC.md to describe OpenAppLock's rules feature
  directly (drop the screen-recording source line and "as observed"/
  "not demoed"/"as shown" framing)
- Replace "the reference app/UI/style/defaults" comments and test names
  with direct descriptions of the behavior
- No behavior change; comments, docs, and @Test display names only

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:23:14 -04:00
32b1694e0a feat: re-skin to native three-tab UI and add Uninstall Protection
Replace the custom themed presentation with the bare native iOS design
language across a three-tab TabView (Home / Rules / Settings):

- MainTabView hosts the enforcement lifecycle (refresh loop, rule-change
  and scene-active reconcile) so it runs regardless of the selected tab
- HomeView: "Currently Blocking" + "Usage" sections (replaces AppsHomeView)
- RulesListView: rules grouped into Schedule / Time Limit / Open Limit
- SettingsView: Uninstall Protection toggle + Manage App Lists
- AppListLibraryView / ManageAppListsView: shared App List management
- AppSettings: app-group-backed settings store

Uninstall Protection: while enabled and any Hard Mode rule is actively
blocking, deny device app-removal via a dedicated ManagedSettingsStore
(RulePolicy.shouldDenyAppRemoval -> RuleEnforcer -> ShieldController).

Slim AppListPickerSheet to the picker role; drop AppsHomeView and the
custom Theme/HoldToCommitButton/RuleCardView chrome. Update spec section 6
and the unit/UI test suites accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:19:24 -04:00
0a0d00f53a docs: mandate plan-first, red-green TDD, and pre-commit UI validation
Make the three workflow expectations explicit, non-negotiable defaults:
always plan before execution, always use red-green TDD, and always
attempt manual UI validation before committing (skippable only when the
tooling is unavailable, and only with an explicit callout + handoff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:32:35 -04:00
e5f571a63e fix: show a generic "App Blocked" shield instead of mis-attributing a rule
The shield titled itself with the first enabled rule whose selection
contained the app, regardless of whether that rule was the one actually
blocking. When several rules covered the same app (e.g. a dormant
schedule + the active limit) it named the wrong rule.

Drop the rule-name lookup entirely: every shield now carries a generic
"App Blocked" title. Open-limit shields keep their functional detail
(running count + "Open (N left)" button) beneath that title. The
text-only decision moves into a pure, unit-tested ShieldPresentation in
Shared/, and the now-dead ShieldLookup.snapshot(containingApplication:)
is removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:32:26 -04:00
42 changed files with 1284 additions and 589 deletions

View File

@@ -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
@@ -79,10 +79,22 @@ docs/SWIFT_GUIDELINES.md Swift coding/testing/patterns/security standards
## Workflow expectations (user preference)
- **Red-green TDD**: update `docs/RULES_FEATURE_SPEC.md` first for behavior
changes, write the failing test, run it (compile failure counts as red),
implement, re-run focused tests, then the full suite. Run tests often and
fail fast.
These three are non-negotiable defaults — follow them on every task, not only
when reminded:
- **Always plan before execution.** Think through and lay out the approach (a
written plan / plan mode for anything non-trivial) and confirm scope before
editing code. Do not start changing files until the plan is clear.
- **Always use red-green TDD.** Update `docs/RULES_FEATURE_SPEC.md` first for
behavior changes, write the failing test, run it (compile failure counts as
red), implement, re-run focused tests, then the full suite. Run tests often
and fail fast.
- **Always attempt to validate the UI manually before committing.** Build and
run the app (simulator/device) and visually confirm the change behaves as
intended. This step **may be skipped only when such tooling is unavailable**
(e.g. the Xcode MCP / a simulator is not reachable in the session) — in that
case, say so explicitly and hand the verification back to the user rather
than silently skipping it.
- Conventional commits (`feat:`, `fix:`, `refactor:` …). **Agent attribution is
required**: every commit an agent authors or co-authors must end with a
`Co-Authored-By:` trailer naming the specific agent/model that did the work,

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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 (95 weekdays schedule, 45m/day, 5 opens/day).
/// A fresh draft for a new rule of the given kind, using the default
/// values (95 weekdays schedule, 45m/day, 5 opens/day).
init(kind: RuleKind) {
self.name = kind.defaultRuleName
self.days = Weekday.weekdays

View File

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

View File

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

View 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
}
}

View File

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

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

View File

@@ -3,67 +3,19 @@
// 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)
}
}
} 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")
}
}
AppListLibraryView(selection: $selected, onPick: { dismiss() })
.navigationTitle("App List")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -74,80 +26,8 @@ struct AppListPickerSheet: View {
.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 {

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

View File

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

View 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)")
}
}

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

View File

@@ -15,7 +15,7 @@ struct RootView: View {
var body: some View {
Group {
if hasCompletedOnboarding {
AppsHomeView()
MainTabView()
} else {
OnboardingView {
hasCompletedOnboarding = true

View 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")"
}
}

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

View File

@@ -9,28 +9,29 @@ import UIKit
/// Customizes the system shield. Apps under an open-limit rule show how many
/// opens were used and offer an "Open" secondary button while opens remain;
/// everything else gets a plain blocked shield named after its rule.
/// everything else gets a plain blocked shield. Shields never name a rule the
/// text is decided by the pure `ShieldPresentation`.
final class ShieldConfigurationExtension: ShieldConfigurationDataSource {
override func configuration(shielding application: Application) -> ShieldConfiguration {
guard let token = application.token else { return blockedConfiguration(ruleName: nil) }
guard let token = application.token else { return configuration(for: .blocked) }
return configuration(forApplicationToken: token)
}
override func configuration(
shielding application: Application, in category: ActivityCategory
) -> ShieldConfiguration {
guard let token = application.token else { return blockedConfiguration(ruleName: nil) }
guard let token = application.token else { return configuration(for: .blocked) }
return configuration(forApplicationToken: token)
}
override func configuration(shielding webDomain: WebDomain) -> ShieldConfiguration {
blockedConfiguration(ruleName: nil)
configuration(for: .blocked)
}
override func configuration(
shielding webDomain: WebDomain, in category: ActivityCategory
) -> ShieldConfiguration {
blockedConfiguration(ruleName: nil)
configuration(for: .blocked)
}
private func configuration(
@@ -41,45 +42,26 @@ final class ShieldConfigurationExtension: ShieldConfigurationDataSource {
let snapshot = ShieldLookup.openLimitSnapshot(
containingApplication: token, in: snapshots)
else {
let name = ShieldLookup.snapshot(containingApplication: token, in: snapshots)?.name
return blockedConfiguration(ruleName: name)
return configuration(for: .blocked)
}
let usage = UsageLedger().usage(for: snapshot.id, onDayContaining: .now)
let remaining = max(0, snapshot.maxOpens - usage.opensUsed)
guard remaining > 0 else {
return ShieldConfiguration(
backgroundBlurStyle: .systemMaterial,
title: .init(text: snapshot.name, color: .label),
subtitle: .init(
text: "No opens left today — the block lifts tomorrow.",
color: .secondaryLabel),
primaryButtonLabel: .init(text: "OK", color: .white),
primaryButtonBackgroundColor: .systemBlue
)
}
return ShieldConfiguration(
backgroundBlurStyle: .systemMaterial,
title: .init(text: snapshot.name, color: .label),
subtitle: .init(
text: "Opened \(usage.opensUsed) of \(snapshot.maxOpens) times today. "
+ "Each open lasts \(MonitoringPlan.openSessionMinutes) minutes.",
color: .secondaryLabel),
primaryButtonLabel: .init(text: "OK", color: .white),
primaryButtonBackgroundColor: .systemBlue,
secondaryButtonLabel: .init(
text: remaining == 1 ? "Open (1 left)" : "Open (\(remaining) left)",
color: .systemBlue)
)
return configuration(
for: .openLimit(
opensUsed: usage.opensUsed,
maxOpens: snapshot.maxOpens,
sessionMinutes: MonitoringPlan.openSessionMinutes))
}
private func blockedConfiguration(ruleName: String?) -> ShieldConfiguration {
private func configuration(for presentation: ShieldPresentation) -> ShieldConfiguration {
ShieldConfiguration(
backgroundBlurStyle: .systemMaterial,
title: .init(text: ruleName ?? "Blocked", color: .label),
subtitle: .init(text: "This app is blocked by OpenAppLock.", color: .secondaryLabel),
title: .init(text: presentation.title, color: .label),
subtitle: .init(text: presentation.subtitle, color: .secondaryLabel),
primaryButtonLabel: .init(text: "OK", color: .white),
primaryButtonBackgroundColor: .systemBlue
primaryButtonBackgroundColor: .systemBlue,
secondaryButtonLabel: presentation.secondaryButton.map {
.init(text: $0, color: .systemBlue)
}
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import Testing
@MainActor
@Suite("Rule status derivation and labels")
struct RuleStatusTests {
/// 09:0017:00 weekdays schedule rule, like the video's "Work Time".
/// 09:0017: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")

View File

@@ -0,0 +1,53 @@
//
// ShieldPresentationTests.swift
// OpenAppLockTests
//
import Testing
@testable import OpenAppLock
@Suite("Shield presentation")
struct ShieldPresentationTests {
@Test("Fully-blocked shield is generic and names no rule")
func blockedIsGeneric() {
let presentation = ShieldPresentation.blocked
#expect(presentation.title == "App Blocked")
#expect(presentation.subtitle == "This app is blocked by OpenAppLock.")
#expect(presentation.secondaryButton == nil)
}
@Test("Open-limit shield with opens remaining shows counts and an Open button")
func openLimitWithOpensRemaining() {
let presentation = ShieldPresentation.openLimit(
opensUsed: 2, maxOpens: 5, sessionMinutes: 15)
// Title is generic never the rule name.
#expect(presentation.title == "App Blocked")
#expect(presentation.subtitle.contains("Opened 2 of 5 times today"))
#expect(presentation.subtitle.contains("15 minutes"))
#expect(presentation.secondaryButton == "Open (3 left)")
}
@Test("A single remaining open reads in the singular")
func openLimitSingularRemaining() {
let presentation = ShieldPresentation.openLimit(
opensUsed: 4, maxOpens: 5, sessionMinutes: 15)
#expect(presentation.secondaryButton == "Open (1 left)")
}
@Test("Spent open-limit shield drops the Open button and reads as blocked")
func openLimitExhausted() {
let presentation = ShieldPresentation.openLimit(
opensUsed: 5, maxOpens: 5, sessionMinutes: 15)
#expect(presentation.title == "App Blocked")
#expect(presentation.subtitle == "No opens left today — the block lifts tomorrow.")
#expect(presentation.secondaryButton == nil)
}
@Test("Over-spending never yields a negative or non-nil Open button")
func openLimitOverSpent() {
let presentation = ShieldPresentation.openLimit(
opensUsed: 9, maxOpens: 5, sessionMinutes: 15)
#expect(presentation.secondaryButton == nil)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View 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"
)
}
}

View File

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

View File

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

View File

@@ -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 (95 schedule, 45m/day, 5 opens/day).
/// The default configuration for a brand-new rule of the given kind
/// (95 schedule, 45m/day, 5 opens/day).
static func `default`(for kind: RuleKind) -> RuleConfiguration {
switch kind {
case .schedule: .schedule(ScheduleConfig())

View File

@@ -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"

View File

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

View File

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

View File

@@ -29,16 +29,4 @@ enum ShieldLookup {
.categoryTokens.contains(token)
}
}
/// Any enabled rule whose selection includes the application (for naming
/// plain blocked shields).
static func snapshot(
containingApplication token: ApplicationToken, in snapshots: [RuleSnapshot]
) -> RuleSnapshot? {
snapshots.first { snapshot in
guard snapshot.isEnabled else { return false }
return AppSelectionCodec.decode(snapshot.selectionData)
.applicationTokens.contains(token)
}
}
}

View File

@@ -0,0 +1,47 @@
//
// ShieldPresentation.swift
// OpenAppLock
//
/// The text a shield shows, decided independently of any UI type so it can be
/// unit tested. Shields never name the rule that blocks an app: when several
/// rules cover the same app the responsible rule can't be told apart, so every
/// shield carries the same generic "App Blocked" title. Open-limit shields add
/// their functional detail (remaining opens + an "Open" button) beneath it.
struct ShieldPresentation: Equatable {
let title: String
let subtitle: String
/// Label for the secondary "Open" button, or `nil` when the shield offers
/// no way through (a full block, or a spent open-limit budget).
let secondaryButton: String?
static let blockedTitle = "App Blocked"
/// A plain, fully-blocked app: no counts, no way through.
static let blocked = ShieldPresentation(
title: blockedTitle,
subtitle: "This app is blocked by OpenAppLock.",
secondaryButton: nil
)
/// An open-limit shield. While opens remain it shows the running count and
/// an "Open (N left)" button; once spent it reads like a plain block.
static func openLimit(
opensUsed: Int, maxOpens: Int, sessionMinutes: Int
) -> ShieldPresentation {
let remaining = max(0, maxOpens - opensUsed)
guard remaining > 0 else {
return ShieldPresentation(
title: blockedTitle,
subtitle: "No opens left today — the block lifts tomorrow.",
secondaryButton: nil
)
}
return ShieldPresentation(
title: blockedTitle,
subtitle: "Opened \(opensUsed) of \(maxOpens) times today. "
+ "Each open lasts \(sessionMinutes) minutes.",
secondaryButton: remaining == 1 ? "Open (1 left)" : "Open (\(remaining) left)"
)
}
}

View File

@@ -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,
]

View File

@@ -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:003: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:0017:00, Block, weekdays)
- Laser Focus (Schedule 14:0018:00, Block)
- **Sleep, Relax and Reset** — "Sleep better, rise refreshed."
- Wind Down (Schedule 20:0022:00, Block)
- Deep Sleep (Schedule 22:0006:00, Block)
- **Build Healthy Habits** — "Spend time on important things."
- Reading Time (Schedule ~19:4520:45, Block)
- Gym Time (Schedule 17:3018:30, Block)
- **Focus Time** — "Protect your deep-work hours."
- Morning Focus (Schedule 08:0011:30, Block, weekdays)
- Deep Work (Schedule 13:3016:00, Block, weekdays)
- **Rest & Recharge** — "Wind the day down on schedule."
- Evening Reset (Schedule 21:0023:00, Block)
- Lights Out (Schedule 23:0006:30, Block)
- **Healthy Balance** — "Make room for what matters."
- Family Dinner (Schedule 18:0019:30, Block)
- Screen-Free Sunday (Schedule 09:0020: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:0006:00,
must be supported — Deep Sleep preset does this).
day and deactivates at `To` (windows crossing midnight, e.g. 23:0006: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
@@ -485,9 +484,14 @@ Xh", "Xh left") is **derived**, not stored.
`OpenSessionStore` reports a still-running granted open for it — so the
foreground loop establishes the turnstile for newly created rules and never
re-locks an app mid-session.
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit
shields show "Opened X of N times today" with an "Open (Y left)" secondary
button; other shields show the blocking rule's name.
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): every shield
carries the same generic **"App Blocked"** title — rule names are never shown,
since the rule a shield is attributed to cannot be determined reliably when
several rules cover the same app. Open-limit shields keep their functional
detail under that title ("Opened X of N times today" with an "Open (Y left)"
secondary button while opens remain); all other shields just read "This app is
blocked by OpenAppLock." The text-only decision lives in the pure, unit-tested
`ShieldPresentation` (in `Shared/`).
- **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press
spends one open in the ledger, lifts the rule's shield, records the session
expiry in `OpenSessionStore`, and starts the ~15-minute one-shot session
@@ -496,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 15 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 |
@@ -521,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).