From d2c0c11ef40c1723b04e3c4b726331dda0c7ff4f Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:40:57 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20tca=201=EC=B0=A8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Profile/ProfileFeature+Dependencies.swift | 101 ++++ .../Profile/ProfileFeature+Heatmap.swift | 280 +++++++++++ .../Profile/ProfileFeature+State.swift | 76 +++ .../Sources/Profile/ProfileFeature.swift | 303 ++++++++++++ .../Sources/Profile/ProfileView.swift | 439 +++++++++--------- .../Profile/ProfileViewCoordinator.swift | 25 +- .../Tests/Profile/ProfileViewModelTests.swift | 162 ++++++- 7 files changed, 1147 insertions(+), 239 deletions(-) create mode 100644 Application/DevLogPresentation/Sources/Profile/ProfileFeature+Dependencies.swift create mode 100644 Application/DevLogPresentation/Sources/Profile/ProfileFeature+Heatmap.swift create mode 100644 Application/DevLogPresentation/Sources/Profile/ProfileFeature+State.swift create mode 100644 Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileFeature+Dependencies.swift b/Application/DevLogPresentation/Sources/Profile/ProfileFeature+Dependencies.swift new file mode 100644 index 00000000..be29c138 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Profile/ProfileFeature+Dependencies.swift @@ -0,0 +1,101 @@ +// +// ProfileFeature+Dependencies.swift +// DevLogPresentation +// +// Created by opfic on 6/15/26. +// + +import ComposableArchitecture +import DevLogDomain + +extension DependencyValues { + var profileFetchUserDataUseCase: FetchUserDataUseCase { + get { self[ProfileFetchUserDataKey.self] } + set { self[ProfileFetchUserDataKey.self] = newValue } + } + + var profileFetchImageDataUseCase: FetchProfileImageDataUseCase { + get { self[ProfileFetchImageDataKey.self] } + set { self[ProfileFetchImageDataKey.self] = newValue } + } + + var profileFetchTodosUseCase: FetchTodosUseCase { + get { self[ProfileFetchTodosKey.self] } + set { self[ProfileFetchTodosKey.self] = newValue } + } + + var profileUpsertStatusMessageUseCase: UpsertStatusMessageUseCase { + get { self[ProfileUpsertStatusMessageKey.self] } + set { self[ProfileUpsertStatusMessageKey.self] = newValue } + } + + var profileFetchHeatmapActivityTypesUseCase: FetchHeatmapActivityTypesUseCase { + get { self[ProfileFetchHeatmapTypesKey.self] } + set { self[ProfileFetchHeatmapTypesKey.self] = newValue } + } + + var profileUpdateHeatmapActivityTypesUseCase: UpdateHeatmapActivityTypesUseCase { + get { self[ProfileUpdateHeatmapTypesKey.self] } + set { self[ProfileUpdateHeatmapTypesKey.self] = newValue } + } +} + +private enum ProfileFetchUserDataKey: DependencyKey { + static var liveValue: FetchUserDataUseCase { + preconditionFailure("FetchUserDataUseCase must be provided.") + } + + static var testValue: FetchUserDataUseCase { + liveValue + } +} + +private enum ProfileFetchImageDataKey: DependencyKey { + static var liveValue: FetchProfileImageDataUseCase { + preconditionFailure("FetchProfileImageDataUseCase must be provided.") + } + + static var testValue: FetchProfileImageDataUseCase { + liveValue + } +} + +private enum ProfileFetchTodosKey: DependencyKey { + static var liveValue: FetchTodosUseCase { + preconditionFailure("FetchTodosUseCase must be provided.") + } + + static var testValue: FetchTodosUseCase { + liveValue + } +} + +private enum ProfileUpsertStatusMessageKey: DependencyKey { + static var liveValue: UpsertStatusMessageUseCase { + preconditionFailure("UpsertStatusMessageUseCase must be provided.") + } + + static var testValue: UpsertStatusMessageUseCase { + liveValue + } +} + +private enum ProfileFetchHeatmapTypesKey: DependencyKey { + static var liveValue: FetchHeatmapActivityTypesUseCase { + preconditionFailure("FetchHeatmapActivityTypesUseCase must be provided.") + } + + static var testValue: FetchHeatmapActivityTypesUseCase { + liveValue + } +} + +private enum ProfileUpdateHeatmapTypesKey: DependencyKey { + static var liveValue: UpdateHeatmapActivityTypesUseCase { + preconditionFailure("UpdateHeatmapActivityTypesUseCase must be provided.") + } + + static var testValue: UpdateHeatmapActivityTypesUseCase { + liveValue + } +} diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileFeature+Heatmap.swift b/Application/DevLogPresentation/Sources/Profile/ProfileFeature+Heatmap.swift new file mode 100644 index 00000000..b718739b --- /dev/null +++ b/Application/DevLogPresentation/Sources/Profile/ProfileFeature+Heatmap.swift @@ -0,0 +1,280 @@ +// +// ProfileFeature+Heatmap.swift +// DevLogPresentation +// +// Created by opfic on 6/15/26. +// + +import DevLogCore +import DevLogDomain +import Foundation + +private struct ProfileHeatmapActivityCounts { + 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 ProfileHeatmapActivityEntry { + var todo: Todo + var activityKinds: Set +} + +extension ProfileFeature { + static func quarterStart(for date: Date) -> Date? { + let month = Calendar.current.component(.month, from: date) + let startMonth = ((month - 1) / 3) * 3 + 1 + var components = Calendar.current.dateComponents([.year], from: date) + components.month = startMonth + components.day = 1 + return Calendar.current.date(from: components) + } + + static 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.current.date(from: components) + } + + static func canSelectQuarter(_ quarterStart: Date, state: State) -> Bool { + guard let earliestQuarterStart = state.earliestQuarterStart, + let currentQuarterStart = self.quarterStart(for: Date()) else { return false } + return earliestQuarterStart <= quarterStart && quarterStart <= currentQuarterStart + } + + static func normalizeActivityKinds(_ rawValues: [String]) -> Set { + let selectableActivityKindRawValues = Set(ActivityKindItem.selectableItems.map(\.rawValue)) + + return Set( + rawValues + .compactMap(ActivityKind.init(rawValue:)) + .filter { selectableActivityKindRawValues.contains($0.rawValue) } + ) + } + + static func canMoveToQuarter(offsetMonths: Int, state: State) -> Bool { + guard let selectedQuarterStart = state.selectedQuarterStart else { return false } + guard let targetQuarterStart = Calendar.current.date( + byAdding: .month, + value: offsetMonths, + to: selectedQuarterStart + ) else { + return false + } + return canSelectQuarter(targetQuarterStart, state: state) + } + + static func fetchQuarterActivityData( + from quarterStart: Date, + fetchTodosUseCase: FetchTodosUseCase + ) async throws -> (quarter: HeatmapQuarter, dayActivitiesByDate: [Date: [HeatmapActivityItem]]) { + guard let nextQuarterStart = Calendar.current.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 + ) + } + + static func makeQuarterActivityData( + createdTodos: [Todo], + completedTodos: [Todo], + deletedTodos: [Todo], + quarterStart: Date + ) -> (quarter: HeatmapQuarter, dayActivitiesByDate: [Date: [HeatmapActivityItem]]) { + var dailyCountsByDate: [Date: ProfileHeatmapActivityCounts] = [:] + var activityEntriesByDate: [Date: [String: ProfileHeatmapActivityEntry]] = [:] + + 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) + } + + private static func makeActivityMonths( + dailyCountsByDate: [Date: ProfileHeatmapActivityCounts], + quarterStart: Date + ) -> [HeatmapMonth] { + let monthStarts = (0..<3).compactMap { + Calendar.current.date(byAdding: .month, value: $0, to: quarterStart) + } + + return monthStarts.map { monthStart in + makeActivityMonth( + monthStart: monthStart, + dailyCountsByDate: dailyCountsByDate + ) + } + } + + private static func makeActivityMonth( + monthStart: Date, + dailyCountsByDate: [Date: ProfileHeatmapActivityCounts] + ) -> HeatmapMonth { + guard let monthInterval = Calendar.current.dateInterval(of: .month, for: monthStart), + let monthLastDay = Calendar.current.date(byAdding: .day, value: -1, to: monthInterval.end), + let firstWeekInterval = Calendar.current.dateInterval(of: .weekOfYear, for: monthInterval.start), + let lastWeekInterval = Calendar.current.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.current.startOfDay(for: cursor) + let isInMonth = Calendar.current.isDate(normalizedDate, equalTo: monthStart, toGranularity: .month) + let dailyCounts = dailyCountsByDate[normalizedDate] ?? ProfileHeatmapActivityCounts() + 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.current.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..) -> [ActivityKind] { + let orderedActivityKinds: [ActivityKind] = [.created, .completed, .deleted] + return orderedActivityKinds.filter { activityKinds.contains($0) } + } +} diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileFeature+State.swift b/Application/DevLogPresentation/Sources/Profile/ProfileFeature+State.swift new file mode 100644 index 00000000..d5514764 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Profile/ProfileFeature+State.swift @@ -0,0 +1,76 @@ +// +// ProfileFeature+State.swift +// DevLogPresentation +// +// Created by opfic on 6/15/26. +// + +import DevLogCore +import Foundation + +extension ProfileFeature.State { + var isLoading: Bool { + loading.isLoading + } + + var quarterTitle: String { + guard let start = selectedQuarterStart else { return "" } + let year = Calendar.current.component(.year, from: start) + let month = Calendar.current.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 else { return [] } + let dayStart = Calendar.current.startOfDay(for: selectedDay.date) + let activities = dayActivitiesByDate[dayStart] ?? [] + + return activities.filter { activity in + !Set(activity.activityKinds).isDisjoint(with: selectedActivityKinds) + } + } + + var canMoveToPreviousQuarter: Bool { + ProfileFeature.canMoveToQuarter(offsetMonths: -3, state: self) + } + + var canMoveToNextQuarter: Bool { + ProfileFeature.canMoveToQuarter(offsetMonths: 3, state: self) + } + + var isViewingCurrentQuarter: Bool { + guard let selectedQuarterStart, + let currentQuarterStart = ProfileFeature.quarterStart(for: Date()) else { + return false + } + return selectedQuarterStart == currentQuarterStart + } + + var availableQuarterYears: [Int] { + guard let earliestQuarterStart, + let currentQuarterStart = ProfileFeature.quarterStart(for: Date()) else { + return [selectedQuarterPickerYear] + } + let earliestYear = Calendar.current.component(.year, from: earliestQuarterStart) + let currentYear = Calendar.current.component(.year, from: currentQuarterStart) + return Array(stride(from: currentYear, through: earliestYear, by: -1)) + } + + func quarterStartForPicker(quarter: Int) -> Date? { + ProfileFeature.quarterStart(year: selectedQuarterPickerYear, quarter: quarter) + } + + func isQuarterSelectableForPicker(_ quarter: Int) -> Bool { + guard let quarterStart = quarterStartForPicker(quarter: quarter) else { return false } + return ProfileFeature.canSelectQuarter(quarterStart, state: self) + } + + func isQuarterSelectedForPicker(_ quarter: Int) -> Bool { + quarterStartForPicker(quarter: quarter) == selectedQuarterStart + } +} diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift b/Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift new file mode 100644 index 00000000..ff109273 --- /dev/null +++ b/Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift @@ -0,0 +1,303 @@ +// +// ProfileFeature.swift +// DevLogPresentation +// +// Created by opfic on 6/15/26. +// + +import Combine +import ComposableArchitecture +import DevLogCore +import DevLogDomain +import Foundation + +@Reducer +struct ProfileFeature { + private enum CancelID: Hashable { + case networkConnectivity + } + + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + var name = "" + var email = "" + var isNetworkConnected = true + var statusMessage = "" + var avatarURL: URL? + var avatarImageData: ProfileAvatarImageData? + var earliestQuarterStart: Date? + var selectedQuarterStart: Date? + var showQuarterPicker = 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 = false + var loading = LoadingFeature.State() + } + + enum Action: BindableAction { + case alert(PresentationAction) + case binding(BindingAction) + case startObserving + case fetchData + case refresh + case networkStatusChanged(Bool) + case setAlert(Bool) + case tapResetStatusMessageButton + case willUpdateStatusMessage + case setQuarterPickerPresented(Bool) + case openQuarterPicker + case selectQuarter(Date) + case moveToCurrentQuarter + case moveQuarter(Int) + case toggleActivityKind(ActivityKind) + case selectDay(HeatmapDay?) + case updateStatusTextFieldFocus(Bool) + case store(StoreAction) + case loading(LoadingFeature.Action) + + enum StoreAction { + case fetchUserData(UserProfile) + case setAvatarImageData(URL, Data) + case setActivityQuarter( + quarterStart: Date, + quarter: HeatmapQuarter, + dayActivitiesByDate: [Date: [HeatmapActivityItem]] + ) + } + } + + @Dependency(\.profileFetchUserDataUseCase) var fetchUserDataUseCase + @Dependency(\.profileFetchImageDataUseCase) var fetchProfileImageDataUseCase + @Dependency(\.profileFetchTodosUseCase) var fetchTodosUseCase + @Dependency(\.profileUpsertStatusMessageUseCase) var upsertStatusMessageUseCase + @Dependency(\.networkConnectivityUseCase) var networkConnectivityUseCase + @Dependency(\.profileFetchHeatmapActivityTypesUseCase) var fetchHeatmapActivityTypesUseCase + @Dependency(\.profileUpdateHeatmapActivityTypesUseCase) var updateHeatmapActivityTypesUseCase + + var body: some ReducerOf { + Scope(state: \.loading, action: \.loading) { + LoadingFeature() + } + BindingReducer() + Reduce { state, action in + switch action { + case .alert(.dismiss): + state.alert = nil + case .alert: + break + case .binding: + break + case .startObserving: + return observeNetworkConnectivityEffect() + case .fetchData, .refresh: + if state.selectedQuarterStart == nil, + let quarterStart = Self.quarterStart(for: Date()) { + state.selectedQuarterStart = quarterStart + } + let rawValues = fetchHeatmapActivityTypesUseCase.execute() + let settings = Self.normalizeActivityKinds(rawValues) + if !settings.isEmpty { + state.selectedActivityKinds = settings + } + if let selectedQuarterStart = state.selectedQuarterStart { + return .merge( + fetchUserDataEffect(), + fetchActivityQuarterEffect(selectedQuarterStart) + ) + } + return fetchUserDataEffect() + case .networkStatusChanged(let isConnected): + state.isNetworkConnected = isConnected + case .setAlert(let isPresented): + state.alert = isPresented ? Self.alertState() : nil + case .tapResetStatusMessageButton: + state.statusMessage = "" + case .willUpdateStatusMessage: + if !state.isNetworkConnected { break } + return updateStatusMessageEffect(state.statusMessage) + case .setQuarterPickerPresented(let isPresented): + state.showQuarterPicker = isPresented + case .openQuarterPicker: + if let selectedQuarterStart = state.selectedQuarterStart { + state.selectedQuarterPickerYear = Calendar.current.component(.year, from: selectedQuarterStart) + } + state.showQuarterPicker = true + case .selectQuarter(let quarterStart): + guard Self.canSelectQuarter(quarterStart, state: state) else { break } + state.showQuarterPicker = false + return updateSelectedQuarter(to: quarterStart, state: &state) + case .moveToCurrentQuarter: + guard let currentQuarterStart = Self.quarterStart(for: Date()), + state.selectedQuarterStart != currentQuarterStart else { break } + return updateSelectedQuarter(to: currentQuarterStart, state: &state) + case .moveQuarter(let delta): + guard let selectedQuarterStart = state.selectedQuarterStart else { break } + let monthDelta = 3 * delta + guard let nextQuarterStart = Calendar.current.date( + byAdding: .month, + value: monthDelta, + to: selectedQuarterStart + ) else { break } + guard Self.canSelectQuarter(nextQuarterStart, state: state) else { break } + return updateSelectedQuarter(to: nextQuarterStart, state: &state) + 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) + } + return updateHeatmapActivityKindsEffect(state.selectedActivityKinds) + case .selectDay(let day): + if let day, state.selectedDay?.date == day.date { + state.selectedDay = nil + } else { + state.selectedDay = day + } + case .updateStatusTextFieldFocus(let focused): + state.showDoneButton = focused + case .store(.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 state.earliestQuarterStart == nil { + state.earliestQuarterStart = Self.quarterStart(for: profile.createdAt) + ?? Calendar.current.startOfDay(for: profile.createdAt) + } + if let avatarURL = profile.avatarURL { + return fetchAvatarImageDataEffect(avatarURL) + } + case .store(.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 .store(.setActivityQuarter(let quarterStart, let quarter, let dayActivitiesByDate)): + guard state.selectedQuarterStart == quarterStart else { break } + state.activityQuarter = quarter + state.dayActivitiesByDate = dayActivitiesByDate + case .loading: + break + } + + return .none + } + .ifLet(\.$alert, action: \.alert) + } +} + +private extension ProfileFeature { + func observeNetworkConnectivityEffect() -> Effect { + .publisher { [networkConnectivityUseCase] in + networkConnectivityUseCase.observe() + .receive(on: DispatchQueue.main) + .map(Action.networkStatusChanged) + } + .cancellable(id: CancelID.networkConnectivity, cancelInFlight: true) + } + + func fetchUserDataEffect() -> Effect { + .run { [fetchUserDataUseCase] send in + do { + let profile = try await fetchUserDataUseCase.execute() + await send(.store(.fetchUserData(profile))) + } catch { + await send(.setAlert(true)) + } + } + } + + func fetchAvatarImageDataEffect(_ url: URL) -> Effect { + .run { [fetchProfileImageDataUseCase] send in + do { + let data = try await fetchProfileImageDataUseCase.execute(from: url) + await send(.store(.setAvatarImageData(url, data))) + } catch { } + } + } + + func fetchActivityQuarterEffect(_ quarterStart: Date) -> Effect { + .run { [fetchTodosUseCase] send in + await send(.loading(.begin(target: .default, mode: .delayed))) + do { + let data = try await Self.fetchQuarterActivityData( + from: quarterStart, + fetchTodosUseCase: fetchTodosUseCase + ) + await send( + .store( + .setActivityQuarter( + quarterStart: quarterStart, + quarter: data.quarter, + dayActivitiesByDate: data.dayActivitiesByDate + ) + ) + ) + await send(.loading(.end(target: .default, mode: .delayed))) + } catch { + await send(.loading(.end(target: .default, mode: .delayed))) + await send(.setAlert(true)) + } + } + } + + func updateStatusMessageEffect(_ message: String) -> Effect { + .run { [upsertStatusMessageUseCase] send in + do { + try await upsertStatusMessageUseCase.execute(message) + } catch { + await send(.setAlert(true)) + } + } + } + + func updateHeatmapActivityKindsEffect(_ activityKinds: Set) -> Effect { + .run { [updateHeatmapActivityTypesUseCase] _ in + 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) + } + } + + func updateSelectedQuarter( + to quarterStart: Date, + state: inout State + ) -> Effect { + guard state.selectedQuarterStart != quarterStart else { return .none } + state.selectedQuarterStart = quarterStart + state.activityQuarter = nil + state.dayActivitiesByDate = [:] + state.selectedDay = nil + return fetchActivityQuarterEffect(quarterStart) + } + + static func alertState() -> AlertState { + AlertState { + TextState("") + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close")) + } + } message: { + TextState(String(localized: "common_error_message")) + } + } +} diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift index 20cf24a2..28f04b85 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift @@ -5,14 +5,26 @@ // Created by opfic on 5/7/25. // +// swiftlint:disable file_length import SwiftUI +import ComposableArchitecture import DevLogCore import DevLogDomain struct ProfileView: View { + @Bindable var store: StoreOf + @FocusState private var focused: Bool let coordinator: ProfileViewCoordinator let isCompactLayout: Bool - @FocusState private var focused: Bool + + init( + coordinator: ProfileViewCoordinator, + isCompactLayout: Bool + ) { + self.store = coordinator.store + self.coordinator = coordinator + self.isCompactLayout = isCompactLayout + } var body: some View { Group { @@ -29,147 +41,106 @@ struct ProfileView: View { } .onChange(of: focused) { _, newValue in withAnimation { - coordinator.viewModel.send(.updateStatusTextFieldFocus(newValue)) + _ = store.send(.updateStatusTextFieldFocus(newValue)) } } - .alert( - "", - isPresented: Binding( - get: { coordinator.viewModel.state.showAlert }, - set: { coordinator.viewModel.send(.setAlert($0)) } - ) - ) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(coordinator.viewModel.state.alertMessage) - } - .sheet( - isPresented: Binding( - get: { coordinator.viewModel.state.showQuarterPicker }, - set: { coordinator.viewModel.send(.setQuarterPickerPresented($0)) } - ) - ) { + .alert($store.scope(state: \.alert, action: \.alert)) + .sheet(isPresented: $store.showQuarterPicker) { quarterPickerSheet } + .overlay { + if store.isLoading { + LoadingView() + } + } } private var profileContentView: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 16) { - HStack { - profileAvatarImage - .frame(width: 60, height: 60) - .cornerRadius(30) - .foregroundStyle(Color.gray) - - VStack(alignment: .leading) { - Text(coordinator.viewModel.state.name) - .font(.title2) - .bold() - Text(coordinator.viewModel.state.email) - .font(.caption2) - .foregroundStyle(Color.gray) - } - } - let connected = coordinator.viewModel.state.isNetworkConnected - HStack { - HStack { - Image(systemName: "face.smiling") - TextField( - text: Binding( - get: { coordinator.viewModel.state.statusMessage }, - set: { coordinator.viewModel.send(.updateStatusMessage($0)) } - ) - ) { - Text(String(localized: "profile_status_placeholder")) - } - .frame(height: UIFont.preferredFont(forTextStyle: .body).lineHeight) - .focused($focused) - .disabled(!connected) - - if !coordinator.viewModel.state.statusMessage.isEmpty, - coordinator.viewModel.state.showDoneButton { - Button(action: { - coordinator.viewModel.send(.tapResetStatusMessageButton) - }) { - Image(systemName: "xmark.circle.fill") - } - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - } - .foregroundStyle(Color.gray) - .padding(8) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(Color(.secondarySystemGroupedBackground)) - ) - if coordinator.viewModel.state.showDoneButton { - Button(action: { - focused = false - coordinator.viewModel.send(.willUpdateStatusMessage) - }) { - Text(String(localized: "profile_done")) - } - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - } - .opacity(connected ? 1 : 0.7) + profileHeader + statusMessageSection activityHeatmapSection } .padding(.horizontal, 16) } - .refreshable { coordinator.viewModel.send(.refresh) } + .refreshable { store.send(.refresh) } .frame(maxWidth: .infinity) .background(Color(.systemGroupedBackground)) - .toolbar { profileToolbarContent } + .toolbar { toolbar } } - @ViewBuilder - private var profileAvatarImage: some View { - if let data = coordinator.viewModel.state.avatarImageData?.data, - let uiImage = UIImage(data: data) { - Image(uiImage: uiImage) - .resizable() - .scaledToFill() - } else { - Image(systemName: "person.crop.circle.fill") - .resizable() - .scaledToFill() - .foregroundStyle(Color(.systemGray2)) - } - } - - @ToolbarContentBuilder - private var profileToolbarContent: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { - Button { - if isCompactLayout { - coordinator.router.push(.settings) + private var profileHeader: some View { + HStack { + Group { + if let data = store.avatarImageData?.data, + let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() } else { - coordinator.router.replace(with: .settings) + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFill() + .foregroundStyle(Color(.systemGray2)) } - } label: { - Image(systemName: "gearshape") + } + .frame(width: 60, height: 60) + .cornerRadius(30) + + VStack(alignment: .leading) { + Text(store.name) + .font(.title2) + .bold() + Text(store.email) + .font(.caption2) + .foregroundStyle(Color.gray) } } } - @ViewBuilder - private func profileDestinationView(_ route: ProfileRoute) -> some View { - switch route { - case .settings: - SettingsView(store: coordinator.settingsStore) - .environment(coordinator.router) - case .activity(let todoId): - TodoDetailView(store: coordinator.makeTodoDetailStore(todoId: todoId)) - case .theme: - @Bindable var settingsStore = coordinator.settingsStore - ThemeView(theme: $settingsStore.theme) - case .pushNotification: - PushNotificationSettingsView(store: coordinator.makePushNotificationSettingsStore()) - case .account: - AccountView(store: coordinator.makeAccountStore()) + private var statusMessageSection: some View { + let connected = store.isNetworkConnected + + return HStack { + HStack { + Image(systemName: "face.smiling") + TextField( + text: $store.statusMessage + ) { + Text(String(localized: "profile_status_placeholder")) + } + .frame(height: UIFont.preferredFont(forTextStyle: .body).lineHeight) + .focused($focused) + .disabled(!connected) + + if !store.statusMessage.isEmpty, + store.showDoneButton { + Button(action: { + store.send(.tapResetStatusMessageButton) + }) { + Image(systemName: "xmark.circle.fill") + } + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .foregroundStyle(Color.gray) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(.secondarySystemGroupedBackground)) + ) + if store.showDoneButton { + Button(action: { + focused = false + store.send(.willUpdateStatusMessage) + }) { + Text(String(localized: "profile_done")) + } + .transition(.move(edge: .trailing).combined(with: .opacity)) + } } + .opacity(connected ? 1 : 0.7) } private var activityHeatmapSection: some View { @@ -178,26 +149,92 @@ struct ProfileView: View { Text(String(localized: "profile_quarterly_activity")) .font(.headline) Spacer() - quarterResetButton - activityTypeSelector + if !store.isViewingCurrentQuarter { + Button { + store.send(.moveToCurrentQuarter) + } label: { + Image(systemName: "arrow.uturn.backward") + .bold() + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + } + Menu { + ForEach(ActivityKindItem.selectableItems) { activityKindItem in + Toggle( + activityKindItem.title, + isOn: Binding( + get: { + guard let activityKind = ActivityKind( + rawValue: activityKindItem.rawValue + ) else { + return false + } + return store.selectedActivityKinds.contains(activityKind) + }, + set: { _ in + guard let activityKind = ActivityKind( + rawValue: activityKindItem.rawValue + ) else { + return + } + store.send(.toggleActivityKind(activityKind)) + } + ) + ) + .disabled({ + guard let activityKind = ActivityKind(rawValue: activityKindItem.rawValue) else { + return false + } + return store.selectedActivityKinds.count == 1 + && store.selectedActivityKinds.contains(activityKind) + }()) + } + } label: { + Image(systemName: "line.3.horizontal.decrease") + .bold() + .foregroundStyle(.blue) + } } - quarterNavigator + HStack { + Button { + store.send(.moveQuarter(-1)) + } label: { + Image(systemName: "chevron.left") + } + .disabled(!store.canMoveToPreviousQuarter) + Spacer() + Button { + store.send(.openQuarterPicker) + } label: { + HStack(spacing: 4) { + Text(store.quarterTitle) + .font(.subheadline) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + } + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + Spacer() + Button { + store.send(.moveQuarter(1)) + } label: { + Image(systemName: "chevron.right") + } + .disabled(!store.canMoveToNextQuarter) + } - if let quarter = coordinator.viewModel.state.activityQuarter { + if let quarter = store.activityQuarter { HeatmapView( quarter: quarter, - selectedActivityKinds: coordinator.viewModel.state.selectedActivityKinds, - selectedDay: coordinator.viewModel.state.selectedDay, - onSelectDay: { coordinator.viewModel.send(.selectDay($0)) } + selectedActivityKinds: store.selectedActivityKinds, + selectedDay: store.selectedDay, + onSelectDay: { store.send(.selectDay($0)) } ) - if let selectedDay = coordinator.viewModel.state.selectedDay { + if let selectedDay = store.selectedDay { selectedDayDetailSection(for: selectedDay) - .overlay { - if coordinator.viewModel.state.isLoading { - LoadingView() - } - } } } } @@ -208,83 +245,36 @@ struct ProfileView: View { ) } - @ViewBuilder - private var quarterResetButton: some View { - if !coordinator.viewModel.isViewingCurrentQuarter { + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { Button { - coordinator.viewModel.send(.moveToCurrentQuarter) + if isCompactLayout { + coordinator.router.push(.settings) + } else { + coordinator.router.replace(with: .settings) + } } label: { - Image(systemName: "arrow.uturn.backward") - .bold() - .foregroundStyle(.blue) - } - .buttonStyle(.plain) - } - } - - private var activityTypeSelector: some View { - Menu { - ForEach(ActivityKindItem.selectableItems) { activityKindItem in - Toggle( - activityKindItem.title, - isOn: Binding( - get: { - guard let activityKind = ActivityKind(rawValue: activityKindItem.rawValue) else { - return false - } - return coordinator.viewModel.state.selectedActivityKinds.contains(activityKind) - }, - set: { _ in - guard let activityKind = ActivityKind(rawValue: activityKindItem.rawValue) else { - return - } - coordinator.viewModel.send(.toggleActivityKind(activityKind)) - } - ) - ) - .disabled({ - guard let activityKind = ActivityKind(rawValue: activityKindItem.rawValue) else { - return false - } - return coordinator.viewModel.state.selectedActivityKinds.count == 1 - && coordinator.viewModel.state.selectedActivityKinds.contains(activityKind) - }()) + Image(systemName: "gearshape") } - } label: { - Image(systemName: "line.3.horizontal.decrease") - .bold() - .foregroundStyle(.blue) } } - private var quarterNavigator: some View { - HStack { - Button { - coordinator.viewModel.send(.moveQuarter(-1)) - } label: { - Image(systemName: "chevron.left") - } - .disabled(!coordinator.viewModel.canMoveToPreviousQuarter) - Spacer() - Button { - coordinator.viewModel.send(.openQuarterPicker) - } label: { - HStack(spacing: 4) { - Text(coordinator.viewModel.quarterTitle) - .font(.subheadline) - Image(systemName: "chevron.up.chevron.down") - .font(.caption2) - } - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - Spacer() - Button { - coordinator.viewModel.send(.moveQuarter(1)) - } label: { - Image(systemName: "chevron.right") - } - .disabled(!coordinator.viewModel.canMoveToNextQuarter) + @ViewBuilder + private func profileDestinationView(_ route: ProfileRoute) -> some View { + switch route { + case .settings: + SettingsView(store: coordinator.settingsStore) + .environment(coordinator.router) + case .activity(let todoId): + TodoDetailView(store: coordinator.makeTodoDetailStore(todoId: todoId)) + case .theme: + @Bindable var settingsStore = coordinator.settingsStore + ThemeView(theme: $settingsStore.theme) + case .pushNotification: + PushNotificationSettingsView(store: coordinator.makePushNotificationSettingsStore()) + case .account: + AccountView(store: coordinator.makeAccountStore()) } } @@ -298,12 +288,9 @@ struct ProfileView: View { Spacer() Picker( "", - selection: Binding( - get: { coordinator.viewModel.state.selectedQuarterPickerYear }, - set: { coordinator.viewModel.send(.setQuarterPickerYear($0)) } - ) + selection: $store.selectedQuarterPickerYear ) { - ForEach(coordinator.viewModel.availableQuarterYears, id: \.self) { year in + ForEach(store.availableQuarterYears, id: \.self) { year in Text(verbatim: String(year)) .tag(year) } @@ -325,7 +312,7 @@ struct ProfileView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarTrailingButton { - coordinator.viewModel.send(.setQuarterPickerPresented(false)) + store.send(.setQuarterPickerPresented(false)) } } } @@ -335,13 +322,13 @@ struct ProfileView: View { @ViewBuilder private func quarterSelectionButton(for quarter: Int) -> some View { - let quarterStart = coordinator.viewModel.quarterStartForPicker(quarter: quarter) - let isEnabled = coordinator.viewModel.isQuarterSelectableForPicker(quarter) - let isSelected = coordinator.viewModel.isQuarterSelectedForPicker(quarter) + let quarterStart = store.state.quarterStartForPicker(quarter: quarter) + let isEnabled = store.state.isQuarterSelectableForPicker(quarter) + let isSelected = store.state.isQuarterSelectedForPicker(quarter) Button { guard let quarterStart else { return } - coordinator.viewModel.send(.selectQuarter(quarterStart)) + store.send(.selectQuarter(quarterStart)) } label: { Text( String.localizedStringWithFormat( @@ -364,7 +351,7 @@ struct ProfileView: View { @ViewBuilder private func selectedDayDetailSection(for day: HeatmapDay) -> some View { - let activities = coordinator.viewModel.selectedDayActivities + let activities = store.selectedDayActivities VStack(alignment: .leading, spacing: 12) { Text(day.date.formatted(.dateTime.year().month(.wide).day())) @@ -380,13 +367,7 @@ struct ProfileView: View { } else { ForEach(activities) { activity in Button { - if !activity.isDeleted { - if isCompactLayout { - coordinator.router.push(.activity(activity.todoId)) - } else { - coordinator.router.replace(with: .activity(activity.todoId)) - } - } + selectActivity(activity) } label: { let item = TodoCategoryItem(from: activity.category) let rowColor = activity.isDeleted ? Color.secondary : .primary @@ -401,15 +382,15 @@ struct ProfileView: View { Text("#\(activity.number)") .font(.caption) .foregroundStyle(.secondary) - ForEach(activity.activityKindItems) { activityKindItem in - Text(activityKindItem.title) + ForEach(activity.activityKindItems) { item in + Text(item.title) .font(.caption2) - .foregroundStyle(activityKindItem.badgeColor) + .foregroundStyle(item.badgeColor) .padding(.horizontal, 6) .padding(.vertical, 2) .background( Capsule() - .fill(activityKindItem.badgeColor.opacity(0.14)) + .fill(item.badgeColor.opacity(0.14)) ) } Spacer() @@ -436,6 +417,16 @@ struct ProfileView: View { set: { coordinator.router.path = $0 } ) } + + private func selectActivity(_ activity: HeatmapActivityItem) { + guard !activity.isDeleted else { return } + + if isCompactLayout { + coordinator.router.push(.activity(activity.todoId)) + } else { + coordinator.router.replace(with: .activity(activity.todoId)) + } + } } enum ProfileRoute: Hashable { diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift b/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift index 7fe9ab22..cb05acc6 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift @@ -13,22 +13,24 @@ import DevLogDomain @MainActor @Observable final class ProfileViewCoordinator { - let viewModel: ProfileViewModel + let store: StoreOf let settingsStore: StoreOf var router = NavigationRouter() private let container: DIContainer init(container: DIContainer) { self.container = container - self.viewModel = ProfileViewModel( - fetchUserDataUseCase: container.resolve(FetchUserDataUseCase.self), - fetchProfileImageDataUseCase: container.resolve(FetchProfileImageDataUseCase.self), - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - upsertStatusMessageUseCase: container.resolve(UpsertStatusMessageUseCase.self), - networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self), - fetchHeatmapActivityTypesUseCase: container.resolve(FetchHeatmapActivityTypesUseCase.self), - updateHeatmapActivityTypesUseCase: container.resolve(UpdateHeatmapActivityTypesUseCase.self) - ) + self.store = Store(initialState: ProfileFeature.State()) { + ProfileFeature() + } withDependencies: { + $0.profileFetchUserDataUseCase = container.resolve(FetchUserDataUseCase.self) + $0.profileFetchImageDataUseCase = container.resolve(FetchProfileImageDataUseCase.self) + $0.profileFetchTodosUseCase = container.resolve(FetchTodosUseCase.self) + $0.profileUpsertStatusMessageUseCase = container.resolve(UpsertStatusMessageUseCase.self) + $0.networkConnectivityUseCase = container.resolve(ObserveNetworkConnectivityUseCase.self) + $0.profileFetchHeatmapActivityTypesUseCase = container.resolve(FetchHeatmapActivityTypesUseCase.self) + $0.profileUpdateHeatmapActivityTypesUseCase = container.resolve(UpdateHeatmapActivityTypesUseCase.self) + } self.settingsStore = Store(initialState: SettingsFeature.State()) { SettingsFeature() } withDependencies: { @@ -40,11 +42,12 @@ final class ProfileViewCoordinator { $0.fetchWebPageImageDirSizeUseCase = container.resolve(FetchWebPageImageDirSizeUseCase.self) $0.clearWebPageImageDirectoryUseCase = container.resolve(ClearWebPageImageDirectoryUseCase.self) } + self.store.send(.startObserving) self.settingsStore.send(.startObserving) } func fetchData() { - viewModel.send(.fetchData) + store.send(.fetchData) } func makeAccountStore() -> StoreOf { diff --git a/Application/DevLogPresentation/Tests/Profile/ProfileViewModelTests.swift b/Application/DevLogPresentation/Tests/Profile/ProfileViewModelTests.swift index 43c54cde..57f158aa 100644 --- a/Application/DevLogPresentation/Tests/Profile/ProfileViewModelTests.swift +++ b/Application/DevLogPresentation/Tests/Profile/ProfileViewModelTests.swift @@ -6,7 +6,9 @@ // import Testing +import ComposableArchitecture import Foundation +import DevLogCore import DevLogDomain @testable import DevLogPresentation @@ -39,11 +41,94 @@ struct ProfileViewModelTests { #expect(spy.calledURLs == [avatarURL, avatarURL]) #expect(viewModel.state.avatarImageData?.data == imageData) } + + @Test("ProfileFeature는 같은 아바타 URL을 다시 받아도 프로필 이미지 데이터를 다시 요청한다") + func ProfileFeature는_같은_아바타_URL을_다시_받아도_프로필_이미지_데이터를_다시_요청한다() async { + let imageData = Data([1, 2, 3]) + let spy = FetchProfileImageDataUseCaseSpy(data: imageData) + let adapter = ProfileStoreTestAdapter(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) + ) + + await adapter.fetchUserData(profile) + await adapter.fetchUserData(profile) + + #expect(spy.calledURLs == [avatarURL, avatarURL]) + #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 { + let spy = UpsertStatusMessageUseCaseSpy() + let adapter = ProfileStoreTestAdapter(upsertStatusMessageUseCase: spy) + + await adapter.updateStatusMessage("working") + await adapter.willUpdateStatusMessage() + + #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 { + let spy = UpdateHeatmapActivityTypesUseCaseSpy() + let fetchSpy = FetchHeatmapActivityTypesUseCaseSpy() + fetchSpy.activityTypes = ["created"] + let adapter = ProfileStoreTestAdapter( + fetchHeatmapActivityTypesUseCase: fetchSpy, + updateHeatmapActivityTypesUseCase: spy + ) + + await adapter.fetchData() + await adapter.toggleActivityKind(.created) + + #expect(adapter.selectedActivityKinds == [.created]) + #expect(spy.activityTypes.isEmpty) + } } @MainActor private func makeProfileViewModel( - fetchProfileImageDataUseCase: FetchProfileImageDataUseCase + fetchProfileImageDataUseCase: FetchProfileImageDataUseCase = FetchProfileImageDataUseCaseSpy(data: Data()), + upsertStatusMessageUseCase: UpsertStatusMessageUseCase = UpsertStatusMessageUseCaseSpy(), + fetchHeatmapActivityTypesUseCase: FetchHeatmapActivityTypesUseCase = FetchHeatmapActivityTypesUseCaseSpy(), + updateHeatmapActivityTypesUseCase: UpdateHeatmapActivityTypesUseCase = UpdateHeatmapActivityTypesUseCaseSpy() ) -> ProfileViewModel { ProfileViewModel( fetchUserDataUseCase: FetchUserDataUseCaseSpy( @@ -57,9 +142,78 @@ private func makeProfileViewModel( ), fetchProfileImageDataUseCase: fetchProfileImageDataUseCase, fetchTodosUseCase: FetchTodosUseCaseSpy(), - upsertStatusMessageUseCase: UpsertStatusMessageUseCaseSpy(), + upsertStatusMessageUseCase: upsertStatusMessageUseCase, networkConnectivityUseCase: ObserveNetworkConnectivityUseCaseSpy(), - fetchHeatmapActivityTypesUseCase: FetchHeatmapActivityTypesUseCaseSpy(), - updateHeatmapActivityTypesUseCase: UpdateHeatmapActivityTypesUseCaseSpy() + fetchHeatmapActivityTypesUseCase: fetchHeatmapActivityTypesUseCase, + updateHeatmapActivityTypesUseCase: updateHeatmapActivityTypesUseCase ) } + +@MainActor +private struct ProfileStoreTestAdapter { + private let store: TestStoreOf + + var avatarImageData: ProfileAvatarImageData? { store.state.avatarImageData } + var selectedActivityKinds: Set { store.state.selectedActivityKinds } + + init( + fetchProfileImageDataUseCase: FetchProfileImageDataUseCase = FetchProfileImageDataUseCaseSpy(data: Data()), + upsertStatusMessageUseCase: UpsertStatusMessageUseCase = UpsertStatusMessageUseCaseSpy(), + fetchHeatmapActivityTypesUseCase: FetchHeatmapActivityTypesUseCase = FetchHeatmapActivityTypesUseCaseSpy(), + updateHeatmapActivityTypesUseCase: UpdateHeatmapActivityTypesUseCase = UpdateHeatmapActivityTypesUseCaseSpy() + ) { + store = TestStore(initialState: ProfileFeature.State()) { + ProfileFeature() + } withDependencies: { + $0.profileFetchUserDataUseCase = FetchUserDataUseCaseSpy( + profile: UserProfile( + name: "opfic", + email: "opfic@example.com", + statusMessage: "", + avatarURL: nil, + createdAt: Date(timeIntervalSince1970: 0) + ) + ) + $0.profileFetchImageDataUseCase = fetchProfileImageDataUseCase + $0.profileFetchTodosUseCase = FetchTodosUseCaseSpy() + $0.profileUpsertStatusMessageUseCase = upsertStatusMessageUseCase + $0.networkConnectivityUseCase = ObserveNetworkConnectivityUseCaseSpy() + $0.profileFetchHeatmapActivityTypesUseCase = fetchHeatmapActivityTypesUseCase + $0.profileUpdateHeatmapActivityTypesUseCase = updateHeatmapActivityTypesUseCase + $0.continuousClock = ImmediateClock() + } + store.exhaustivity = .off(showSkippedAssertions: false) + } + + func fetchData() async { + await store.send(.fetchData) + await drainReceivedActions() + } + + func fetchUserData(_ profile: UserProfile) async { + await store.send(.store(.fetchUserData(profile))) + await drainReceivedActions() + } + + func updateStatusMessage(_ message: String) async { + await store.send(.binding(.set(\.statusMessage, message))) { + $0.statusMessage = message + } + } + + func willUpdateStatusMessage() async { + await store.send(.willUpdateStatusMessage) + await drainReceivedActions() + } + + func toggleActivityKind(_ activityKind: ActivityKind) async { + await store.send(.toggleActivityKind(activityKind)) + await drainReceivedActions() + } + + private func drainReceivedActions() async { + for _ in 0..<10 { + await store.skipReceivedActions(strict: false) + } + } +} From 3ad4a717844630111ff2002db3690ee576aaf713 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 16 Jun 2026 02:47:37 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20ProfileHeatmapBuilder=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Profile/ProfileFeature+State.swift | 12 ++++++------ .../Sources/Profile/ProfileFeature.swift | 14 +++++++------- ...e+Heatmap.swift => ProfileHeatmapBuilder.swift} | 10 +++++----- 3 files changed, 18 insertions(+), 18 deletions(-) rename Application/DevLogPresentation/Sources/Profile/{ProfileFeature+Heatmap.swift => ProfileHeatmapBuilder.swift} (97%) diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileFeature+State.swift b/Application/DevLogPresentation/Sources/Profile/ProfileFeature+State.swift index d5514764..3f2efa53 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileFeature+State.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileFeature+State.swift @@ -36,16 +36,16 @@ extension ProfileFeature.State { } var canMoveToPreviousQuarter: Bool { - ProfileFeature.canMoveToQuarter(offsetMonths: -3, state: self) + ProfileHeatmapBuilder.canMoveToQuarter(offsetMonths: -3, state: self) } var canMoveToNextQuarter: Bool { - ProfileFeature.canMoveToQuarter(offsetMonths: 3, state: self) + ProfileHeatmapBuilder.canMoveToQuarter(offsetMonths: 3, state: self) } var isViewingCurrentQuarter: Bool { guard let selectedQuarterStart, - let currentQuarterStart = ProfileFeature.quarterStart(for: Date()) else { + let currentQuarterStart = ProfileHeatmapBuilder.quarterStart(for: Date()) else { return false } return selectedQuarterStart == currentQuarterStart @@ -53,7 +53,7 @@ extension ProfileFeature.State { var availableQuarterYears: [Int] { guard let earliestQuarterStart, - let currentQuarterStart = ProfileFeature.quarterStart(for: Date()) else { + let currentQuarterStart = ProfileHeatmapBuilder.quarterStart(for: Date()) else { return [selectedQuarterPickerYear] } let earliestYear = Calendar.current.component(.year, from: earliestQuarterStart) @@ -62,12 +62,12 @@ extension ProfileFeature.State { } func quarterStartForPicker(quarter: Int) -> Date? { - ProfileFeature.quarterStart(year: selectedQuarterPickerYear, quarter: quarter) + ProfileHeatmapBuilder.quarterStart(year: selectedQuarterPickerYear, quarter: quarter) } func isQuarterSelectableForPicker(_ quarter: Int) -> Bool { guard let quarterStart = quarterStartForPicker(quarter: quarter) else { return false } - return ProfileFeature.canSelectQuarter(quarterStart, state: self) + return ProfileHeatmapBuilder.canSelectQuarter(quarterStart, state: self) } func isQuarterSelectedForPicker(_ quarter: Int) -> Bool { diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift b/Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift index ff109273..9a3f7a94 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift @@ -95,11 +95,11 @@ struct ProfileFeature { return observeNetworkConnectivityEffect() case .fetchData, .refresh: if state.selectedQuarterStart == nil, - let quarterStart = Self.quarterStart(for: Date()) { + let quarterStart = ProfileHeatmapBuilder.quarterStart(for: Date()) { state.selectedQuarterStart = quarterStart } let rawValues = fetchHeatmapActivityTypesUseCase.execute() - let settings = Self.normalizeActivityKinds(rawValues) + let settings = ProfileHeatmapBuilder.normalizeActivityKinds(rawValues) if !settings.isEmpty { state.selectedActivityKinds = settings } @@ -127,11 +127,11 @@ struct ProfileFeature { } state.showQuarterPicker = true case .selectQuarter(let quarterStart): - guard Self.canSelectQuarter(quarterStart, state: state) else { break } + guard ProfileHeatmapBuilder.canSelectQuarter(quarterStart, state: state) else { break } state.showQuarterPicker = false return updateSelectedQuarter(to: quarterStart, state: &state) case .moveToCurrentQuarter: - guard let currentQuarterStart = Self.quarterStart(for: Date()), + guard let currentQuarterStart = ProfileHeatmapBuilder.quarterStart(for: Date()), state.selectedQuarterStart != currentQuarterStart else { break } return updateSelectedQuarter(to: currentQuarterStart, state: &state) case .moveQuarter(let delta): @@ -142,7 +142,7 @@ struct ProfileFeature { value: monthDelta, to: selectedQuarterStart ) else { break } - guard Self.canSelectQuarter(nextQuarterStart, state: state) else { break } + guard ProfileHeatmapBuilder.canSelectQuarter(nextQuarterStart, state: state) else { break } return updateSelectedQuarter(to: nextQuarterStart, state: &state) case .toggleActivityKind(let activityKind): if state.selectedActivityKinds.contains(activityKind), @@ -174,7 +174,7 @@ struct ProfileFeature { state.avatarImageData = nil } if state.earliestQuarterStart == nil { - state.earliestQuarterStart = Self.quarterStart(for: profile.createdAt) + state.earliestQuarterStart = ProfileHeatmapBuilder.quarterStart(for: profile.createdAt) ?? Calendar.current.startOfDay(for: profile.createdAt) } if let avatarURL = profile.avatarURL { @@ -232,7 +232,7 @@ private extension ProfileFeature { .run { [fetchTodosUseCase] send in await send(.loading(.begin(target: .default, mode: .delayed))) do { - let data = try await Self.fetchQuarterActivityData( + let data = try await ProfileHeatmapBuilder.fetchQuarterActivityData( from: quarterStart, fetchTodosUseCase: fetchTodosUseCase ) diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileFeature+Heatmap.swift b/Application/DevLogPresentation/Sources/Profile/ProfileHeatmapBuilder.swift similarity index 97% rename from Application/DevLogPresentation/Sources/Profile/ProfileFeature+Heatmap.swift rename to Application/DevLogPresentation/Sources/Profile/ProfileHeatmapBuilder.swift index b718739b..c4386c03 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileFeature+Heatmap.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileHeatmapBuilder.swift @@ -1,5 +1,5 @@ // -// ProfileFeature+Heatmap.swift +// ProfileHeatmapBuilder.swift // DevLogPresentation // // Created by opfic on 6/15/26. @@ -31,7 +31,7 @@ private struct ProfileHeatmapActivityEntry { var activityKinds: Set } -extension ProfileFeature { +enum ProfileHeatmapBuilder { static func quarterStart(for date: Date) -> Date? { let month = Calendar.current.component(.month, from: date) let startMonth = ((month - 1) / 3) * 3 + 1 @@ -50,9 +50,9 @@ extension ProfileFeature { return Calendar.current.date(from: components) } - static func canSelectQuarter(_ quarterStart: Date, state: State) -> Bool { + static func canSelectQuarter(_ quarterStart: Date, state: ProfileFeature.State) -> Bool { guard let earliestQuarterStart = state.earliestQuarterStart, - let currentQuarterStart = self.quarterStart(for: Date()) else { return false } + let currentQuarterStart = Self.quarterStart(for: Date()) else { return false } return earliestQuarterStart <= quarterStart && quarterStart <= currentQuarterStart } @@ -66,7 +66,7 @@ extension ProfileFeature { ) } - static func canMoveToQuarter(offsetMonths: Int, state: State) -> Bool { + static func canMoveToQuarter(offsetMonths: Int, state: ProfileFeature.State) -> Bool { guard let selectedQuarterStart = state.selectedQuarterStart else { return false } guard let targetQuarterStart = Calendar.current.date( byAdding: .month, From 384902d2bf5f99fa96bc365d68d597121e2cf1bb Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 16 Jun 2026 02:55:40 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20send=EC=9D=98=20animation=20api?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogPresentation/Sources/Profile/ProfileView.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift index 28f04b85..0445778d 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift @@ -40,9 +40,7 @@ struct ProfileView: View { } } .onChange(of: focused) { _, newValue in - withAnimation { - _ = store.send(.updateStatusTextFieldFocus(newValue)) - } + store.send(.updateStatusTextFieldFocus(newValue), animation: .default) } .alert($store.scope(state: \.alert, action: \.alert)) .sheet(isPresented: $store.showQuarterPicker) { From c36fabd5813d3eb326a77cd6ef09db4611d2213c Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 16 Jun 2026 02:55:56 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor:=20=EA=B0=9C=ED=96=89=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogPresentation/Sources/Profile/ProfileView.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift index 0445778d..b447a9d9 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift @@ -43,9 +43,7 @@ struct ProfileView: View { store.send(.updateStatusTextFieldFocus(newValue), animation: .default) } .alert($store.scope(state: \.alert, action: \.alert)) - .sheet(isPresented: $store.showQuarterPicker) { - quarterPickerSheet - } + .sheet(isPresented: $store.showQuarterPicker) { quarterPickerSheet } .overlay { if store.isLoading { LoadingView() From a384169310fec2740493e80e967815cd9abc1dcf Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 16 Jun 2026 02:57:20 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20Button=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=98=88=EC=8B=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogPresentation/Sources/Profile/ProfileView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift index b447a9d9..449aec45 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift @@ -112,9 +112,9 @@ struct ProfileView: View { if !store.statusMessage.isEmpty, store.showDoneButton { - Button(action: { + Button { store.send(.tapResetStatusMessageButton) - }) { + } label: { Image(systemName: "xmark.circle.fill") } .transition(.move(edge: .trailing).combined(with: .opacity)) @@ -127,10 +127,10 @@ struct ProfileView: View { .fill(Color(.secondarySystemGroupedBackground)) ) if store.showDoneButton { - Button(action: { + Button { focused = false store.send(.willUpdateStatusMessage) - }) { + } label: { Text(String(localized: "profile_done")) } .transition(.move(edge: .trailing).combined(with: .opacity)) From 7363a46f8c630735bdef55cfb7e1c0d5ab5fc1ce Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:04:40 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=ED=86=A0=EA=B8=80=EC=9D=98=20i?= =?UTF-8?q?sOn=EC=97=90=20BindingAction=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Profile/ProfileFeature+State.swift | 35 +++++++++++++++++ .../Sources/Profile/ProfileFeature.swift | 27 ++++++------- .../Sources/Profile/ProfileView.swift | 39 ++++++------------- .../Tests/Profile/ProfileViewModelTests.swift | 15 ++++++- 4 files changed, 75 insertions(+), 41 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileFeature+State.swift b/Application/DevLogPresentation/Sources/Profile/ProfileFeature+State.swift index 3f2efa53..a3798b64 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileFeature+State.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileFeature+State.swift @@ -35,6 +35,33 @@ extension ProfileFeature.State { } } + var isCreatedActivitySelected: Bool { + get { selectedActivityKinds.contains(.created) } + set { setActivityKind(.created, isSelected: newValue) } + } + + var isCompletedActivitySelected: Bool { + get { selectedActivityKinds.contains(.completed) } + set { setActivityKind(.completed, isSelected: newValue) } + } + + var isDeletedActivitySelected: Bool { + get { selectedActivityKinds.contains(.deleted) } + set { setActivityKind(.deleted, isSelected: newValue) } + } + + var isCreatedActivityToggleDisabled: Bool { + selectedActivityKinds == [.created] + } + + var isCompletedActivityToggleDisabled: Bool { + selectedActivityKinds == [.completed] + } + + var isDeletedActivityToggleDisabled: Bool { + selectedActivityKinds == [.deleted] + } + var canMoveToPreviousQuarter: Bool { ProfileHeatmapBuilder.canMoveToQuarter(offsetMonths: -3, state: self) } @@ -73,4 +100,12 @@ extension ProfileFeature.State { func isQuarterSelectedForPicker(_ quarter: Int) -> Bool { quarterStartForPicker(quarter: quarter) == selectedQuarterStart } + + private mutating func setActivityKind(_ activityKind: ActivityKind, isSelected: Bool) { + if isSelected { + selectedActivityKinds.insert(activityKind) + } else if 1 < selectedActivityKinds.count { + selectedActivityKinds.remove(activityKind) + } + } } diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift b/Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift index 9a3f7a94..f24f6b83 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileFeature.swift @@ -53,7 +53,6 @@ struct ProfileFeature { case selectQuarter(Date) case moveToCurrentQuarter case moveQuarter(Int) - case toggleActivityKind(ActivityKind) case selectDay(HeatmapDay?) case updateStatusTextFieldFocus(Bool) case store(StoreAction) @@ -89,6 +88,12 @@ struct ProfileFeature { state.alert = nil case .alert: break + case .binding(\.isCreatedActivitySelected): + return updateHeatmapActivityKindsEffectIfNeeded(.created, state: state) + case .binding(\.isCompletedActivitySelected): + return updateHeatmapActivityKindsEffectIfNeeded(.completed, state: state) + case .binding(\.isDeletedActivitySelected): + return updateHeatmapActivityKindsEffectIfNeeded(.deleted, state: state) case .binding: break case .startObserving: @@ -144,18 +149,6 @@ struct ProfileFeature { ) else { break } guard ProfileHeatmapBuilder.canSelectQuarter(nextQuarterStart, state: state) else { break } return updateSelectedQuarter(to: nextQuarterStart, state: &state) - 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) - } - return updateHeatmapActivityKindsEffect(state.selectedActivityKinds) case .selectDay(let day): if let day, state.selectedDay?.date == day.date { state.selectedDay = nil @@ -277,6 +270,14 @@ private extension ProfileFeature { } } + func updateHeatmapActivityKindsEffectIfNeeded( + _ activityKind: ActivityKind, + state: State + ) -> Effect { + guard state.selectedActivityKinds != [activityKind] else { return .none } + return updateHeatmapActivityKindsEffect(state.selectedActivityKinds) + } + func updateSelectedQuarter( to quarterStart: Date, state: inout State diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift index 449aec45..bb4299be 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift @@ -157,34 +157,19 @@ struct ProfileView: View { } Menu { ForEach(ActivityKindItem.selectableItems) { activityKindItem in - Toggle( - activityKindItem.title, - isOn: Binding( - get: { - guard let activityKind = ActivityKind( - rawValue: activityKindItem.rawValue - ) else { - return false - } - return store.selectedActivityKinds.contains(activityKind) - }, - set: { _ in - guard let activityKind = ActivityKind( - rawValue: activityKindItem.rawValue - ) else { - return - } - store.send(.toggleActivityKind(activityKind)) - } - ) - ) - .disabled({ - guard let activityKind = ActivityKind(rawValue: activityKindItem.rawValue) else { - return false + if let activityKind = ActivityKind(rawValue: activityKindItem.rawValue) { + switch activityKind { + case .created: + Toggle(activityKindItem.title, isOn: $store.isCreatedActivitySelected) + .disabled(store.isCreatedActivityToggleDisabled) + case .completed: + Toggle(activityKindItem.title, isOn: $store.isCompletedActivitySelected) + .disabled(store.isCompletedActivityToggleDisabled) + case .deleted: + Toggle(activityKindItem.title, isOn: $store.isDeletedActivitySelected) + .disabled(store.isDeletedActivityToggleDisabled) } - return store.selectedActivityKinds.count == 1 - && store.selectedActivityKinds.contains(activityKind) - }()) + } } } label: { Image(systemName: "line.3.horizontal.decrease") diff --git a/Application/DevLogPresentation/Tests/Profile/ProfileViewModelTests.swift b/Application/DevLogPresentation/Tests/Profile/ProfileViewModelTests.swift index 57f158aa..478bfe2b 100644 --- a/Application/DevLogPresentation/Tests/Profile/ProfileViewModelTests.swift +++ b/Application/DevLogPresentation/Tests/Profile/ProfileViewModelTests.swift @@ -207,7 +207,20 @@ private struct ProfileStoreTestAdapter { } func toggleActivityKind(_ activityKind: ActivityKind) async { - await store.send(.toggleActivityKind(activityKind)) + switch activityKind { + case .created: + await store.send(.binding(.set(\.isCreatedActivitySelected, !store.state.isCreatedActivitySelected))) { + $0.isCreatedActivitySelected.toggle() + } + case .completed: + await store.send(.binding(.set(\.isCompletedActivitySelected, !store.state.isCompletedActivitySelected))) { + $0.isCompletedActivitySelected.toggle() + } + case .deleted: + await store.send(.binding(.set(\.isDeletedActivitySelected, !store.state.isDeletedActivitySelected))) { + $0.isDeletedActivitySelected.toggle() + } + } await drainReceivedActions() } From 0d24e1b7e68055e9ea93f4273b2d875d3829fef6 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:15:03 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=ED=85=8D=EC=8A=A4=ED=8A=B8=ED=95=84?= =?UTF-8?q?=EB=93=9C=EA=B0=80=20=ED=8F=AC=EC=BB=A4=EC=8B=B1=20=EB=90=A0=20?= =?UTF-8?q?=EB=95=8C=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=82=AC=EC=A7=84?= =?UTF-8?q?=EC=9D=B4=20=EA=B9=9C=EB=B9=A1=EA=B1=B0=EB=A6=AC=EB=8A=94=20?= =?UTF-8?q?=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/DevLogPresentation/Sources/Profile/ProfileView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift index bb4299be..5fa89dcf 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift @@ -83,6 +83,7 @@ struct ProfileView: View { } .frame(width: 60, height: 60) .cornerRadius(30) + .transaction { $0.animation = nil } VStack(alignment: .leading) { Text(store.name) From a205a23d30e1b6d2bf98f23cdaede4a1d5f6ee38 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:46:49 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20refreshable=EC=9D=B4=20?= =?UTF-8?q?=EB=81=9D=EB=82=98=EA=B8=B0=20=EC=A0=84=20ProgressView=EA=B0=80?= =?UTF-8?q?=20=EC=82=AC=EB=9D=BC=EC=A7=80=EB=8A=94=20=ED=98=84=EC=83=81=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogPresentation/Sources/Profile/ProfileView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift index 5fa89dcf..c1f5efac 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift @@ -60,7 +60,7 @@ struct ProfileView: View { } .padding(.horizontal, 16) } - .refreshable { store.send(.refresh) } + .refreshable { await store.send(.refresh).finish() } .frame(maxWidth: .infinity) .background(Color(.systemGroupedBackground)) .toolbar { toolbar }