← All repositories

listless

A simple list app for Apple platforms

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


ListlessiOS/Views/ItemRowView.swift (9418B)

1import SwiftUI
2
3struct ItemRowView: View {
4    let item: ItemValue
5    let itemID: UUID
6    let index: Int
7    let totalItems: Int
8    let isSelected: Bool
9    @Binding var isDragging: Bool
10    @Binding var isSwiping: Bool
11    let onToggle: (UUID) -> Void
12    let onTitleChange: (UUID, String) -> Void
13    let onDelete: (UUID) -> Void
14    let onSelect: (UUID) -> Void
15    let isLastActiveItem: Bool
16    let onStartEdit: (UUID) -> Void
17    let onEndEdit: (UUID, _ shouldCreateNewItem: Bool) -> Void
18    @FocusState.Binding var focusedField: FocusField?
19
20    @AppStorage("colorTheme") private var colorThemeRaw = 0
21    private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara }
22    @State private var swipeOffset: CGFloat = 0
23    @State private var swipeDirection: ItemRowSwipeGesture.SwipeDirection = .none
24    @State private var isSwipeTriggered: Bool = false
25    @State private var editingTitle: String = ""
26    @State private var isCurrentlyEditing: Bool = false
27    @State private var tapPoint: CGPoint? = nil
28    @State private var cachedAccentColor: Color = .clear
29
30    init(
31        item: ItemValue,
32        itemID: UUID,
33        index: Int = 0,
34        totalItems: Int = 1,
35        isSelected: Bool,
36        isDragging: Binding<Bool> = .constant(false),
37        isSwiping: Binding<Bool> = .constant(false),
38        isLastActiveItem: Bool = false,
39        focusedField: FocusState<FocusField?>.Binding,
40        onToggle: @escaping (UUID) -> Void,
41        onTitleChange: @escaping (UUID, String) -> Void,
42        onDelete: @escaping (UUID) -> Void,
43        onSelect: @escaping (UUID) -> Void,
44        onStartEdit: @escaping (UUID) -> Void = { _ in },
45        onEndEdit: @escaping (UUID, _ shouldCreateNewItem: Bool) -> Void = { _, _ in }
46    ) {
47        self.item = item
48        self.itemID = itemID
49        self.index = index
50        self.totalItems = totalItems
51        self.isSelected = isSelected
52        _isDragging = isDragging
53        _isSwiping = isSwiping
54        self.isLastActiveItem = isLastActiveItem
55        self.onToggle = onToggle
56        self.onTitleChange = onTitleChange
57        self.onDelete = onDelete
58        self.onSelect = onSelect
59        self.onStartEdit = onStartEdit
60        self.onEndEdit = onEndEdit
61        _focusedField = focusedField
62    }
63
64    var body: some View {
65        HStack(alignment: .center, spacing: ItemRowMetrics.contentSpacing) {
66            Button {
67                onToggle(itemID)
68            } label: {
69                // When a right-swipe is past the threshold, preview the toggled state
70                let previewCompleted = isSwipeTriggered && swipeDirection == .right
71                    ? !item.isCompleted
72                    : item.isCompleted
73                Image(systemName: previewCompleted ? "checkmark.circle.fill" : "circle")
74                    .contentTransition(.identity)
75                    .frame(width: 22, height: 22)
76                    .foregroundStyle(Color.secondary)
77                    .font(.system(size: 17))
78            }
79            .buttonStyle(.borderless)
80            .accessibilityIdentifier("item-checkbox")
81            .accessibilityValue(item.isCompleted ? "checkmark.circle.fill" : "circle")
82
83            if !item.isCompleted && (isSelected || isEditing) {
84                TappableTextField(
85                    text: $editingTitle,
86                    isCompleted: item.isCompleted,
87                    isDragging: isDragging,
88                    onEditingChanged: { editing, shouldCreateNewItem in
89                        DispatchQueue.main.async {
90                            isCurrentlyEditing = editing
91                            if editing { onStartEdit(itemID) }
92                            else {
93                                tapPoint = nil
94                                onEndEdit(itemID, shouldCreateNewItem)
95                            }
96                        }
97                    },
98                    returnKeyType: isLastActiveItem && !editingTitle.isEmpty ? .next : .done,
99                    onContentChange: { newTitle in
100                        guard !item.isCompleted else { return }
101                        onTitleChange(itemID, newTitle)
102                    },
103                    uiAccessibilityIdentifier: "item-text-\(itemID.uuidString)",
104                    initialCursorPoint: tapPoint
105                )
106                .focused($focusedField, equals: .item(itemID))
107                .frame(maxWidth: .infinity, alignment: .leading)
108            } else if !item.isCompleted {
109                itemProxy
110                    .frame(maxWidth: .infinity, alignment: .leading)
111                    .contentShape(Rectangle())
112                    .gesture(SpatialTapGesture().onEnded { value in
113                        tapPoint = value.location
114                        onSelect(itemID)
115                        focusedField = .item(itemID)
116                    })
117            } else {
118                itemProxy
119                    .frame(maxWidth: .infinity, alignment: .leading)
120            }
121        }
122        .padding(.vertical, ItemRowMetrics.contentVerticalPadding)
123        .padding(.trailing, ItemRowMetrics.contentHorizontalPadding)
124        .padding(
125            .leading,
126            item.isCompleted ? ItemRowMetrics.completedLeadingPadding : ItemRowMetrics.activeLeadingPadding
127        )
128        .frame(maxWidth: .infinity, alignment: .leading)
129        .contentShape(Rectangle())
130        .onTapGesture {
131            // .onTapGesture (not .simultaneousGesture) lets the child Button suppress this
132            // gesture for its own hit area, so circle button taps don't also fire here.
133            // If tapping a completed row while another row is being edited, preserve
134            // the current focus/selection.
135            if item.isCompleted,
136               let field = focusedField,
137               case .item(let id) = field,
138               id != itemID
139            {
140                return
141            }
142            if item.isCompleted {
143                withAnimation { onToggle(itemID) }
144            } else {
145                tapPoint = nil
146                onSelect(itemID)
147                focusedField = .item(itemID)
148            }
149        }
150        .background(cardBackground)
151        .overlay(alignment: .leading) {
152            if !item.isCompleted {
153                Rectangle()
154                    .fill(cachedAccentColor)
155                    .frame(width: ItemRowMetrics.accentBarWidth)
156            }
157        }
158        .onAppear {
159            editingTitle = item.title
160            cachedAccentColor = computeAccentColor()
161            if index == 0, !item.isCompleted,
162                ProcessInfo.processInfo.arguments.contains("SCREENSHOT_SWIPE")
163            {
164                swipeOffset = 60
165                swipeDirection = .right
166                isSwipeTriggered = true
167            }
168        }
169        .onChange(of: item.title) { _, newValue in
170            if !isCurrentlyEditing {
171                editingTitle = newValue
172            }
173        }
174        .onChange(of: index) { _, _ in
175            cachedAccentColor = computeAccentColor()
176        }
177        .onChange(of: totalItems) { _, _ in
178            cachedAccentColor = computeAccentColor()
179        }
180        .onChange(of: colorThemeRaw) { _, _ in
181            cachedAccentColor = computeAccentColor()
182        }
183        .itemSwipeGesture(
184            isDragging: $isDragging,
185            isEditing: focusedField == .item(itemID),
186            isSwiping: $isSwiping,
187            swipeOffset: $swipeOffset,
188            swipeDirection: $swipeDirection,
189            isTriggered: $isSwipeTriggered,
190            completeColor: cachedAccentColor,
191            onComplete: { onToggle(itemID) },
192            onDelete: { onDelete(itemID) }
193        )
194        .onChange(of: isDragging) { _, newValue in
195            if newValue {
196                swipeOffset = 0
197                swipeDirection = .none
198                isSwipeTriggered = false
199            }
200        }
201        .clipShape(ItemCardModifier.shape)
202        .overlay(
203            isSelected && !item.isCompleted
204                ? ItemCardModifier.shape
205                    .strokeBorder(cachedAccentColor.opacity(0.40), lineWidth: 2)
206                : nil
207        )
208    }
209
210    private var isEditing: Bool {
211        focusedField == .item(itemID)
212    }
213
214    @ViewBuilder
215    private var itemProxy: some View {
216        if item.isCompleted {
217            Text(editingTitle)
218                .font(ItemRowMetrics.bodySUI)
219                .foregroundStyle(.secondary)
220                .strikethrough(true, color: .secondary)
221                .accessibilityIdentifier("item-text-\(itemID.uuidString)")
222        } else {
223            Text(editingTitle)
224                .font(ItemRowMetrics.bodySUI)
225                .foregroundStyle(.primary)
226                .accessibilityIdentifier("item-text-\(itemID.uuidString)")
227        }
228    }
229
230    @MainActor
231    private func computeAccentColor() -> Color {
232        guard !item.isCompleted else { return .clear }
233        return cachedItemColor(forIndex: index, total: totalItems, theme: colorTheme)
234    }
235
236    @ViewBuilder
237    private var cardBackground: some View {
238        if item.isCompleted {
239            isSelected ? Color.completedSelected : Color.clear
240        } else if isSelected {
241            Color.itemCard.overlay(cachedAccentColor.opacity(0.15))
242        } else {
243            Color.itemCard
244        }
245    }
246}