Skip to content

Commit f25b2cc

Browse files
authored
[#579] HomeView에 TCA를 적용한다 (#607)
* feat: HomeFeature 1차 구현 * refactor: tca 요소로 애니메이션 처리 * refactor: 코디네이터가 Store을 들고있기 때문에 Bindable로 수정 * refactor: CasePathable를 통해 SheetState 구현 * refactor: AlertState 적용 * refactor: 웹페이지 url은 시트에서 처리하도록 개선 * refactor: 웹페이지 입력은 얼럿에서 시트에서 내비게이션 형태로 수정 * refactor: FullScreenCoverState 적용 * ui: 상단 내비게이션바 영역 최소화 * refactor: 뷰에서 쓰는 순서대로 컴포넌트 정렬 * ui: row 간의 패딩 최소화 * refactor: 미사용 코드 제거 * refactor: 웹페이지 액션을 ContentPicker 내로 이전 * refactor: ModalType 제거 * test: HomeFeature에 맞도록 테스트코드 수정 * refactor: 하나의 액션만 send 하도록 수정 * refactor: 불필요 DispatchQueue.main.async 제거 * fix: Today fetchData 테스트 병렬 호출 검증 안정화 * refactor: 피커 여는 액션 개선 * refactor: view / store 간 액션 분리 * refactor: 이미 분리되어 있던 Store들의 reduce() 패턴 싱크
1 parent 6510233 commit f25b2cc

20 files changed

Lines changed: 1723 additions & 986 deletions
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
//
2+
// HomeFeature+Dependencies.swift
3+
// DevLogPresentation
4+
//
5+
// Created by opfic on 6/14/26.
6+
//
7+
8+
import ComposableArchitecture
9+
import DevLogDomain
10+
11+
extension DependencyValues {
12+
var homeUpdateTodoCategoryPreferencesUseCase: UpdateTodoCategoryPreferencesUseCase {
13+
get { self[HomeUpdatePreferencesUseCaseKey.self] }
14+
set { self[HomeUpdatePreferencesUseCaseKey.self] = newValue }
15+
}
16+
17+
var homeAddWebPageUseCase: AddWebPageUseCase {
18+
get { self[HomeAddWebPageUseCaseKey.self] }
19+
set { self[HomeAddWebPageUseCaseKey.self] = newValue }
20+
}
21+
22+
var homeDeleteWebPageUseCase: DeleteWebPageUseCase {
23+
get { self[HomeDeleteWebPageUseCaseKey.self] }
24+
set { self[HomeDeleteWebPageUseCaseKey.self] = newValue }
25+
}
26+
27+
var homeUndoDeleteWebPageUseCase: UndoDeleteWebPageUseCase {
28+
get { self[HomeUndoDeleteWebPageUseCaseKey.self] }
29+
set { self[HomeUndoDeleteWebPageUseCaseKey.self] = newValue }
30+
}
31+
32+
var homeFetchTodosUseCase: FetchTodosUseCase {
33+
get { self[HomeFetchTodosUseCaseKey.self] }
34+
set { self[HomeFetchTodosUseCaseKey.self] = newValue }
35+
}
36+
37+
var homeFetchWebPagesUseCase: FetchWebPagesUseCase {
38+
get { self[HomeFetchWebPagesUseCaseKey.self] }
39+
set { self[HomeFetchWebPagesUseCaseKey.self] = newValue }
40+
}
41+
}
42+
43+
private enum HomeUpdatePreferencesUseCaseKey: DependencyKey {
44+
static var liveValue: UpdateTodoCategoryPreferencesUseCase {
45+
preconditionFailure("UpdateTodoCategoryPreferencesUseCase must be provided.")
46+
}
47+
48+
static var testValue: UpdateTodoCategoryPreferencesUseCase {
49+
liveValue
50+
}
51+
}
52+
53+
private enum HomeAddWebPageUseCaseKey: DependencyKey {
54+
static var liveValue: AddWebPageUseCase {
55+
preconditionFailure("AddWebPageUseCase must be provided.")
56+
}
57+
58+
static var testValue: AddWebPageUseCase {
59+
liveValue
60+
}
61+
}
62+
63+
private enum HomeDeleteWebPageUseCaseKey: DependencyKey {
64+
static var liveValue: DeleteWebPageUseCase {
65+
preconditionFailure("DeleteWebPageUseCase must be provided.")
66+
}
67+
68+
static var testValue: DeleteWebPageUseCase {
69+
liveValue
70+
}
71+
}
72+
73+
private enum HomeUndoDeleteWebPageUseCaseKey: DependencyKey {
74+
static var liveValue: UndoDeleteWebPageUseCase {
75+
preconditionFailure("UndoDeleteWebPageUseCase must be provided.")
76+
}
77+
78+
static var testValue: UndoDeleteWebPageUseCase {
79+
liveValue
80+
}
81+
}
82+
83+
private enum HomeFetchTodosUseCaseKey: DependencyKey {
84+
static var liveValue: FetchTodosUseCase {
85+
preconditionFailure("FetchTodosUseCase must be provided.")
86+
}
87+
88+
static var testValue: FetchTodosUseCase {
89+
liveValue
90+
}
91+
}
92+
93+
private enum HomeFetchWebPagesUseCaseKey: DependencyKey {
94+
static var liveValue: FetchWebPagesUseCase {
95+
preconditionFailure("FetchWebPagesUseCase must be provided.")
96+
}
97+
98+
static var testValue: FetchWebPagesUseCase {
99+
liveValue
100+
}
101+
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
//
2+
// HomeFeature+Effects.swift
3+
// DevLogPresentation
4+
//
5+
// Created by opfic on 6/14/26.
6+
//
7+
8+
import Combine
9+
import ComposableArchitecture
10+
import DevLogCore
11+
import DevLogDomain
12+
import Foundation
13+
14+
extension HomeFeature {
15+
private enum CancelID: Hashable {
16+
case delayedTodoEditor
17+
case networkConnectivity
18+
}
19+
20+
func observeNetworkConnectivityEffect() -> Effect<Action> {
21+
.publisher { [networkConnectivityUseCase] in
22+
networkConnectivityUseCase.observe()
23+
.receive(on: DispatchQueue.main)
24+
.map { .store(.networkStatusChanged($0)) }
25+
}
26+
.cancellable(id: CancelID.networkConnectivity, cancelInFlight: true)
27+
}
28+
29+
func fetchTodoCategoryPreferencesEffect() -> Effect<Action> {
30+
.run { [fetchPreferencesUseCase] send in
31+
await send(.loading(.begin(target: LoadingTarget.preferences.target, mode: .immediate)))
32+
do {
33+
let preferences = try await fetchPreferencesUseCase.execute()
34+
await send(.store(.setTodoCategory(preferences.map(TodoCategoryItem.init(from:)))))
35+
} catch {
36+
await send(.store(.setAlert(isPresented: true, type: .error)))
37+
}
38+
await send(.loading(.end(target: LoadingTarget.preferences.target, mode: .immediate)))
39+
}
40+
}
41+
42+
func fetchRecentTodosEffect() -> Effect<Action> {
43+
.run { [fetchTodosUseCase] send in
44+
await send(.loading(.begin(target: LoadingTarget.recentTodos.target, mode: .immediate)))
45+
do {
46+
let page = try await fetchRecentTodos(fetchTodosUseCase: fetchTodosUseCase)
47+
let items = page.items
48+
.filter { $0.createdAt != $0.updatedAt }
49+
.prefix(5)
50+
.compactMap(RecentTodoItem.init(from:))
51+
await send(.store(.updateRecentTodos(Array(items))))
52+
} catch {
53+
await send(.store(.setAlert(isPresented: true, type: .error)))
54+
}
55+
await send(.loading(.end(target: LoadingTarget.recentTodos.target, mode: .immediate)))
56+
}
57+
}
58+
59+
func fetchWebPagesEffect() -> Effect<Action> {
60+
.run { [fetchWebPagesUseCase] send in
61+
await send(.loading(.begin(target: LoadingTarget.webPage.target, mode: .immediate)))
62+
do {
63+
let pages = try await fetchWebPagesUseCase.execute("")
64+
await send(.store(.updateWebPages(pages.map(WebPageItem.init(from:)))))
65+
} catch {
66+
await send(.store(.setAlert(isPresented: true, type: .error)))
67+
}
68+
await send(.loading(.end(target: LoadingTarget.webPage.target, mode: .immediate)))
69+
}
70+
}
71+
72+
func addWebPageEffect(_ urlString: String) -> Effect<Action> {
73+
.run { [addWebPageUseCase, fetchWebPagesUseCase, trackAnalyticsEventUseCase] send in
74+
await send(.loading(.begin(target: LoadingTarget.overlay.target, mode: .delayed)))
75+
do {
76+
try await addWebPageUseCase.execute(urlString)
77+
trackAnalyticsEventUseCase?.execute(.webPageCreate)
78+
let pages = try await fetchWebPagesUseCase.execute("")
79+
await send(.store(.updateWebPages(pages.map(WebPageItem.init(from:)))))
80+
} catch {
81+
await send(.store(.setAlert(isPresented: true, type: .error)))
82+
}
83+
await send(.loading(.end(target: LoadingTarget.overlay.target, mode: .delayed)))
84+
}
85+
}
86+
87+
func deleteWebPageEffect(_ page: WebPageItem) -> Effect<Action> {
88+
.run { [deleteWebPageUseCase] send in
89+
do {
90+
try await deleteWebPageUseCase.execute(page.url.absoluteString)
91+
} catch {
92+
await send(.store(.handleWebPageDeleteFailure(page.id)))
93+
await send(.store(.setAlert(isPresented: true, type: .error)))
94+
}
95+
}
96+
}
97+
98+
func undoDeleteWebPageEffect(_ urlString: String) -> Effect<Action> {
99+
.run { [undoDeleteWebPageUseCase, addWebPageUseCase] send in
100+
do {
101+
try await undoDeleteWebPageUseCase.execute(urlString)
102+
try await addWebPageUseCase.execute(urlString)
103+
} catch {
104+
if let webPageURL = URL(string: urlString) {
105+
await send(.store(.setWebPageHidden(webPageURL, true)))
106+
}
107+
await send(.store(.setAlert(isPresented: true, type: .error)))
108+
}
109+
}
110+
}
111+
112+
func updateTodoCategoryPreferencesEffect(_ items: [TodoCategoryItem]) -> Effect<Action> {
113+
.run { [updatePreferencesUseCase] send in
114+
do {
115+
try await updatePreferencesUseCase.execute(items.map(\.preference))
116+
} catch {
117+
await send(.store(.setAlert(isPresented: true, type: .error)))
118+
}
119+
}
120+
}
121+
122+
func delayedTodoEditorEffect() -> Effect<Action> {
123+
.run { [clock] send in
124+
// iOS 17에서 시트 dismiss 직후 fullScreenCover를 바로 올리지 않도록 하기 위해서 0.1초 딜레이
125+
try await clock.sleep(for: .seconds(0.1))
126+
await send(.store(.setPresentation(.todoEditor, true)))
127+
}
128+
.cancellable(id: CancelID.delayedTodoEditor, cancelInFlight: true)
129+
}
130+
131+
func fetchRecentTodos(fetchTodosUseCase: FetchTodosUseCase) async throws -> TodoPage {
132+
try await fetchTodosUseCase.execute(
133+
TodoQuery(
134+
sortTarget: .updatedAt,
135+
sortOrder: .latest,
136+
pageSize: 100
137+
),
138+
cursor: nil
139+
)
140+
}
141+
142+
static func setPresentation(
143+
_ state: inout State,
144+
presentation: Presentation,
145+
isPresented: Bool
146+
) {
147+
switch presentation {
148+
case .todoEditor:
149+
state.fullScreenCover = isPresented ? state.selectedTodoCategory.map(FullScreenCoverState.todoEditor) : nil
150+
if !isPresented {
151+
state.selectedTodoCategory = nil
152+
}
153+
case .contentPicker:
154+
state.sheet = isPresented ? .contentPicker(.init()) : state.showContentPicker ? nil : state.sheet
155+
case .searchView:
156+
state.fullScreenCover = isPresented ? .search : nil
157+
}
158+
}
159+
160+
static func setAlert(
161+
_ state: inout State,
162+
isPresented: Bool,
163+
type: AlertType?
164+
) {
165+
guard isPresented, let type else {
166+
state.alert = nil
167+
return
168+
}
169+
170+
state.alert = alertState(for: type)
171+
}
172+
173+
static func alertState(for type: AlertType) -> AlertState<Never> {
174+
let title: String
175+
let message: String
176+
177+
switch type {
178+
case .invalidURL:
179+
title = String(localized: "home_invalid_url_title")
180+
message = String(localized: "home_invalid_url_message")
181+
case .error:
182+
title = String(localized: "common_error_title")
183+
message = String(localized: "common_error_message")
184+
}
185+
186+
return AlertState<Never> {
187+
TextState(title)
188+
} actions: {
189+
ButtonState(role: .cancel) {
190+
TextState(String(localized: "common_close"))
191+
}
192+
} message: {
193+
TextState(message)
194+
}
195+
}
196+
197+
static func syncRecentTodos(
198+
_ recentTodos: [RecentTodoItem],
199+
preferences: [TodoCategoryItem]
200+
) -> [RecentTodoItem] {
201+
recentTodos.map { recentTodo in
202+
guard let item = preferences.first(where: {
203+
$0.category.storageValue == recentTodo.category.storageValue
204+
}) else {
205+
return recentTodo
206+
}
207+
208+
var recentTodo = recentTodo
209+
recentTodo.category = item.category
210+
return recentTodo
211+
}
212+
}
213+
214+
static func normalizedWebPageURL(_ input: String) -> String? {
215+
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
216+
guard !trimmed.isEmpty else { return nil }
217+
if trimmed == "https://" || trimmed == "http://" {
218+
return nil
219+
}
220+
if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") {
221+
return trimmed
222+
}
223+
return "https://" + trimmed
224+
}
225+
}

0 commit comments

Comments
 (0)