commit 3018f8245a5ba574d55df4e918abc7aca98cdad7
parent 09301221000ff103601c578b10b7c1c3088e9d64
Author: Michael Camilleri <[email protected]>
Date: Thu, 26 Mar 2026 06:42:50 +0900
Use overflow menu
Although it was a design goal for the iOS version to use gestures as
much as possible (rather than affordances like buttons), I think this
can be taken too far. A compromise had already been reached where a
settings button was present to allow the user to customise the list.
This commit replaces that with an overflow menu that allows a user to
rename the list, delete all items and access settings.
Co-Authored-By: Claude 4.6 Opus <[email protected]>
Diffstat:
4 files changed, 95 insertions(+), 61 deletions(-)
diff --git a/ListlessiOS/Extensions/ItemListView+NavigationHeader.swift b/ListlessiOS/Extensions/ItemListView+NavigationHeader.swift
@@ -2,13 +2,48 @@ import SwiftUI
extension ItemListView {
@ViewBuilder
- private var settingsButton: some View {
+ private var overflowMenu: some View {
+ if #available(iOS 26.0, *) {
+ Menu {
+ overflowMenuItems
+ } label: {
+ Image(systemName: "ellipsis")
+ .font(.title2)
+ .foregroundStyle(.secondary)
+ .frame(width: 44, height: 44)
+ }
+ .buttonStyle(.glass)
+ } else {
+ Menu {
+ overflowMenuItems
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ .font(.title2)
+ .foregroundStyle(.secondary)
+ .frame(width: 44, height: 44)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+
+ @ViewBuilder
+ private var overflowMenuItems: some View {
+ Button {
+ showRenameAlert()
+ } label: {
+ Label("Rename List", systemImage: "pencil")
+ }
+ Button(role: .destructive) {
+ iState.isShowingDeleteAllAlert = true
+ } label: {
+ Label("Delete All", systemImage: "trash")
+ }
+ .disabled(items.isEmpty)
+ Divider()
Button {
showSettings()
} label: {
- Image(systemName: "gearshape")
- .font(.title2)
- .foregroundStyle(.secondary)
+ Label("Settings", systemImage: "gearshape")
}
}
@@ -17,9 +52,6 @@ extension ItemListView {
Text(headingText)
.font(.largeTitle)
.fontWeight(.bold)
- .onTapGesture(count: 4) {
- showSyncDiagnostics()
- }
Spacer()
if syncMonitor.hasDiagnosticsIssue {
Button {
@@ -29,13 +61,8 @@ extension ItemListView {
.font(.title2)
.foregroundStyle(.red)
}
- .buttonStyle(.plain)
- }
- if #available(iOS 26.0, *) {
- settingsButton.buttonStyle(.glass)
- } else {
- settingsButton.buttonStyle(.plain)
}
+ overflowMenu
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
diff --git a/ListlessiOS/Extensions/ItemListView+Undo.swift b/ListlessiOS/Extensions/ItemListView+Undo.swift
@@ -21,6 +21,23 @@ extension ItemListView {
return .handled
}
+ func deleteAllItemsWithUndo() {
+ let ids = items.map(\.id)
+ guard !ids.isEmpty else { return }
+ let count = ids.count
+ managedObjectContext.undoManager?.beginUndoGrouping()
+ do {
+ try store.deleteMultiple(itemIDs: ids)
+ } catch {
+ presentStoreError(error)
+ managedObjectContext.undoManager?.endUndoGrouping()
+ return
+ }
+ managedObjectContext.undoManager?.endUndoGrouping()
+ let noun = count == 1 ? "item" : "items"
+ showUndoToast(message: "\(count) \(noun) deleted")
+ }
+
func clearCompletedItemsWithUndo() {
let ids = completedItems.map(\.id)
guard !ids.isEmpty else { return }
diff --git a/ListlessiOS/Views/ItemListView.swift b/ListlessiOS/Views/ItemListView.swift
@@ -16,6 +16,9 @@ struct ItemListView: View, ItemListViewProtocol {
var clearingItemIDs: Set<UUID> = []
var undoToast: UndoToastData? = nil
var isSwiping: Bool = false
+ var isShowingRenameAlert = false
+ var isShowingDeleteAllAlert = false
+ var renameText: String = ""
var draftPlacement: DraftItemPlacement?
var draftTitle: String = ""
var fetchWorkaround: Int = 0
@@ -86,37 +89,6 @@ struct ItemListView: View, ItemListViewProtocol {
draftPlacement == .append
}
- var draftTitleBinding: Binding<String> {
- Binding(
- get: { iState.draftTitle },
- set: { iState.draftTitle = $0 }
- )
- }
-
- private var isDraggingStateBinding: Binding<Bool> {
- $isDragging
- }
-
- private var pullToCreateStateBinding: Binding<PullToCreateState> {
- Binding(
- get: { pState.pullToCreate },
- set: { pState.pullToCreate = $0 }
- )
- }
-
- private var pullUpOffsetStateBinding: Binding<CGFloat> {
- Binding(
- get: { pState.pullUpOffset },
- set: { pState.pullUpOffset = $0 }
- )
- }
-
- private var isShowingSyncDiagnosticsStateBinding: Binding<Bool> {
- Binding(
- get: { iState.isShowingSyncDiagnostics },
- set: { iState.isShowingSyncDiagnostics = $0 }
- )
- }
private var selectedIndex: Int? {
guard let currentID = fState.selectedItemID else { return nil }
@@ -233,6 +205,11 @@ struct ItemListView: View, ItemListViewProtocol {
iState.isShowingSettings = true
}
+ func showRenameAlert() {
+ iState.renameText = headingText
+ iState.isShowingRenameAlert = true
+ }
+
private func dragScaleEffect() -> CGFloat {
let liftPoints: CGFloat = 20
let width = layoutStorage.draggedRowWidth
@@ -273,7 +250,7 @@ struct ItemListView: View, ItemListViewProtocol {
),
isSelected: fState.selectedItemID == draftPrependRowID,
draftID: draftPrependRowID,
- title: draftTitleBinding,
+ title: $iState.draftTitle,
onEditingChanged: { editing, _ in
DispatchQueue.main.async {
if editing {
@@ -299,7 +276,7 @@ struct ItemListView: View, ItemListViewProtocol {
),
isSelected: fState.selectedItemID == draftAppendRowID,
draftID: draftAppendRowID,
- title: draftTitleBinding,
+ title: $iState.draftTitle,
onEditingChanged: { editing, shouldCreateNewItem in
DispatchQueue.main.async {
if editing {
@@ -334,7 +311,7 @@ struct ItemListView: View, ItemListViewProtocol {
index: index + draftOffset,
totalItems: displayActiveItems.count + draftTotal,
isSelected: fState.selectedItemID == itemID,
- isDragging: isDraggingStateBinding,
+ isDragging: $isDragging,
isSwiping: $iState.isSwiping,
isLastActiveItem: index == displayActiveItems.count - 1,
focusedField: $focusedFieldBinding,
@@ -463,7 +440,7 @@ struct ItemListView: View, ItemListViewProtocol {
guard !Task.isCancelled else { return }
dismissUndoToast()
}
- .sheet(isPresented: isShowingSyncDiagnosticsStateBinding) {
+ .sheet(isPresented: $iState.isShowingSyncDiagnostics) {
NavigationStack {
SyncDiagnosticsView(syncMonitor: syncMonitor)
.toolbar {
@@ -473,14 +450,29 @@ struct ItemListView: View, ItemListViewProtocol {
}
}
}
- .sheet(
- isPresented: Binding(
- get: { iState.isShowingSettings },
- set: { iState.isShowingSettings = $0 }
- )
- ) {
+ .sheet(isPresented: $iState.isShowingSettings) {
SettingsView(syncMonitor: syncMonitor)
}
+ .alert("Rename List", isPresented: $iState.isShowingRenameAlert) {
+ TextField("List name", text: $iState.renameText)
+ Button("Cancel", role: .cancel) {}
+ Button("Rename") {
+ let trimmed = iState.renameText
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty {
+ headingText = trimmed
+ }
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ .alert("Delete All", isPresented: $iState.isShowingDeleteAllAlert) {
+ Button("Cancel", role: .cancel) {}
+ Button("Delete All", role: .destructive) {
+ deleteAllItemsWithUndo()
+ }
+ } message: {
+ Text("Are you sure you want to delete all items? You can undo this action.")
+ }
}
private var itemScrollView: some View {
@@ -561,8 +553,8 @@ struct ItemListView: View, ItemListViewProtocol {
pullToClearIndicatorRow
}
.pullGestures(
- pullToCreate: pullToCreateStateBinding,
- pullUpOffset: pullUpOffsetStateBinding,
+ pullToCreate: $pState.pullToCreate,
+ pullUpOffset: $pState.pullUpOffset,
isDraftOpen: draftPlacement != nil,
hasCompletedItems: !completedItems.isEmpty,
pullCreateThreshold: pullCreateThreshold,
diff --git a/ListlessiOS/Views/SettingsView.swift b/ListlessiOS/Views/SettingsView.swift
@@ -3,7 +3,6 @@ import SwiftUI
struct SettingsView: View {
@ObservedObject var syncMonitor: CloudKitSyncMonitor
@Environment(\.dismiss) private var dismiss
- @AppStorage("headingText") private var headingText = "Items"
@AppStorage("appearanceMode") private var appearanceMode = 0
@AppStorage("colorTheme") private var colorThemeRaw = 0
@AppStorage("hapticsEnabled") private var hapticsEnabled = true
@@ -14,10 +13,6 @@ struct SettingsView: View {
var body: some View {
NavigationStack {
List {
- Section("List Title") {
- TextField("List Title", text: $headingText)
- }
-
Section("Theme") {
ForEach(ColorTheme.displayOrder) { theme in
Button {
@@ -66,6 +61,9 @@ struct SettingsView: View {
if debugMode {
Section("Debugging") {
Toggle("FPS Overlay", isOn: $showFPSOverlay)
+ NavigationLink("iCloud Diagnostics") {
+ SyncDiagnosticsView(syncMonitor: syncMonitor)
+ }
}
}