From 5f78a4a5babe5decc243333b8e791afac24fc6ed Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:24:12 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=9C=A0=EC=8A=A4=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/DomainAssembler.swift | 4 ++++ .../Protocol/ProfileImageDataRepository.swift | 12 +++++++++++ .../FetchProfileImageDataUseCase.swift | 12 +++++++++++ .../FetchProfileImageDataUseCaseImpl.swift | 20 +++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 Application/DevLogDomain/Sources/Protocol/ProfileImageDataRepository.swift create mode 100644 Application/DevLogDomain/Sources/UseCase/UserData/Fetch/ProfileImageData/FetchProfileImageDataUseCase.swift create mode 100644 Application/DevLogDomain/Sources/UseCase/UserData/Fetch/ProfileImageData/FetchProfileImageDataUseCaseImpl.swift diff --git a/Application/DevLogDomain/Sources/DomainAssembler.swift b/Application/DevLogDomain/Sources/DomainAssembler.swift index 4a78965c..03a5fdb0 100644 --- a/Application/DevLogDomain/Sources/DomainAssembler.swift +++ b/Application/DevLogDomain/Sources/DomainAssembler.swift @@ -116,6 +116,10 @@ private extension DomainAssembler { FetchUserDataUseCaseImpl(container.resolve(UserDataRepository.self)) } + container.register(FetchProfileImageDataUseCase.self) { + FetchProfileImageDataUseCaseImpl(container.resolve(ProfileImageDataRepository.self)) + } + container.register(UpsertStatusMessageUseCase.self) { UpsertStatusMessageUseCaseImpl(container.resolve(UserDataRepository.self)) } diff --git a/Application/DevLogDomain/Sources/Protocol/ProfileImageDataRepository.swift b/Application/DevLogDomain/Sources/Protocol/ProfileImageDataRepository.swift new file mode 100644 index 00000000..5134f38b --- /dev/null +++ b/Application/DevLogDomain/Sources/Protocol/ProfileImageDataRepository.swift @@ -0,0 +1,12 @@ +// +// ProfileImageDataRepository.swift +// DevLogDomain +// +// Created by opfic on 6/11/26. +// + +import Foundation + +public protocol ProfileImageDataRepository { + func fetchImageData(from url: URL) async throws -> Data +} diff --git a/Application/DevLogDomain/Sources/UseCase/UserData/Fetch/ProfileImageData/FetchProfileImageDataUseCase.swift b/Application/DevLogDomain/Sources/UseCase/UserData/Fetch/ProfileImageData/FetchProfileImageDataUseCase.swift new file mode 100644 index 00000000..05a67e33 --- /dev/null +++ b/Application/DevLogDomain/Sources/UseCase/UserData/Fetch/ProfileImageData/FetchProfileImageDataUseCase.swift @@ -0,0 +1,12 @@ +// +// FetchProfileImageDataUseCase.swift +// DevLogDomain +// +// Created by opfic on 6/11/26. +// + +import Foundation + +public protocol FetchProfileImageDataUseCase { + func execute(from url: URL) async throws -> Data +} diff --git a/Application/DevLogDomain/Sources/UseCase/UserData/Fetch/ProfileImageData/FetchProfileImageDataUseCaseImpl.swift b/Application/DevLogDomain/Sources/UseCase/UserData/Fetch/ProfileImageData/FetchProfileImageDataUseCaseImpl.swift new file mode 100644 index 00000000..29427a51 --- /dev/null +++ b/Application/DevLogDomain/Sources/UseCase/UserData/Fetch/ProfileImageData/FetchProfileImageDataUseCaseImpl.swift @@ -0,0 +1,20 @@ +// +// FetchProfileImageDataUseCaseImpl.swift +// DevLogDomain +// +// Created by opfic on 6/11/26. +// + +import Foundation + +public final class FetchProfileImageDataUseCaseImpl: FetchProfileImageDataUseCase { + private let repository: ProfileImageDataRepository + + public init(_ repository: ProfileImageDataRepository) { + self.repository = repository + } + + public func execute(from url: URL) async throws -> Data { + try await repository.fetchImageData(from: url) + } +} From d5c3dcc3e1dccc9eba62175b8668c6ca2fcd72a5 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:24:22 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogData/Sources/DataAssembler.swift | 7 ++ .../Protocol/ProfileImageDataService.swift | 12 +++ .../ProfileImageDataRepositoryImpl.swift | 39 ++++++++ .../ProfileImageDataRepositoryImplTests.swift | 89 +++++++++++++++++++ 4 files changed, 147 insertions(+) create mode 100644 Application/DevLogData/Sources/Protocol/ProfileImageDataService.swift create mode 100644 Application/DevLogData/Sources/Repository/ProfileImageDataRepositoryImpl.swift create mode 100644 Application/DevLogData/Tests/Repository/ProfileImageDataRepositoryImplTests.swift diff --git a/Application/DevLogData/Sources/DataAssembler.swift b/Application/DevLogData/Sources/DataAssembler.swift index 42c7b2bd..fe176b3b 100644 --- a/Application/DevLogData/Sources/DataAssembler.swift +++ b/Application/DevLogData/Sources/DataAssembler.swift @@ -94,6 +94,13 @@ public final class DataAssembler: Assembler { UserDataRepositoryImpl(userService: container.resolve(UserService.self)) } + container.register(ProfileImageDataRepository.self) { + ProfileImageDataRepositoryImpl( + service: container.resolve(ProfileImageDataService.self), + store: container.resolve(MemoryCacheStore.self) + ) + } + container.register(AnalyticsRepository.self) { AnalyticsRepositoryImpl( analyticsService: container.resolve(AnalyticsService.self) diff --git a/Application/DevLogData/Sources/Protocol/ProfileImageDataService.swift b/Application/DevLogData/Sources/Protocol/ProfileImageDataService.swift new file mode 100644 index 00000000..ecb00e1f --- /dev/null +++ b/Application/DevLogData/Sources/Protocol/ProfileImageDataService.swift @@ -0,0 +1,12 @@ +// +// ProfileImageDataService.swift +// DevLogData +// +// Created by opfic on 6/11/26. +// + +import Foundation + +public protocol ProfileImageDataService { + func fetchImageData(from url: URL) async throws -> Data +} diff --git a/Application/DevLogData/Sources/Repository/ProfileImageDataRepositoryImpl.swift b/Application/DevLogData/Sources/Repository/ProfileImageDataRepositoryImpl.swift new file mode 100644 index 00000000..09a3f4a4 --- /dev/null +++ b/Application/DevLogData/Sources/Repository/ProfileImageDataRepositoryImpl.swift @@ -0,0 +1,39 @@ +// +// ProfileImageDataRepositoryImpl.swift +// DevLogData +// +// Created by opfic on 6/11/26. +// + +import Foundation +import DevLogDomain + +public final class ProfileImageDataRepositoryImpl: ProfileImageDataRepository { + private let service: ProfileImageDataService + private let store: MemoryCacheStore + + public init( + service: ProfileImageDataService, + store: MemoryCacheStore + ) { + self.service = service + self.store = store + } + + public func fetchImageData(from url: URL) async throws -> Data { + do { + let data = try await service.fetchImageData(from: url) + store.setValue(data, forKey: Self.cacheKey(for: url)) + return data + } catch { + if let data: Data = store.value(forKey: Self.cacheKey(for: url)) { + return data + } + throw error + } + } + + private static func cacheKey(for url: URL) -> String { + "profileImageData:\(url.absoluteString)" + } +} diff --git a/Application/DevLogData/Tests/Repository/ProfileImageDataRepositoryImplTests.swift b/Application/DevLogData/Tests/Repository/ProfileImageDataRepositoryImplTests.swift new file mode 100644 index 00000000..4d251bdb --- /dev/null +++ b/Application/DevLogData/Tests/Repository/ProfileImageDataRepositoryImplTests.swift @@ -0,0 +1,89 @@ +// +// ProfileImageDataRepositoryImplTests.swift +// DevLogDataTests +// +// Created by opfic on 6/11/26. +// + +import Foundation +import Testing +@testable import DevLogData + +struct ProfileImageDataRepositoryImplTests { + @Test("캐시가 있어도 원격 이미지 데이터를 다시 요청하고 성공 데이터를 저장한다") + func 캐시가_있어도_원격_이미지_데이터를_다시_요청하고_성공_데이터를_저장한다() async throws { + let cachedData = Data([1, 2, 3]) + let remoteData = Data([4, 5, 6]) + let service = ProfileImageDataServiceSpy(data: cachedData) + let store = ProfileImageMemoryCacheStoreSpy() + let repository = ProfileImageDataRepositoryImpl(service: service, store: store) + let url = URL(string: "https://example.com/avatar.png")! + + _ = try await repository.fetchImageData(from: url) + service.data = remoteData + let data = try await repository.fetchImageData(from: url) + + #expect(data == remoteData) + #expect(service.calledURLs == [url, url]) + #expect(store.storedData == remoteData) + } + + @Test("원격 이미지 요청 실패 시 메모리 캐시 데이터를 반환한다") + func 원격_이미지_요청_실패_시_메모리_캐시_데이터를_반환한다() async throws { + let cachedData = Data([1, 2, 3]) + let service = ProfileImageDataServiceSpy(data: cachedData) + let store = ProfileImageMemoryCacheStoreSpy() + let repository = ProfileImageDataRepositoryImpl(service: service, store: store) + let url = URL(string: "https://example.com/avatar.png")! + + _ = try await repository.fetchImageData(from: url) + service.error = ProfileImageDataRepositoryImplTestsError.serviceFailed + let data = try await repository.fetchImageData(from: url) + + #expect(data == cachedData) + #expect(service.calledURLs == [url, url]) + } +} + +private final class ProfileImageDataServiceSpy: ProfileImageDataService { + var data: Data + var error: Error? + private(set) var calledURLs: [URL] = [] + + init(data: Data) { + self.data = data + } + + func fetchImageData(from url: URL) async throws -> Data { + calledURLs.append(url) + + if let error { + throw error + } + + return data + } +} + +private final class ProfileImageMemoryCacheStoreSpy: MemoryCacheStore { + private var values = [String: Any]() + private(set) var storedData: Data? + + func value(forKey key: String) -> T? { + values[key] as? T + } + + func setValue(_ value: T?, forKey key: String) { + guard let value else { + values.removeValue(forKey: key) + return + } + + values[key] = value + storedData = value as? Data + } +} + +private enum ProfileImageDataRepositoryImplTestsError: Error { + case serviceFailed +} From a1a3785430836cde40247caaaf3894bb48393fa1 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:24:33 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20Nexa=EB=A1=9C=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=9A=94=EC=B2=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogInfra/Sources/InfraAssembler.swift | 4 +++ .../Service/ProfileImageDataServiceImpl.swift | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 Application/DevLogInfra/Sources/Service/ProfileImageDataServiceImpl.swift diff --git a/Application/DevLogInfra/Sources/InfraAssembler.swift b/Application/DevLogInfra/Sources/InfraAssembler.swift index bfa50b9d..14717f9c 100644 --- a/Application/DevLogInfra/Sources/InfraAssembler.swift +++ b/Application/DevLogInfra/Sources/InfraAssembler.swift @@ -61,6 +61,10 @@ public final class InfraAssembler: Assembler { UserServiceImpl() } + container.register(ProfileImageDataService.self) { + ProfileImageDataServiceImpl() + } + container.register(PushNotificationService.self) { PushNotificationServiceImpl() } diff --git a/Application/DevLogInfra/Sources/Service/ProfileImageDataServiceImpl.swift b/Application/DevLogInfra/Sources/Service/ProfileImageDataServiceImpl.swift new file mode 100644 index 00000000..c3a49fec --- /dev/null +++ b/Application/DevLogInfra/Sources/Service/ProfileImageDataServiceImpl.swift @@ -0,0 +1,35 @@ +// +// ProfileImageDataServiceImpl.swift +// DevLogInfra +// +// Created by opfic on 6/11/26. +// + +import Foundation +import Nexa +import DevLogData + +final class ProfileImageDataServiceImpl: ProfileImageDataService { + func fetchImageData(from url: URL) async throws -> Data { + try await NXAPIClient( + configuration: NXClientConfiguration(baseURL: url) + ) + .get(url.absoluteString) + .timeout(10) + .intercept(ProfileImageDataCachePolicyInterceptor()) + .validate(.successStatusCode) + .raw() + .data + } +} + +private struct ProfileImageDataCachePolicyInterceptor: NXHTTPInterceptor { + func intercept( + context: NXRequestExecutionContext, + next: @escaping @Sendable (NXRequestExecutionContext) async throws -> NXRawResponse + ) async throws -> NXRawResponse { + var request = context.request + request.cachePolicy = .reloadIgnoringLocalCacheData + return try await next(context.replacingRequest(request)) + } +} From 41e90f390291290f1a1a5c195df4a654c396c045 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:24:45 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=95=84=EB=B0=94=ED=83=80=EC=97=90=EC=84=9C=20CacheableImage?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/Component/CacheableImage.swift | 115 ------------------ .../Sources/Profile/ProfileView.swift | 22 +++- .../Profile/ProfileViewCoordinator.swift | 1 + .../Sources/Profile/ProfileViewModel.swift | 36 ++++++ .../Tests/Profile/ProfileViewModelTests.swift | 65 ++++++++++ .../Tests/Support/TestSupport.swift | 50 ++++++++ 6 files changed, 168 insertions(+), 121 deletions(-) delete mode 100644 Application/DevLogPresentation/Sources/Common/Component/CacheableImage.swift create mode 100644 Application/DevLogPresentation/Tests/Profile/ProfileViewModelTests.swift diff --git a/Application/DevLogPresentation/Sources/Common/Component/CacheableImage.swift b/Application/DevLogPresentation/Sources/Common/Component/CacheableImage.swift deleted file mode 100644 index 0ecb0754..00000000 --- a/Application/DevLogPresentation/Sources/Common/Component/CacheableImage.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// CacheableImage.swift -// DevLogPresentation -// -// Created by 최윤진 on 11/30/25. -// - -import SwiftUI -import DevLogDomain - -struct CacheableImage: View { - @State private var loadedUIImage: UIImage? - @State private var isInvalid: Bool = false - private let url: URL? - private let request: URLRequest - @ViewBuilder private var content: () -> Content - - init( - url: URL?, - @ViewBuilder content: @escaping () -> Content = { - Image(systemName: "photo") - .foregroundColor(.gray) - .font(.largeTitle) - .scaledToFill() - } - ) { - self.url = url - self.content = content - if let url { - var request = URLRequest(url: url) - request.cachePolicy = .returnCacheDataElseLoad - request.timeoutInterval = 10 - self.request = request - } else { - self.request = URLRequest(url: URL(string: "about:blank")!) - self.isInvalid = true - } - } - - var body: some View { - Group { - if let loadedUIImage { - Image(uiImage: loadedUIImage) - .resizable() - .scaledToFill() - } else if isInvalid { - content() - } else { - ProgressView() - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .task(id: url) { - self.isInvalid = (self.url == nil) - await loadImageWithCache() - } - } - - @MainActor - private func loadImageWithCache() async { - guard let url = self.url else { return } - - if url.isFileURL { - await loadLocalImage(from: url) - return - } - - if let cachedResponse = URLCache.imageCached.cachedResponse(for: request) { - if let uiImage = UIImage(data: cachedResponse.data) { - self.loadedUIImage = uiImage - return - } - } - - do { - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode - else { return } - - let cachedURLResponse = CachedURLResponse(response: httpResponse, data: data) - URLCache.imageCached.storeCachedResponse(cachedURLResponse, for: request) - - if let uiImage = UIImage(data: data) { - self.loadedUIImage = uiImage - } - } catch { - isInvalid = true - } - } - - @MainActor - private func loadLocalImage(from url: URL) async { - do { - let data = try await Task.detached { - try Data(contentsOf: url) - }.value - - if let uiImage = UIImage(data: data) { - self.loadedUIImage = uiImage - } else { - isInvalid = true - } - } catch { - isInvalid = true - } - } -} - -extension URLCache { - static let imageCached: URLCache = { - let diskCapacity = 300 * 1024 * 1024 // 300MB - let cache = URLCache(memoryCapacity: 10, diskCapacity: diskCapacity, diskPath: "imageCache") - return cache - }() -} diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift index 3a01c5ab..c4d4f21e 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileView.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileView.swift @@ -57,12 +57,7 @@ struct ProfileView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 16) { HStack { - CacheableImage(url: coordinator.viewModel.state.avatarURL) { - Image(systemName: "person.crop.circle.fill") - .resizable() - .scaledToFill() - .foregroundStyle(Color(.systemGray2)) - } + profileAvatarImage .frame(width: 60, height: 60) .cornerRadius(30) .foregroundStyle(Color.gray) @@ -129,6 +124,21 @@ struct ProfileView: View { .toolbar { profileToolbarContent } } + @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) { diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift b/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift index 368275fc..beaea6c7 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileViewCoordinator.swift @@ -21,6 +21,7 @@ final class ProfileViewCoordinator { 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), diff --git a/Application/DevLogPresentation/Sources/Profile/ProfileViewModel.swift b/Application/DevLogPresentation/Sources/Profile/ProfileViewModel.swift index 3b49073c..fa3b2033 100644 --- a/Application/DevLogPresentation/Sources/Profile/ProfileViewModel.swift +++ b/Application/DevLogPresentation/Sources/Profile/ProfileViewModel.swift @@ -10,6 +10,18 @@ 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 { @@ -19,6 +31,7 @@ final class ProfileViewModel: StorePattern { var isLoading: Bool = false var statusMessage: String = "" var avatarURL: URL? + var avatarImageData: ProfileAvatarImageData? var earliestQuarterStart: Date? var selectedQuarterStart: Date? var showQuarterPicker: Bool = false @@ -41,6 +54,7 @@ final class ProfileViewModel: StorePattern { case tapResetStatusMessageButton case willUpdateStatusMessage case fetchUserData(UserProfile) + case setAvatarImageData(URL, Data) case setActivityQuarter( quarterStart: Date, quarter: HeatmapQuarter, @@ -60,6 +74,7 @@ final class ProfileViewModel: StorePattern { enum SideEffect { case fetchUserData + case fetchAvatarImageData(URL) case fetchActivityQuarter(Date) case updateStatusMessage(String) case updateHeatmapActivityKinds(Set) @@ -67,6 +82,7 @@ final class ProfileViewModel: StorePattern { 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 @@ -78,6 +94,7 @@ final class ProfileViewModel: StorePattern { init( fetchUserDataUseCase: FetchUserDataUseCase, + fetchProfileImageDataUseCase: FetchProfileImageDataUseCase, fetchTodosUseCase: FetchTodosUseCase, upsertStatusMessageUseCase: UpsertStatusMessageUseCase, networkConnectivityUseCase: ObserveNetworkConnectivityUseCase, @@ -85,6 +102,7 @@ final class ProfileViewModel: StorePattern { updateHeatmapActivityTypesUseCase: UpdateHeatmapActivityTypesUseCase ) { self.fetchUserDataUseCase = fetchUserDataUseCase + self.fetchProfileImageDataUseCase = fetchProfileImageDataUseCase self.fetchTodosUseCase = fetchTodosUseCase self.upsertStatusMessageUseCase = upsertStatusMessageUseCase self.networkConnectivityUseCase = networkConnectivityUseCase @@ -121,14 +139,25 @@ final class ProfileViewModel: StorePattern { 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): @@ -202,6 +231,13 @@ final class ProfileViewModel: StorePattern { 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 { diff --git a/Application/DevLogPresentation/Tests/Profile/ProfileViewModelTests.swift b/Application/DevLogPresentation/Tests/Profile/ProfileViewModelTests.swift new file mode 100644 index 00000000..43c54cde --- /dev/null +++ b/Application/DevLogPresentation/Tests/Profile/ProfileViewModelTests.swift @@ -0,0 +1,65 @@ +// +// ProfileViewModelTests.swift +// DevLogPresentationTests +// +// Created by opfic on 6/11/26. +// + +import Testing +import Foundation +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) + } +} + +@MainActor +private func makeProfileViewModel( + fetchProfileImageDataUseCase: FetchProfileImageDataUseCase +) -> ProfileViewModel { + ProfileViewModel( + fetchUserDataUseCase: FetchUserDataUseCaseSpy( + profile: UserProfile( + name: "opfic", + email: "opfic@example.com", + statusMessage: "", + avatarURL: nil, + createdAt: Date(timeIntervalSince1970: 0) + ) + ), + fetchProfileImageDataUseCase: fetchProfileImageDataUseCase, + fetchTodosUseCase: FetchTodosUseCaseSpy(), + upsertStatusMessageUseCase: UpsertStatusMessageUseCaseSpy(), + networkConnectivityUseCase: ObserveNetworkConnectivityUseCaseSpy(), + fetchHeatmapActivityTypesUseCase: FetchHeatmapActivityTypesUseCaseSpy(), + updateHeatmapActivityTypesUseCase: UpdateHeatmapActivityTypesUseCaseSpy() + ) +} diff --git a/Application/DevLogPresentation/Tests/Support/TestSupport.swift b/Application/DevLogPresentation/Tests/Support/TestSupport.swift index 15bf5fc5..fbf466ab 100644 --- a/Application/DevLogPresentation/Tests/Support/TestSupport.swift +++ b/Application/DevLogPresentation/Tests/Support/TestSupport.swift @@ -192,6 +192,56 @@ final class FetchTodosUseCaseSpy: FetchTodosUseCase { } } +final class FetchUserDataUseCaseSpy: FetchUserDataUseCase { + var profile: UserProfile + + init(profile: UserProfile) { + self.profile = profile + } + + func execute() async throws -> UserProfile { + profile + } +} + +final class FetchProfileImageDataUseCaseSpy: FetchProfileImageDataUseCase { + var data: Data + private(set) var calledURLs: [URL] = [] + + init(data: Data) { + self.data = data + } + + func execute(from url: URL) async throws -> Data { + calledURLs.append(url) + return data + } +} + +final class UpsertStatusMessageUseCaseSpy: UpsertStatusMessageUseCase { + private(set) var messages: [String] = [] + + func execute(_ message: String) async throws { + messages.append(message) + } +} + +final class FetchHeatmapActivityTypesUseCaseSpy: FetchHeatmapActivityTypesUseCase { + var activityTypes: [String] = [] + + func execute() -> [String] { + activityTypes + } +} + +final class UpdateHeatmapActivityTypesUseCaseSpy: UpdateHeatmapActivityTypesUseCase { + private(set) var activityTypes: [[String]] = [] + + func execute(_ activityTypes: [String]) { + self.activityTypes.append(activityTypes) + } +} + final class FetchWebPagesUseCaseSpy: FetchWebPagesUseCase { var webPages: [WebPage] private(set) var calledQueries: [String] = [] From 285fe06cb5c3e42099ba3daecd21a3748e2a2a22 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:32:10 +0900 Subject: [PATCH 5/6] =?UTF-8?q?chore:=20=EB=B2=84=EC=A0=84=201.2.5?= =?UTF-8?q?=EB=A1=9C=20=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/Shared/Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Application/Shared/Version.xcconfig b/Application/Shared/Version.xcconfig index 481a7a3a..050e30a3 100644 --- a/Application/Shared/Version.xcconfig +++ b/Application/Shared/Version.xcconfig @@ -1,2 +1,2 @@ -MARKETING_VERSION = 1.2 +MARKETING_VERSION = 1.2.5 IPHONEOS_DEPLOYMENT_TARGET = 17.0 From 8cd6dc49f95312a0859838d0f502fcb82fac0878 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:59:24 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20Nexa=201.1.1=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Service/ProfileImageDataServiceImpl.swift | 2 +- Tuist/ProjectDescriptionHelpers/Project+Packages.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Application/DevLogInfra/Sources/Service/ProfileImageDataServiceImpl.swift b/Application/DevLogInfra/Sources/Service/ProfileImageDataServiceImpl.swift index c3a49fec..aa4776b0 100644 --- a/Application/DevLogInfra/Sources/Service/ProfileImageDataServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/ProfileImageDataServiceImpl.swift @@ -14,7 +14,7 @@ final class ProfileImageDataServiceImpl: ProfileImageDataService { try await NXAPIClient( configuration: NXClientConfiguration(baseURL: url) ) - .get(url.absoluteString) + .get() .timeout(10) .intercept(ProfileImageDataCachePolicyInterceptor()) .validate(.successStatusCode) diff --git a/Tuist/ProjectDescriptionHelpers/Project+Packages.swift b/Tuist/ProjectDescriptionHelpers/Project+Packages.swift index 803fe537..9562fd0a 100644 --- a/Tuist/ProjectDescriptionHelpers/Project+Packages.swift +++ b/Tuist/ProjectDescriptionHelpers/Project+Packages.swift @@ -23,7 +23,7 @@ public enum DevLogPackages { ) public static let nexaPackage: Package = .package( url: "https://github.com/opficdev/Nexa", - .upToNextMajor(from: "1.1.0") + .upToNextMinor(from: "1.1.1") ) public static let presentationPackageDependencies: [TargetDependency] = [