Skip to content

Commit 00214e8

Browse files
vargaatclaude
andcommitted
Fix swipe action bug and refactor to item-level behavior
Fixes a bug where swipe stays open after undoing a pending removal. Refactors swipe behavior from list-level to item-level architecture. - Add isEnabled parameter to SwipeAction modifier - Move swipe behavior to TodoItemViewModel with computed properties - TodoInteractor sets filter settings on items at creation - Update tests to reflect new item-level architecture 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent ae711ee commit 00214e8

File tree

10 files changed

+248
-160
lines changed

10 files changed

+248
-160
lines changed

Core/Core/Common/CommonUI/InstUI/Utils/SwipeAction.swift

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@
1818

1919
import SwiftUI
2020

21-
public enum SwipeCompletionBehavior {
22-
/// Swipe action remains fully revealed after swipe completion. Gesture is disabled after action is triggered.
23-
case stayOpen
24-
/// Swipe action immediately resets to closed position after swipe completion. Gesture remains enabled for repeated use.
25-
case reset
21+
extension InstUI {
22+
23+
public enum SwipeCompletionBehavior: Equatable {
24+
/// Swipe action remains fully revealed after swipe completion. Gesture is disabled after action is triggered.
25+
case stayOpen
26+
/// Swipe action immediately resets to closed position after swipe completion. Gesture remains enabled for repeated use.
27+
case reset
28+
}
2629
}
2730

2831
extension View {
@@ -36,19 +39,25 @@ extension View {
3639
/// - backgroundColor: The background color revealed behind the content during the swipe.
3740
/// - completionBehavior: Determines what happens after the swipe action completes. Defaults to `.stayOpen`.
3841
/// - isSwiping: Binding that tracks whether a swipe gesture is currently active. Use this to disable scrolling or other gestures while swiping.
39-
/// - onSwipe: Closure called when the swipe action is completed.
42+
/// - isEnabled: Whether the swipe gesture is enabled. Defaults to `true`.
43+
/// - onSwipeCommitted: Optional closure called immediately when the swipe action is committed (user releases after reaching threshold) but before animations finish.
44+
/// - onSwipe: Closure called when the swipe action is completed animations included. For `.reset` behavior, this is called after the close animation finishes. For `.stayOpen` behavior, this is called while the open animation is running.
4045
/// - label: The view displayed in the revealed area during the swipe.
4146
public func swipeAction<Label: View>(
4247
backgroundColor: Color,
43-
completionBehavior: SwipeCompletionBehavior = .stayOpen,
48+
completionBehavior: InstUI.SwipeCompletionBehavior = .stayOpen,
4449
isSwiping: Binding<Bool> = .constant(false),
50+
isEnabled: Bool = true,
51+
onSwipeCommitted: (() -> Void)? = nil,
4552
onSwipe: @escaping () -> Void,
4653
@ViewBuilder label: @escaping () -> Label
4754
) -> some View {
4855
modifier(SwipeActionModifier(
4956
backgroundColor: backgroundColor,
5057
completionBehavior: completionBehavior,
5158
isSwiping: isSwiping,
59+
isEnabled: isEnabled,
60+
onSwipeCommitted: onSwipeCommitted,
5261
onSwipe: onSwipe,
5362
label: label
5463
))
@@ -57,21 +66,27 @@ extension View {
5766

5867
private struct SwipeActionModifier<Label: View>: ViewModifier {
5968
let backgroundColor: Color
60-
let completionBehavior: SwipeCompletionBehavior
69+
let completionBehavior: InstUI.SwipeCompletionBehavior
6170
@Binding var isSwiping: Bool
71+
let isEnabled: Bool
72+
let onSwipeCommitted: (() -> Void)?
6273
let onSwipe: () -> Void
6374
let label: () -> Label
6475

6576
init(
6677
backgroundColor: Color,
67-
completionBehavior: SwipeCompletionBehavior,
78+
completionBehavior: InstUI.SwipeCompletionBehavior,
6879
isSwiping: Binding<Bool>,
80+
isEnabled: Bool,
81+
onSwipeCommitted: (() -> Void)?,
6982
onSwipe: @escaping () -> Void,
7083
label: @escaping () -> Label
7184
) {
7285
self.backgroundColor = backgroundColor
7386
self.completionBehavior = completionBehavior
7487
self._isSwiping = isSwiping
88+
self.isEnabled = isEnabled
89+
self.onSwipeCommitted = onSwipeCommitted
7590
self.onSwipe = onSwipe
7691
self.label = label
7792
}
@@ -113,7 +128,7 @@ private struct SwipeActionModifier<Label: View>: ViewModifier {
113128
DragGesture()
114129
.onChanged(handleDragChanged)
115130
.onEnded(handleDragEnded),
116-
isEnabled: !isActionInvoked
131+
isEnabled: isEnabled && !isActionInvoked
117132
)
118133
}
119134

@@ -192,6 +207,8 @@ private struct SwipeActionModifier<Label: View>: ViewModifier {
192207
isSwiping = false
193208

194209
if isActionThresholdReached {
210+
onSwipeCommitted?()
211+
195212
switch completionBehavior {
196213
case .stayOpen:
197214
animateToOpenedState()
@@ -200,7 +217,7 @@ private struct SwipeActionModifier<Label: View>: ViewModifier {
200217
case .reset:
201218
animateToClosedState()
202219
Task { @MainActor in
203-
// Wait for close animation to complete before invoking callback
220+
// Wait for close animation to complete before invoking callback.
204221
// This prevents visual state changes during the animation
205222
// Note: await suspends the Task but does NOT block the main thread
206223
try? await Task.sleep(nanoseconds: 430_000_000)

Core/Core/Features/Todos/Model/TodoInteractor.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,18 @@ final class TodoInteractorLive: TodoInteractor {
129129
let filterOptions = sessionDefaults.todoFilterOptions ?? TodoFilterOptions.default
130130
let coursesByCanvasContextIds = Dictionary(uniqueKeysWithValues: courses.map { ($0.canvasContextID, $0) })
131131

132+
let shouldKeepCompletedItemsVisible = filterOptions.visibilityOptions.contains(.showCompleted)
133+
132134
let todos = plannables
133135
.filter { plannable in
134136
let course = coursesByCanvasContextIds[plannable.canvasContextIDRaw ?? ""]
135137
return filterOptions.shouldInclude(plannable: plannable, course: course)
136138
}
137-
.compactMap { plannable in
139+
.compactMap { plannable -> TodoItemViewModel? in
138140
let course = coursesByCanvasContextIds[plannable.canvasContextIDRaw ?? ""]
139-
return TodoItemViewModel(plannable, course: course)
141+
guard let item = TodoItemViewModel(plannable, course: course) else { return nil }
142+
item.shouldKeepCompletedItemsVisible = shouldKeepCompletedItemsVisible
143+
return item
140144
}
141145

142146
let notDoneTodos = todos.filter { $0.markAsDoneState == .notDone }

Core/Core/Features/Todos/View/TodoListItemCell.swift

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,30 @@ struct TodoListItemCell: View {
2424

2525
@ObservedObject var item: TodoItemViewModel
2626
@Binding var isSwiping: Bool
27-
let swipeCompletionBehavior: SwipeCompletionBehavior
27+
let swipeCompletionBehavior: InstUI.SwipeCompletionBehavior
28+
let swipeEnabled: Bool
2829
let onTap: (_ item: TodoItemViewModel, _ viewController: WeakViewController) -> Void
2930
let onMarkAsDone: (_ item: TodoItemViewModel) -> Void
30-
let onSwipeMarkAsDone: (_ item: TodoItemViewModel) -> Void
31+
let onSwipe: (_ item: TodoItemViewModel) -> Void
32+
let onSwipeCommitted: ((_ item: TodoItemViewModel) -> Void)?
3133

3234
init(
3335
item: TodoItemViewModel,
34-
swipeCompletionBehavior: SwipeCompletionBehavior,
36+
swipeCompletionBehavior: InstUI.SwipeCompletionBehavior,
37+
swipeEnabled: Bool = true,
3538
onTap: @escaping (TodoItemViewModel, WeakViewController) -> Void,
3639
onMarkAsDone: @escaping (TodoItemViewModel) -> Void,
37-
onSwipeMarkAsDone: @escaping (TodoItemViewModel) -> Void,
40+
onSwipe: @escaping (TodoItemViewModel) -> Void,
41+
onSwipeCommitted: ((TodoItemViewModel) -> Void)? = nil,
3842
isSwiping: Binding<Bool> = .constant(false)
3943
) {
4044
self.item = item
4145
self.swipeCompletionBehavior = swipeCompletionBehavior
46+
self.swipeEnabled = swipeEnabled
4247
self.onTap = onTap
4348
self.onMarkAsDone = onMarkAsDone
44-
self.onSwipeMarkAsDone = onSwipeMarkAsDone
49+
self.onSwipe = onSwipe
50+
self.onSwipeCommitted = onSwipeCommitted
4551
self._isSwiping = isSwiping
4652
}
4753

@@ -74,7 +80,9 @@ struct TodoListItemCell: View {
7480
backgroundColor: item.swipeBackgroundColor,
7581
completionBehavior: swipeCompletionBehavior,
7682
isSwiping: $isSwiping,
77-
onSwipe: { onSwipeMarkAsDone(item) },
83+
isEnabled: swipeEnabled,
84+
onSwipeCommitted: { onSwipeCommitted?(item) },
85+
onSwipe: { onSwipe(item) },
7886
label: { swipeActionView }
7987
)
8088
}
@@ -123,14 +131,14 @@ struct TodoListItemCell: View {
123131
swipeCompletionBehavior: .reset,
124132
onTap: { _, _ in },
125133
onMarkAsDone: { _ in },
126-
onSwipeMarkAsDone: { _ in }
134+
onSwipe: { _ in }
127135
)
128136
TodoListItemCell(
129137
item: .makeLongText(),
130138
swipeCompletionBehavior: .reset,
131139
onTap: { _, _ in },
132140
onMarkAsDone: { _ in },
133-
onSwipeMarkAsDone: { _ in }
141+
onSwipe: { _ in }
134142
)
135143
}
136144
.background(Color.backgroundLightest)

Core/Core/Features/Todos/View/TodoListScreen.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,12 @@ struct TodoListScreen: View {
5959
ForEach(group.items) { item in
6060
TodoListItemCell(
6161
item: item,
62-
swipeCompletionBehavior: viewModel.swipeCompletionBehavior,
62+
swipeCompletionBehavior: item.swipeCompletionBehavior,
63+
swipeEnabled: item.isSwipeEnabled,
6364
onTap: viewModel.didTapItem,
6465
onMarkAsDone: viewModel.markItemAsDone,
65-
onSwipeMarkAsDone: viewModel.handleSwipeAction,
66+
onSwipe: viewModel.handleSwipeAction,
67+
onSwipeCommitted: viewModel.handleSwipeCommitted,
6668
isSwiping: $isCellSwiping
6769
)
6870
.padding(.leading, leadingPadding)

Core/Core/Features/Todos/ViewModel/TodoItemViewModel.swift

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,33 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO
3232
updateSwipeProperties()
3333
}
3434
}
35+
36+
// MARK: - Swipe Properties
37+
3538
@Published public private(set) var swipeBackgroundColor: Color = .backgroundSuccess
3639
@Published public private(set) var swipeActionText: String = ""
3740
@Published public private(set) var swipeActionIcon: Image = .checkLine
3841

42+
public var shouldKeepCompletedItemsVisible: Bool = false
43+
44+
public var swipeCompletionBehavior: InstUI.SwipeCompletionBehavior {
45+
if markAsDoneState == .done {
46+
return .reset
47+
} else {
48+
return shouldKeepCompletedItemsVisible ? .reset : .stayOpen
49+
}
50+
}
51+
52+
public var isSwipeEnabled: Bool {
53+
markAsDoneState != .loading
54+
}
55+
56+
public var shouldToggleInPlaceAfterSwipe: Bool {
57+
markAsDoneState == .done || shouldKeepCompletedItemsVisible
58+
}
59+
60+
// MARK: - UI Properties
61+
3962
/// This is the view identity that might change. Don't use this for business logic.
4063
public private(set) var id: String = UUID.string
4164
public let type: PlannableType
@@ -121,6 +144,19 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO
121144
id = UUID.string
122145
}
123146

147+
public var markAsDoneAccessibilityLabel: String? {
148+
switch markAsDoneState {
149+
case .notDone:
150+
return String(localized: "Mark as done", bundle: .core)
151+
case .loading:
152+
return nil
153+
case .done:
154+
return String(localized: "Mark as not done", bundle: .core)
155+
}
156+
}
157+
158+
// MARK: - Private Functions
159+
124160
private func updateSwipeProperties() {
125161
switch markAsDoneState {
126162
case .done:
@@ -134,24 +170,13 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO
134170
}
135171
}
136172

137-
public var markAsDoneAccessibilityLabel: String? {
138-
switch markAsDoneState {
139-
case .notDone:
140-
return String(localized: "Mark as done", bundle: .core)
141-
case .loading:
142-
return nil
143-
case .done:
144-
return String(localized: "Mark as not done", bundle: .core)
145-
}
146-
}
147-
148173
/// Helper function to format the date text for a Todo item.
149174
/// - Parameters:
150175
/// - date: The start date of the todo item.
151176
/// - isAllDay: Whether the event is an all-day event.
152177
/// - endAt: The end date if the event has a time range.
153178
/// - Returns: Formatted date string.
154-
static func formatDateText(date: Date, isAllDay: Bool, endAt: Date?) -> String {
179+
internal static func formatDateText(date: Date, isAllDay: Bool, endAt: Date?) -> String {
155180
if isAllDay {
156181
String(localized: "All Day", bundle: .core)
157182
} else if let end = endAt {
@@ -168,7 +193,7 @@ public class TodoItemViewModel: Identifiable, Equatable, Comparable, ObservableO
168193
/// - courseCode: The course code.
169194
/// - fallback: Fallback value if no course data is available.
170195
/// - Returns: The appropriate context name.
171-
public static func contextName(
196+
private static func contextName(
172197
isCourseNameNickname: Bool,
173198
courseName: String?,
174199
courseCode: String?,

Core/Core/Features/Todos/ViewModel/TodoListViewModel.swift

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ class TodoListViewModel: ObservableObject {
2626
@Published private(set) var items: [TodoGroupViewModel] = []
2727
@Published private(set) var state: InstUI.ScreenState = .loading
2828
@Published private(set) var filterIcon: Image = .filterLine
29-
@Published private(set) var swipeCompletionBehavior: SwipeCompletionBehavior = .stayOpen
3029
let screenConfig = InstUI.BaseScreenConfig(
3130
emptyPandaConfig: .init(
3231
scene: VacationPanda(),
@@ -47,9 +46,6 @@ class TodoListViewModel: ObservableObject {
4746
private var markDoneTimers: [String: AnyCancellable] = [:]
4847
/// Tracks item IDs that have been optimistically removed via swipe and are awaiting API response
4948
private var optimisticallyRemovedIds: Set<String> = []
50-
private var shouldKeepCompletedItemsVisible: Bool {
51-
swipeCompletionBehavior == .reset
52-
}
5349

5450
init(
5551
interactor: TodoInteractor,
@@ -67,7 +63,6 @@ class TodoListViewModel: ObservableObject {
6763
.assign(to: \.items, on: self, ownership: .weak)
6864
.store(in: &subscriptions)
6965

70-
updateSwipeCompletionBehavior()
7166
updateFilterIcon()
7267
refresh(completion: { }, ignoreCache: false)
7368
}
@@ -117,7 +112,6 @@ class TodoListViewModel: ObservableObject {
117112

118113
func handleFiltersChanged() {
119114
updateFilterIcon()
120-
updateSwipeCompletionBehavior()
121115

122116
interactor.isCacheExpired()
123117
.sink { [weak self] cacheExpired in
@@ -155,20 +149,19 @@ class TodoListViewModel: ObservableObject {
155149
}
156150
}
157151

158-
func handleSwipeAction(_ item: TodoItemViewModel) {
159-
let isCurrentlyDone = item.markAsDoneState == .done
152+
/// Cancels any pending auto-removal timer immediately when swipe is committed.
153+
/// This is called before the animation delay to prevent the cell getting removed while the swipe animation is being finished for undo action.
154+
func handleSwipeCommitted(_ item: TodoItemViewModel) {
155+
cancelDelayedRemove(for: item)
156+
}
160157

161-
if isCurrentlyDone {
162-
// Undoing - always toggle in place and cancel any pending removal
163-
cancelDelayedRemove(for: item)
158+
/// Performs the mark as done/undone action after the swipe animation completes.
159+
/// Timer cancellation happens earlier in handleSwipeCommitted to avoid race conditions.
160+
func handleSwipeAction(_ item: TodoItemViewModel) {
161+
if item.shouldToggleInPlaceAfterSwipe {
164162
toggleItemStateInPlace(item)
165163
} else {
166-
// Marking as done - respect filter behavior
167-
if shouldKeepCompletedItemsVisible {
168-
toggleItemStateInPlace(item)
169-
} else {
170-
removeItemWithOptimisticUI(item)
171-
}
164+
removeItemWithOptimisticUI(item)
172165
}
173166
}
174167

@@ -363,9 +356,4 @@ class TodoListViewModel: ObservableObject {
363356
let filterOptions = sessionDefaults.todoFilterOptions ?? TodoFilterOptions.default
364357
filterIcon = filterOptions.isDefault ? .filterLine : .filterSolid
365358
}
366-
367-
private func updateSwipeCompletionBehavior() {
368-
let filterOptions = sessionDefaults.todoFilterOptions ?? TodoFilterOptions.default
369-
swipeCompletionBehavior = filterOptions.visibilityOptions.contains(.showCompleted) ? .reset : .stayOpen
370-
}
371359
}

Core/CoreTests/Features/Todos/Model/Filter/TodoFilterOptionsTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class TodoFilterOptionsTests: XCTestCase {
6666
dateRangeEnd: .nextWeek
6767
)
6868

69-
let expected = Date(fromISOString: "2025-01-12T00:00:00Z")!
69+
let expected = referenceDate.startOfWeek()
7070
XCTAssertEqual(options.startDate, expected)
7171

7272
Clock.reset()
@@ -82,7 +82,7 @@ class TodoFilterOptionsTests: XCTestCase {
8282
dateRangeEnd: .nextWeek
8383
)
8484

85-
let expected = Date(fromISOString: "2025-01-25T23:59:59Z")!
85+
let expected = referenceDate.startOfWeek().addWeeks(2).addSeconds(-1)
8686
XCTAssertEqual(options.endDate, expected)
8787

8888
Clock.reset()

0 commit comments

Comments
 (0)