From d8d7028d60a5a967d9a6f199fe9cf6ea26ed39d7 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 12 Jun 2026 20:02:35 -0400 Subject: [PATCH] feat: introduce reusable app lists for rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppList @Model with launch migration from legacy inline selections (rules with identical selections share one list; idempotent) - rules point at one app list; Block/Allow Only belongs to the rule and is offered only in the Schedule editor (limit kinds sanitize to Block) - app-list picker sheet (select / create / edit / delete with in-use protection) replaces the per-rule selection sheet - Hard Mode locks app-list editing while any hard rule is blocking - SwiftData stability: relationships are only wired between managed models, and unit tests share one in-memory container (fresh context + data wipe per test) — per-test container creation trapped intermittently inside SwiftData Co-Authored-By: Claude --- AGENTS.md | 9 + OpenAppLock/Logic/RulePolicy.swift | 10 + OpenAppLock/Models/AppList.swift | 47 ++++ OpenAppLock/Models/BlockingRule.swift | 12 +- OpenAppLock/Models/RuleDraft.swift | 36 ++- OpenAppLock/OpenAppLockApp.swift | 3 +- OpenAppLock/Services/AppListMigration.swift | 45 ++++ OpenAppLock/Services/RuleEnforcer.swift | 2 +- OpenAppLock/Services/SampleRules.swift | 30 ++- OpenAppLock/Services/ShieldController.swift | 3 + .../Views/AppLists/AppListEditorView.swift | 101 ++++++++ .../Views/AppLists/AppListPickerSheet.swift | 156 ++++++++++++ OpenAppLock/Views/Apps/AppsHomeView.swift | 7 +- .../Views/Rules/AppSelectionSheet.swift | 90 ------- OpenAppLock/Views/Rules/NewRuleSheet.swift | 2 +- OpenAppLock/Views/Rules/RuleDetailSheet.swift | 3 +- OpenAppLock/Views/Rules/RuleEditorView.swift | 29 ++- OpenAppLockTests/AppListTests.swift | 236 ++++++++++++++++++ OpenAppLockTests/RuleModelTests.swift | 20 +- OpenAppLockTests/TestSupport.swift | 36 +++ OpenAppLockUITests/AppListUITests.swift | 106 ++++++++ docs/RULES_FEATURE_SPEC.md | 35 ++- 22 files changed, 880 insertions(+), 138 deletions(-) create mode 100644 OpenAppLock/Models/AppList.swift create mode 100644 OpenAppLock/Services/AppListMigration.swift create mode 100644 OpenAppLock/Views/AppLists/AppListEditorView.swift create mode 100644 OpenAppLock/Views/AppLists/AppListPickerSheet.swift delete mode 100644 OpenAppLock/Views/Rules/AppSelectionSheet.swift create mode 100644 OpenAppLockTests/AppListTests.swift create mode 100644 OpenAppLockUITests/AppListUITests.swift diff --git a/AGENTS.md b/AGENTS.md index c0a61f0..bfdfe19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,15 @@ them): `newRuleButton`, `ruleCard-`, `ruleStatus-`, `allowScreenTimeButton`, `permissionDeniedLabel`, `openSettingsButton`. Gotchas learned the hard way: +- **SwiftData relationships**: never assign a relationship property (e.g. + `rule.appList`) inside a model's `init` or on un-inserted instances — + insert both models into a context first, then wire them. +- **SwiftData container churn**: repeatedly creating `ModelContainer`s for + this schema traps intermittently (EXC_BREAKPOINT inside SwiftData's + configuration setup), which Xcode shows as a test "hang" paused at a + breakpoint. Unit tests must go through `makeInMemoryContext()` + (TestSupport.swift): one shared container per process, fresh context + + data wipe per test. - Identifiers on SwiftUI containers need `.accessibilityElement(children: .combine)` (or a Button/control) to be queryable. - List/Form section headers render uppercased unless `.textCase(nil)` — diff --git a/OpenAppLock/Logic/RulePolicy.swift b/OpenAppLock/Logic/RulePolicy.swift index bb44c8a..60be668 100644 --- a/OpenAppLock/Logic/RulePolicy.swift +++ b/OpenAppLock/Logic/RulePolicy.swift @@ -50,6 +50,16 @@ enum RulePolicy { !isHardLocked(rule, 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 + /// for other rules stay allowed; they cannot weaken an active block. + static func canEditAppLists( + rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current + ) -> Bool { + !rules.contains { isHardLocked($0, at: now, calendar: calendar) } + } + /// Pauses the rule's current window. Returns false (and changes nothing) /// when unblocking is not allowed. @discardableResult diff --git a/OpenAppLock/Models/AppList.swift b/OpenAppLock/Models/AppList.swift new file mode 100644 index 0000000..b736bc0 --- /dev/null +++ b/OpenAppLock/Models/AppList.swift @@ -0,0 +1,47 @@ +// +// AppList.swift +// OpenAppLock +// + +import Foundation +import SwiftData + +/// A named, reusable selection of apps/categories/websites. Rules point at a +/// list, so editing the list affects every rule that uses it. Deleting a list +/// detaches it from its rules (they fall back to "no apps"). +@Model +final class AppList { + @Attribute(.unique) var id: UUID + var name: String + /// Encoded `FamilyActivitySelection` (opaque tokens). Nil until apps are picked. + var selectionData: Data? + /// Denormalized count of selected apps/categories/domains for display. + var selectionCount: Int + var createdAt: Date + + @Relationship(deleteRule: .nullify, inverse: \BlockingRule.appList) + var rules: [BlockingRule] = [] + + init( + id: UUID = UUID(), + name: String, + selectionData: Data? = nil, + selectionCount: Int = 0, + createdAt: Date = .now + ) { + self.id = id + self.name = name + self.selectionData = selectionData + self.selectionCount = selectionCount + self.createdAt = createdAt + } + + /// Whether any rule currently points at this list (guards deletion). + static func isInUse(_ list: AppList, context: ModelContext) -> Bool { + let listID = list.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.appList?.id == listID } + ) + return ((try? context.fetchCount(descriptor)) ?? 0) > 0 + } +} diff --git a/OpenAppLock/Models/BlockingRule.swift b/OpenAppLock/Models/BlockingRule.swift index 201a94c..80dac2f 100644 --- a/OpenAppLock/Models/BlockingRule.swift +++ b/OpenAppLock/Models/BlockingRule.swift @@ -23,9 +23,17 @@ final class BlockingRule { /// Inline default so existing stores migrate cleanly. var blockAdultContent: Bool = false var selectionModeRaw: String - /// Encoded `FamilyActivitySelection` (opaque tokens). Nil until the user picks apps. + /// The reusable app list this rule blocks (or allows, in Allow Only mode). + /// + /// Deliberately not an `init` parameter: SwiftData relationship properties + /// must only be assigned once both models are inserted in a context — + /// writing them on unmanaged instances traps intermittently inside + /// SwiftData (EXC_BREAKPOINT on the next insert/save). + var appList: AppList? + /// Legacy inline selection, superseded by `appList`. Kept only so + /// `AppListMigration` can read pre-app-list stores; always nil afterwards. var selectionData: Data? - /// Denormalized count of selected apps/categories/domains for display. + /// Legacy denormalized count; superseded by `appList?.selectionCount`. var selectionCount: Int var dayNumbers: [Int] var startMinutes: Int diff --git a/OpenAppLock/Models/RuleDraft.swift b/OpenAppLock/Models/RuleDraft.swift index 2759f96..9dfe4cd 100644 --- a/OpenAppLock/Models/RuleDraft.swift +++ b/OpenAppLock/Models/RuleDraft.swift @@ -4,6 +4,7 @@ // import Foundation +import SwiftData /// Value-type working copy of a rule used by the editors, so cancelling an /// edit never touches the persisted model. Hashable so it can drive @@ -19,8 +20,10 @@ struct RuleDraft: Hashable { var hardMode: Bool var blockAdultContent: Bool var selectionMode: SelectionMode - var selectionData: Data? - var selectionCount: Int + /// Reference to the persisted list the rule will use. App lists are + /// managed (created/edited) directly by the picker, so the draft only + /// carries the pointer. + var appList: AppList? /// A fresh draft for a new rule of the given kind, using the reference /// app's defaults (9–5 weekdays schedule, 45m/day, 5 opens/day). @@ -35,8 +38,7 @@ struct RuleDraft: Hashable { self.hardMode = false self.blockAdultContent = false self.selectionMode = .block - self.selectionData = nil - self.selectionCount = 0 + self.appList = nil } init(rule: BlockingRule) { @@ -50,8 +52,7 @@ struct RuleDraft: Hashable { self.hardMode = rule.hardMode self.blockAdultContent = rule.blockAdultContent self.selectionMode = rule.selectionMode - self.selectionData = rule.selectionData - self.selectionCount = rule.selectionCount + self.appList = rule.appList } init(preset: RulePreset) { @@ -62,6 +63,9 @@ struct RuleDraft: Hashable { self.endMinutes = preset.endMinutes } + /// Writes the draft back onto a rule. The rule (and the chosen list) must + /// already be inserted in a context: SwiftData relationships may only be + /// assigned between managed models (see `BlockingRule.appList`). func apply(to rule: BlockingRule) { rule.name = name rule.kind = kind @@ -73,22 +77,32 @@ struct RuleDraft: Hashable { rule.hardMode = hardMode rule.blockAdultContent = blockAdultContent rule.selectionMode = selectionMode - rule.selectionData = selectionData - rule.selectionCount = selectionCount + if rule.appList !== appList { + rule.appList = appList + } } - func makeRule() -> BlockingRule { + /// Creates and inserts a new rule from this draft. The rule is inserted + /// *before* the draft is applied so the app-list relationship is only + /// ever written on a managed model. + @discardableResult + func insertRule(into context: ModelContext) -> BlockingRule { let rule = BlockingRule(name: name, kind: kind) + context.insert(rule) apply(to: rule) return rule } - /// Trims the name and falls back to the kind's default when it is empty, - /// so a cleared name field can never produce an unnamed rule. + /// Trims the name (falling back to the kind's default when it is empty) + /// and forces Block mode for time- and open-limit rules — Allow Only is a + /// schedule-rule concept. func sanitized() -> RuleDraft { var copy = self let trimmed = name.trimmingCharacters(in: .whitespaces) copy.name = trimmed.isEmpty ? kind.defaultRuleName : trimmed + if kind != .schedule { + copy.selectionMode = .block + } return copy } diff --git a/OpenAppLock/OpenAppLockApp.swift b/OpenAppLock/OpenAppLockApp.swift index 9d873e7..5c1ae3d 100644 --- a/OpenAppLock/OpenAppLockApp.swift +++ b/OpenAppLock/OpenAppLockApp.swift @@ -21,7 +21,7 @@ struct OpenAppLockApp: App { UserDefaults.standard.set(onboardingCompleted, forKey: "hasCompletedOnboarding") } - let schema = Schema([BlockingRule.self]) + let schema = Schema([BlockingRule.self, AppList.self]) let modelConfiguration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: config.isUITesting ) @@ -34,6 +34,7 @@ struct OpenAppLockApp: App { if let scenario = config.seedScenario { SampleRules.seed(scenario, into: container.mainContext) } + AppListMigration.run(in: container.mainContext) let authProvider: AuthorizationProviding = config.isUITesting diff --git a/OpenAppLock/Services/AppListMigration.swift b/OpenAppLock/Services/AppListMigration.swift new file mode 100644 index 0000000..4d770d3 --- /dev/null +++ b/OpenAppLock/Services/AppListMigration.swift @@ -0,0 +1,45 @@ +// +// AppListMigration.swift +// OpenAppLock +// + +import Foundation +import SwiftData + +/// One-time launch migration from the legacy model where every rule carried +/// its own inline `FamilyActivitySelection` to shared, reusable app lists. +/// +/// Each rule that still has inline selection data gets a list named after it; +/// rules with byte-identical selections share a single list. The inline copy +/// is cleared afterwards, which also makes the migration idempotent. +enum AppListMigration { + static func run(in context: ModelContext) { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.selectionData != nil } + ) + guard let legacyRules = try? context.fetch(descriptor), !legacyRules.isEmpty else { + return + } + + var listsBySelection: [Data: AppList] = [:] + for rule in legacyRules.sorted(by: { $0.createdAt < $1.createdAt }) { + guard rule.appList == nil, let selectionData = rule.selectionData else { continue } + let list: AppList + if let existing = listsBySelection[selectionData] { + list = existing + } else { + list = AppList( + name: "\(rule.name) Apps", + selectionData: selectionData, + selectionCount: rule.selectionCount + ) + context.insert(list) + listsBySelection[selectionData] = list + } + rule.appList = list + rule.selectionData = nil + rule.selectionCount = 0 + } + try? context.save() + } +} diff --git a/OpenAppLock/Services/RuleEnforcer.swift b/OpenAppLock/Services/RuleEnforcer.swift index 96a9f4e..0991a23 100644 --- a/OpenAppLock/Services/RuleEnforcer.swift +++ b/OpenAppLock/Services/RuleEnforcer.swift @@ -35,7 +35,7 @@ final class RuleEnforcer { active.insert(rule.id) shields.applyShield( ruleID: rule.id, - selectionData: rule.selectionData, + selectionData: rule.appList?.selectionData, mode: rule.selectionMode, blockAdultContent: rule.blockAdultContent ) diff --git a/OpenAppLock/Services/SampleRules.swift b/OpenAppLock/Services/SampleRules.swift index b97f600..9a88f8f 100644 --- a/OpenAppLock/Services/SampleRules.swift +++ b/OpenAppLock/Services/SampleRules.swift @@ -15,13 +15,29 @@ enum SampleRules { now: Date = .now, calendar: Calendar = .current ) { - switch scenario { - case .standard: - context.insert(activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar)) - context.insert(upcomingRule(named: "Sleep", now: now, calendar: calendar)) - case .hardModeActive: - context.insert(activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar)) - context.insert(upcomingRule(named: "Sleep", now: now, calendar: calendar)) + // Shared list so seeded rules demonstrate the app-list UI. The count + // is display-only; UI tests never decode real tokens. + let distractions = AppList(name: "Distractions", selectionCount: 3) + context.insert(distractions) + + let rules = + switch scenario { + case .standard: + [ + activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar), + upcomingRule(named: "Sleep", now: now, calendar: calendar), + ] + case .hardModeActive: + [ + activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar), + upcomingRule(named: "Sleep", now: now, calendar: calendar), + ] + } + // Relationships are wired only after both sides are managed + // (see BlockingRule.appList). + for rule in rules { + context.insert(rule) + rule.appList = distractions } } diff --git a/OpenAppLock/Services/ShieldController.swift b/OpenAppLock/Services/ShieldController.swift index 38f2343..8c8cab1 100644 --- a/OpenAppLock/Services/ShieldController.swift +++ b/OpenAppLock/Services/ShieldController.swift @@ -82,6 +82,7 @@ final class MockShieldController: ShieldApplying { private(set) var shieldedRuleIDs: Set = [] private(set) var appliedModes: [UUID: SelectionMode] = [:] private(set) var appliedAdultContentFlags: [UUID: Bool] = [:] + private(set) var appliedSelectionData: [UUID: Data?] = [:] func applyShield( ruleID: UUID, selectionData: Data?, mode: SelectionMode, blockAdultContent: Bool @@ -89,6 +90,7 @@ final class MockShieldController: ShieldApplying { shieldedRuleIDs.insert(ruleID) appliedModes[ruleID] = mode appliedAdultContentFlags[ruleID] = blockAdultContent + appliedSelectionData[ruleID] = selectionData } func clearShields(except activeRuleIDs: Set) { @@ -97,6 +99,7 @@ final class MockShieldController: ShieldApplying { appliedAdultContentFlags = appliedAdultContentFlags.filter { activeRuleIDs.contains($0.key) } + appliedSelectionData = appliedSelectionData.filter { activeRuleIDs.contains($0.key) } } } diff --git a/OpenAppLock/Views/AppLists/AppListEditorView.swift b/OpenAppLock/Views/AppLists/AppListEditorView.swift new file mode 100644 index 0000000..cd79e3c --- /dev/null +++ b/OpenAppLock/Views/AppLists/AppListEditorView.swift @@ -0,0 +1,101 @@ +// +// AppListEditorView.swift +// OpenAppLock +// + +import FamilyControls +import SwiftData +import SwiftUI + +/// Creates or edits an app list: a name field above Apple's +/// `FamilyActivityPicker`. Pushed inside the App List picker's stack, so the +/// back swipe cancels and Save commits. +struct AppListEditorView: View { + /// Nil creates a new list; otherwise edits (and saves into) the given one. + let list: AppList? + var onComplete: (AppList) -> Void + + @Environment(\.modelContext) private var modelContext + + @State private var name: String + @State private var selection: FamilyActivitySelection + + init(list: AppList?, onComplete: @escaping (AppList) -> Void) { + self.list = list + self.onComplete = onComplete + self._name = State(initialValue: list?.name ?? "") + self._selection = State(initialValue: AppSelectionCodec.decode(list?.selectionData)) + } + + var body: some View { + VStack(spacing: 0) { + // Styled like the app's other grouped inputs (rounded fill, no + // border) — FamilyActivityPicker below brings its own background, + // so the field sits directly on the sheet background. + TextField("List Name", text: $name) + .submitLabel(.done) + .padding(12) + .background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal) + .padding(.vertical, 8) + .accessibilityIdentifier("appListNameField") + + FamilyActivityPicker(selection: $selection) + } + .navigationTitle(list == nil ? "New List" : "Edit List") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(role: .confirm) { + save() + } label: { + Image(systemName: "checkmark") + } + .accessibilityLabel("Save List") + .accessibilityIdentifier("saveAppListButton") + } + } + .safeAreaInset(edge: .bottom) { + VStack(spacing: 8) { + Text(selectionSummary) + .font(.footnote) + .foregroundStyle(.secondary) + .accessibilityIdentifier("selectionCountLabel") + Button { + save() + } label: { + Text("Save") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .accessibilityIdentifier("saveAppListBottomButton") + } + .padding(.horizontal) + .padding(.bottom, 8) + } + } + + private var selectionSummary: String { + let count = AppSelectionCodec.count(of: selection) + return count == 1 ? "1 App Selected" : "\(count) Apps Selected" + } + + private func save() { + let trimmed = name.trimmingCharacters(in: .whitespaces) + let resolvedName = trimmed.isEmpty ? "Untitled List" : trimmed + let data = AppSelectionCodec.encode(selection) + let count = AppSelectionCodec.count(of: selection) + + if let list { + list.name = resolvedName + list.selectionData = data + list.selectionCount = count + onComplete(list) + } else { + let created = AppList(name: resolvedName, selectionData: data, selectionCount: count) + modelContext.insert(created) + onComplete(created) + } + } +} diff --git a/OpenAppLock/Views/AppLists/AppListPickerSheet.swift b/OpenAppLock/Views/AppLists/AppListPickerSheet.swift new file mode 100644 index 0000000..bda34c0 --- /dev/null +++ b/OpenAppLock/Views/AppLists/AppListPickerSheet.swift @@ -0,0 +1,156 @@ +// +// AppListPickerSheet.swift +// 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. +struct AppListPickerSheet: View { + @Binding var selected: AppList? + + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @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, lists are read-only — + /// editing one would be a back door out of the hard block. + private var listsLocked: Bool { + !RulePolicy.canEditAppLists(rules: rules) + } + + 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") + } + } + .navigationTitle("App List") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close", systemImage: "xmark") { + dismiss() + } + .accessibilityIdentifier("closeAppListPickerButton") + } + } + .navigationDestination(isPresented: $creatingList) { + AppListEditorView(list: nil) { created in + selected = created + creatingList = false + } + } + .navigationDestination(item: $editingList) { list in + AppListEditorView(list: list) { _ in + editingList = nil + } + } + .alert("This list is in use", isPresented: $deletionBlocked) { + Button("OK", role: .cancel) {} + } message: { + Text("Remove it from the rules that use it before deleting.") + } + } + } + + private func listRow(_ list: AppList) -> some View { + HStack { + Button { + selected = list + dismiss() + } label: { + HStack { + Image(systemName: isSelected(list) ? "checkmark.circle.fill" : "circle") + .foregroundStyle( + isSelected(list) ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.secondary) + ) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(list.name) + .foregroundStyle(Color.primary) + Text(list.appCountLabel) + .font(.caption) + .foregroundStyle(Color.secondary) + } + } + } + .accessibilityIdentifier("appListRow-\(list.name)") + Spacer() + if !listsLocked { + Button("Edit") { + editingList = list + } + .font(.subheadline) + .accessibilityIdentifier("editAppListButton-\(list.name)") + } + } + .buttonStyle(.borderless) + .swipeActions { + if !listsLocked { + Button("Delete", role: .destructive) { + delete(list) + } + } + } + } + + private func isSelected(_ list: AppList) -> Bool { + selected?.id == list.id + } + + private func delete(_ list: AppList) { + guard !AppList.isInUse(list, context: modelContext) else { + deletionBlocked = true + return + } + if isSelected(list) { + selected = nil + } + modelContext.delete(list) + } +} + +extension AppList { + /// "4 Apps" / "1 App" label shared by the picker, editor, and detail rows. + var appCountLabel: String { + selectionCount == 1 ? "1 App" : "\(selectionCount) Apps" + } +} diff --git a/OpenAppLock/Views/Apps/AppsHomeView.swift b/OpenAppLock/Views/Apps/AppsHomeView.swift index 9b1dda6..f7339e3 100644 --- a/OpenAppLock/Views/Apps/AppsHomeView.swift +++ b/OpenAppLock/Views/Apps/AppsHomeView.swift @@ -173,8 +173,7 @@ struct AppsHomeView: View { } private func blockSummary(for rule: BlockingRule) -> String { - let apps = rule.selectionCount == 1 ? "1 app" : "\(rule.selectionCount) apps" - return "\(rule.selectionMode.displayName) · \(apps)" + "\(rule.selectionMode.displayName) · \(rule.appList?.name ?? "No apps")" } private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String { @@ -195,7 +194,9 @@ struct AppsHomeView: View { rules.map { "\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.blockAdultContent)|" + "\($0.startMinutes)|\($0.endMinutes)|\($0.dayNumbers)|" - + "\($0.selectionCount)|\($0.pausedUntil?.timeIntervalSince1970 ?? 0)" + + "\($0.selectionModeRaw)|\($0.appList?.id.uuidString ?? "-")|" + + "\($0.appList?.selectionCount ?? 0)|" + + "\($0.pausedUntil?.timeIntervalSince1970 ?? 0)" } .joined(separator: ",") } diff --git a/OpenAppLock/Views/Rules/AppSelectionSheet.swift b/OpenAppLock/Views/Rules/AppSelectionSheet.swift deleted file mode 100644 index 8d75102..0000000 --- a/OpenAppLock/Views/Rules/AppSelectionSheet.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// AppSelectionSheet.swift -// OpenAppLock -// - -import FamilyControls -import SwiftUI - -/// App/category/website selection wrapping Apple's `FamilyActivityPicker`, -/// presented as a plain sheet with a segmented Block / Allow Only control. -struct AppSelectionSheet: View { - @Binding var draft: RuleDraft - @Environment(\.dismiss) private var dismiss - - @State private var selection: FamilyActivitySelection - @State private var mode: SelectionMode - - init(draft: Binding) { - self._draft = draft - self._selection = State( - initialValue: AppSelectionCodec.decode(draft.wrappedValue.selectionData) - ) - self._mode = State(initialValue: draft.wrappedValue.selectionMode) - } - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - Picker("Mode", selection: $mode) { - ForEach(SelectionMode.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) - } - } - .pickerStyle(.segmented) - .padding(.horizontal) - .padding(.vertical, 8) - .accessibilityIdentifier("selectionModePicker") - - FamilyActivityPicker(selection: $selection) - } - .navigationTitle("Selected") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - .accessibilityIdentifier("selectionBackButton") - } - ToolbarItem(placement: .confirmationAction) { - Button("Done") { - save() - } - .accessibilityIdentifier("confirmSelectionButton") - } - } - .safeAreaInset(edge: .bottom) { - VStack(spacing: 8) { - Text(selectionSummary) - .font(.footnote) - .foregroundStyle(.secondary) - .accessibilityIdentifier("selectionCountLabel") - Button { - save() - } label: { - Text("Save") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .accessibilityIdentifier("saveSelectionButton") - } - .padding(.horizontal) - .padding(.bottom, 8) - } - } - } - - private var selectionSummary: String { - let count = AppSelectionCodec.count(of: selection) - return count == 1 ? "1 App Selected" : "\(count) Apps Selected" - } - - private func save() { - draft.selectionData = AppSelectionCodec.encode(selection) - draft.selectionCount = AppSelectionCodec.count(of: selection) - draft.selectionMode = mode - dismiss() - } -} diff --git a/OpenAppLock/Views/Rules/NewRuleSheet.swift b/OpenAppLock/Views/Rules/NewRuleSheet.swift index 8ae1481..3e75f81 100644 --- a/OpenAppLock/Views/Rules/NewRuleSheet.swift +++ b/OpenAppLock/Views/Rules/NewRuleSheet.swift @@ -54,7 +54,7 @@ struct NewRuleSheet: View { mode: .create, draft: draft, onCommit: { committed in - modelContext.insert(committed.makeRule()) + committed.insertRule(into: modelContext) dismiss() } ) diff --git a/OpenAppLock/Views/Rules/RuleDetailSheet.swift b/OpenAppLock/Views/Rules/RuleDetailSheet.swift index 5672210..ee43b15 100644 --- a/OpenAppLock/Views/Rules/RuleDetailSheet.swift +++ b/OpenAppLock/Views/Rules/RuleDetailSheet.swift @@ -123,7 +123,8 @@ struct RuleDetailSheet: View { } private var appCountLabel: String { - rule.selectionCount == 1 ? "1 App" : "\(rule.selectionCount) Apps" + guard let list = rule.appList else { return "No apps" } + return "\(list.name) · \(list.appCountLabel)" } private func row(_ label: String, _ value: String) -> some View { diff --git a/OpenAppLock/Views/Rules/RuleEditorView.swift b/OpenAppLock/Views/Rules/RuleEditorView.swift index 7d1da12..1da66d1 100644 --- a/OpenAppLock/Views/Rules/RuleEditorView.swift +++ b/OpenAppLock/Views/Rules/RuleEditorView.swift @@ -74,7 +74,7 @@ struct RuleEditorView: View { } } .sheet(isPresented: $showingAppPicker) { - AppSelectionSheet(draft: $draft) + AppListPickerSheet(selected: $draft.appList) } } @@ -112,14 +112,22 @@ struct RuleEditorView: View { } daysSection Section { - selectedAppsRow + Picker("Mode", selection: $draft.selectionMode) { + ForEach(SelectionMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("selectionModePicker") + appListRow } header: { - Text("Apps are blocked").textCase(nil) + Text(draft.selectionMode == .block ? "Apps are blocked" : "Only these apps are allowed") + .textCase(nil) } toggleSections case .timeLimit: Section { - selectedAppsRow + appListRow } header: { Text("When I use").textCase(nil) } @@ -138,7 +146,7 @@ struct RuleEditorView: View { toggleSections case .openLimit: Section { - selectedAppsRow + appListRow } header: { Text("When I open").textCase(nil) } @@ -204,15 +212,15 @@ struct RuleEditorView: View { } } - private var selectedAppsRow: some View { + private var appListRow: some View { Button { showingAppPicker = true } label: { HStack { - Text("Selected Apps") + Text("App List") .foregroundStyle(Color.primary) Spacer() - Text(draft.selectionCount == 1 ? "1 App" : "\(draft.selectionCount) Apps") + Text(appListLabel) .foregroundStyle(Color.secondary) Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) @@ -222,6 +230,11 @@ struct RuleEditorView: View { .accessibilityIdentifier("selectedAppsRow") } + private var appListLabel: String { + guard let list = draft.appList else { return "Choose" } + return "\(list.name) · \(list.appCountLabel)" + } + private func budgetRow( value: String, stepperID: String, diff --git a/OpenAppLockTests/AppListTests.swift b/OpenAppLockTests/AppListTests.swift new file mode 100644 index 0000000..fe80dfa --- /dev/null +++ b/OpenAppLockTests/AppListTests.swift @@ -0,0 +1,236 @@ +// +// AppListTests.swift +// OpenAppLockTests +// + +import Foundation +import SwiftData +import Testing + +@testable import OpenAppLock + +// Note: every test wires `rule.appList` only after both models are inserted — +// SwiftData relationships must not be written on unmanaged instances +// (see BlockingRule.appList). + +@MainActor +@Suite("AppList model & relationship") +struct AppListModelTests { + @Test("App lists persist and fetch through SwiftData") + func persistence() throws { + let context = try makeInMemoryContext() + let list = AppList(name: "Distractions", selectionData: Data([1, 2]), selectionCount: 2) + context.insert(list) + try context.save() + + let fetched = try context.fetch(FetchDescriptor()) + #expect(fetched.count == 1) + let saved = try #require(fetched.first) + #expect(saved.name == "Distractions") + #expect(saved.selectionCount == 2) + #expect(saved.selectionData == Data([1, 2])) + } + + @Test("Deleting a list detaches it from its rules") + func deletingListDetachesRules() throws { + let context = try makeInMemoryContext() + let list = AppList(name: "Distractions") + let rule = BlockingRule(name: "Work Time") + context.insert(list) + context.insert(rule) + rule.appList = list + try context.save() + + context.delete(list) + try context.save() + + let rules = try context.fetch(FetchDescriptor()) + #expect(rules.count == 1) + #expect(rules.first?.appList == nil) + } + + @Test("Deleting a rule keeps its list") + func deletingRuleKeepsList() throws { + let context = try makeInMemoryContext() + let list = AppList(name: "Distractions") + let rule = BlockingRule(name: "Work Time") + context.insert(list) + context.insert(rule) + rule.appList = list + try context.save() + + context.delete(rule) + try context.save() + + let lists = try context.fetch(FetchDescriptor()) + #expect(lists.count == 1) + } + + @Test("Lists report whether any rule uses them") + func usageQuery() throws { + let context = try makeInMemoryContext() + let used = AppList(name: "Used") + let unused = AppList(name: "Unused") + let rule = BlockingRule(name: "Work Time") + context.insert(used) + context.insert(unused) + context.insert(rule) + rule.appList = used + try context.save() + + #expect(AppList.isInUse(used, context: context)) + #expect(!AppList.isInUse(unused, context: context)) + } +} + +@MainActor +@Suite("Legacy selection → AppList migration") +struct AppListMigrationTests { + @Test("Rules with legacy inline selections get a list named after them") + func createsListsFromLegacySelections() throws { + let context = try makeInMemoryContext() + let rule = BlockingRule(name: "Work Time", selectionData: Data([1]), selectionCount: 3) + context.insert(rule) + try context.save() + + AppListMigration.run(in: context) + + let lists = try context.fetch(FetchDescriptor()) + #expect(lists.count == 1) + let list = try #require(rule.appList) + #expect(list.name == "Work Time Apps") + #expect(list.selectionData == Data([1])) + #expect(list.selectionCount == 3) + // The legacy inline copy is cleared so migration never re-runs. + #expect(rule.selectionData == nil) + } + + @Test("Rules with identical selections share one list") + func sharesListForIdenticalSelections() throws { + let context = try makeInMemoryContext() + let first = BlockingRule(name: "Work Time", selectionData: Data([7]), selectionCount: 2) + let second = BlockingRule(name: "Sleep", selectionData: Data([7]), selectionCount: 2) + let different = BlockingRule(name: "Gym", selectionData: Data([9]), selectionCount: 1) + context.insert(first) + context.insert(second) + context.insert(different) + try context.save() + + AppListMigration.run(in: context) + + let lists = try context.fetch(FetchDescriptor()) + #expect(lists.count == 2) + #expect(first.appList === second.appList) + #expect(first.appList !== different.appList) + } + + @Test("Migration is idempotent and skips selection-less rules") + func idempotentAndSkipsEmpty() throws { + let context = try makeInMemoryContext() + let legacy = BlockingRule(name: "Work Time", selectionData: Data([1]), selectionCount: 1) + let empty = BlockingRule(name: "No Apps") + context.insert(legacy) + context.insert(empty) + try context.save() + + AppListMigration.run(in: context) + AppListMigration.run(in: context) + + let lists = try context.fetch(FetchDescriptor()) + #expect(lists.count == 1) + #expect(empty.appList == nil) + } +} + +@MainActor +@Suite("Rule drafts with app lists") +struct AppListDraftTests { + @Test("Drafts carry the rule's app list and apply it back") + func draftCarriesAppList() throws { + let context = try makeInMemoryContext() + let list = AppList(name: "Distractions", selectionCount: 4) + let rule = BlockingRule(name: "Work Time") + context.insert(list) + context.insert(rule) + rule.appList = list + + var draft = RuleDraft(rule: rule) + #expect(draft.appList === list) + + draft.name = "Other" + let other = draft.insertRule(into: context) + #expect(other.appList === list) + #expect(other.name == "Other") + } + + @Test("Sanitizing forces Block mode for time- and open-limit rules") + func sanitizedForcesBlockForLimitKinds() { + var timeDraft = RuleDraft(kind: .timeLimit) + timeDraft.selectionMode = .allowOnly + #expect(timeDraft.sanitized().selectionMode == .block) + + var openDraft = RuleDraft(kind: .openLimit) + openDraft.selectionMode = .allowOnly + #expect(openDraft.sanitized().selectionMode == .block) + } + + @Test("Sanitizing keeps Allow Only on schedule rules") + func sanitizedKeepsAllowOnlyForSchedule() { + var draft = RuleDraft(kind: .schedule) + draft.selectionMode = .allowOnly + #expect(draft.sanitized().selectionMode == .allowOnly) + } +} + +@MainActor +@Suite("App-list editing under Hard Mode") +struct AppListEditingPolicyTests { + let mondayDuringWork = date(2025, 1, 6, 10, 0) + let mondayEvening = date(2025, 1, 6, 19, 0) + + @Test("App lists are locked while any hard-mode rule is actively blocking") + func lockedDuringHardModeSession() { + let hard = BlockingRule(name: "Locked In", hardMode: true) + let soft = BlockingRule(name: "Work Time") + #expect( + !RulePolicy.canEditAppLists(rules: [hard, soft], at: mondayDuringWork, calendar: utc)) + } + + @Test("App lists stay editable when no hard-mode rule is blocking") + func editableWithoutHardSession() { + let softActive = BlockingRule(name: "Work Time") + let hardInactive = BlockingRule(name: "Locked In", hardMode: true) + #expect( + RulePolicy.canEditAppLists( + rules: [softActive, hardInactive], at: mondayEvening, calendar: utc)) + #expect(RulePolicy.canEditAppLists(rules: [], at: mondayDuringWork, calendar: utc)) + } + + @Test("A disabled hard-mode rule does not lock app lists") + func disabledHardRuleDoesNotLock() { + let rule = BlockingRule(name: "Locked In", isEnabled: false, hardMode: true) + #expect(RulePolicy.canEditAppLists(rules: [rule], at: mondayDuringWork, calendar: utc)) + } +} + +@MainActor +@Suite("Enforcement reads selections through app lists") +struct AppListEnforcementTests { + let mondayDuringWork = date(2025, 1, 6, 10, 0) + + @Test("The rule's app-list selection reaches the shield layer") + func forwardsAppListSelection() throws { + let context = try makeInMemoryContext() + let shields = MockShieldController() + let enforcer = RuleEnforcer(shields: shields) + let list = AppList(name: "Distractions", selectionData: Data([1, 2, 3])) + let rule = BlockingRule(name: "Work Time") + context.insert(list) + context.insert(rule) + rule.appList = list + + enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc) + + #expect(shields.appliedSelectionData[rule.id] == Data([1, 2, 3])) + } +} diff --git a/OpenAppLockTests/RuleModelTests.swift b/OpenAppLockTests/RuleModelTests.swift index 8f4a8f2..5eb0de3 100644 --- a/OpenAppLockTests/RuleModelTests.swift +++ b/OpenAppLockTests/RuleModelTests.swift @@ -47,11 +47,7 @@ struct RuleModelTests { @Test("Rules persist and fetch through SwiftData") func persistence() throws { - let container = try ModelContainer( - for: BlockingRule.self, - configurations: ModelConfiguration(isStoredInMemoryOnly: true) - ) - let context = container.mainContext + let context = try makeInMemoryContext() let rule = BlockingRule( name: "Deep Sleep", hardMode: true, days: Weekday.everyDay, startMinutes: 22 * 60, endMinutes: 6 * 60 @@ -83,7 +79,11 @@ struct RuleDraftTests { } @Test("Draft → rule → draft round-trips every field") - func roundTrip() { + func roundTrip() throws { + let context = try makeInMemoryContext() + let list = AppList(name: "Distractions", selectionCount: 3) + context.insert(list) + var draft = RuleDraft(kind: .schedule) draft.name = "Locked In" draft.days = Weekday.everyDay @@ -92,16 +92,18 @@ struct RuleDraftTests { draft.hardMode = true draft.blockAdultContent = true draft.selectionMode = .allowOnly - draft.selectionCount = 3 + draft.appList = list - let rule = draft.makeRule() + let rule = draft.insertRule(into: context) #expect(rule.blockAdultContent) #expect(RuleDraft(rule: rule) == draft) } @Test("Applying a draft updates an existing rule") - func applyToExisting() { + func applyToExisting() throws { + let context = try makeInMemoryContext() let rule = BlockingRule(name: "Old Name") + context.insert(rule) var draft = RuleDraft(rule: rule) draft.name = "New Name" draft.hardMode = true diff --git a/OpenAppLockTests/TestSupport.swift b/OpenAppLockTests/TestSupport.swift index c4c1b23..596ff8d 100644 --- a/OpenAppLockTests/TestSupport.swift +++ b/OpenAppLockTests/TestSupport.swift @@ -4,9 +4,45 @@ // import Foundation +import SwiftData @testable import OpenAppLock +/// One in-memory ModelContainer shared by the whole test process. +/// +/// Repeatedly creating containers for this schema (inverse relationships) +/// trips an intermittent EXC_BREAKPOINT inside SwiftData's configuration +/// setup — observed both in test runs and in a sequential snippet that made +/// six containers. Tests therefore share a single container; isolation comes +/// from a fresh ModelContext plus a data wipe per test. +@MainActor +private let sharedTestContainer: ModelContainer = { + do { + return try ModelContainer( + for: Schema([BlockingRule.self, AppList.self]), + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + } catch { + fatalError("Could not create the shared test ModelContainer: \(error)") + } +}() + +/// Fresh, empty SwiftData context for model tests. The wipe deletes object +/// by object — batch `delete(model:)` refuses to fire the appList nullify +/// inverse ("Constraint trigger violation"). +@MainActor +func makeInMemoryContext() throws -> ModelContext { + let context = ModelContext(sharedTestContainer) + for rule in try context.fetch(FetchDescriptor()) { + context.delete(rule) + } + for list in try context.fetch(FetchDescriptor()) { + context.delete(list) + } + try context.save() + return context +} + /// Fixed UTC gregorian calendar so schedule math is deterministic regardless /// of the machine running the tests. let utc: Calendar = { diff --git a/OpenAppLockUITests/AppListUITests.swift b/OpenAppLockUITests/AppListUITests.swift new file mode 100644 index 0000000..0468a7f --- /dev/null +++ b/OpenAppLockUITests/AppListUITests.swift @@ -0,0 +1,106 @@ +// +// AppListUITests.swift +// OpenAppLockUITests +// + +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. +final class AppListUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testScheduleEditorOffersModeChoice() throws { + let app = XCUIApplication.launchOpenAppLock() + app.buttons["newRuleButton"].waitToAppear().tap() + app.buttons["ruleKind-schedule"].waitToAppear().tap() + + app.staticTexts["ruleEditorTitle"].waitToAppear() + app.swipeUp() + app.element("selectionModePicker").waitToAppear() + XCTAssertTrue(app.staticTexts["Apps are blocked"].exists) + } + + func testLimitEditorsAreBlockOnly() throws { + let app = XCUIApplication.launchOpenAppLock() + app.buttons["newRuleButton"].waitToAppear().tap() + + app.buttons["ruleKind-timeLimit"].waitToAppear().tap() + app.element("selectedAppsRow").waitToAppear() + XCTAssertFalse( + app.element("selectionModePicker").exists, + "Time-limit rules are always Block; the mode picker must not appear" + ) + + // Back to the rule-type list, then check the open-limit editor too. + let edge = app.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5)) + let middle = app.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)) + edge.press(forDuration: 0.05, thenDragTo: middle) + + app.buttons["ruleKind-openLimit"].waitToAppear().tap() + app.element("selectedAppsRow").waitToAppear() + XCTAssertFalse(app.element("selectionModePicker").exists) + } + + func testCreateAppListFlowSelectsNewList() throws { + let app = XCUIApplication.launchOpenAppLock() + app.buttons["newRuleButton"].waitToAppear().tap() + app.buttons["ruleKind-timeLimit"].waitToAppear().tap() + + app.element("selectedAppsRow").waitToAppear().tap() + + // Fresh install: no lists yet, so the picker offers creation. + app.element("emptyAppListsLabel").waitToAppear() + app.buttons["newAppListButton"].tap() + + let nameField = app.textFields["appListNameField"].waitToAppear() + nameField.tap() + nameField.typeText("Focus Apps\n") + app.buttons["saveAppListButton"].waitToAppear().tap() + + // Saving pops back to the picker with the new list selected. + app.element("appListRow-Focus Apps").waitToAppear() + app.buttons["closeAppListPickerButton"].tap() + + // The editor row now reports the chosen list. + let row = app.element("selectedAppsRow").waitToAppear() + XCTAssertTrue(row.label.contains("Focus Apps"), "Got: \(row.label)") + } + + func testDetailShowsAppListName() throws { + let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard") + app.buttons["ruleCard-Work Time"].waitToAppear().tap() + + let row = app.element("detailRow-Block").waitToAppear() + XCTAssertTrue(row.label.contains("Distractions"), "Got: \(row.label)") + } + + func testHardModeSessionLocksAppListEditing() throws { + let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active") + + // "Sleep" is soft and editable even while "Locked In" hard-blocks. + app.buttons["ruleCard-Sleep"].waitToAppear().tap() + app.buttons["editRuleButton"].waitToAppear().tap() + app.element("selectedAppsRow").waitToAppear().tap() + + 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" + ) + } + + func testAppListsEditableWithoutHardSession() throws { + let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard") + + app.buttons["ruleCard-Sleep"].waitToAppear().tap() + app.buttons["editRuleButton"].waitToAppear().tap() + app.element("selectedAppsRow").waitToAppear().tap() + + app.buttons["editAppListButton-Distractions"].waitToAppear() + XCTAssertFalse(app.element("appListsLockedNotice").exists) + } +} diff --git a/docs/RULES_FEATURE_SPEC.md b/docs/RULES_FEATURE_SPEC.md index 7c44670..7b88e65 100644 --- a/docs/RULES_FEATURE_SPEC.md +++ b/docs/RULES_FEATURE_SPEC.md @@ -23,7 +23,16 @@ Common attributes across all types: - **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)) - **Days of week** — 7-day toggle set, summarized as "Weekdays" / "Weekends" / "Every day" / custom -- **App selection** — apps, categories, and websites; selection mode is either **Block** (block selected) or **Allow Only** (block everything except selected) +- **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"). +- **Selection mode** — **Block** (block the list) or **Allow Only** (block + everything except the list). The mode belongs to the *rule*, not the list. + Only **Schedule** rules offer the choice; Time Limit and Open Limit rules + are always Block (a usage budget over "everything except X" is not + meaningful, and drafts of those kinds are sanitized back to Block). - **Hard Mode** — boolean, PRO-gated in Opal; subtitle "No unblocks allowed". When off, the rule detail shows "Unblocks allowed: Yes" - **Enabled/disabled** — a rule can be disabled without deleting ("Disable Rule") @@ -147,7 +156,11 @@ Sections (each an inset rounded group with a small icon + caption header): row of 7 circular toggles `S M T W T F S`; selected = filled white circle with black letter, unselected = dark circle. 3. **🛡 Apps are blocked** - - Row: `Selected Apps` → `N Apps ›` — pushes the App Picker. + - Row: `App List` → ` · N Apps ›` (or `Choose ›` when none) — + presents the App List picker. + - A segmented `Block | Allow Only` row (Schedule editor only) chooses how + the rule interprets its list; the section header reads "Apps are + 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)* — @@ -209,8 +222,22 @@ Full-height sheet: > 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, and -> keep the `Block`/`Allow Only` segmented control as our own wrapper state. +> embed `FamilyActivityPicker`** instead of cloning Opal's custom picker. +> +> **App Lists (OpenAppLock):** the selection itself lives on a reusable +> **App List** (`@Model AppList`: name + encoded `FamilyActivitySelection`). +> The editor's App List row presents a picker sheet listing saved lists +> (checkmark on the rule's current list; tap to select), an Edit affordance +> per list, and a "New List" flow — a name field plus an embedded +> `FamilyActivityPicker`. The `Block`/`Allow Only` segmented control lives in +> the Schedule rule editor (it is rule state, not list state). Legacy rules +> that stored an inline selection are migrated at launch: one list per +> distinct selection (rules sharing identical selection data share a list), +> named " Apps". Lists in use by a rule cannot be deleted from the +> picker. While any **Hard Mode** rule is actively blocking, all lists are +> read-only — the picker hides Edit/Delete and shows a lock notice — because +> editing a list would be a back door out of the hard block. Creating new +> lists and selecting lists for other rules remain available. ---