Skip to content

Commit 5fd6fe6

Browse files
authored
[#581] MainView에 TCA를 적용한다 (#618)
* feat: Store 1차 구현 * refactor: AlertState 적용 * refactor: store을 Bindable 처리 * refactor: MainViewCoordinator 제거 및 Store를 뷰에 귀속 * refactor: 불필요 DispatchQueue.main.async 제거 * refactor: MainFeature analytics 처리 정리 * refactor: badge count async API 적용 * chore: MainViewModel 제거 * refactor: MainFeature alert 액션 단순화
1 parent d93fc59 commit 5fd6fe6

5 files changed

Lines changed: 475 additions & 195 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
//
2+
// MainFeature.swift
3+
// DevLogPresentation
4+
//
5+
// Created by opfic on 6/16/26.
6+
//
7+
8+
import Combine
9+
import ComposableArchitecture
10+
import DevLogCore
11+
import DevLogDomain
12+
import Foundation
13+
import UserNotifications
14+
15+
@Reducer
16+
struct MainFeature {
17+
@ObservableState
18+
struct State: Equatable {
19+
@Presents var alert: AlertState<Never>?
20+
var unreadPushCount = 0
21+
var isObservingUnreadPushCount = false
22+
}
23+
24+
enum Action: Equatable {
25+
case alert(PresentationAction<Never>)
26+
case view(ViewAction)
27+
case store(StoreAction)
28+
29+
enum ViewAction: Equatable {
30+
case onAppear
31+
case selectedTabChanged(MainTab)
32+
}
33+
34+
enum StoreAction: Equatable {
35+
case setUnreadPushCount(Int)
36+
case setAlert
37+
}
38+
}
39+
40+
private enum CancelID: Hashable {
41+
case unreadPushCount
42+
}
43+
44+
@Dependency(\.observeUnreadPushCountUseCase) var observeUnreadPushCountUseCase
45+
@Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase
46+
@Dependency(\.setApplicationBadgeCount) var setApplicationBadgeCount
47+
48+
var body: some ReducerOf<Self> {
49+
Reduce { state, action in
50+
switch action {
51+
case .alert:
52+
break
53+
case .view(.onAppear):
54+
guard !state.isObservingUnreadPushCount else { break }
55+
state.isObservingUnreadPushCount = true
56+
return observeUnreadPushCountEffect()
57+
case .view(.selectedTabChanged(let tab)):
58+
guard let screenName = tab.analyticsScreenName else { break }
59+
return trackScreenViewEffect(screenName)
60+
case .store(.setUnreadPushCount(let count)):
61+
state.unreadPushCount = count
62+
return updateBadgeCountEffect(count)
63+
case .store(.setAlert):
64+
state.alert = Self.alertState()
65+
}
66+
67+
return .none
68+
}
69+
.ifLet(\.$alert, action: \.alert)
70+
}
71+
}
72+
73+
extension DependencyValues {
74+
var observeUnreadPushCountUseCase: ObserveUnreadPushCountUseCase {
75+
get { self[ObserveUnreadPushCountUseCaseKey.self] }
76+
set { self[ObserveUnreadPushCountUseCaseKey.self] = newValue }
77+
}
78+
79+
var setApplicationBadgeCount: @Sendable (Int) async throws -> Void {
80+
get { self[SetApplicationBadgeCountKey.self] }
81+
set { self[SetApplicationBadgeCountKey.self] = newValue }
82+
}
83+
}
84+
85+
private enum ObserveUnreadPushCountUseCaseKey: DependencyKey {
86+
static var liveValue: ObserveUnreadPushCountUseCase {
87+
preconditionFailure("ObserveUnreadPushCountUseCase must be provided.")
88+
}
89+
90+
static var testValue: ObserveUnreadPushCountUseCase {
91+
liveValue
92+
}
93+
}
94+
95+
private enum SetApplicationBadgeCountKey: DependencyKey {
96+
static let liveValue: @Sendable (Int) async throws -> Void = { count in
97+
try await UNUserNotificationCenter.current().setBadgeCount(count)
98+
}
99+
100+
static var testValue: @Sendable (Int) async throws -> Void {
101+
liveValue
102+
}
103+
}
104+
105+
private extension MainFeature {
106+
func observeUnreadPushCountEffect() -> Effect<Action> {
107+
.run { [observeUnreadPushCountUseCase] send in
108+
do {
109+
let publisher = try observeUnreadPushCountUseCase.observe()
110+
for try await count in publisher.values {
111+
await send(.store(.setUnreadPushCount(count)))
112+
}
113+
} catch {
114+
await send(.store(.setAlert))
115+
}
116+
}
117+
.cancellable(id: CancelID.unreadPushCount, cancelInFlight: true)
118+
}
119+
120+
func trackScreenViewEffect(_ screenName: String) -> Effect<Action> {
121+
.run { [trackAnalyticsEventUseCase] _ in
122+
trackAnalyticsEventUseCase?.execute(.screenView(screenName))
123+
}
124+
}
125+
126+
func updateBadgeCountEffect(_ count: Int) -> Effect<Action> {
127+
.run { [setApplicationBadgeCount] _ in
128+
do {
129+
try await setApplicationBadgeCount(count)
130+
} catch {
131+
Logger(category: "MainFeature").error("Failed to update application badge count", error: error)
132+
}
133+
}
134+
}
135+
136+
static func alertState() -> AlertState<Never> {
137+
AlertState {
138+
TextState(String(localized: "common_error_title"))
139+
} actions: {
140+
ButtonState(role: .cancel) {
141+
TextState(String(localized: "common_close"))
142+
}
143+
} message: {
144+
TextState(String(localized: "main_alert_badge_error_message"))
145+
}
146+
}
147+
}
148+
149+
private extension MainTab {
150+
var analyticsScreenName: String? {
151+
switch self {
152+
case .home:
153+
return "home"
154+
case .today:
155+
return "today"
156+
case .notification:
157+
return nil
158+
case .profile:
159+
return "profile"
160+
}
161+
}
162+
}

Application/DevLogPresentation/Sources/Main/MainView.swift

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,35 +6,42 @@
66
//
77

88
import SwiftUI
9+
import ComposableArchitecture
910
import DevLogCore
1011
import DevLogDomain
1112

1213
struct MainView: View {
1314
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
14-
@State private var coordinator: MainViewCoordinator
1515
@State private var todoWindowCoordinator: TodoWindowCoordinator
1616
@State private var homeViewCoordinator: HomeViewCoordinator
1717
@State private var todayViewCoordinator: TodayViewCoordinator
1818
@State private var pushNotificationListViewCoordinator: PushNotificationListViewCoordinator
1919
@State private var profileViewCoordinator: ProfileViewCoordinator
2020
@Binding var selectedTab: MainTab
21+
@State private var store: StoreOf<MainFeature>
2122
private let windowEvent: TodoEditorWindowEvent
2223

2324
init(
2425
container: DIContainer,
2526
windowEvent: TodoEditorWindowEvent,
2627
selectedTab: Binding<MainTab>
2728
) {
28-
self._coordinator = State(initialValue: MainViewCoordinator(container: container))
29+
self._store = State(initialValue: Store(initialState: MainFeature.State()) {
30+
MainFeature()
31+
} withDependencies: {
32+
$0.observeUnreadPushCountUseCase = container.resolve(ObserveUnreadPushCountUseCase.self)
33+
$0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self)
34+
})
2935
self._todoWindowCoordinator = State(initialValue: TodoWindowCoordinator(container: container))
3036
self._homeViewCoordinator = State(initialValue: HomeViewCoordinator(container: container))
3137
self._todayViewCoordinator = State(initialValue: TodayViewCoordinator(container: container))
3238
self._pushNotificationListViewCoordinator = State(
3339
initialValue: PushNotificationListViewCoordinator(container: container)
3440
)
3541
self._profileViewCoordinator = State(initialValue: ProfileViewCoordinator(container: container))
36-
self.windowEvent = windowEvent
42+
3743
self._selectedTab = selectedTab
44+
self.windowEvent = windowEvent
3845
}
3946

4047
var body: some View {
@@ -46,13 +53,13 @@ struct MainView: View {
4653
}
4754
}
4855
.onAppear {
49-
coordinator.viewModel.send(.onAppear)
56+
store.send(.view(.onAppear))
5057
homeViewCoordinator.bindWindowEvent(windowEvent)
5158
homeViewCoordinator.bindTodoMutationEvent()
5259
todoWindowCoordinator.bindWindowEvent(windowEvent)
5360
}
5461
.onChange(of: selectedTab, initial: true) { _, newValue in
55-
coordinator.viewModel.send(.selectedTabChanged(newValue))
62+
store.send(.view(.selectedTabChanged(newValue)))
5663
if newValue == .home {
5764
homeViewCoordinator.fetchData()
5865
} else if newValue == .today {
@@ -63,14 +70,7 @@ struct MainView: View {
6370
profileViewCoordinator.fetchData()
6471
}
6572
}
66-
.alert(
67-
coordinator.viewModel.state.alertTitle,
68-
isPresented: mainAlertPresented
69-
) {
70-
Button(String(localized: "common_close"), role: .cancel) { }
71-
} message: {
72-
Text(coordinator.viewModel.state.alertMessage)
73-
}
73+
.alert($store.scope(state: \.alert, action: \.alert))
7474
.toastHost()
7575
}
7676

@@ -92,7 +92,7 @@ struct MainView: View {
9292
.tabItem {
9393
tabLabel(.notification)
9494
}
95-
.badge(coordinator.viewModel.state.unreadPushCount)
95+
.badge(store.unreadPushCount)
9696
.tag(MainTab.notification)
9797

9898
profileView
@@ -163,7 +163,7 @@ struct MainView: View {
163163
private func sidebarRow(_ tab: MainTab) -> some View {
164164
if tab == .notification {
165165
tabLabel(tab)
166-
.badge(coordinator.viewModel.state.unreadPushCount)
166+
.badge(store.unreadPushCount)
167167
.tag(tab)
168168
} else {
169169
tabLabel(tab)
@@ -381,13 +381,6 @@ private extension MainView {
381381
horizontalSizeClass == .compact
382382
}
383383

384-
var mainAlertPresented: Binding<Bool> {
385-
Binding(
386-
get: { coordinator.viewModel.state.showAlert },
387-
set: { coordinator.viewModel.send(.setAlert($0)) }
388-
)
389-
}
390-
391384
var sidebarSelection: Binding<MainTab?> {
392385
Binding(
393386
get: { selectedTab },

Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift

Lines changed: 0 additions & 23 deletions
This file was deleted.

0 commit comments

Comments
 (0)