Skip to content

Commit 4fc460c

Browse files
authored
[#576] TodoListView에 TCA를 적용한다 (#597)
* test: TodoListViewModel 상태 관리 테스트 추가 * feat: TodoListFeature 추가 * refactor: TodoListView Store 연결 * refactor: BindingAction 적용 * refactor: AlertState 적용 * refactor: FullScreenCoverState 적용 * refactor: isPinned 플래그에서 옵셔널 제거 * refactor: 불필요 == 연산자 제거 * refactor: 과도한 refresh 요청 방지를 위한 cancelId 추가
1 parent a00f5c7 commit 4fc460c

12 files changed

Lines changed: 1251 additions & 639 deletions

File tree

Application/DevLogCore/Sources/TodoQuery.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,35 +7,35 @@
77

88
import Foundation
99

10-
public struct TodoQuery: Equatable {
11-
public enum SortTarget: Equatable, Hashable {
10+
public struct TodoQuery: Equatable, Sendable {
11+
public enum SortTarget: Equatable, Hashable, Sendable {
1212
case createdAt
1313
case completedAt
1414
case deletedAt
1515
case updatedAt
1616
case dueDate
1717
}
1818

19-
public enum SortOrder: Equatable, Hashable {
19+
public enum SortOrder: Equatable, Hashable, Sendable {
2020
case latest
2121
case oldest
2222
}
2323

24-
public enum CompletionFilter: Equatable, Hashable {
24+
public enum CompletionFilter: Equatable, Hashable, Sendable {
2525
case all
2626
case incomplete
2727
case completed
2828
}
2929

30-
public enum DueDateFilter: Equatable, Hashable {
30+
public enum DueDateFilter: Equatable, Hashable, Sendable {
3131
case all
3232
case withDueDate
3333
case withoutDueDate
3434
}
3535

3636
public var categoryId: String?
3737
public var keyword: String?
38-
public var isPinned: Bool?
38+
public var isPinned: Bool
3939
public var completionFilter: CompletionFilter
4040
public var dueDateFilter: DueDateFilter
4141
public var sortDateFrom: Date?
@@ -49,7 +49,7 @@ public struct TodoQuery: Equatable {
4949
public init(
5050
categoryId: String? = nil,
5151
keyword: String? = nil,
52-
isPinned: Bool? = nil,
52+
isPinned: Bool = false,
5353
completionFilter: CompletionFilter = .all,
5454
dueDateFilter: DueDateFilter = .all,
5555
sortDateFrom: Date? = nil,

Application/DevLogDomain/Sources/Entity/TodoCursor.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import Foundation
99

10-
public struct TodoCursor {
10+
public struct TodoCursor: Equatable {
1111
public let primarySortDate: Date?
1212
public let secondarySortDate: Date?
1313
public let documentID: String

Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ final class TodoServiceImpl: TodoService {
3535
"sortOrder=\(query.sortOrder == .latest ? "latest" : "oldest")",
3636
query.keyword != nil ? "keywordLength=\(trimmedKeyword.count)" : nil,
3737
query.categoryId != nil ? "category=\(query.categoryId!)" : nil,
38-
query.isPinned != nil ? "pinned=\(query.isPinned!)" : nil,
38+
query.isPinned ? "pinned=true" : nil,
3939
query.completionFilter.isCompletedValue != nil
4040
? "completed=\(query.completionFilter.isCompletedValue!)"
4141
: nil,
@@ -58,8 +58,8 @@ final class TodoServiceImpl: TodoService {
5858
)
5959
}
6060

61-
if let isPinned = query.isPinned {
62-
firestoreQuery = firestoreQuery.whereField("isPinned", isEqualTo: isPinned)
61+
if query.isPinned {
62+
firestoreQuery = firestoreQuery.whereField("isPinned", isEqualTo: true)
6363
}
6464

6565
if let isCompleted = query.completionFilter.isCompletedValue {
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
//
2+
// TodoListFeature+Effects.swift
3+
// DevLogPresentation
4+
//
5+
// Created by opfic on 6/12/26.
6+
//
7+
8+
import ComposableArchitecture
9+
import DevLogCore
10+
import DevLogDomain
11+
import Foundation
12+
13+
extension TodoListFeature {
14+
func searchEffect(
15+
_ keyword: String,
16+
category: TodoCategory
17+
) -> Effect<Action> {
18+
.run { [fetchTodosUseCase] send in
19+
do {
20+
let query = TodoQuery(categoryId: category.storageValue, keyword: keyword)
21+
let page = try await fetchTodosUseCase.execute(query, cursor: nil)
22+
try Task.checkCancellation()
23+
await send(.fetchSearchResults(page.items.compactMap(TodoListItem.init(from:))))
24+
await send(.loading(.end(target: .default, mode: .immediate)))
25+
} catch is CancellationError {
26+
return
27+
} catch {
28+
await send(.setAlert(true))
29+
await send(.loading(.end(target: .default, mode: .immediate)))
30+
}
31+
}
32+
.cancellable(id: CancelID.request, cancelInFlight: true)
33+
}
34+
35+
func toggleCompletedEffect(_ item: TodoListItem) -> Effect<Action> {
36+
.concatenate(
37+
.send(.loading(.begin(target: .default, mode: .delayed))),
38+
.run { [fetchTodoByIdUseCase, upsertTodoUseCase, trackAnalyticsEventUseCase] send in
39+
do {
40+
var todo = try await fetchTodoByIdUseCase.execute(item.id)
41+
let now = Date()
42+
todo.isCompleted.toggle()
43+
todo.completedAt = todo.isCompleted ? now : nil
44+
todo.updatedAt = now
45+
try await upsertTodoUseCase.execute(todo)
46+
if todo.isCompleted {
47+
trackAnalyticsEventUseCase?.execute(.todoComplete)
48+
}
49+
guard let todoListItem = TodoListItem(from: todo) else {
50+
await send(.setAlert(true))
51+
await send(.loading(.end(target: .default, mode: .delayed)))
52+
return
53+
}
54+
await send(.didToggleCompleted(todoListItem))
55+
await send(.loading(.end(target: .default, mode: .delayed)))
56+
} catch {
57+
await send(.setAlert(true))
58+
await send(.loading(.end(target: .default, mode: .delayed)))
59+
}
60+
}
61+
)
62+
}
63+
64+
func togglePinnedEffect(_ item: TodoListItem) -> Effect<Action> {
65+
.concatenate(
66+
.send(.loading(.begin(target: .default, mode: .delayed))),
67+
.run { [fetchTodoByIdUseCase, upsertTodoUseCase] send in
68+
do {
69+
var todo = try await fetchTodoByIdUseCase.execute(item.id)
70+
todo.isPinned.toggle()
71+
todo.updatedAt = Date()
72+
try await upsertTodoUseCase.execute(todo)
73+
guard let todoListItem = TodoListItem(from: todo) else {
74+
await send(.setAlert(true))
75+
await send(.loading(.end(target: .default, mode: .delayed)))
76+
return
77+
}
78+
await send(.didTogglePinned(todoListItem))
79+
await send(.loading(.end(target: .default, mode: .delayed)))
80+
} catch {
81+
await send(.setAlert(true))
82+
await send(.loading(.end(target: .default, mode: .delayed)))
83+
}
84+
}
85+
)
86+
}
87+
88+
func swipeTodoEffect(_ todo: TodoListItem, state: inout State) -> Effect<Action> {
89+
guard state.todos.contains(where: { $0.id == todo.id }) else { return .none }
90+
state.undoTodoId = todo.id
91+
state.deleteToastTodoId = todo.id
92+
Self.setTodoHidden(&state, todoId: todo.id, isHidden: true)
93+
return deleteEffect(todo)
94+
}
95+
96+
func deleteEffect(_ item: TodoListItem) -> Effect<Action> {
97+
.run { [deleteTodoUseCase] send in
98+
do {
99+
try await deleteTodoUseCase.execute(item.id)
100+
} catch {
101+
await send(.setTodoHidden(item.id, false))
102+
await send(.setAlert(true))
103+
}
104+
}
105+
}
106+
107+
func undoDeleteEffect(_ todoId: String) -> Effect<Action> {
108+
.run { [undoDeleteTodoUseCase] send in
109+
do {
110+
try await undoDeleteTodoUseCase.execute(todoId)
111+
} catch {
112+
await send(.setTodoHidden(todoId, true))
113+
await send(.setAlert(true))
114+
}
115+
}
116+
}
117+
118+
static func setAlert(
119+
_ state: inout State,
120+
isPresented: Bool
121+
) {
122+
state.alert = isPresented ? Self.alertState() : nil
123+
}
124+
125+
static func alertState() -> AlertState<Never> {
126+
AlertState {
127+
TextState(String(localized: "common_error_title"))
128+
} actions: {
129+
ButtonState(role: .cancel) {
130+
TextState(String(localized: "common_close"))
131+
}
132+
} message: {
133+
TextState(String(localized: "common_error_message"))
134+
}
135+
}
136+
137+
static func setTodoHidden(
138+
_ state: inout State,
139+
todoId: String,
140+
isHidden: Bool
141+
) {
142+
if let todoIndex = state.todos.firstIndex(where: { $0.id == todoId }) {
143+
state.todos[todoIndex].isHidden = isHidden
144+
}
145+
146+
if let searchResultIndex = state.searchResults.firstIndex(where: { $0.id == todoId }) {
147+
state.searchResults[searchResultIndex].isHidden = isHidden
148+
}
149+
}
150+
}

0 commit comments

Comments
 (0)