diff --git a/OpenAppLock/Views/AppLists/AppListDetailView.swift b/OpenAppLock/Views/AppLists/AppListDetailView.swift new file mode 100644 index 0000000..928383e --- /dev/null +++ b/OpenAppLock/Views/AppLists/AppListDetailView.swift @@ -0,0 +1,48 @@ +// +// AppListDetailView.swift +// OpenAppLock +// + +import FamilyControls +import SwiftUI + +/// Read-only view of an app list's contents. Shown while a Hard Mode rule is +/// actively blocking: the list itself stays locked (no name field, no "Edit +/// Apps", no Save), but the user can still see which apps the list includes — +/// viewing is never a back door out of the block. +struct AppListDetailView: View { + let list: AppList + + private var selection: FamilyActivitySelection { + AppSelectionCodec.decode(list.selectionData) + } + + var body: some View { + List { + Section { + if AppSelectionCodec.count(of: selection) == 0 { + Text("This list has no apps.") + .foregroundStyle(.secondary) + .accessibilityIdentifier("appListDetailEmptyLabel") + } else { + AppSelectionRows(selection: selection) + } + } header: { + HStack { + Text("Apps").textCase(nil) + Spacer() + Text(list.appCountLabel).textCase(nil) + } + } footer: { + Label( + "Hard Mode is on — this list is read-only until the block ends.", + systemImage: "lock.fill" + ) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("appListReadOnlyNotice") + } + } + .navigationTitle(list.name) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/OpenAppLock/Views/AppLists/AppListEditorView.swift b/OpenAppLock/Views/AppLists/AppListEditorView.swift index d33e70b..54dc32f 100644 --- a/OpenAppLock/Views/AppLists/AppListEditorView.swift +++ b/OpenAppLock/Views/AppLists/AppListEditorView.swift @@ -45,7 +45,7 @@ struct AppListEditorView: View { .foregroundStyle(.secondary) .accessibilityIdentifier("emptySelectionLabel") } else { - selectionRows + AppSelectionRows(selection: selection) } Button { pickingApps = true @@ -79,21 +79,6 @@ struct AppListEditorView: View { } } - /// Rows for everything the selection contains. FamilyControls' Label - /// initializers resolve the opaque tokens to icon + name. - @ViewBuilder - private var selectionRows: some View { - ForEach(Array(selection.applicationTokens), id: \.self) { token in - Label(token) - } - ForEach(Array(selection.categoryTokens), id: \.self) { token in - Label(token) - } - ForEach(Array(selection.webDomainTokens), id: \.self) { token in - Label(token) - } - } - private var countLabel: String { let count = AppSelectionCodec.count(of: selection) return count == 1 ? "1 App" : "\(count) Apps" diff --git a/OpenAppLock/Views/AppLists/AppListLibraryView.swift b/OpenAppLock/Views/AppLists/AppListLibraryView.swift index 3f9443c..7b6867b 100644 --- a/OpenAppLock/Views/AppLists/AppListLibraryView.swift +++ b/OpenAppLock/Views/AppLists/AppListLibraryView.swift @@ -6,17 +6,21 @@ import SwiftData import SwiftUI -/// The reusable app-list library: the saved lists, an Edit affordance, the New -/// List flow, swipe-to-delete, and the Hard Mode lock. Used in two modes: +/// 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: /// /// - **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). -/// Creating a list selects it without dismissing. -/// - **Management** (`selection` nil): no checkmark; tapping a row (when unlocked) -/// opens it for editing. Used by Settings ▸ Manage App Lists. +/// 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. +/// - **Management** (`selection` nil): no checkmark; tapping the row opens it — +/// the full editor when unlocked, the read-only `AppListDetailView` while +/// locked. Used by Settings ▸ Manage App Lists. /// -/// In both modes editing and deletion are disabled while any Hard Mode rule is -/// actively blocking — changing a list would be a back door out of the block. +/// Editing and deletion are disabled in both modes while any Hard Mode rule is +/// actively blocking — changing a list would be a back door out of the block — +/// but viewing a list's apps stays allowed, since reading can't weaken a block. struct AppListLibraryView: View { /// Picker mode when non-nil; management mode when nil. var selection: Binding? @@ -29,6 +33,7 @@ struct AppListLibraryView: View { @Query private var rules: [BlockingRule] @State private var editingList: AppList? + @State private var viewingList: AppList? @State private var creatingList = false @State private var deletionBlocked = false @@ -95,6 +100,9 @@ struct AppListLibraryView: View { editingList = nil } } + .navigationDestination(item: $viewingList) { list in + AppListDetailView(list: list) + } .alert("This list is in use", isPresented: $deletionBlocked) { Button("OK", role: .cancel) {} } message: { @@ -123,7 +131,15 @@ struct AppListLibraryView: View { } .accessibilityIdentifier("appListRow-\(list.name)") Spacer() - if !listsLocked { + // Locked lists stay read-only (no "Edit"), but can still be + // opened to view their apps; unlocked lists open the editor. + if listsLocked { + Button("View") { + viewingList = list + } + .font(.subheadline) + .accessibilityIdentifier("viewAppListButton-\(list.name)") + } else { Button("Edit") { editingList = list } @@ -134,19 +150,18 @@ struct AppListLibraryView: View { .buttonStyle(.borderless) .swipeActions { deleteAction(list) } } else { - // Management mode: the whole row taps to edit (a full-width target), - // with a disclosure chevron instead of a redundant Edit button. + // Management mode: the whole row taps in (a full-width target) with + // a disclosure chevron. Unlocked, it opens the editor; locked, it + // opens the read-only detail so the apps stay viewable. Button { - if !listsLocked { editingList = list } + if listsLocked { viewingList = list } else { editingList = list } } label: { HStack { rowText(list) Spacer() - if !listsLocked { - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(Color(.tertiaryLabel)) - } + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color(.tertiaryLabel)) } .contentShape(Rectangle()) } diff --git a/OpenAppLock/Views/AppLists/AppSelectionRows.swift b/OpenAppLock/Views/AppLists/AppSelectionRows.swift new file mode 100644 index 0000000..958ae02 --- /dev/null +++ b/OpenAppLock/Views/AppLists/AppSelectionRows.swift @@ -0,0 +1,27 @@ +// +// AppSelectionRows.swift +// OpenAppLock +// + +import FamilyControls +import SwiftUI + +/// Read-only rows for everything a `FamilyActivitySelection` contains. +/// FamilyControls' `Label` initializers resolve the opaque tokens to icon + +/// name. Shared by the app-list editor and the read-only detail so both render +/// a list's contents identically. +struct AppSelectionRows: View { + let selection: FamilyActivitySelection + + var body: some View { + ForEach(Array(selection.applicationTokens), id: \.self) { token in + Label(token) + } + ForEach(Array(selection.categoryTokens), id: \.self) { token in + Label(token) + } + ForEach(Array(selection.webDomainTokens), id: \.self) { token in + Label(token) + } + } +} diff --git a/OpenAppLockUITests/AppListUITests.swift b/OpenAppLockUITests/AppListUITests.swift index ef56ced..b18f34d 100644 --- a/OpenAppLockUITests/AppListUITests.swift +++ b/OpenAppLockUITests/AppListUITests.swift @@ -107,6 +107,39 @@ final class AppListUITests: XCTestCase { app.buttons["editAppListButton-Distractions"].exists, "App lists must be read-only while a Hard Mode rule is blocking" ) + // Editing is locked, but the list can still be opened to view its apps. + XCTAssertTrue( + app.buttons["viewAppListButton-Distractions"].exists, + "Locked lists must still offer a read-only View affordance" + ) + } + + func testHardModeAllowsViewingAppListAppsReadOnly() throws { + let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active") + app.goToSettingsTab() + app.buttons["manageAppListsButton"].waitToAppear().tap() + + // The library is locked while "Locked In" hard-blocks ... + app.element("appListsLockedNotice").waitToAppear() + // ... yet tapping a list opens it for read-only viewing. + app.element("appListRow-Distractions").waitToAppear().tap() + + assertReadOnlyDetail(app) + } + + func testHardModeViewFromPickerOpensReadOnlyDetail() throws { + let app = XCUIApplication.launchOpenAppLock(seedScenario: "hard-mode-active") + app.goToRulesTab() + + // Soft "Sleep" is still editable, so its app-list picker is reachable + // while "Locked In" hard-blocks — but every list inside it is locked. + app.buttons["ruleCard-Sleep"].waitToAppear().tap() + app.buttons["editRuleButton"].waitToAppear().tap() + app.element("selectedAppsRow").waitToAppear().tap() + + // The picker offers "View" (not "Edit"); it opens the read-only detail. + app.buttons["viewAppListButton-Distractions"].waitToAppear().tap() + assertReadOnlyDetail(app) } func testAppListsEditableWithoutHardSession() throws { @@ -117,7 +150,44 @@ final class AppListUITests: XCTestCase { app.buttons["editRuleButton"].waitToAppear().tap() app.element("selectedAppsRow").waitToAppear().tap() - app.buttons["editAppListButton-Distractions"].waitToAppear() XCTAssertFalse(app.element("appListsLockedNotice").exists) + // With no hard block, "Edit" (not "View") opens the full editor. + XCTAssertFalse(app.buttons["viewAppListButton-Distractions"].exists) + app.buttons["editAppListButton-Distractions"].waitToAppear().tap() + app.element("appListNameField").waitToAppear() + app.buttons["editAppsButton"].waitToAppear() + } + + func testManageAppListsOpensEditorWhenUnlocked() throws { + let app = XCUIApplication.launchOpenAppLock(seedScenario: "standard") + app.goToSettingsTab() + app.buttons["manageAppListsButton"].waitToAppear().tap() + + // No hard block: a management row taps straight into the full editor, + // not the read-only detail. + XCTAssertFalse(app.element("appListsLockedNotice").exists) + app.element("appListRow-Distractions").waitToAppear().tap() + + app.element("appListNameField").waitToAppear() + app.buttons["editAppsButton"].waitToAppear() + XCTAssertFalse( + app.element("appListReadOnlyNotice").exists, + "An unlocked list opens the editor, not the read-only detail" + ) + } + + /// 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. + private func assertReadOnlyDetail(_ app: XCUIApplication) { + app.element("appListReadOnlyNotice").waitToAppear() + XCTAssertFalse( + app.buttons["editAppsButton"].exists, + "The app selection must stay locked in Hard Mode" + ) + XCTAssertFalse( + app.buttons["saveAppListButton"].exists, + "Saving list changes must stay locked in Hard Mode" + ) } }