diff --git a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift index ce6c4f78..e3b25b5c 100644 --- a/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift +++ b/Application/DevLogPresentation/Sources/Home/Editor/TodoEditorFeature.swift @@ -230,7 +230,7 @@ extension DependencyValues { set { self[UpsertTodoUseCaseKey.self] = newValue } } - var trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? { + var trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase { get { self[TrackAnalyticsEventUseCaseKey.self] } set { self[TrackAnalyticsEventUseCaseKey.self] = newValue } } @@ -257,7 +257,17 @@ private enum UpsertTodoUseCaseKey: DependencyKey { } private enum TrackAnalyticsEventUseCaseKey: DependencyKey { - static let liveValue: TrackAnalyticsEventUseCase? = nil + static var liveValue: TrackAnalyticsEventUseCase { + preconditionFailure("TrackAnalyticsEventUseCase must be provided.") + } + + static var testValue: TrackAnalyticsEventUseCase { + NoOpTrackAnalyticsEventUseCase() + } +} + +private struct NoOpTrackAnalyticsEventUseCase: TrackAnalyticsEventUseCase { + func execute(_ event: AnalyticsEvent) { } } private extension TodoEditorFeature { @@ -294,7 +304,7 @@ private extension TodoEditorFeature { await send(.loading(.begin(target: .default, mode: .immediate))) do { try await upsertTodoUseCase.execute(draft) - trackAnalyticsEventUseCase?.execute(.todoCreate) + trackAnalyticsEventUseCase.execute(.todoCreate) await send(.createSucceeded) } catch { await send(.saveFailed) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift index 239a0831..8a56eca5 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeFeature+Effects.swift @@ -20,7 +20,6 @@ extension HomeFeature { func observeNetworkConnectivityEffect() -> Effect { .publisher { [networkConnectivityUseCase] in networkConnectivityUseCase.observe() - .receive(on: DispatchQueue.main) .map { .store(.networkStatusChanged($0)) } } .cancellable(id: CancelID.networkConnectivity, cancelInFlight: true) @@ -74,7 +73,7 @@ extension HomeFeature { await send(.loading(.begin(target: LoadingTarget.overlay.target, mode: .delayed))) do { try await addWebPageUseCase.execute(urlString) - trackAnalyticsEventUseCase?.execute(.webPageCreate) + trackAnalyticsEventUseCase.execute(.webPageCreate) let pages = try await fetchWebPagesUseCase.execute("") await send(.store(.updateWebPages(pages.map(WebPageItem.init(from:))))) } catch { diff --git a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift index 75f8f9a9..cf60abe9 100644 --- a/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift +++ b/Application/DevLogPresentation/Sources/Home/List/TodoListFeature+Effects.swift @@ -44,7 +44,7 @@ extension TodoListFeature { todo.updatedAt = now try await upsertTodoUseCase.execute(todo) if todo.isCompleted { - trackAnalyticsEventUseCase?.execute(.todoComplete) + trackAnalyticsEventUseCase.execute(.todoComplete) } guard let todoListItem = TodoListItem(from: todo) else { await send(.store(.setAlert(true))) diff --git a/Application/DevLogPresentation/Sources/Main/MainFeature.swift b/Application/DevLogPresentation/Sources/Main/MainFeature.swift index c9e4acc0..d7d50401 100644 --- a/Application/DevLogPresentation/Sources/Main/MainFeature.swift +++ b/Application/DevLogPresentation/Sources/Main/MainFeature.swift @@ -119,7 +119,7 @@ private extension MainFeature { func trackScreenViewEffect(_ screenName: String) -> Effect { .run { [trackAnalyticsEventUseCase] _ in - trackAnalyticsEventUseCase?.execute(.screenView(screenName)) + trackAnalyticsEventUseCase.execute(.screenView(screenName)) } } diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift b/Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift index f24f6b83..7250fafe 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift @@ -195,7 +195,6 @@ private extension ProfileFeature { func observeNetworkConnectivityEffect() -> Effect { .publisher { [networkConnectivityUseCase] in networkConnectivityUseCase.observe() - .receive(on: DispatchQueue.main) .map(Action.networkStatusChanged) } .cancellable(id: CancelID.networkConnectivity, cancelInFlight: true) diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileViewModel.swift b/Application/DevLogPresentation/Sources/Profile/ProfileViewModel.swift deleted file mode 100644 index fa3b2033..00000000 --- a/Application/DevLogPresentation/Sources/Profile/ProfileViewModel.swift +++ /dev/null @@ -1,648 +0,0 @@ -// -// ProfileViewModel.swift -// DevLogPresentation -// -// Created by 최윤진 on 11/22/25. -// - -import Foundation -import Combine -import DevLogCore -import DevLogDomain - -struct ProfileAvatarImageData: Equatable { - let id: Int - let data: Data - - static func == ( - lhs: ProfileAvatarImageData, - rhs: ProfileAvatarImageData - ) -> Bool { - lhs.id == rhs.id - } -} - -@Observable -final class ProfileViewModel: StorePattern { - struct State: Equatable { - var name: String = "" - var email: String = "" - var isNetworkConnected: Bool = true - var isLoading: Bool = false - var statusMessage: String = "" - var avatarURL: URL? - var avatarImageData: ProfileAvatarImageData? - var earliestQuarterStart: Date? - var selectedQuarterStart: Date? - var showQuarterPicker: Bool = false - var selectedQuarterPickerYear = Calendar.current.component(.year, from: Date()) - var activityQuarter: HeatmapQuarter? - var dayActivitiesByDate: [Date: [HeatmapActivityItem]] = [:] - var selectedActivityKinds: Set = [.created, .completed, .deleted] - var selectedDay: HeatmapDay? - var showDoneButton: Bool = false - var showAlert: Bool = false - var alertTitle: String = "" - var alertMessage: String = "" - } - - enum Action { - case fetchData, refresh - case networkStatusChanged(Bool) - case setLoading(Bool) - case setAlert(Bool) - case tapResetStatusMessageButton - case willUpdateStatusMessage - case fetchUserData(UserProfile) - case setAvatarImageData(URL, Data) - case setActivityQuarter( - quarterStart: Date, - quarter: HeatmapQuarter, - dayActivitiesByDate: [Date: [HeatmapActivityItem]] - ) - case setQuarterPickerPresented(Bool) - case setQuarterPickerYear(Int) - case openQuarterPicker - case selectQuarter(Date) - case moveToCurrentQuarter - case moveQuarter(Int) - case toggleActivityKind(ActivityKind) - case selectDay(HeatmapDay?) - case updateStatusMessage(String) - case updateStatusTextFieldFocus(Bool) - } - - enum SideEffect { - case fetchUserData - case fetchAvatarImageData(URL) - case fetchActivityQuarter(Date) - case updateStatusMessage(String) - case updateHeatmapActivityKinds(Set) - } - - private(set) var state = State() - private let fetchUserDataUseCase: FetchUserDataUseCase - private let fetchProfileImageDataUseCase: FetchProfileImageDataUseCase - private let fetchTodosUseCase: FetchTodosUseCase - private let upsertStatusMessageUseCase: UpsertStatusMessageUseCase - private let networkConnectivityUseCase: ObserveNetworkConnectivityUseCase - private let fetchHeatmapActivityTypesUseCase: FetchHeatmapActivityTypesUseCase - private let updateHeatmapActivityTypesUseCase: UpdateHeatmapActivityTypesUseCase - private let calendar = Calendar.current - private let loadingState = LoadingState() - private var cancellables = Set() - - init( - fetchUserDataUseCase: FetchUserDataUseCase, - fetchProfileImageDataUseCase: FetchProfileImageDataUseCase, - fetchTodosUseCase: FetchTodosUseCase, - upsertStatusMessageUseCase: UpsertStatusMessageUseCase, - networkConnectivityUseCase: ObserveNetworkConnectivityUseCase, - fetchHeatmapActivityTypesUseCase: FetchHeatmapActivityTypesUseCase, - updateHeatmapActivityTypesUseCase: UpdateHeatmapActivityTypesUseCase - ) { - self.fetchUserDataUseCase = fetchUserDataUseCase - self.fetchProfileImageDataUseCase = fetchProfileImageDataUseCase - self.fetchTodosUseCase = fetchTodosUseCase - self.upsertStatusMessageUseCase = upsertStatusMessageUseCase - self.networkConnectivityUseCase = networkConnectivityUseCase - self.fetchHeatmapActivityTypesUseCase = fetchHeatmapActivityTypesUseCase - self.updateHeatmapActivityTypesUseCase = updateHeatmapActivityTypesUseCase - setupNetworkObserving() - } - - // swiftlint:disable cyclomatic_complexity - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - var effects: [SideEffect] = [] - switch action { - case .fetchData, .refresh: - if state.selectedQuarterStart == nil { - guard let quarterStart = quarterStart(for: Date()) else { break } - state.selectedQuarterStart = quarterStart - } - effects = [.fetchUserData] - let rawValues = fetchHeatmapActivityTypesUseCase.execute() - let settings = normalizeActivityKinds(rawValues) - if !settings.isEmpty { - state.selectedActivityKinds = settings - } - if let selectedQuarterStart = state.selectedQuarterStart { - effects.append(.fetchActivityQuarter(selectedQuarterStart)) - } - case .networkStatusChanged(let isConnected): - state.isNetworkConnected = isConnected - case .setLoading(let isLoading): - state.isLoading = isLoading - case .setAlert(let isPresented): - setAlert(&state, isPresented: isPresented) - case .tapResetStatusMessageButton: - state.statusMessage = "" - case .fetchUserData(let profile): - let previousAvatarURL = state.avatarURL - state.name = profile.name - state.email = profile.email - state.statusMessage = profile.statusMessage - state.avatarURL = profile.avatarURL - if previousAvatarURL != profile.avatarURL { - state.avatarImageData = nil - } - if let avatarURL = profile.avatarURL { - effects = [.fetchAvatarImageData(avatarURL)] - } - if state.earliestQuarterStart == nil { - state.earliestQuarterStart = quarterStart(for: profile.createdAt) - ?? calendar.startOfDay(for: profile.createdAt) - } - case .setAvatarImageData(let url, let data): - guard state.avatarURL == url else { break } - let id = (state.avatarImageData?.id ?? 0) + 1 - state.avatarImageData = ProfileAvatarImageData(id: id, data: data) - case .setQuarterPickerPresented(let isPresented): - state.showQuarterPicker = isPresented - case .setQuarterPickerYear(let year): - state.selectedQuarterPickerYear = year - case .openQuarterPicker: - if let selectedQuarterStart = state.selectedQuarterStart { - state.selectedQuarterPickerYear = calendar.component(.year, from: selectedQuarterStart) - } - state.showQuarterPicker = true - case .setActivityQuarter(let quarterStart, let quarter, let dayActivitiesByDate): - guard state.selectedQuarterStart == quarterStart else { break } - state.activityQuarter = quarter - state.dayActivitiesByDate = dayActivitiesByDate - case .selectDay(let day): - if let day, state.selectedDay?.date == day.date { - state.selectedDay = nil - } else { - state.selectedDay = day - } - case .selectQuarter(let quarterStart): - guard canSelectQuarter(quarterStart) else { break } - state.showQuarterPicker = false - updateSelectedQuarter(to: quarterStart, state: &state, effects: &effects) - case .moveToCurrentQuarter: - guard let currentQuarterStart = quarterStart(for: Date()), - state.selectedQuarterStart != currentQuarterStart else { break } - updateSelectedQuarter(to: currentQuarterStart, state: &state, effects: &effects) - case .moveQuarter(let delta): - guard let selectedQuarterStart = state.selectedQuarterStart else { break } - let monthDelta = 3 * delta - guard let nextQuarterStart = calendar.date( - byAdding: .month, - value: monthDelta, - to: selectedQuarterStart - ) else { break } - guard canSelectQuarter(nextQuarterStart) else { break } - updateSelectedQuarter(to: nextQuarterStart, state: &state, effects: &effects) - case .toggleActivityKind(let activityKind): - if state.selectedActivityKinds.contains(activityKind), state.selectedActivityKinds.count == 1 { - break - } - - if state.selectedActivityKinds.contains(activityKind) { - state.selectedActivityKinds.remove(activityKind) - } else { - state.selectedActivityKinds.insert(activityKind) - } - effects = [.updateHeatmapActivityKinds(state.selectedActivityKinds)] - case .willUpdateStatusMessage: - if !state.isNetworkConnected { break } - let message = self.state.statusMessage - effects = [.updateStatusMessage(message)] - case .updateStatusMessage(let message): - state.statusMessage = message - case .updateStatusTextFieldFocus(let focused): - state.showDoneButton = focused - } - if self.state != state { self.state = state } - return effects - } - // swiftlint:enable cyclomatic_complexity - - func run(_ effect: SideEffect) { - switch effect { - case .fetchUserData: - Task { - do { - let profile = try await fetchUserDataUseCase.execute() - send(.fetchUserData(profile)) - } catch { - send(.setAlert(true)) - } - } - case .fetchAvatarImageData(let url): - Task { - do { - let data = try await fetchProfileImageDataUseCase.execute(from: url) - send(.setAvatarImageData(url, data)) - } catch { } - } - case .fetchActivityQuarter(let quarterStart): - beginLoading(mode: .delayed) - Task { - do { - defer { endLoading(mode: .delayed) } - let quarterActivityData = try await fetchQuarterActivityData(from: quarterStart) - send( - .setActivityQuarter( - quarterStart: quarterStart, - quarter: quarterActivityData.quarter, - dayActivitiesByDate: quarterActivityData.dayActivitiesByDate - ) - ) - } catch { - send(.setAlert(true)) - } - } - case .updateStatusMessage(let message): - Task { - do { - try await upsertStatusMessageUseCase.execute(message) - } catch { - send(.setAlert(true)) - } - } - case .updateHeatmapActivityKinds(let activityKinds): - let rawValues = ActivityKindItem.selectableItems - .map(\.rawValue) - .filter { rawValue in - guard let activityKind = ActivityKind(rawValue: rawValue) else { - return false - } - return activityKinds.contains(activityKind) - } - updateHeatmapActivityTypesUseCase.execute(rawValues) - } - } -} - -private struct HeatmapActivityCounts { - var createdCount = 0 - var completedCount = 0 - var deletedCount = 0 - - mutating func increment(_ activityKind: ActivityKind) { - switch activityKind { - case .created: - createdCount += 1 - case .completed: - completedCount += 1 - case .deleted: - deletedCount += 1 - } - } -} - -private struct HeatmapActivityEntry { - var todo: Todo - var activityKinds: Set -} - -extension ProfileViewModel { - private func setupNetworkObserving() { - networkConnectivityUseCase.observe() - .receive(on: DispatchQueue.main) - .sink { [weak self] isConnected in - self?.send(.networkStatusChanged(isConnected)) - } - .store(in: &cancellables) - } - - var quarterTitle: String { - guard let start = state.selectedQuarterStart else { return "" } - let year = calendar.component(.year, from: start) - let month = calendar.component(.month, from: start) - let quarter = ((month - 1) / 3) + 1 - return String.localizedStringWithFormat( - String(localized: "profile_year_quarter_format"), - String(year), - String(quarter) - ) - } - - var selectedDayActivities: [HeatmapActivityItem] { - guard let selectedDay = state.selectedDay else { return [] } - let dayStart = calendar.startOfDay(for: selectedDay.date) - let activities = state.dayActivitiesByDate[dayStart] ?? [] - - return activities.filter { activity in - !Set(activity.activityKinds).isDisjoint(with: state.selectedActivityKinds) - } - } - - var canMoveToPreviousQuarter: Bool { - canMoveToQuarter(offsetMonths: -3) - } - - var canMoveToNextQuarter: Bool { - canMoveToQuarter(offsetMonths: 3) - } - - var isViewingCurrentQuarter: Bool { - guard let selectedQuarterStart = state.selectedQuarterStart, - let currentQuarterStart = quarterStart(for: Date()) else { - return false - } - return selectedQuarterStart == currentQuarterStart - } - - var availableQuarterYears: [Int] { - guard let earliestQuarterStart = state.earliestQuarterStart, - let currentQuarterStart = quarterStart(for: Date()) else { return [state.selectedQuarterPickerYear] } - let earliestYear = calendar.component(.year, from: earliestQuarterStart) - let currentYear = calendar.component(.year, from: currentQuarterStart) - return Array(stride(from: currentYear, through: earliestYear, by: -1)) - } - - func quarterStartForPicker(quarter: Int) -> Date? { - quarterStart(year: state.selectedQuarterPickerYear, quarter: quarter) - } - - func isQuarterSelectableForPicker(_ quarter: Int) -> Bool { - guard let quarterStart = quarterStartForPicker(quarter: quarter) else { return false } - return canSelectQuarter(quarterStart) - } - - func isQuarterSelectedForPicker(_ quarter: Int) -> Bool { - quarterStartForPicker(quarter: quarter) == state.selectedQuarterStart - } -} - -private extension ProfileViewModel { - func updateSelectedQuarter( - to quarterStart: Date, - state: inout State, - effects: inout [SideEffect] - ) { - guard state.selectedQuarterStart != quarterStart else { return } - state.selectedQuarterStart = quarterStart - state.activityQuarter = nil - state.dayActivitiesByDate = [:] - state.selectedDay = nil - effects = [.fetchActivityQuarter(quarterStart)] - } - - func setAlert( - _ state: inout State, - isPresented: Bool - ) { - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") - state.showAlert = isPresented - } - - func fetchQuarterActivityData( - from quarterStart: Date - ) async throws -> (quarter: HeatmapQuarter, dayActivitiesByDate: [Date: [HeatmapActivityItem]]) { - guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { - return (HeatmapQuarter(quarterStart: quarterStart, months: []), [:]) - } - - async let createdTodoPage = fetchTodosUseCase.execute( - TodoQuery( - sortDateFrom: quarterStart, - sortDateTo: nextQuarterStart, - includesDeleted: true, - sortTarget: .createdAt, - pageSize: 100, - fetchAllPages: true - ), - cursor: nil - ) - async let completedTodoPage = fetchTodosUseCase.execute( - TodoQuery( - sortDateFrom: quarterStart, - sortDateTo: nextQuarterStart, - includesDeleted: true, - sortTarget: .completedAt, - pageSize: 100, - fetchAllPages: true - ), - cursor: nil - ) - async let deletedTodoPage = fetchTodosUseCase.execute( - TodoQuery( - sortDateFrom: quarterStart, - sortDateTo: nextQuarterStart, - includesDeleted: true, - sortTarget: .deletedAt, - pageSize: 100, - fetchAllPages: true - ), - cursor: nil - ) - - let (createdTodoPageResult, completedTodoPageResult, deletedTodoPageResult) = try await ( - createdTodoPage, - completedTodoPage, - deletedTodoPage - ) - return makeQuarterActivityData( - createdTodos: createdTodoPageResult.items, - completedTodos: completedTodoPageResult.items, - deletedTodos: deletedTodoPageResult.items, - quarterStart: quarterStart - ) - } - - func canSelectQuarter(_ quarterStart: Date) -> Bool { - guard let earliestQuarterStart = state.earliestQuarterStart, - let currentQuarterStart = self.quarterStart(for: Date()) else { return false } - return earliestQuarterStart <= quarterStart && quarterStart <= currentQuarterStart - } - - func normalizeActivityKinds(_ rawValues: [String]) -> Set { - let selectableActivityKindRawValues = Set(ActivityKindItem.selectableItems.map(\.rawValue)) - - return Set( - rawValues - .compactMap(ActivityKind.init(rawValue:)) - .filter { selectableActivityKindRawValues.contains($0.rawValue) } - ) - } - - func makeActivityMonths( - dailyCountsByDate: [Date: HeatmapActivityCounts], - quarterStart: Date - ) -> [HeatmapMonth] { - let monthStarts = (0..<3).compactMap { - calendar.date(byAdding: .month, value: $0, to: quarterStart) - } - - return monthStarts.map { monthStart in - makeActivityMonth( - monthStart: monthStart, - dailyCountsByDate: dailyCountsByDate, - calendar: calendar - ) - } - } - - func makeActivityMonth( - monthStart: Date, - dailyCountsByDate: [Date: HeatmapActivityCounts], - calendar: Calendar - ) -> HeatmapMonth { - guard let monthInterval = calendar.dateInterval(of: .month, for: monthStart), - let monthLastDay = calendar.date(byAdding: .day, value: -1, to: monthInterval.end), - let firstWeekInterval = calendar.dateInterval(of: .weekOfYear, for: monthInterval.start), - let lastWeekInterval = calendar.dateInterval(of: .weekOfYear, for: monthLastDay) else { - return HeatmapMonth(monthStart: monthStart, weeks: []) - } - - var days: [HeatmapDay] = [] - var cursor = firstWeekInterval.start - while cursor < lastWeekInterval.end { - let normalizedDate = calendar.startOfDay(for: cursor) - let isInMonth = calendar.isDate(normalizedDate, equalTo: monthStart, toGranularity: .month) - let dailyCounts = dailyCountsByDate[normalizedDate] ?? HeatmapActivityCounts() - let createdCount = isInMonth ? dailyCounts.createdCount : 0 - let completedCount = isInMonth ? dailyCounts.completedCount : 0 - let deletedCount = isInMonth ? dailyCounts.deletedCount : 0 - days.append( - HeatmapDay( - date: normalizedDate, - createdCount: createdCount, - completedCount: completedCount, - deletedCount: deletedCount, - isVisible: isInMonth - ) - ) - guard let nextDay = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } - cursor = nextDay - } - - var weeks: [[HeatmapDay]] = [] - var index = 0 - while index < days.count { - let endIndex = min(index + 7, days.count) - weeks.append(Array(days[index.. Date? { - let month = calendar.component(.month, from: date) - let startMonth = ((month - 1) / 3) * 3 + 1 - var components = calendar.dateComponents([.year], from: date) - components.month = startMonth - components.day = 1 - return calendar.date(from: components) - } - - func quarterStart(year: Int, quarter: Int) -> Date? { - guard (1...4).contains(quarter) else { return nil } - var components = DateComponents() - components.year = year - components.month = ((quarter - 1) * 3) + 1 - components.day = 1 - return calendar.date(from: components) - } - - func canMoveToQuarter(offsetMonths: Int) -> Bool { - guard let selectedQuarterStart = state.selectedQuarterStart else { return false } - guard let targetQuarterStart = calendar.date( - byAdding: .month, value: offsetMonths, to: selectedQuarterStart) - else { - return false - } - return canSelectQuarter(targetQuarterStart) - } - - func makeQuarterActivityData( - createdTodos: [Todo], - completedTodos: [Todo], - deletedTodos: [Todo], - quarterStart: Date - ) -> (quarter: HeatmapQuarter, dayActivitiesByDate: [Date: [HeatmapActivityItem]]) { - var dailyCountsByDate: [Date: HeatmapActivityCounts] = [:] - var activityEntriesByDate: [Date: [String: HeatmapActivityEntry]] = [:] - - for todo in createdTodos { - appendHeatmapActivity( - todo: todo, - kind: .created, - occurredAt: todo.createdAt, - dailyCountsByDate: &dailyCountsByDate, - activityEntriesByDate: &activityEntriesByDate - ) - } - - for todo in completedTodos { - guard let completedAt = todo.completedAt else { continue } - appendHeatmapActivity( - todo: todo, - kind: .completed, - occurredAt: completedAt, - dailyCountsByDate: &dailyCountsByDate, - activityEntriesByDate: &activityEntriesByDate - ) - } - - for todo in deletedTodos { - guard let deletedAt = todo.deletedAt else { continue } - appendHeatmapActivity( - todo: todo, - kind: .deleted, - occurredAt: deletedAt, - dailyCountsByDate: &dailyCountsByDate, - activityEntriesByDate: &activityEntriesByDate - ) - } - - let quarter = HeatmapQuarter( - quarterStart: quarterStart, - months: makeActivityMonths(dailyCountsByDate: dailyCountsByDate, quarterStart: quarterStart) - ) - let dayActivitiesByDate = activityEntriesByDate.mapValues { activityEntries in - activityEntries.values.compactMap { activityEntry in - HeatmapActivityItem( - todo: activityEntry.todo, - activityKinds: orderedActivityKinds(from: activityEntry.activityKinds) - ) - } - .sorted() - } - return (quarter, dayActivitiesByDate) - } - - func appendHeatmapActivity( - todo: Todo, - kind: ActivityKind, - occurredAt: Date, - dailyCountsByDate: inout [Date: HeatmapActivityCounts], - activityEntriesByDate: inout [Date: [String: HeatmapActivityEntry]] - ) { - let dayStart = calendar.startOfDay(for: occurredAt) - var heatmapActivityCounts = dailyCountsByDate[dayStart] ?? HeatmapActivityCounts() - heatmapActivityCounts.increment(kind) - dailyCountsByDate[dayStart] = heatmapActivityCounts - - var activityEntries = activityEntriesByDate[dayStart] ?? [:] - var heatmapActivityEntry = activityEntries[todo.id] ?? HeatmapActivityEntry(todo: todo, activityKinds: []) - heatmapActivityEntry.todo = todo - heatmapActivityEntry.activityKinds.insert(kind) - activityEntries[todo.id] = heatmapActivityEntry - activityEntriesByDate[dayStart] = activityEntries - } - - func orderedActivityKinds(from activityKinds: Set) -> [ActivityKind] { - let orderedActivityKinds: [ActivityKind] = [.created, .completed, .deleted] - return orderedActivityKinds.filter { activityKinds.contains($0) } - } - - func beginLoading(mode: LoadingState.Mode) { - loadingState.begin(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } - - func endLoading(mode: LoadingState.Mode) { - loadingState.end(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } -} diff --git a/Application/DevLogPresentation/Sources/Protocol/StorePattern.swift b/Application/DevLogPresentation/Sources/Protocol/StorePattern.swift deleted file mode 100644 index 33893c29..00000000 --- a/Application/DevLogPresentation/Sources/Protocol/StorePattern.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// StorePattern.swift -// DevLogPresentation -// -// Created by 최윤진 on 11/22/25. -// - -import Foundation -import DevLogDomain - -@MainActor -public protocol StorePattern: AnyObject { - associatedtype State - associatedtype Action - associatedtype SideEffect - - var state: State { get } - func send(_ action: Action) - func reduce(with action: Action) -> [SideEffect] - func run(_ effect: SideEffect) -} - -extension StorePattern { - func send(_ action: Action) { - let sideEffects = reduce(with: action) - sideEffects.forEach(run) - } - - func reduce(with action: Action) -> [SideEffect] { - return [] - } - - func run(_ effect: SideEffect) { } -} diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewModel.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewModel.swift deleted file mode 100644 index a1604314..00000000 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewModel.swift +++ /dev/null @@ -1,431 +0,0 @@ -// -// PushNotificationListViewModel.swift -// DevLogPresentation -// -// Created by 최윤진 on 11/22/25. -// - -import Foundation -import Combine -import DevLogCore -import DevLogDomain - -@Observable -final class PushNotificationListViewModel: StorePattern { - struct State: Equatable { - var notifications: [PushNotificationItem] = [] - var showAlert: Bool = false - var alertTitle: String = "" - var alertMessage: String = "" - var isLoading: Bool = false - var hasMore: Bool = false - var nextCursor: PushNotificationCursor? - var query: PushNotificationQuery - var selectedNotificationId: String? - var selectedTodoId: TodoIdItem? - } - - enum Action { - case fetchNotifications - case loadNextPage - case deleteNotification(PushNotificationItem) - case toggleRead(PushNotificationItem) - case undoDelete - case finishDeleteToast(String) - case setAlert(isPresented: Bool) - case setLoading(Bool) - case appendNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?) - case resetPagination - case setHasMore(Bool) - case syncNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?, hasMore: Bool) - case setNotificationHidden(String, Bool) - case toggleSortOption - case setTimeFilter(PushNotificationQuery.TimeFilter) - case toggleUnreadOnly - case resetFilters - case selectNotification(String?) - } - - enum SideEffect { - case fetchNotifications(PushNotificationQuery, cursor: PushNotificationCursor?) - case delete(PushNotificationItem) - case undoDelete(String) - case toggleRead(String) - } - - private(set) var state: State - private let fetchUseCase: FetchPushNotificationsUseCase - private let deleteUseCase: DeletePushNotificationUseCase - private let undoDeleteUseCase: UndoDeletePushNotificationUseCase - private let toggleReadUseCase: TogglePushNotificationReadUseCase - private let fetchQueryUseCase: FetchPushNotificationQueryUseCase - private let updateQueryUseCase: UpdatePushNotificationQueryUseCase - private let loadingState = LoadingState() - private var undoNotificationId: String? - private var cancellable: AnyCancellable? - - init( - fetchUseCase: FetchPushNotificationsUseCase, - deleteUseCase: DeletePushNotificationUseCase, - undoDeleteUseCase: UndoDeletePushNotificationUseCase, - toggleReadUseCase: TogglePushNotificationReadUseCase, - fetchQueryUseCase: FetchPushNotificationQueryUseCase, - updateQueryUseCase: UpdatePushNotificationQueryUseCase - ) { - self.fetchUseCase = fetchUseCase - self.deleteUseCase = deleteUseCase - self.undoDeleteUseCase = undoDeleteUseCase - self.toggleReadUseCase = toggleReadUseCase - self.fetchQueryUseCase = fetchQueryUseCase - self.updateQueryUseCase = updateQueryUseCase - self.state = State( - query: fetchQueryUseCase.execute() - ) - } - - var appliedFilterCount: Int { - var count = 0 - if state.query.sortOrder != .latest { count += 1 } - if state.query.timeFilter != .none { count += 1 } - if state.query.unreadOnly { count += 1 } - return count - } - - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - var effects: [SideEffect] = [] - - switch action { - case .deleteNotification, .toggleRead, .undoDelete, .finishDeleteToast, .setAlert, .toggleSortOption, - .setTimeFilter, .toggleUnreadOnly, .resetFilters, .selectNotification: - effects = reduceByUser(action, state: &state) - - case .fetchNotifications, .loadNextPage: - effects = reduceByView(action, state: &state) - - case .setLoading, .appendNotifications, .resetPagination, .setHasMore, - .syncNotifications, .setNotificationHidden: - effects = reduceByRun(action, state: &state) - } - - if self.state != state { self.state = state } - return effects - } - - func run(_ effect: SideEffect) { - switch effect { - case .fetchNotifications(let query, let cursor): - if cursor == nil { - stopObservingNotifications() - } - beginLoading(.delayed) - Task { - do { - defer { endLoading(.delayed) } - let existingCount = cursor == nil ? 0 : self.state.notifications.count - - let page = try await fetchUseCase.execute(query, cursor: cursor) - - if cursor == nil { send(.resetPagination) } - send( - .appendNotifications( - page.items.map { PushNotificationItem(from: $0) }, - nextCursor: page.nextCursor - ) - ) - - let hasMore = page.items.count == query.pageSize && page.nextCursor != nil - send(.setHasMore(hasMore)) - startObservingNotifications( - query: query, - limit: max(query.pageSize, existingCount + page.items.count) - ) - } catch { - send(.setAlert(isPresented: true)) - } - - } - case .delete(let item): - Task { - do { - try await deleteUseCase.execute(item.id) - } catch { - send(.setNotificationHidden(item.id, false)) - send(.setAlert(isPresented: true)) - } - } - case .undoDelete(let notificationId): - Task { - do { - try await undoDeleteUseCase.execute(notificationId) - } catch { - send(.setNotificationHidden(notificationId, true)) - send(.setAlert(isPresented: true)) - } - } - case .toggleRead(let todoId): - beginLoading(.delayed) - Task { - do { - defer { endLoading(.delayed) } - try await toggleReadUseCase.execute(todoId) - } catch { - send(.setAlert(isPresented: true)) - } - } - } - } -} - -// MARK: - Reduce Methods -private extension PushNotificationListViewModel { - func reduceByUser(_ action: Action, state: inout State) -> [SideEffect] { - switch action { - case .deleteNotification(let item): - if state.notifications.contains(where: { $0.id == item.id }) { - self.undoNotificationId = item.id - setNotificationHidden(&state, notificationId: item.id, isHidden: true) - presentDeleteNotificationToast(item.id) - return [.delete(item)] - } - return [] - case .toggleRead(let item): - if let index = state.notifications.firstIndex(where: { $0.id == item.id }) { - state.notifications[index].isRead.toggle() - return [.toggleRead(item.todoId)] - } - case .undoDelete: - guard let undoNotificationId else { return [] } - setNotificationHidden(&state, notificationId: undoNotificationId, isHidden: false) - self.undoNotificationId = nil - return [.undoDelete(undoNotificationId)] - case .finishDeleteToast(let notificationId): - state.notifications.removeAll { $0.id == notificationId && $0.isHidden } - if self.undoNotificationId == notificationId { - self.undoNotificationId = nil - } - case .setAlert(let isPresented): - setAlert(&state, isPresented: isPresented) - case .toggleSortOption: - state.query.sortOrder = state.query.sortOrder == .latest ? .oldest : .latest - updateQueryUseCase.execute(state.query) - state.nextCursor = nil - return [.fetchNotifications(state.query, cursor: nil)] - case .setTimeFilter(let filter): - state.query.timeFilter = filter - updateQueryUseCase.execute(state.query) - state.nextCursor = nil - return [.fetchNotifications(state.query, cursor: nil)] - case .toggleUnreadOnly: - state.query.unreadOnly.toggle() - updateQueryUseCase.execute(state.query) - state.nextCursor = nil - return [.fetchNotifications(state.query, cursor: nil)] - case .resetFilters: - state.query = .default - updateQueryUseCase.execute(state.query) - state.nextCursor = nil - return [.fetchNotifications(state.query, cursor: nil)] - case .selectNotification(let notificationId): - state.selectedNotificationId = notificationId - guard let notificationId else { - state.selectedTodoId = nil - return [] - } - guard let index = state.notifications.firstIndex(where: { $0.id == notificationId }) else { - state.selectedTodoId = nil - return [] - } - let item = state.notifications[index] - state.selectedTodoId = TodoIdItem(id: item.todoId) - if !item.isRead { - state.notifications[index].isRead.toggle() - return [.toggleRead(item.todoId)] - } - default: - break - } - return [] - } - - func reduceByView(_ action: Action, state: inout State) -> [SideEffect] { - switch action { - case .fetchNotifications: - state.nextCursor = nil - return [.fetchNotifications(state.query, cursor: nil)] - case .loadNextPage: - guard state.hasMore, !state.isLoading else { return [] } - return [.fetchNotifications(state.query, cursor: state.nextCursor)] - default: - break - } - return [] - } - - func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { - switch action { - case .setLoading(let value): - state.isLoading = value - case .setHasMore(let value): - state.hasMore = value - case .resetPagination: - state.notifications = [] - state.nextCursor = nil - case .appendNotifications(let notifications, let nextCursor): - state.notifications.append(contentsOf: mergedHiddenNotifications( - currentNotifications: state.notifications, - incomingNotifications: notifications - )) - state.nextCursor = nextCursor - case .syncNotifications(let notifications, let nextCursor, let hasMore): - state.notifications = mergedHiddenNotifications( - currentNotifications: state.notifications, - incomingNotifications: notifications - ) - state.nextCursor = nextCursor - state.hasMore = hasMore - case .setNotificationHidden(let notificationId, let isHidden): - setNotificationHidden(&state, notificationId: notificationId, isHidden: isHidden) - default: - break - } - return [] - } -} - -private extension PushNotificationListViewModel { - func setAlert( - _ state: inout State, - isPresented: Bool - ) { - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") - state.showAlert = isPresented - } - - func presentDeleteNotificationToast(_ notificationId: String) { - ToastPresenter.present( - message: String(localized: "common_undo"), - systemImage: "arrow.uturn.left", - duration: 5, - font: .caption, - multilineTextAlignment: .center, - lineLimit: 3, - action: { [weak self] in - self?.send(.undoDelete) - }, - onDismiss: { [weak self] in - self?.send(.finishDeleteToast(notificationId)) - } - ) - } - - func setNotificationHidden( - _ state: inout State, - notificationId: String, - isHidden: Bool - ) { - if let notificationIndex = state.notifications.firstIndex(where: { - $0.id == notificationId - }) { - state.notifications[notificationIndex].isHidden = isHidden - } - } - - func mergedHiddenNotifications( - currentNotifications: [PushNotificationItem], - incomingNotifications: [PushNotificationItem] - ) -> [PushNotificationItem] { - let hiddenNotificationIds = Set(currentNotifications - .filter(\.isHidden) - .map(\.id) - ) - - return incomingNotifications.map { incomingNotification in - guard hiddenNotificationIds.contains(incomingNotification.id) else { - return incomingNotification - } - - var hiddenNotification = incomingNotification - hiddenNotification.isHidden = true - return hiddenNotification - } - } - - func startObservingNotifications( - query: PushNotificationQuery, - limit: Int - ) { - cancellable = try? fetchUseCase.observe(query, limit: limit) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [weak self] completion in - guard let self else { return } - if case .failure = completion { - self.send(.setAlert(isPresented: true)) - } - }, - receiveValue: { [weak self] page in - guard let self else { return } - let items = page.items.map { PushNotificationItem(from: $0) } - let hasMore = items.count == max(query.pageSize, limit) && page.nextCursor != nil - self.send(.syncNotifications(items, nextCursor: page.nextCursor, hasMore: hasMore)) - } - ) - } - - func stopObservingNotifications() { - cancellable?.cancel() - cancellable = nil - } - - private func beginLoading(_ mode: LoadingState.Mode) { - loadingState.begin(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } - - private func endLoading(_ mode: LoadingState.Mode) { - loadingState.end(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } -} - -extension PushNotificationQuery.SortOrder { - var title: String { - switch self { - case .latest: return String(localized: "push_sort_latest") - case .oldest: return String(localized: "push_sort_oldest") - } - } -} - -extension PushNotificationQuery.TimeFilter { - var title: String { - switch self { - case .none: - return String(localized: "push_timefilter_all") - case .hours(let value): - return String.localizedStringWithFormat( - String(localized: "push_timefilter_hours_format"), - Int64(value) - ) - case .days(let value): - return String.localizedStringWithFormat( - String(localized: "push_timefilter_days_format"), - Int64(value) - ) - } - } - - static var availableOptions: [PushNotificationQuery.TimeFilter] {[ - .none, - .hours(1), - .hours(6), - .hours(24), - .days(3), - .days(7) - ] - } -} diff --git a/Application/DevLogPresentation/Sources/Root/RootFeature.swift b/Application/DevLogPresentation/Sources/Root/RootFeature.swift new file mode 100644 index 00000000..861530c1 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Root/RootFeature.swift @@ -0,0 +1,201 @@ +// +// RootFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/17/26. +// + +import Combine +import ComposableArchitecture +import DevLogCore +import DevLogDomain +import Foundation + +@Reducer +struct RootFeature { + private enum CancelID: Hashable { + case networkConnectivity + case session + case theme + } + + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + @Presents var sheet: SheetState? + var isNetworkConnected = true + var signIn: Bool? + var theme: SystemTheme = .automatic + var selectedMainTab = MainTab.home + var isObservingNetworkConnectivity = false + var isObservingSession = false + var isObservingTheme = false + } + + @ObservableState + struct SheetState: Equatable, Identifiable { + let todoId: String + var id: String { todoId } + } + + enum Action: BindableAction, Equatable { + case alert(PresentationAction) + case binding(BindingAction) + case sheet(PresentationAction) + case onAppear + case presentTodoDetail(String) + case openWidgetRoute(MainTab) + case networkStatusChanged(Bool) + case setTheme(SystemTheme) + case didLogined(Bool) + + enum Sheet: Equatable { + case tapCloseButton + } + } + + @Dependency(\.observeAuthSessionUseCase) var observeAuthSessionUseCase + @Dependency(\.networkConnectivityUseCase) var networkConnectivityUseCase + @Dependency(\.systemThemeUseCase) var systemThemeUseCase + @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase + @Dependency(\.setApplicationBadgeCount) var setApplicationBadgeCount + + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .alert: + break + case .binding: + break + case .sheet(.dismiss), .sheet(.presented(.tapCloseButton)): + state.sheet = nil + case .sheet: + break + case .onAppear: + var effect = clearApplicationBadgeCountEffect() + + if !state.isObservingNetworkConnectivity { + state.isObservingNetworkConnectivity = true + effect = .merge(effect, observeNetworkConnectivityEffect()) + } + + if !state.isObservingSession { + state.isObservingSession = true + effect = .merge(effect, observeSessionEffect()) + } + + if !state.isObservingTheme { + state.isObservingTheme = true + effect = .merge(effect, observeThemeEffect()) + } + + return effect + case .presentTodoDetail(let todoId): + state.sheet = .init(todoId: todoId) + case .openWidgetRoute(let mainTab): + guard state.signIn == true else { break } + state.selectedMainTab = mainTab + case .networkStatusChanged(let isConnected): + let wasConnected = state.isNetworkConnected + state.isNetworkConnected = isConnected + if wasConnected && !isConnected { + state.alert = Self.alertState() + } + case .setTheme(let theme): + state.theme = theme + case .didLogined(let result): + state.signIn = result + if result { + state.selectedMainTab = .home + } else { + return trackLoginScreenEffect() + } + } + + return .none + } + .ifLet(\.$alert, action: \.alert) + .ifLet(\.$sheet, action: \.sheet) { + RootSheetFeature() + } + } +} + +private struct RootSheetFeature: Reducer { + typealias State = RootFeature.SheetState + typealias Action = RootFeature.Action.Sheet + + var body: some ReducerOf { + EmptyReducer() + } +} + +extension DependencyValues { + var observeAuthSessionUseCase: ObserveAuthSessionUseCase { + get { self[ObserveAuthSessionUseCaseKey.self] } + set { self[ObserveAuthSessionUseCaseKey.self] = newValue } + } +} + +private enum ObserveAuthSessionUseCaseKey: DependencyKey { + static var liveValue: ObserveAuthSessionUseCase { + preconditionFailure("ObserveAuthSessionUseCase must be provided.") + } + + static var testValue: ObserveAuthSessionUseCase { + liveValue + } +} + +private extension RootFeature { + func clearApplicationBadgeCountEffect() -> Effect { + .run { [setApplicationBadgeCount] _ in + try? await setApplicationBadgeCount(0) + } + } + + func observeNetworkConnectivityEffect() -> Effect { + .publisher { [networkConnectivityUseCase] in + networkConnectivityUseCase.observe() + .map(Action.networkStatusChanged) + } + .cancellable(id: CancelID.networkConnectivity, cancelInFlight: true) + } + + func observeSessionEffect() -> Effect { + .publisher { [observeAuthSessionUseCase] in + observeAuthSessionUseCase.observe() + .removeDuplicates() + .map(Action.didLogined) + } + .cancellable(id: CancelID.session, cancelInFlight: true) + } + + func observeThemeEffect() -> Effect { + .publisher { [systemThemeUseCase] in + systemThemeUseCase.observe() + .removeDuplicates() + .map(Action.setTheme) + } + .cancellable(id: CancelID.theme, cancelInFlight: true) + } + + func trackLoginScreenEffect() -> Effect { + .run { [trackAnalyticsEventUseCase] _ in + trackAnalyticsEventUseCase.execute(.screenView("login")) + } + } + + static func alertState() -> AlertState { + AlertState { + TextState(String(localized: "root_network_disconnected_title")) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(String(localized: "root_network_disconnected_message")) + } + } +} diff --git a/Application/DevLogPresentation/Sources/Root/RootView.swift b/Application/DevLogPresentation/Sources/Root/RootView.swift index 9175d1b7..bda9c16a 100644 --- a/Application/DevLogPresentation/Sources/Root/RootView.swift +++ b/Application/DevLogPresentation/Sources/Root/RootView.swift @@ -13,9 +13,7 @@ import DevLogDomain public struct RootView: View { @Environment(\.diContainer) var container: DIContainer - @State var viewModel: RootViewModel - @State private var selectedRoute: Route? - @State private var selectedMainTab = MainTab.home + @State private var store: StoreOf private let widgetURLTab: (URL) -> MainTab? private let windowEvent: TodoEditorWindowEvent private let pushNotificationTodoIdPublisher: AnyPublisher @@ -31,12 +29,14 @@ public struct RootView: View { pushNotificationTodoIdPublisher: AnyPublisher, clearPushNotificationRoute: @escaping () -> Void ) { - self._viewModel = State(initialValue: RootViewModel( - sessionUseCase: sessionUseCase, - networkConnectivityUseCase: networkConnectivityUseCase, - systemThemeUseCase: systemThemeUseCase, - trackAnalyticsEventUseCase: trackAnalyticsEventUseCase - )) + self._store = State(initialValue: Store(initialState: RootFeature.State()) { + RootFeature() + } withDependencies: { + $0.observeAuthSessionUseCase = sessionUseCase + $0.networkConnectivityUseCase = networkConnectivityUseCase + $0.systemThemeUseCase = systemThemeUseCase + $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + }) self.widgetURLTab = widgetURLTab self.windowEvent = windowEvent self.pushNotificationTodoIdPublisher = pushNotificationTodoIdPublisher @@ -46,81 +46,56 @@ public struct RootView: View { public var body: some View { ZStack { Color(UIColor.systemGroupedBackground).ignoresSafeArea() - if let signIn = viewModel.state.signIn { + if let signIn = store.signIn { if signIn { MainView( container: container, windowEvent: windowEvent, - selectedTab: $selectedMainTab + selectedTab: $store.selectedMainTab ) } else { LoginView(signInUseCase: container.resolve(SignInUseCase.self)) } } } - .preferredColorScheme(viewModel.state.theme.colorScheme) - .onAppear { viewModel.send(.onAppear) } - .onChange(of: viewModel.state.signIn) { _, value in - guard let value else { return } - if value { - selectedMainTab = .home - } - } + .preferredColorScheme(store.theme.colorScheme) + .onAppear { store.send(.onAppear) } .onOpenURL { url in guard let mainTab = widgetURLTab(url) else { return } - switch viewModel.state.signIn { - case .some(false): - break - case .some(true): - selectedMainTab = mainTab - case .none: - break - } + store.send(.openWidgetRoute(mainTab)) } - .alert(viewModel.state.alertTitle, isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert($0)) } - )) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(viewModel.state.alertMessage) - } - .sheet(item: $selectedRoute) { route in - switch route { - case .todoDetail(let todoId): - NavigationStack { - TodoDetailView(store: Store( - initialState: TodoDetailFeature.State(todoId: todoId, showEditButton: false) - ) { - TodoDetailFeature() - } withDependencies: { - $0.fetchTodoByIdUseCase = container.resolve(FetchTodoByIdUseCase.self) - $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) - }) - .toolbar { - ToolbarLeadingButton { - selectedRoute = nil - } - } - } - .background(Color(.systemGroupedBackground)) - .presentationDragIndicator(.visible) + .alert($store.scope(state: \.alert, action: \.alert)) + .sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { sheetStore in + sheetContent(todoId: sheetStore.todoId) { + sheetStore.send(.tapCloseButton) } } .onReceive(pushNotificationTodoIdPublisher) { todoId in - selectedRoute = .todoDetail(todoId) + store.send(.presentTodoDetail(todoId)) clearPushNotificationRoute() } } -} - -private enum Route: Equatable, Identifiable { - case todoDetail(String) - var id: String { - switch self { - case .todoDetail(let todoId): - return "todo:\(todoId)" + private func sheetContent( + todoId: String, + onClose: @escaping () -> Void + ) -> some View { + NavigationStack { + TodoDetailView(store: Store( + initialState: TodoDetailFeature.State(todoId: todoId, showEditButton: false) + ) { + TodoDetailFeature() + } withDependencies: { + $0.fetchTodoByIdUseCase = container.resolve(FetchTodoByIdUseCase.self) + $0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self) + }) + .toolbar { + ToolbarLeadingButton { + onClose() + } + } } + .background(Color(.systemGroupedBackground)) + .presentationDragIndicator(.visible) } } diff --git a/Application/DevLogPresentation/Sources/Root/RootViewModel.swift b/Application/DevLogPresentation/Sources/Root/RootViewModel.swift deleted file mode 100644 index 405f395e..00000000 --- a/Application/DevLogPresentation/Sources/Root/RootViewModel.swift +++ /dev/null @@ -1,161 +0,0 @@ -// -// RootViewModel.swift -// DevLogPresentation -// -// Created by AI on 2/12/26. -// - -import Foundation -import Combine -import UserNotifications -import DevLogCore -import DevLogDomain - -@Observable -final class RootViewModel: StorePattern { - struct State: Equatable { - var showAlert: Bool = false - var alertTitle: String = "" - var alertMessage: String = "" - var isNetworkConnected: Bool = true - var signIn: Bool? - var theme: SystemTheme = .automatic - } - - enum Action { - case onAppear - case setAlert(Bool) - case networkStatusChanged(Bool) - case setTheme(SystemTheme) - case didLogined(Bool) - } - - enum SideEffect { - case clearApplicationBadgeCount - case observeNetworkConnectivity - case observeSession - case observeTheme - case trackLoginScreen - } - - private(set) var state: State - private var cancellables = Set() - private var isObservingNetworkConnectivity = false - private var isObservingSession = false - private var isObservingTheme = false - private let sessionUseCase: ObserveAuthSessionUseCase - private let networkConnectivityUseCase: ObserveNetworkConnectivityUseCase - private let systemThemeUseCase: ObserveSystemThemeUseCase - private let trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase - - init( - sessionUseCase: ObserveAuthSessionUseCase, - networkConnectivityUseCase: ObserveNetworkConnectivityUseCase, - systemThemeUseCase: ObserveSystemThemeUseCase, - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase - ) { - self.sessionUseCase = sessionUseCase - self.networkConnectivityUseCase = networkConnectivityUseCase - self.systemThemeUseCase = systemThemeUseCase - self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase - self.state = State() - } - - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - var effects: [SideEffect] = [] - - switch action { - case .onAppear: - effects = [ - .clearApplicationBadgeCount, - .observeNetworkConnectivity, - .observeSession, - .observeTheme - ] - case .setAlert(let isPresented): - setAlert(&state, isPresented: isPresented) - case .networkStatusChanged(let isConnected): - let wasConnected = state.isNetworkConnected - state.isNetworkConnected = isConnected - if wasConnected && !isConnected { - setAlert(&state, isPresented: true) - } - case .setTheme(let theme): - state.theme = theme - case .didLogined(let result): - state.signIn = result - if !result { - effects = [.trackLoginScreen] - } - } - - if self.state != state { self.state = state } - return effects - } - - func run(_ effect: SideEffect) { - switch effect { - case .clearApplicationBadgeCount: - UNUserNotificationCenter.current().setBadgeCount(0) { _ in } - case .observeNetworkConnectivity: - setupNetworkObserving() - case .observeSession: - setupSessionObserving() - case .observeTheme: - setupThemeObserving() - case .trackLoginScreen: - trackAnalyticsEventUseCase.execute(.screenView("login")) - } - } -} - -// MARK: - Helper Methods -private extension RootViewModel { - func setAlert( - _ state: inout State, - isPresented: Bool - ) { - state.alertTitle = String(localized: "root_network_disconnected_title") - state.alertMessage = String(localized: "root_network_disconnected_message") - state.showAlert = isPresented - } - - func setupNetworkObserving() { - guard !isObservingNetworkConnectivity else { return } - isObservingNetworkConnectivity = true - - networkConnectivityUseCase.observe() - .receive(on: DispatchQueue.main) - .sink { [weak self] isConnected in - self?.send(.networkStatusChanged(isConnected)) - } - .store(in: &cancellables) - } - - func setupSessionObserving() { - guard !isObservingSession else { return } - isObservingSession = true - - sessionUseCase.observe() - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] signIn in - self?.send(.didLogined(signIn)) - } - .store(in: &cancellables) - } - - func setupThemeObserving() { - guard !isObservingTheme else { return } - isObservingTheme = true - - systemThemeUseCase.observe() - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] theme in - self?.send(.setTheme(theme)) - } - .store(in: &cancellables) - } -} diff --git a/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift b/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift index 49c15ecc..bf46c4db 100644 --- a/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/SettingsFeature.swift @@ -234,7 +234,6 @@ private extension SettingsFeature { func observeNetworkConnectivityEffect() -> Effect { .publisher { [networkConnectivityUseCase] in networkConnectivityUseCase.observe() - .receive(on: DispatchQueue.main) .map(Action.networkStatusChanged) } .cancellable(id: CancelID.networkConnectivity, cancelInFlight: true) @@ -244,7 +243,6 @@ private extension SettingsFeature { .publisher { [systemThemeUseCase] in systemThemeUseCase.observe() .removeDuplicates() - .receive(on: DispatchQueue.main) .map { .binding(.set(\.theme, $0)) } } .cancellable(id: CancelID.systemTheme, cancelInFlight: true) diff --git a/Application/DevLogPresentation/Sources/Structure/Profile/ProfileAvatarImageData.swift b/Application/DevLogPresentation/Sources/Structure/Profile/ProfileAvatarImageData.swift new file mode 100644 index 00000000..d05834d2 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Structure/Profile/ProfileAvatarImageData.swift @@ -0,0 +1,17 @@ +// +// ProfileAvatarImageData.swift +// DevLogPresentation +// +// Created by opfic on 6/17/26. +// + +import Foundation + +struct ProfileAvatarImageData: Equatable { + let id: Int + let data: Data + + static func == (lhs: ProfileAvatarImageData, rhs: ProfileAvatarImageData) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Application/DevLogPresentation/Sources/Structure/PushNotificationQuery+Presentation.swift b/Application/DevLogPresentation/Sources/Structure/PushNotificationQuery+Presentation.swift new file mode 100644 index 00000000..f98fa2fb --- /dev/null +++ b/Application/DevLogPresentation/Sources/Structure/PushNotificationQuery+Presentation.swift @@ -0,0 +1,49 @@ +// +// PushNotificationQuery+Presentation.swift +// DevLogPresentation +// +// Created by opfic on 6/17/26. +// + +import DevLogCore +import Foundation + +extension PushNotificationQuery.SortOrder { + var title: String { + switch self { + case .latest: + return String(localized: "push_sort_latest") + case .oldest: + return String(localized: "push_sort_oldest") + } + } +} + +extension PushNotificationQuery.TimeFilter { + var title: String { + switch self { + case .none: + return String(localized: "push_timefilter_all") + case .hours(let value): + return String.localizedStringWithFormat( + String(localized: "push_timefilter_hours_format"), + Int64(value) + ) + case .days(let value): + return String.localizedStringWithFormat( + String(localized: "push_timefilter_days_format"), + Int64(value) + ) + } + } + + static var availableOptions: [PushNotificationQuery.TimeFilter] { [ + .none, + .hours(1), + .hours(6), + .hours(12), + .days(1), + .days(7), + .days(30) + ] } +} diff --git a/Application/DevLogPresentation/Sources/Today/TodayFeature.swift b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift index 01ddda06..3c804c05 100644 --- a/Application/DevLogPresentation/Sources/Today/TodayFeature.swift +++ b/Application/DevLogPresentation/Sources/Today/TodayFeature.swift @@ -308,7 +308,7 @@ private extension TodayFeature { todo.completedAt = now todo.updatedAt = now try await upsertTodoUseCase.execute(todo) - trackAnalyticsEventUseCase?.execute(.todoComplete) + trackAnalyticsEventUseCase.execute(.todoComplete) await send(.store(.removeTodo(todo.id))) await send(.loading(.end(target: .default, mode: .delayed))) } catch { diff --git a/Application/DevLogPresentation/Sources/Today/TodayViewModel.swift b/Application/DevLogPresentation/Sources/Today/TodayViewModel.swift deleted file mode 100644 index 6aeb8572..00000000 --- a/Application/DevLogPresentation/Sources/Today/TodayViewModel.swift +++ /dev/null @@ -1,432 +0,0 @@ -// -// TodayViewModel.swift -// DevLogPresentation -// -// Created by opfic on 3/6/26. -// - -import Foundation -import DevLogCore -import DevLogDomain - -@Observable -final class TodayViewModel: StorePattern { - // TodayView 상단에서 사용자가 선택하는 요약 탭 범위. - enum SectionScope: Hashable, CaseIterable { - case all - case focused - case overdue - case dueSoon - } - - // 요약 탭 아래 실제 리스트에 렌더링되는 섹션 분류. - enum SectionCategory: Hashable { - case later - case unscheduled - case focused - case overdue - case dueSoon - } - - struct SectionContent: Identifiable, Equatable { - var id: SectionCategory { category } - let category: SectionCategory - let title: String - let items: [TodayTodoItem] - } - - struct SectionCollection { - var focused: [TodayTodoItem] = [] - var overdue: [TodayTodoItem] = [] - var dueSoon: [TodayTodoItem] = [] - var later: [TodayTodoItem] = [] - var unscheduled: [TodayTodoItem] = [] - } - - struct State: Equatable { - var todos: [TodayTodoItem] = [] - var isLoading: Bool = false - var showAlert: Bool = false - var alertTitle: String = "" - var alertMessage: String = "" - var selectedSectionScope: SectionScope = .all - var displayOptions: TodayDisplayOptions = .default - } - - enum Action { - case refresh - case setAlert(Bool) - case setSectionScope(SectionScope) - case setDueDateVisibility(TodayDisplayOptions.DueDateVisibility) - case setFocusVisibility(TodayDisplayOptions.FocusVisibility) - case resetDisplayOptions - case completeTodo(TodayTodoItem) - case togglePinned(TodayTodoItem) - case fetchData - case fetchTodos([TodayTodoItem]) - case setLoading(Bool) - case updateTodo(TodayTodoItem) - case removeTodo(String) - } - - enum SideEffect { - case fetchTodos - case completeTodo(TodayTodoItem) - case togglePinned(TodayTodoItem) - } - - private(set) var state = State() - private let calendar = Calendar.current - private let pageSize = 20 - private let upcomingWindowDays = 7 - private let fetchTodosUseCase: FetchTodosUseCase - private let fetchTodoByIdUseCase: FetchTodoByIdUseCase - private let upsertTodoUseCase: UpsertTodoUseCase - private let updateTodayDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase - private let trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase - private let loadingState = LoadingState() - - init( - fetchTodosUseCase: FetchTodosUseCase, - fetchTodoByIdUseCase: FetchTodoByIdUseCase, - upsertTodoUseCase: UpsertTodoUseCase, - fetchTodayDisplayOptionsUseCase: FetchTodayDisplayOptionsUseCase, - updateTodayDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase, - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase - ) { - self.fetchTodosUseCase = fetchTodosUseCase - self.fetchTodoByIdUseCase = fetchTodoByIdUseCase - self.upsertTodoUseCase = upsertTodoUseCase - self.updateTodayDisplayOptionsUseCase = updateTodayDisplayOptionsUseCase - self.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase - self.state.displayOptions = fetchTodayDisplayOptionsUseCase.execute() - } - - var sections: [SectionContent] { - let items = groupedSectionItems(from: displayedTodos) - - switch state.selectedSectionScope { - case .all: - return - makeSection( - category: .focused, - title: String(localized: "today_section_focused"), - items: items.focused - ) - + makeSection( - category: .overdue, - title: String(localized: "today_section_overdue"), - items: items.overdue - ) - + makeSection( - category: .dueSoon, - title: String.localizedStringWithFormat( - String(localized: "today_section_due_soon_format"), - Int64(upcomingWindowDays) - ), - items: items.dueSoon - ) - + makeSection( - category: .later, - title: String(localized: "today_section_later"), - items: items.later - ) - + makeSection( - category: .unscheduled, - title: String(localized: "today_section_unscheduled"), - items: items.unscheduled - ) - case .focused: - return makeSection( - category: .focused, - title: String(localized: "today_section_focused"), - items: items.focused - ) - case .overdue: - return makeSection( - category: .overdue, - title: String(localized: "today_section_overdue"), - items: items.overdue - ) - case .dueSoon: - return makeSection( - category: .dueSoon, - title: String.localizedStringWithFormat( - String(localized: "today_section_due_soon_format"), - Int64(upcomingWindowDays) - ), - items: items.dueSoon - ) - } - } - - func summaryValue(for scope: SectionScope) -> Int { - switch scope { - case .all: - return displayedTodos.count - case .focused: - return displayedTodos.filter(\.isPinned).count - case .overdue: - return displayedTodos.filter(isOverdue).count - case .dueSoon: - return displayedTodos.filter(isDueSoon).count - } - } - - func reduce(with action: Action) -> [SideEffect] { - var state = self.state - var effects: [SideEffect] = [] - - switch action { - case .refresh, .setAlert, .setSectionScope, .setDueDateVisibility, .setFocusVisibility, - .resetDisplayOptions, .completeTodo, .togglePinned: - effects = reduceByUser(action, state: &state) - case .fetchData: - effects = reduceByView(action, state: &state) - case .fetchTodos, .setLoading, .updateTodo, .removeTodo: - effects = reduceByRun(action, state: &state) - } - - if self.state != state { self.state = state } - return effects - } - - func run(_ effect: SideEffect) { - switch effect { - case .fetchTodos: - beginLoading(.delayed) - Task { - do { - defer { endLoading(.delayed) } - async let todosWithDueDatePage = fetchTodosUseCase.execute( - TodoQuery( - completionFilter: .incomplete, - dueDateFilter: .withDueDate, - sortTarget: .dueDate, - sortOrder: .oldest, - pageSize: pageSize, - fetchAllPages: true - ), - cursor: nil - ) - async let todosWithoutDueDatePage = fetchTodosUseCase.execute( - TodoQuery( - completionFilter: .incomplete, - dueDateFilter: .withoutDueDate, - sortTarget: .updatedAt, - sortOrder: .latest, - pageSize: pageSize, - fetchAllPages: true - ), - cursor: nil - ) - let todosWithDueDate = try await todosWithDueDatePage.items.compactMap { TodayTodoItem(from: $0) } - let todosWithoutDueDate = try await todosWithoutDueDatePage.items.compactMap { TodayTodoItem(from: $0) } - send(.fetchTodos(todosWithDueDate + todosWithoutDueDate)) - } catch { - send(.setAlert(true)) - } - } - case .completeTodo(let item): - beginLoading(.delayed) - Task { - do { - defer { endLoading(.delayed) } - var todo = try await fetchTodoByIdUseCase.execute(item.id) - let now = Date() - todo.isCompleted = true - todo.completedAt = now - todo.updatedAt = now - try await upsertTodoUseCase.execute(todo) - trackAnalyticsEventUseCase.execute(.todoComplete) - send(.removeTodo(todo.id)) - } catch { - send(.setAlert(true)) - } - } - case .togglePinned(let item): - beginLoading(.delayed) - Task { - do { - defer { endLoading(.delayed) } - var todo = try await fetchTodoByIdUseCase.execute(item.id) - todo.isPinned.toggle() - todo.updatedAt = Date() - try await upsertTodoUseCase.execute(todo) - guard let todayTodoItem = TodayTodoItem(from: todo) else { - send(.setAlert(true)) - return - } - send(.updateTodo(todayTodoItem)) - } catch { - send(.setAlert(true)) - } - } - } - } -} - -private extension TodayViewModel { - func makeSection( - category: SectionCategory, - title: String, - items: [TodayTodoItem] - ) -> [SectionContent] { - guard !items.isEmpty else { return [] } - return [SectionContent(category: category, title: title, items: items)] - } - - func reduceByUser(_ action: Action, state: inout State) -> [SideEffect] { - switch action { - case .refresh: - return [.fetchTodos] - case .setAlert(let isPresented): - setAlert(&state, isPresented: isPresented) - case .setSectionScope(let scope): - if state.selectedSectionScope == scope, scope != .all { - state.selectedSectionScope = .all - } else { - state.selectedSectionScope = scope - } - case .setDueDateVisibility(let visibility): - state.displayOptions.dueDateVisibility = visibility - updateTodayDisplayOptionsUseCase.execute(state.displayOptions) - case .setFocusVisibility(let visibility): - state.displayOptions.focusVisibility = visibility - updateTodayDisplayOptionsUseCase.execute(state.displayOptions) - case .resetDisplayOptions: - state.displayOptions = .default - updateTodayDisplayOptionsUseCase.execute(state.displayOptions) - case .completeTodo(let item): - return [.completeTodo(item)] - case .togglePinned(let item): - return [.togglePinned(item)] - default: - break - } - return [] - } - - func reduceByView(_ action: Action, state: inout State) -> [SideEffect] { - switch action { - case .fetchData: - return [.fetchTodos] - default: - break - } - return [] - } - - func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { - switch action { - case .fetchTodos(let items): - state.todos = items - case .setLoading(let isLoading): - state.isLoading = isLoading - case .updateTodo(let item): - if let index = state.todos.firstIndex(where: { $0.id == item.id }) { - state.todos[index] = item - } else { - state.todos.append(item) - } - case .removeTodo(let todoId): - state.todos.removeAll { $0.id == todoId } - default: - break - } - return [] - } - - func setAlert( - _ state: inout State, - isPresented: Bool - ) { - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") - state.showAlert = isPresented - } - - var displayedTodos: [TodayTodoItem] { - let dueDateFilteredTodos: [TodayTodoItem] - switch state.displayOptions.dueDateVisibility { - case .all: - dueDateFilteredTodos = state.todos - case .withDueDateOnly: - dueDateFilteredTodos = state.todos.filter { $0.dueDate != nil } - case .withoutDueDateOnly: - dueDateFilteredTodos = state.todos.filter { $0.dueDate == nil } - } - - switch state.displayOptions.focusVisibility { - case .all: - return dueDateFilteredTodos - case .focusedOnly: - return dueDateFilteredTodos.filter(\.isPinned) - } - } - - func groupedSectionItems( - from items: [TodayTodoItem] - ) -> SectionCollection { - let startOfToday = calendar.startOfDay(for: Date()) - guard let windowEnd = calendar.date(byAdding: .day, value: upcomingWindowDays, to: startOfToday) else { - return SectionCollection( - focused: items.filter(\.isPinned), - unscheduled: items.filter { !$0.isPinned && $0.dueDate == nil } - ) - } - - var collection = SectionCollection() - - for item in items { - if item.isPinned { - collection.focused.append(item) - continue - } - - guard let dueDate = item.dueDate else { - collection.unscheduled.append(item) - continue - } - - let dueDay = calendar.startOfDay(for: dueDate) - if dueDay < startOfToday { - collection.overdue.append(item) - } else if dueDay <= windowEnd { - collection.dueSoon.append(item) - } else { - collection.later.append(item) - } - } - - return collection - } - - func isOverdue(_ item: TodayTodoItem) -> Bool { - guard let dueDate = item.dueDate else { return false } - return calendar.startOfDay(for: dueDate) < calendar.startOfDay(for: Date()) - } - - private func beginLoading(_ mode: LoadingState.Mode) { - loadingState.begin(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } - - private func endLoading(_ mode: LoadingState.Mode) { - loadingState.end(mode: mode) { [weak self] isLoading in - self?.send(.setLoading(isLoading)) - } - } - - func isDueSoon(_ item: TodayTodoItem) -> Bool { - guard let dueDate = item.dueDate else { return false } - let startOfToday = calendar.startOfDay(for: Date()) - guard let windowEnd = calendar.date(byAdding: .day, value: upcomingWindowDays, to: startOfToday) else { - return false - } - let dueDay = calendar.startOfDay(for: dueDate) - return startOfToday <= dueDay && dueDay <= windowEnd - } - -} diff --git a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift index 8a3367f8..80e84efa 100644 --- a/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift +++ b/Application/DevLogPresentation/Tests/Home/HomeFeatureTestSupport.swift @@ -55,7 +55,7 @@ struct HomeStoreTestAdapter { fetchTodosUseCase: FetchTodosUseCase = FetchTodosUseCaseSpy(), fetchWebPagesUseCase: FetchWebPagesUseCase = FetchWebPagesUseCaseSpy(webPages: []), networkConnectivityUseCase: ObserveNetworkConnectivityUseCase = ObserveNetworkConnectivityUseCaseSpy(), - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? = HomeTrackAnalyticsEventUseCaseSpy(), + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = HomeTrackAnalyticsEventUseCaseSpy(), configureDependencies: ((inout DependencyValues) -> Void)? = nil ) { let clock = TestClock() diff --git a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift index 4ba816c4..4f19bd4a 100644 --- a/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Home/TodoEditorFeatureTestDoubles.swift @@ -41,7 +41,7 @@ final class TodoEditorStoreTestAdapter { fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase = TodoEditorFetchPreferencesUseCaseSpy(), fetchReferenceItemsUseCase: FetchReferenceItemsUseCase = TodoEditorFetchReferenceItemsUseCaseSpy(), upsertTodoUseCase: UpsertTodoUseCase = TodoEditorUpsertTodoUseCaseSpy(), - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase? = nil + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = TodoEditorTrackAnalyticsEventUseCaseSpy() ) { self.now = now store = TestStore( diff --git a/Application/DevLogPresentation/Tests/Profile/ProfileViewModelTests.swift b/Application/DevLogPresentation/Tests/Profile/ProfileFeatureTests.swift similarity index 56% rename from Application/DevLogPresentation/Tests/Profile/ProfileViewModelTests.swift rename to Application/DevLogPresentation/Tests/Profile/ProfileFeatureTests.swift index 478bfe2b..60f15976 100644 --- a/Application/DevLogPresentation/Tests/Profile/ProfileViewModelTests.swift +++ b/Application/DevLogPresentation/Tests/Profile/ProfileFeatureTests.swift @@ -1,5 +1,5 @@ // -// ProfileViewModelTests.swift +// ProfileFeatureTests.swift // DevLogPresentationTests // // Created by opfic on 6/11/26. @@ -13,35 +13,7 @@ import DevLogDomain @testable import DevLogPresentation @MainActor -struct ProfileViewModelTests { - @Test("같은 아바타 URL을 다시 받아도 프로필 이미지 데이터를 다시 요청한다") - func 같은_아바타_URL을_다시_받아도_프로필_이미지_데이터를_다시_요청한다() async { - let imageData = Data([1, 2, 3]) - let spy = FetchProfileImageDataUseCaseSpy(data: imageData) - let viewModel = makeProfileViewModel(fetchProfileImageDataUseCase: spy) - let avatarURL = URL(string: "https://example.com/avatar.png")! - let profile = UserProfile( - name: "opfic", - email: "opfic@example.com", - statusMessage: "status", - avatarURL: avatarURL, - createdAt: Date(timeIntervalSince1970: 0) - ) - - viewModel.send(.fetchUserData(profile)) - await waitUntil { - spy.calledURLs == [avatarURL] - } - - viewModel.send(.fetchUserData(profile)) - await waitUntil { - spy.calledURLs == [avatarURL, avatarURL] - } - - #expect(spy.calledURLs == [avatarURL, avatarURL]) - #expect(viewModel.state.avatarImageData?.data == imageData) - } - +struct ProfileFeatureTests { @Test("ProfileFeature는 같은 아바타 URL을 다시 받아도 프로필 이미지 데이터를 다시 요청한다") func ProfileFeature는_같은_아바타_URL을_다시_받아도_프로필_이미지_데이터를_다시_요청한다() async { let imageData = Data([1, 2, 3]) @@ -63,22 +35,8 @@ struct ProfileViewModelTests { #expect(adapter.avatarImageData?.data == imageData) } - @Test("현재 ProfileViewModel은 연결 상태일 때 상태 메시지 저장을 요청한다") - func 현재_ProfileViewModel은_연결_상태일_때_상태_메시지_저장을_요청한다() async { - let spy = UpsertStatusMessageUseCaseSpy() - let viewModel = makeProfileViewModel(upsertStatusMessageUseCase: spy) - - viewModel.send(.updateStatusMessage("working")) - viewModel.send(.willUpdateStatusMessage) - await waitUntil { - spy.messages == ["working"] - } - - #expect(spy.messages == ["working"]) - } - - @Test("ProfileFeature는 연결 상태일 때 현재 ProfileViewModel처럼 상태 메시지 저장을 요청한다") - func ProfileFeature는_연결_상태일_때_현재_ProfileViewModel처럼_상태_메시지_저장을_요청한다() async { + @Test("ProfileFeature는 연결 상태일 때 상태 메시지 저장을 요청한다") + func ProfileFeature는_연결_상태일_때_상태_메시지_저장을_요청한다() async { let spy = UpsertStatusMessageUseCaseSpy() let adapter = ProfileStoreTestAdapter(upsertStatusMessageUseCase: spy) @@ -88,25 +46,8 @@ struct ProfileViewModelTests { #expect(spy.messages == ["working"]) } - @Test("현재 ProfileViewModel은 마지막 활동 종류를 해제하지 않는다") - func 현재_ProfileViewModel은_마지막_활동_종류를_해제하지_않는다() { - let spy = UpdateHeatmapActivityTypesUseCaseSpy() - let fetchSpy = FetchHeatmapActivityTypesUseCaseSpy() - fetchSpy.activityTypes = ["created"] - let viewModel = makeProfileViewModel( - fetchHeatmapActivityTypesUseCase: fetchSpy, - updateHeatmapActivityTypesUseCase: spy - ) - - viewModel.send(.fetchData) - viewModel.send(.toggleActivityKind(.created)) - - #expect(viewModel.state.selectedActivityKinds == [.created]) - #expect(spy.activityTypes.isEmpty) - } - - @Test("ProfileFeature는 현재 ProfileViewModel처럼 마지막 활동 종류를 해제하지 않는다") - func ProfileFeature는_현재_ProfileViewModel처럼_마지막_활동_종류를_해제하지_않는다() async { + @Test("ProfileFeature는 마지막 활동 종류를 해제하지 않는다") + func ProfileFeature는_마지막_활동_종류를_해제하지_않는다() async { let spy = UpdateHeatmapActivityTypesUseCaseSpy() let fetchSpy = FetchHeatmapActivityTypesUseCaseSpy() fetchSpy.activityTypes = ["created"] @@ -123,32 +64,6 @@ struct ProfileViewModelTests { } } -@MainActor -private func makeProfileViewModel( - fetchProfileImageDataUseCase: FetchProfileImageDataUseCase = FetchProfileImageDataUseCaseSpy(data: Data()), - upsertStatusMessageUseCase: UpsertStatusMessageUseCase = UpsertStatusMessageUseCaseSpy(), - fetchHeatmapActivityTypesUseCase: FetchHeatmapActivityTypesUseCase = FetchHeatmapActivityTypesUseCaseSpy(), - updateHeatmapActivityTypesUseCase: UpdateHeatmapActivityTypesUseCase = UpdateHeatmapActivityTypesUseCaseSpy() -) -> ProfileViewModel { - ProfileViewModel( - fetchUserDataUseCase: FetchUserDataUseCaseSpy( - profile: UserProfile( - name: "opfic", - email: "opfic@example.com", - statusMessage: "", - avatarURL: nil, - createdAt: Date(timeIntervalSince1970: 0) - ) - ), - fetchProfileImageDataUseCase: fetchProfileImageDataUseCase, - fetchTodosUseCase: FetchTodosUseCaseSpy(), - upsertStatusMessageUseCase: upsertStatusMessageUseCase, - networkConnectivityUseCase: ObserveNetworkConnectivityUseCaseSpy(), - fetchHeatmapActivityTypesUseCase: fetchHeatmapActivityTypesUseCase, - updateHeatmapActivityTypesUseCase: updateHeatmapActivityTypesUseCase - ) -} - @MainActor private struct ProfileStoreTestAdapter { private let store: TestStoreOf diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift index 52358f5d..0e2773ef 100644 --- a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift +++ b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListTestSupport.swift @@ -35,80 +35,6 @@ protocol PushNotificationListStateDriving { func finishDeleteToast(_ notificationId: String) async } -@MainActor -struct PushNotificationListViewModelTestAdapter: PushNotificationListStateDriving { - private let viewModel: PushNotificationListViewModel - - var notifications: [PushNotificationItem] { viewModel.state.notifications } - var query: PushNotificationQuery { viewModel.state.query } - var hasMore: Bool { viewModel.state.hasMore } - var selectedNotificationId: String? { viewModel.state.selectedNotificationId } - var selectedTodoId: TodoIdItem? { viewModel.state.selectedTodoId } - var appliedFilterCount: Int { viewModel.appliedFilterCount } - - init( - fetchUseCase: FetchPushNotificationsUseCase = PushNotificationListFetchUseCaseSpy(), - deleteUseCase: DeletePushNotificationUseCase = DeletePushNotificationUseCaseSpy(), - undoDeleteUseCase: UndoDeletePushNotificationUseCase = UndoDeletePushNotificationUseCaseSpy(), - toggleReadUseCase: TogglePushNotificationReadUseCase = TogglePushNotificationReadUseCaseSpy(), - fetchQueryUseCase: FetchPushNotificationQueryUseCase = FetchPushNotificationQueryUseCaseSpy(), - updateQueryUseCase: UpdatePushNotificationQueryUseCase = UpdatePushNotificationQueryUseCaseSpy() - ) { - viewModel = PushNotificationListViewModel( - fetchUseCase: fetchUseCase, - deleteUseCase: deleteUseCase, - undoDeleteUseCase: undoDeleteUseCase, - toggleReadUseCase: toggleReadUseCase, - fetchQueryUseCase: fetchQueryUseCase, - updateQueryUseCase: updateQueryUseCase - ) - } - - func fetchNotifications() async { - viewModel.send(.fetchNotifications) - } - - func loadNextPage() async { - viewModel.send(.loadNextPage) - } - - func toggleSortOption() async { - viewModel.send(.toggleSortOption) - } - - func setTimeFilter(_ filter: PushNotificationQuery.TimeFilter) async { - viewModel.send(.setTimeFilter(filter)) - } - - func toggleUnreadOnly() async { - viewModel.send(.toggleUnreadOnly) - } - - func resetFilters() async { - viewModel.send(.resetFilters) - } - - func selectNotification(_ notificationId: String?) async { - viewModel.send(.selectNotification(notificationId)) - } - - func toggleRead(_ item: PushNotificationItem) async { - viewModel.send(.toggleRead(item)) - } - - func deleteNotification(_ item: PushNotificationItem) async { - viewModel.send(.deleteNotification(item)) - } - - func undoDelete() async { - viewModel.send(.undoDelete) - } - - func finishDeleteToast(_ notificationId: String) async { - viewModel.send(.finishDeleteToast(notificationId)) - } -} - @MainActor struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { private let store: TestStoreOf diff --git a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListViewModelTests.swift b/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListViewModelTests.swift deleted file mode 100644 index adbe141e..00000000 --- a/Application/DevLogPresentation/Tests/PushNotification/PushNotificationListViewModelTests.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// PushNotificationListViewModelTests.swift -// DevLogPresentationTests -// -// Created by opfic on 6/12/26. -// - -import Testing -import DevLogDomain -@testable import DevLogPresentation - -@MainActor -struct PushNotificationListViewModelTests { - @Test("fetchNotifications는 첫 페이지를 조회하고 목록과 hasMore 상태를 갱신한다") - func fetchNotifications는_첫_페이지를_조회하고_목록과_hasMore_상태를_갱신한다() async throws { - let cursor = makePushNotificationCursor(documentID: "cursor-1") - let notifications = (0..<20).map { - makePushNotification(id: "notification-\($0)", number: $0, isRead: $0.isMultiple(of: 2)) - } - let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ - PushNotificationPage(items: notifications, nextCursor: cursor) - ]) - let adapter = PushNotificationListViewModelTestAdapter(fetchUseCase: fetchSpy) - - try await verifyFetchNotifications(adapter: adapter, fetchUseCaseSpy: fetchSpy) - } - - @Test("loadNextPage는 다음 커서로 조회한 알림을 기존 목록 뒤에 추가한다") - func loadNextPage는_다음_커서로_조회한_알림을_기존_목록_뒤에_추가한다() async throws { - let cursor = makePushNotificationCursor(documentID: "cursor-1") - let firstPage = (0..<20).map { - makePushNotification(id: "notification-\($0)", number: $0) - } - let nextNotification = makePushNotification(id: "notification-next", number: 20) - let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ - PushNotificationPage(items: firstPage, nextCursor: cursor), - PushNotificationPage(items: [nextNotification], nextCursor: nil) - ]) - let adapter = PushNotificationListViewModelTestAdapter(fetchUseCase: fetchSpy) - - try await verifyLoadNextPage( - adapter: adapter, - fetchUseCaseSpy: fetchSpy, - nextNotification: nextNotification - ) - } - - @Test("필터 액션은 query와 적용 필터 수를 갱신한다") - func 필터_액션은_query와_적용_필터_수를_갱신한다() async throws { - let updateSpy = UpdatePushNotificationQueryUseCaseSpy() - let adapter = PushNotificationListViewModelTestAdapter(updateQueryUseCase: updateSpy) - - try await verifyFilterStateTransitions( - adapter: adapter, - updateQueryUseCaseSpy: updateSpy - ) - } - - @Test("selectNotification은 선택 상태를 바꾸고 읽지 않은 알림을 읽음 처리한다") - func selectNotification은_선택_상태를_바꾸고_읽지_않은_알림을_읽음_처리한다() async throws { - let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ - PushNotificationPage( - items: [ - makePushNotification(id: "notification-1", number: 1, isRead: false) - ], - nextCursor: nil - ) - ]) - let toggleSpy = TogglePushNotificationReadUseCaseSpy() - let adapter = PushNotificationListViewModelTestAdapter( - fetchUseCase: fetchSpy, - toggleReadUseCase: toggleSpy - ) - - try await verifySelectNotification( - adapter: adapter, - toggleReadUseCaseSpy: toggleSpy - ) - } - - @Test("toggleRead는 알림 읽음 상태를 토글하고 유스케이스를 호출한다") - func toggleRead는_알림_읽음_상태를_토글하고_유스케이스를_호출한다() async throws { - let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ - PushNotificationPage( - items: [ - makePushNotification(id: "notification-1", number: 1, isRead: true) - ], - nextCursor: nil - ) - ]) - let toggleSpy = TogglePushNotificationReadUseCaseSpy() - let adapter = PushNotificationListViewModelTestAdapter( - fetchUseCase: fetchSpy, - toggleReadUseCase: toggleSpy - ) - - try await verifyToggleRead( - adapter: adapter, - toggleReadUseCaseSpy: toggleSpy - ) - } - - @Test("delete와 undoDelete는 숨김 상태와 최종 제거 상태를 제어한다") - func delete와_undoDelete는_숨김_상태와_최종_제거_상태를_제어한다() async throws { - let fetchSpy = PushNotificationListFetchUseCaseSpy(pages: [ - PushNotificationPage( - items: [makePushNotification(id: "notification-1", number: 1)], - nextCursor: nil - ) - ]) - let deleteSpy = DeletePushNotificationUseCaseSpy() - let undoSpy = UndoDeletePushNotificationUseCaseSpy() - let adapter = PushNotificationListViewModelTestAdapter( - fetchUseCase: fetchSpy, - deleteUseCase: deleteSpy, - undoDeleteUseCase: undoSpy - ) - - try await verifyDeleteUndoAndFinishToast( - adapter: adapter, - deleteUseCaseSpy: deleteSpy, - undoDeleteUseCaseSpy: undoSpy - ) - } -} diff --git a/Application/DevLogPresentation/Tests/Root/RootFeatureTestSupport.swift b/Application/DevLogPresentation/Tests/Root/RootFeatureTestSupport.swift new file mode 100644 index 00000000..a961b077 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Root/RootFeatureTestSupport.swift @@ -0,0 +1,307 @@ +// +// RootFeatureTestSupport.swift +// DevLogPresentationTests +// +// Created by opfic on 6/17/26. +// + +import Combine +import ComposableArchitecture +import DevLogCore +import DevLogDomain +import Testing +@testable import DevLogPresentation + +@MainActor +protocol RootStateDriving { + var snapshot: RootStateSnapshot { get } + var sheetTodoId: String? { get } + + func onAppear() async + func setAlert(_ isPresented: Bool) async + func networkStatusChanged(_ isConnected: Bool) async + func setTheme(_ theme: SystemTheme) async + func didLogined(_ signIn: Bool) async + func presentTodoDetail(_ todoId: String) async + func dismissSheet() async + func selectMainTab(_ tab: MainTab) async + func openWidgetRoute(_ tab: MainTab) async +} + +struct RootStateSnapshot: Equatable { + let alertTitle: String? + let alertMessage: String? + let isNetworkConnected: Bool + let signIn: Bool? + let theme: SystemTheme + let selectedMainTab: MainTab +} + +@MainActor +struct RootStoreTestAdapter: RootStateDriving { + private let store: TestStoreOf + + var snapshot: RootStateSnapshot { + RootStateSnapshot( + alertTitle: store.state.alert.map { String(state: $0.title) }, + alertMessage: store.state.alert?.message.map { String(state: $0) }, + isNetworkConnected: store.state.isNetworkConnected, + signIn: store.state.signIn, + theme: store.state.theme, + selectedMainTab: store.state.selectedMainTab + ) + } + var sheetTodoId: String? { store.state.sheet?.todoId } + + init( + sessionUseCase: ObserveAuthSessionUseCase = ObserveAuthSessionUseCaseSpy(currentValue: true), + networkConnectivityUseCase: ObserveNetworkConnectivityUseCase = RootObserveNetworkConnectivityUseCaseSpy( + currentValue: true + ), + systemThemeUseCase: ObserveSystemThemeUseCase = RootObserveSystemThemeUseCaseSpy( + currentValue: .automatic + ), + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = RootTrackAnalyticsEventUseCaseSpy(), + badgeCountSpy: RootApplicationBadgeCountSpy = RootApplicationBadgeCountSpy() + ) { + store = TestStore(initialState: RootFeature.State()) { + RootFeature() + } withDependencies: { + $0.observeAuthSessionUseCase = sessionUseCase + $0.networkConnectivityUseCase = networkConnectivityUseCase + $0.systemThemeUseCase = systemThemeUseCase + $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + $0.setApplicationBadgeCount = { count in + try await badgeCountSpy.setBadgeCount(count) + } + } + store.exhaustivity = .off(showSkippedAssertions: false) + } + + func onAppear() async { + await store.send(.onAppear) + await drainReceivedActions() + } + + func setAlert(_ isPresented: Bool) async { + if isPresented { + await store.send(.networkStatusChanged(false)) + } else { + await store.send(.alert(.dismiss)) + } + } + + func networkStatusChanged(_ isConnected: Bool) async { + await store.send(.networkStatusChanged(isConnected)) + } + + func setTheme(_ theme: SystemTheme) async { + await store.send(.setTheme(theme)) + } + + func didLogined(_ signIn: Bool) async { + await store.send(.didLogined(signIn)) + } + + func presentTodoDetail(_ todoId: String) async { + await store.send(.presentTodoDetail(todoId)) + } + + func dismissSheet() async { + await store.send(.sheet(.dismiss)) + } + + func selectMainTab(_ tab: MainTab) async { + await store.send(.binding(.set(\.selectedMainTab, tab))) + } + + func openWidgetRoute(_ tab: MainTab) async { + await store.send(.openWidgetRoute(tab)) + } + + private func drainReceivedActions() async { + for _ in 0..<8 { + await store.skipReceivedActions(strict: false) + } + } +} + +@MainActor +func verifyNetworkDisconnectedAlert(adapter: some RootStateDriving) async { + await adapter.networkStatusChanged(false) + + #expect( + adapter.snapshot + == RootStateSnapshot( + alertTitle: String(localized: "root_network_disconnected_title"), + alertMessage: String(localized: "root_network_disconnected_message"), + isNetworkConnected: false, + signIn: nil, + theme: .automatic, + selectedMainTab: .home + ) + ) +} + +@MainActor +func verifySetAlert(adapter: some RootStateDriving) async { + await adapter.networkStatusChanged(false) + await adapter.setAlert(false) + + #expect( + adapter.snapshot + == RootStateSnapshot( + alertTitle: nil, + alertMessage: nil, + isNetworkConnected: false, + signIn: nil, + theme: .automatic, + selectedMainTab: .home + ) + ) +} + +@MainActor +func verifyThemeUpdate(adapter: some RootStateDriving) async { + await adapter.setTheme(.dark) + + #expect(adapter.snapshot.theme == .dark) + #expect(adapter.snapshot.alertTitle == nil) + #expect(adapter.snapshot.selectedMainTab == .home) +} + +@MainActor +func verifyDidLoginedFalse( + adapter: some RootStateDriving, + trackAnalyticsEventUseCaseSpy: RootTrackAnalyticsEventUseCaseSpy +) async { + await adapter.didLogined(false) + await waitUntil { + trackAnalyticsEventUseCaseSpy.screenNames == ["login"] + } + + #expect(adapter.snapshot.signIn == false) + #expect(adapter.snapshot.selectedMainTab == .home) + #expect(trackAnalyticsEventUseCaseSpy.screenNames == ["login"]) +} + +@MainActor +func verifyDidLoginedTrue( + adapter: some RootStateDriving, + trackAnalyticsEventUseCaseSpy: RootTrackAnalyticsEventUseCaseSpy +) async { + await adapter.selectMainTab(.today) + await adapter.didLogined(true) + + #expect(adapter.snapshot.signIn == true) + #expect(adapter.snapshot.selectedMainTab == .home) + #expect(trackAnalyticsEventUseCaseSpy.screenNames.isEmpty) +} + +@MainActor +func verifyObservedInitialValues(adapter: some RootStateDriving) async { + await adapter.onAppear() + await waitUntil { + let snapshot = adapter.snapshot + return snapshot.signIn == false + && !snapshot.isNetworkConnected + && snapshot.theme == .dark + && snapshot.alertTitle == String(localized: "root_network_disconnected_title") + } + + #expect( + adapter.snapshot + == RootStateSnapshot( + alertTitle: String(localized: "root_network_disconnected_title"), + alertMessage: String(localized: "root_network_disconnected_message"), + isNetworkConnected: false, + signIn: false, + theme: .dark, + selectedMainTab: .home + ) + ) +} + +@MainActor +func verifyTodoDetailSheetPresentation(adapter: some RootStateDriving) async { + await adapter.presentTodoDetail("todo-1") + #expect(adapter.sheetTodoId == "todo-1") + + await adapter.dismissSheet() + #expect(adapter.sheetTodoId == nil) +} + +@MainActor +func verifyWidgetRouteOpensWhenSignedIn(adapter: some RootStateDriving) async { + await adapter.openWidgetRoute(.today) + #expect(adapter.snapshot.selectedMainTab == .home) + + await adapter.didLogined(true) + await adapter.openWidgetRoute(.today) + #expect(adapter.snapshot.selectedMainTab == .today) +} + +final class ObserveAuthSessionUseCaseSpy: ObserveAuthSessionUseCase { + let subject: CurrentValueSubject + private(set) var observeCallCount = 0 + + init(currentValue: Bool) { + subject = CurrentValueSubject(currentValue) + } + + func observe() -> AnyPublisher { + observeCallCount += 1 + return subject.eraseToAnyPublisher() + } +} + +final class RootObserveNetworkConnectivityUseCaseSpy: ObserveNetworkConnectivityUseCase { + let subject: CurrentValueSubject + private(set) var observeCallCount = 0 + + init(currentValue: Bool) { + subject = CurrentValueSubject(currentValue) + } + + func observe() -> AnyPublisher { + observeCallCount += 1 + return subject.eraseToAnyPublisher() + } +} + +final class RootObserveSystemThemeUseCaseSpy: ObserveSystemThemeUseCase { + let subject: CurrentValueSubject + private(set) var observeCallCount = 0 + + init(currentValue: SystemTheme) { + subject = CurrentValueSubject(currentValue) + } + + func observe() -> AnyPublisher { + observeCallCount += 1 + return subject.eraseToAnyPublisher() + } +} + +final class RootTrackAnalyticsEventUseCaseSpy: 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) + } +} + +final class RootApplicationBadgeCountSpy: @unchecked Sendable { + private(set) var counts = [Int]() + + func setBadgeCount(_ count: Int) async throws { + counts.append(count) + } +} diff --git a/Application/DevLogPresentation/Tests/Root/RootFeatureTests.swift b/Application/DevLogPresentation/Tests/Root/RootFeatureTests.swift new file mode 100644 index 00000000..bd75da05 --- /dev/null +++ b/Application/DevLogPresentation/Tests/Root/RootFeatureTests.swift @@ -0,0 +1,107 @@ +// +// RootFeatureTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/17/26. +// + +import DevLogCore +import Testing + +@MainActor +struct RootFeatureTests { + @Test("RootFeature networkStatusChanged는 기존 Root 상태관리처럼 alert 문구와 표시 상태를 갱신한다") + func RootFeature_networkStatusChanged는_기존_Root_상태관리처럼_alert_문구와_표시_상태를_갱신한다() async { + let adapter = RootStoreTestAdapter() + + await verifyNetworkDisconnectedAlert(adapter: adapter) + } + + @Test("RootFeature setAlert(false)는 기존 Root 상태관리처럼 alert 문구를 유지한 채 표시 상태를 해제한다") + func RootFeature_setAlert_false는_기존_Root_상태관리처럼_alert_문구를_유지한_채_표시_상태를_해제한다() async { + let adapter = RootStoreTestAdapter() + + await verifySetAlert(adapter: adapter) + } + + @Test("RootFeature setTheme은 기존 Root 상태관리처럼 테마 상태를 갱신한다") + func RootFeature_setTheme은_기존_Root_상태관리처럼_테마_상태를_갱신한다() async { + let adapter = RootStoreTestAdapter() + + await verifyThemeUpdate(adapter: adapter) + } + + @Test("RootFeature didLogined(false)는 기존 Root 상태관리처럼 signIn 상태를 갱신하고 login 화면 추적을 요청한다") + func RootFeature_didLogined_false는_기존_Root_상태관리처럼_signIn_상태를_갱신하고_login_화면_추적을_요청한다() async { + let trackSpy = RootTrackAnalyticsEventUseCaseSpy() + let adapter = RootStoreTestAdapter(trackAnalyticsEventUseCase: trackSpy) + + await verifyDidLoginedFalse(adapter: adapter, trackAnalyticsEventUseCaseSpy: trackSpy) + } + + @Test("RootFeature didLogined(true)는 기존 Root 상태관리처럼 signIn 상태를 true로 갱신하고 selectedMainTab을 home으로 되돌린다") + func RootFeature_didLogined_true는_기존_Root_상태관리처럼_signIn_상태를_true로_갱신하고_selectedMainTab을_home으로_되돌린다() async { + let trackSpy = RootTrackAnalyticsEventUseCaseSpy() + let adapter = RootStoreTestAdapter(trackAnalyticsEventUseCase: trackSpy) + + await verifyDidLoginedTrue(adapter: adapter, trackAnalyticsEventUseCaseSpy: trackSpy) + } + + @Test("RootFeature onAppear는 기존 Root 상태관리처럼 session, network, theme 관찰을 한 번만 시작한다") + func RootFeature_onAppear는_기존_Root_상태관리처럼_session_network_theme_관찰을_한_번만_시작한다() async { + let sessionSpy = ObserveAuthSessionUseCaseSpy(currentValue: true) + let networkSpy = RootObserveNetworkConnectivityUseCaseSpy(currentValue: true) + let themeSpy = RootObserveSystemThemeUseCaseSpy(currentValue: .automatic) + let adapter = RootStoreTestAdapter( + sessionUseCase: sessionSpy, + networkConnectivityUseCase: networkSpy, + systemThemeUseCase: themeSpy + ) + + await adapter.onAppear() + await adapter.onAppear() + + #expect(sessionSpy.observeCallCount == 1) + #expect(networkSpy.observeCallCount == 1) + #expect(themeSpy.observeCallCount == 1) + } + + @Test("RootFeature onAppear는 기존 Root 상태관리처럼 초기 publisher 값으로 signIn, network, theme 상태를 반영한다") + func RootFeature_onAppear는_기존_Root_상태관리처럼_초기_publisher_값으로_signIn_network_theme_상태를_반영한다() async { + let adapter = RootStoreTestAdapter( + sessionUseCase: ObserveAuthSessionUseCaseSpy(currentValue: false), + networkConnectivityUseCase: RootObserveNetworkConnectivityUseCaseSpy(currentValue: false), + systemThemeUseCase: RootObserveSystemThemeUseCaseSpy(currentValue: .dark) + ) + + await verifyObservedInitialValues(adapter: adapter) + } + + @Test("RootFeature onAppear는 앱 badge 초기화를 요청한다") + func RootFeature_onAppear는_앱_badge_초기화를_요청한다() async { + let badgeSpy = RootApplicationBadgeCountSpy() + let adapter = RootStoreTestAdapter(badgeCountSpy: badgeSpy) + + await adapter.onAppear() + await adapter.onAppear() + await waitUntil { + badgeSpy.counts == [0, 0] + } + + #expect(badgeSpy.counts == [0, 0]) + } + + @Test("RootFeature는 TodoDetail sheet 표시와 해제를 store state로 관리한다") + func RootFeature는_TodoDetail_sheet_표시와_해제를_store_state로_관리한다() async { + let adapter = RootStoreTestAdapter() + + await verifyTodoDetailSheetPresentation(adapter: adapter) + } + + @Test("RootFeature는 로그인된 경우에만 widget route로 selectedMainTab을 변경한다") + func RootFeature는_로그인된_경우에만_widget_route로_selectedMainTab을_변경한다() async { + let adapter = RootStoreTestAdapter() + + await verifyWidgetRouteOpensWhenSignedIn(adapter: adapter) + } +} diff --git a/Application/DevLogPresentation/Tests/Today/TodayFeatureTestDoubles.swift b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestDoubles.swift index 7a65193c..95a00d75 100644 --- a/Application/DevLogPresentation/Tests/Today/TodayFeatureTestDoubles.swift +++ b/Application/DevLogPresentation/Tests/Today/TodayFeatureTestDoubles.swift @@ -54,71 +54,6 @@ protocol TodayStateDriving { func togglePinned(_ item: TodayTodoItem) async } -@MainActor -struct TodayViewModelTestAdapter: TodayStateDriving { - private let viewModel: TodayViewModel - - var todos: [TodayTodoItem] { viewModel.state.todos } - var selectedSectionScope: TodayTestSectionScope { viewModel.state.selectedSectionScope.testValue } - var displayOptions: TodayDisplayOptions { viewModel.state.displayOptions } - var showAlert: Bool { viewModel.state.showAlert } - var isLoading: Bool { viewModel.state.isLoading } - var displayedSections: [TodayDisplayedSection] { viewModel.sections.map(\.testValue) } - var summaryCounts: [TodayTestSectionScope: Int] { - Dictionary( - uniqueKeysWithValues: TodayTestSectionScope.allCases.map { scope in - (scope, viewModel.summaryValue(for: scope.viewModelValue)) - } - ) - } - - init( - fetchUseCase: FetchTodosUseCase = TodayFetchTodosUseCaseSpy(), - fetchTodoByIdUseCase: FetchTodoByIdUseCase = TodayFetchTodoByIdUseCaseSpy(), - upsertUseCase: UpsertTodoUseCase = TodayUpsertTodoUseCaseSpy(), - fetchDisplayOptionsUseCase: FetchTodayDisplayOptionsUseCase = TodayFetchDisplayOptionsUseCaseSpy(), - updateDisplayOptionsUseCase: UpdateTodayDisplayOptionsUseCase = TodayUpdateDisplayOptionsUseCaseSpy(), - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = TodayTrackAnalyticsEventUseCaseSpy() - ) { - viewModel = TodayViewModel( - fetchTodosUseCase: fetchUseCase, - fetchTodoByIdUseCase: fetchTodoByIdUseCase, - upsertTodoUseCase: upsertUseCase, - fetchTodayDisplayOptionsUseCase: fetchDisplayOptionsUseCase, - updateTodayDisplayOptionsUseCase: updateDisplayOptionsUseCase, - trackAnalyticsEventUseCase: trackAnalyticsEventUseCase - ) - } - - func fetchData() async { - viewModel.send(.fetchData) - } - - func setSectionScope(_ scope: TodayTestSectionScope) async { - viewModel.send(.setSectionScope(scope.viewModelValue)) - } - - func setDueDateVisibility(_ visibility: TodayDisplayOptions.DueDateVisibility) async { - viewModel.send(.setDueDateVisibility(visibility)) - } - - func setFocusVisibility(_ visibility: TodayDisplayOptions.FocusVisibility) async { - viewModel.send(.setFocusVisibility(visibility)) - } - - func resetDisplayOptions() async { - viewModel.send(.resetDisplayOptions) - } - - func completeTodo(_ item: TodayTodoItem) async { - viewModel.send(.completeTodo(item)) - } - - func togglePinned(_ item: TodayTodoItem) async { - viewModel.send(.togglePinned(item)) - } -} - @MainActor struct TodayStoreTestAdapter: TodayStateDriving { private let store: TestStoreOf @@ -205,35 +140,7 @@ struct TodayStoreTestAdapter: TodayStateDriving { } } -private extension TodayViewModel.SectionScope { - var testValue: TodayTestSectionScope { - switch self { - case .all: - return .all - case .focused: - return .focused - case .overdue: - return .overdue - case .dueSoon: - return .dueSoon - } - } -} - private extension TodayTestSectionScope { - var viewModelValue: TodayViewModel.SectionScope { - switch self { - case .all: - return .all - case .focused: - return .focused - case .overdue: - return .overdue - case .dueSoon: - return .dueSoon - } - } - var featureValue: TodayFeature.SectionScope { switch self { case .all: @@ -263,23 +170,6 @@ private extension TodayFeature.SectionScope { } } -private extension TodayViewModel.SectionCategory { - var testValue: TodayTestSectionCategory { - switch self { - case .later: - return .later - case .unscheduled: - return .unscheduled - case .focused: - return .focused - case .overdue: - return .overdue - case .dueSoon: - return .dueSoon - } - } -} - private extension TodayFeature.SectionCategory { var testValue: TodayTestSectionCategory { switch self { @@ -297,15 +187,6 @@ private extension TodayFeature.SectionCategory { } } -private extension TodayViewModel.SectionContent { - var testValue: TodayDisplayedSection { - TodayDisplayedSection( - category: category.testValue, - itemIds: items.map(\.id) - ) - } -} - private extension TodayFeature.SectionContent { var testValue: TodayDisplayedSection { TodayDisplayedSection( diff --git a/Application/DevLogPresentation/Tests/Today/TodayFeatureTests.swift b/Application/DevLogPresentation/Tests/Today/TodayFeatureTests.swift index 898742aa..d643765e 100644 --- a/Application/DevLogPresentation/Tests/Today/TodayFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Today/TodayFeatureTests.swift @@ -65,22 +65,8 @@ struct TodayFeatureTests { ) } - @Test("현재 TodayViewModel fetchData는 요약과 섹션 상태를 갱신한다") - func 현재_TodayViewModel_fetchData는_요약과_섹션_상태를_갱신한다() async throws { - let todos = makeTodaySectionTodos() - let fetchSpy = TodayFetchTodosUseCaseSpy( - pagesByFilter: [ - .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), - .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) - ] - ) - let adapter = TodayViewModelTestAdapter(fetchUseCase: fetchSpy) - - try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchSpy) - } - - @Test("TodayFeature fetchData는 현재 TodayViewModel과 같은 요약과 섹션 상태를 만든다") - func TodayFeature_fetchData는_현재_TodayViewModel과_같은_요약과_섹션_상태를_만든다() async throws { + @Test("TodayFeature fetchData는 요약과 섹션 상태를 갱신한다") + func TodayFeature_fetchData는_요약과_섹션_상태를_갱신한다() async throws { let todos = makeTodaySectionTodos() let fetchSpy = TodayFetchTodosUseCaseSpy( pagesByFilter: [ @@ -93,22 +79,8 @@ struct TodayFeatureTests { try await verifyTodayFetchData(adapter: adapter, fetchUseCaseSpy: fetchSpy) } - @Test("현재 TodayViewModel setSectionScope는 동일 탭 재선택 시 all로 되돌린다") - func 현재_TodayViewModel_setSectionScope는_동일_탭_재선택_시_all로_되돌린다() async throws { - let todos = makeTodaySectionTodos() - let fetchSpy = TodayFetchTodosUseCaseSpy( - pagesByFilter: [ - .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), - .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) - ] - ) - let adapter = TodayViewModelTestAdapter(fetchUseCase: fetchSpy) - - try await verifyTodaySectionScopeToggle(adapter: adapter, fetchUseCaseSpy: fetchSpy) - } - - @Test("TodayFeature setSectionScope는 현재 TodayViewModel과 같은 토글 동작을 유지한다") - func TodayFeature_setSectionScope는_현재_TodayViewModel과_같은_토글_동작을_유지한다() async throws { + @Test("TodayFeature setSectionScope는 동일 탭 재선택 시 all로 되돌린다") + func TodayFeature_setSectionScope는_동일_탭_재선택_시_all로_되돌린다() async throws { let todos = makeTodaySectionTodos() let fetchSpy = TodayFetchTodosUseCaseSpy( pagesByFilter: [ @@ -121,30 +93,8 @@ struct TodayFeatureTests { try await verifyTodaySectionScopeToggle(adapter: adapter, fetchUseCaseSpy: fetchSpy) } - @Test("현재 TodayViewModel displayOptions 변경은 필터링 결과와 저장 상태를 갱신한다") - func 현재_TodayViewModel_displayOptions_변경은_필터링_결과와_저장_상태를_갱신한다() async throws { - let todos = makeTodaySectionTodos() - let fetchSpy = TodayFetchTodosUseCaseSpy( - pagesByFilter: [ - .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), - .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) - ] - ) - let updateSpy = TodayUpdateDisplayOptionsUseCaseSpy() - let adapter = TodayViewModelTestAdapter( - fetchUseCase: fetchSpy, - updateDisplayOptionsUseCase: updateSpy - ) - - try await verifyTodayDisplayOptions( - adapter: adapter, - fetchUseCaseSpy: fetchSpy, - updateDisplayOptionsUseCaseSpy: updateSpy - ) - } - - @Test("TodayFeature displayOptions 변경은 현재 TodayViewModel과 같은 필터링 결과를 유지한다") - func TodayFeature_displayOptions_변경은_현재_TodayViewModel과_같은_필터링_결과를_유지한다() async throws { + @Test("TodayFeature displayOptions 변경은 필터링 결과와 저장 상태를 갱신한다") + func TodayFeature_displayOptions_변경은_필터링_결과와_저장_상태를_갱신한다() async throws { let todos = makeTodaySectionTodos() let fetchSpy = TodayFetchTodosUseCaseSpy( pagesByFilter: [ @@ -165,33 +115,8 @@ struct TodayFeatureTests { ) } - @Test("현재 TodayViewModel togglePinned는 Todo를 갱신하고 섹션을 다시 계산한다") - func 현재_TodayViewModel_togglePinned는_Todo를_갱신하고_섹션을_다시_계산한다() async throws { - let todos = makeTodaySectionTodos() - let fetchSpy = TodayFetchTodosUseCaseSpy( - pagesByFilter: [ - .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), - .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) - ] - ) - let fetchByIdSpy = TodayFetchTodoByIdUseCaseSpy(todos: todos) - let upsertSpy = TodayUpsertTodoUseCaseSpy() - let adapter = TodayViewModelTestAdapter( - fetchUseCase: fetchSpy, - fetchTodoByIdUseCase: fetchByIdSpy, - upsertUseCase: upsertSpy - ) - - try await verifyTodayTogglePinned( - adapter: adapter, - fetchUseCaseSpy: fetchSpy, - fetchTodoByIdUseCaseSpy: fetchByIdSpy, - upsertTodoUseCaseSpy: upsertSpy - ) - } - - @Test("TodayFeature togglePinned는 현재 TodayViewModel과 같은 섹션 재계산을 유지한다") - func TodayFeature_togglePinned는_현재_TodayViewModel과_같은_섹션_재계산을_유지한다() async throws { + @Test("TodayFeature togglePinned는 Todo를 갱신하고 섹션을 다시 계산한다") + func TodayFeature_togglePinned는_Todo를_갱신하고_섹션을_다시_계산한다() async throws { let todos = makeTodaySectionTodos() let fetchSpy = TodayFetchTodosUseCaseSpy( pagesByFilter: [ @@ -215,36 +140,8 @@ struct TodayFeatureTests { ) } - @Test("현재 TodayViewModel completeTodo는 Todo를 제거하고 완료 이벤트를 남긴다") - func 현재_TodayViewModel_completeTodo는_Todo를_제거하고_완료_이벤트를_남긴다() async throws { - let todos = makeTodaySectionTodos() - let fetchSpy = TodayFetchTodosUseCaseSpy( - pagesByFilter: [ - .withDueDate: .init(items: todos.filter { $0.dueDate != nil }, nextCursor: nil), - .withoutDueDate: .init(items: todos.filter { $0.dueDate == nil }, nextCursor: nil) - ] - ) - let fetchByIdSpy = TodayFetchTodoByIdUseCaseSpy(todos: todos) - let upsertSpy = TodayUpsertTodoUseCaseSpy() - let trackSpy = TodayTrackAnalyticsEventUseCaseSpy() - let adapter = TodayViewModelTestAdapter( - fetchUseCase: fetchSpy, - fetchTodoByIdUseCase: fetchByIdSpy, - upsertUseCase: upsertSpy, - trackAnalyticsEventUseCase: trackSpy - ) - - try await verifyTodayCompleteTodo( - adapter: adapter, - fetchUseCaseSpy: fetchSpy, - fetchTodoByIdUseCaseSpy: fetchByIdSpy, - upsertTodoUseCaseSpy: upsertSpy, - trackAnalyticsEventUseCaseSpy: trackSpy - ) - } - - @Test("TodayFeature completeTodo는 현재 TodayViewModel과 같은 제거와 완료 추적을 유지한다") - func TodayFeature_completeTodo는_현재_TodayViewModel과_같은_제거와_완료_추적을_유지한다() async throws { + @Test("TodayFeature completeTodo는 Todo를 제거하고 완료 이벤트를 남긴다") + func TodayFeature_completeTodo는_Todo를_제거하고_완료_이벤트를_남긴다() async throws { let todos = makeTodaySectionTodos() let fetchSpy = TodayFetchTodosUseCaseSpy( pagesByFilter: [ @@ -271,17 +168,8 @@ struct TodayFeatureTests { ) } - @Test("현재 TodayViewModel fetchData 실패는 에러 표시 상태를 만든다") - func 현재_TodayViewModel_fetchData_실패는_에러_표시_상태를_만든다() async { - let fetchSpy = TodayFetchTodosUseCaseSpy() - fetchSpy.error = TodayTestError.failure - let adapter = TodayViewModelTestAdapter(fetchUseCase: fetchSpy) - - await verifyTodayFetchFailureShowsAlert(adapter: adapter) - } - - @Test("TodayFeature fetchData 실패는 현재 TodayViewModel과 같은 에러 표시 상태를 만든다") - func TodayFeature_fetchData_실패는_현재_TodayViewModel과_같은_에러_표시_상태를_만든다() async { + @Test("TodayFeature fetchData 실패는 에러 표시 상태를 만든다") + func TodayFeature_fetchData_실패는_에러_표시_상태를_만든다() async { let fetchSpy = TodayFetchTodosUseCaseSpy() fetchSpy.error = TodayTestError.failure let adapter = TodayStoreTestAdapter(fetchUseCase: fetchSpy)