ItemRowView.swift (9418B)
1 import SwiftUI 2 3 struct 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 }