Skip to content

Commit 9e1c5c3

Browse files
authored
[#577] PushNotificationListView에 TCA를 적용한다 (#605)
* feat: PushNotificationListView store 1차 적용 * refactor: SheetState 적용 * fix: compact / regular ui 변경 시 시트가 기존과 다르게 동작하는 현상 해결 * refactor: 불필요한 상태 처리 제거 * refactor: 각 store -> store로 통일 * refactor: BindingAction 적용 * refactor: Store에서 호출되는 액션 분리 * refactor: 불필요 컴포넌트화 제거 * refactor: 불필요한 컴포넌트 정리 형태 개선 * fix: PushNotificationList 헤더 깜빡임 방지 * ui: 데이터가 없을 때는 스크롤이 안되도록 수정 * refactor: 불필요한 onChange(of:) 로직 제거 * feat: 낙관적 업데이트 이후 실패 시 롤백 로직 구현
1 parent c271104 commit 9e1c5c3

20 files changed

Lines changed: 1613 additions & 409 deletions

Application/DevLogPresentation/Sources/Home/Detail/TodoDetailView.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,13 @@ struct TodoDetailView: View {
3434
.onAppear { store.send(.onAppear) }
3535
.navigationBarTitleDisplayMode(.inline)
3636
.alert($store.scope(state: \.alert, action: \.alert))
37-
.sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { sheetStore in
38-
sheetContent(sheetStore)
37+
.sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { store in
38+
sheetContent(store)
3939
}
4040
.fullScreenCover(
4141
item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover)
42-
) { coverStore in
43-
fullScreenCoverContent(coverStore)
42+
) { store in
43+
fullScreenCoverContent(store)
4444
}
4545
.toolbar { toolbarContent }
4646
}

Application/DevLogPresentation/Sources/Home/Editor/TodoEditorView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ struct TodoEditorView: View {
5353
.navigationTitle(store.navigationTitle)
5454
.navigationBarTitleDisplayMode(.inline)
5555
.toolbarBackground(.background, for: .navigationBar)
56-
.sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { sheetStore in
57-
sheetContent(sheetStore)
56+
.sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { store in
57+
sheetContent(store)
5858
}
5959
.toolbar {
6060
if !isiOSAppOnMac {

Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ extension TodoListFeature {
2020
let query = TodoQuery(categoryId: category.storageValue, keyword: keyword)
2121
let page = try await fetchTodosUseCase.execute(query, cursor: nil)
2222
try Task.checkCancellation()
23-
await send(.fetchSearchResults(page.items.compactMap(TodoListItem.init(from:))))
23+
await send(.store(.fetchSearchResults(page.items.compactMap(TodoListItem.init(from:)))))
2424
await send(.loading(.end(target: .default, mode: .immediate)))
2525
} catch is CancellationError {
2626
return
2727
} catch {
28-
await send(.setAlert(true))
28+
await send(.store(.setAlert(true)))
2929
await send(.loading(.end(target: .default, mode: .immediate)))
3030
}
3131
}
@@ -47,14 +47,14 @@ extension TodoListFeature {
4747
trackAnalyticsEventUseCase?.execute(.todoComplete)
4848
}
4949
guard let todoListItem = TodoListItem(from: todo) else {
50-
await send(.setAlert(true))
50+
await send(.store(.setAlert(true)))
5151
await send(.loading(.end(target: .default, mode: .delayed)))
5252
return
5353
}
54-
await send(.didToggleCompleted(todoListItem))
54+
await send(.store(.didToggleCompleted(todoListItem)))
5555
await send(.loading(.end(target: .default, mode: .delayed)))
5656
} catch {
57-
await send(.setAlert(true))
57+
await send(.store(.setAlert(true)))
5858
await send(.loading(.end(target: .default, mode: .delayed)))
5959
}
6060
}
@@ -71,14 +71,14 @@ extension TodoListFeature {
7171
todo.updatedAt = Date()
7272
try await upsertTodoUseCase.execute(todo)
7373
guard let todoListItem = TodoListItem(from: todo) else {
74-
await send(.setAlert(true))
74+
await send(.store(.setAlert(true)))
7575
await send(.loading(.end(target: .default, mode: .delayed)))
7676
return
7777
}
78-
await send(.didTogglePinned(todoListItem))
78+
await send(.store(.didTogglePinned(todoListItem)))
7979
await send(.loading(.end(target: .default, mode: .delayed)))
8080
} catch {
81-
await send(.setAlert(true))
81+
await send(.store(.setAlert(true)))
8282
await send(.loading(.end(target: .default, mode: .delayed)))
8383
}
8484
}
@@ -88,7 +88,6 @@ extension TodoListFeature {
8888
func swipeTodoEffect(_ todo: TodoListItem, state: inout State) -> Effect<Action> {
8989
guard state.todos.contains(where: { $0.id == todo.id }) else { return .none }
9090
state.undoTodoId = todo.id
91-
state.deleteToastTodoId = todo.id
9291
Self.setTodoHidden(&state, todoId: todo.id, isHidden: true)
9392
return deleteEffect(todo)
9493
}
@@ -98,8 +97,8 @@ extension TodoListFeature {
9897
do {
9998
try await deleteTodoUseCase.execute(item.id)
10099
} catch {
101-
await send(.setTodoHidden(item.id, false))
102-
await send(.setAlert(true))
100+
await send(.store(.setTodoHidden(item.id, false)))
101+
await send(.store(.setAlert(true)))
103102
}
104103
}
105104
}
@@ -109,8 +108,8 @@ extension TodoListFeature {
109108
do {
110109
try await undoDeleteTodoUseCase.execute(todoId)
111110
} catch {
112-
await send(.setTodoHidden(todoId, true))
113-
await send(.setAlert(true))
111+
await send(.store(.setTodoHidden(todoId, true)))
112+
await send(.store(.setAlert(true)))
114113
}
115114
}
116115
}

