feat: introduce reusable app lists for rules

- 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
This commit is contained in:
2026-06-12 20:02:35 -04:00
parent 05d48a9f34
commit 93f2eceb3a
22 changed files with 880 additions and 138 deletions

View File

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