diff --git a/Application/DevLogPresentation/Sources/Main/MainFeature.swift b/Application/DevLogPresentation/Sources/Main/MainFeature.swift new file mode 100644 index 00000000..c9e4acc0 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Main/MainFeature.swift @@ -0,0 +1,162 @@ +// +// MainFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/16/26. +// + +import Combine +import ComposableArchitecture +import DevLogCore +import DevLogDomain +import Foundation +import UserNotifications + +@Reducer +struct MainFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + var unreadPushCount = 0 + var isObservingUnreadPushCount = false + } + + enum Action: Equatable { + case alert(PresentationAction) + case view(ViewAction) + case store(StoreAction) + + enum ViewAction: Equatable { + case onAppear + case selectedTabChanged(MainTab) + } + + enum StoreAction: Equatable { + case setUnreadPushCount(Int) + case setAlert + } + } + + private enum CancelID: Hashable { + case unreadPushCount + } + + @Dependency(\.observeUnreadPushCountUseCase) var observeUnreadPushCountUseCase + @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase + @Dependency(\.setApplicationBadgeCount) var setApplicationBadgeCount + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .alert: + break + case .view(.onAppear): + guard !state.isObservingUnreadPushCount else { break } + state.isObservingUnreadPushCount = true + return observeUnreadPushCountEffect() + case .view(.selectedTabChanged(let tab)): + guard let screenName = tab.analyticsScreenName else { break } + return trackScreenViewEffect(screenName) + case .store(.setUnreadPushCount(let count)): + state.unreadPushCount = count + return updateBadgeCountEffect(count) + case .store(.setAlert): + state.alert = Self.alertState() + } + + return .none + } + .ifLet(\.$alert, action: \.alert) + } +} + +extension DependencyValues { + var observeUnreadPushCountUseCase: ObserveUnreadPushCountUseCase { + get { self[ObserveUnreadPushCountUseCaseKey.self] } + set { self[ObserveUnreadPushCountUseCaseKey.self] = newValue } + } + + var setApplicationBadgeCount: @Sendable (Int) async throws -> Void { + get { self[SetApplicationBadgeCountKey.self] } + set { self[SetApplicationBadgeCountKey.self] = newValue } + } +} + +private enum ObserveUnreadPushCountUseCaseKey: DependencyKey { + static var liveValue: ObserveUnreadPushCountUseCase { + preconditionFailure("ObserveUnreadPushCountUseCase must be provided.") + } + + static var testValue: ObserveUnreadPushCountUseCase { + liveValue + } +} + +private enum SetApplicationBadgeCountKey: DependencyKey { + static let liveValue: @Sendable (Int) async throws -> Void = { count in + try await UNUserNotificationCenter.current().setBadgeCount(count) + } + + static var testValue: @Sendable (Int) async throws -> Void { + liveValue + } +} + +private extension MainFeature { + func observeUnreadPushCountEffect() -> Effect { + .run { [observeUnreadPushCountUseCase] send in + do { + let publisher = try observeUnreadPushCountUseCase.observe() + for try await count in publisher.values { + await send(.store(.setUnreadPushCount(count))) + } + } catch { + await send(.store(.setAlert)) + } + } + .cancellable(id: CancelID.unreadPushCount, cancelInFlight: true) + } + + func trackScreenViewEffect(_ screenName: String) -> Effect { + .run { [trackAnalyticsEventUseCase] _ in + trackAnalyticsEventUseCase?.execute(.screenView(screenName)) + } + } + + func updateBadgeCountEffect(_ count: Int) -> Effect { + .run { [setApplicationBadgeCount] _ in + do { + try await setApplicationBadgeCount(count) + } catch { + Logger(category: "MainFeature").error("Failed to update application badge count", error: error) + } + } + } + + static func alertState() -> AlertState { + AlertState { + TextState(String(localized: "common_error_title")) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(String(localized: "main_alert_badge_error_message")) + } + } +} + +private extension MainTab { + var analyticsScreenName: String? { + switch self { + case .home: + return "home" + case .today: + return "today" + case .notification: + return nil + case .profile: + return "profile" + } + } +} diff --git a/Application/DevLogPresentation/Sources/Main/MainView.swift b/Application/DevLogPresentation/Sources/Main/MainView.swift index 2a4cba44..f0409b92 100644 --- a/Application/DevLogPresentation/Sources/Main/MainView.swift +++ b/Application/DevLogPresentation/Sources/Main/MainView.swift @@ -6,18 +6,19 @@ // import SwiftUI +import ComposableArchitecture import DevLogCore import DevLogDomain struct MainView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @State private var coordinator: MainViewCoordinator @State private var todoWindowCoordinator: TodoWindowCoordinator @State private var homeViewCoordinator: HomeViewCoordinator @State private var todayViewCoordinator: TodayViewCoordinator @State private var pushNotificationListViewCoordinator: PushNotificationListViewCoordinator @State private var profileViewCoordinator: ProfileViewCoordinator @Binding var selectedTab: MainTab + @State private var store: StoreOf private let windowEvent: TodoEditorWindowEvent init( @@ -25,7 +26,12 @@ struct MainView: View { windowEvent: TodoEditorWindowEvent, selectedTab: Binding ) { - self._coordinator = State(initialValue: MainViewCoordinator(container: container)) + self._store = State(initialValue: Store(initialState: MainFeature.State()) { + MainFeature() + } withDependencies: { + $0.observeUnreadPushCountUseCase = container.resolve(ObserveUnreadPushCountUseCase.self) + $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) + }) self._todoWindowCoordinator = State(initialValue: TodoWindowCoordinator(container: container)) self._homeViewCoordinator = State(initialValue: HomeViewCoordinator(container: container)) self._todayViewCoordinator = State(initialValue: TodayViewCoordinator(container: container)) @@ -33,8 +39,9 @@ struct MainView: View { initialValue: PushNotificationListViewCoordinator(container: container) ) self._profileViewCoordinator = State(initialValue: ProfileViewCoordinator(container: container)) - self.windowEvent = windowEvent + self._selectedTab = selectedTab + self.windowEvent = windowEvent } var body: some View { @@ -46,13 +53,13 @@ struct MainView: View { } } .onAppear { - coordinator.viewModel.send(.onAppear) + store.send(.view(.onAppear)) homeViewCoordinator.bindWindowEvent(windowEvent) homeViewCoordinator.bindTodoMutationEvent() todoWindowCoordinator.bindWindowEvent(windowEvent) } .onChange(of: selectedTab, initial: true) { _, newValue in - coordinator.viewModel.send(.selectedTabChanged(newValue)) + store.send(.view(.selectedTabChanged(newValue))) if newValue == .home { homeViewCoordinator.fetchData() } else if newValue == .today { @@ -63,14 +70,7 @@ struct MainView: View { profileViewCoordinator.fetchData() } } - .alert( - coordinator.viewModel.state.alertTitle, - isPresented: mainAlertPresented - ) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(coordinator.viewModel.state.alertMessage) - } + .alert($store.scope(state: \.alert, action: \.alert)) .toastHost() } @@ -92,7 +92,7 @@ struct MainView: View { .tabItem { tabLabel(.notification) } - .badge(coordinator.viewModel.state.unreadPushCount) + .badge(store.unreadPushCount) .tag(MainTab.notification) profileView @@ -163,7 +163,7 @@ struct MainView: View { private func sidebarRow(_ tab: MainTab) -> some View { if tab == .notification { tabLabel(tab) - .badge(coordinator.viewModel.state.unreadPushCount) + .badge(store.unreadPushCount) .tag(tab) } else { tabLabel(tab) @@ -381,13 +381,6 @@ private extension MainView { horizontalSizeClass == .compact } - var mainAlertPresented: Binding { - Binding( - get: { coordinator.viewModel.state.showAlert }, - set: { coordinator.viewModel.send(.setAlert($0)) } - ) - } - var sidebarSelection: Binding { Binding( get: { selectedTab }, diff --git a/Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift b/Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift deleted file mode 100644 index 825265f8..00000000 --- a/Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// MainViewCoordinator.swift -// DevLogPresentation -// -// Created by opfic on 5/9/26. -// - -import Foundation -import DevLogCore -import DevLogDomain - -@MainActor -@Observable -final class MainViewCoordinator { - let viewModel: MainViewModel - - init(container: DIContainer) { - self.viewModel = MainViewModel( - trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self), - unreadPushCountUseCase: container.resolve(ObserveUnreadPushCountUseCase.self) - ) - } -} diff --git a/Application/DevLogPresentation/Sources/Main/MainViewModel.swift b/Application/DevLogPresentation/Sources/Main/MainViewModel.swift deleted file mode 100644 index 0347af30..00000000 --- a/Application/DevLogPresentation/Sources/Main/MainViewModel.swift +++ /dev/null @@ -1,150 +0,0 @@ -// -// MainViewModel.swift -// DevLogPresentation -// -// Created by opfic on 3/17/26. -// - -import Foundation -import Combine -import UserNotifications -import DevLogDomain -import DevLogCore - -@Observable -final class MainViewModel: StorePattern { - struct State: Equatable { - var unreadPushCount = 0 - var showAlert = false - var alertTitle = "" - var alertMessage = "" - } - - enum Action { - case onAppear - case selectedTabChanged(MainTab) - case setUnreadPushCount(Int) - case setAlert(Bool) - } - - enum SideEffect { - case observeUnreadPushCount - case trackScreenView(MainTab) - case updateBadgeCount(Int) - } - - private(set) var state = State() - private let logger = Logger(category: "MainViewModel") - private var cancellables = Set() - private var isObservingUnreadPushCount = false - private let trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase - private let unreadPushCountUseCase: ObserveUnreadPushCountUseCase - - init( - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase, - unreadPushCountUseCase: ObserveUnreadPushCountUseCase - ) { - self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase - self.unreadPushCountUseCase = unreadPushCountUseCase - } - - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - var sideEffects: [SideEffect] = [] - - switch action { - case .onAppear: - if !isObservingUnreadPushCount { - isObservingUnreadPushCount = true - sideEffects = [.observeUnreadPushCount] - } - case .selectedTabChanged(let tab): - if tab.analyticsScreenName != nil { - sideEffects = [.trackScreenView(tab)] - } - case .setUnreadPushCount(let count): - state.unreadPushCount = count - sideEffects = [.updateBadgeCount(count)] - case .setAlert(let isPresented): - setAlert(&state, isPresented: isPresented) - } - - if self.state != state { self.state = state } - return sideEffects - } - - func run(_ effect: SideEffect) { - switch effect { - case .observeUnreadPushCount: - observeUnreadPushCount() - case .trackScreenView(let tab): - trackScreenView(tab) - case .updateBadgeCount(let count): - updateBadgeCount(count) - } - } -} - -private extension MainViewModel { - func setAlert( - _ state: inout State, - isPresented: Bool - ) { - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "main_alert_badge_error_message") - state.showAlert = isPresented - } - - func observeUnreadPushCount() { - do { - try unreadPushCountUseCase.observe() - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [weak self] completion in - guard let self else { return } - if case .failure(let error) = completion { - logger.error("Failed to observe unread push count", error: error) - self.send(.setAlert(true)) - } - }, - receiveValue: { [weak self] count in - self?.send(.setUnreadPushCount(count)) - } - ) - .store(in: &cancellables) - } catch { - logger.error("Failed to start observing unread push count", error: error) - send(.setAlert(true)) - } - } - - func updateBadgeCount(_ count: Int) { - UNUserNotificationCenter.current().setBadgeCount(count) { [weak self] error in - if let error { - Task { @MainActor in - self?.logger.error("Failed to update application badge count", error: error) - } - } - } - } - - func trackScreenView(_ tab: MainTab) { - guard let screenName = tab.analyticsScreenName else { return } - trackAnalyticsEventUseCase.execute(.screenView(screenName)) - } -} - -private extension MainTab { - var analyticsScreenName: String? { - switch self { - case .home: - return "home" - case .today: - return "today" - case .notification: - return nil - case .profile: - return "profile" - } - } -} diff --git a/Application/DevLogPresentation/Tests/Main/MainFeatureTests.swift b/Application/DevLogPresentation/Tests/Main/MainFeatureTests.swift new file mode 100644 index 00000000..c0201d3f --- /dev/null +++ b/Application/DevLogPresentation/Tests/Main/MainFeatureTests.swift @@ -0,0 +1,298 @@ +// +// MainFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/16/26. +// + +import Combine +import ComposableArchitecture +import DevLogDomain +import Foundation +import Testing +@testable import DevLogPresentation + +@MainActor +struct MainFeatureTests { + @Test("MainFeature는 기존 Main 상태관리처럼 최초 onAppear에서만 unread count 관찰을 시작한다") + func MainFeature는_기존_Main_상태관리처럼_최초_onAppear에서만_unread_count_관찰을_시작한다() async { + let reference = MainStateManagementReference() + let unreadPushCountUseCase = MainObserveUnreadPushCountUseCaseSpy() + let store = makeStore(unreadPushCountUseCase: unreadPushCountUseCase) + + let firstEffects = reference.reduce(.onAppear) + let secondEffects = reference.reduce(.onAppear) + await store.send(.view(.onAppear)) { + $0.isObservingUnreadPushCount = true + } + await store.send(.view(.onAppear)) + + #expect(firstEffects == [.observeUnreadPushCount]) + #expect(secondEffects.isEmpty) + #expect(unreadPushCountUseCase.observeCallCount == 1) + } + + @Test("MainFeature는 기존 Main 상태관리처럼 notification 탭을 제외한 화면 전환을 추적한다") + func MainFeature는_기존_Main_상태관리처럼_notification_탭을_제외한_화면_전환을_추적한다() async { + let reference = MainStateManagementReference() + let trackAnalyticsEventUseCase = MainTrackAnalyticsEventUseCaseSpy() + let store = makeStore(trackAnalyticsEventUseCase: trackAnalyticsEventUseCase) + + let expectedEffects = [ + reference.reduce(.selectedTabChanged(.home)), + reference.reduce(.selectedTabChanged(.today)), + reference.reduce(.selectedTabChanged(.notification)), + reference.reduce(.selectedTabChanged(.profile)) + ] + await store.send(.view(.selectedTabChanged(.home))) + await store.send(.view(.selectedTabChanged(.today))) + await store.send(.view(.selectedTabChanged(.notification))) + await store.send(.view(.selectedTabChanged(.profile))) + await waitUntil { + trackAnalyticsEventUseCase.screenNames == ["home", "today", "profile"] + } + + #expect(expectedEffects == [ + [.trackScreenView("home")], + [.trackScreenView("today")], + [], + [.trackScreenView("profile")] + ]) + #expect(trackAnalyticsEventUseCase.screenNames == ["home", "today", "profile"]) + } + + @Test("MainFeature는 기존 Main 상태관리처럼 unread count를 갱신하고 badge 갱신을 요청한다") + func MainFeature는_기존_Main_상태관리처럼_unread_count를_갱신하고_badge_갱신을_요청한다() async { + let reference = MainStateManagementReference() + let badgeCountSpy = MainApplicationBadgeCountSpy() + let store = makeStore(badgeCountSpy: badgeCountSpy) + + let effects = reference.reduce(.setUnreadPushCount(7)) + await store.send(.store(.setUnreadPushCount(7))) { + $0.unreadPushCount = reference.state.unreadPushCount + } + await waitUntil { + badgeCountSpy.counts == [7] + } + + #expect(reference.state.unreadPushCount == 7) + #expect(effects == [.updateBadgeCount(7)]) + #expect(badgeCountSpy.counts == [7]) + } + + @Test("MainFeature는 기존 Main 상태관리처럼 alert 표시 여부와 문구를 함께 갱신한다") + func MainFeature는_기존_Main_상태관리처럼_alert_state를_갱신한다() async { + let reference = MainStateManagementReference() + let store = makeStore() + + let presentEffects = reference.reduce(.setAlert) + await store.send(.store(.setAlert)) { + $0.alert = reference.state.alert + } + + await store.send(.alert(.dismiss)) { + $0.alert = nil + } + + #expect(presentEffects.isEmpty) + #expect(reference.state.alert == expectedMainErrorAlert()) + } + + @Test("MainFeature는 기존 Main 상태관리처럼 unread count 관찰 시작 실패 시 alert를 표시한다") + func MainFeature는_기존_Main_상태관리처럼_unread_count_관찰_시작_실패_시_alert를_표시한다() async { + let reference = MainStateManagementReference() + let unreadPushCountUseCase = MainObserveUnreadPushCountUseCaseSpy(error: MainTestError.failure) + let store = makeStore(unreadPushCountUseCase: unreadPushCountUseCase) + + _ = reference.reduce(.onAppear) + _ = reference.reduce(.setAlert) + await store.send(.view(.onAppear)) { + $0.isObservingUnreadPushCount = true + } + await store.receive(.store(.setAlert)) { + $0.alert = reference.state.alert + } + } + + @Test("MainFeature는 unread count 관찰 값을 기존 Main 상태관리의 setUnreadPushCount 상태 변화로 반영한다") + func MainFeature는_unread_count_관찰_값을_기존_Main_상태관리의_setUnreadPushCount_상태_변화로_반영한다() async { + let reference = MainStateManagementReference() + let subject = PassthroughSubject() + let unreadPushCountUseCase = MainObserveUnreadPushCountUseCaseSpy(publisher: subject.eraseToAnyPublisher()) + let badgeCountSpy = MainApplicationBadgeCountSpy() + let store = makeStore( + unreadPushCountUseCase: unreadPushCountUseCase, + badgeCountSpy: badgeCountSpy + ) + + _ = reference.reduce(.onAppear) + await store.send(.view(.onAppear)) { + $0.isObservingUnreadPushCount = true + } + + let effects = reference.reduce(.setUnreadPushCount(3)) + subject.send(3) + await store.receive(.store(.setUnreadPushCount(3))) { + $0.unreadPushCount = reference.state.unreadPushCount + } + await waitUntil { + badgeCountSpy.counts == [3] + } + + #expect(reference.state.unreadPushCount == 3) + #expect(effects == [.updateBadgeCount(3)]) + #expect(badgeCountSpy.counts == [3]) + subject.send(completion: .finished) + } +} + +@MainActor +private func makeStore( + unreadPushCountUseCase: ObserveUnreadPushCountUseCase = MainObserveUnreadPushCountUseCaseSpy(), + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = MainTrackAnalyticsEventUseCaseSpy(), + badgeCountSpy: MainApplicationBadgeCountSpy = MainApplicationBadgeCountSpy() +) -> TestStoreOf { + let store = TestStore(initialState: MainFeature.State()) { + MainFeature() + } withDependencies: { + $0.observeUnreadPushCountUseCase = unreadPushCountUseCase + $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + $0.setApplicationBadgeCount = { count in + try await badgeCountSpy.setBadgeCount(count) + } + } + store.exhaustivity = .off(showSkippedAssertions: false) + return store +} + +@MainActor +private final class MainStateManagementReference { + struct State: Equatable { + var alert: AlertState? + var unreadPushCount = 0 + } + + enum Action { + case onAppear + case selectedTabChanged(MainTab) + case setUnreadPushCount(Int) + case setAlert + } + + enum Effect: Equatable { + case observeUnreadPushCount + case trackScreenView(String) + case updateBadgeCount(Int) + } + + private(set) var state = State() + private var isObservingUnreadPushCount = false + + func reduce(_ action: Action) -> [Effect] { + switch action { + case .onAppear: + if !isObservingUnreadPushCount { + isObservingUnreadPushCount = true + return [.observeUnreadPushCount] + } + case .selectedTabChanged(let tab): + if let screenName = tab.analyticsScreenName { + return [.trackScreenView(screenName)] + } + case .setUnreadPushCount(let count): + state.unreadPushCount = count + return [.updateBadgeCount(count)] + case .setAlert: + setAlert() + } + + return [] + } + + private func setAlert() { + state.alert = expectedMainErrorAlert() + } +} + +private func expectedMainErrorAlert() -> AlertState { + AlertState { + TextState(String(localized: "common_error_title")) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(String(localized: "main_alert_badge_error_message")) + } +} + +private final class MainObserveUnreadPushCountUseCaseSpy: ObserveUnreadPushCountUseCase { + var publisher: AnyPublisher + var error: Error? + private(set) var observeCallCount = 0 + + init( + publisher: AnyPublisher = Empty().eraseToAnyPublisher(), + error: Error? = nil + ) { + self.publisher = publisher + self.error = error + } + + func observe() throws -> AnyPublisher { + observeCallCount += 1 + + if let error { + throw error + } + + return publisher + } +} + +private final class MainTrackAnalyticsEventUseCaseSpy: TrackAnalyticsEventUseCase { + private var events = [AnalyticsEvent]() + + var screenNames: [String] { + events.compactMap { event in + guard case .screenView(let screenName) = event else { return nil } + return screenName + } + } + + func execute(_ event: AnalyticsEvent) { + events.append(event) + } +} + +private final class MainApplicationBadgeCountSpy: @unchecked Sendable { + private(set) var counts = [Int]() + var error: Error? + + func setBadgeCount(_ count: Int) async throws { + counts.append(count) + + if let error { + throw error + } + } +} + +private enum MainTestError: Error { + case failure +} + +private extension MainTab { + var analyticsScreenName: String? { + switch self { + case .home: + return "home" + case .today: + return "today" + case .notification: + return nil + case .profile: + return "profile" + } + } +}