Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions OpenAppLock/Views/AppLists/AppListDetailView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
17 changes: 1 addition & 16 deletions OpenAppLock/Views/AppLists/AppListEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ struct AppListEditorView: View {
.foregroundStyle(.secondary)
.accessibilityIdentifier("emptySelectionLabel")
} else {
selectionRows
AppSelectionRows(selection: selection)
}
Button {
pickingApps = true
Expand Down Expand Up @@ -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"
Expand Down
47 changes: 31 additions & 16 deletions OpenAppLock/Views/AppLists/AppListLibraryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppList?>?
Expand All @@ -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

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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
}
Expand All @@ -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())
}
Expand Down
27 changes: 27 additions & 0 deletions OpenAppLock/Views/AppLists/AppSelectionRows.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
72 changes: 71 additions & 1 deletion OpenAppLockUITests/AppListUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"
)
}
}
Loading