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
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
132
133
134
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}