← All repositories

listless

A simple list app for Apple platforms

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


ListlessiOS/Views/ItemListView.swift (25169B)

1import SwiftUI
2import UIKit
3
4struct ItemListView: View, ItemListViewProtocol {
5    class LayoutStorage {
6        var draggedRowWidth: CGFloat = 0
7        var draggedRowFrame: CGRect = .zero
8        var contentBottomY: CGFloat = 0
9        var lastAutoScrollTime: CFTimeInterval = 0
10    }
11
12    struct InteractionStateData {
13        var dragState: DragState = .idle
14        var draftCount: Int = 0
15        var isShowingSyncDiagnostics = false
16        var isShowingSettings = false
17        var clearingItemIDs: Set<UUID> = []
18        var undoToast: UndoToastData? = nil
19        var isSwiping: Bool = false
20        var isShowingRenameAlert = false
21        var isShowingDeleteAllAlert = false
22        var renameText: String = ""
23        var draftPlacement: DraftItemPlacement?
24        var draftTitle: String = ""
25        var fetchWorkaround: Int = 0
26
27        var isShowingOverlay: Bool {
28            isShowingSettings || isShowingSyncDiagnostics || isShowingRenameAlert
29        }
30    }
31
32    struct PullStateData {
33        var pullToCreate = PullToCreateState()
34        var pullUpOffset: CGFloat = 0
35
36        var headerHeight: CGFloat = 60
37    }
38
39    @AppStorage("listName") var listName = "Items"
40    @AppStorage("colorTheme") private var colorThemeRaw = 0
41    @AppStorage("hapticsEnabled") private var hapticsEnabled = true
42    @AppStorage("showFPSOverlay") private var showFPSOverlay = false
43    private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara }
44    @Environment(\.undoManager) var undoManager
45    @Environment(\.managedObjectContext) var managedObjectContext
46
47    let store: ItemStore
48    @ObservedObject var syncMonitor: CloudKitSyncMonitor
49    @FetchRequest(
50        sortDescriptors: [],
51        animation: .default
52    )
53    var items: FetchedResults<ItemEntity>
54    @FocusState private var focusedFieldBinding: FocusField?
55    @State var fState = FocusStateData()
56    @State var iState = InteractionStateData()
57    @State var pState = PullStateData()
58    @State var isDragging = false
59    @State var layoutStorage = LayoutStorage()
60    @State var scrollPosition = ScrollPosition()
61    @State private var showTutorialHint = false
62
63    var focusedField: FocusField? {
64        get { fState.focusedField }
65        nonmutating set {
66            fState.focusedField = newValue
67            focusedFieldBinding = newValue
68        }
69    }
70
71    var dragState: DragState {
72        get { iState.dragState }
73        nonmutating set { iState.dragState = newValue }
74    }
75
76    var draftPlacement: DraftItemPlacement? {
77        get { iState.draftPlacement }
78        nonmutating set {
79            if newValue != nil, iState.draftPlacement == nil {
80                iState.draftCount += 1
81            }
82            iState.draftPlacement = newValue
83        }
84    }
85
86    var draftTitle: String {
87        get { iState.draftTitle }
88        nonmutating set { iState.draftTitle = newValue }
89    }
90
91    private var isPrependDraftVisible: Bool {
92        draftPlacement == .prepend
93    }
94
95    private var isAppendDraftVisible: Bool {
96        draftPlacement == .append
97    }
98
99
100    private var selectedIndex: Int? {
101        guard let currentID = fState.selectedItemID else { return nil }
102        return activeItems.firstIndex(where: { $0.id == currentID })
103    }
104
105    private var canMoveSelectionUp: Bool {
106        guard focusedField == .scrollView else { return false }
107        guard let index = selectedIndex else { return false }
108        return index > 0
109    }
110
111    private var canMoveSelectionDown: Bool {
112        guard focusedField == .scrollView else { return false }
113        guard let index = selectedIndex else { return false }
114        return index < activeItems.count - 1
115    }
116
117    private struct MenuState: Equatable {
118        let selectedItemID: UUID?
119        let isScrollViewFocused: Bool
120        let activeItemCount: Int
121        let completedItemCount: Int
122        let selectedIndex: Int?
123    }
124
125    private var menuCoordinatorTrigger: MenuState {
126        MenuState(
127            selectedItemID: fState.selectedItemID,
128            isScrollViewFocused: focusedField == .scrollView,
129            activeItemCount: activeItems.count,
130            completedItemCount: completedItems.count,
131            selectedIndex: selectedIndex
132        )
133    }
134
135    func updateMenuCoordinator() {
136        let coord = IOSMenuCoordinator.shared
137        coord.newItem = { createNewItem() }
138        coord.deleteItem = { _ = deleteSelectedItemWithUndo() }
139        coord.moveUp = { moveSelectedItemUp() }
140        coord.moveDown = { moveSelectedItemDown() }
141        coord.markCompleted = { markSelectedItemCompleted() }
142        coord.navigatePageUp = { _ = navigatePageUp() }
143        coord.navigatePageDown = { _ = navigatePageDown() }
144        coord.navigateToFirst = { _ = navigateToFirst() }
145        coord.navigateToLast = { _ = navigateToLast() }
146        let inNavMode = focusedField == .scrollView
147        coord.canDelete = fState.selectedItemID != nil && inNavMode
148        coord.canMoveUp = canMoveSelectionUp
149        coord.canMoveDown = canMoveSelectionDown
150        coord.canMarkCompleted = fState.selectedItemID != nil && inNavMode
151        coord.markCompletedTitle = completedItems.contains(where: { $0.id == fState.selectedItemID })
152            ? "Mark as Incomplete" : "Mark as Complete"
153    }
154
155    var vStackSpacing: CGFloat { 0 }
156    var rowGap: CGFloat { 12 }
157    var pullCreateThreshold: CGFloat { 70 }
158    var flickThreshold: CGFloat { 500 }
159    var isCompletelyEmpty: Bool { activeItems.isEmpty && completedItems.isEmpty }
160
161    var onFinishTutorial: (() -> Void)?
162    var isTutorial: Bool { onFinishTutorial != nil }
163
164    init(store: ItemStore, syncMonitor: CloudKitSyncMonitor, onFinishTutorial: (() -> Void)? = nil) {
165        self.store = store
166        self.syncMonitor = syncMonitor
167        self.onFinishTutorial = onFinishTutorial
168    }
169
170    func revealDraftItemUI(at placement: DraftItemPlacement, animated: Bool) {
171        let itemID = draftID(for: placement)
172        if animated {
173            withAnimation { draftPlacement = placement }
174        } else {
175            draftPlacement = placement
176        }
177        var transaction = Transaction()
178        transaction.disablesAnimations = true
179        withTransaction(transaction) {
180            fState.pendingFocus = .item(itemID)
181            focusedField = .item(itemID)
182            fState.selectedItemID = itemID
183        }
184    }
185
186    func clearDraftItemUI(at placement: DraftItemPlacement, hasTitle: Bool) {
187        let clear: () -> Void = {
188            if draftPlacement == placement {
189                draftPlacement = nil
190            }
191            draftTitle = ""
192            if fState.selectedItemID == draftID(for: placement) {
193                fState.selectedItemID = nil
194            }
195
196            guard placement == .prepend else { return }
197
198            var state = pState.pullToCreate
199            state.isInsertionPending = false
200            state.indicatorOffset = 0
201            pState.pullToCreate = state
202        }
203
204        if placement == .prepend, !hasTitle {
205            withAnimation(.spring(response: 0.24, dampingFraction: 1.0)) {
206                clear()
207            }
208        } else if placement == .prepend {
209            var transaction = Transaction(animation: nil)
210            transaction.disablesAnimations = true
211            withTransaction(transaction) {
212                clear()
213            }
214        } else if !hasTitle {
215            withAnimation { clear() }
216        } else {
217            clear()
218        }
219
220        if placement == .prepend || !hasTitle {
221            focusedField = nil
222        }
223    }
224
225    func didStartDrag() {
226        isDragging = true
227        if hapticsEnabled {
228            let generator = UIImpactFeedbackGenerator(style: .light)
229            generator.impactOccurred()
230        }
231    }
232
233    func showSyncDiagnostics() {
234        iState.isShowingSyncDiagnostics = true
235    }
236
237    func showSettings() {
238        iState.isShowingSettings = true
239    }
240
241    func showRenameAlert() {
242        iState.renameText = listName
243        iState.isShowingRenameAlert = true
244    }
245
246    private func dragScaleEffect() -> CGFloat {
247        let liftPoints: CGFloat = 20
248        let width = layoutStorage.draggedRowWidth
249        guard width > 0 else { return 1.05 }
250        return (width + liftPoints) / width
251    }
252
253    /// Combined indicator and phantom entry row sharing the same VStack slot.
254    /// The phantom's UITextView is created while the indicator is visible
255    /// (during the pull), so it's ready when the user releases.
256    @ViewBuilder var pullToCreateIndicatorRow: some View {
257        let pullOffset = pState.pullToCreate.pullOffset
258        let indicatorHeight = PullToCreateIndicator.indicatorHeight
259        let indicatorDisplayOffset = pState.pullToCreate.indicatorDisplayOffset(
260            threshold: pullCreateThreshold
261        )
262        let frameHeight: CGFloat = isPrependDraftVisible
263            ? 0
264            : min(pullOffset, indicatorHeight + rowGap)
265        let opacity: Double = if isPrependDraftVisible || pullOffset <= 0 {
266            0
267        } else if displayActiveItems.isEmpty {
268            min(1, pullOffset / indicatorHeight)
269        } else {
270            1
271        }
272        PullToCreateIndicator(
273            pullOffset: max(0, indicatorDisplayOffset),
274            threshold: pullCreateThreshold
275        )
276        .frame(
277            height: frameHeight,
278            alignment: .top
279        )
280        .offset(
281            y: displayActiveItems.isEmpty
282                ? -indicatorHeight * (1 - min(1, pullOffset / indicatorHeight))
283                : 0
284        )
285        .opacity(opacity)
286    }
287
288    /// The draft row content styled to match a item row. Controlled by the
289    /// ZStack in ``pullToCreateIndicatorRow`` rather than its own visibility.
290    @ViewBuilder private var draftPrependRow: some View {
291        DraftRowView(
292            accentColor: itemColor(
293                forIndex: 0, total: max(1, displayActiveItems.count + 1), theme: colorTheme
294            ),
295            isSelected: fState.selectedItemID == draftPrependRowID,
296            draftID: draftPrependRowID,
297            title: $iState.draftTitle,
298            onEditingChanged: { editing, _ in
299                DispatchQueue.main.async {
300                    if editing {
301                        beginDraftItemEditing(.prepend)
302                    } else {
303                        commitDraftItem()
304                    }
305                }
306            },
307            returnKeyType: .done,
308            accessibilityIdentifier: "draft-row-prepend",
309            focusedField: $focusedFieldBinding
310        )
311    }
312
313    @ViewBuilder private var draftAppendRow: some View {
314        if isAppendDraftVisible {
315            DraftRowView(
316                accentColor: itemColor(
317                    forIndex: displayActiveItems.count,
318                    total: max(1, displayActiveItems.count + 1),
319                    theme: colorTheme
320                ),
321                isSelected: fState.selectedItemID == draftAppendRowID,
322                draftID: draftAppendRowID,
323                title: $iState.draftTitle,
324                onEditingChanged: { editing, shouldCreateNewItem in
325                    DispatchQueue.main.async {
326                        if editing {
327                            beginDraftItemEditing(.append)
328                        } else {
329                            commitDraftItem(
330                                shouldCreateNewItem: shouldCreateNewItem
331                            )
332                        }
333                    }
334                },
335                returnKeyType: draftTitle.trimmingCharacters(
336                    in: .whitespacesAndNewlines
337                ).isEmpty ? .done : .next,
338                accessibilityIdentifier: "draft-row-append",
339                focusedField: $focusedFieldBinding
340            )
341            .padding(.bottom, rowGap)
342            .id(draftAppendRowID)
343        }
344    }
345
346    @ViewBuilder private var itemRows: some View {
347        let _ = iState.fetchWorkaround
348        let draftOffset = isPrependDraftVisible ? 1 : 0
349        let draftTotal = draftPlacement != nil ? 1 : 0
350        ForEach(Array(displayActiveItems.enumerated()), id: \.element.id) { index, item in
351            let itemID = item.id
352            ItemRowView(
353                item: item,
354                itemID: itemID,
355                index: index + draftOffset,
356                totalItems: displayActiveItems.count + draftTotal,
357                isSelected: fState.selectedItemID == itemID,
358                isDragging: $isDragging,
359                isSwiping: $iState.isSwiping,
360                isLastActiveItem: index == displayActiveItems.count - 1,
361                focusedField: $focusedFieldBinding,
362                onToggle: { handleSwipeComplete($0); withAnimation { iState.fetchWorkaround &+= 1 } },
363                onTitleChange: { updateTitle(itemID: $0, title: $1) },
364                onDelete: { deleteItemWithUndo(itemID: $0) },
365                onSelect: { selectItem($0) },
366                onStartEdit: { startEditing($0) },
367                onEndEdit: {
368                    if fState.selectedItemID == $0 {
369                        fState.selectedItemID = nil
370                    }
371                    endEditing($0, shouldCreateNewItem: $1)
372                }
373            )
374            .scaleEffect(draggedItemID == itemID ? dragScaleEffect() : 1.0)
375            .shadow(
376                color: draggedItemID == itemID ? .black.opacity(0.3) : .clear,
377                radius: 12, y: 4
378            )
379            .itemDragGesture(
380                isActive: !item.isCompleted && focusedFieldBinding != .item(itemID),
381                itemID: itemID,
382                onDragStart: { width in
383                    layoutStorage.draggedRowWidth = width
384                    startDrag(itemID: itemID)
385                },
386                onDragChanged: { point in
387                    handleIOSDragChanged(itemID: itemID, point: point)
388                },
389                onDragEnded: { commitIOSDrag() }
390            )
391            .background {
392                if draggedItemID == itemID {
393                    Color.clear
394                        .onGeometryChange(for: CGRect.self) { proxy in
395                            proxy.frame(in: .global)
396                        } action: { frame in
397                            layoutStorage.draggedRowFrame = frame
398                        }
399                }
400            }
401            .padding(.bottom, rowGap)
402            .zIndex(draggedItemID == itemID ? 2 : 1)
403            .id(itemID)
404        }
405
406        draftAppendRow
407            .zIndex(3)
408            .overlay(alignment: .top) {
409                if showTutorialHint {
410                    Text("Submit empty to remove")
411                        .font(.body)
412                        .foregroundStyle(.black)
413                        .padding(.horizontal, 20)
414                        .padding(.vertical, 14)
415                        .background(
416                            RoundedRectangle(cornerRadius: 12)
417                                .fill(Color(red: 1.0, green: 0.84, blue: 0.04))
418                        )
419                        .shadow(color: .black.opacity(0.15), radius: 8, y: 2)
420                        .offset(y: -56)
421                        .transition(.move(edge: .bottom).combined(with: .opacity))
422                }
423            }
424
425        ForEach(completedItems) { item in
426            let itemID = item.id
427            let isBeingCleared = iState.clearingItemIDs.contains(itemID)
428            ItemRowView(
429                item: item,
430                itemID: itemID,
431                isSelected: fState.selectedItemID == itemID,
432                isSwiping: $iState.isSwiping,
433                focusedField: $focusedFieldBinding,
434                onToggle: { handleSwipeComplete($0); withAnimation { iState.fetchWorkaround &+= 1 } },
435                onTitleChange: { updateTitle(itemID: $0, title: $1) },
436                onDelete: { deleteItemWithUndo(itemID: $0) },
437                onSelect: { selectItem($0) }
438            )
439            .opacity(isBeingCleared ? 0 : 1)
440            .offset(y: isBeingCleared ? 40 : 0)
441            .padding(.bottom, rowGap)
442            .id(itemID)
443        }
444    }
445
446    var body: some View {
447        itemScrollView
448            .overlay(alignment: .topLeading) {
449                if showFPSOverlay {
450                    FPSOverlay()
451                        .padding(.top, -16)
452                        .padding(.leading, 8)
453                        .allowsHitTesting(false)
454                }
455            }
456            .simultaneousGesture(
457                SpatialTapGesture(coordinateSpace: .global).onEnded { value in
458                    guard value.location.y > layoutStorage.contentBottomY else { return }
459                    handleBackgroundTap()
460                }
461            )
462            .accessibilityIdentifier("item-list-scrollview")
463            .background {
464                let isEditing = if case .item = focusedFieldBinding { true } else { false }
465                KeyCommandBridge(
466                    isActive: !isEditing && !iState.isShowingOverlay,
467                    onUp: { _ = navigateUp() },
468                    onDown: { _ = navigateDown() },
469                    onSpace: { _ = toggleSelectedItem() },
470                    onReturn: { _ = focusSelectedItem() },
471                    onDelete: { _ = deleteSelectedItemWithUndo() },
472                    onHome: { _ = navigateToFirst() },
473                    onEnd: { _ = navigateToLast() },
474                    onPageUp: { _ = navigatePageUp() },
475                    onPageDown: { _ = navigatePageDown() }
476                )
477            }
478            .onAppear {
479                fState.focusedField = .scrollView
480                updateMenuCoordinator()
481                if ProcessInfo.processInfo.arguments.contains("SCREENSHOT_SHOW_SETTINGS") {
482                    iState.isShowingSettings = true
483                }
484            }
485            .onChange(of: menuCoordinatorTrigger) { _, _ in updateMenuCoordinator() }
486            .onChange(of: undoManager, initial: true) { _, newValue in
487                managedObjectContext.undoManager = newValue
488            }
489            .toolbar {
490                platformToolbar
491            }
492            .safeAreaInset(edge: .bottom) {
493                syncErrorBanner
494            }
495            .overlay(alignment: .bottom) {
496                if let toast = iState.undoToast {
497                    UndoToastView(
498                        data: toast,
499                        onUndo: { performUndo() },
500                        onDismiss: { dismissUndoToast() }
501                    )
502                }
503            }
504            .task(id: iState.undoToast?.id) {
505                guard iState.undoToast != nil else { return }
506                try? await Task.sleep(for: .seconds(7))
507                guard !Task.isCancelled else { return }
508                dismissUndoToast()
509            }
510            .onChange(of: iState.draftPlacement) { _, newValue in
511                if isTutorial, newValue == .append {
512                    withAnimation {
513                        showTutorialHint = true
514                    }
515                }
516            }
517            .task(id: showTutorialHint) {
518                guard showTutorialHint else { return }
519                try? await Task.sleep(for: .seconds(4))
520                guard !Task.isCancelled else { return }
521                withAnimation {
522                    showTutorialHint = false
523                }
524            }
525            .sheet(isPresented: $iState.isShowingSyncDiagnostics) {
526                NavigationStack {
527                    SyncDiagnosticsView(syncMonitor: syncMonitor)
528                        .toolbar {
529                            ToolbarItem(placement: .topBarLeading) {
530                                Button("Close") { iState.isShowingSyncDiagnostics = false }
531                            }
532                        }
533                }
534            }
535            .sheet(isPresented: $iState.isShowingSettings) {
536                SettingsView(syncMonitor: syncMonitor)
537            }
538            .alert("Rename List", isPresented: $iState.isShowingRenameAlert) {
539                TextField("List name", text: $iState.renameText)
540                Button("Cancel", role: .cancel) {}
541                Button("Rename") {
542                    let trimmed = iState.renameText
543                        .trimmingCharacters(in: .whitespacesAndNewlines)
544                    if !trimmed.isEmpty {
545                        listName = trimmed
546                    }
547                }
548                .keyboardShortcut(.defaultAction)
549            }
550            .alert("Delete All", isPresented: $iState.isShowingDeleteAllAlert) {
551                Button("Cancel", role: .cancel) {}
552                Button("Delete All", role: .destructive) {
553                    deleteAllItemsWithUndo()
554                }
555            } message: {
556                Text("Are you sure you want to delete all items? You can undo this action.")
557            }
558    }
559
560    private var itemScrollView: some View {
561        ZStack(alignment: .top) {
562            ScrollView {
563                VStack(alignment: .leading, spacing: vStackSpacing) {
564                    navigationHeader
565                        .padding(.bottom, 12)
566                    pullToCreateIndicatorRow
567                    if isPrependDraftVisible {
568                        draftPrependRow
569                            .padding(.bottom, rowGap)
570                    }
571                    itemRows
572                }
573                .frame(maxWidth: .infinity, alignment: .topLeading)
574                .onGeometryChange(for: CGFloat.self) {
575                    $0.frame(in: .global).maxY
576                } action: {
577                    layoutStorage.contentBottomY = $0
578                }
579                .padding(.trailing, 16)
580                .padding(.vertical, 12)
581                .onChange(of: focusedFieldBinding) { oldValue, newValue in
582                    fState.focusedField = newValue
583                    handleFocusChange(from: oldValue, to: newValue)
584
585                    if newValue == nil,
586                        !iState.isShowingOverlay
587                    {
588                        if let pending = fState.pendingFocus {
589                            focusedFieldBinding = pending
590                            fState.focusedField = pending
591                            fState.pendingFocus = nil
592                        } else {
593                            focusedFieldBinding = .scrollView
594                            fState.focusedField = .scrollView
595                        }
596                    }
597
598                    if case .item(let id) = (newValue ?? fState.focusedField),
599                        draggedItemID == nil,
600                        id != draftPrependRowID
601                    {
602                        withAnimation {
603                            scrollPosition.scrollTo(id: id)
604                        }
605                    }
606                }
607                .onChange(of: fState.selectedItemID) { _, newID in
608                    if let newID, draggedItemID == nil {
609                        guard newID != draftPrependRowID else { return }
610                        withAnimation {
611                            scrollPosition.scrollTo(id: newID)
612                        }
613                    }
614                }
615            }
616            .scrollPosition($scrollPosition)
617            .scrollDisabled(draggedItemID != nil || iState.isSwiping)
618            .scrollBounceBehavior(.always)
619            .contentMargins(.bottom, 20)
620            .background {
621                Color.outerBackground.ignoresSafeArea()
622            }
623            .overlay {
624                if isCompletelyEmpty && draftPlacement == nil {
625                    Text("Pull down to create")
626                        .font(ItemRowMetrics.hintSUI)
627                        .foregroundStyle(.secondary)
628                        .offset(y: pState.pullToCreate.pullOffset)
629                        .allowsHitTesting(false)
630                }
631            }
632            .overlay(alignment: .bottom) {
633                pullToClearIndicatorRow
634            }
635            .pullGestures(
636                pullToCreate: $pState.pullToCreate,
637                pullUpOffset: $pState.pullUpOffset,
638                isEditing: focusedField != nil && focusedField != .scrollView,
639                hasCompletedItems: !completedItems.isEmpty,
640                pullCreateThreshold: pullCreateThreshold,
641                flickThreshold: flickThreshold,
642                pullClearThreshold: pullClearThreshold,
643                onCreateItemAtTop: { revealPhantomRow() },
644                onClearCompleted: {
645                    let ids = Set(completedItems.map(\.id))
646                    withAnimation(.easeIn(duration: 0.35)) {
647                        iState.clearingItemIDs = ids
648                    } completion: {
649                        iState.clearingItemIDs = []
650                        clearCompletedItemsWithUndo()
651                    }
652                }
653            )
654            .sensoryFeedback(.impact(weight: .light), trigger: hapticsEnabled ? iState.draftCount : 0)
655
656        }
657    }
658}
659
660