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:
156
OpenAppLock/Views/AppLists/AppListPickerSheet.swift
Normal file
156
OpenAppLock/Views/AppLists/AppListPickerSheet.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user