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 54dc32f..c84499a 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 { - AppSelectionRows(selection: selection) + Section { + if AppSelectionCodec.count(of: selection) == 0 { + Text("No apps yet. Edit Apps to choose what this list includes.") + .foregroundStyle(.secondary) + .accessibilityIdentifier("emptySelectionLabel") + } else { + AppSelectionRows(selection: selection) + } + 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.") } } @@ -84,6 +119,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 @@ -100,65 +153,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 7b6867b..9ab2d12 100644 --- a/OpenAppLock/Views/AppLists/AppListLibraryView.swift +++ b/OpenAppLock/Views/AppLists/AppListLibraryView.swift @@ -7,15 +7,18 @@ import SwiftData import SwiftUI /// The reusable app-list library: the saved lists, per-row Edit/View -/// affordances, the New List flow, swipe-to-delete, and the Hard Mode lock. -/// Used in two modes: +/// affordances, the New List flow, swipe-to-delete, and the Hard Mode lock. It +/// is always **pushed** onto a navigation stack — by the rule editor's App List +/// row (picker mode) or by Settings ▸ Manage App Lists (management mode). 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). -/// A trailing button opens the list — "Edit" when unlocked, "View" (read-only) -/// while a Hard Mode rule blocks. Creating a list selects it without dismissing. +/// selects the list and calls `onPick`, which pops back to the rule editor. A +/// trailing button opens the list — "Edit" (the full editor as a **sheet +/// overlay**) when unlocked, "View" (the read-only `AppListDetailView`) while +/// a Hard Mode rule blocks. Creating a list selects it without popping. /// - **Management** (`selection` nil): no checkmark; tapping the row opens it — -/// the full editor when unlocked, the read-only `AppListDetailView` while +/// the editor sheet when unlocked, the read-only `AppListDetailView` while /// locked. Used by Settings ▸ Manage App Lists. /// /// Editing and deletion are disabled in both modes while any Hard Mode rule is @@ -24,7 +27,8 @@ import SwiftUI struct AppListLibraryView: View { /// Picker mode when non-nil; management mode when nil. var selection: Binding? - /// Called after a row is tapped in picker mode (e.g. to dismiss the sheet). + /// Called after a row is tapped in picker mode — the rule editor uses this + /// to pop the pushed selection screen back to itself. var onPick: (() -> Void)? @Environment(\.modelContext) private var modelContext @@ -89,16 +93,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 } } .navigationDestination(item: $viewingList) { list in AppListDetailView(list: list) 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 43cacee..aceee2e 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 b18f34d..1e5ca1a 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,