Application/DevLogPresentation/Sources/Home/List/TodoListFeature.swift

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ struct TodoListFeature {
2727
var loading = LoadingFeature.State()
2828
var undoTodoId: String?
2929
var nextCursor: TodoCursor?
30-
var deleteToastTodoId: String?
3130
let searchResultsLimit = 5
3231

3332
init(category: TodoCategory) {
@@ -65,26 +64,29 @@ struct TodoListFeature {
6564
case fullScreenCover(PresentationAction<Never>)
6665
case binding(BindingAction<State>)
6766
case refresh
68-
case setAlert(Bool)
6967
case setFullScreenCover(FullScreenCoverState?)
7068
case swipeTodo(TodoListItem)
7169
case resetFilters
7270
case finishDeleteToast(String)
73-
case presentedDeleteToast
7471
case tapToggleCompleted(TodoListItem)
7572
case tapTogglePinned(TodoListItem)
7673
case undoDelete
7774
case onAppear
7875
case loadNextPage
79-
case applySearchQuery(String)
80-
case fetchSearchResults([TodoListItem])
81-
case didToggleCompleted(TodoListItem)
82-
case didTogglePinned(TodoListItem)
83-
case setTodoHidden(String, Bool)
84-
case appendTodos([TodoListItem], nextCursor: TodoCursor?)
85-
case resetPagination
86-
case setHasMore(Bool)
76+
case store(StoreAction)
8777
case loading(LoadingFeature.Action)
78+
79+
enum StoreAction: Equatable {
80+
case setAlert(Bool)
81+
case applySearchQuery(String)
82+
case fetchSearchResults([TodoListItem])
83+
case didToggleCompleted(TodoListItem)
84+
case didTogglePinned(TodoListItem)
85+
case setTodoHidden(String, Bool)
86+
case appendTodos([TodoListItem], nextCursor: TodoCursor?)
87+
case resetPagination
88+
case setHasMore(Bool)
89+
}
8890
}
8991

9092
enum CancelID: Hashable {
@@ -187,7 +189,7 @@ private extension TodoListFeature {
187189
break
188190
case .refresh, .onAppear:
189191
return fetchEffect(query: state.query, cursor: nil)
190-
case .setAlert(let value):
192+
case .store(.setAlert(let value)):
191193
Self.setAlert(&state, isPresented: value)
192194
case .setFullScreenCover(let cover):
193195
state.fullScreenCover = cover
@@ -203,8 +205,6 @@ private extension TodoListFeature {
203205
if state.undoTodoId == todoId {
204206
state.undoTodoId = nil
205207
}
206-
case .presentedDeleteToast:
207-
state.deleteToastTodoId = nil
208208
case .tapToggleCompleted(let todo):
209209
return toggleCompletedEffect(todo)
210210
case .tapTogglePinned(let todo):
@@ -217,24 +217,24 @@ private extension TodoListFeature {
217217
case .loadNextPage:
218218
guard state.hasMore, !state.isLoading else { return .none }
219219
return fetchEffect(query: state.query, cursor: state.nextCursor, resetsPagination: false)
220-
case .applySearchQuery(let query):
220+
case .store(.applySearchQuery(let query)):
221221
return applySearchQueryEffect(query, state: &state)
222-
case .fetchSearchResults(let items):
222+
case .store(.fetchSearchResults(let items)):
223223
state.searchResults = items
224-
case .didToggleCompleted(let todo), .didTogglePinned(let todo):
224+
case .store(.didToggleCompleted(let todo)), .store(.didTogglePinned(let todo)):
225225
if let index = state.todos.firstIndex(where: { $0.id == todo.id }) {
226226
state.todos[index] = todo
227227
}
228-
case .setTodoHidden(let todoId, let isHidden):
228+
case .store(.setTodoHidden(let todoId, let isHidden)):
229229
Self.setTodoHidden(&state, todoId: todoId, isHidden: isHidden)
230-
case .appendTodos(let todos, let nextCursor):
230+
case .store(.appendTodos(let todos, let nextCursor)):
231231
state.todos.append(contentsOf: todos)
232232
state.nextCursor = nextCursor
233-
case .resetPagination:
233+
case .store(.resetPagination):
234234
state.todos = []
235235
state.nextCursor = nil
236236
state.hasMore = false
237-
case .setHasMore(let value):
237+
case .store(.setHasMore(let value)):
238238
state.hasMore = value
239239
case .loading:
240240
break
@@ -254,18 +254,18 @@ private extension TodoListFeature {
254254
do {
255255
let page = try await fetchTodosUseCase.execute(query, cursor: cursor)
256256
if resetsPagination {
257-
await send(.resetPagination)
257+
await send(.store(.resetPagination))
258258
}
259-
await send(.appendTodos(
259+
await send(.store(.appendTodos(
260260
page.items.compactMap(TodoListItem.init(from:)),
261261
nextCursor: page.nextCursor
262-
))
263-
await send(.setHasMore(page.items.count == query.pageSize && page.nextCursor != nil))
262+
)))
263+
await send(.store(.setHasMore(page.items.count == query.pageSize && page.nextCursor != nil)))
264264
await send(.loading(.end(target: .default, mode: .delayed)))
265265
} catch is CancellationError {
266266
return
267267
} catch {
268-
await send(.setAlert(true))
268+
await send(.store(.setAlert(true)))
269269
await send(.loading(.end(target: .default, mode: .delayed)))
270270
}
271271
}
@@ -311,7 +311,7 @@ private extension TodoListFeature {
311311
.send(.loading(.begin(target: .default, mode: .immediate))),
312312
.run { [clock, searchDebounceDelay] send in
313313
try await clock.sleep(for: searchDebounceDelay)
314-
await send(.applySearchQuery(keyword))
314+
await send(.store(.applySearchQuery(keyword)))
315315
}
316316
.cancellable(id: CancelID.debounce, cancelInFlight: true)
317317
)

Application/DevLogPresentation/Sources/Home/List/TodoListView.swift

Lines changed: 53 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,6 @@ struct TodoListView: View {
8181
.background(NavigationBarConfigurator())
8282
.background(Color(.systemGroupedBackground))
8383
.task { store.send(.onAppear) }
84-
.onChange(of: store.deleteToastTodoId) { _, todoId in
85-
guard let todoId else { return }
86-
presentDeleteTodoToast(todoId)
87-
store.send(.presentedDeleteToast)
88-
}
8984
}
9085

9186
@ViewBuilder
@@ -143,6 +138,7 @@ struct TodoListView: View {
143138
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
144139
Button(role: .destructive, action: {
145140
store.send(.swipeTodo(todo))
141+
presentDeleteTodoToast(todo.id)
146142
}) {
147143
Image(systemName: "trash")
148144
}
@@ -327,8 +323,58 @@ struct TodoListView: View {
327323
}
328324
}
329325

330-
sortMenu
331-
filterMenu
326+
Menu {
327+
Picker(selection: $store.query.sortTarget) {
328+
ForEach([TodoQuery.SortTarget.createdAt, .updatedAt], id: \.self) { option in
329+
Text(option.title).tag(option)
330+
}
331+
} label: {
332+
Text(String(localized: "todo_list_sort_by"))
333+
}
334+
Picker(selection: $store.query.sortOrder) {
335+
ForEach([TodoQuery.SortOrder.latest, .oldest], id: \.self) { option in
336+
Text(option.title).tag(option)
337+
}
338+
} label: {
339+
Text(String(localized: "todo_list_sort_order"))
340+
}
341+
} label: {
342+
let condition = store.state.query.sortTarget == .createdAt && store.state.query.sortOrder == .latest
343+
HStack {
344+
Text(
345+
String.localizedStringWithFormat(
346+
String(localized: "todo_list_sort_format"),
347+
store.state.query.sortTarget.title,
348+
store.state.query.sortOrder.title
349+
)
350+
)
351+
Image(systemName: "chevron.down")
352+
}
353+
.foregroundStyle(condition ? Color(.label) : .white)
354+
.adaptiveButtonStyle(color: condition ? .clear : .blue)
355+
}
356+
357+
Menu {
358+
Toggle(isOn: $store.query.isPinned) {
359+
Text(String(localized: "todo_pinned"))
360+
}
361+
362+
Picker(selection: $store.query.completionFilter) {
363+
ForEach([TodoQuery.CompletionFilter.all, .incomplete, .completed], id: \.self) { option in
364+
Text(option.title).tag(option)
365+
}
366+
} label: {
367+
Text(String(localized: "todo_list_completion_status"))
368+
}
369+
} label: {
370+
let condition = store.state.query.isPinned || store.state.query.completionFilter != .all
371+
HStack {
372+
Text(String(localized: "todo_list_filter_options"))
373+
Image(systemName: "chevron.down")
374+
}
375+
.foregroundStyle(condition ? .white : Color(.label))
376+
.adaptiveButtonStyle(color: condition ? .blue : .clear)
377+
}
332378
}
333379
}
334380
.scrollIndicators(.never)
@@ -344,63 +390,6 @@ struct TodoListView: View {
344390
}
345391
}
346392

347-
private var sortMenu: some View {
348-
Menu {
349-
Picker(selection: $store.query.sortTarget) {
350-
ForEach([TodoQuery.SortTarget.createdAt, .updatedAt], id: \.self) { option in
351-
Text(option.title).tag(option)
352-
}
353-
} label: {
354-
Text(String(localized: "todo_list_sort_by"))
355-
}
356-
Picker(selection: $store.query.sortOrder) {
357-
ForEach([TodoQuery.SortOrder.latest, .oldest], id: \.self) { option in
358-
Text(option.title).tag(option)
359-
}
360-
} label: {
361-
Text(String(localized: "todo_list_sort_order"))
362-
}
363-
} label: {
364-
let condition = store.state.query.sortTarget == .createdAt && store.state.query.sortOrder == .latest
365-
HStack {
366-
Text(
367-
String.localizedStringWithFormat(
368-
String(localized: "todo_list_sort_format"),
369-
store.state.query.sortTarget.title,
370-
store.state.query.sortOrder.title
371-
)
372-
)
373-
Image(systemName: "chevron.down")
374-
}
375-
.foregroundStyle(condition ? Color(.label) : .white)
376-
.adaptiveButtonStyle(color: condition ? .clear : .blue)
377-
}
378-
}
379-
380-
private var filterMenu: some View {
381-
Menu {
382-
Toggle(isOn: $store.query.isPinned) {
383-
Text(String(localized: "todo_pinned"))
384-
}
385-
386-
Picker(selection: $store.query.completionFilter) {
387-
ForEach([TodoQuery.CompletionFilter.all, .incomplete, .completed], id: \.self) { option in
388-
Text(option.title).tag(option)
389-
}
390-
} label: {
391-
Text(String(localized: "todo_list_completion_status"))
392-
}
393-
} label: {
394-
let condition = store.state.query.isPinned || store.state.query.completionFilter != .all
395-
HStack {
396-
Text(String(localized: "todo_list_filter_options"))
397-
Image(systemName: "chevron.down")
398-
}
399-
.foregroundStyle(condition ? .white : Color(.label))
400-
.adaptiveButtonStyle(color: condition ? .blue : .clear)
401-
}
402-
}
403-
404393
private var filterBadge: some View {
405394
let isDark = colorScheme == .dark
406395
let blue = Color(uiColor: .systemBlue)

Application/DevLogPresentation/Sources/Main/MainView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ struct MainView: View {
309309

310310
@ViewBuilder
311311
private var notificationRegularDetailView: some View {
312-
if let todoId = pushNotificationListViewCoordinator.todoIdToPresent?.id {
312+
if let todoId = pushNotificationListViewCoordinator.store.selectedTodoId?.id {
313313
TodoDetailView(
314314
store: pushNotificationListViewCoordinator.makeTodoDetailStore(
315315
todoId: todoId

0 commit comments

Comments
 (0)