From e4d6807efcae09112d11fbcb62fd9148f234c662 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:17:27 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20Store=201=EC=B0=A8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Main/MainFeature.swift | 167 ++++++++++ .../Sources/Main/MainView.swift | 16 +- .../Sources/Main/MainViewCoordinator.swift | 13 +- .../Tests/Main/MainFeatureTests.swift | 300 ++++++++++++++++++ 4 files changed, 483 insertions(+), 13 deletions(-) create mode 100644 Application/DevLogPresentation/Sources/Main/MainFeature.swift create mode 100644 Application/DevLogPresentation/Tests/Main/MainFeatureTests.swift diff --git a/Application/DevLogPresentation/Sources/Main/MainFeature.swift b/Application/DevLogPresentation/Sources/Main/MainFeature.swift new file mode 100644 index 00000000..1f303b43 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Main/MainFeature.swift @@ -0,0 +1,167 @@ +// +// 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 { + var unreadPushCount = 0 + var showAlert = false + var alertTitle = "" + var alertMessage = "" + var isObservingUnreadPushCount = false + } + + enum Action: Equatable { + case view(ViewAction) + case store(StoreAction) + + enum ViewAction: Equatable { + case onAppear + case selectedTabChanged(MainTab) + } + + enum StoreAction: Equatable { + case setUnreadPushCount(Int) + case setAlert(Bool) + } + } + + 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 .view(.onAppear): + guard !state.isObservingUnreadPushCount else { break } + state.isObservingUnreadPushCount = true + return observeUnreadPushCountEffect() + case .view(.selectedTabChanged(let tab)): + guard tab.analyticsScreenName != nil else { break } + return trackScreenViewEffect(tab) + case .store(.setUnreadPushCount(let count)): + state.unreadPushCount = count + return updateBadgeCountEffect(count) + case .store(.setAlert(let isPresented)): + Self.setAlert(&state, isPresented: isPresented) + } + + return .none + } + } +} + +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 withCheckedThrowingContinuation { continuation in + UNUserNotificationCenter.current().setBadgeCount(count) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } + + 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() + .receive(on: DispatchQueue.main) + for try await count in publisher.values { + await send(.store(.setUnreadPushCount(count))) + } + } catch { + await send(.store(.setAlert(true))) + } + } + .cancellable(id: CancelID.unreadPushCount, cancelInFlight: true) + } + + func trackScreenViewEffect(_ tab: MainTab) -> Effect { + .run { [trackAnalyticsEventUseCase] _ in + guard let screenName = tab.analyticsScreenName else { return } + 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 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 + } +} + +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..28cedf0e 100644 --- a/Application/DevLogPresentation/Sources/Main/MainView.swift +++ b/Application/DevLogPresentation/Sources/Main/MainView.swift @@ -46,13 +46,13 @@ struct MainView: View { } } .onAppear { - coordinator.viewModel.send(.onAppear) + coordinator.store.send(.view(.onAppear)) homeViewCoordinator.bindWindowEvent(windowEvent) homeViewCoordinator.bindTodoMutationEvent() todoWindowCoordinator.bindWindowEvent(windowEvent) } .onChange(of: selectedTab, initial: true) { _, newValue in - coordinator.viewModel.send(.selectedTabChanged(newValue)) + coordinator.store.send(.view(.selectedTabChanged(newValue))) if newValue == .home { homeViewCoordinator.fetchData() } else if newValue == .today { @@ -64,12 +64,12 @@ struct MainView: View { } } .alert( - coordinator.viewModel.state.alertTitle, + coordinator.store.alertTitle, isPresented: mainAlertPresented ) { Button(String(localized: "common_close"), role: .cancel) { } } message: { - Text(coordinator.viewModel.state.alertMessage) + Text(coordinator.store.alertMessage) } .toastHost() } @@ -92,7 +92,7 @@ struct MainView: View { .tabItem { tabLabel(.notification) } - .badge(coordinator.viewModel.state.unreadPushCount) + .badge(coordinator.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(coordinator.store.unreadPushCount) .tag(tab) } else { tabLabel(tab) @@ -383,8 +383,8 @@ private extension MainView { var mainAlertPresented: Binding { Binding( - get: { coordinator.viewModel.state.showAlert }, - set: { coordinator.viewModel.send(.setAlert($0)) } + get: { coordinator.store.showAlert }, + set: { coordinator.store.send(.store(.setAlert($0))) } ) } diff --git a/Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift b/Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift index 825265f8..dd1d718d 100644 --- a/Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift @@ -6,18 +6,21 @@ // import Foundation +import ComposableArchitecture import DevLogCore import DevLogDomain @MainActor @Observable final class MainViewCoordinator { - let viewModel: MainViewModel + let store: StoreOf init(container: DIContainer) { - self.viewModel = MainViewModel( - trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self), - unreadPushCountUseCase: container.resolve(ObserveUnreadPushCountUseCase.self) - ) + self.store = Store(initialState: MainFeature.State()) { + MainFeature() + } withDependencies: { + $0.observeUnreadPushCountUseCase = container.resolve(ObserveUnreadPushCountUseCase.self) + $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) + } } } diff --git a/Application/DevLogPresentation/Tests/Main/MainFeatureTests.swift b/Application/DevLogPresentation/Tests/Main/MainFeatureTests.swift new file mode 100644 index 00000000..90bbf219 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Main/MainFeatureTests.swift @@ -0,0 +1,300 @@ +// +// 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_표시_여부와_문구를_함께_갱신한다() async { + let reference = MainStateManagementReference() + let store = makeStore() + + let presentEffects = reference.reduce(.setAlert(true)) + await store.send(.store(.setAlert(true))) { + $0.showAlert = reference.state.showAlert + $0.alertTitle = reference.state.alertTitle + $0.alertMessage = reference.state.alertMessage + } + + let dismissEffects = reference.reduce(.setAlert(false)) + await store.send(.store(.setAlert(false))) { + $0.showAlert = reference.state.showAlert + $0.alertTitle = reference.state.alertTitle + $0.alertMessage = reference.state.alertMessage + } + + #expect(presentEffects.isEmpty) + #expect(dismissEffects.isEmpty) + #expect(!reference.state.showAlert) + #expect(reference.state.alertTitle == String(localized: "common_error_title")) + #expect(reference.state.alertMessage == String(localized: "main_alert_badge_error_message")) + } + + @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(true)) + await store.send(.view(.onAppear)) { + $0.isObservingUnreadPushCount = true + } + await store.receive(.store(.setAlert(true))) { + $0.showAlert = reference.state.showAlert + $0.alertTitle = reference.state.alertTitle + $0.alertMessage = reference.state.alertMessage + } + } + + @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 unreadPushCount = 0 + var showAlert = false + var alertTitle = "" + var alertMessage = "" + } + + enum Action { + case onAppear + case selectedTabChanged(MainTab) + case setUnreadPushCount(Int) + case setAlert(Bool) + } + + 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(let isPresented): + setAlert(isPresented) + } + + return [] + } + + private func setAlert(_ isPresented: Bool) { + state.alertTitle = String(localized: "common_error_title") + state.alertMessage = String(localized: "main_alert_badge_error_message") + state.showAlert = isPresented + } +} + +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" + } + } +} From 40c9196c150ac084e3753e3021ba43c6ee59c4bd Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:26:54 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20AlertState=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Main/MainFeature.swift | 24 +++++++++--- .../Sources/Main/MainView.swift | 19 ++-------- .../Tests/Main/MainFeatureTests.swift | 38 +++++++++---------- 3 files changed, 41 insertions(+), 40 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Main/MainFeature.swift b/Application/DevLogPresentation/Sources/Main/MainFeature.swift index 1f303b43..dcd6b2f0 100644 --- a/Application/DevLogPresentation/Sources/Main/MainFeature.swift +++ b/Application/DevLogPresentation/Sources/Main/MainFeature.swift @@ -16,14 +16,13 @@ import UserNotifications struct MainFeature { @ObservableState struct State: Equatable { + @Presents var alert: AlertState? var unreadPushCount = 0 - var showAlert = false - var alertTitle = "" - var alertMessage = "" var isObservingUnreadPushCount = false } enum Action: Equatable { + case alert(PresentationAction) case view(ViewAction) case store(StoreAction) @@ -49,6 +48,8 @@ struct MainFeature { var body: some ReducerOf { Reduce { state, action in switch action { + case .alert: + break case .view(.onAppear): guard !state.isObservingUnreadPushCount else { break } state.isObservingUnreadPushCount = true @@ -65,6 +66,7 @@ struct MainFeature { return .none } + .ifLet(\.$alert, action: \.alert) } } @@ -145,9 +147,19 @@ private extension MainFeature { _ state: inout State, isPresented: Bool ) { - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "main_alert_badge_error_message") - state.showAlert = isPresented + state.alert = isPresented ? alertState() : nil + } + + 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")) + } } } diff --git a/Application/DevLogPresentation/Sources/Main/MainView.swift b/Application/DevLogPresentation/Sources/Main/MainView.swift index 28cedf0e..7b5159ad 100644 --- a/Application/DevLogPresentation/Sources/Main/MainView.swift +++ b/Application/DevLogPresentation/Sources/Main/MainView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import ComposableArchitecture import DevLogCore import DevLogDomain @@ -38,6 +39,8 @@ struct MainView: View { } var body: some View { + @Bindable var store = coordinator.store + Group { if isCompactLayout { tabView @@ -63,14 +66,7 @@ struct MainView: View { profileViewCoordinator.fetchData() } } - .alert( - coordinator.store.alertTitle, - isPresented: mainAlertPresented - ) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(coordinator.store.alertMessage) - } + .alert($store.scope(state: \.alert, action: \.alert)) .toastHost() } @@ -381,13 +377,6 @@ private extension MainView { horizontalSizeClass == .compact } - var mainAlertPresented: Binding { - Binding( - get: { coordinator.store.showAlert }, - set: { coordinator.store.send(.store(.setAlert($0))) } - ) - } - var sidebarSelection: Binding { Binding( get: { selectedTab }, diff --git a/Application/DevLogPresentation/Tests/Main/MainFeatureTests.swift b/Application/DevLogPresentation/Tests/Main/MainFeatureTests.swift index 90bbf219..05be46e9 100644 --- a/Application/DevLogPresentation/Tests/Main/MainFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Main/MainFeatureTests.swift @@ -81,29 +81,23 @@ struct MainFeatureTests { } @Test("MainFeature는 기존 Main 상태관리처럼 alert 표시 여부와 문구를 함께 갱신한다") - func MainFeature는_기존_Main_상태관리처럼_alert_표시_여부와_문구를_함께_갱신한다() async { + func MainFeature는_기존_Main_상태관리처럼_alert_state를_갱신한다() async { let reference = MainStateManagementReference() let store = makeStore() let presentEffects = reference.reduce(.setAlert(true)) await store.send(.store(.setAlert(true))) { - $0.showAlert = reference.state.showAlert - $0.alertTitle = reference.state.alertTitle - $0.alertMessage = reference.state.alertMessage + $0.alert = reference.state.alert } let dismissEffects = reference.reduce(.setAlert(false)) await store.send(.store(.setAlert(false))) { - $0.showAlert = reference.state.showAlert - $0.alertTitle = reference.state.alertTitle - $0.alertMessage = reference.state.alertMessage + $0.alert = reference.state.alert } #expect(presentEffects.isEmpty) #expect(dismissEffects.isEmpty) - #expect(!reference.state.showAlert) - #expect(reference.state.alertTitle == String(localized: "common_error_title")) - #expect(reference.state.alertMessage == String(localized: "main_alert_badge_error_message")) + #expect(reference.state.alert == nil) } @Test("MainFeature는 기존 Main 상태관리처럼 unread count 관찰 시작 실패 시 alert를 표시한다") @@ -118,9 +112,7 @@ struct MainFeatureTests { $0.isObservingUnreadPushCount = true } await store.receive(.store(.setAlert(true))) { - $0.showAlert = reference.state.showAlert - $0.alertTitle = reference.state.alertTitle - $0.alertMessage = reference.state.alertMessage + $0.alert = reference.state.alert } } @@ -178,10 +170,8 @@ private func makeStore( @MainActor private final class MainStateManagementReference { struct State: Equatable { + var alert: AlertState? var unreadPushCount = 0 - var showAlert = false - var alertTitle = "" - var alertMessage = "" } enum Action { @@ -222,9 +212,19 @@ private final class MainStateManagementReference { } private func setAlert(_ isPresented: Bool) { - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "main_alert_badge_error_message") - state.showAlert = isPresented + state.alert = isPresented ? expectedMainErrorAlert() : nil + } +} + +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")) } } From fddd14a10594561d06fe7cf9529ec78b7a3d125b Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:43:09 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20store=EC=9D=84=20Bindable=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Main/MainView.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Main/MainView.swift b/Application/DevLogPresentation/Sources/Main/MainView.swift index 7b5159ad..1dc07ba2 100644 --- a/Application/DevLogPresentation/Sources/Main/MainView.swift +++ b/Application/DevLogPresentation/Sources/Main/MainView.swift @@ -18,6 +18,7 @@ struct MainView: View { @State private var todayViewCoordinator: TodayViewCoordinator @State private var pushNotificationListViewCoordinator: PushNotificationListViewCoordinator @State private var profileViewCoordinator: ProfileViewCoordinator + @Bindable var store: StoreOf @Binding var selectedTab: MainTab private let windowEvent: TodoEditorWindowEvent @@ -26,7 +27,8 @@ struct MainView: View { windowEvent: TodoEditorWindowEvent, selectedTab: Binding ) { - self._coordinator = State(initialValue: MainViewCoordinator(container: container)) + let coordinator = MainViewCoordinator(container: container) + self._coordinator = State(initialValue: coordinator) self._todoWindowCoordinator = State(initialValue: TodoWindowCoordinator(container: container)) self._homeViewCoordinator = State(initialValue: HomeViewCoordinator(container: container)) self._todayViewCoordinator = State(initialValue: TodayViewCoordinator(container: container)) @@ -34,13 +36,12 @@ struct MainView: View { initialValue: PushNotificationListViewCoordinator(container: container) ) self._profileViewCoordinator = State(initialValue: ProfileViewCoordinator(container: container)) + self.store = coordinator.store self.windowEvent = windowEvent self._selectedTab = selectedTab } var body: some View { - @Bindable var store = coordinator.store - Group { if isCompactLayout { tabView @@ -49,13 +50,13 @@ struct MainView: View { } } .onAppear { - coordinator.store.send(.view(.onAppear)) + store.send(.view(.onAppear)) homeViewCoordinator.bindWindowEvent(windowEvent) homeViewCoordinator.bindTodoMutationEvent() todoWindowCoordinator.bindWindowEvent(windowEvent) } .onChange(of: selectedTab, initial: true) { _, newValue in - coordinator.store.send(.view(.selectedTabChanged(newValue))) + store.send(.view(.selectedTabChanged(newValue))) if newValue == .home { homeViewCoordinator.fetchData() } else if newValue == .today { @@ -88,7 +89,7 @@ struct MainView: View { .tabItem { tabLabel(.notification) } - .badge(coordinator.store.unreadPushCount) + .badge(store.unreadPushCount) .tag(MainTab.notification) profileView @@ -159,7 +160,7 @@ struct MainView: View { private func sidebarRow(_ tab: MainTab) -> some View { if tab == .notification { tabLabel(tab) - .badge(coordinator.store.unreadPushCount) + .badge(store.unreadPushCount) .tag(tab) } else { tabLabel(tab) From 4d9996ad9e5b03a47b458eff8d380c223733f442 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:38:16 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20MainViewCoordinator=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20Store=EB=A5=BC=20=EB=B7=B0=EC=97=90=20?= =?UTF-8?q?=EA=B7=80=EC=86=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Main/MainView.swift | 15 ++++++----- .../Sources/Main/MainViewCoordinator.swift | 26 ------------------- 2 files changed, 9 insertions(+), 32 deletions(-) delete mode 100644 Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift diff --git a/Application/DevLogPresentation/Sources/Main/MainView.swift b/Application/DevLogPresentation/Sources/Main/MainView.swift index 1dc07ba2..f0409b92 100644 --- a/Application/DevLogPresentation/Sources/Main/MainView.swift +++ b/Application/DevLogPresentation/Sources/Main/MainView.swift @@ -12,14 +12,13 @@ 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 - @Bindable var store: StoreOf @Binding var selectedTab: MainTab + @State private var store: StoreOf private let windowEvent: TodoEditorWindowEvent init( @@ -27,8 +26,12 @@ struct MainView: View { windowEvent: TodoEditorWindowEvent, selectedTab: Binding ) { - let coordinator = MainViewCoordinator(container: container) - self._coordinator = State(initialValue: coordinator) + 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)) @@ -36,9 +39,9 @@ struct MainView: View { initialValue: PushNotificationListViewCoordinator(container: container) ) self._profileViewCoordinator = State(initialValue: ProfileViewCoordinator(container: container)) - self.store = coordinator.store - self.windowEvent = windowEvent + self._selectedTab = selectedTab + self.windowEvent = windowEvent } var body: some View { diff --git a/Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift b/Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift deleted file mode 100644 index dd1d718d..00000000 --- a/Application/DevLogPresentation/Sources/Main/MainViewCoordinator.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// MainViewCoordinator.swift -// DevLogPresentation -// -// Created by opfic on 5/9/26. -// - -import Foundation -import ComposableArchitecture -import DevLogCore -import DevLogDomain - -@MainActor -@Observable -final class MainViewCoordinator { - let store: StoreOf - - init(container: DIContainer) { - self.store = Store(initialState: MainFeature.State()) { - MainFeature() - } withDependencies: { - $0.observeUnreadPushCountUseCase = container.resolve(ObserveUnreadPushCountUseCase.self) - $0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self) - } - } -} From 7c7203e6f5d33b3c92c9549aa150848f1c14de74 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:20:20 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94=20D?= =?UTF-8?q?ispatchQueue.main.async=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/DevLogPresentation/Sources/Main/MainFeature.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Application/DevLogPresentation/Sources/Main/MainFeature.swift b/Application/DevLogPresentation/Sources/Main/MainFeature.swift index dcd6b2f0..f3d16522 100644 --- a/Application/DevLogPresentation/Sources/Main/MainFeature.swift +++ b/Application/DevLogPresentation/Sources/Main/MainFeature.swift @@ -115,7 +115,6 @@ private extension MainFeature { .run { [observeUnreadPushCountUseCase] send in do { let publisher = try observeUnreadPushCountUseCase.observe() - .receive(on: DispatchQueue.main) for try await count in publisher.values { await send(.store(.setUnreadPushCount(count))) } From db63fe9439cf35e7ded28a6fe86bb814350040f3 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:22:05 +0900 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20MainFeature=20analytics=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogPresentation/Sources/Main/MainFeature.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Main/MainFeature.swift b/Application/DevLogPresentation/Sources/Main/MainFeature.swift index f3d16522..5fb96ef4 100644 --- a/Application/DevLogPresentation/Sources/Main/MainFeature.swift +++ b/Application/DevLogPresentation/Sources/Main/MainFeature.swift @@ -55,8 +55,8 @@ struct MainFeature { state.isObservingUnreadPushCount = true return observeUnreadPushCountEffect() case .view(.selectedTabChanged(let tab)): - guard tab.analyticsScreenName != nil else { break } - return trackScreenViewEffect(tab) + guard let screenName = tab.analyticsScreenName else { break } + return trackScreenViewEffect(screenName) case .store(.setUnreadPushCount(let count)): state.unreadPushCount = count return updateBadgeCountEffect(count) @@ -125,9 +125,8 @@ private extension MainFeature { .cancellable(id: CancelID.unreadPushCount, cancelInFlight: true) } - func trackScreenViewEffect(_ tab: MainTab) -> Effect { + func trackScreenViewEffect(_ screenName: String) -> Effect { .run { [trackAnalyticsEventUseCase] _ in - guard let screenName = tab.analyticsScreenName else { return } trackAnalyticsEventUseCase?.execute(.screenView(screenName)) } } From c1257e8a260a819f389fcb9e455884600ef37a09 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:26:56 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20badge=20count=20async=20API=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogPresentation/Sources/Main/MainFeature.swift | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Main/MainFeature.swift b/Application/DevLogPresentation/Sources/Main/MainFeature.swift index 5fb96ef4..d3f2c412 100644 --- a/Application/DevLogPresentation/Sources/Main/MainFeature.swift +++ b/Application/DevLogPresentation/Sources/Main/MainFeature.swift @@ -94,15 +94,7 @@ private enum ObserveUnreadPushCountUseCaseKey: DependencyKey { private enum SetApplicationBadgeCountKey: DependencyKey { static let liveValue: @Sendable (Int) async throws -> Void = { count in - try await withCheckedThrowingContinuation { continuation in - UNUserNotificationCenter.current().setBadgeCount(count) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - } + try await UNUserNotificationCenter.current().setBadgeCount(count) } static var testValue: @Sendable (Int) async throws -> Void { From 3ad930c8c519b68ee28a381229f8cad0c1bd9708 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:27:06 +0900 Subject: [PATCH 8/9] =?UTF-8?q?chore:=20MainViewModel=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Main/MainViewModel.swift | 150 ------------------ 1 file changed, 150 deletions(-) delete mode 100644 Application/DevLogPresentation/Sources/Main/MainViewModel.swift 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" - } - } -} From d7c8684687e749dfefc0b42b499dd5dba784b0e6 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:49:30 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20MainFeature=20alert=20=EC=95=A1?= =?UTF-8?q?=EC=85=98=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Main/MainFeature.swift | 15 +++-------- .../Tests/Main/MainFeatureTests.swift | 26 +++++++++---------- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Main/MainFeature.swift b/Application/DevLogPresentation/Sources/Main/MainFeature.swift index d3f2c412..c9e4acc0 100644 --- a/Application/DevLogPresentation/Sources/Main/MainFeature.swift +++ b/Application/DevLogPresentation/Sources/Main/MainFeature.swift @@ -33,7 +33,7 @@ struct MainFeature { enum StoreAction: Equatable { case setUnreadPushCount(Int) - case setAlert(Bool) + case setAlert } } @@ -60,8 +60,8 @@ struct MainFeature { case .store(.setUnreadPushCount(let count)): state.unreadPushCount = count return updateBadgeCountEffect(count) - case .store(.setAlert(let isPresented)): - Self.setAlert(&state, isPresented: isPresented) + case .store(.setAlert): + state.alert = Self.alertState() } return .none @@ -111,7 +111,7 @@ private extension MainFeature { await send(.store(.setUnreadPushCount(count))) } } catch { - await send(.store(.setAlert(true))) + await send(.store(.setAlert)) } } .cancellable(id: CancelID.unreadPushCount, cancelInFlight: true) @@ -133,13 +133,6 @@ private extension MainFeature { } } - static func setAlert( - _ state: inout State, - isPresented: Bool - ) { - state.alert = isPresented ? alertState() : nil - } - static func alertState() -> AlertState { AlertState { TextState(String(localized: "common_error_title")) diff --git a/Application/DevLogPresentation/Tests/Main/MainFeatureTests.swift b/Application/DevLogPresentation/Tests/Main/MainFeatureTests.swift index 05be46e9..c0201d3f 100644 --- a/Application/DevLogPresentation/Tests/Main/MainFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Main/MainFeatureTests.swift @@ -85,19 +85,17 @@ struct MainFeatureTests { let reference = MainStateManagementReference() let store = makeStore() - let presentEffects = reference.reduce(.setAlert(true)) - await store.send(.store(.setAlert(true))) { + let presentEffects = reference.reduce(.setAlert) + await store.send(.store(.setAlert)) { $0.alert = reference.state.alert } - let dismissEffects = reference.reduce(.setAlert(false)) - await store.send(.store(.setAlert(false))) { - $0.alert = reference.state.alert + await store.send(.alert(.dismiss)) { + $0.alert = nil } #expect(presentEffects.isEmpty) - #expect(dismissEffects.isEmpty) - #expect(reference.state.alert == nil) + #expect(reference.state.alert == expectedMainErrorAlert()) } @Test("MainFeature는 기존 Main 상태관리처럼 unread count 관찰 시작 실패 시 alert를 표시한다") @@ -107,11 +105,11 @@ struct MainFeatureTests { let store = makeStore(unreadPushCountUseCase: unreadPushCountUseCase) _ = reference.reduce(.onAppear) - _ = reference.reduce(.setAlert(true)) + _ = reference.reduce(.setAlert) await store.send(.view(.onAppear)) { $0.isObservingUnreadPushCount = true } - await store.receive(.store(.setAlert(true))) { + await store.receive(.store(.setAlert)) { $0.alert = reference.state.alert } } @@ -178,7 +176,7 @@ private final class MainStateManagementReference { case onAppear case selectedTabChanged(MainTab) case setUnreadPushCount(Int) - case setAlert(Bool) + case setAlert } enum Effect: Equatable { @@ -204,15 +202,15 @@ private final class MainStateManagementReference { case .setUnreadPushCount(let count): state.unreadPushCount = count return [.updateBadgeCount(count)] - case .setAlert(let isPresented): - setAlert(isPresented) + case .setAlert: + setAlert() } return [] } - private func setAlert(_ isPresented: Bool) { - state.alert = isPresented ? expectedMainErrorAlert() : nil + private func setAlert() { + state.alert = expectedMainErrorAlert() } }