diff --git a/AGENTS.md b/AGENTS.md index c01f552..23c4541 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,12 +214,19 @@ when reminded: inaccurate, update it in the same commit (see Documentation above). Then write the failing test, run it (compile failure counts as red), implement, re-run focused tests, then the full suite. Run tests often and fail fast. -- **Always attempt to validate the UI manually before committing.** Build and - run the app (simulator/device) and visually confirm the change behaves as - intended. This step **may be skipped only when such tooling is unavailable** - (e.g. the Xcode MCP / a simulator is not reachable in the session) — in that - case, say so explicitly and hand the verification back to the user rather - than silently skipping it. +- **Always attempt to validate the UI manually before committing.** Every + change that touches what a screen renders (views, layout, hit targets, + strings) must be visually confirmed via the Xcode MCP before committing — + either an `xcode_RenderPreview` snapshot of the affected view, or better a + `DeviceInteraction*` run on a simulator: build & install the app, drive the + affected flow with synthesized taps, and inspect the captured screenshots + directly (subagents can drive the simulator; see the `device-interaction` + skill). Read the screenshots yourself, don't only trust interaction logs. + Manual verification is what catches "the right element exists but the layout + looks wrong" failures that tests can't. This step **may be skipped only when + such tooling is unavailable** (e.g. the Xcode MCP / a simulator is not + reachable in the session) — in that case, say so explicitly and hand the + verification back to the user rather than silently skipping it. - **Branch and open a PR for every change.** New features and bug fixes do not go directly onto `main`. Create a topic branch (`feat/…`, `fix/…`, `chore/…`), push it, and open a GitHub PR with `gh pr create` for the diff --git a/OpenAppLock/Views/AppLists/AppListLibraryView.swift b/OpenAppLock/Views/AppLists/AppListLibraryView.swift index 2c319f7..583e370 100644 --- a/OpenAppLock/Views/AppLists/AppListLibraryView.swift +++ b/OpenAppLock/Views/AppLists/AppListLibraryView.swift @@ -14,8 +14,11 @@ import SwiftUI /// /// - **Picker** (`selection` non-nil): each row's tap **toggles** its membership /// in the rule's selection, so multiple lists can be combined into one rule. -/// A trailing button opens the list for editing/viewing as before. Creating -/// a list appends it to the selection. +/// The toggle target spans the whole row left of the trailing button — icon, +/// title, description, and the stretch in between — so tapping the title or +/// description selects/deselects too; the row exposes `.isSelected` to +/// VoiceOver. A trailing button opens the list for editing/viewing as before. +/// Creating a list appends it to the selection. /// - **Management** (`selection` nil): no checkmark; tapping the row opens it — /// the editor sheet when unlocked, the read-only `AppListDetailView` while /// locked. Used by Settings ▸ Manage App Lists. @@ -150,7 +153,7 @@ struct AppListLibraryView: View { @ViewBuilder private func listRow(_ list: AppList) -> some View { if isPicking { - // Picker mode: tapping the row toggles the list's membership. + // Whole-row tap target (see the picker-mode doc comment above). HStack { Button { toggle(list) @@ -163,9 +166,11 @@ struct AppListLibraryView: View { .frame(width: 28) rowText(list) } + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .contentShape(Rectangle()) } .accessibilityIdentifier("appListRow-\(list.name)") - Spacer() + .accessibilityAddTraits(isSelected(list) ? .isSelected : []) // Locked lists stay read-only (no "Edit"), but can still be // opened to view their apps; unlocked lists open the editor. if listsLocked { diff --git a/OpenAppLockUITests/AppListUITests.swift b/OpenAppLockUITests/AppListUITests.swift index 0d20aca..4dcbcab 100644 --- a/OpenAppLockUITests/AppListUITests.swift +++ b/OpenAppLockUITests/AppListUITests.swift @@ -305,6 +305,58 @@ final class AppListUITests: XCTestCase { ) } + func testPickerRowTouchTargetExtendsToEditButton() throws { + let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard") + app.goToRulesTab() + 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(untilAppears: app.buttons["editAppListButton-Distractions"]) + let row = app.element("appListRow-Distractions").waitToAppear() + let edit = app.buttons["editAppListButton-Distractions"].waitToAppear() + + XCTAssertGreaterThanOrEqual( + row.frame.height, 44, + "Row tap target should be at least 44pt tall" + ) + XCTAssertGreaterThanOrEqual( + row.frame.maxX, edit.frame.minX - 12, + "Row tap target should extend to the Edit button, got \(row.frame) vs \(edit.frame)" + ) + + // Tapping the title (the leading stretch of the row) selects the list. + row.tapAtNormalizedOffset(0.15, 0.5) + waitForSelection(row, expected: true) + + // Tapping further along the row — well past the text, in the stretch + // before the Edit button that used to be an untappable gap — deselects. + row.tapAtNormalizedOffset(0.8, 0.5) + waitForSelection(row, expected: false) + + // The title still toggles back on after the dead-zone tap. + row.tapAtNormalizedOffset(0.15, 0.5) + waitForSelection(row, expected: true) + } + + /// Polls until the element's `isSelected` accessibility trait matches, + /// rather than reading the property straight after a tap — the SwiftUI + /// re-render that recomputes the trait updates asynchronously on a slow + /// runner. + private func waitForSelection( + _ element: XCUIElement, expected: Bool, + file: StaticString = #filePath, line: UInt = #line + ) { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "isSelected == %@", NSNumber(value: expected)), + object: element) + XCTAssertEqual( + XCTWaiter().wait(for: [expectation], timeout: 5), .completed, + "Expected isSelected to become \(expected)", + file: file, line: line) + } + /// Asserts the read-only `AppListDetailView` is showing: its lock notice is /// present and neither edit affordance (the apps picker, the Save button) /// exists — the "no editing" rule holds while a list is merely viewable.