listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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:
MListlessiOS/Extensions/ItemListView+NavigationHeader.swift | 53++++++++++++++++++++++++++++++++++++++++-------------
MListlessiOS/Extensions/ItemListView+Undo.swift | 17+++++++++++++++++
MListlessiOS/Views/ItemListView.swift | 78+++++++++++++++++++++++++++++++++++-------------------------------------------
MListlessiOS/Views/SettingsView.swift | 8+++-----
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) + } } }