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
34 changes: 34 additions & 0 deletions OpenAppLock/Logic/AppListEditState.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 5 additions & 0 deletions OpenAppLock/Models/AppList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
199 changes: 103 additions & 96 deletions OpenAppLock/Views/AppLists/AppListEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
}

Expand All @@ -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
Expand All @@ -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<FamilyActivitySelection>) {
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()
}
}
29 changes: 16 additions & 13 deletions OpenAppLock/Views/AppLists/AppListLibraryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,7 +27,8 @@ import SwiftUI
struct AppListLibraryView: View {
/// Picker mode when non-nil; management mode when nil.
var selection: Binding<AppList?>?
/// 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
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 0 additions & 38 deletions OpenAppLock/Views/AppLists/AppListPickerSheet.swift

This file was deleted.

Loading
Loading