From 3e889bf192043855c6204653b75f81e2d97259d7 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 19 Jun 2026 19:25:31 -0400 Subject: [PATCH] feat: restructure app-list editing navigation Rework how app lists are reached and edited from the rule editor: - The rule editor's App List row now pushes the selection screen onto the editor's own navigation stack instead of presenting it as a sheet overlay; the back button returns. - Editing or creating a list opens it as a sheet overlay with a Close button and a checkmark confirm (in both the rule-editor flow and Settings > Manage App Lists, which share the library). - Closing the editor with outstanding edits raises the standard iOS "Discard Changes?" confirmation; swipe-to-dismiss is disabled while edits are outstanding. Dirty-detection extracted to a unit-tested AppListEditState. - The "Edit Apps" picker binds FamilyActivityPicker directly so selections apply live; its bottom Save button and toolbar checkmark are removed, leaving the list editor's checkmark as the single commit point. Updated the feature spec (3.7 / 6) and the app-list UI tests; added unit tests for outstanding-edit detection. Full suite green (245 tests). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Xeke7pAWJ9S4RiGc5hMghr --- Docs/AGENT_RULES_FEATURE_SPEC.md | 30 ++- OpenAppLock/Logic/AppListEditState.swift | 34 +++ OpenAppLock/Models/AppList.swift | 5 + .../Views/AppLists/AppListEditorView.swift | 199 +++++++++--------- .../Views/AppLists/AppListLibraryView.swift | 11 +- .../Views/AppLists/AppListPickerSheet.swift | 38 ---- OpenAppLock/Views/Rules/RuleEditorView.swift | 8 +- OpenAppLockTests/AppListEditStateTests.swift | 61 ++++++ OpenAppLockUITests/AppListUITests.swift | 71 ++++++- OpenAppLockUITests/SettingsUITests.swift | 8 +- 10 files changed, 300 insertions(+), 165 deletions(-) create mode 100644 OpenAppLock/Logic/AppListEditState.swift delete mode 100644 OpenAppLock/Views/AppLists/AppListPickerSheet.swift create mode 100644 OpenAppLockTests/AppListEditStateTests.swift diff --git a/Docs/AGENT_RULES_FEATURE_SPEC.md b/Docs/AGENT_RULES_FEATURE_SPEC.md index 05ebd01..7da6aa3 100644 --- a/Docs/AGENT_RULES_FEATURE_SPEC.md +++ b/Docs/AGENT_RULES_FEATURE_SPEC.md @@ -66,7 +66,7 @@ Tab bar: [Home] [My Apps] [Timer] │ │ └── tap preset card ───▶ Rule Editor (pre-filled) │ └── tap rule card ─▶ Rule Detail sheet │ └── "Edit Rule" ─▶ Rule Editor (edit mode) - │ └── "Selected Apps >" ─▶ App Picker + │ └── "App List >" ─▶ App List selection (pushed) ─▶ Edit/New ─▶ List editor (sheet overlay) ─▶ "Edit Apps" ─▶ FamilyActivityPicker └── "Apps" section (folders: Distracting / Always Allowed / Never Allowed) ``` @@ -249,18 +249,28 @@ Full-height sheet: > > **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 editor's App List row **pushes** the selection screen onto the editor's +> own navigation stack (the back button returns) — the saved lists with a +> checkmark on the rule's current one, an Edit affordance per list, and a "New +> List" entry. Tapping a list selects it and pops back to the editor. +> **Editing or creating a list opens it as a sheet overlay** with a **Close** +> button (top-left ✕) and a **checkmark** confirm (top-right): a name field +> plus an "Edit Apps" row that pushes an embedded `FamilyActivityPicker`. +> Selecting/deselecting in that picker **applies immediately** to the list's +> working selection — it has no Save of its own — so the editor's checkmark is +> the single commit point. Closing the overlay with **outstanding edits** +> (name or selection changed) first raises the standard iOS **"Discard +> Changes?"** confirmation (a destructive *Discard Changes* action); the +> swipe-to-dismiss is disabled while edits are outstanding so the prompt can't +> be bypassed. 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. +> selection screen. While any **Hard Mode** rule is actively blocking, all +> lists are read-only — the selection screen 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. --- @@ -554,7 +564,7 @@ layout or selected section. |---|---| | Home tab | `NavigationStack` + `List`. Every row carries a **leading kind icon**, the name, and a ` · ` subtitle, where *context* is the rule's live status: a schedule reads its countdown (`Schedule · 6h left`), a limit reads its usage once used today (`Time Limit · 18m of 45m used`) or its plain budget while untouched (`Time Limit · 45m / day`). **"Currently Blocking"** section (renamed from "Blocked Apps") — the *rules* blocking right now: 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). **"Usage"** section: every enabled limit rule scheduled today that is *not* currently blocking; rows have **no trailing label** (the context lives in the subtitle). | | Rules tab | `NavigationStack` + `List` split into **Schedule / Time Limit / Open Limit** sections (empty sections hidden); **rules are list rows** (leading kind icon, name, and a `` subtitle — the same live status/countdown/usage as Home, but **without the type prefix** since the section header already conveys the kind, and **without a separate trailing status label**; the `ruleStatus-` identifier lives on this subtitle); "+" 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. The toggle itself is **locked while any Hard Mode rule is actively blocking**: the switch is replaced by a trailing red `lock.fill` (same treatment as a Home "Currently Blocking" hard row) so the protection can't be turned off mid-block — its whole purpose. **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). An **About** section holds outbound `Link` rows — **GitHub** and **Website** — each shown only when its destination is configured (see §6.2). | +| 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. The toggle itself is **locked while any Hard Mode rule is actively blocking**: the switch is replaced by a trailing red `lock.fill` (same treatment as a Home "Currently Blocking" hard row) so the protection can't be turned off mid-block — its whole purpose. **Manage App Lists** pushes the shared App List library in management mode (create / edit / delete, the list editor opening as a sheet overlay with a Close/confirm pair and the discard-changes prompt; honoring the Hard Mode lock — the same library the rule editor's App List row pushes, minus selection). An **About** section holds outbound `Link` rows — **GitHub** and **Website** — each shown only when its destination is configured (see §6.2). | | 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 | diff --git a/OpenAppLock/Logic/AppListEditState.swift b/OpenAppLock/Logic/AppListEditState.swift new file mode 100644 index 0000000..176b99a --- /dev/null +++ b/OpenAppLock/Logic/AppListEditState.swift @@ -0,0 +1,34 @@ +// +// AppListEditState.swift +// OpenAppLock +// + +import FamilyControls + +/// Decides whether an open app-list editor has unsaved work. The editor uses +/// this to gate the "Discard Changes?" confirmation: closing with outstanding +/// edits prompts before throwing them away, the standard iOS pattern. +enum AppListEditState { + /// Outstanding edits exist when the name changed or the chosen apps changed + /// from what the editor opened with. + static func hasOutstandingEdits( + originalName: String, + currentName: String, + originalSelection: FamilyActivitySelection, + currentSelection: FamilyActivitySelection + ) -> Bool { + if currentName != originalName { return true } + return !selectionsMatch(originalSelection, currentSelection) + } + + /// Two selections match when their app, category, and web-domain token sets + /// are equal. Compared as sets so token ordering never matters (unlike the + /// encoded `Data`, whose byte order isn't guaranteed stable). + static func selectionsMatch( + _ lhs: FamilyActivitySelection, _ rhs: FamilyActivitySelection + ) -> Bool { + lhs.applicationTokens == rhs.applicationTokens + && lhs.categoryTokens == rhs.categoryTokens + && lhs.webDomainTokens == rhs.webDomainTokens + } +} diff --git a/OpenAppLock/Models/AppList.swift b/OpenAppLock/Models/AppList.swift index b736bc0..967da60 100644 --- a/OpenAppLock/Models/AppList.swift +++ b/OpenAppLock/Models/AppList.swift @@ -44,4 +44,9 @@ final class AppList { ) return ((try? context.fetchCount(descriptor)) ?? 0) > 0 } + + /// "4 Apps" / "1 App" label shared by the library, editor, and detail rows. + var appCountLabel: String { + selectionCount == 1 ? "1 App" : "\(selectionCount) Apps" + } } diff --git a/OpenAppLock/Views/AppLists/AppListEditorView.swift b/OpenAppLock/Views/AppLists/AppListEditorView.swift index d33e70b..82e8d0d 100644 --- a/OpenAppLock/Views/AppLists/AppListEditorView.swift +++ b/OpenAppLock/Views/AppLists/AppListEditorView.swift @@ -7,75 +7,110 @@ import FamilyControls import SwiftData import SwiftUI -/// Creates or edits an app list. A plain List (consistent with the rest of -/// the app) holds the name field and the apps currently in the list; "Edit -/// Apps" pushes Apple's Screen Time picker, whose Save applies the new -/// selection back here. The navigation-bar checkmark persists the list. +/// Creates or edits an app list, presented as a sheet overlay from the app-list +/// library. Its own NavigationStack carries a Close button — which, when there +/// are outstanding edits, confirms before discarding them (the standard iOS +/// pattern) — and a checkmark that persists the list. "Edit Apps" pushes Apple's +/// Screen Time picker, whose selections apply live; there is no separate Save +/// inside the picker, so the only commit point is this editor's checkmark. 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 + @Environment(\.dismiss) private var dismiss @State private var name: String @State private var selection: FamilyActivitySelection @State private var pickingApps = false + @State private var confirmingDiscard = false + + /// What the editor opened with, for detecting outstanding edits on close. + private let originalName: String + private let originalSelection: 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)) + let initialName = list?.name ?? "" + let initialSelection = AppSelectionCodec.decode(list?.selectionData) + self._name = State(initialValue: initialName) + self._selection = State(initialValue: initialSelection) + self.originalName = initialName + self.originalSelection = initialSelection } var body: some View { - List { - Section { - TextField("List Name", text: $name) - .submitLabel(.done) - .accessibilityIdentifier("appListNameField") - } header: { - Text("Name").textCase(nil) - } + NavigationStack { + List { + Section { + TextField("List Name", text: $name) + .submitLabel(.done) + .accessibilityIdentifier("appListNameField") + } header: { + Text("Name").textCase(nil) + } - Section { - if AppSelectionCodec.count(of: selection) == 0 { - Text("No apps yet. Edit Apps to choose what this list includes.") - .foregroundStyle(.secondary) - .accessibilityIdentifier("emptySelectionLabel") - } else { - selectionRows + Section { + if AppSelectionCodec.count(of: selection) == 0 { + Text("No apps yet. Edit Apps to choose what this list includes.") + .foregroundStyle(.secondary) + .accessibilityIdentifier("emptySelectionLabel") + } else { + selectionRows + } + Button { + pickingApps = true + } label: { + Label("Edit Apps", systemImage: "checklist") + } + .accessibilityIdentifier("editAppsButton") + } header: { + HStack { + Text("Apps").textCase(nil) + Spacer() + Text(countLabel).textCase(nil) + } } - Button { - pickingApps = true - } label: { - Label("Edit Apps", systemImage: "checklist") + } + .navigationTitle(list == nil ? "New List" : "Edit List") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close", systemImage: "xmark") { + attemptClose() + } + .accessibilityIdentifier("closeAppListButton") } - .accessibilityIdentifier("editAppsButton") - } header: { - HStack { - Text("Apps").textCase(nil) - Spacer() - Text(countLabel).textCase(nil) + ToolbarItem(placement: .confirmationAction) { + Button(role: .confirm) { + save() + } label: { + Image(systemName: "checkmark") + } + .accessibilityLabel("Save List") + .accessibilityIdentifier("saveAppListButton") } } - } - .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") + .navigationDestination(isPresented: $pickingApps) { + AppPickerScreen(selection: $selection) } } - .navigationDestination(isPresented: $pickingApps) { - AppPickerScreen(selection: $selection) + // Block the swipe-to-dismiss while there are unsaved edits so the only + // way out is the Close button, which routes through the discard prompt. + .interactiveDismissDisabled(hasOutstandingEdits) + .confirmationDialog( + "Discard Changes?", + isPresented: $confirmingDiscard, + titleVisibility: .visible + ) { + Button("Discard Changes", role: .destructive) { + dismiss() + } + Button("Keep Editing", role: .cancel) {} + } message: { + Text("Your edits to this list haven't been saved.") } } @@ -99,6 +134,24 @@ struct AppListEditorView: View { return count == 1 ? "1 App" : "\(count) Apps" } + private var hasOutstandingEdits: Bool { + AppListEditState.hasOutstandingEdits( + originalName: originalName, + currentName: name, + originalSelection: originalSelection, + currentSelection: selection + ) + } + + /// Close immediately when nothing changed; otherwise confirm the discard. + private func attemptClose() { + if hasOutstandingEdits { + confirmingDiscard = true + } else { + dismiss() + } + } + private func save() { let trimmed = name.trimmingCharacters(in: .whitespaces) let resolvedName = trimmed.isEmpty ? "Untitled List" : trimmed @@ -115,65 +168,19 @@ struct AppListEditorView: View { modelContext.insert(created) onComplete(created) } + dismiss() } } -/// Screen 2: Apple's Screen Time picker. Save applies the working selection -/// back to the editor and pops; the back swipe discards it. +/// Apple's Screen Time picker, pushed by "Edit Apps". It binds straight to the +/// editor's working selection, so selecting or deselecting an app applies the +/// change immediately — those edits are committed only when the editor is saved. private struct AppPickerScreen: View { @Binding var selection: FamilyActivitySelection - @Environment(\.dismiss) private var dismiss - @State private var working: FamilyActivitySelection - - init(selection: Binding) { - self._selection = selection - self._working = State(initialValue: selection.wrappedValue) - } - var body: some View { - FamilyActivityPicker(selection: $working) + FamilyActivityPicker(selection: $selection) .navigationTitle("Edit Apps") .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(role: .confirm) { - saveSelection() - } label: { - Image(systemName: "checkmark") - } - .accessibilityLabel("Save Apps") - .accessibilityIdentifier("confirmSelectionButton") - } - } - .safeAreaInset(edge: .bottom) { - VStack(spacing: 8) { - Text(selectionSummary) - .font(.footnote) - .foregroundStyle(.secondary) - .accessibilityIdentifier("selectionCountLabel") - Button { - saveSelection() - } 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: working) - return count == 1 ? "1 App Selected" : "\(count) Apps Selected" - } - - private func saveSelection() { - selection = working - dismiss() } } diff --git a/OpenAppLock/Views/AppLists/AppListLibraryView.swift b/OpenAppLock/Views/AppLists/AppListLibraryView.swift index 3f9443c..cc34926 100644 --- a/OpenAppLock/Views/AppLists/AppListLibraryView.swift +++ b/OpenAppLock/Views/AppLists/AppListLibraryView.swift @@ -84,16 +84,15 @@ struct AppListLibraryView: View { } } } - .navigationDestination(isPresented: $creatingList) { + // The editor pops out as its own sheet overlay (with Close + confirm and + // a discard prompt); it dismisses itself, which clears these bindings. + .sheet(isPresented: $creatingList) { AppListEditorView(list: nil) { created in selection?.wrappedValue = created - creatingList = false } } - .navigationDestination(item: $editingList) { list in - AppListEditorView(list: list) { _ in - editingList = nil - } + .sheet(item: $editingList) { list in + AppListEditorView(list: list) { _ in } } .alert("This list is in use", isPresented: $deletionBlocked) { Button("OK", role: .cancel) {} diff --git a/OpenAppLock/Views/AppLists/AppListPickerSheet.swift b/OpenAppLock/Views/AppLists/AppListPickerSheet.swift deleted file mode 100644 index dc40a25..0000000 --- a/OpenAppLock/Views/AppLists/AppListPickerSheet.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// AppListPickerSheet.swift -// OpenAppLock -// - -import SwiftUI - -/// 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 - - var body: some View { - NavigationStack { - AppListLibraryView(selection: $selected, onPick: { dismiss() }) - .navigationTitle("App List") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Close", systemImage: "xmark") { - dismiss() - } - .accessibilityIdentifier("closeAppListPickerButton") - } - } - } - } -} - -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/Rules/RuleEditorView.swift b/OpenAppLock/Views/Rules/RuleEditorView.swift index 68ac55f..35fe666 100644 --- a/OpenAppLock/Views/Rules/RuleEditorView.swift +++ b/OpenAppLock/Views/Rules/RuleEditorView.swift @@ -73,8 +73,12 @@ struct RuleEditorView: View { } } } - .sheet(isPresented: $showingAppPicker) { - AppListPickerSheet(selected: $draft.appList) + // Push the app-list selection onto the editor's own stack (the back + // button returns here); the library presents its editor as a sheet. + .navigationDestination(isPresented: $showingAppPicker) { + AppListLibraryView(selection: $draft.appList, onPick: { showingAppPicker = false }) + .navigationTitle("App List") + .navigationBarTitleDisplayMode(.inline) } } diff --git a/OpenAppLockTests/AppListEditStateTests.swift b/OpenAppLockTests/AppListEditStateTests.swift new file mode 100644 index 0000000..bffc124 --- /dev/null +++ b/OpenAppLockTests/AppListEditStateTests.swift @@ -0,0 +1,61 @@ +// +// AppListEditStateTests.swift +// OpenAppLockTests +// + +import FamilyControls +import Testing + +@testable import OpenAppLock + +@Suite("App-list editor outstanding-edits detection") +struct AppListEditStateTests { + @Test("A freshly opened editor has no outstanding edits") + func untouchedHasNoEdits() { + #expect( + !AppListEditState.hasOutstandingEdits( + originalName: "", + currentName: "", + originalSelection: FamilyActivitySelection(), + currentSelection: FamilyActivitySelection() + ) + ) + #expect( + !AppListEditState.hasOutstandingEdits( + originalName: "Distractions", + currentName: "Distractions", + originalSelection: FamilyActivitySelection(), + currentSelection: FamilyActivitySelection() + ) + ) + } + + @Test("Renaming the list counts as an outstanding edit") + func renameIsAnEdit() { + #expect( + AppListEditState.hasOutstandingEdits( + originalName: "", + currentName: "Focus Apps", + originalSelection: FamilyActivitySelection(), + currentSelection: FamilyActivitySelection() + ) + ) + #expect( + AppListEditState.hasOutstandingEdits( + originalName: "Distractions", + currentName: "Distractions ", + originalSelection: FamilyActivitySelection(), + currentSelection: FamilyActivitySelection() + ) + ) + } + + @Test("Two empty selections match regardless of name when the name is unchanged") + func emptySelectionsMatch() { + #expect( + AppListEditState.selectionsMatch( + FamilyActivitySelection(), FamilyActivitySelection() + ) + ) + } +} diff --git a/OpenAppLockUITests/AppListUITests.swift b/OpenAppLockUITests/AppListUITests.swift index ef56ced..7a90183 100644 --- a/OpenAppLockUITests/AppListUITests.swift +++ b/OpenAppLockUITests/AppListUITests.swift @@ -55,34 +55,89 @@ final class AppListUITests: XCTestCase { app.buttons["newRuleButton"].waitToAppear().tap() app.buttons["ruleKind-timeLimit"].waitToAppear().tap() + // The App List row pushes the selection screen onto the editor's stack. app.element("selectedAppsRow").waitToAppear().tap() - // Fresh install: no lists yet, so the picker offers creation. + // Fresh install: no lists yet, so the selection screen offers creation. app.element("emptyAppListsLabel").waitToAppear() app.buttons["newAppListButton"].tap() + // The list editor pops out as its own sheet overlay. let nameField = app.textFields["appListNameField"].waitToAppear() nameField.tap() nameField.typeText("Focus Apps\n") - // Screen 1 lists the (empty) selection; Edit Apps pushes the Screen - // Time picker, whose Save returns here. + // Edit Apps pushes the Screen Time picker; selections apply live, so it + // has no Save of its own — the nav back button returns to the editor. app.element("emptySelectionLabel").waitToAppear() app.buttons["editAppsButton"].tap() - app.element("selectionCountLabel").waitToAppear() - app.buttons["confirmSelectionButton"].tap() + let appsBar = app.navigationBars["Edit Apps"].waitToAppear() + appsBar.buttons["BackButton"].tap() + // The editor's checkmark saves the list and dismisses the overlay. 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() + // Back on the selection screen with the new list present; tapping it + // selects the list and pops back to the rule editor. + app.element("appListRow-Focus Apps").waitToAppear().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 testClosingListEditorWithEditsPromptsToDiscard() throws { + let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard") + app.goToRulesTab() + app.buttons["ruleCard-Sleep"].waitToAppear().tap() + app.buttons["editRuleButton"].waitToAppear().tap() + + // Open the "Distractions" list for editing (a sheet overlay). + app.element("selectedAppsRow").waitToAppear().tap() + app.buttons["editAppListButton-Distractions"].waitToAppear().tap() + + // Make an outstanding edit by renaming the list (submit to drop the + // keyboard, which otherwise interferes with resolving the dialog). + let nameField = app.textFields["appListNameField"].waitToAppear() + nameField.tap() + nameField.typeText(" Edited\n") + + // Closing with unsaved edits raises the standard discard confirmation. + app.buttons["closeAppListButton"].tap() + XCTAssertTrue( + app.buttons["Discard Changes"].waitToAppear().exists, + "Closing with unsaved edits should confirm before discarding" + ) + + // Discarding dismisses the editor; the rename is dropped. + app.buttons["Discard Changes"].tap() + + // Back on the selection screen with the original list name intact. + app.element("appListRow-Distractions").waitToAppear() + XCTAssertFalse( + app.textFields["appListNameField"].exists, + "Discarding should close the editor overlay" + ) + } + + func testClosingUneditedListEditorDismissesWithoutPrompt() throws { + let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard") + app.goToSettingsTab() + app.element("manageAppListsButton").waitToAppear().tap() + + // Open the list and close it again without touching anything. + app.element("appListRow-Distractions").waitToAppear().tap() + app.textFields["appListNameField"].waitToAppear() + app.buttons["closeAppListButton"].tap() + + // No outstanding edits, so it closes straight away — no discard prompt. + XCTAssertFalse( + app.buttons["Discard Changes"].waitForExistence(timeout: 1.5), + "Closing an unedited list must not prompt to discard" + ) + app.element("appListRow-Distractions").waitToAppear() + } + func testDetailShowsAppListName() throws { let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard") app.goToRulesTab() diff --git a/OpenAppLockUITests/SettingsUITests.swift b/OpenAppLockUITests/SettingsUITests.swift index 39bcc96..ef2166d 100644 --- a/OpenAppLockUITests/SettingsUITests.swift +++ b/OpenAppLockUITests/SettingsUITests.swift @@ -49,21 +49,19 @@ final class SettingsUITests: XCTestCase { app.element("emptyAppListsLabel").waitToAppear() app.buttons["newAppListButton"].tap() + // The list editor opens as a sheet overlay. 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() + // The editor's checkmark saves and dismisses the overlay. app.buttons["saveAppListButton"].waitToAppear().tap() // Saving returns to the management list with the new list present. app.element("appListRow-Distractions").waitToAppear() - // Management rows open for editing on tap (no separate Edit button). + // Management rows open the editor overlay on tap (no separate Edit button). app.element("appListRow-Distractions").tap() XCTAssertTrue( app.textFields["appListNameField"].waitToAppear().exists,