← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


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)
+                        }
                     }
                 }