From f4eee65c23353bc7b9d491b7c6a323fc69e37999 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 25 Jan 2026 17:34:23 +0900 Subject: [PATCH 01/29] =?UTF-8?q?[#53]=20=ED=94=BC=EB=93=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/DIContainer/FeedDIContainer.swift | 2 +- .../ViewModel/SplashViewModel.swift | 2 - .../Data/DataSources/FeedDataSource.swift | 144 ++++++++++++++--- .../Features/Feed/Data/FeedAPIService.swift | 148 ++++++++++++++++++ .../PreviewMocks/MockFeedRepository.swift | 10 +- .../Repositories/FeedRepositoryImpl.swift | 8 +- .../Domain/Protocols/FeedRepository.swift | 12 +- .../Domain/UseCases/FetchFeedsUseCase.swift | 16 +- .../MainFeed/ViewModel/FeedViewModel.swift | 24 +-- Tuist/Package.resolved | 2 +- 10 files changed, 311 insertions(+), 57 deletions(-) create mode 100644 Codive/Features/Feed/Data/FeedAPIService.swift diff --git a/Codive/DIContainer/FeedDIContainer.swift b/Codive/DIContainer/FeedDIContainer.swift index d7ba0ca0..6cd971f4 100644 --- a/Codive/DIContainer/FeedDIContainer.swift +++ b/Codive/DIContainer/FeedDIContainer.swift @@ -26,7 +26,7 @@ final class FeedDIContainer { }() private lazy var feedDataSource: FeedDataSource = { - return MockFeedDataSource() + return DefaultFeedDataSource() }() // MARK: - Repositories diff --git a/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift b/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift index e987e7c7..222395ad 100644 --- a/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift +++ b/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift @@ -70,7 +70,6 @@ final class SplashViewModel: ObservableObject { appRouter.finishSplash() return - /* // 1. 키체인에 토큰이 있는지 확인 guard tokenService.hasValidTokens() else { appRouter.finishSplash() @@ -91,7 +90,6 @@ final class SplashViewModel: ObservableObject { // 4. 토큰 재발급 await reissueTokens() - */ } private func reissueTokens() async { diff --git a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift index cce6f0e2..533dfe11 100644 --- a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift +++ b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift @@ -7,27 +7,113 @@ import Foundation +/// Feed 페이지네이션 결과 +struct FeedPageResult { + let feeds: [Feed] + let nextCursor: String? + let hasNext: Bool +} + /// Feed 데이터를 가져오는 DataSource 프로토콜 protocol FeedDataSource { - /// Feed 목록 조회 + /// Feed 목록 조회 (커서 기반 페이지네이션) func fetchFeeds( - page: Int, + cursor: String?, limit: Int, styleIds: [Int]?, situationIds: [Int]?, followingOnly: Bool - ) async throws -> [Feed] + ) async throws -> FeedPageResult /// 특정 Feed의 상세 정보 조회 func fetchFeedDetail(id: Int) async throws -> Feed - /// Feed의 좋아요 조회 + /// Feed의 좋아요 토글 func toggleLike(feedId: Int) async throws - + /// 특정 Feed를 좋아한 사용자 목록 조회 func fetchLikers(feedId: Int) async throws -> [User] } +// MARK: - DefaultFeedDataSource + +final class DefaultFeedDataSource: FeedDataSource { + + private let apiService: FeedAPIServiceProtocol + + init(apiService: FeedAPIServiceProtocol = FeedAPIService()) { + self.apiService = apiService + } + + func fetchFeeds( + cursor: String?, + limit: Int, + styleIds: [Int]?, + situationIds: [Int]?, + followingOnly: Bool + ) async throws -> FeedPageResult { + let result = try await apiService.fetchFeeds( + cursor: cursor, + size: Int32(limit), + styleIds: styleIds?.map { Int64($0) }, + situationIds: situationIds?.map { Int64($0) }, + followScope: followingOnly ? .following : .all + ) + + let feeds = result.feeds.map { $0.toDomain() } + return FeedPageResult( + feeds: feeds, + nextCursor: result.nextCursor, + hasNext: result.hasNext + ) + } + + func fetchFeedDetail(id: Int) async throws -> Feed { + // TODO: Implement API call for feed detail + throw FeedDataSourceError.notFound + } + + func toggleLike(feedId: Int) async throws { + // TODO: Implement API call for toggle like + } + + func fetchLikers(feedId: Int) async throws -> [User] { + // TODO: Implement API call for likers + return [] + } +} + +// MARK: - DTO Mapping + +private extension FeedItemDTO { + func toDomain() -> Feed { + return Feed( + id: Int(feedId), + content: nil, + author: author.toDomain(), + images: imageUrl.map { [FeedImage(imageUrl: $0)] } ?? [], + situationId: nil, + styleIds: nil, + hashtags: nil, + createdAt: createdAt, + likeCount: nil, + isLiked: isLiked, + commentCount: nil + ) + } +} + +private extension FeedAuthorDTO { + func toDomain() -> User { + return User( + id: String(memberId), + nickname: clokeyId ?? "", + profileImageUrl: profileImageUrl, + isFollowing: isFollowing + ) + } +} + /// Mock FeedDataSource - 서버 연결 전 테스트용 구현 final class MockFeedDataSource: FeedDataSource { @@ -50,7 +136,7 @@ final class MockFeedDataSource: FeedDataSource { id: "user\(id % 5 + 1)", nickname: "유저\(id % 5 + 1)", profileImageUrl: "https://example.com/profile\(id % 5 + 1).jpg", - bio: "안녕하세요! 패션을 사랑하는 유저입니다 ✨", + bio: "안녕하세요! 패션을 사랑하는 유저입니다", isFollowing: isFollowing ) @@ -89,26 +175,26 @@ final class MockFeedDataSource: FeedDataSource { return Feed( id: id, - content: "오늘의 OOTD #\(id) 🎨\n날씨가 좋아서 가벼운 옷차림으로 나왔어요!", + content: "오늘의 OOTD #\(id)\n날씨가 좋아서 가벼운 옷차림으로 나왔어요!", author: author, images: images, situationId: (id % 3) + 1, styleIds: [(id % 10) + 1, ((id + 1) % 10) + 1], hashtags: ["#OOTD", "#fashion", "#daily"], createdAt: Date().addingTimeInterval(-Double(id * 3600)), - likeCount: Int.random(in: 0...500), // 상세 조회용 + likeCount: Int.random(in: 0...500), isLiked: Bool.random(), commentCount: Int.random(in: 0...50) ) } func fetchFeeds( - page: Int, + cursor: String?, limit: Int, styleIds: [Int]? = nil, situationIds: [Int]? = nil, followingOnly: Bool = false - ) async throws -> [Feed] { + ) async throws -> FeedPageResult { // 네트워크 지연 시뮬레이션 try? await Task.sleep(nanoseconds: 500_000_000) // 0.5초 @@ -136,15 +222,25 @@ final class MockFeedDataSource: FeedDataSource { filteredFeeds = filteredFeeds.filter { $0.author.isFollowing == true } } - // 페이지네이션 - let startIndex = (page - 1) * limit + // 커서 기반 페이지네이션 (Mock: cursor = lastFeedId) + var startIndex = 0 + if let cursor = cursor, let lastId = Int(cursor) { + if let index = filteredFeeds.firstIndex(where: { $0.id == lastId }) { + startIndex = index + 1 + } + } + let endIndex = min(startIndex + limit, filteredFeeds.count) - guard startIndex < filteredFeeds.count else { - return [] + if startIndex >= filteredFeeds.count { + return FeedPageResult(feeds: [], nextCursor: nil, hasNext: false) } - return Array(filteredFeeds[startIndex.. Feed { @@ -213,18 +309,24 @@ enum FeedDataSourceError: Error { #if DEBUG /// 프리뷰용 빈 DataSource final class EmptyFeedDataSource: FeedDataSource { - func fetchFeeds(page: Int, limit: Int, styleIds: [Int]?, situationIds: [Int]?, followingOnly: Bool) async throws -> [Feed] { - [] + func fetchFeeds( + cursor: String?, + limit: Int, + styleIds: [Int]?, + situationIds: [Int]?, + followingOnly: Bool + ) async throws -> FeedPageResult { + FeedPageResult(feeds: [], nextCursor: nil, hasNext: false) } - + func fetchFeedDetail(id: Int) async throws -> Feed { throw FeedDataSourceError.notFound } - + func toggleLike(feedId: Int) async throws {} - + func fetchLikers(feedId: Int) async throws -> [User] { [] } } -#endif +#endif \ No newline at end of file diff --git a/Codive/Features/Feed/Data/FeedAPIService.swift b/Codive/Features/Feed/Data/FeedAPIService.swift new file mode 100644 index 00000000..6ec55361 --- /dev/null +++ b/Codive/Features/Feed/Data/FeedAPIService.swift @@ -0,0 +1,148 @@ +// +// FeedAPIService.swift +// Codive +// +// Created by Gemini on 1/19/26. +// + +import Foundation +import CodiveAPI +import OpenAPIRuntime + +// MARK: - FeedAPIService Protocol + +protocol FeedAPIServiceProtocol { + func fetchFeeds( + cursor: String?, + size: Int32, + styleIds: [Int64]?, + situationIds: [Int64]?, + followScope: FeedFollowScope + ) async throws -> FeedListResult +} + +// MARK: - Supporting Types + +enum FeedFollowScope { + case all + case following + + var apiValue: Operations.Feed_getFeeds.Input.Query.followScopePayload { + switch self { + case .all: return .ALL + case .following: return .FOLLOWING + } + } +} + +struct FeedListResult { + let feeds: [FeedItemDTO] + let nextCursor: String? + let hasNext: Bool +} + +struct FeedItemDTO { + let feedId: Int64 + let imageUrl: String? + let isLiked: Bool? + let createdAt: Date? + let author: FeedAuthorDTO +} + +struct FeedAuthorDTO { + let memberId: Int64 + let clokeyId: String? + let profileImageUrl: String? + let isFollowing: Bool? +} + +// MARK: - FeedAPIService Implementation + +final class FeedAPIService: FeedAPIServiceProtocol { + + private let client: Client + private let jsonDecoder: JSONDecoder + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + self.client = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] + ) + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + } +} + +// MARK: - Fetch Feeds + +extension FeedAPIService { + + func fetchFeeds( + cursor: String?, + size: Int32, + styleIds: [Int64]?, + situationIds: [Int64]?, + followScope: FeedFollowScope + ) async throws -> FeedListResult { + + let styleIdsStr = styleIds?.map(String.init).joined(separator: ",") + let situationIdsStr = situationIds?.map(String.init).joined(separator: ",") + + let input = Operations.Feed_getFeeds.Input( + query: .init( + followScope: followScope.apiValue, + styleIds: styleIdsStr, + situationIds: situationIdsStr, + size: size, + cursor: cursor + ) + ) + + let response = try await client.Feed_getFeeds(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseFeedListResponse.self, + from: data + ) + + let items = decoded.result?.items ?? [] + let feeds: [FeedItemDTO] = items.map { item in + FeedItemDTO( + feedId: item.feedId ?? 0, + imageUrl: item.imageUrl, + isLiked: item.isLiked, + createdAt: item.createdAt, + author: FeedAuthorDTO( + memberId: item.author?.memberId ?? 0, + clokeyId: item.author?.clokeyId, + profileImageUrl: item.author?.profileImageUrl, + isFollowing: item.author?.isFollowing + ) + ) + } + + return FeedListResult( + feeds: feeds, + nextCursor: decoded.result?.nextCursor, + hasNext: decoded.result?.hasNext ?? false + ) + + case .undocumented(statusCode: let code, _): + throw FeedAPIError.serverError(statusCode: code, message: "피드 목록 조회 실패") + } + } +} + +// MARK: - FeedAPIError + +enum FeedAPIError: LocalizedError { + case serverError(statusCode: Int, message: String) + + var errorDescription: String? { + switch self { + case .serverError(let statusCode, let message): + return "서버 오류 (\(statusCode)): \(message)" + } + } +} \ No newline at end of file diff --git a/Codive/Features/Feed/Data/PreviewMocks/MockFeedRepository.swift b/Codive/Features/Feed/Data/PreviewMocks/MockFeedRepository.swift index 4dfdb1b1..9a0783e9 100644 --- a/Codive/Features/Feed/Data/PreviewMocks/MockFeedRepository.swift +++ b/Codive/Features/Feed/Data/PreviewMocks/MockFeedRepository.swift @@ -51,8 +51,14 @@ final class MockFeedRepository: FeedRepository { } } - func fetchFeeds(page: Int, limit: Int, styleIds: [Int]?, situationIds: [Int]?, followingOnly: Bool) async throws -> [Feed] { - return [] + func fetchFeeds( + cursor: String?, + limit: Int, + styleIds: [Int]?, + situationIds: [Int]?, + followingOnly: Bool + ) async throws -> FeedPageResult { + return FeedPageResult(feeds: [], nextCursor: nil, hasNext: false) } func fetchFeedDetail(feedId: Int) async throws -> Feed { diff --git a/Codive/Features/Feed/Data/Repositories/FeedRepositoryImpl.swift b/Codive/Features/Feed/Data/Repositories/FeedRepositoryImpl.swift index 8467c76b..72ddeb91 100644 --- a/Codive/Features/Feed/Data/Repositories/FeedRepositoryImpl.swift +++ b/Codive/Features/Feed/Data/Repositories/FeedRepositoryImpl.swift @@ -17,14 +17,14 @@ final class FeedRepositoryImpl: FeedRepository { } func fetchFeeds( - page: Int, + cursor: String? = nil, limit: Int, styleIds: [Int]? = nil, situationIds: [Int]? = nil, followingOnly: Bool = false - ) async throws -> [Feed] { + ) async throws -> FeedPageResult { return try await dataSource.fetchFeeds( - page: page, + cursor: cursor, limit: limit, styleIds: styleIds, situationIds: situationIds, @@ -39,7 +39,7 @@ final class FeedRepositoryImpl: FeedRepository { func toggleLike(feedId: Int) async throws { try await dataSource.toggleLike(feedId: feedId) } - + func fetchLikers(feedId: Int) async throws -> [User] { return try await dataSource.fetchLikers(feedId: feedId) } diff --git a/Codive/Features/Feed/Domain/Protocols/FeedRepository.swift b/Codive/Features/Feed/Domain/Protocols/FeedRepository.swift index 18ee3216..567ea6d0 100644 --- a/Codive/Features/Feed/Domain/Protocols/FeedRepository.swift +++ b/Codive/Features/Feed/Domain/Protocols/FeedRepository.swift @@ -8,21 +8,21 @@ import Foundation protocol FeedRepository { - // MARK: - 피드 전체 조회 + // MARK: - 피드 전체 조회 (커서 기반 페이지네이션) func fetchFeeds( - page: Int, + cursor: String?, limit: Int, styleIds: [Int]?, situationIds: [Int]?, followingOnly: Bool - ) async throws -> [Feed] - + ) async throws -> FeedPageResult + // MARK: - 피드 상세 조회 func fetchFeedDetail(feedId: Int) async throws -> Feed - + // MARK: - 좋아요 토글 func toggleLike(feedId: Int) async throws - + // MARK: - 좋아요 누른 유저 목록 조회 func fetchLikers(feedId: Int) async throws -> [User] } diff --git a/Codive/Features/Feed/Domain/UseCases/FetchFeedsUseCase.swift b/Codive/Features/Feed/Domain/UseCases/FetchFeedsUseCase.swift index 0f52b611..c854d6fe 100644 --- a/Codive/Features/Feed/Domain/UseCases/FetchFeedsUseCase.swift +++ b/Codive/Features/Feed/Domain/UseCases/FetchFeedsUseCase.swift @@ -9,21 +9,21 @@ import Foundation /// Feed 목록을 가져오는 UseCase protocol FetchFeedsUseCase { - /// Feed 목록을 페이지 단위로 조회 + /// Feed 목록을 커서 단위로 조회 /// - Parameters: - /// - page: 페이지 번호 (1부터 시작) + /// - cursor: 페이지네이션 커서 (nil이면 처음부터) /// - limit: 한 페이지당 가져올 개수 /// - styleIds: 스타일 필터 (nil 또는 빈 배열이면 전체) /// - situationIds: 상황 필터 (nil 또는 빈 배열이면 전체) /// - followingOnly: 팔로잉한 사용자만 (기본값: false) - /// - Returns: Feed 배열 + /// - Returns: FeedPageResult (feeds, nextCursor, hasNext) func execute( - page: Int, + cursor: String?, limit: Int, styleIds: [Int]?, situationIds: [Int]?, followingOnly: Bool - ) async throws -> [Feed] + ) async throws -> FeedPageResult } final class DefaultFetchFeedsUseCase: FetchFeedsUseCase { @@ -34,14 +34,14 @@ final class DefaultFetchFeedsUseCase: FetchFeedsUseCase { } func execute( - page: Int, + cursor: String?, limit: Int, styleIds: [Int]? = nil, situationIds: [Int]? = nil, followingOnly: Bool = false - ) async throws -> [Feed] { + ) async throws -> FeedPageResult { return try await repository.fetchFeeds( - page: page, + cursor: cursor, limit: limit, styleIds: styleIds, situationIds: situationIds, diff --git a/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift b/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift index 2ca87198..f182dbbb 100644 --- a/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift +++ b/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift @@ -36,8 +36,8 @@ final class FeedViewModel: ObservableObject { private let navigationRouter: NavigationRouter private let fetchFeedsUseCase: FetchFeedsUseCase private let feedRepository: FeedRepository - private var currentPage: Int = 1 private let pageSize: Int = 20 + private var nextCursor: String? private var hasMorePages: Bool = true // MARK: - Initialization @@ -66,19 +66,20 @@ final class FeedViewModel: ObservableObject { isLoading = true errorMessage = nil + nextCursor = nil do { - let newFeeds = try await fetchFeedsUseCase.execute( - page: 1, + let result = try await fetchFeedsUseCase.execute( + cursor: nil, limit: pageSize, styleIds: selectedStyleIds, situationIds: selectedSituationIds, followingOnly: followingOnly ) - feeds = newFeeds - currentPage = 1 - hasMorePages = newFeeds.count == pageSize + feeds = result.feeds + nextCursor = result.nextCursor + hasMorePages = result.hasNext } catch { errorMessage = "Feed를 불러오는데 실패했습니다: \(error.localizedDescription)" feeds = [] @@ -95,18 +96,17 @@ final class FeedViewModel: ObservableObject { isLoading = true do { - let nextPage = currentPage + 1 - let newFeeds = try await fetchFeedsUseCase.execute( - page: nextPage, + let result = try await fetchFeedsUseCase.execute( + cursor: nextCursor, limit: pageSize, styleIds: selectedStyleIds, situationIds: selectedSituationIds, followingOnly: followingOnly ) - feeds.append(contentsOf: newFeeds) - currentPage = nextPage - hasMorePages = newFeeds.count == pageSize + feeds.append(contentsOf: result.feeds) + nextCursor = result.nextCursor + hasMorePages = result.hasNext } catch { errorMessage = "더 많은 Feed를 불러오는데 실패했습니다: \(error.localizedDescription)" } diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 857e0670..0a9b1494 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -16,7 +16,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "124c84772a8a0199b93c4ac81f2b98dc926f6430" + "revision" : "b6e0e349a2d84f9b5356fbe433e1d6eea6bd2441" } }, { From 4c60dfd53bcc227d8f950f011d5e3b7cff0797de Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 25 Jan 2026 17:34:23 +0900 Subject: [PATCH 02/29] =?UTF-8?q?[#53]=20UI=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=9E=90=EB=8F=99=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=9E=84=EC=8B=9C=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/Presentation/ViewModel/SplashViewModel.swift | 4 ++-- Codive/Features/Closet/Data/ClothAPIService.swift | 8 +++++++- .../Components/CustomAIRecommendationView.swift | 8 +++++--- .../Presentation/ViewModel/ClothDetailViewModel.swift | 7 +++++-- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift b/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift index 222395ad..ed9a5000 100644 --- a/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift +++ b/Codive/Features/Auth/Presentation/ViewModel/SplashViewModel.swift @@ -67,8 +67,8 @@ final class SplashViewModel: ObservableObject { private func checkAutoLogin() async { // 임시 자동 로그인 해제 (토큰이 있어도 로그인 화면으로 이동) - appRouter.finishSplash() - return +// appRouter.finishSplash() +// return // 1. 키체인에 토큰이 있는지 확인 guard tokenService.hasValidTokens() else { diff --git a/Codive/Features/Closet/Data/ClothAPIService.swift b/Codive/Features/Closet/Data/ClothAPIService.swift index 4c52dbc3..5aba1cbc 100644 --- a/Codive/Features/Closet/Data/ClothAPIService.swift +++ b/Codive/Features/Closet/Data/ClothAPIService.swift @@ -58,6 +58,7 @@ struct ClothDetailResult { let name: String? let brand: String? let clothUrl: String? + let seasons: [Season] } struct ClothUpdateAPIRequest { @@ -220,13 +221,18 @@ extension ClothAPIService { throw ClothAPIError.serverError(statusCode: 0, message: "result가 nil입니다") } + let seasons = result.seasons?.compactMap { seasonString -> Season? in + Season(rawValue: seasonString.rawValue) + } ?? [] + return ClothDetailResult( clothImageUrl: result.clothImageUrl ?? "", parentCategory: result.parentCategory, category: result.category, name: result.name, brand: result.brand, - clothUrl: result.clothUrl + clothUrl: result.clothUrl, + seasons: seasons ) case .undocumented(statusCode: let code, _): diff --git a/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift b/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift index 40b22b1d..f1509a80 100644 --- a/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift +++ b/Codive/Features/Closet/Presentation/Components/CustomAIRecommendationView.swift @@ -240,9 +240,11 @@ struct CustomAIRecommendationView: View { } private var thumbnailImagesView: some View { - HStack(spacing: 8) { - ForEach(Array(items.enumerated()), id: \.element.id) { index, item in - thumbnailButton(for: item, at: index) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + thumbnailButton(for: item, at: index) + } } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift b/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift index c1987b2d..713957e3 100644 --- a/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift +++ b/Codive/Features/Closet/Presentation/ViewModel/ClothDetailViewModel.swift @@ -55,12 +55,15 @@ final class ClothDetailViewModel: ObservableObject { } var seasonText: String { - if cloth.seasons.isEmpty { + // API 상세 응답이 있으면 우선 사용 + let seasons: [Season] = detailData?.seasons ?? Array(cloth.seasons) + + if seasons.isEmpty { return "계절 없음" } let orderedSeasons: [Season] = [.spring, .summer, .fall, .winter] - let selectedSeasons = orderedSeasons.filter { cloth.seasons.contains($0) } + let selectedSeasons = orderedSeasons.filter { seasons.contains($0) } return selectedSeasons.map { $0.displayName }.joined(separator: ", ") } From 366d3a627e0ecefee6af736d2a110fe071f0909e Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 25 Jan 2026 17:34:23 +0900 Subject: [PATCH 03/29] =?UTF-8?q?[#53]=20=EC=98=B7=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/DataSources/ClothDataSource.swift | 36 ++++++++----------- .../Closet/Domain/Entities/ProductItem.swift | 19 +++++++++- .../ViewModel/AddCodiDetailViewModel.swift | 2 +- .../ClothSheet/CustomProductBottomSheet.swift | 2 +- 4 files changed, 35 insertions(+), 24 deletions(-) diff --git a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift index bf19cc18..2da42e8a 100644 --- a/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift +++ b/Codive/Features/Closet/Data/DataSources/ClothDataSource.swift @@ -56,31 +56,25 @@ final class DefaultClothDataSource: ClothDataSource { self.apiService = apiService } - // MARK: - Mock Data (TODO: API 연결 후 제거) - - private let mockClothItems: [ProductItem] = [ - ProductItem(id: 1, imageName: "sample1", isTodayCloth: true, brand: "Nike", name: "에어포스 1"), - ProductItem(id: 2, imageName: "sample2", isTodayCloth: true, brand: "Adidas", name: "후디"), - ProductItem(id: 3, imageName: "sample3", isTodayCloth: true, brand: nil, name: "검은 모자"), - ProductItem(id: 4, imageName: "sample4", isTodayCloth: false, brand: "Uniqlo", name: "오버핏 티셔츠"), - ProductItem(id: 5, imageName: "sample5", isTodayCloth: false, brand: "Zara", name: "슬랙스"), - ProductItem(id: 6, imageName: "sample6", isTodayCloth: false, brand: nil, name: nil) - ] - // MARK: - Methods - + func fetchClothItems(category: String?) async throws -> [ProductItem] { - // TODO: 실제 API 호출로 대체 + // 전체 옷 목록 조회 (페이지네이션 없이 전체) + let result = try await apiService.fetchClothes( + lastClothId: nil, + size: 100, + categoryId: nil, + seasons: [] + ) - // 카테고리 필터링 (전체면 전부 반환) - if let category = category, category != "전체" { - return mockClothItems.filter { _ in - // TODO: ProductItem에 category 필드 추가 후 필터링 - return true - } + return result.clothes.map { item in + ProductItem( + id: Int(item.clothId), + imageUrl: item.imageUrl, + brand: item.brand, + name: item.name + ) } - - return mockClothItems } /// 옷 저장 (전체 흐름: Presigned URL → S3 업로드 → 옷 생성) diff --git a/Codive/Features/Closet/Domain/Entities/ProductItem.swift b/Codive/Features/Closet/Domain/Entities/ProductItem.swift index 975e85cb..43a1a4f6 100644 --- a/Codive/Features/Closet/Domain/Entities/ProductItem.swift +++ b/Codive/Features/Closet/Domain/Entities/ProductItem.swift @@ -10,8 +10,25 @@ import Foundation // MARK: - Product Item Model struct ProductItem: Identifiable { let id: Int - let imageName: String + let imageName: String? + let imageUrl: String? let isTodayCloth: Bool let brand: String? let name: String? + + init( + id: Int, + imageName: String? = nil, + imageUrl: String? = nil, + isTodayCloth: Bool = false, + brand: String? = nil, + name: String? = nil + ) { + self.id = id + self.imageName = imageName + self.imageUrl = imageUrl + self.isTodayCloth = isTodayCloth + self.brand = brand + self.name = name + } } diff --git a/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift b/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift index d375e296..2432d46b 100644 --- a/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift +++ b/Codive/Features/LookBook/Presentation/ViewModel/AddCodiDetailViewModel.swift @@ -91,7 +91,7 @@ final class AddCodiDetailViewModel: ObservableObject, DraggableImageViewModelPro let newImage = DraggableImageEntity( id: product.id, - name: product.imageName, + name: product.imageUrl ?? product.imageName ?? "", position: CGPoint( x: centerX + randomOffsetX, y: centerY + randomOffsetY diff --git a/Codive/Shared/DesignSystem/ClothSheet/CustomProductBottomSheet.swift b/Codive/Shared/DesignSystem/ClothSheet/CustomProductBottomSheet.swift index d160442f..1252c46d 100644 --- a/Codive/Shared/DesignSystem/ClothSheet/CustomProductBottomSheet.swift +++ b/Codive/Shared/DesignSystem/ClothSheet/CustomProductBottomSheet.swift @@ -55,7 +55,7 @@ struct CustomProductBottomSheet: View { LazyVGrid(columns: columns, spacing: 10) { ForEach(products.sorted { $0.isTodayCloth && !$1.isTodayCloth }) { product in CustomProductCard( - imageName: product.imageName, + imageName: product.imageUrl ?? product.imageName ?? "", isTodayCloth: product.isTodayCloth, isSelected: selectedProducts.contains(product.id) ) { From 42a9fc9bb1c4128fc4e645aae1924dc8b445b8d8 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:41:23 +0900 Subject: [PATCH 04/29] =?UTF-8?q?[#53]=20=ED=94=BC=EB=93=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 피드 목록 조회 API 구현 - 히드 상세 조회 API 구현 및 UI 추가 --- .../Data/DataSources/FeedDataSource.swift | 50 ++++++++++++- .../Feed/Data/HistoryAPIService.swift | 75 +++++++++++++++++++ .../Components/FeedContentSection.swift | 4 +- .../ViewModel/FeedDetailViewModel.swift | 5 +- .../Presentation/MainFeed/View/FeedView.swift | 2 +- .../MainFeed/ViewModel/FeedViewModel.swift | 1 + .../DesignSystem/Views/CustomFeedCard.swift | 48 ++++++++---- Codive/Shared/Domain/Entities/Feed.swift | 3 + 8 files changed, 166 insertions(+), 22 deletions(-) diff --git a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift index 533dfe11..79597404 100644 --- a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift +++ b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift @@ -40,9 +40,14 @@ protocol FeedDataSource { final class DefaultFeedDataSource: FeedDataSource { private let apiService: FeedAPIServiceProtocol + private let historyAPIService: HistoryAPIServiceProtocol - init(apiService: FeedAPIServiceProtocol = FeedAPIService()) { + init( + apiService: FeedAPIServiceProtocol = FeedAPIService(), + historyAPIService: HistoryAPIServiceProtocol = HistoryAPIService() + ) { self.apiService = apiService + self.historyAPIService = historyAPIService } func fetchFeeds( @@ -69,8 +74,8 @@ final class DefaultFeedDataSource: FeedDataSource { } func fetchFeedDetail(id: Int) async throws -> Feed { - // TODO: Implement API call for feed detail - throw FeedDataSourceError.notFound + let dto = try await historyAPIService.fetchHistoryDetail(historyId: Int64(id)) + return dto.toDomain(feedId: id) } func toggleLike(feedId: Int) async throws { @@ -114,6 +119,43 @@ private extension FeedAuthorDTO { } } +extension HistoryDetailDTO { + func toDomain(feedId: Int) -> Feed { + let author = User( + id: String(memberId), + nickname: nickname ?? "", + profileImageUrl: profileImageUrl + ) + + let feedImages = images.map { img in + FeedImage(imageUrl: img.imageUrl) + } + + let styleIds = styles.map { Int($0.styleId) } + let styleNames = styles.map { $0.styleName } + + // historyDate를 Date로 변환 + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withFullDate, .withDashSeparatorInDate] + let createdAtDate = historyDate.flatMap { dateFormatter.date(from: $0) } + + return Feed( + id: feedId, + content: nil, + author: author, + images: feedImages, + situationId: situationId.map { Int($0) }, + styleIds: styleIds.isEmpty ? nil : styleIds, + styleNames: styleNames.isEmpty ? nil : styleNames, + hashtags: nil, + createdAt: createdAtDate, + likeCount: Int(likeCount), + isLiked: nil, + commentCount: Int(commentCount) + ) + } +} + /// Mock FeedDataSource - 서버 연결 전 테스트용 구현 final class MockFeedDataSource: FeedDataSource { @@ -180,6 +222,7 @@ final class MockFeedDataSource: FeedDataSource { images: images, situationId: (id % 3) + 1, styleIds: [(id % 10) + 1, ((id + 1) % 10) + 1], + styleNames: ["캐주얼", "스트릿"], hashtags: ["#OOTD", "#fashion", "#daily"], createdAt: Date().addingTimeInterval(-Double(id * 3600)), likeCount: Int.random(in: 0...500), @@ -273,6 +316,7 @@ final class MockFeedDataSource: FeedDataSource { images: oldFeed.images, situationId: oldFeed.situationId, styleIds: oldFeed.styleIds, + styleNames: oldFeed.styleNames, hashtags: oldFeed.hashtags, createdAt: oldFeed.createdAt, likeCount: newLikeCount, diff --git a/Codive/Features/Feed/Data/HistoryAPIService.swift b/Codive/Features/Feed/Data/HistoryAPIService.swift index 3e6817df..7caebdfa 100644 --- a/Codive/Features/Feed/Data/HistoryAPIService.swift +++ b/Codive/Features/Feed/Data/HistoryAPIService.swift @@ -13,6 +13,32 @@ import OpenAPIRuntime protocol HistoryAPIServiceProtocol { func createHistory(request: HistoryCreateAPIRequest) async throws -> Int64 + func fetchHistoryDetail(historyId: Int64) async throws -> HistoryDetailDTO +} + +// MARK: - History Detail DTO + +struct HistoryDetailDTO { + let memberId: Int64 + let profileImageUrl: String? + let nickname: String? + let images: [HistoryImageDTO] + let likeCount: Int64 + let commentCount: Int64 + let historyDate: String? + let situationId: Int64? + let situationName: String? + let styles: [HistoryStyleDTO] +} + +struct HistoryImageDTO { + let imageId: Int64 + let imageUrl: String +} + +struct HistoryStyleDTO { + let styleId: Int64 + let styleName: String } // MARK: - Request Types @@ -91,6 +117,55 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { throw HistoryAPIError.serverError(statusCode: code) } } + + // MARK: - Fetch History Detail + + func fetchHistoryDetail(historyId: Int64) async throws -> HistoryDetailDTO { + let input = Operations.History_getHistoryDetails.Input( + path: .init(historyId: historyId) + ) + + let response = try await client.History_getHistoryDetails(input) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseDailyHistoryResponse.self, + from: data + ) + + guard let result = decoded.result else { + throw HistoryAPIError.noData + } + + return HistoryDetailDTO( + memberId: result.memberId ?? 0, + profileImageUrl: result.profileImageUrl, + nickname: result.nickname, + images: result.images?.compactMap { img in + guard let imageUrl = img.imageUrl else { return nil } + return HistoryImageDTO( + imageId: img.imageId ?? 0, + imageUrl: imageUrl + ) + } ?? [], + likeCount: result.likeCount ?? 0, + commentCount: result.commentCount ?? 0, + historyDate: result.historyDate, + situationId: result.situationId, + situationName: result.situationName, + styles: result.styles?.compactMap { style in + guard let styleId = style.styleId, let styleName = style.styleName else { return nil } + return HistoryStyleDTO(styleId: styleId, styleName: styleName) + } ?? [] + ) + + case .undocumented(statusCode: let code, _): + throw HistoryAPIError.serverError(statusCode: code) + } + } } // MARK: - Response Types diff --git a/Codive/Features/Feed/Presentation/Components/FeedContentSection.swift b/Codive/Features/Feed/Presentation/Components/FeedContentSection.swift index 2acefcb4..3ffd79e0 100644 --- a/Codive/Features/Feed/Presentation/Components/FeedContentSection.swift +++ b/Codive/Features/Feed/Presentation/Components/FeedContentSection.swift @@ -95,8 +95,8 @@ struct FeedContentSection: View { Text(style) .font(.codive_body2_medium) .foregroundStyle(Color.Codive.grayscale1) - .padding(.horizontal, 12) - .padding(.vertical, 6) + .padding(.horizontal, 9) + .padding(.vertical, 7) .background(Color.white) .overlay( RoundedRectangle(cornerRadius: 100) diff --git a/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift b/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift index 156453d0..20de6155 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift @@ -69,9 +69,7 @@ final class FeedDetailViewModel: ObservableObject { self.imageUrls = fetchedFeed.images.map { $0.imageUrl } self.displayableTags = mapToDisplayableTags(from: fetchedFeed.images) self.formattedDate = format(date: fetchedFeed.createdAt) - - // TODO: styleIds를 실제 스타일 이름으로 변환하는 로직 구현 필요 - self.displayableStyles = [] // 현재는 임시로 빈 배열 할당 + self.displayableStyles = fetchedFeed.styleNames ?? [] } catch { errorMessage = TextLiteral.Feed.loadDetailFailed @@ -129,6 +127,7 @@ final class FeedDetailViewModel: ObservableObject { images: currentFeed.images, situationId: currentFeed.situationId, styleIds: currentFeed.styleIds, + styleNames: currentFeed.styleNames, hashtags: currentFeed.hashtags, createdAt: currentFeed.createdAt, likeCount: newLikeCount, diff --git a/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift b/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift index 59ae58c0..cdd8194b 100644 --- a/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift +++ b/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift @@ -133,7 +133,7 @@ private struct FeedCellView: View { } label: { CustomFeedCard( imageUrl: feed.images.first?.imageUrl ?? "", - profileImageUrl: feed.author.profileImageUrl ?? "sample_profile", + profileImageUrl: feed.author.profileImageUrl ?? "", nickname: feed.author.nickname, isLiked: Binding( get: { feed.isLiked ?? false }, diff --git a/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift b/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift index f182dbbb..9f107e0b 100644 --- a/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift +++ b/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift @@ -157,6 +157,7 @@ final class FeedViewModel: ObservableObject { images: originalFeed.images, situationId: originalFeed.situationId, styleIds: originalFeed.styleIds, + styleNames: originalFeed.styleNames, hashtags: originalFeed.hashtags, createdAt: originalFeed.createdAt, likeCount: newLikeCount, diff --git a/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift b/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift index d718a009..3398dbe7 100644 --- a/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift +++ b/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift @@ -23,9 +23,21 @@ struct CustomFeedCard: View { Rectangle() .fill(Color.gray) .overlay( - Image(imageUrl) - .resizable() - .scaledToFill() + AsyncImage(url: URL(string: imageUrl)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + case .failure: + Image(systemName: "photo") + .foregroundColor(.gray) + case .empty: + ProgressView() + @unknown default: + EmptyView() + } + } ) .overlay( LinearGradient( @@ -54,21 +66,31 @@ struct CustomFeedCard: View { .frame(maxWidth: .infinity, maxHeight: .infinity) // 프로필 정보 - HStack { + HStack(spacing: 8) { // 프로필 이미지 - Image(profileImageUrl) - .resizable() - .scaledToFill() - .frame(width: 28, height: 28) - .clipShape(Circle()) - .overlay( - Circle() - ) - + AsyncImage(url: URL(string: profileImageUrl)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + case .failure, .empty: + Image(systemName: "person.circle.fill") + .resizable() + .foregroundColor(.gray) + @unknown default: + EmptyView() + } + } + .frame(width: 28, height: 28) + .clipShape(Circle()) + // 닉네임 Text(nickname) .font(.codive_body2_medium) .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.tail) } .padding(.leading, 15) .padding(.bottom, 15) diff --git a/Codive/Shared/Domain/Entities/Feed.swift b/Codive/Shared/Domain/Entities/Feed.swift index 87255d79..4bb09315 100644 --- a/Codive/Shared/Domain/Entities/Feed.swift +++ b/Codive/Shared/Domain/Entities/Feed.swift @@ -16,6 +16,7 @@ public struct Feed: Identifiable, Equatable { public let situationId: Int? public let styleIds: [Int]? + public let styleNames: [String]? public let hashtags: [String]? public let createdAt: Date? @@ -30,6 +31,7 @@ public struct Feed: Identifiable, Equatable { images: [FeedImage], situationId: Int? = nil, styleIds: [Int]? = nil, + styleNames: [String]? = nil, hashtags: [String]? = nil, createdAt: Date? = nil, likeCount: Int? = nil, @@ -42,6 +44,7 @@ public struct Feed: Identifiable, Equatable { self.images = images self.situationId = situationId self.styleIds = styleIds + self.styleNames = styleNames self.hashtags = hashtags self.createdAt = createdAt self.likeCount = likeCount From 6a48a2b9919fd2a3198e0b1d06f326f75323043c Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 20 Jan 2026 22:12:22 +0900 Subject: [PATCH 05/29] =?UTF-8?q?[#53]=20ClokeyId=20->=20NickName=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Feed/Data/DataSources/FeedDataSource.swift | 2 +- Codive/Features/Feed/Data/FeedAPIService.swift | 6 +++--- Codive/Shared/DesignSystem/Views/CustomFeedCard.swift | 6 +++--- Tuist/Package.resolved | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift index 79597404..352d73a4 100644 --- a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift +++ b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift @@ -112,7 +112,7 @@ private extension FeedAuthorDTO { func toDomain() -> User { return User( id: String(memberId), - nickname: clokeyId ?? "", + nickname: nickname ?? "", profileImageUrl: profileImageUrl, isFollowing: isFollowing ) diff --git a/Codive/Features/Feed/Data/FeedAPIService.swift b/Codive/Features/Feed/Data/FeedAPIService.swift index 6ec55361..512686f1 100644 --- a/Codive/Features/Feed/Data/FeedAPIService.swift +++ b/Codive/Features/Feed/Data/FeedAPIService.swift @@ -51,7 +51,7 @@ struct FeedItemDTO { struct FeedAuthorDTO { let memberId: Int64 - let clokeyId: String? + let nickname: String? let profileImageUrl: String? let isFollowing: Bool? } @@ -115,7 +115,7 @@ extension FeedAPIService { createdAt: item.createdAt, author: FeedAuthorDTO( memberId: item.author?.memberId ?? 0, - clokeyId: item.author?.clokeyId, + nickname: item.author?.nickname, profileImageUrl: item.author?.profileImageUrl, isFollowing: item.author?.isFollowing ) @@ -145,4 +145,4 @@ enum FeedAPIError: LocalizedError { return "서버 오류 (\(statusCode)): \(message)" } } -} \ No newline at end of file +} diff --git a/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift b/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift index 3398dbe7..4f102c06 100644 --- a/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift +++ b/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift @@ -18,7 +18,7 @@ struct CustomFeedCard: View { // MARK: - Body var body: some View { ZStack(alignment: .bottomLeading) { - + // 메인 배경 이미지 Rectangle() .fill(Color.gray) @@ -46,7 +46,7 @@ struct CustomFeedCard: View { endPoint: .bottom ) ) - + // 좋아요 버튼 (우측 상단) VStack { HStack { @@ -64,7 +64,7 @@ struct CustomFeedCard: View { } .padding(15) .frame(maxWidth: .infinity, maxHeight: .infinity) - + // 프로필 정보 HStack(spacing: 8) { // 프로필 이미지 diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 0a9b1494..36ff331f 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -16,7 +16,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "b6e0e349a2d84f9b5356fbe433e1d6eea6bd2441" + "revision" : "7a69672cd7ee19c45fb7dce58004a64ea9613e90" } }, { From 2f9628a0ae5b7c228e26a239ff56d82e818244a0 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:16:06 +0900 Subject: [PATCH 06/29] =?UTF-8?q?[#53]=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=8A=AC=EB=9D=BC=EC=9D=B4=EB=8D=94=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=ED=83=9C=EA=B7=B8=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/DataSources/FeedDataSource.swift | 2 +- .../Feed/Data/HistoryAPIService.swift | 51 ++++++++++++++++ .../FeedDetail/View/FeedImageSlider.swift | 21 ++++++- .../ViewModel/FeedDetailViewModel.swift | 61 ++++++++++++++----- Codive/Shared/Domain/Entities/Feed.swift | 6 +- Tuist/Package.resolved | 2 +- 6 files changed, 122 insertions(+), 21 deletions(-) diff --git a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift index 352d73a4..8902b8a2 100644 --- a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift +++ b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift @@ -128,7 +128,7 @@ extension HistoryDetailDTO { ) let feedImages = images.map { img in - FeedImage(imageUrl: img.imageUrl) + FeedImage(imageId: img.imageId, imageUrl: img.imageUrl) } let styleIds = styles.map { Int($0.styleId) } diff --git a/Codive/Features/Feed/Data/HistoryAPIService.swift b/Codive/Features/Feed/Data/HistoryAPIService.swift index 7caebdfa..0a9a57c5 100644 --- a/Codive/Features/Feed/Data/HistoryAPIService.swift +++ b/Codive/Features/Feed/Data/HistoryAPIService.swift @@ -14,6 +14,7 @@ import OpenAPIRuntime protocol HistoryAPIServiceProtocol { func createHistory(request: HistoryCreateAPIRequest) async throws -> Int64 func fetchHistoryDetail(historyId: Int64) async throws -> HistoryDetailDTO + func fetchClothTags(historyImageId: Int64) async throws -> [ClothTagDTO] } // MARK: - History Detail DTO @@ -41,6 +42,14 @@ struct HistoryStyleDTO { let styleName: String } +struct ClothTagDTO { + let clothId: Int64 + let name: String? + let brand: String? + let locationX: Double + let locationY: Double +} + // MARK: - Request Types struct HistoryCreateAPIRequest { @@ -166,6 +175,48 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { throw HistoryAPIError.serverError(statusCode: code) } } + + // MARK: - Fetch Cloth Tags + + func fetchClothTags(historyImageId: Int64) async throws -> [ClothTagDTO] { + let input = Operations.History_getHistoryClothTags.Input( + path: .init(historyImageId: historyImageId) + ) + + let response = try await client.History_getHistoryClothTags(input) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseHistoryClothTagListResponse.self, + from: data + ) + + guard let payloads = decoded.result?.payloads else { + throw HistoryAPIError.noData + } + + return payloads.compactMap { tag in + guard let clothId = tag.clothId, + let locationX = tag.locationX, + let locationY = tag.locationY else { + return nil + } + return ClothTagDTO( + clothId: clothId, + name: tag.name, + brand: tag.brand, + locationX: locationX, + locationY: locationY + ) + } + + case .undocumented(statusCode: let code, _): + throw HistoryAPIError.serverError(statusCode: code) + } + } } // MARK: - Response Types diff --git a/Codive/Features/Feed/Presentation/FeedDetail/View/FeedImageSlider.swift b/Codive/Features/Feed/Presentation/FeedDetail/View/FeedImageSlider.swift index ac448ea7..f9fa061c 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/View/FeedImageSlider.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/View/FeedImageSlider.swift @@ -48,7 +48,26 @@ struct FeedImageSlider: View { .frame(width: geometry.size.width, height: geometry.size.height) } } - + + // 페이지 인디케이터 (우측 상단) + if imageUrls.count > 1 { + VStack { + HStack { + Spacer() + Text("\(currentIndex + 1)/\(imageUrls.count)") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.black.opacity(0.5)) + .clipShape(Capsule()) + } + Spacer() + } + .padding(.trailing, 20) + .padding(.top, 20) + } + Button(action: { withAnimation { onTagButtonTap() diff --git a/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift b/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift index 20de6155..7d8be581 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift @@ -28,9 +28,10 @@ final class FeedDetailViewModel: ObservableObject { private let feedId: Int private let fetchFeedDetailUseCase: FetchFeedDetailUseCase - private let fetchLikersUseCase: FetchFeedLikersUseCase + private let fetchLikersUseCase: FetchFeedLikersUseCase private let feedRepository: FeedRepository private let navigationRouter: NavigationRouter + private let historyAPIService: HistoryAPIServiceProtocol private let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = TextLiteral.Feed.dateFormat @@ -44,13 +45,15 @@ final class FeedDetailViewModel: ObservableObject { fetchFeedDetailUseCase: FetchFeedDetailUseCase, fetchLikersUseCase: FetchFeedLikersUseCase, feedRepository: FeedRepository, - navigationRouter: NavigationRouter + navigationRouter: NavigationRouter, + historyAPIService: HistoryAPIServiceProtocol = HistoryAPIService() ) { self.feedId = feedId self.fetchFeedDetailUseCase = fetchFeedDetailUseCase self.fetchLikersUseCase = fetchLikersUseCase self.feedRepository = feedRepository self.navigationRouter = navigationRouter + self.historyAPIService = historyAPIService } // MARK: - Feed 상세 로딩 @@ -67,10 +70,12 @@ final class FeedDetailViewModel: ObservableObject { // 데이터 가공 self.feed = fetchedFeed self.imageUrls = fetchedFeed.images.map { $0.imageUrl } - self.displayableTags = mapToDisplayableTags(from: fetchedFeed.images) self.formattedDate = format(date: fetchedFeed.createdAt) self.displayableStyles = fetchedFeed.styleNames ?? [] + // 각 이미지의 태그를 API로 가져오기 + self.displayableTags = await loadTagsForImages(images: fetchedFeed.images) + } catch { errorMessage = TextLiteral.Feed.loadDetailFailed feed = nil @@ -84,20 +89,44 @@ final class FeedDetailViewModel: ObservableObject { } // MARK: - Data Transformation - - private func mapToDisplayableTags(from images: [FeedImage]) -> [[ClothTag]] { - images.map { image in - image.tags.map { tag in - // TODO: API 연동 시 clothId로 실제 옷 정보(brand, name) 조회하여 사용 - ClothTag( - id: tag.id, - clothId: tag.clothId, - brand: TextLiteral.Feed.defaultBrand, - name: TextLiteral.Feed.defaultProductName + " \(tag.clothId)", - locationX: CGFloat(tag.locationX), - locationY: CGFloat(tag.locationY) - ) + + private func loadTagsForImages(images: [FeedImage]) async -> [[ClothTag]] { + await withTaskGroup(of: (Int, [ClothTag]).self) { group in + // 각 이미지의 태그를 병렬로 가져오기 + for (index, image) in images.enumerated() { + group.addTask { [weak self] in + guard let self = self, + let imageId = image.imageId else { + return (index, []) + } + + do { + let tagDTOs = try await self.historyAPIService.fetchClothTags(historyImageId: imageId) + let clothTags = tagDTOs.map { dto in + ClothTag( + id: UUID(), + clothId: Int(dto.clothId), + brand: dto.brand ?? TextLiteral.Feed.defaultBrand, + name: dto.name ?? TextLiteral.Feed.defaultProductName, + locationX: CGFloat(dto.locationX), + locationY: CGFloat(dto.locationY) + ) + } + return (index, clothTags) + } catch { + print("Failed to load tags for image \(imageId): \(error)") + return (index, []) + } + } + } + + // 결과를 순서대로 정렬 + var result: [(Int, [ClothTag])] = [] + for await value in group { + result.append(value) } + result.sort { $0.0 < $1.0 } + return result.map { $0.1 } } } diff --git a/Codive/Shared/Domain/Entities/Feed.swift b/Codive/Shared/Domain/Entities/Feed.swift index 4bb09315..38773ee1 100644 --- a/Codive/Shared/Domain/Entities/Feed.swift +++ b/Codive/Shared/Domain/Entities/Feed.swift @@ -56,10 +56,12 @@ public struct Feed: Identifiable, Equatable { // MARK: - FeedImage public struct FeedImage: Identifiable, Equatable { public let id: UUID = UUID() + public let imageId: Int64? public let imageUrl: String public let tags: [ImageClothTag] - - public init(imageUrl: String, tags: [ImageClothTag] = []) { + + public init(imageId: Int64? = nil, imageUrl: String, tags: [ImageClothTag] = []) { + self.imageId = imageId self.imageUrl = imageUrl self.tags = tags } diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 36ff331f..f0d75349 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -16,7 +16,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "7a69672cd7ee19c45fb7dce58004a64ea9613e90" + "revision" : "2ec7b429a93da400a78729f64abc13889e9eb695" } }, { From 3cc112ae79dcf7c5ac0e90025f1c32f68d55f595 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Wed, 21 Jan 2026 23:41:57 +0900 Subject: [PATCH 07/29] =?UTF-8?q?[#53]=20feedDetailView=EC=97=90=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9,=20=ED=95=B4=EC=8B=9C=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/DataSources/FeedDataSource.swift | 4 +- .../Feed/Data/HistoryAPIService.swift | 4 ++ .../Components/FeedContentSection.swift | 50 ++++++++++++++----- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift index 8902b8a2..480a51cd 100644 --- a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift +++ b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift @@ -141,13 +141,13 @@ extension HistoryDetailDTO { return Feed( id: feedId, - content: nil, + content: content, author: author, images: feedImages, situationId: situationId.map { Int($0) }, styleIds: styleIds.isEmpty ? nil : styleIds, styleNames: styleNames.isEmpty ? nil : styleNames, - hashtags: nil, + hashtags: hashtags, createdAt: createdAtDate, likeCount: Int(likeCount), isLiked: nil, diff --git a/Codive/Features/Feed/Data/HistoryAPIService.swift b/Codive/Features/Feed/Data/HistoryAPIService.swift index 0a9a57c5..567c48b3 100644 --- a/Codive/Features/Feed/Data/HistoryAPIService.swift +++ b/Codive/Features/Feed/Data/HistoryAPIService.swift @@ -29,6 +29,8 @@ struct HistoryDetailDTO { let historyDate: String? let situationId: Int64? let situationName: String? + let content: String? + let hashtags: [String]? let styles: [HistoryStyleDTO] } @@ -165,6 +167,8 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { historyDate: result.historyDate, situationId: result.situationId, situationName: result.situationName, + content: result.content, + hashtags: result.hashtags?.compactMap { $0 as? String }, styles: result.styles?.compactMap { style in guard let styleId = style.styleId, let styleName = style.styleName else { return nil } return HistoryStyleDTO(styleId: styleId, styleName: styleName) diff --git a/Codive/Features/Feed/Presentation/Components/FeedContentSection.swift b/Codive/Features/Feed/Presentation/Components/FeedContentSection.swift index 3ffd79e0..6b85138c 100644 --- a/Codive/Features/Feed/Presentation/Components/FeedContentSection.swift +++ b/Codive/Features/Feed/Presentation/Components/FeedContentSection.swift @@ -18,7 +18,39 @@ struct FeedContentSection: View { let onLikeTap: () -> Void let onCommentTap: () -> Void let onLikesCountTap: () -> Void - + + // content에서 해시태그 부분만 주황색으로 표시 + private var attributedContent: AttributedString { + var attributed = AttributedString(content) + + // 해시태그 패턴 찾기 (#로 시작하는 단어) + let pattern = "#[가-힣a-zA-Z0-9_]+" + guard let regex = try? NSRegularExpression(pattern: pattern) else { + attributed.foregroundColor = Color.Codive.grayscale1 + return attributed + } + + let nsString = content as NSString + let matches = regex.matches(in: content, range: NSRange(location: 0, length: nsString.length)) + + // 기본 색상 설정 + attributed.foregroundColor = Color.Codive.grayscale1 + + // 각 해시태그 부분만 주황색으로 + for match in matches { + if let range = Range(match.range, in: content) { + let start = AttributedString.Index(range.lowerBound, within: attributed) + let end = AttributedString.Index(range.upperBound, within: attributed) + + if let start = start, let end = end { + attributed[start.. Date: Wed, 21 Jan 2026 23:52:50 +0900 Subject: [PATCH 08/29] =?UTF-8?q?[#53]=20feedDetailView=EC=97=90=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Feed/Data/HistoryAPIService.swift | 2 ++ .../Add/ViewModel/PhotoTagViewModel.swift | 1 + .../FeedDetail/View/FeedImageSlider.swift | 2 +- .../View/LinkedProductListView.swift | 35 +++++++++++++++---- .../View/RemoteTaggableImageView.swift | 2 +- .../ViewModel/FeedDetailViewModel.swift | 1 + Codive/Shared/Domain/Entities/ClothTag.swift | 1 + 7 files changed, 36 insertions(+), 8 deletions(-) diff --git a/Codive/Features/Feed/Data/HistoryAPIService.swift b/Codive/Features/Feed/Data/HistoryAPIService.swift index 567c48b3..d3f9b2f8 100644 --- a/Codive/Features/Feed/Data/HistoryAPIService.swift +++ b/Codive/Features/Feed/Data/HistoryAPIService.swift @@ -48,6 +48,7 @@ struct ClothTagDTO { let clothId: Int64 let name: String? let brand: String? + let clothImageUrl: String? let locationX: Double let locationY: Double } @@ -212,6 +213,7 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { clothId: clothId, name: tag.name, brand: tag.brand, + clothImageUrl: tag.clothImageUrl, locationX: locationX, locationY: locationY ) diff --git a/Codive/Features/Feed/Presentation/Add/ViewModel/PhotoTagViewModel.swift b/Codive/Features/Feed/Presentation/Add/ViewModel/PhotoTagViewModel.swift index cb34419a..0e1385ed 100644 --- a/Codive/Features/Feed/Presentation/Add/ViewModel/PhotoTagViewModel.swift +++ b/Codive/Features/Feed/Presentation/Add/ViewModel/PhotoTagViewModel.swift @@ -95,6 +95,7 @@ final class PhotoTagViewModel: ObservableObject { clothId: product.id, brand: product.brand ?? "", name: product.name ?? "", + imageUrl: product.imageUrl, locationX: 0.5, locationY: 0.5 ) diff --git a/Codive/Features/Feed/Presentation/FeedDetail/View/FeedImageSlider.swift b/Codive/Features/Feed/Presentation/FeedDetail/View/FeedImageSlider.swift index f9fa061c..a9d8a2de 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/View/FeedImageSlider.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/View/FeedImageSlider.swift @@ -100,7 +100,7 @@ struct FeedImageSlider: View { "https://via.placeholder.com/300x400/0000FF" ], tags: [ - [ClothTag(id: UUID(), clothId: 1, brand: "Typeservice", name: "Layered Henry Neck", locationX: 0.3, locationY: 0.4)], + [ClothTag(id: UUID(), clothId: 1, brand: "Typeservice", name: "Layered Henry Neck", imageUrl: nil, locationX: 0.3, locationY: 0.4)], [] ], currentIndex: .constant(0), diff --git a/Codive/Features/Feed/Presentation/FeedDetail/View/LinkedProductListView.swift b/Codive/Features/Feed/Presentation/FeedDetail/View/LinkedProductListView.swift index 6764ade8..87251c0e 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/View/LinkedProductListView.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/View/LinkedProductListView.swift @@ -26,6 +26,7 @@ struct LinkedProductListView: View { } }, label: { ProductThumbnailItem( + tag: tag, isSelected: selectedTagId == tag.id ) }) @@ -39,19 +40,41 @@ struct LinkedProductListView: View { // MARK: - Item View private struct ProductThumbnailItem: View { + let tag: ClothTag let isSelected: Bool - + var body: some View { RoundedRectangle(cornerRadius: 8) .fill(Color.gray.opacity(0.1)) .frame(width: 60, height: 60) .overlay( - Image(systemName: "tshirt") - .foregroundStyle(Color.gray) + Group { + if let imageUrl = tag.imageUrl { + AsyncImage(url: URL(string: imageUrl)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + case .failure: + Image(systemName: "tshirt") + .foregroundStyle(Color.gray) + case .empty: + ProgressView() + @unknown default: + EmptyView() + } + } + } else { + Image(systemName: "tshirt") + .foregroundStyle(Color.gray) + } + } ) + .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(isSelected ? Color.black : Color.clear, lineWidth: 1) + .stroke(isSelected ? Color.black : Color.clear, lineWidth: 2) ) .padding(.bottom, 2) } @@ -61,8 +84,8 @@ private struct ProductThumbnailItem: View { #Preview { LinkedProductListView( tags: [ - ClothTag(id: UUID(), clothId: 1, brand: "Nike", name: "Shirt", locationX: 0, locationY: 0), - ClothTag(id: UUID(), clothId: 2, brand: "Adidas", name: "Pants", locationX: 0, locationY: 0) + ClothTag(id: UUID(), clothId: 1, brand: "Nike", name: "Shirt", imageUrl: nil, locationX: 0, locationY: 0), + ClothTag(id: UUID(), clothId: 2, brand: "Adidas", name: "Pants", imageUrl: nil, locationX: 0, locationY: 0) ], selectedTagId: .constant(nil) ) diff --git a/Codive/Features/Feed/Presentation/FeedDetail/View/RemoteTaggableImageView.swift b/Codive/Features/Feed/Presentation/FeedDetail/View/RemoteTaggableImageView.swift index 7e223578..7d314375 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/View/RemoteTaggableImageView.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/View/RemoteTaggableImageView.swift @@ -99,7 +99,7 @@ struct RemoteTaggableImageView: View { RemoteTaggableImageView( imageUrl: "https://via.placeholder.com/300x400", tags: .constant([ - ClothTag(id: UUID(), clothId: 1, brand: "Nike", name: "Shirt", locationX: 0.5, locationY: 0.3) + ClothTag(id: UUID(), clothId: 1, brand: "Nike", name: "Shirt", imageUrl: nil, locationX: 0.5, locationY: 0.3) ]), selectedTagId: nil ) { tagId in diff --git a/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift b/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift index 7d8be581..ed7a4e59 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift @@ -108,6 +108,7 @@ final class FeedDetailViewModel: ObservableObject { clothId: Int(dto.clothId), brand: dto.brand ?? TextLiteral.Feed.defaultBrand, name: dto.name ?? TextLiteral.Feed.defaultProductName, + imageUrl: dto.clothImageUrl, locationX: CGFloat(dto.locationX), locationY: CGFloat(dto.locationY) ) diff --git a/Codive/Shared/Domain/Entities/ClothTag.swift b/Codive/Shared/Domain/Entities/ClothTag.swift index 69294aa7..555b7b8d 100644 --- a/Codive/Shared/Domain/Entities/ClothTag.swift +++ b/Codive/Shared/Domain/Entities/ClothTag.swift @@ -14,6 +14,7 @@ struct ClothTag: Identifiable, Equatable, Hashable { let clothId: Int let brand: String let name: String + let imageUrl: String? var locationX: CGFloat var locationY: CGFloat From a954e4987732e8d9448882e48bbf1da6ddfeb048 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Thu, 22 Jan 2026 22:04:17 +0900 Subject: [PATCH 09/29] =?UTF-8?q?[#53]=20Feed=20=ED=83=AD=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94,=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/DataSources/FeedDataSource.swift | 21 ++++- .../Features/Feed/Data/FeedAPIService.swift | 88 +++++++++++++++++++ Tuist/Package.resolved | 4 +- 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift index 480a51cd..ddd8355c 100644 --- a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift +++ b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift @@ -79,12 +79,16 @@ final class DefaultFeedDataSource: FeedDataSource { } func toggleLike(feedId: Int) async throws { - // TODO: Implement API call for toggle like + try await apiService.toggleLike(historyId: Int64(feedId)) } func fetchLikers(feedId: Int) async throws -> [User] { - // TODO: Implement API call for likers - return [] + let result = try await apiService.fetchLikers( + historyId: Int64(feedId), + lastLikeId: nil, + size: 100 + ) + return result.likers.map { $0.toDomain() } } } @@ -119,6 +123,17 @@ private extension FeedAuthorDTO { } } +private extension LikerDTO { + func toDomain() -> User { + return User( + id: String(memberId), + nickname: nickname ?? "", + profileImageUrl: profileImageUrl, + isFollowing: isFollowing + ) + } +} + extension HistoryDetailDTO { func toDomain(feedId: Int) -> Feed { let author = User( diff --git a/Codive/Features/Feed/Data/FeedAPIService.swift b/Codive/Features/Feed/Data/FeedAPIService.swift index 512686f1..8c792ffc 100644 --- a/Codive/Features/Feed/Data/FeedAPIService.swift +++ b/Codive/Features/Feed/Data/FeedAPIService.swift @@ -19,6 +19,10 @@ protocol FeedAPIServiceProtocol { situationIds: [Int64]?, followScope: FeedFollowScope ) async throws -> FeedListResult + + func toggleLike(historyId: Int64) async throws + + func fetchLikers(historyId: Int64, lastLikeId: Int64?, size: Int32) async throws -> LikersResult } // MARK: - Supporting Types @@ -56,6 +60,19 @@ struct FeedAuthorDTO { let isFollowing: Bool? } +struct LikersResult { + let likers: [LikerDTO] + let hasNext: Bool +} + +struct LikerDTO { + let likeId: Int64 + let memberId: Int64 + let nickname: String? + let profileImageUrl: String? + let isFollowing: Bool? +} + // MARK: - FeedAPIService Implementation final class FeedAPIService: FeedAPIServiceProtocol { @@ -134,6 +151,77 @@ extension FeedAPIService { } } +// MARK: - Toggle Like + +extension FeedAPIService { + + func toggleLike(historyId: Int64) async throws { + let input = Operations.Like_toggleLike.Input( + query: .init(historyId: historyId) + ) + + let response = try await client.Like_toggleLike(input) + + switch response { + case .ok: + return + case .undocumented(statusCode: let code, _): + throw FeedAPIError.serverError(statusCode: code, message: "좋아요 토글 실패") + } + } +} + +// MARK: - Fetch Likers + +extension FeedAPIService { + + func fetchLikers(historyId: Int64, lastLikeId: Int64?, size: Int32) async throws -> LikersResult { + let input = Operations.Like_getLikedMembers.Input( + query: .init( + historyId: historyId, + lastLikeId: lastLikeId, + size: size + ) + ) + + let response = try await client.Like_getLikedMembers(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseSliceResponseLikedMemberPreview.self, + from: data + ) + + guard let result = decoded.result else { + throw FeedAPIError.serverError(statusCode: 200, message: "좋아요 유저 목록 없음") + } + + let likers: [LikerDTO] = result.content?.compactMap { member in + guard let memberId = member.id else { + return nil + } + return LikerDTO( + likeId: member.lastLikeId ?? 0, + memberId: memberId, + nickname: member.nickname, + profileImageUrl: member.imageUrl, + isFollowing: member.followStatus + ) + } ?? [] + + return LikersResult( + likers: likers, + hasNext: !(result.isLast ?? false) + ) + + case .undocumented(statusCode: let code, _): + throw FeedAPIError.serverError(statusCode: code, message: "좋아요 유저 목록 조회 실패") + } + } +} + // MARK: - FeedAPIError enum FeedAPIError: LocalizedError { diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index f0d75349..0a3cb76b 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ReactiveX/RxSwift.git", "state" : { - "revision" : "5004a18539bd68905c5939aa893075f578f4f03d", - "version" : "6.9.1" + "revision" : "b529ef9b7fa3ba2b38d299df23fc2165f21abbb9", + "version" : "6.10.0" } }, { From f03bcba3539732b3275bd809a2f65bdce2c8fb70 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Thu, 22 Jan 2026 22:14:29 +0900 Subject: [PATCH 10/29] =?UTF-8?q?[#53]=20Feed=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20UI=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FeedDetail/LikesList/FeedLikesListView.swift | 10 +--------- Codive/Shared/DesignSystem/Views/CustomUserRow.swift | 8 +++++--- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/Codive/Features/Feed/Presentation/FeedDetail/LikesList/FeedLikesListView.swift b/Codive/Features/Feed/Presentation/FeedDetail/LikesList/FeedLikesListView.swift index 4fe4a141..9dd54cb4 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/LikesList/FeedLikesListView.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/LikesList/FeedLikesListView.swift @@ -20,14 +20,6 @@ struct FeedLikesListView: View { .font(.codive_title2) .foregroundStyle(Color.Codive.grayscale1) Spacer() - Button(action: { - dismiss() - }, label: { - Image(systemName: "xmark") - .resizable() - .frame(width: 16, height: 16) - .foregroundStyle(Color.Codive.grayscale1) - }) } .padding(.horizontal, 20) .padding(.vertical, 16) @@ -48,7 +40,7 @@ struct FeedLikesListView: View { user: SimpleUser( userId: Int(user.id) ?? 0, nickname: user.nickname, - handle: user.id, + handle: "", avatarURL: URL(string: user.profileImageUrl ?? "") ), buttonTitle: (user.isFollowing ?? false) ? TextLiteral.LikesList.following : TextLiteral.LikesList.follow, diff --git a/Codive/Shared/DesignSystem/Views/CustomUserRow.swift b/Codive/Shared/DesignSystem/Views/CustomUserRow.swift index 3c178a06..a1911089 100644 --- a/Codive/Shared/DesignSystem/Views/CustomUserRow.swift +++ b/Codive/Shared/DesignSystem/Views/CustomUserRow.swift @@ -73,9 +73,11 @@ struct CustomUserRow: View { Text(user.nickname) .font(.codive_body1_medium) .foregroundStyle(Color.Codive.grayscale1) - Text(user.handle) - .font(.codive_body3_medium) - .foregroundStyle(Color.Codive.grayscale3) + if !user.handle.isEmpty { + Text(user.handle) + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.grayscale3) + } } .padding(.leading, 8) From 5ebe26c4230f98d216a460ad888465ba4d757388 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Thu, 22 Jan 2026 22:32:44 +0900 Subject: [PATCH 11/29] =?UTF-8?q?[#53]=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B2=98=20=EA=B5=AC=EC=A1=B0=EC=97=90=20=EB=A7=9E=EA=B2=8C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20-=20UseCase=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=83=9D=EC=84=B1=20-=20Repository=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=20-=20ViewModel=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/DIContainer/FeedDIContainer.swift | 29 ++++++++++++++----- .../PreviewMocks/MockFeedRepository.swift | 24 +++++++++++++++ .../Repositories/FeedRepositoryImpl.swift | 22 +++++++++++++- .../Domain/Protocols/FeedRepository.swift | 3 ++ .../UseCases/FetchClothTagsUseCase.swift | 28 ++++++++++++++++++ .../Domain/UseCases/ToggleLikeUseCase.swift | 28 ++++++++++++++++++ .../ViewModel/FeedDetailViewModel.swift | 29 ++++++------------- .../MainFeed/ViewModel/FeedViewModel.swift | 8 ++--- 8 files changed, 139 insertions(+), 32 deletions(-) create mode 100644 Codive/Features/Feed/Domain/UseCases/FetchClothTagsUseCase.swift create mode 100644 Codive/Features/Feed/Domain/UseCases/ToggleLikeUseCase.swift diff --git a/Codive/DIContainer/FeedDIContainer.swift b/Codive/DIContainer/FeedDIContainer.swift index 6cd971f4..fe509fab 100644 --- a/Codive/DIContainer/FeedDIContainer.swift +++ b/Codive/DIContainer/FeedDIContainer.swift @@ -36,7 +36,10 @@ final class FeedDIContainer { }() private lazy var feedRepository: FeedRepository = { - return FeedRepositoryImpl(dataSource: feedDataSource) + return FeedRepositoryImpl( + dataSource: feedDataSource, + historyAPIService: HistoryAPIService() + ) }() // MARK: - UseCases @@ -57,13 +60,21 @@ final class FeedDIContainer { return DefaultFetchFeedLikersUseCase(feedRepository: feedRepository) } + func makeToggleLikeUseCase() -> ToggleLikeUseCase { + return DefaultToggleLikeUseCase(feedRepository: feedRepository) + } + + func makeFetchClothTagsUseCase() -> FetchClothTagsUseCase { + return DefaultFetchClothTagsUseCase(feedRepository: feedRepository) + } + // MARK: - ViewModels func makeFeedViewModel() -> FeedViewModel { return FeedViewModel( navigationRouter: navigationRouter, fetchFeedsUseCase: makeFetchFeedsUseCase(), - feedRepository: feedRepository + toggleLikeUseCase: makeToggleLikeUseCase() ) } @@ -72,7 +83,8 @@ final class FeedDIContainer { feedId: feedId, fetchFeedDetailUseCase: makeFetchFeedDetailUseCase(), fetchLikersUseCase: makeFetchFeedLikersUseCase(), - feedRepository: feedRepository, + toggleLikeUseCase: makeToggleLikeUseCase(), + fetchClothTagsUseCase: makeFetchClothTagsUseCase(), navigationRouter: navigationRouter ) } @@ -95,13 +107,16 @@ extension FeedDIContainer { repository: FeedRepository, navigationRouter: NavigationRouter ) -> FeedDetailViewModel { - let useCase = DefaultFetchFeedDetailUseCase(repository: repository) - let likersUseCase = DefaultFetchFeedLikersUseCase(feedRepository: repository) + let detailUseCase = DefaultFetchFeedDetailUseCase(repository: repository) + let likersUseCase = DefaultFetchFeedLikersUseCase(feedRepository: repository) + let toggleLikeUseCase = DefaultToggleLikeUseCase(feedRepository: repository) + let clothTagsUseCase = DefaultFetchClothTagsUseCase(feedRepository: repository) return FeedDetailViewModel( feedId: feedId, - fetchFeedDetailUseCase: useCase, + fetchFeedDetailUseCase: detailUseCase, fetchLikersUseCase: likersUseCase, - feedRepository: repository, + toggleLikeUseCase: toggleLikeUseCase, + fetchClothTagsUseCase: clothTagsUseCase, navigationRouter: navigationRouter ) } diff --git a/Codive/Features/Feed/Data/PreviewMocks/MockFeedRepository.swift b/Codive/Features/Feed/Data/PreviewMocks/MockFeedRepository.swift index 9a0783e9..21b20671 100644 --- a/Codive/Features/Feed/Data/PreviewMocks/MockFeedRepository.swift +++ b/Codive/Features/Feed/Data/PreviewMocks/MockFeedRepository.swift @@ -78,5 +78,29 @@ final class MockFeedRepository: FeedRepository { .init(id: "4", nickname: "옷잘알", profileImageUrl: nil) ] } + + func fetchClothTags(historyImageId: Int64) async throws -> [ClothTag] { + print("Fetching cloth tags for historyImageId: \(historyImageId)") + return [ + ClothTag( + id: UUID(), + clothId: 101, + brand: "Zara", + name: "Black T-Shirt", + imageUrl: "https://via.placeholder.com/100x100", + locationX: 0.5, + locationY: 0.4 + ), + ClothTag( + id: UUID(), + clothId: 102, + brand: "Uniqlo", + name: "Blue Jeans", + imageUrl: "https://via.placeholder.com/100x100", + locationX: 0.5, + locationY: 0.7 + ) + ] + } } #endif diff --git a/Codive/Features/Feed/Data/Repositories/FeedRepositoryImpl.swift b/Codive/Features/Feed/Data/Repositories/FeedRepositoryImpl.swift index 72ddeb91..7be8b914 100644 --- a/Codive/Features/Feed/Data/Repositories/FeedRepositoryImpl.swift +++ b/Codive/Features/Feed/Data/Repositories/FeedRepositoryImpl.swift @@ -11,9 +11,14 @@ import Foundation /// DataSource로부터 데이터를 가져와서 Domain Layer에 제공 final class FeedRepositoryImpl: FeedRepository { private let dataSource: FeedDataSource + private let historyAPIService: HistoryAPIServiceProtocol - init(dataSource: FeedDataSource) { + init( + dataSource: FeedDataSource, + historyAPIService: HistoryAPIServiceProtocol = HistoryAPIService() + ) { self.dataSource = dataSource + self.historyAPIService = historyAPIService } func fetchFeeds( @@ -43,4 +48,19 @@ final class FeedRepositoryImpl: FeedRepository { func fetchLikers(feedId: Int) async throws -> [User] { return try await dataSource.fetchLikers(feedId: feedId) } + + func fetchClothTags(historyImageId: Int64) async throws -> [ClothTag] { + let tagDTOs = try await historyAPIService.fetchClothTags(historyImageId: historyImageId) + return tagDTOs.map { dto in + ClothTag( + id: UUID(), + clothId: Int(dto.clothId), + brand: dto.brand ?? TextLiteral.Feed.defaultBrand, + name: dto.name ?? TextLiteral.Feed.defaultProductName, + imageUrl: dto.clothImageUrl, + locationX: CGFloat(dto.locationX), + locationY: CGFloat(dto.locationY) + ) + } + } } diff --git a/Codive/Features/Feed/Domain/Protocols/FeedRepository.swift b/Codive/Features/Feed/Domain/Protocols/FeedRepository.swift index 567ea6d0..9495c0e0 100644 --- a/Codive/Features/Feed/Domain/Protocols/FeedRepository.swift +++ b/Codive/Features/Feed/Domain/Protocols/FeedRepository.swift @@ -25,4 +25,7 @@ protocol FeedRepository { // MARK: - 좋아요 누른 유저 목록 조회 func fetchLikers(feedId: Int) async throws -> [User] + + // MARK: - 이미지의 옷 태그 조회 + func fetchClothTags(historyImageId: Int64) async throws -> [ClothTag] } diff --git a/Codive/Features/Feed/Domain/UseCases/FetchClothTagsUseCase.swift b/Codive/Features/Feed/Domain/UseCases/FetchClothTagsUseCase.swift new file mode 100644 index 00000000..35e2ba56 --- /dev/null +++ b/Codive/Features/Feed/Domain/UseCases/FetchClothTagsUseCase.swift @@ -0,0 +1,28 @@ +// +// FetchClothTagsUseCase.swift +// Codive +// +// Created by Claude on 2026/01/22. +// + +import Foundation + +protocol FetchClothTagsUseCase { + func execute(historyImageId: Int64) async throws -> [ClothTag] +} + +final class DefaultFetchClothTagsUseCase: FetchClothTagsUseCase { + + // MARK: - Properties + private let feedRepository: FeedRepository + + // MARK: - Initializer + init(feedRepository: FeedRepository) { + self.feedRepository = feedRepository + } + + // MARK: - FetchClothTagsUseCase + func execute(historyImageId: Int64) async throws -> [ClothTag] { + return try await feedRepository.fetchClothTags(historyImageId: historyImageId) + } +} diff --git a/Codive/Features/Feed/Domain/UseCases/ToggleLikeUseCase.swift b/Codive/Features/Feed/Domain/UseCases/ToggleLikeUseCase.swift new file mode 100644 index 00000000..90b6d5ae --- /dev/null +++ b/Codive/Features/Feed/Domain/UseCases/ToggleLikeUseCase.swift @@ -0,0 +1,28 @@ +// +// ToggleLikeUseCase.swift +// Codive +// +// Created by Claude on 2026/01/22. +// + +import Foundation + +protocol ToggleLikeUseCase { + func execute(feedId: Int) async throws +} + +final class DefaultToggleLikeUseCase: ToggleLikeUseCase { + + // MARK: - Properties + private let feedRepository: FeedRepository + + // MARK: - Initializer + init(feedRepository: FeedRepository) { + self.feedRepository = feedRepository + } + + // MARK: - ToggleLikeUseCase + func execute(feedId: Int) async throws { + try await feedRepository.toggleLike(feedId: feedId) + } +} diff --git a/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift b/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift index ed7a4e59..32457709 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift @@ -29,9 +29,9 @@ final class FeedDetailViewModel: ObservableObject { private let feedId: Int private let fetchFeedDetailUseCase: FetchFeedDetailUseCase private let fetchLikersUseCase: FetchFeedLikersUseCase - private let feedRepository: FeedRepository + private let toggleLikeUseCase: ToggleLikeUseCase + private let fetchClothTagsUseCase: FetchClothTagsUseCase private let navigationRouter: NavigationRouter - private let historyAPIService: HistoryAPIServiceProtocol private let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = TextLiteral.Feed.dateFormat @@ -44,16 +44,16 @@ final class FeedDetailViewModel: ObservableObject { feedId: Int, fetchFeedDetailUseCase: FetchFeedDetailUseCase, fetchLikersUseCase: FetchFeedLikersUseCase, - feedRepository: FeedRepository, - navigationRouter: NavigationRouter, - historyAPIService: HistoryAPIServiceProtocol = HistoryAPIService() + toggleLikeUseCase: ToggleLikeUseCase, + fetchClothTagsUseCase: FetchClothTagsUseCase, + navigationRouter: NavigationRouter ) { self.feedId = feedId self.fetchFeedDetailUseCase = fetchFeedDetailUseCase self.fetchLikersUseCase = fetchLikersUseCase - self.feedRepository = feedRepository + self.toggleLikeUseCase = toggleLikeUseCase + self.fetchClothTagsUseCase = fetchClothTagsUseCase self.navigationRouter = navigationRouter - self.historyAPIService = historyAPIService } // MARK: - Feed 상세 로딩 @@ -101,18 +101,7 @@ final class FeedDetailViewModel: ObservableObject { } do { - let tagDTOs = try await self.historyAPIService.fetchClothTags(historyImageId: imageId) - let clothTags = tagDTOs.map { dto in - ClothTag( - id: UUID(), - clothId: Int(dto.clothId), - brand: dto.brand ?? TextLiteral.Feed.defaultBrand, - name: dto.name ?? TextLiteral.Feed.defaultProductName, - imageUrl: dto.clothImageUrl, - locationX: CGFloat(dto.locationX), - locationY: CGFloat(dto.locationY) - ) - } + let clothTags = try await self.fetchClothTagsUseCase.execute(historyImageId: imageId) return (index, clothTags) } catch { print("Failed to load tags for image \(imageId): \(error)") @@ -167,7 +156,7 @@ final class FeedDetailViewModel: ObservableObject { // 서버 요청 do { - try await feedRepository.toggleLike(feedId: currentFeed.id) + try await toggleLikeUseCase.execute(feedId: currentFeed.id) } catch { // 실패 시 롤백 feed = originalFeed diff --git a/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift b/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift index 9f107e0b..fbe3129f 100644 --- a/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift +++ b/Codive/Features/Feed/Presentation/MainFeed/ViewModel/FeedViewModel.swift @@ -35,7 +35,7 @@ final class FeedViewModel: ObservableObject { private let navigationRouter: NavigationRouter private let fetchFeedsUseCase: FetchFeedsUseCase - private let feedRepository: FeedRepository + private let toggleLikeUseCase: ToggleLikeUseCase private let pageSize: Int = 20 private var nextCursor: String? private var hasMorePages: Bool = true @@ -45,11 +45,11 @@ final class FeedViewModel: ObservableObject { init( navigationRouter: NavigationRouter, fetchFeedsUseCase: FetchFeedsUseCase, - feedRepository: FeedRepository + toggleLikeUseCase: ToggleLikeUseCase ) { self.navigationRouter = navigationRouter self.fetchFeedsUseCase = fetchFeedsUseCase - self.feedRepository = feedRepository + self.toggleLikeUseCase = toggleLikeUseCase } // MARK: - Public Methods @@ -167,7 +167,7 @@ final class FeedViewModel: ObservableObject { // 서버에 요청 do { - try await feedRepository.toggleLike(feedId: feedId) + try await toggleLikeUseCase.execute(feedId: feedId) } catch { // 에러 시 롤백 feeds[index] = originalFeed From 694f642cd238032c511903c6595aa3d6a94864d7 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Fri, 23 Jan 2026 00:14:55 +0900 Subject: [PATCH 12/29] =?UTF-8?q?[#53]=20Comment=20API=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20-=20CodiveAPI=20=EC=9D=B8=EC=A6=9D=20=EB=AF=B8?= =?UTF-8?q?=EB=93=A4=EC=9B=A8=EC=96=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Comment 모델에 API 응답 매핑 메서드 추가 - CommentRepository에 대댓글 메서드 추가 - DefaultCommentDataSource 구현 (CodiveAuthMiddleware 포함) - 댓글 조회/작성: GET, POST /comments - 대댓글 조회/작성: GET, POST /comments/{commentId}/replies - CommentRepositoryImpl에 대댓글 구현 추가 --- .../Data/DataSources/CommentDataSource.swift | 196 +++++++++++++++++- .../PreviewMocks/MockCommentRepository.swift | 40 +++- .../Repositories/CommentRepositoryImpl.swift | 8 + .../Domain/Protocols/CommentRepository.swift | 19 +- Codive/Shared/Domain/Entities/Comment.swift | 44 +++- 5 files changed, 297 insertions(+), 10 deletions(-) diff --git a/Codive/Features/Comment/Data/DataSources/CommentDataSource.swift b/Codive/Features/Comment/Data/DataSources/CommentDataSource.swift index ecb70e2c..e2eeee65 100644 --- a/Codive/Features/Comment/Data/DataSources/CommentDataSource.swift +++ b/Codive/Features/Comment/Data/DataSources/CommentDataSource.swift @@ -6,28 +6,201 @@ // import Foundation +import CodiveAPI // MARK: - Protocol protocol CommentDataSource { func fetchComments(feedId: Int, page: Int) async throws -> (comments: [Comment], hasNext: Bool) func postComment(feedId: Int, content: String) async throws -> Comment + func fetchReplies(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) + func postReply(feedId: Int, commentId: Int, content: String) async throws -> Comment +} + +// MARK: - Default Implementation (CodiveAPI) +final class DefaultCommentDataSource: CommentDataSource { + private let apiClient: Client + private let jsonDecoder: JSONDecoder + + init() { + self.apiClient = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: KeychainTokenProvider())] + ) + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + } + + init(apiClient: Client) { + self.apiClient = apiClient + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + } + + func fetchComments(feedId: Int, page: Int) async throws -> (comments: [Comment], hasNext: Bool) { + let response = try await apiClient.Comment_getComments( + query: Operations.Comment_getComments.Input.Query( + historyId: Int64(feedId), + lastCommentId: nil, + size: 10, + direction: .DESC + ) + ) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseSliceResponseCommentListResponse.self, + from: data + ) + + let comments = (apiResponse.result?.content ?? []).map { Comment.from(apiResponse: $0) } + let hasNext = !(apiResponse.result?.isLast ?? true) + + return (comments: comments, hasNext: hasNext) + default: + if case .undocumented(let statusCode, let payload) = response { + if let body = payload.body { + let data = try await Data(collecting: body, upTo: .max) + if let responseBody = String(data: data, encoding: .utf8) { + print("fetchComments error response [\(statusCode)]: \(responseBody)") + } + } + } + print("fetchComments response: \(response)") + throw NSError(domain: "CommentDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch comments"]) + } + } + + func postComment(feedId: Int, content: String) async throws -> Comment { + let body = Components.Schemas.CommentCreateRequest( + historyId: Int64(feedId), + content: content + ) + + let response = try await apiClient.Comment_createComment( + body: .json(body) + ) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseCommentCreateResponse.self, + from: data + ) + + guard let commentId = apiResponse.result?.commentId else { + throw NSError(domain: "CommentDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]) + } + + // 새로운 댓글 객체 생성 (추가 정보는 필요하면 별도로 조회) + let currentUser = User(id: "", nickname: "현재 사용자", profileImageUrl: nil) + let newComment = Comment( + id: Int(commentId), + content: content, + author: currentUser, + isMine: true, + hasReplies: false, + replies: [] + ) + + return newComment + default: + throw NSError(domain: "CommentDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to post comment"]) + } + } + + func fetchReplies(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) { + let response = try await apiClient.Comment_getReplies( + path: Operations.Comment_getReplies.Input.Path(commentId: Int64(commentId)), + query: Operations.Comment_getReplies.Input.Query( + lastReplyId: nil, + size: 10, + direction: .DESC + ) + ) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseSliceResponseReplyListResponse.self, + from: data + ) + + let replies = (apiResponse.result?.content ?? []).map { Comment.from(apiResponse: $0) } + let hasNext = !(apiResponse.result?.isLast ?? true) + + return (replies: replies, hasNext: hasNext) + default: + throw NSError(domain: "CommentDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch replies"]) + } + } + + func postReply(feedId: Int, commentId: Int, content: String) async throws -> Comment { + // 대댓글 작성용 요청 + let body = Components.Schemas.CommentCreateRequest( + historyId: Int64(feedId), + content: content + ) + + let response = try await apiClient.Comment_createReply( + path: Operations.Comment_createReply.Input.Path(commentId: Int64(commentId)), + body: .json(body) + ) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseCommentCreateResponse.self, + from: data + ) + + guard let replyId = apiResponse.result?.commentId else { + throw NSError(domain: "CommentDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]) + } + + // 새로운 대댓글 객체 생성 + let currentUser = User(id: "", nickname: "현재 사용자", profileImageUrl: nil) + let newReply = Comment( + id: Int(replyId), + content: content, + author: currentUser, + isMine: true, + hasReplies: false, + replies: [] + ) + + return newReply + default: + print("postReply response: \(response)") + throw NSError(domain: "CommentDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to post reply"]) + } + } } // MARK: - Mock Implementation final class MockCommentDataSource: CommentDataSource { func fetchComments(feedId: Int, page: Int) async throws -> (comments: [Comment], hasNext: Bool) { try await Task.sleep(nanoseconds: 500_000_000) - + if page == 0 { return (comments: CommentMockData.comments, hasNext: true) } else { return (comments: [], hasNext: false) } } - + func postComment(feedId: Int, content: String) async throws -> Comment { try await Task.sleep(nanoseconds: 300_000_000) - + let newComment = Comment( id: Int.random(in: 100...999), content: content, @@ -36,4 +209,21 @@ final class MockCommentDataSource: CommentDataSource { ) return newComment } + + func fetchReplies(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) { + try await Task.sleep(nanoseconds: 500_000_000) + return (replies: [], hasNext: false) + } + + func postReply(feedId: Int, commentId: Int, content: String) async throws -> Comment { + try await Task.sleep(nanoseconds: 300_000_000) + + let newReply = Comment( + id: Int.random(in: 100...999), + content: content, + author: CommentMockData.users[2], + isMine: true + ) + return newReply + } } diff --git a/Codive/Features/Comment/Data/PreviewMocks/MockCommentRepository.swift b/Codive/Features/Comment/Data/PreviewMocks/MockCommentRepository.swift index 3f88cb94..fa94ae9c 100644 --- a/Codive/Features/Comment/Data/PreviewMocks/MockCommentRepository.swift +++ b/Codive/Features/Comment/Data/PreviewMocks/MockCommentRepository.swift @@ -9,11 +9,11 @@ import Foundation import Combine final class MockCommentRepository: CommentRepository { - + func fetchComments(feedId: Int, page: Int) async throws -> (comments: [Comment], hasNext: Bool) { // 0.2초 딜레이 try await Task.sleep(nanoseconds: 200_000_000) - + // 페이지가 0일 때만 가짜 데이터를 반환하고, 그 이후 페이지는 없다고 가정 if page == 0 { return (comments: CommentMockData.comments, hasNext: true) @@ -21,11 +21,11 @@ final class MockCommentRepository: CommentRepository { return (comments: [], hasNext: false) } } - + @discardableResult func postComment(feedId: Int, content: String) async throws -> Comment { try await Task.sleep(nanoseconds: 300_000_000) - + let newComment = Comment( id: Int.random(in: 100...999), content: content, @@ -35,4 +35,36 @@ final class MockCommentRepository: CommentRepository { ) return newComment } + + func fetchReplies(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) { + try await Task.sleep(nanoseconds: 200_000_000) + + if page == 0 { + return (replies: CommentMockData.comments.suffix(2).map { comment in + Comment( + id: comment.id + 1000, + content: comment.content, + author: comment.author, + isMine: false, + hasReplies: false + ) + }, hasNext: false) + } else { + return (replies: [], hasNext: false) + } + } + + @discardableResult + func postReply(feedId: Int, commentId: Int, content: String) async throws -> Comment { + try await Task.sleep(nanoseconds: 300_000_000) + + let newReply = Comment( + id: Int.random(in: 1000...9999), + content: content, + author: CommentMockData.users[2], + isMine: true, + hasReplies: false + ) + return newReply + } } diff --git a/Codive/Features/Comment/Data/Repositories/CommentRepositoryImpl.swift b/Codive/Features/Comment/Data/Repositories/CommentRepositoryImpl.swift index d8591e67..3afb90d1 100644 --- a/Codive/Features/Comment/Data/Repositories/CommentRepositoryImpl.swift +++ b/Codive/Features/Comment/Data/Repositories/CommentRepositoryImpl.swift @@ -25,4 +25,12 @@ final class CommentRepositoryImpl: CommentRepository { func postComment(feedId: Int, content: String) async throws -> Comment { return try await dataSource.postComment(feedId: feedId, content: content) } + + func fetchReplies(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) { + return try await dataSource.fetchReplies(commentId: commentId, page: page) + } + + func postReply(feedId: Int, commentId: Int, content: String) async throws -> Comment { + return try await dataSource.postReply(feedId: feedId, commentId: commentId, content: content) + } } diff --git a/Codive/Features/Comment/Domain/Protocols/CommentRepository.swift b/Codive/Features/Comment/Domain/Protocols/CommentRepository.swift index 7b1096c8..addfefcc 100644 --- a/Codive/Features/Comment/Domain/Protocols/CommentRepository.swift +++ b/Codive/Features/Comment/Domain/Protocols/CommentRepository.swift @@ -8,6 +8,7 @@ import Foundation /// 댓글 도메인에 대한 데이터 소스 액세스를 정의하는 프로토콜임. + public protocol CommentRepository { /// 특정 피드에 달린 댓글 목록을 가져옴. /// - Parameters: @@ -15,7 +16,7 @@ public protocol CommentRepository { /// - page: 페이지네이션을 위한 페이지 번호 /// - Returns: `Comment` 배열과 다음 페이지 존재 여부를 포함하는 튜플 func fetchComments(feedId: Int, page: Int) async throws -> (comments: [Comment], hasNext: Bool) - + /// 새로운 댓글을 작성함. /// - Parameters: /// - feedId: 댓글을 작성할 피드의 ID @@ -23,4 +24,20 @@ public protocol CommentRepository { /// - Returns: 생성된 `Comment` 객체 @discardableResult func postComment(feedId: Int, content: String) async throws -> Comment + + /// 특정 댓글의 대댓글 목록을 가져옴. + /// - Parameters: + /// - commentId: 대댓글을 조회할 댓글의 ID + /// - page: 페이지네이션을 위한 페이지 번호 + /// - Returns: `Comment` 배열과 다음 페이지 존재 여부를 포함하는 튜플 + func fetchReplies(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) + + /// 새로운 대댓글을 작성함. + /// - Parameters: + /// - feedId: 피드 ID + /// - commentId: 대댓글을 작성할 댓글의 ID + /// - content: 대댓글 내용 + /// - Returns: 생성된 `Comment` 객체 + @discardableResult + func postReply(feedId: Int, commentId: Int, content: String) async throws -> Comment } diff --git a/Codive/Shared/Domain/Entities/Comment.swift b/Codive/Shared/Domain/Entities/Comment.swift index 7f8b572c..51b81fdd 100644 --- a/Codive/Shared/Domain/Entities/Comment.swift +++ b/Codive/Shared/Domain/Entities/Comment.swift @@ -6,16 +6,17 @@ // import Foundation +import CodiveAPI public struct Comment: Identifiable, Equatable { public let id: Int public let content: String public let author: User public let isMine: Bool - + // 댓글(Parent) 전용 필드 public let hasReplies: Bool - + // UI 상태 관리를 위한 필드 public var replies: [Comment]? @@ -35,3 +36,42 @@ public struct Comment: Identifiable, Equatable { self.replies = replies } } + +// MARK: - API 매핑 +extension Comment { + /// CodiveAPI의 CommentListResponse를 Comment로 변환 + static func from(apiResponse: Components.Schemas.CommentListResponse) -> Comment { + let user = User( + id: String(apiResponse.memberId ?? 0), + nickname: apiResponse.nickName ?? "", + profileImageUrl: apiResponse.profileImageUrl + ) + + return Comment( + id: Int(apiResponse.commentId ?? 0), + content: apiResponse.content ?? "", + author: user, + isMine: apiResponse.isMine ?? false, + hasReplies: apiResponse.replied ?? false, + replies: [] + ) + } + + /// CodiveAPI의 ReplyListResponse를 Comment로 변환 + static func from(apiResponse: Components.Schemas.ReplyListResponse) -> Comment { + let user = User( + id: String(apiResponse.memberId ?? 0), + nickname: apiResponse.nickName ?? "", + profileImageUrl: apiResponse.profileImageUrl + ) + + return Comment( + id: Int(apiResponse.replyId ?? 0), + content: apiResponse.content ?? "", + author: user, + isMine: apiResponse.isMine ?? false, + hasReplies: false, + replies: [] + ) + } +} From 16a40b9b9d6dc64985375b92fa83d7c015be8c61 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Fri, 23 Jan 2026 00:15:02 +0900 Subject: [PATCH 13/29] =?UTF-8?q?[#53]=20Comment=20UseCase,=20ViewModel=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=20-=20=EB=8C=80=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FetchRepliesUseCase, PostReplyUseCase 생성 - CommentViewModel에 대댓글 관련 상태 추가 - replyingToCommentId, currentReplyText - reloadComments(), reloadReplies() 메서드로 실시간 업데이트 - setReplyingTo(), cancelReply(), postReply() 메서드 구현 --- .../Domain/UseCases/FetchRepliesUseCase.swift | 28 ++++ .../Domain/UseCases/PostReplyUseCase.swift | 30 +++++ .../ViewModel/CommentViewModel.swift | 123 ++++++++++++++++-- 3 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 Codive/Features/Comment/Domain/UseCases/FetchRepliesUseCase.swift create mode 100644 Codive/Features/Comment/Domain/UseCases/PostReplyUseCase.swift diff --git a/Codive/Features/Comment/Domain/UseCases/FetchRepliesUseCase.swift b/Codive/Features/Comment/Domain/UseCases/FetchRepliesUseCase.swift new file mode 100644 index 00000000..74048fb6 --- /dev/null +++ b/Codive/Features/Comment/Domain/UseCases/FetchRepliesUseCase.swift @@ -0,0 +1,28 @@ +// +// FetchRepliesUseCase.swift +// Codive +// +// Created by 황상환 on 2025/12/22. +// + +import Foundation + +protocol FetchRepliesUseCase { + func execute(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) +} + +final class DefaultFetchRepliesUseCase: FetchRepliesUseCase { + + // MARK: - Properties + private let commentRepository: CommentRepository + + // MARK: - Initializer + init(commentRepository: CommentRepository) { + self.commentRepository = commentRepository + } + + // MARK: - FetchRepliesUseCase + func execute(commentId: Int, page: Int) async throws -> (replies: [Comment], hasNext: Bool) { + return try await commentRepository.fetchReplies(commentId: commentId, page: page) + } +} diff --git a/Codive/Features/Comment/Domain/UseCases/PostReplyUseCase.swift b/Codive/Features/Comment/Domain/UseCases/PostReplyUseCase.swift new file mode 100644 index 00000000..4a1726ae --- /dev/null +++ b/Codive/Features/Comment/Domain/UseCases/PostReplyUseCase.swift @@ -0,0 +1,30 @@ +// +// PostReplyUseCase.swift +// Codive +// +// Created by 황상환 on 2025/12/22. +// + +import Foundation + +protocol PostReplyUseCase { + @discardableResult + func execute(feedId: Int, commentId: Int, content: String) async throws -> Comment +} + +final class DefaultPostReplyUseCase: PostReplyUseCase { + + // MARK: - Properties + private let commentRepository: CommentRepository + + // MARK: - Initializer + init(commentRepository: CommentRepository) { + self.commentRepository = commentRepository + } + + // MARK: - PostReplyUseCase + @discardableResult + func execute(feedId: Int, commentId: Int, content: String) async throws -> Comment { + return try await commentRepository.postReply(feedId: feedId, commentId: commentId, content: content) + } +} diff --git a/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift b/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift index 65ae81d6..8bbc475e 100644 --- a/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift +++ b/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift @@ -10,37 +10,48 @@ import Combine @MainActor final class CommentViewModel: ObservableObject { - + // MARK: - Properties - + // 댓글 목록 @Published var comments: [Comment] = [] - + // 현재 댓글 입력 텍스트 @Published var currentCommentText: String = "" - + // 댓글 로딩 상태 @Published var isLoading: Bool = false - + // 다음 페이지 존재 여부 @Published var hasNextPage: Bool = true - + + // 대댓글 관련 상태 + @Published var replyingToCommentId: Int? + @Published var currentReplyText: String = "" + @Published var isReplyLoading: Bool = false + private var cancellables = Set() - + private let feedId: Int private let fetchCommentsUseCase: FetchCommentsUseCase private let postCommentUseCase: PostCommentUseCase + private let fetchRepliesUseCase: FetchRepliesUseCase + private let postReplyUseCase: PostReplyUseCase // MARK: - Initializer - + init( feedId: Int, fetchCommentsUseCase: FetchCommentsUseCase, - postCommentUseCase: PostCommentUseCase + postCommentUseCase: PostCommentUseCase, + fetchRepliesUseCase: FetchRepliesUseCase, + postReplyUseCase: PostReplyUseCase ) { self.feedId = feedId self.fetchCommentsUseCase = fetchCommentsUseCase self.postCommentUseCase = postCommentUseCase + self.fetchRepliesUseCase = fetchRepliesUseCase + self.postReplyUseCase = postReplyUseCase } // MARK: - Public Methods @@ -48,7 +59,7 @@ final class CommentViewModel: ObservableObject { func fetchFirstPage() { guard !isLoading, hasNextPage else { return } isLoading = true - + Task { do { let result = try await fetchCommentsUseCase.execute(feedId: feedId, page: 0) @@ -61,7 +72,22 @@ final class CommentViewModel: ObservableObject { self.isLoading = false } } - + + func reloadComments() { + isLoading = true + + Task { + do { + let result = try await fetchCommentsUseCase.execute(feedId: feedId, page: 0) + self.comments = result.comments + self.hasNextPage = result.hasNext + } catch { + print("Error reloading comments: \(error)") + } + self.isLoading = false + } + } + func fetchNextPage() { // TODO: 다음 페이지 로딩 구현 (페이지네이션) } @@ -69,16 +95,85 @@ final class CommentViewModel: ObservableObject { func postComment() { guard !currentCommentText.isEmpty else { return } let content = currentCommentText - + Task { do { - let newComment = try await postCommentUseCase.execute(feedId: feedId, content: content) - self.comments.insert(newComment, at: 0) + _ = try await postCommentUseCase.execute(feedId: feedId, content: content) self.currentCommentText = "" + // 댓글 작성 후 목록 다시 로드 (실제 사용자 정보 반영) + self.reloadComments() } catch { // TODO: 에러 처리 print("Error posting comment: \(error)") } } } + + // MARK: - Reply Methods + + func setReplyingTo(commentId: Int) { + replyingToCommentId = commentId + currentReplyText = "" + } + + func cancelReply() { + replyingToCommentId = nil + currentReplyText = "" + } + + func fetchReplies(for commentId: Int, page: Int = 0) { + guard !isReplyLoading else { return } + isReplyLoading = true + + Task { + do { + let result = try await fetchRepliesUseCase.execute(commentId: commentId, page: page) + + // commentId와 일치하는 댓글을 찾아 대댓글을 업데이트 + if let index = self.comments.firstIndex(where: { $0.id == commentId }) { + self.comments[index].replies = result.replies + } + } catch { + print("Error fetching replies: \(error)") + } + self.isReplyLoading = false + } + } + + func reloadReplies(for commentId: Int) { + isReplyLoading = true + + Task { + do { + let result = try await fetchRepliesUseCase.execute(commentId: commentId, page: 0) + + // commentId와 일치하는 댓글을 찾아 대댓글을 업데이트 + if let index = self.comments.firstIndex(where: { $0.id == commentId }) { + self.comments[index].replies = result.replies + } + } catch { + print("Error reloading replies: \(error)") + } + self.isReplyLoading = false + } + } + + func postReply() { + guard !currentReplyText.isEmpty, let commentId = replyingToCommentId else { return } + let content = currentReplyText + + Task { + do { + _ = try await postReplyUseCase.execute(feedId: feedId, commentId: commentId, content: content) + + self.currentReplyText = "" + self.replyingToCommentId = nil + + // 대댓글 작성 후 목록 다시 로드 (실제 사용자 정보 반영) + self.reloadReplies(for: commentId) + } catch { + print("Error posting reply: \(error)") + } + } + } } From 415dd9cb93ab8ddb8a7d18c21f7ce6248c7c5299 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Fri, 23 Jan 2026 00:15:09 +0900 Subject: [PATCH 14/29] =?UTF-8?q?[#53]=20Comment=20UI,=20Sheet=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=99=84=EC=84=B1=20-=20=EB=8C=80=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=9E=85=EB=A0=A5=20=EB=B0=8F=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommentRow에 viewModel 주입으로 대댓글 기능 활성화 - isMine일 때 '(작성자)' 표시 - 대댓글 입력 모드 시 댓글 미리보기 표시 - FeedDetailView에 CommentView 바텀시트 연결 - CommentDIContainer에서 DefaultCommentDataSource 사용 - FeedDIContainer에 CommentDIContainer 통합 --- Codive/DIContainer/CommentDIContainer.swift | 18 ++- Codive/DIContainer/FeedDIContainer.swift | 4 +- .../Presentation/View/CommentView.swift | 120 ++++++++++++++---- .../FeedDetail/View/FeedDetailView.swift | 26 +++- 4 files changed, 137 insertions(+), 31 deletions(-) diff --git a/Codive/DIContainer/CommentDIContainer.swift b/Codive/DIContainer/CommentDIContainer.swift index e120d433..0797f010 100644 --- a/Codive/DIContainer/CommentDIContainer.swift +++ b/Codive/DIContainer/CommentDIContainer.swift @@ -6,10 +6,11 @@ // import Foundation +import CodiveAPI @MainActor final class CommentDIContainer { - + // MARK: - Properties let navigationRouter: NavigationRouter lazy var commentViewFactory = CommentViewFactory(commentDIContainer: self) @@ -22,8 +23,7 @@ final class CommentDIContainer { // MARK: - DataSources private lazy var commentDataSource: CommentDataSource = { - // 나중에 실제 API가 구현되면 이 부분만 DefaultCommentDataSource()로 교체 - return MockCommentDataSource() + return DefaultCommentDataSource() }() // MARK: - Repositories @@ -42,13 +42,23 @@ final class CommentDIContainer { return DefaultPostCommentUseCase(commentRepository: commentRepository) } + func makeFetchRepliesUseCase() -> FetchRepliesUseCase { + return DefaultFetchRepliesUseCase(commentRepository: commentRepository) + } + + func makePostReplyUseCase() -> PostReplyUseCase { + return DefaultPostReplyUseCase(commentRepository: commentRepository) + } + // MARK: - ViewModels func makeCommentViewModel(feedId: Int) -> CommentViewModel { return CommentViewModel( feedId: feedId, fetchCommentsUseCase: makeFetchCommentsUseCase(), - postCommentUseCase: makePostCommentUseCase() + postCommentUseCase: makePostCommentUseCase(), + fetchRepliesUseCase: makeFetchRepliesUseCase(), + postReplyUseCase: makePostReplyUseCase() ) } diff --git a/Codive/DIContainer/FeedDIContainer.swift b/Codive/DIContainer/FeedDIContainer.swift index fe509fab..20a108a0 100644 --- a/Codive/DIContainer/FeedDIContainer.swift +++ b/Codive/DIContainer/FeedDIContainer.swift @@ -13,6 +13,7 @@ final class FeedDIContainer { // MARK: - Properties let navigationRouter: NavigationRouter lazy var feedViewFactory = FeedViewFactory(feedDIContainer: self) + lazy var commentDIContainer = CommentDIContainer(navigationRouter: navigationRouter) // MARK: - Initializer init(navigationRouter: NavigationRouter) { @@ -94,7 +95,8 @@ final class FeedDIContainer { func makeFeedDetailView(feedId: Int) -> FeedDetailView { return FeedDetailView( viewModel: makeFeedDetailViewModel(feedId: feedId), - navigationRouter: navigationRouter + navigationRouter: navigationRouter, + commentDIContainer: commentDIContainer ) } } diff --git a/Codive/Features/Comment/Presentation/View/CommentView.swift b/Codive/Features/Comment/Presentation/View/CommentView.swift index 2ec93b49..a9c091f7 100644 --- a/Codive/Features/Comment/Presentation/View/CommentView.swift +++ b/Codive/Features/Comment/Presentation/View/CommentView.swift @@ -40,7 +40,7 @@ struct CommentView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 24) { ForEach(viewModel.comments) { comment in - CommentRow(comment: comment) + CommentRow(comment: comment, viewModel: viewModel) } if viewModel.isLoading { ProgressView() @@ -59,15 +59,57 @@ struct CommentView: View { // MARK: - Comment Input Area VStack(spacing: 0) { Divider().overlay(Color.Codive.grayscale6) - HStack(alignment: .center, spacing: 12) { - TextField(TextLiteral.Comment.placeholder, text: $viewModel.currentCommentText) - .padding(.horizontal, 15) - .frame(height: 40) - .background(Color.Codive.main6) + + // 대댓글 입력 중이면 표시 + if let replyingCommentId = viewModel.replyingToCommentId, + let replyingComment = viewModel.comments.first(where: { $0.id == replyingCommentId }) { + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("@\(replyingComment.author.nickname)") + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.main0) + Text(replyingComment.content) + .font(.codive_body3_regular) + .foregroundStyle(Color.Codive.grayscale2) + .lineLimit(1) + } + Spacer() + Button(action: { + viewModel.cancelReply() + }, label: { + Image(systemName: "xmark") + .font(.system(size: 12)) + .foregroundStyle(Color.Codive.grayscale4) + }) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color.Codive.grayscale6) .clipShape(RoundedRectangle(cornerRadius: 8)) - .font(.codive_body2_regular) + .padding(.horizontal, 20) + .padding(.vertical, 8) + } + } + + // 댓글/대댓글 입력 필드 + HStack(alignment: .center, spacing: 12) { + TextField( + viewModel.replyingToCommentId != nil ? "대댓글 입력..." : TextLiteral.Comment.placeholder, + text: viewModel.replyingToCommentId != nil ? $viewModel.currentReplyText : $viewModel.currentCommentText + ) + .padding(.horizontal, 15) + .frame(height: 40) + .background(Color.Codive.main6) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .font(.codive_body2_regular) + Button(action: { - viewModel.postComment() + if viewModel.replyingToCommentId != nil { + viewModel.postReply() + } else { + viewModel.postComment() + } }, label: { Image("comment_enter") .foregroundStyle(.white) @@ -75,7 +117,11 @@ struct CommentView: View { .background(Color.Codive.main0) .clipShape(Circle()) }) - .disabled(viewModel.currentCommentText.isEmpty) + .disabled( + viewModel.replyingToCommentId != nil + ? viewModel.currentReplyText.isEmpty + : viewModel.currentCommentText.isEmpty + ) } .padding(.horizontal, 20) .padding(.bottom, 40) @@ -99,7 +145,8 @@ struct CommentView: View { struct CommentRow: View { let comment: Comment var isReply: Bool = false - + @ObservedObject var viewModel: CommentViewModel + @State private var isExpanded: Bool = false var body: some View { @@ -117,9 +164,17 @@ struct CommentRow: View { VStack(alignment: .leading, spacing: 4) { // 닉네임 - Text(comment.author.nickname) - .font(.codive_body2_medium) - .foregroundStyle(Color.Codive.grayscale1) + HStack(spacing: 4) { + Text(comment.author.nickname) + .font(.codive_body2_medium) + .foregroundStyle(Color.Codive.grayscale1) + + if comment.isMine { + Text("(작성자)") + .font(.codive_body3_regular) + .foregroundStyle(Color.Codive.main0) + } + } // 댓글내용 Text(comment.content) @@ -128,7 +183,9 @@ struct CommentRow: View { .fixedSize(horizontal: false, vertical: true) .lineSpacing(4) - Button(action: {}, label: { + Button(action: { + viewModel.setReplyingTo(commentId: comment.id) + }, label: { Text(TextLiteral.Comment.addReply) .font(.codive_body3_regular) .foregroundStyle(Color.Codive.grayscale4) @@ -136,13 +193,24 @@ struct CommentRow: View { .padding(.top, 4) // MARK: 답글 더보기/숨기기 버튼 - if comment.hasReplies, let replies = comment.replies, !replies.isEmpty { + if comment.hasReplies { Button(action: { - withAnimation(.easeOut(duration: 0.2)) { isExpanded.toggle() } + withAnimation(.easeOut(duration: 0.2)) { + isExpanded.toggle() + if isExpanded && (comment.replies?.isEmpty ?? true) { + viewModel.fetchReplies(for: comment.id) + } + } }, label: { - Text(isExpanded ? TextLiteral.Comment.hideReplies : TextLiteral.Comment.repliesCount(replies.count)) - .font(.codive_body2_regular) - .foregroundStyle(Color.Codive.grayscale4) + if let replies = comment.replies, !replies.isEmpty { + Text(isExpanded ? TextLiteral.Comment.hideReplies : TextLiteral.Comment.repliesCount(replies.count)) + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale4) + } else { + Text(TextLiteral.Comment.repliesCount(1)) + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale4) + } }) .padding(.top, 8) } @@ -160,7 +228,7 @@ struct CommentRow: View { if isExpanded, let replies = comment.replies { VStack(alignment: .leading, spacing: 20) { ForEach(replies) { reply in - CommentRow(comment: reply, isReply: true) + CommentRow(comment: reply, isReply: true, viewModel: viewModel) } } .padding(.top, 10) @@ -171,18 +239,22 @@ struct CommentRow: View { // MARK: - Preview struct CommentView_Previews: PreviewProvider { - + static var previews: some View { let mockRepository = MockCommentRepository() let fetchUseCase = DefaultFetchCommentsUseCase(commentRepository: mockRepository) let postUseCase = DefaultPostCommentUseCase(commentRepository: mockRepository) - + let fetchRepliesUseCase = DefaultFetchRepliesUseCase(commentRepository: mockRepository) + let postReplyUseCase = DefaultPostReplyUseCase(commentRepository: mockRepository) + let viewModel = CommentViewModel( feedId: 1, fetchCommentsUseCase: fetchUseCase, - postCommentUseCase: postUseCase + postCommentUseCase: postUseCase, + fetchRepliesUseCase: fetchRepliesUseCase, + postReplyUseCase: postReplyUseCase ) - + return CommentView(viewModel: viewModel) .previewDisplayName("댓글과 답글") } diff --git a/Codive/Features/Feed/Presentation/FeedDetail/View/FeedDetailView.swift b/Codive/Features/Feed/Presentation/FeedDetail/View/FeedDetailView.swift index 98cabd90..1d6013d9 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/View/FeedDetailView.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/View/FeedDetailView.swift @@ -12,6 +12,7 @@ struct FeedDetailView: View { // MARK: - Properties @StateObject var viewModel: FeedDetailViewModel @ObservedObject var navigationRouter: NavigationRouter + let commentDIContainer: CommentDIContainer // UI State @State private var currentImageIndex: Int = 0 @@ -21,10 +22,12 @@ struct FeedDetailView: View { // MARK: - Initializer init( viewModel: FeedDetailViewModel, - navigationRouter: NavigationRouter + navigationRouter: NavigationRouter, + commentDIContainer: CommentDIContainer ) { _viewModel = StateObject(wrappedValue: viewModel) _navigationRouter = ObservedObject(wrappedValue: navigationRouter) + self.commentDIContainer = commentDIContainer } // MARK: - Body @@ -133,6 +136,24 @@ struct FeedDetailView: View { FeedLikesListView(viewModel: viewModel) .presentationDetents([.medium, .large]) }) + .sheet(isPresented: Binding( + get: { navigationRouter.sheetDestination != nil && isCommentSheet(navigationRouter.sheetDestination) }, + set: { if !$0 { navigationRouter.dismissSheet() } } + ), content: { + if case .comment(let feedId) = navigationRouter.sheetDestination { + commentDIContainer.commentViewFactory.makeView(for: .comment(feedId: feedId)) + } + }) + } + + // MARK: - Helper Methods + + private func isCommentSheet(_ destination: AppDestination?) -> Bool { + guard let destination = destination else { return false } + if case .comment = destination { + return true + } + return false } } @@ -149,6 +170,7 @@ struct FeedDetailView: View { FeedDetailView( viewModel: viewModel, - navigationRouter: navigationRouter + navigationRouter: navigationRouter, + commentDIContainer: CommentDIContainer(navigationRouter: navigationRouter) ) } From ed624dfcfa2c642019fea72f4d56db2a44dacc04 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Fri, 23 Jan 2026 20:32:25 +0900 Subject: [PATCH 15/29] =?UTF-8?q?[#53]=20Feed=20=ED=95=84=ED=84=B0=20API?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=20-=20styleIds=EC=99=80=20situationIds=20?= =?UTF-8?q?ID=20=EB=B3=80=ED=99=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TextLiteral의 situationWork 문자열을 Constants와 일치시킴 ("출근복" -> "출근룩") - SituationConstants에 getIds() 메서드 추가로 StyleConstants와 동일한 인터페이스 제공 - FeedView에서 상단 카테고리 선택 시 StyleConstants를 사용하여 스타일 ID로 변환 - FeedFilterBottomSheet의 필터 적용 시 StyleConstants/SituationConstants를 사용하여 ID 배열로 변환 - 선택된 필터가 없을 때 nil 처리로 전체 피드 조회 가능 --- Codive/Core/Resources/TextLiteral.swift | 2 +- .../Feed/Data/Constants/SituationConstants.swift | 10 ++++++++++ .../Presentation/MainFeed/View/FeedView.swift | 16 +++++++++++++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Codive/Core/Resources/TextLiteral.swift b/Codive/Core/Resources/TextLiteral.swift index dddecf84..b645d46f 100644 --- a/Codive/Core/Resources/TextLiteral.swift +++ b/Codive/Core/Resources/TextLiteral.swift @@ -111,7 +111,7 @@ enum TextLiteral { static let situationTravel = "여행" static let situationExercise = "운동" static let situationFestival = "축제" - static let situationWork = "출근복" + static let situationWork = "출근룩" static let situationParty = "파티" // Photo Tag diff --git a/Codive/Features/Feed/Data/Constants/SituationConstants.swift b/Codive/Features/Feed/Data/Constants/SituationConstants.swift index 8ba73c2a..962c199e 100644 --- a/Codive/Features/Feed/Data/Constants/SituationConstants.swift +++ b/Codive/Features/Feed/Data/Constants/SituationConstants.swift @@ -49,4 +49,14 @@ enum SituationConstants { static func getFirstId(from names: Set) -> Int64? { names.compactMap { find(byName: $0)?.id }.first } + + /// 이름 배열로 ID 배열 변환 + static func getIds(from names: [String]) -> [Int64] { + names.compactMap { find(byName: $0)?.id } + } + + /// 이름 Set으로 ID 배열 변환 + static func getIds(from names: Set) -> [Int64] { + getIds(from: Array(names)) + } } diff --git a/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift b/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift index cdd8194b..6f1bd561 100644 --- a/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift +++ b/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Foundation struct FeedView: View { @@ -56,7 +57,15 @@ struct FeedView: View { Task { await viewModel.applyFilters() } } .onChange(of: selectedCategory) { _ in - viewModel.selectedStyleIds = nil + if selectedCategory.isEmpty { + viewModel.selectedStyleIds = nil + } else { + if let styleItem = StyleConstants.find(byName: selectedCategory) { + viewModel.selectedStyleIds = [Int(styleItem.styleId)] + } else { + viewModel.selectedStyleIds = nil + } + } viewModel.selectedSituationIds = nil Task { await viewModel.applyFilters() } } @@ -69,6 +78,11 @@ struct FeedView: View { selectedSheetSituations.removeAll() } onApply: { isShowingFilterSheet = false + selectedCategory = "" // Clear top category when applying sheet filters + let styleIds = StyleConstants.getIds(from: selectedSheetStyles).map { Int($0) } + let situationIds = SituationConstants.getIds(from: selectedSheetSituations).map { Int($0) } + viewModel.selectedStyleIds = styleIds.isEmpty ? nil : styleIds + viewModel.selectedSituationIds = situationIds.isEmpty ? nil : situationIds Task { await viewModel.applyFilters() } } .presentationDetents([.height(500)]) From 6f173b574d12c07a388d4f45bb87ca3094d56d5d Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Fri, 23 Jan 2026 20:41:27 +0900 Subject: [PATCH 16/29] =?UTF-8?q?[#53]=20Feed=20=ED=95=84=ED=84=B0=20-=20?= =?UTF-8?q?=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=EC=9D=B4=20=EC=83=81=EB=8B=A8=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EC=97=90=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - selectedCategory를 String에서 Set으로 변경하여 여러 개 선택 지원 - FeedFilterBar에서 다중 선택 가능하도록 로직 수정 - 바텀시트에서 선택한 스타일이 상단 카테고리에도 자동으로 표시됨 - 필터 초기화 시 selectedCategory.removeAll() 사용 --- .../Presentation/MainFeed/View/FeedView.swift | 18 ++++++----------- .../DesignSystem/Buttons/FeedFilterBar.swift | 20 +++++++++---------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift b/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift index 6f1bd561..2f28d965 100644 --- a/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift +++ b/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift @@ -16,7 +16,7 @@ struct FeedView: View { // MARK: Filter States // Top Bar States @State private var isFollowingSelected: Bool = false - @State private var selectedCategory: String = "" + @State private var selectedCategory: Set = [] // Bottom Sheet States @State private var isShowingFilterSheet: Bool = false @@ -57,15 +57,8 @@ struct FeedView: View { Task { await viewModel.applyFilters() } } .onChange(of: selectedCategory) { _ in - if selectedCategory.isEmpty { - viewModel.selectedStyleIds = nil - } else { - if let styleItem = StyleConstants.find(byName: selectedCategory) { - viewModel.selectedStyleIds = [Int(styleItem.styleId)] - } else { - viewModel.selectedStyleIds = nil - } - } + let styleIds = StyleConstants.getIds(from: selectedCategory).map { Int($0) } + viewModel.selectedStyleIds = styleIds.isEmpty ? nil : styleIds viewModel.selectedSituationIds = nil Task { await viewModel.applyFilters() } } @@ -78,7 +71,8 @@ struct FeedView: View { selectedSheetSituations.removeAll() } onApply: { isShowingFilterSheet = false - selectedCategory = "" // Clear top category when applying sheet filters + // Update top category with selected styles from sheet + selectedCategory = selectedSheetStyles let styleIds = StyleConstants.getIds(from: selectedSheetStyles).map { Int($0) } let situationIds = SituationConstants.getIds(from: selectedSheetSituations).map { Int($0) } viewModel.selectedStyleIds = styleIds.isEmpty ? nil : styleIds @@ -111,7 +105,7 @@ struct FeedView: View { } else { FeedEmptyView(type: .noFeeds) { viewModel.clearFiltersAndReload() - selectedCategory = "" + selectedCategory.removeAll() } } } else if let errorMessage = viewModel.errorMessage { diff --git a/Codive/Shared/DesignSystem/Buttons/FeedFilterBar.swift b/Codive/Shared/DesignSystem/Buttons/FeedFilterBar.swift index c4081d0c..fd4ff177 100644 --- a/Codive/Shared/DesignSystem/Buttons/FeedFilterBar.swift +++ b/Codive/Shared/DesignSystem/Buttons/FeedFilterBar.swift @@ -8,11 +8,11 @@ import SwiftUI struct FeedFilterBar: View { - + // MARK: - Properties @Binding var isFollowingSelected: Bool let categories: [String] - @Binding var selectedCategory: String + @Binding var selectedCategory: Set var onFilterTap: () -> Void // MARK: - Body @@ -25,16 +25,16 @@ struct FeedFilterBar: View { // 스타일 카테고리 버튼 ForEach(categories, id: \.self) { category in - let isSelected = selectedCategory == category - + let isSelected = selectedCategory.contains(category) + FilterSelectionButton( title: category, isSelected: isSelected ) { - if selectedCategory == category { - selectedCategory = "" // Deselect + if selectedCategory.contains(category) { + selectedCategory.remove(category) } else { - selectedCategory = category + selectedCategory.insert(category) } } } @@ -134,13 +134,13 @@ private struct FilterSelectionButton: View { FeedFilterBar( isFollowingSelected: .constant(true), categories: ["미니멀", "캐주얼", "스트릿", "빈티지"], - selectedCategory: .constant("") + selectedCategory: .constant([]) ) {} - + FeedFilterBar( isFollowingSelected: .constant(false), categories: ["미니멀", "캐주얼", "스트릿", "빈티지"], - selectedCategory: .constant("미니멀") + selectedCategory: .constant(["미니멀", "캐주얼"]) ) {} } } From 5653ff2a6b8c4ac0d2f77a30c3afff293fe4c9eb Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Fri, 23 Jan 2026 20:45:50 +0900 Subject: [PATCH 17/29] =?UTF-8?q?[#53]=20Feed=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20-=20=EC=84=A0=ED=83=9D=EB=90=9C=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=A5=BC=20=EC=95=9E?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FeedView와 FeedFilterBottomSheet의 스타일 순서 통일 - sortedCategories computed property 추가로 선택된 항목을 최우선으로 앞에 배치 - 비선택 항목들은 원래 순서대로 뒤에 배치 - FeedFilterBar에 정렬된 배열 전달 --- .../Presentation/MainFeed/View/FeedView.swift | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift b/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift index 2f28d965..f5a049a9 100644 --- a/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift +++ b/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift @@ -33,18 +33,31 @@ struct FeedView: View { ] private let styleCategories = [ - TextLiteral.Add.styleCasual, TextLiteral.Add.styleLoving, TextLiteral.Add.styleMinimal, - TextLiteral.Add.styleVintage, TextLiteral.Add.styleSporty, TextLiteral.Add.styleStreet, - TextLiteral.Add.styleChic, TextLiteral.Add.styleOffice, TextLiteral.Add.styleClassic, + TextLiteral.Add.styleLoving, TextLiteral.Add.styleMinimal, TextLiteral.Add.styleVintage, + TextLiteral.Add.styleSporty, TextLiteral.Add.styleStreet, TextLiteral.Add.styleChic, + TextLiteral.Add.styleOffice, TextLiteral.Add.styleCasual, TextLiteral.Add.styleClassic, TextLiteral.Add.styleHighteen ] - + + // 선택된 카테고리를 앞으로, 나머지를 뒤로 정렬 + private var sortedCategories: [String] { + let selected = Array(selectedCategory).sorted { a, b in + guard let indexA = styleCategories.firstIndex(of: a), + let indexB = styleCategories.firstIndex(of: b) else { + return false + } + return indexA < indexB + } + let notSelected = styleCategories.filter { !selectedCategory.contains($0) } + return selected + notSelected + } + // MARK: - Body var body: some View { VStack { FeedFilterBar( isFollowingSelected: $isFollowingSelected, - categories: styleCategories, + categories: sortedCategories, selectedCategory: $selectedCategory ) { isShowingFilterSheet = true From a0d96f890184e0c4aaab81bb1078186d4b81b6ed Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:28:01 +0900 Subject: [PATCH 18/29] =?UTF-8?q?[#53]=20CodiveAPI=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tuist/Package.resolved | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 0a3cb76b..39b9887a 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -16,7 +16,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "2ec7b429a93da400a78729f64abc13889e9eb695" + "revision" : "d606e28bc8ed5d09556e8330e757c3a45d45f3c9" } }, { From 4651dcfc5f9f924de7ae0fadb0b3dcff76b20c18 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Fri, 23 Jan 2026 22:14:36 +0900 Subject: [PATCH 19/29] =?UTF-8?q?[#53]=20=EA=B2=80=EC=83=89=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95/=ED=95=B4=EC=8B=9C=ED=83=9C=EA=B7=B8=20API=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/DIContainer/SearchDIContainer.swift | 6 +- .../Data/DataSources/SearchDataSource.swift | 151 ++++-------------- .../Repositories/SearchRepositoryImpl.swift | 10 +- .../Search/Data/SearchAPIService.swift | 149 +++++++++++++++++ .../Domain/Protocols/SearchRepository.swift | 4 +- .../Domain/UseCases/SearchUseCase.swift | 10 +- .../Presentation/View/SearchResultView.swift | 1 + .../ViewModel/SearchResultViewModel.swift | 47 +++--- 8 files changed, 227 insertions(+), 151 deletions(-) create mode 100644 Codive/Features/Search/Data/SearchAPIService.swift diff --git a/Codive/DIContainer/SearchDIContainer.swift b/Codive/DIContainer/SearchDIContainer.swift index 70fb9427..6b22a155 100644 --- a/Codive/DIContainer/SearchDIContainer.swift +++ b/Codive/DIContainer/SearchDIContainer.swift @@ -13,8 +13,10 @@ final class SearchDIContainer { let navigationRouter: NavigationRouter lazy var searchViewFactory = SearchViewFactory(searchDIContainer: self) - lazy var searchDataSource = SearchDataSource() - + lazy var searchAPIService: SearchAPIServiceProtocol = SearchAPIService() + + lazy var searchDataSource = SearchDataSource(apiService: searchAPIService) + lazy var searchRepository: SearchRepository = SearchRepositoryImpl(datasource: searchDataSource) lazy var searchUseCase = SearchUseCase(repository: searchRepository) diff --git a/Codive/Features/Search/Data/DataSources/SearchDataSource.swift b/Codive/Features/Search/Data/DataSources/SearchDataSource.swift index b1f3c60e..e21f7899 100644 --- a/Codive/Features/Search/Data/DataSources/SearchDataSource.swift +++ b/Codive/Features/Search/Data/DataSources/SearchDataSource.swift @@ -8,13 +8,23 @@ import Foundation final class SearchDataSource { - + + // MARK: - Properties + + private let apiService: SearchAPIServiceProtocol + + // MARK: - Initializer + + init(apiService: SearchAPIServiceProtocol = SearchAPIService()) { + self.apiService = apiService + } + // MARK: - Fetch Methods (기존) - + func fetchUserName() -> SearchEntity { return SearchEntity(username: "코디브") } - + func fetchRecentSearchTags() -> [SearchTagEntity] { return [ SearchTagEntity(id: 1, text: "겨울"), @@ -23,7 +33,7 @@ final class SearchDataSource { SearchTagEntity(id: 4, text: "패션이 좋아요") ] } - + func fetchRecommendedNews() -> [NewsEntity] { return [ NewsEntity( @@ -38,123 +48,26 @@ final class SearchDataSource { ) ] } - - func fetchPosts(query: String) -> [PostEntity] { - let allPosts = getAllPosts() - - if query.isEmpty || query == "전체" { - return allPosts - } - - let lowercasedQuery = query.lowercased() - return allPosts.filter { post in - let matchNickname = post.nickname.lowercased().contains(lowercasedQuery) - let matchDescription = post.description?.lowercased().contains(lowercasedQuery) ?? false - return matchNickname || matchDescription - } + func fetchPosts(query: String) async throws -> [PostEntity] { + let result = try await apiService.searchHistories( + keyword: query, + page: 0, + size: 10, + sort: "LATEST" + ) + return result.posts } - + // MARK: - Fetch Methods (계정용 추가) - - /// 검색어를 기준으로 유저 목록을 필터링해서 반환 - func fetchUsers(query: String) -> [SimpleUser] { - let allUsers = getAllUsers() - - // 전체 or 빈 문자열이면 전부 리턴 - if query.isEmpty || query == "전체" { - return allUsers - } - - let lowercasedQuery = query.lowercased() - - return allUsers.filter { user in - user.nickname.lowercased().contains(lowercasedQuery) - || user.handle.lowercased().contains(lowercasedQuery) - } - } - - // MARK: - Private Methods (공통) - - private func createDate(year: Int, month: Int, day: Int) -> Date { - var components = DateComponents() - components.year = year - components.month = month - components.day = day - return Calendar.current.date(from: components) ?? Date() - } - - // MARK: - Private Methods (게시글 더미) - - private func getAllPosts() -> [PostEntity] { - return [ - PostEntity( - id: 1, - postImageUrl: "https://picsum.photos/id/1018/162/216", - profileImageUrl: "https://picsum.photos/id/237/28/28", - nickname: "유저A", - likes: 150, - date: createDate(year: 2025, month: 11, day: 19), - description: "가을 자켓 코디" - ), - PostEntity( - id: 2, - postImageUrl: "https://picsum.photos/id/1019/162/216", - profileImageUrl: nil, - nickname: "유저B", - likes: 80, - date: createDate(year: 2025, month: 11, day: 17), - description: "겨울 드뮤어룩" - ), - PostEntity( - id: 3, - postImageUrl: "https://picsum.photos/id/1020/162/216", - profileImageUrl: "https://picsum.photos/id/100/28/28", - nickname: "유저C", - likes: 250, - date: createDate(year: 2025, month: 11, day: 18), - description: "데일리룩 추천" - ), - PostEntity( - id: 4, - postImageUrl: "https://picsum.photos/id/1021/162/216", - profileImageUrl: nil, - nickname: "유저D", - likes: 50, - date: createDate(year: 2025, month: 11, day: 19), - description: "드뮤어룩 첼시부츠" - ) - ] - } - - // MARK: - Private Methods (유저 더미) - - private func getAllUsers() -> [SimpleUser] { - return [ - SimpleUser( - userId: 1, - nickname: "코디브 공식", - handle: "@codive_official", - avatarURL: URL(string: "https://picsum.photos/id/200/80/80") - ), - SimpleUser( - userId: 2, - nickname: "한금준", - handle: "@geumjoon", - avatarURL: URL(string: "https://picsum.photos/id/201/80/80") - ), - SimpleUser( - userId: 3, - nickname: "드뮤어룩 장인", - handle: "@demure_master", - avatarURL: URL(string: "https://picsum.photos/id/202/80/80") - ), - SimpleUser( - userId: 4, - nickname: "한강러버", - handle: "@hanriver_lover", - avatarURL: nil - ) - ] + + /// 검색어를 기준으로 유저 목록을 API로 검색 + func fetchUsers(query: String) async throws -> [SimpleUser] { + let result = try await apiService.searchUsers( + keyword: query, + page: 0, + size: 10 + ) + return result.users } } diff --git a/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift b/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift index 4e324d4e..8811f966 100644 --- a/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift +++ b/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift @@ -28,11 +28,11 @@ final class SearchRepositoryImpl: SearchRepository { return datasource.fetchRecommendedNews() } - func fetchPosts(query: String) -> [PostEntity] { - return datasource.fetchPosts(query: query) + func fetchPosts(query: String) async throws -> [PostEntity] { + return try await datasource.fetchPosts(query: query) } - - func fetchUsers(query: String) -> [SimpleUser] { - return datasource.fetchUsers(query: query) + + func fetchUsers(query: String) async throws -> [SimpleUser] { + return try await datasource.fetchUsers(query: query) } } diff --git a/Codive/Features/Search/Data/SearchAPIService.swift b/Codive/Features/Search/Data/SearchAPIService.swift new file mode 100644 index 00000000..c3c2fffc --- /dev/null +++ b/Codive/Features/Search/Data/SearchAPIService.swift @@ -0,0 +1,149 @@ +// +// SearchAPIService.swift +// Codive +// +// Created by Claude on 1/23/26. +// + +import Foundation +import CodiveAPI +import OpenAPIRuntime + +// MARK: - Search API Service Protocol + +protocol SearchAPIServiceProtocol { + func searchUsers(keyword: String, page: Int64, size: Int32) async throws -> SearchUserResult + func searchHistories(keyword: String, page: Int64, size: Int32, sort: String?) async throws -> SearchHistoryResult +} + +// MARK: - Supporting Types + +struct SearchUserResult { + let users: [SimpleUser] + let isLast: Bool +} + +struct SearchHistoryResult { + let posts: [PostEntity] + let isLast: Bool +} + +// MARK: - Search API Service Implementation + +final class SearchAPIService: SearchAPIServiceProtocol { + + private let client: Client + private let jsonDecoder: JSONDecoder + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + self.client = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] + ) + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + } + + // MARK: - Search Users + + func searchUsers(keyword: String, page: Int64, size: Int32) async throws -> SearchUserResult { + let input = Operations.Search_searchUserByClokeyIdAndNickname.Input( + query: .init( + keyword: keyword, + page: page, + size: size + ) + ) + + let response = try await client.Search_searchUserByClokeyIdAndNickname(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseSliceResponseSearchedMemberResponse.self, + from: data + ) + + let members = decoded.result?.content ?? [] + let users: [SimpleUser] = members.compactMap { member -> SimpleUser? in + guard let userId = member.memberId else { return nil } + return SimpleUser( + userId: Int(userId), + nickname: member.nickname ?? "", + handle: member.clokeyId ?? "", + avatarURL: member.profileImageUrl.flatMap { URL(string: $0) } + ) + } + + return SearchUserResult( + users: users, + isLast: decoded.result?.isLast ?? true + ) + + case .undocumented(statusCode: let code, _): + throw SearchAPIError.serverError(statusCode: code, message: "유저 검색 실패") + } + } + + // MARK: - Search Histories + + func searchHistories(keyword: String, page: Int64, size: Int32, sort: String?) async throws -> SearchHistoryResult { + let sortPayload = sort.flatMap { sortStr in + Operations.Search_searchHistoryByHashtagsAndCategories.Input.Query.sortPayload(rawValue: sortStr) + } + + let input = Operations.Search_searchHistoryByHashtagsAndCategories.Input( + query: .init( + keyword: keyword, + page: page, + size: size, + sort: sortPayload + ) + ) + + let response = try await client.Search_searchHistoryByHashtagsAndCategories(input) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + let decoded = try jsonDecoder.decode( + Components.Schemas.BaseResponseSliceResponseSearchedHistoryResponse.self, + from: data + ) + + let histories = decoded.result?.content ?? [] + let posts: [PostEntity] = histories.compactMap { history -> PostEntity? in + guard let historyId = history.historyId else { return nil } + return PostEntity( + id: Int(historyId), + postImageUrl: history.historyImageUrl, + profileImageUrl: history.profileImageUrl, + nickname: history.nickname ?? "", + likes: 0, + date: Date(), + description: nil + ) + } + + return SearchHistoryResult( + posts: posts, + isLast: decoded.result?.isLast ?? true + ) + + case .undocumented(statusCode: let code, _): + throw SearchAPIError.serverError(statusCode: code, message: "해시태그 검색 실패") + } + } +} + +// MARK: - Search API Error + +enum SearchAPIError: LocalizedError { + case serverError(statusCode: Int, message: String) + + var errorDescription: String? { + switch self { + case .serverError(let statusCode, let message): + return "서버 오류 (\(statusCode)): \(message)" + } + } +} diff --git a/Codive/Features/Search/Domain/Protocols/SearchRepository.swift b/Codive/Features/Search/Domain/Protocols/SearchRepository.swift index 7b8fc9dd..1b76f8a9 100644 --- a/Codive/Features/Search/Domain/Protocols/SearchRepository.swift +++ b/Codive/Features/Search/Domain/Protocols/SearchRepository.swift @@ -9,6 +9,6 @@ protocol SearchRepository { func fetchUserName() -> SearchEntity func fetchRecentSearchTags() -> [SearchTagEntity] func fetchRecommendedNews() -> [NewsEntity] - func fetchPosts(query: String) -> [PostEntity] - func fetchUsers(query: String) -> [SimpleUser] + func fetchPosts(query: String) async throws -> [PostEntity] + func fetchUsers(query: String) async throws -> [SimpleUser] } diff --git a/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift b/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift index 0290d4ef..96e5481c 100644 --- a/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift +++ b/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift @@ -28,11 +28,11 @@ final class SearchUseCase { return repository.fetchRecommendedNews() } - func fetchPosts(query: String) -> [PostEntity] { - return repository.fetchPosts(query: query) + func fetchPosts(query: String) async throws -> [PostEntity] { + return try await repository.fetchPosts(query: query) } - - func fetchUsers(query: String) -> [SimpleUser] { - return repository.fetchUsers(query: query) + + func fetchUsers(query: String) async throws -> [SimpleUser] { + return try await repository.fetchUsers(query: query) } } diff --git a/Codive/Features/Search/Presentation/View/SearchResultView.swift b/Codive/Features/Search/Presentation/View/SearchResultView.swift index ed404b59..91282c07 100644 --- a/Codive/Features/Search/Presentation/View/SearchResultView.swift +++ b/Codive/Features/Search/Presentation/View/SearchResultView.swift @@ -55,6 +55,7 @@ struct SearchResultView: View { ) { // 버튼 동작 } + .padding(.vertical, 6) } } .padding(.top, 18) diff --git a/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift b/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift index 0c574931..563a8a44 100644 --- a/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift +++ b/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift @@ -68,36 +68,47 @@ final class SearchResultViewModel: ObservableObject { // MARK: - Public Methods func loadInitialData() { - loadPosts() - loadUsers() + Task { + await loadPosts() + await loadUsers() + } } - - func loadPosts() { - self.allPosts = useCase.fetchPosts(query: self.initialQuery) - self.posts = self.allPosts - self.applySorting(newSort: self.currentSort) + + func loadPosts() async { + do { + self.allPosts = try await useCase.fetchPosts(query: self.initialQuery) + self.posts = self.allPosts + self.applySorting(newSort: self.currentSort) + } catch { + print("게시물 로딩 실패: \(error.localizedDescription)") + } } - - func loadUsers() { - self.allUsers = useCase.fetchUsers(query: self.initialQuery) - self.users = self.allUsers - print("유저 로딩 완료: \(self.users.count)명") + + func loadUsers() async { + do { + self.allUsers = try await useCase.fetchUsers(query: self.initialQuery) + self.users = self.allUsers + print("유저 로딩 완료: \(self.users.count)명") + } catch { + print("유저 로딩 실패: \(error.localizedDescription)") + } } - + func executeNewSearch(query: String) { let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedQuery.isEmpty { print("검색어를 입력해 주세요.") return } - + self.initialQuery = trimmedQuery self.currentSort = "전체" - loadPosts() - loadUsers() - - print("현재 페이지에서 검색 결과 갱신: \(trimmedQuery)") + Task { + await loadPosts() + await loadUsers() + print("현재 페이지에서 검색 결과 갱신: \(trimmedQuery)") + } } // MARK: - Navigation From 17bc11c43c63a36c1724e30e7419134b0aed0428 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Fri, 23 Jan 2026 22:37:03 +0900 Subject: [PATCH 20/29] =?UTF-8?q?[#53]=20=ED=95=B4=EC=8B=9C=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EA=B8=B0=EB=A1=9D=20=ED=95=84=ED=84=B0=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/DataSources/SearchDataSource.swift | 5 ++-- .../Repositories/SearchRepositoryImpl.swift | 4 ++-- .../Domain/Protocols/SearchRepository.swift | 2 +- .../Domain/UseCases/SearchUseCase.swift | 4 ++-- .../ViewModel/SearchResultViewModel.swift | 23 ++++--------------- 5 files changed, 13 insertions(+), 25 deletions(-) diff --git a/Codive/Features/Search/Data/DataSources/SearchDataSource.swift b/Codive/Features/Search/Data/DataSources/SearchDataSource.swift index e21f7899..23bc8104 100644 --- a/Codive/Features/Search/Data/DataSources/SearchDataSource.swift +++ b/Codive/Features/Search/Data/DataSources/SearchDataSource.swift @@ -49,12 +49,13 @@ final class SearchDataSource { ] } - func fetchPosts(query: String) async throws -> [PostEntity] { + func fetchPosts(query: String, sort: String?) async throws -> [PostEntity] { + let sortParam = sort == "인기순" ? "POPULAR" : (sort == "최신순" ? "LATEST" : nil) let result = try await apiService.searchHistories( keyword: query, page: 0, size: 10, - sort: "LATEST" + sort: sortParam ) return result.posts } diff --git a/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift b/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift index 8811f966..0a6eda27 100644 --- a/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift +++ b/Codive/Features/Search/Data/Repositories/SearchRepositoryImpl.swift @@ -28,8 +28,8 @@ final class SearchRepositoryImpl: SearchRepository { return datasource.fetchRecommendedNews() } - func fetchPosts(query: String) async throws -> [PostEntity] { - return try await datasource.fetchPosts(query: query) + func fetchPosts(query: String, sort: String?) async throws -> [PostEntity] { + return try await datasource.fetchPosts(query: query, sort: sort) } func fetchUsers(query: String) async throws -> [SimpleUser] { diff --git a/Codive/Features/Search/Domain/Protocols/SearchRepository.swift b/Codive/Features/Search/Domain/Protocols/SearchRepository.swift index 1b76f8a9..622fafd4 100644 --- a/Codive/Features/Search/Domain/Protocols/SearchRepository.swift +++ b/Codive/Features/Search/Domain/Protocols/SearchRepository.swift @@ -9,6 +9,6 @@ protocol SearchRepository { func fetchUserName() -> SearchEntity func fetchRecentSearchTags() -> [SearchTagEntity] func fetchRecommendedNews() -> [NewsEntity] - func fetchPosts(query: String) async throws -> [PostEntity] + func fetchPosts(query: String, sort: String?) async throws -> [PostEntity] func fetchUsers(query: String) async throws -> [SimpleUser] } diff --git a/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift b/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift index 96e5481c..69ba28a8 100644 --- a/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift +++ b/Codive/Features/Search/Domain/UseCases/SearchUseCase.swift @@ -28,8 +28,8 @@ final class SearchUseCase { return repository.fetchRecommendedNews() } - func fetchPosts(query: String) async throws -> [PostEntity] { - return try await repository.fetchPosts(query: query) + func fetchPosts(query: String, sort: String?) async throws -> [PostEntity] { + return try await repository.fetchPosts(query: query, sort: sort) } func fetchUsers(query: String) async throws -> [SimpleUser] { diff --git a/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift b/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift index 563a8a44..9254c4b4 100644 --- a/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift +++ b/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift @@ -46,24 +46,12 @@ final class SearchResultViewModel: ObservableObject { $currentSort .removeDuplicates() .sink { [weak self] newSort in - self?.applySorting(newSort: newSort) + Task { + await self?.loadPosts() + } } .store(in: &cancellables) } - - private func applySorting(newSort: String) { - switch newSort { - case "인기순": - self.posts = self.allPosts.sorted { $0.likes > $1.likes } - case "최신순": - self.posts = self.allPosts.sorted { $0.date > $1.date } - case "전체": - self.posts = self.allPosts - default: - break - } - print("정렬 적용 완료: \(newSort), 결과 \(self.posts.count)개") - } // MARK: - Public Methods @@ -76,9 +64,8 @@ final class SearchResultViewModel: ObservableObject { func loadPosts() async { do { - self.allPosts = try await useCase.fetchPosts(query: self.initialQuery) - self.posts = self.allPosts - self.applySorting(newSort: self.currentSort) + let sort = currentSort == "전체" ? nil : currentSort + self.posts = try await useCase.fetchPosts(query: self.initialQuery, sort: sort) } catch { print("게시물 로딩 실패: \(error.localizedDescription)") } From dc382a89d76aa9deb6a5ab78c8c558c6231d44b6 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Fri, 23 Jan 2026 23:21:54 +0900 Subject: [PATCH 21/29] =?UTF-8?q?[#53]=20=EA=B2=80=EC=83=89->=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95/=EA=B8=B0=EB=A1=9D=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/DIContainer/FeedDIContainer.swift | 2 +- Codive/Features/Main/View/MainTabView.swift | 2 ++ .../Search/Presentation/View/HashtagView.swift | 14 +++++++++----- .../Presentation/View/SearchResultView.swift | 14 +++++++++----- .../ViewModel/SearchResultViewModel.swift | 9 +++++++++ Codive/Router/AppDestination.swift | 3 ++- Codive/Router/ViewFactory/FeedViewFactory.swift | 6 +++++- 7 files changed, 37 insertions(+), 13 deletions(-) diff --git a/Codive/DIContainer/FeedDIContainer.swift b/Codive/DIContainer/FeedDIContainer.swift index 20a108a0..b13c2e7a 100644 --- a/Codive/DIContainer/FeedDIContainer.swift +++ b/Codive/DIContainer/FeedDIContainer.swift @@ -12,7 +12,7 @@ final class FeedDIContainer { // MARK: - Properties let navigationRouter: NavigationRouter - lazy var feedViewFactory = FeedViewFactory(feedDIContainer: self) + lazy var feedViewFactory = FeedViewFactory(feedDIContainer: self, navigationRouter: navigationRouter) lazy var commentDIContainer = CommentDIContainer(navigationRouter: navigationRouter) // MARK: - Initializer diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index 74ca3784..1419ec39 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -178,6 +178,8 @@ struct MainTabView: View { notificationDIContainer.makeNotificationView() case .feedDetail(let feedId): feedDIContainer.makeFeedDetailView(feedId: feedId) + case .otherProfile: + feedDIContainer.feedViewFactory.makeView(for: destination) case .comment(let feedId): commentDIContainer.makeCommentView(feedId: feedId) case .editCategory: diff --git a/Codive/Features/Search/Presentation/View/HashtagView.swift b/Codive/Features/Search/Presentation/View/HashtagView.swift index 33457ab9..a9ff2671 100644 --- a/Codive/Features/Search/Presentation/View/HashtagView.swift +++ b/Codive/Features/Search/Presentation/View/HashtagView.swift @@ -37,11 +37,15 @@ struct HashtagView: View { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 11) { ForEach(viewModel.posts) { post in - PostCard( - postImageUrl: post.postImageUrl, - profileImageUrl: post.profileImageUrl, - nickname: post.nickname - ) + Button { + viewModel.navigateToFeedDetail(feedId: post.id) + } label: { + PostCard( + postImageUrl: post.postImageUrl, + profileImageUrl: post.profileImageUrl, + nickname: post.nickname + ) + } } } .padding(.top, 18) diff --git a/Codive/Features/Search/Presentation/View/SearchResultView.swift b/Codive/Features/Search/Presentation/View/SearchResultView.swift index 91282c07..9373ca03 100644 --- a/Codive/Features/Search/Presentation/View/SearchResultView.swift +++ b/Codive/Features/Search/Presentation/View/SearchResultView.swift @@ -49,11 +49,15 @@ struct SearchResultView: View { if selectedSegment == .account { VStack(spacing: 0) { ForEach(viewModel.users, id: \.userId) { user in - CustomUserRow( - user: user, - buttonStyle: .none - ) { - // 버튼 동작 + Button { + viewModel.navigateToUserProfile(userId: Int(user.userId)) + } label: { + CustomUserRow( + user: user, + buttonStyle: .none + ) { + // 버튼 동작 + } } .padding(.vertical, 6) } diff --git a/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift b/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift index 9254c4b4..a60a98a1 100644 --- a/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift +++ b/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift @@ -99,7 +99,16 @@ final class SearchResultViewModel: ObservableObject { } // MARK: - Navigation + func handleBackTap() { navigationRouter.navigateBack() } + + func navigateToUserProfile(userId: Int) { + navigationRouter.navigate(to: .otherProfile(userId: userId)) + } + + func navigateToFeedDetail(feedId: Int) { + navigationRouter.navigate(to: .feedDetail(feedId: feedId)) + } } diff --git a/Codive/Router/AppDestination.swift b/Codive/Router/AppDestination.swift index 0d99120b..d733d489 100644 --- a/Codive/Router/AppDestination.swift +++ b/Codive/Router/AppDestination.swift @@ -42,7 +42,8 @@ enum AppDestination: Hashable, Identifiable { case comment(feedId: Int) case favoriteCodiList(showHeart: Bool) case followList(mode: FollowListMode) - + case otherProfile(userId: Int) + case myCloset case clothDetail(cloth: Cloth) case clothEdit(cloth: Cloth) diff --git a/Codive/Router/ViewFactory/FeedViewFactory.swift b/Codive/Router/ViewFactory/FeedViewFactory.swift index 8f7dae0b..2107466a 100644 --- a/Codive/Router/ViewFactory/FeedViewFactory.swift +++ b/Codive/Router/ViewFactory/FeedViewFactory.swift @@ -10,10 +10,12 @@ import SwiftUI @MainActor final class FeedViewFactory { private weak var feedDIContainer: FeedDIContainer? + private let navigationRouter: NavigationRouter // MARK: - Initializer - init(feedDIContainer: FeedDIContainer) { + init(feedDIContainer: FeedDIContainer, navigationRouter: NavigationRouter) { self.feedDIContainer = feedDIContainer + self.navigationRouter = navigationRouter } // MARK: - Methods @@ -22,6 +24,8 @@ final class FeedViewFactory { switch destination { case .feedDetail(let feedId): feedDIContainer?.makeFeedDetailView(feedId: feedId) + case .otherProfile: + OtherProfileView(navigationRouter: navigationRouter) default: EmptyView() } From be1653b5b4ce161cbb55f9216c3dbf63d8ca3633 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 24 Jan 2026 15:00:11 +0900 Subject: [PATCH 22/29] =?UTF-8?q?[#53]=20=EA=B2=80=EC=83=89=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20postCard=20UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Search/Presentation/Component/PostCard.swift | 12 +++--------- .../Search/Presentation/View/HashtagView.swift | 10 ++++++++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Codive/Features/Search/Presentation/Component/PostCard.swift b/Codive/Features/Search/Presentation/Component/PostCard.swift index a6a3cc67..272bb7a5 100644 --- a/Codive/Features/Search/Presentation/Component/PostCard.swift +++ b/Codive/Features/Search/Presentation/Component/PostCard.swift @@ -12,10 +12,8 @@ struct PostCard: View { let postImageUrl: String? let profileImageUrl: String? let nickname: String - + // MARK: - Constants (디자인 값) - private let cardWidth: CGFloat = 162 - private let cardHeight: CGFloat = 216 private let cornerRadius: CGFloat = 12 private let profileImageSize: CGFloat = 28 private let nicknameFont = Font.codive_body2_medium @@ -39,16 +37,11 @@ struct PostCard: View { ProgressView() } } - .frame(width: cardWidth, height: cardHeight) .clipped() - .cornerRadius(cornerRadius) } else { Rectangle() .fill(Color.gray.opacity(0.3)) .overlay(Image(systemName: "photo").foregroundStyle(Color.gray)) - .frame(width: cardWidth, height: cardHeight) - .clipped() - .cornerRadius(cornerRadius) } HStack(spacing: 4) { if let urlString = profileImageUrl, let url = URL(string: urlString) { @@ -85,7 +78,8 @@ struct PostCard: View { .padding(.leading, overlayLeft) .padding(.bottom, overlayBottom) } - .frame(width: cardWidth, height: cardHeight) + .aspectRatio(3/4, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) } } diff --git a/Codive/Features/Search/Presentation/View/HashtagView.swift b/Codive/Features/Search/Presentation/View/HashtagView.swift index a9ff2671..99846b4d 100644 --- a/Codive/Features/Search/Presentation/View/HashtagView.swift +++ b/Codive/Features/Search/Presentation/View/HashtagView.swift @@ -10,7 +10,13 @@ import SwiftUI // MARK: - Hashtag View struct HashtagView: View { @ObservedObject var viewModel: SearchResultViewModel - + + // MARK: - Grid Columns + private let columns: [GridItem] = [ + GridItem(.flexible(), spacing: 11), + GridItem(.flexible(), spacing: 11) + ] + // MARK: - Computed Properties private var sortOptionsString: [String] { viewModel.sortOptions.map { $0.displayName } @@ -35,7 +41,7 @@ struct HashtagView: View { .padding(.top, 18) .zIndex(10) - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 11) { + LazyVGrid(columns: columns, spacing: 16) { ForEach(viewModel.posts) { post in Button { viewModel.navigateToFeedDetail(feedId: post.id) From c30d997b993ae0740cd13166b56e2ff446369b03 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 25 Jan 2026 00:37:56 +0900 Subject: [PATCH 23/29] =?UTF-8?q?[#53]=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=83=81=EB=8B=A8=EB=B0=94=20=EA=B0=80?= =?UTF-8?q?=EB=A6=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Main/View/MainTabView.swift | 4 ++-- .../Profile/MyProfile/Presentation/View/ProfileView.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index 1419ec39..604385b6 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -150,8 +150,8 @@ struct MainTabView: View { } } - // 기본 규칙: Add 탭에서만 상단바 숨김 - return viewModel.selectedTab != .add + // 기본 규칙: Add, Profile 탭에서만 상단바 숨김 + return viewModel.selectedTab != .add && viewModel.selectedTab != .profile } /// 하단 탭 바를 표시할지 여부 diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift index f45a7d4e..362ec2db 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -21,7 +21,7 @@ struct ProfileView: View { ScrollView(showsIndicators: false) { VStack(spacing: 0) { topBar - + profileSection .padding(.top, 32) From a9f77cdbc340a78be7ecf47afc044a3b8e9ba964 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 25 Jan 2026 15:02:15 +0900 Subject: [PATCH 24/29] =?UTF-8?q?[#53]=20=EB=82=B4=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=A1=B0=ED=9A=8C/=20=ED=8C=94=EB=A1=9C=EC=9E=89,?= =?UTF-8?q?=ED=8C=94=EB=A1=9C=EC=9B=8C=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Main/View/MainTabView.swift | 4 +- .../MyProfile/Data/ProfileAPIService.swift | 141 ++++++++++++++++++ .../Presentation/View/ProfileView.swift | 51 ++++++- .../ViewModel/ProfileViewModel.swift | 60 ++++++-- .../ViewModel/OtherProfileViewModel.swift | 6 +- .../Presentation/View/FollowListView.swift | 7 +- .../Viewmodel/FollowListViewModel.swift | 41 +++-- .../Search/Data/SearchAPIService.swift | 2 +- Codive/Router/AppDestination.swift | 2 +- Codive/Shared/Domain/Entities/Comment.swift | 4 +- Tuist/Package.resolved | 2 +- 11 files changed, 271 insertions(+), 49 deletions(-) create mode 100644 Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index 604385b6..a1d24570 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -190,8 +190,8 @@ struct MainTabView: View { FavoriteCodiView(showHeart: showHeart, navigationRouter: navigationRouter) case .settings: ProfileSettingView(navigationRouter: navigationRouter) - case .followList(let mode): - FollowListView(mode: mode, navigationRouter: navigationRouter) + case .followList(let mode, let memberId): + FollowListView(mode: mode, memberId: memberId, navigationRouter: navigationRouter) case .myCloset: closetDIContainer.makeMyClosetView() case .clothDetail, .clothEdit: diff --git a/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift new file mode 100644 index 00000000..32c8c7b8 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift @@ -0,0 +1,141 @@ +// +// ProfileAPIService.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +import Foundation +import CodiveAPI +import OpenAPIRuntime + +// MARK: - Profile API Service Protocol + +protocol ProfileAPIServiceProtocol { + func fetchMyProfile() async throws -> MyProfileInfo + func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult +} + +// MARK: - Supporting Types + +struct MyProfileInfo { + let userId: Int + let nickname: String + let displayName: String + let introduction: String? + let profileImageUrl: String? + let followerCount: Int + let followingCount: Int +} + +struct FollowListResult { + let followers: [SimpleUser] + let isLast: Bool +} + +// MARK: - Profile API Service Implementation + +final class ProfileAPIService: ProfileAPIServiceProtocol { + private let client: Client + private let jsonDecoder: JSONDecoder + + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { + self.client = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: tokenProvider)] + ) + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + } + + func fetchMyProfile() async throws -> MyProfileInfo { + let response = try await client.Member_getMyInfo() + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + + // API 응답을 BaseResponseMemberInfoResponse로 decode + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseMemberInfoResponse.self, + from: data + ) + + guard let memberInfo = apiResponse.result else { + throw ProfileAPIError.invalidResponse + } + + guard let userId = memberInfo.memberId, + let nickname = memberInfo.nickname else { + throw ProfileAPIError.invalidResponse + } + + return MyProfileInfo( + userId: Int(userId), + nickname: nickname, + displayName: nickname, + introduction: memberInfo.bio, + profileImageUrl: memberInfo.profileImageUrl, + followerCount: Int(memberInfo.followerCount ?? 0), + followingCount: Int(memberInfo.followingCount ?? 0) + ) + + case .undocumented(statusCode: let code, _): + throw ProfileAPIError.serverError(statusCode: code, message: "내 프로필 조회 실패") + } + } + + func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult { + let response = try await client.Member_getFollows( + query: Operations.Member_getFollows.Input.Query( + memberId: Int64(memberId), + lastFollowId: lastFollowId, + isFollowing: isFollowing, + size: size + ) + ) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseSliceResponseFollowMemberResponse.self, + from: data + ) + + let members = apiResponse.result?.content ?? [] + let followers: [SimpleUser] = members.compactMap { member -> SimpleUser? in + guard let userId = member.memberId else { return nil } + return SimpleUser( + userId: Int(userId), + nickname: member.nickname ?? "", + handle: member.nickname ?? "", + avatarURL: member.profileImageUrl.flatMap { URL(string: $0) } + ) + } + + return FollowListResult( + followers: followers, + isLast: apiResponse.result?.isLast ?? true + ) + + case .undocumented(statusCode: let code, _): + throw ProfileAPIError.serverError(statusCode: code, message: "팔로우 목록 조회 실패") + } + } +} + +// MARK: - Profile API Error + +enum ProfileAPIError: LocalizedError { + case serverError(statusCode: Int, message: String) + case invalidResponse + + var errorDescription: String? { + switch self { + case .serverError(let statusCode, let message): + return "서버 오류 (\(statusCode)): \(message)" + case .invalidResponse: + return "올바르지 않은 응답 형식입니다" + } + } +} diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift index 362ec2db..f220c24c 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -21,10 +21,10 @@ struct ProfileView: View { ScrollView(showsIndicators: false) { VStack(spacing: 0) { topBar - + profileSection .padding(.top, 32) - + Divider() .padding(.top, 24) .foregroundStyle(Color.Codive.grayscale7) @@ -39,6 +39,9 @@ struct ProfileView: View { } } .background(Color.white) + .task { + await viewModel.loadMyProfile() + } } // MARK: - Top Bar @@ -71,11 +74,45 @@ struct ProfileView: View { // MARK: - Profile private var profileSection: some View { VStack { - Image("CustomProfile") - .resizable() - .scaledToFill() - .frame(width: 80, height: 80) - .clipShape(Circle()) + if let profileImageUrl = viewModel.profileImageUrl, let url = URL(string: profileImageUrl) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + .frame(width: 80, height: 80) + .clipShape(Circle()) + case .empty, .failure: + Image(systemName: "person.fill") + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + .clipShape(Circle()) + .foregroundColor(.gray) + .background(Color.gray.opacity(0.1)) + .clipShape(Circle()) + @unknown default: + Image(systemName: "person.fill") + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + .clipShape(Circle()) + .foregroundColor(.gray) + .background(Color.gray.opacity(0.1)) + .clipShape(Circle()) + } + } + } else { + Image(systemName: "person.fill") + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + .clipShape(Circle()) + .foregroundColor(.gray) + .background(Color.gray.opacity(0.1)) + .clipShape(Circle()) + } Text(viewModel.displayName) .font(.codive_title2) diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift index 7b9e8084..c571f721 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift @@ -9,42 +9,70 @@ import SwiftUI @MainActor class ProfileViewModel: ObservableObject { - // MARK: - Mock Data - @Published var username: String = "kiki01" - @Published var displayName: String = "일기러버" - @Published var introText: String = "안녕하세요 일기 러버에요" - @Published var followerCount: Int = 22 - @Published var followingCount: Int = 20 - + // MARK: - Profile Data + @Published var userId: Int = 0 + @Published var username: String = "" + @Published var displayName: String = "" + @Published var introText: String = "" + @Published var followerCount: Int = 0 + @Published var followingCount: Int = 0 + @Published var profileImageUrl: String? + // MARK: - State @Published var month: Date = Date() // 현재 표시 월 @Published var selectedDate: Date? = Date() // 선택된 날짜 - + @Published var isLoading: Bool = false + @Published var errorMessage: String? + // MARK: - Dependencies private let navigationRouter: NavigationRouter - + private let profileAPIService: ProfileAPIServiceProtocol + // MARK: - Initializer - init(navigationRouter: NavigationRouter) { + init(navigationRouter: NavigationRouter, profileAPIService: ProfileAPIServiceProtocol = ProfileAPIService()) { self.navigationRouter = navigationRouter + self.profileAPIService = profileAPIService } + // MARK: - Loading + func loadMyProfile() async { + isLoading = true + errorMessage = nil + + do { + let profileInfo = try await profileAPIService.fetchMyProfile() + self.userId = profileInfo.userId + self.username = profileInfo.nickname + self.displayName = profileInfo.displayName + self.introText = profileInfo.introduction ?? "" + self.followerCount = profileInfo.followerCount + self.followingCount = profileInfo.followingCount + self.profileImageUrl = profileInfo.profileImageUrl + } catch { + self.errorMessage = error.localizedDescription + print("프로필 로드 실패: \(error.localizedDescription)") + } + + isLoading = false + } + // MARK: - Actions func onEditProfileTapped() { print("Edit profile tapped") } - + func onSettingsTapped() { navigationRouter.navigate(to: .settings) } - + func onFollowerTapped() { - navigationRouter.navigate(to: .followList(mode: .followers)) + navigationRouter.navigate(to: .followList(mode: .followers, memberId: userId)) } - + func onFollowingTapped() { - navigationRouter.navigate(to: .followList(mode: .followings)) + navigationRouter.navigate(to: .followList(mode: .followings, memberId: userId)) } - + func onMoreFavoriteCodiTapped() { navigationRouter.navigate(to: .favoriteCodiList(showHeart: true)) } diff --git a/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift b/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift index 8b23c07f..42dfb787 100644 --- a/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift +++ b/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift @@ -48,11 +48,11 @@ class OtherProfileViewModel: ObservableObject { } func onFollowerTapped() { - navigationRouter.navigate(to: .followList(mode: .followers)) + navigationRouter.navigate(to: .followList(mode: .followers, memberId: 0)) // TODO: 실제 userId로 변경 필요 } - + func onFollowingTapped() { - navigationRouter.navigate(to: .followList(mode: .followings)) + navigationRouter.navigate(to: .followList(mode: .followings, memberId: 0)) // TODO: 실제 userId로 변경 필요 } func onFollowButtonTapped() { diff --git a/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift b/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift index 943f74dd..3f88cea4 100644 --- a/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift +++ b/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift @@ -11,9 +11,9 @@ struct FollowListView: View { @ObservedObject private var navigationRouter: NavigationRouter @StateObject private var viewModel: FollowListViewModel - init(mode: FollowListMode, navigationRouter: NavigationRouter) { + init(mode: FollowListMode, memberId: Int, navigationRouter: NavigationRouter) { self.navigationRouter = navigationRouter - _viewModel = StateObject(wrappedValue: FollowListViewModel(mode: mode)) + _viewModel = StateObject(wrappedValue: FollowListViewModel(mode: mode, memberId: memberId)) } var body: some View { @@ -44,5 +44,8 @@ struct FollowListView: View { } .background(Color.white) .navigationBarBackButtonHidden(true) + .task { + await viewModel.load() + } } } diff --git a/Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift b/Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift index ae90b7e1..98cb044d 100644 --- a/Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift +++ b/Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift @@ -10,27 +10,40 @@ import SwiftUI final class FollowListViewModel: ObservableObject { @Published private(set) var items: [FollowRowItem] = [] + @Published var isLoading: Bool = false + @Published var errorMessage: String? + let mode: FollowListMode + private let profileAPIService: ProfileAPIServiceProtocol + private let memberId: Int - init(mode: FollowListMode) { + init(mode: FollowListMode, memberId: Int, profileAPIService: ProfileAPIServiceProtocol = ProfileAPIService()) { self.mode = mode - load() + self.memberId = memberId + self.profileAPIService = profileAPIService } - func load() { - // 실제 구현에서는 mode에 따라 API 분기 + func load() async { + isLoading = true + errorMessage = nil + + do { + let result = try await profileAPIService.fetchFollows( + memberId: memberId, + isFollowing: mode == .followings, + lastFollowId: nil, + size: 20 + ) - if mode == .followers { - items = [ - .init(user: .init(userId: 1, nickname: "닉네임", handle: "아이디", avatarURL: nil), isFollowing: false), - .init(user: .init(userId: 2, nickname: "닉네임", handle: "아이디", avatarURL: nil), isFollowing: true) - ] - } else { - items = [ - .init(user: .init(userId: 3, nickname: "닉네임", handle: "아이디", avatarURL: nil), isFollowing: true), - .init(user: .init(userId: 4, nickname: "닉네임", handle: "아이디", avatarURL: nil), isFollowing: true) - ] + self.items = result.followers.map { user in + FollowRowItem(user: user, isFollowing: true) + } + } catch { + self.errorMessage = error.localizedDescription + print("팔로우 목록 로드 실패: \(error.localizedDescription)") } + + isLoading = false } func onTapButton(userId: UserID) { diff --git a/Codive/Features/Search/Data/SearchAPIService.swift b/Codive/Features/Search/Data/SearchAPIService.swift index c3c2fffc..78d4683a 100644 --- a/Codive/Features/Search/Data/SearchAPIService.swift +++ b/Codive/Features/Search/Data/SearchAPIService.swift @@ -69,7 +69,7 @@ final class SearchAPIService: SearchAPIServiceProtocol { return SimpleUser( userId: Int(userId), nickname: member.nickname ?? "", - handle: member.clokeyId ?? "", + handle: member.nickname ?? "", avatarURL: member.profileImageUrl.flatMap { URL(string: $0) } ) } diff --git a/Codive/Router/AppDestination.swift b/Codive/Router/AppDestination.swift index d733d489..505d4958 100644 --- a/Codive/Router/AppDestination.swift +++ b/Codive/Router/AppDestination.swift @@ -41,7 +41,7 @@ enum AppDestination: Hashable, Identifiable { case feedDetail(feedId: Int) case comment(feedId: Int) case favoriteCodiList(showHeart: Bool) - case followList(mode: FollowListMode) + case followList(mode: FollowListMode, memberId: Int) case otherProfile(userId: Int) case myCloset diff --git a/Codive/Shared/Domain/Entities/Comment.swift b/Codive/Shared/Domain/Entities/Comment.swift index 51b81fdd..d7dca3c9 100644 --- a/Codive/Shared/Domain/Entities/Comment.swift +++ b/Codive/Shared/Domain/Entities/Comment.swift @@ -43,7 +43,7 @@ extension Comment { static func from(apiResponse: Components.Schemas.CommentListResponse) -> Comment { let user = User( id: String(apiResponse.memberId ?? 0), - nickname: apiResponse.nickName ?? "", + nickname: apiResponse.nickname ?? "", profileImageUrl: apiResponse.profileImageUrl ) @@ -61,7 +61,7 @@ extension Comment { static func from(apiResponse: Components.Schemas.ReplyListResponse) -> Comment { let user = User( id: String(apiResponse.memberId ?? 0), - nickname: apiResponse.nickName ?? "", + nickname: apiResponse.nickname ?? "", profileImageUrl: apiResponse.profileImageUrl ) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 39b9887a..4a06c42f 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -16,7 +16,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "d606e28bc8ed5d09556e8330e757c3a45d45f3c9" + "revision" : "dfde09be34ac830a9efc93efd52296ff61c7fda2" } }, { From 1939e1041e3448c3bbd1dcbfb64a8fb5fb3c2caf Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 25 Jan 2026 15:27:54 +0900 Subject: [PATCH 25/29] =?UTF-8?q?[#53]=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91,=20=EC=84=A4=EC=A0=95=20=EB=84=A4=EB=B9=84?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=EC=85=98=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Main/View/MainTabView.swift | 4 ++++ .../Presentation/ViewModel/ProfileViewModel.swift | 2 +- .../Setting/Presentation/View/SettingView.swift | 12 ++++++------ .../Presentation/ViewModel/SettingViewModel.swift | 5 +++++ .../settingProfile.imageset/Contents.json | 2 +- .../Icon_folder/settingProfile.imageset/profile.png | Bin Codive/Router/AppDestination.swift | 9 +++++---- 7 files changed, 22 insertions(+), 12 deletions(-) rename "Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/\355\224\204\353\241\234\355\225\204.png" => Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/profile.png (100%) diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index a1d24570..88f3b26e 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -22,6 +22,7 @@ struct MainTabView: View { private let notificationDIContainer: NotificationDIContainer private let commentDIContainer: CommentDIContainer private let lookBookDIContainer: LookBookDIContainer + private let settingDIContainer: SettingDIContainer // MARK: - Initializer init(appDIContainer: AppDIContainer) { @@ -34,6 +35,7 @@ struct MainTabView: View { self.notificationDIContainer = appDIContainer.makeNotificationDIContainer() self.commentDIContainer = appDIContainer.makeCommentDIContainer() self.lookBookDIContainer = appDIContainer.makeLookBookDIContainer() + self.settingDIContainer = appDIContainer.makeSettingDIContainer() self._navigationRouter = ObservedObject(wrappedValue: appDIContainer.navigationRouter) let viewModel = MainTabViewModel(navigationRouter: appDIContainer.navigationRouter) @@ -189,6 +191,8 @@ struct MainTabView: View { case .favoriteCodiList(let showHeart): FavoriteCodiView(showHeart: showHeart, navigationRouter: navigationRouter) case .settings: + settingDIContainer.makeSettingView() + case .profileSetting: ProfileSettingView(navigationRouter: navigationRouter) case .followList(let mode, let memberId): FollowListView(mode: mode, memberId: memberId, navigationRouter: navigationRouter) diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift index c571f721..9bd39358 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift @@ -58,7 +58,7 @@ class ProfileViewModel: ObservableObject { // MARK: - Actions func onEditProfileTapped() { - print("Edit profile tapped") + navigationRouter.navigate(to: .profileSetting) } func onSettingsTapped() { diff --git a/Codive/Features/Setting/Presentation/View/SettingView.swift b/Codive/Features/Setting/Presentation/View/SettingView.swift index e578f194..3c160447 100644 --- a/Codive/Features/Setting/Presentation/View/SettingView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingView.swift @@ -18,8 +18,7 @@ struct SettingView: View { var body: some View { VStack(spacing: 0) { CustomNavigationBar(title: TextLiteral.Setting.title) { - // 뒤로가기 액션 - print("뒤로가기") + vm.navigateBack() } ScrollView { @@ -33,6 +32,7 @@ struct SettingView: View { .padding(.top, 24) } } + .navigationBarHidden(true) .task { await vm.load() } @@ -171,16 +171,16 @@ private struct SettingRow: View { let text: String var body: some View { - HStack { + HStack(spacing: 12) { Text(text) .font(.codive_body1_regular) .foregroundStyle(Color.Codive.grayscale1) Spacer() - Image("backSmall") - .frame(width: 6, height: 12) - .foregroundStyle(Color.Codive.main1) + Image(systemName: "chevron.right") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color.Codive.grayscale3) } } } diff --git a/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift index cf0985df..4deac0ba 100644 --- a/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift +++ b/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift @@ -66,4 +66,9 @@ final class SettingViewModel: ObservableObject { self.error = error } } + + // MARK: - Navigation + func navigateBack() { + navigationRouter.navigateBack() + } } diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json b/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json index 1216e975..ed27c2cf 100644 --- a/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json +++ b/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "프로필.png", + "filename" : "profile.png", "idiom" : "universal" } ], diff --git "a/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/\355\224\204\353\241\234\355\225\204.png" b/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/profile.png similarity index 100% rename from "Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/\355\224\204\353\241\234\355\225\204.png" rename to Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/profile.png diff --git a/Codive/Router/AppDestination.swift b/Codive/Router/AppDestination.swift index 505d4958..4b645c6a 100644 --- a/Codive/Router/AppDestination.swift +++ b/Codive/Router/AppDestination.swift @@ -47,6 +47,7 @@ enum AppDestination: Hashable, Identifiable { case myCloset case clothDetail(cloth: Cloth) case clothEdit(cloth: Cloth) + case profileSetting var id: Self { self } @@ -78,9 +79,9 @@ enum AppDestination: Hashable, Identifiable { return true // Profile Flow - case .favoriteCodiList, .settings, .followList: + case .favoriteCodiList, .settings, .followList, .profileSetting: return true - + // Closet Flow - 전체 화면 case .myCloset, .clothDetail, .clothEdit: return true @@ -116,9 +117,9 @@ enum AppDestination: Hashable, Identifiable { return false // Profile Flow - 자체 네비게이션 바 있음 - case .favoriteCodiList, .settings, .followList: + case .favoriteCodiList, .settings, .followList, .profileSetting: return false - + // Closet Flow - 자체 네비게이션 바 있음 case .myCloset, .clothDetail, .clothEdit: return false From 267f81cb17d7c2f2ac4f173f00bd545dcb0766bd Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 25 Jan 2026 15:33:00 +0900 Subject: [PATCH 26/29] =?UTF-8?q?[#53]=20=EA=B8=B0=EB=B3=B8=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/View/CommentView.swift | 4 +++- .../Components/ProfileHeaderView.swift | 10 +++------ .../Component/NotificationRow.swift | 3 +-- .../View/ProfileSettingView.swift | 10 +++------ .../Presentation/View/ProfileView.swift | 21 +++++------------- .../Presentation/View/OtherProfileView.swift | 2 +- .../Report/Presentation/View/ReportView.swift | 2 +- .../Presentation/Component/PostCard.swift | 6 ++--- .../Contents.json | 0 .../profile.png | Bin .../DesignSystem/Views/CustomFeedCard.swift | 4 ++-- .../DesignSystem/Views/CustomUserRow.swift | 12 +++++++--- 12 files changed, 31 insertions(+), 43 deletions(-) rename Codive/Resources/Icons.xcassets/Icon_folder/{settingProfile.imageset => Profile.imageset}/Contents.json (100%) rename Codive/Resources/Icons.xcassets/Icon_folder/{settingProfile.imageset => Profile.imageset}/profile.png (100%) diff --git a/Codive/Features/Comment/Presentation/View/CommentView.swift b/Codive/Features/Comment/Presentation/View/CommentView.swift index a9c091f7..dc5ff3ed 100644 --- a/Codive/Features/Comment/Presentation/View/CommentView.swift +++ b/Codive/Features/Comment/Presentation/View/CommentView.swift @@ -157,7 +157,9 @@ struct CommentRow: View { AsyncImage(url: URL(string: comment.author.profileImageUrl ?? "")) { image in image.resizable().aspectRatio(contentMode: .fill) } placeholder: { - Circle().fill(Color.Codive.grayscale5) + Image("Profile") + .resizable() + .aspectRatio(contentMode: .fill) } .frame(width: isReply ? 28 : 36, height: isReply ? 28 : 36) .clipShape(Circle()) diff --git a/Codive/Features/Feed/Presentation/Components/ProfileHeaderView.swift b/Codive/Features/Feed/Presentation/Components/ProfileHeaderView.swift index c1833983..93f698db 100644 --- a/Codive/Features/Feed/Presentation/Components/ProfileHeaderView.swift +++ b/Codive/Features/Feed/Presentation/Components/ProfileHeaderView.swift @@ -15,14 +15,10 @@ struct ProfileHeaderView: View { var body: some View { HStack(spacing: 8) { // TODO: API 연동 시 profileImageUrl을 사용하여 비동기 이미지 로딩 (KingFisher 또는 AsyncImage) - Circle() - .fill(Color.gray.opacity(0.2)) + Image("Profile") + .resizable() + .scaledToFill() .frame(width: 32, height: 32) - .overlay( - Image(systemName: "person.crop.circle.fill") - .resizable() - .foregroundStyle(Color.gray) - ) .clipShape(Circle()) Text(nickname) diff --git a/Codive/Features/Notification/Presentation/Component/NotificationRow.swift b/Codive/Features/Notification/Presentation/Component/NotificationRow.swift index a40915e3..e7f7cfb1 100644 --- a/Codive/Features/Notification/Presentation/Component/NotificationRow.swift +++ b/Codive/Features/Notification/Presentation/Component/NotificationRow.swift @@ -58,10 +58,9 @@ struct NotificationRow: View { } private var defaultImage: some View { - Image(systemName: "person.circle.fill") + Image("Profile") .resizable() .aspectRatio(contentMode: .fill) - .foregroundStyle(Color.gray.opacity(0.3)) .frame(width: profileImageSize, height: profileImageSize) .clipShape(Circle()) } diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift index 040273e4..a73b5b50 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift @@ -57,13 +57,9 @@ struct ProfileSettingView: View { .resizable() .scaledToFill() } else { - Circle() - .fill(Color.Codive.grayscale6) - .overlay { - Image("settingProfile") - .resizable() - .scaledToFit() - } + Image("Profile") + .resizable() + .scaledToFill() } } .frame(width: 100, height: 100) diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift index f220c24c..d42a148d 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -84,34 +84,25 @@ struct ProfileView: View { .frame(width: 80, height: 80) .clipShape(Circle()) case .empty, .failure: - Image(systemName: "person.fill") + Image("Profile") .resizable() - .scaledToFit() + .scaledToFill() .frame(width: 80, height: 80) .clipShape(Circle()) - .foregroundColor(.gray) - .background(Color.gray.opacity(0.1)) - .clipShape(Circle()) @unknown default: - Image(systemName: "person.fill") + Image("Profile") .resizable() - .scaledToFit() + .scaledToFill() .frame(width: 80, height: 80) .clipShape(Circle()) - .foregroundColor(.gray) - .background(Color.gray.opacity(0.1)) - .clipShape(Circle()) } } } else { - Image(systemName: "person.fill") + Image("Profile") .resizable() - .scaledToFit() + .scaledToFill() .frame(width: 80, height: 80) .clipShape(Circle()) - .foregroundColor(.gray) - .background(Color.gray.opacity(0.1)) - .clipShape(Circle()) } Text(viewModel.displayName) diff --git a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift index 039a180e..e50a04d4 100644 --- a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift +++ b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift @@ -87,7 +87,7 @@ struct OtherProfileView: View { // MARK: - Profile private var profileSection: some View { VStack { - Image("CustomProfile") + Image("Profile") .resizable() .scaledToFill() .frame(width: 80, height: 80) diff --git a/Codive/Features/Report/Presentation/View/ReportView.swift b/Codive/Features/Report/Presentation/View/ReportView.swift index 70fbc868..e176c49b 100644 --- a/Codive/Features/Report/Presentation/View/ReportView.swift +++ b/Codive/Features/Report/Presentation/View/ReportView.swift @@ -47,7 +47,7 @@ struct ReportView: View { Group { HStack { // 후에 실제 프로필 이미지 주입 - Image("CustomProfile") + Image("Profile") .resizable() .scaledToFill() .frame(width: 40, height: 40) diff --git a/Codive/Features/Search/Presentation/Component/PostCard.swift b/Codive/Features/Search/Presentation/Component/PostCard.swift index 272bb7a5..95b73701 100644 --- a/Codive/Features/Search/Presentation/Component/PostCard.swift +++ b/Codive/Features/Search/Presentation/Component/PostCard.swift @@ -51,10 +51,9 @@ struct PostCard: View { .resizable() .aspectRatio(contentMode: .fill) } else if phase.error != nil { - Image(systemName: "person.circle.fill") + Image("Profile") .resizable() .aspectRatio(contentMode: .fill) - .foregroundStyle(Color.gray.opacity(0.3)) } else { ProgressView() } @@ -62,10 +61,9 @@ struct PostCard: View { .frame(width: profileImageSize, height: profileImageSize) .clipShape(Circle()) } else { - Image(systemName: "person.circle.fill") + Image("Profile") .resizable() .aspectRatio(contentMode: .fill) - .foregroundStyle(Color.gray.opacity(0.3)) .frame(width: profileImageSize, height: profileImageSize) .clipShape(Circle()) } diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json b/Codive/Resources/Icons.xcassets/Icon_folder/Profile.imageset/Contents.json similarity index 100% rename from Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/Contents.json rename to Codive/Resources/Icons.xcassets/Icon_folder/Profile.imageset/Contents.json diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/profile.png b/Codive/Resources/Icons.xcassets/Icon_folder/Profile.imageset/profile.png similarity index 100% rename from Codive/Resources/Icons.xcassets/Icon_folder/settingProfile.imageset/profile.png rename to Codive/Resources/Icons.xcassets/Icon_folder/Profile.imageset/profile.png diff --git a/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift b/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift index 4f102c06..65011242 100644 --- a/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift +++ b/Codive/Shared/DesignSystem/Views/CustomFeedCard.swift @@ -75,9 +75,9 @@ struct CustomFeedCard: View { .resizable() .scaledToFill() case .failure, .empty: - Image(systemName: "person.circle.fill") + Image("Profile") .resizable() - .foregroundColor(.gray) + .scaledToFill() @unknown default: EmptyView() } diff --git a/Codive/Shared/DesignSystem/Views/CustomUserRow.swift b/Codive/Shared/DesignSystem/Views/CustomUserRow.swift index a1911089..b0468bf1 100644 --- a/Codive/Shared/DesignSystem/Views/CustomUserRow.swift +++ b/Codive/Shared/DesignSystem/Views/CustomUserRow.swift @@ -58,11 +58,17 @@ struct CustomUserRow: View { .resizable() .scaledToFill() case .empty: - Color.Codive.grayscale4 + Image("Profile") + .resizable() + .scaledToFill() case .failure: - Color.Codive.grayscale4 + Image("Profile") + .resizable() + .scaledToFill() @unknown default: - Color.Codive.grayscale4 + Image("Profile") + .resizable() + .scaledToFill() } } .frame(width: 40, height: 40) From 7a472b21bd4a1c721cea7ec9b235c0713a5f05af Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 25 Jan 2026 15:56:10 +0900 Subject: [PATCH 27/29] =?UTF-8?q?[#53]=20Profile=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20=EC=A0=95=EB=A6=AC=20-?= =?UTF-8?q?=20Clean=20Architecture=20=EC=A4=80=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Domain 계층 추가: * ProfileRepository protocol * FetchMyProfileUseCase / DefaultFetchMyProfileUseCase * FetchFollowsUseCase / DefaultFetchFollowsUseCase * ProfileEntity (도메인 모델) - Data 계층 확충: * ProfileDataSource - API 호출 추상화 * ProfileRepositoryImpl - Repository 구현 * 의존성 체인: Repository → DataSource → APIService - ProfileDIContainer 생성: * 모든 뷰 생성 메서드 제공 * 의존성 주입 관리 - ViewModel 개선: * ProfileViewModel: APIService → FetchMyProfileUseCase * FollowListViewModel: APIService → FetchFollowsUseCase * 모든 ViewModel이 DIContainer에서 주입됨 - View 구조 정리: * ProfileView, FollowListView, ProfileSettingView, OtherProfileView * 모두 DIContainer 주입 방식으로 변경 - 라우팅 일관성 확보: * MainTabView에서 profileDIContainer 사용 * FeedViewFactory에서 profileDIContainer.makeOtherProfileViewModel() 사용 * 모든 Profile 관련 라우팅이 DIContainer 경유 --- Codive/DIContainer/AppDIContainer.swift | 4 + Codive/DIContainer/FeedDIContainer.swift | 1 + Codive/DIContainer/ProfileDIContainer.swift | 87 +++++++++++++++++++ Codive/Features/Main/View/MainTabView.swift | 8 +- .../Data/DataSources/ProfileDataSource.swift | 29 +++++++ .../MyProfile/Data/ProfileAPIService.swift | 17 ---- .../Repositories/ProfileRepositoryImpl.swift | 22 +++++ .../Domain/Entities/ProfileEntity.swift | 21 +++++ .../Domain/Protocols/ProfileRepository.swift | 11 +++ .../Domain/UseCases/FetchFollowsUseCase.swift | 22 +++++ .../UseCases/FetchMyProfileUseCase.swift | 22 +++++ .../View/ProfileSettingView.swift | 6 +- .../Presentation/View/ProfileView.swift | 6 +- .../ViewModel/ProfileViewModel.swift | 8 +- .../Presentation/View/OtherProfileView.swift | 6 +- .../Presentation/View/FollowListView.swift | 6 +- .../Viewmodel/FollowListViewModel.swift | 9 +- .../Router/ViewFactory/FeedViewFactory.swift | 9 +- 18 files changed, 253 insertions(+), 41 deletions(-) create mode 100644 Codive/DIContainer/ProfileDIContainer.swift create mode 100644 Codive/Features/Profile/MyProfile/Data/DataSources/ProfileDataSource.swift create mode 100644 Codive/Features/Profile/MyProfile/Data/Repositories/ProfileRepositoryImpl.swift create mode 100644 Codive/Features/Profile/MyProfile/Domain/Entities/ProfileEntity.swift create mode 100644 Codive/Features/Profile/MyProfile/Domain/Protocols/ProfileRepository.swift create mode 100644 Codive/Features/Profile/MyProfile/Domain/UseCases/FetchFollowsUseCase.swift create mode 100644 Codive/Features/Profile/MyProfile/Domain/UseCases/FetchMyProfileUseCase.swift diff --git a/Codive/DIContainer/AppDIContainer.swift b/Codive/DIContainer/AppDIContainer.swift index 5e1b1714..8794f4f2 100644 --- a/Codive/DIContainer/AppDIContainer.swift +++ b/Codive/DIContainer/AppDIContainer.swift @@ -66,4 +66,8 @@ final class AppDIContainer { func makeCommentDIContainer() -> CommentDIContainer { return CommentDIContainer(navigationRouter: navigationRouter) } + + func makeProfileDIContainer() -> ProfileDIContainer { + return ProfileDIContainer(navigationRouter: navigationRouter) + } } diff --git a/Codive/DIContainer/FeedDIContainer.swift b/Codive/DIContainer/FeedDIContainer.swift index b13c2e7a..9316806d 100644 --- a/Codive/DIContainer/FeedDIContainer.swift +++ b/Codive/DIContainer/FeedDIContainer.swift @@ -14,6 +14,7 @@ final class FeedDIContainer { let navigationRouter: NavigationRouter lazy var feedViewFactory = FeedViewFactory(feedDIContainer: self, navigationRouter: navigationRouter) lazy var commentDIContainer = CommentDIContainer(navigationRouter: navigationRouter) + lazy var profileDIContainer = ProfileDIContainer(navigationRouter: navigationRouter) // MARK: - Initializer init(navigationRouter: NavigationRouter) { diff --git a/Codive/DIContainer/ProfileDIContainer.swift b/Codive/DIContainer/ProfileDIContainer.swift new file mode 100644 index 00000000..9fdd4b29 --- /dev/null +++ b/Codive/DIContainer/ProfileDIContainer.swift @@ -0,0 +1,87 @@ +// +// ProfileDIContainer.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +import Foundation + +@MainActor +final class ProfileDIContainer { + + // MARK: - Properties + let navigationRouter: NavigationRouter + + // MARK: - Initializer + init(navigationRouter: NavigationRouter) { + self.navigationRouter = navigationRouter + } + + // MARK: - API Service + private lazy var profileAPIService: ProfileAPIServiceProtocol = { + return ProfileAPIService() + }() + + // MARK: - Data Sources + private lazy var profileDataSource: ProfileDataSourceProtocol = { + return ProfileDataSource(apiService: profileAPIService) + }() + + // MARK: - Repositories + private lazy var profileRepository: ProfileRepository = { + return ProfileRepositoryImpl(dataSource: profileDataSource) + }() + + // MARK: - UseCases + func makeFetchMyProfileUseCase() -> FetchMyProfileUseCase { + return DefaultFetchMyProfileUseCase(repository: profileRepository) + } + + func makeFetchFollowsUseCase() -> FetchFollowsUseCase { + return DefaultFetchFollowsUseCase(repository: profileRepository) + } + + // MARK: - ViewModels + func makeProfileViewModel() -> ProfileViewModel { + return ProfileViewModel( + navigationRouter: navigationRouter, + fetchMyProfileUseCase: makeFetchMyProfileUseCase() + ) + } + + func makeFollowListViewModel(mode: FollowListMode, memberId: Int) -> FollowListViewModel { + return FollowListViewModel( + mode: mode, + memberId: memberId, + fetchFollowsUseCase: makeFetchFollowsUseCase() + ) + } + + func makeProfileSettingViewModel() -> ProfileSettingViewModel { + return ProfileSettingViewModel(navigationRouter: navigationRouter) + } + + func makeOtherProfileViewModel() -> OtherProfileViewModel { + return OtherProfileViewModel(navigationRouter: navigationRouter) + } + + // MARK: - Views + func makeProfileView() -> ProfileView { + return ProfileView(viewModel: makeProfileViewModel(), navigationRouter: navigationRouter) + } + + func makeFollowListView(mode: FollowListMode, memberId: Int) -> FollowListView { + return FollowListView( + viewModel: makeFollowListViewModel(mode: mode, memberId: memberId), + navigationRouter: navigationRouter + ) + } + + func makeProfileSettingView() -> ProfileSettingView { + return ProfileSettingView( + viewModel: makeProfileSettingViewModel(), + navigationRouter: navigationRouter + ) + } +} diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index 88f3b26e..c502b247 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -23,6 +23,7 @@ struct MainTabView: View { private let commentDIContainer: CommentDIContainer private let lookBookDIContainer: LookBookDIContainer private let settingDIContainer: SettingDIContainer + private let profileDIContainer: ProfileDIContainer // MARK: - Initializer init(appDIContainer: AppDIContainer) { @@ -36,6 +37,7 @@ struct MainTabView: View { self.commentDIContainer = appDIContainer.makeCommentDIContainer() self.lookBookDIContainer = appDIContainer.makeLookBookDIContainer() self.settingDIContainer = appDIContainer.makeSettingDIContainer() + self.profileDIContainer = appDIContainer.makeProfileDIContainer() self._navigationRouter = ObservedObject(wrappedValue: appDIContainer.navigationRouter) let viewModel = MainTabViewModel(navigationRouter: appDIContainer.navigationRouter) @@ -74,7 +76,7 @@ struct MainTabView: View { case .feed: FeedView(viewModel: feedDIContainer.makeFeedViewModel()) case .profile: - ProfileView(navigationRouter: navigationRouter) + profileDIContainer.makeProfileView() } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -193,9 +195,9 @@ struct MainTabView: View { case .settings: settingDIContainer.makeSettingView() case .profileSetting: - ProfileSettingView(navigationRouter: navigationRouter) + profileDIContainer.makeProfileSettingView() case .followList(let mode, let memberId): - FollowListView(mode: mode, memberId: memberId, navigationRouter: navigationRouter) + profileDIContainer.makeFollowListView(mode: mode, memberId: memberId) case .myCloset: closetDIContainer.makeMyClosetView() case .clothDetail, .clothEdit: diff --git a/Codive/Features/Profile/MyProfile/Data/DataSources/ProfileDataSource.swift b/Codive/Features/Profile/MyProfile/Data/DataSources/ProfileDataSource.swift new file mode 100644 index 00000000..aed78a1f --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Data/DataSources/ProfileDataSource.swift @@ -0,0 +1,29 @@ +// +// ProfileDataSource.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +import Foundation + +protocol ProfileDataSourceProtocol { + func fetchMyProfile() async throws -> MyProfileInfo + func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult +} + +final class ProfileDataSource: ProfileDataSourceProtocol { + private let apiService: ProfileAPIServiceProtocol + + init(apiService: ProfileAPIServiceProtocol = ProfileAPIService()) { + self.apiService = apiService + } + + func fetchMyProfile() async throws -> MyProfileInfo { + return try await apiService.fetchMyProfile() + } + + func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult { + return try await apiService.fetchFollows(memberId: memberId, isFollowing: isFollowing, lastFollowId: lastFollowId, size: size) + } +} diff --git a/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift index 32c8c7b8..5967d1cd 100644 --- a/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift +++ b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift @@ -16,23 +16,6 @@ protocol ProfileAPIServiceProtocol { func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult } -// MARK: - Supporting Types - -struct MyProfileInfo { - let userId: Int - let nickname: String - let displayName: String - let introduction: String? - let profileImageUrl: String? - let followerCount: Int - let followingCount: Int -} - -struct FollowListResult { - let followers: [SimpleUser] - let isLast: Bool -} - // MARK: - Profile API Service Implementation final class ProfileAPIService: ProfileAPIServiceProtocol { diff --git a/Codive/Features/Profile/MyProfile/Data/Repositories/ProfileRepositoryImpl.swift b/Codive/Features/Profile/MyProfile/Data/Repositories/ProfileRepositoryImpl.swift new file mode 100644 index 00000000..8803cba3 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Data/Repositories/ProfileRepositoryImpl.swift @@ -0,0 +1,22 @@ +// +// ProfileRepositoryImpl.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +final class ProfileRepositoryImpl: ProfileRepository { + private let dataSource: ProfileDataSourceProtocol + + init(dataSource: ProfileDataSourceProtocol) { + self.dataSource = dataSource + } + + func fetchMyProfile() async throws -> MyProfileInfo { + return try await dataSource.fetchMyProfile() + } + + func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult { + return try await dataSource.fetchFollows(memberId: memberId, isFollowing: isFollowing, lastFollowId: lastFollowId, size: size) + } +} diff --git a/Codive/Features/Profile/MyProfile/Domain/Entities/ProfileEntity.swift b/Codive/Features/Profile/MyProfile/Domain/Entities/ProfileEntity.swift new file mode 100644 index 00000000..cb3ca000 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Domain/Entities/ProfileEntity.swift @@ -0,0 +1,21 @@ +// +// ProfileEntity.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +struct MyProfileInfo { + let userId: Int + let nickname: String + let displayName: String + let introduction: String? + let profileImageUrl: String? + let followerCount: Int + let followingCount: Int +} + +struct FollowListResult { + let followers: [SimpleUser] + let isLast: Bool +} diff --git a/Codive/Features/Profile/MyProfile/Domain/Protocols/ProfileRepository.swift b/Codive/Features/Profile/MyProfile/Domain/Protocols/ProfileRepository.swift new file mode 100644 index 00000000..a88910c5 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Domain/Protocols/ProfileRepository.swift @@ -0,0 +1,11 @@ +// +// ProfileRepository.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +protocol ProfileRepository { + func fetchMyProfile() async throws -> MyProfileInfo + func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult +} diff --git a/Codive/Features/Profile/MyProfile/Domain/UseCases/FetchFollowsUseCase.swift b/Codive/Features/Profile/MyProfile/Domain/UseCases/FetchFollowsUseCase.swift new file mode 100644 index 00000000..f8b3f276 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Domain/UseCases/FetchFollowsUseCase.swift @@ -0,0 +1,22 @@ +// +// FetchFollowsUseCase.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +protocol FetchFollowsUseCase { + func execute(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult +} + +final class DefaultFetchFollowsUseCase: FetchFollowsUseCase { + private let repository: ProfileRepository + + init(repository: ProfileRepository) { + self.repository = repository + } + + func execute(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult { + return try await repository.fetchFollows(memberId: memberId, isFollowing: isFollowing, lastFollowId: lastFollowId, size: size) + } +} diff --git a/Codive/Features/Profile/MyProfile/Domain/UseCases/FetchMyProfileUseCase.swift b/Codive/Features/Profile/MyProfile/Domain/UseCases/FetchMyProfileUseCase.swift new file mode 100644 index 00000000..3ac629ef --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Domain/UseCases/FetchMyProfileUseCase.swift @@ -0,0 +1,22 @@ +// +// FetchMyProfileUseCase.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +protocol FetchMyProfileUseCase { + func execute() async throws -> MyProfileInfo +} + +final class DefaultFetchMyProfileUseCase: FetchMyProfileUseCase { + private let repository: ProfileRepository + + init(repository: ProfileRepository) { + self.repository = repository + } + + func execute() async throws -> MyProfileInfo { + return try await repository.fetchMyProfile() + } +} diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift index a73b5b50..32ee4406 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift @@ -10,11 +10,11 @@ import Combine struct ProfileSettingView: View { @ObservedObject private var navigationRouter: NavigationRouter - @StateObject private var viewModel: ProfileSettingViewModel + @ObservedObject private var viewModel: ProfileSettingViewModel - init(navigationRouter: NavigationRouter) { + init(viewModel: ProfileSettingViewModel, navigationRouter: NavigationRouter) { + self._viewModel = ObservedObject(wrappedValue: viewModel) self.navigationRouter = navigationRouter - self._viewModel = StateObject(wrappedValue: ProfileSettingViewModel(navigationRouter: navigationRouter)) } enum Field: Hashable { diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift index d42a148d..82a4e163 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -10,11 +10,11 @@ import SwiftUI // MARK: - View struct ProfileView: View { @ObservedObject private var navigationRouter: NavigationRouter - @StateObject private var viewModel: ProfileViewModel + @ObservedObject private var viewModel: ProfileViewModel - init(navigationRouter: NavigationRouter) { + init(viewModel: ProfileViewModel, navigationRouter: NavigationRouter) { + self._viewModel = ObservedObject(wrappedValue: viewModel) self.navigationRouter = navigationRouter - self._viewModel = StateObject(wrappedValue: ProfileViewModel(navigationRouter: navigationRouter)) } var body: some View { diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift index 9bd39358..39dd18c4 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift @@ -26,12 +26,12 @@ class ProfileViewModel: ObservableObject { // MARK: - Dependencies private let navigationRouter: NavigationRouter - private let profileAPIService: ProfileAPIServiceProtocol + private let fetchMyProfileUseCase: FetchMyProfileUseCase // MARK: - Initializer - init(navigationRouter: NavigationRouter, profileAPIService: ProfileAPIServiceProtocol = ProfileAPIService()) { + init(navigationRouter: NavigationRouter, fetchMyProfileUseCase: FetchMyProfileUseCase) { self.navigationRouter = navigationRouter - self.profileAPIService = profileAPIService + self.fetchMyProfileUseCase = fetchMyProfileUseCase } // MARK: - Loading @@ -40,7 +40,7 @@ class ProfileViewModel: ObservableObject { errorMessage = nil do { - let profileInfo = try await profileAPIService.fetchMyProfile() + let profileInfo = try await fetchMyProfileUseCase.execute() self.userId = profileInfo.userId self.username = profileInfo.nickname self.displayName = profileInfo.displayName diff --git a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift index e50a04d4..ae05e20a 100644 --- a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift +++ b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift @@ -10,11 +10,11 @@ import SwiftUI // MARK: - View struct OtherProfileView: View { @ObservedObject private var navigationRouter: NavigationRouter - @StateObject private var viewModel: OtherProfileViewModel + @ObservedObject private var viewModel: OtherProfileViewModel - init(navigationRouter: NavigationRouter) { + init(viewModel: OtherProfileViewModel, navigationRouter: NavigationRouter) { + self._viewModel = ObservedObject(wrappedValue: viewModel) self.navigationRouter = navigationRouter - self._viewModel = StateObject(wrappedValue: OtherProfileViewModel(navigationRouter: navigationRouter)) } var body: some View { diff --git a/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift b/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift index 3f88cea4..410e670a 100644 --- a/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift +++ b/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift @@ -9,11 +9,11 @@ import SwiftUI struct FollowListView: View { @ObservedObject private var navigationRouter: NavigationRouter - @StateObject private var viewModel: FollowListViewModel + @ObservedObject private var viewModel: FollowListViewModel - init(mode: FollowListMode, memberId: Int, navigationRouter: NavigationRouter) { + init(viewModel: FollowListViewModel, navigationRouter: NavigationRouter) { + self._viewModel = ObservedObject(wrappedValue: viewModel) self.navigationRouter = navigationRouter - _viewModel = StateObject(wrappedValue: FollowListViewModel(mode: mode, memberId: memberId)) } var body: some View { diff --git a/Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift b/Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift index 98cb044d..9235cec9 100644 --- a/Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift +++ b/Codive/Features/Profile/Shared/Presentation/Viewmodel/FollowListViewModel.swift @@ -8,19 +8,20 @@ import Foundation import SwiftUI +@MainActor final class FollowListViewModel: ObservableObject { @Published private(set) var items: [FollowRowItem] = [] @Published var isLoading: Bool = false @Published var errorMessage: String? let mode: FollowListMode - private let profileAPIService: ProfileAPIServiceProtocol + private let fetchFollowsUseCase: FetchFollowsUseCase private let memberId: Int - init(mode: FollowListMode, memberId: Int, profileAPIService: ProfileAPIServiceProtocol = ProfileAPIService()) { + init(mode: FollowListMode, memberId: Int, fetchFollowsUseCase: FetchFollowsUseCase) { self.mode = mode self.memberId = memberId - self.profileAPIService = profileAPIService + self.fetchFollowsUseCase = fetchFollowsUseCase } func load() async { @@ -28,7 +29,7 @@ final class FollowListViewModel: ObservableObject { errorMessage = nil do { - let result = try await profileAPIService.fetchFollows( + let result = try await fetchFollowsUseCase.execute( memberId: memberId, isFollowing: mode == .followings, lastFollowId: nil, diff --git a/Codive/Router/ViewFactory/FeedViewFactory.swift b/Codive/Router/ViewFactory/FeedViewFactory.swift index 2107466a..1f998cf2 100644 --- a/Codive/Router/ViewFactory/FeedViewFactory.swift +++ b/Codive/Router/ViewFactory/FeedViewFactory.swift @@ -25,7 +25,14 @@ final class FeedViewFactory { case .feedDetail(let feedId): feedDIContainer?.makeFeedDetailView(feedId: feedId) case .otherProfile: - OtherProfileView(navigationRouter: navigationRouter) + if let profileDIContainer = feedDIContainer?.profileDIContainer { + OtherProfileView( + viewModel: profileDIContainer.makeOtherProfileViewModel(), + navigationRouter: navigationRouter + ) + } else { + EmptyView() + } default: EmptyView() } From cfe3db926d2b3623afff8ff8669f9aad91bcdaec Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 25 Jan 2026 16:04:47 +0900 Subject: [PATCH 28/29] =?UTF-8?q?[#53]=20=ED=94=84=EB=A1=9C=ED=95=84=20DI?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyProfile/Presentation/View/ProfileSettingView.swift | 4 ++-- .../Profile/MyProfile/Presentation/View/ProfileView.swift | 4 ++-- .../OtherProfile/Presentation/View/OtherProfileView.swift | 4 ++-- .../Profile/Shared/Presentation/View/FollowListView.swift | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift index 32ee4406..60f42bda 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift @@ -14,7 +14,7 @@ struct ProfileSettingView: View { init(viewModel: ProfileSettingViewModel, navigationRouter: NavigationRouter) { self._viewModel = ObservedObject(wrappedValue: viewModel) - self.navigationRouter = navigationRouter + self._navigationRouter = ObservedObject(wrappedValue: navigationRouter) } enum Field: Hashable { @@ -190,5 +190,5 @@ struct ProfileSettingView: View { } #Preview { - ProfileSettingView(navigationRouter: NavigationRouter()) + EmptyView() } diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift index 82a4e163..614deabb 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -14,7 +14,7 @@ struct ProfileView: View { init(viewModel: ProfileViewModel, navigationRouter: NavigationRouter) { self._viewModel = ObservedObject(wrappedValue: viewModel) - self.navigationRouter = navigationRouter + self._navigationRouter = ObservedObject(wrappedValue: navigationRouter) } var body: some View { @@ -215,5 +215,5 @@ struct ProfileView: View { } #Preview { - ProfileView(navigationRouter: NavigationRouter()) + EmptyView() } diff --git a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift index ae05e20a..c8f61772 100644 --- a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift +++ b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift @@ -14,7 +14,7 @@ struct OtherProfileView: View { init(viewModel: OtherProfileViewModel, navigationRouter: NavigationRouter) { self._viewModel = ObservedObject(wrappedValue: viewModel) - self.navigationRouter = navigationRouter + self._navigationRouter = ObservedObject(wrappedValue: navigationRouter) } var body: some View { @@ -218,5 +218,5 @@ struct OtherProfileView: View { } #Preview { - OtherProfileView(navigationRouter: NavigationRouter()) + EmptyView() } diff --git a/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift b/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift index 410e670a..d2b90253 100644 --- a/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift +++ b/Codive/Features/Profile/Shared/Presentation/View/FollowListView.swift @@ -13,7 +13,7 @@ struct FollowListView: View { init(viewModel: FollowListViewModel, navigationRouter: NavigationRouter) { self._viewModel = ObservedObject(wrappedValue: viewModel) - self.navigationRouter = navigationRouter + self._navigationRouter = ObservedObject(wrappedValue: navigationRouter) } var body: some View { From 62fb30a0b299d46523779e005c83d49984c63899 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 25 Jan 2026 17:21:26 +0900 Subject: [PATCH 29/29] =?UTF-8?q?[#53]=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/DIContainer/ProfileDIContainer.swift | 10 +- .../Data/DataSources/ProfileDataSource.swift | 15 ++ .../MyProfile/Data/ProfileAPIService.swift | 133 ++++++++++++++++++ .../Repositories/ProfileRepositoryImpl.swift | 14 ++ .../Domain/Protocols/ProfileRepository.swift | 5 + .../UseCases/UpdateProfileUseCase.swift | 46 ++++++ .../View/ProfileSettingView.swift | 104 +++++++++++--- .../ViewModel/ProfileSettingViewModel.swift | 130 +++++++++++++++-- Project.swift | 3 +- 9 files changed, 432 insertions(+), 28 deletions(-) create mode 100644 Codive/Features/Profile/MyProfile/Domain/UseCases/UpdateProfileUseCase.swift diff --git a/Codive/DIContainer/ProfileDIContainer.swift b/Codive/DIContainer/ProfileDIContainer.swift index 9fdd4b29..5db9497c 100644 --- a/Codive/DIContainer/ProfileDIContainer.swift +++ b/Codive/DIContainer/ProfileDIContainer.swift @@ -42,6 +42,10 @@ final class ProfileDIContainer { return DefaultFetchFollowsUseCase(repository: profileRepository) } + func makeUpdateProfileUseCase() -> UpdateProfileUseCase { + return DefaultUpdateProfileUseCase(repository: profileRepository) + } + // MARK: - ViewModels func makeProfileViewModel() -> ProfileViewModel { return ProfileViewModel( @@ -59,7 +63,11 @@ final class ProfileDIContainer { } func makeProfileSettingViewModel() -> ProfileSettingViewModel { - return ProfileSettingViewModel(navigationRouter: navigationRouter) + return ProfileSettingViewModel( + navigationRouter: navigationRouter, + updateProfileUseCase: makeUpdateProfileUseCase(), + profileRepository: profileRepository + ) } func makeOtherProfileViewModel() -> OtherProfileViewModel { diff --git a/Codive/Features/Profile/MyProfile/Data/DataSources/ProfileDataSource.swift b/Codive/Features/Profile/MyProfile/Data/DataSources/ProfileDataSource.swift index aed78a1f..f2fd3a05 100644 --- a/Codive/Features/Profile/MyProfile/Data/DataSources/ProfileDataSource.swift +++ b/Codive/Features/Profile/MyProfile/Data/DataSources/ProfileDataSource.swift @@ -10,6 +10,9 @@ import Foundation protocol ProfileDataSourceProtocol { func fetchMyProfile() async throws -> MyProfileInfo func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult + func updateProfile(nickname: String, bio: String, isPublic: Bool, currentImageUrl: String?) async throws -> MyProfileInfo + func checkNicknameDuplicate(nickname: String) async throws -> Bool + func uploadProfileImage(_ imageData: Data) async throws -> String } final class ProfileDataSource: ProfileDataSourceProtocol { @@ -26,4 +29,16 @@ final class ProfileDataSource: ProfileDataSourceProtocol { func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult { return try await apiService.fetchFollows(memberId: memberId, isFollowing: isFollowing, lastFollowId: lastFollowId, size: size) } + + func updateProfile(nickname: String, bio: String, isPublic: Bool, currentImageUrl: String?) async throws -> MyProfileInfo { + return try await apiService.updateProfile(nickname: nickname, bio: bio, isPublic: isPublic, currentImageUrl: currentImageUrl) + } + + func checkNicknameDuplicate(nickname: String) async throws -> Bool { + return try await apiService.checkNicknameDuplicate(nickname: nickname) + } + + func uploadProfileImage(_ imageData: Data) async throws -> String { + return try await apiService.uploadProfileImage(imageData) + } } diff --git a/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift index 5967d1cd..bcd2a836 100644 --- a/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift +++ b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift @@ -8,12 +8,16 @@ import Foundation import CodiveAPI import OpenAPIRuntime +import CryptoKit // MARK: - Profile API Service Protocol protocol ProfileAPIServiceProtocol { func fetchMyProfile() async throws -> MyProfileInfo func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult + func updateProfile(nickname: String, bio: String, isPublic: Bool, currentImageUrl: String?) async throws -> MyProfileInfo + func checkNicknameDuplicate(nickname: String) async throws -> Bool + func uploadProfileImage(_ imageData: Data) async throws -> String } // MARK: - Profile API Service Implementation @@ -105,6 +109,129 @@ final class ProfileAPIService: ProfileAPIServiceProtocol { throw ProfileAPIError.serverError(statusCode: code, message: "팔로우 목록 조회 실패") } } + + func updateProfile(nickname: String, bio: String, isPublic: Bool, currentImageUrl: String?) async throws -> MyProfileInfo { + let visibility: Components.Schemas.ProfileUpdateRequest.visibilityPayload = isPublic ? .PUBLIC : .PRIVATE + + let requestBody = Components.Schemas.ProfileUpdateRequest( + nickname: nickname, + bio: bio, + visibility: visibility, + profileImageUrl: currentImageUrl ?? "", + profileBackImageUrl: "" + ) + + let response = try await client.Member_updateProfile( + .init(body: .json(requestBody)) + ) + + switch response { + case .ok: + // 204 응답은 result가 없으므로, 수정된 정보를 다시 fetch + return try await fetchMyProfile() + + case .undocumented(statusCode: let code, _): + throw ProfileAPIError.serverError(statusCode: code, message: "프로필 수정 실패 (상태코드: \(code))") + } + } + + func checkNicknameDuplicate(nickname: String) async throws -> Bool { + let requestBody = Components.Schemas.DuplicatedIdCheckRequest(nickname: nickname) + + let response = try await client.Member_checkDuplicateNickname( + .init(body: .json(requestBody)) + ) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseDuplicatedIdCheckResponse.self, + from: data + ) + + // result.duplicated가 true면 중복된 것 (사용 불가) + return apiResponse.result?.duplicated ?? false + + case .undocumented(statusCode: let code, _): + throw ProfileAPIError.serverError(statusCode: code, message: "닉네임 중복확인 실패 (상태코드: \(code))") + } + } + + func uploadProfileImage(_ imageData: Data) async throws -> String { + let md5Hash = calculateMD5(from: imageData) + let uploadPayload = Components.Schemas.ClothImagesUploadRequestPayload(fileExtension: .JPEG, md5Hashes: md5Hash) + let requestBody = Components.Schemas.ClothImagesUploadRequest(payloads: [uploadPayload]) + + let response = try await client.Cloth_getClothUploadPresignedUrl( + .init(body: .json(requestBody)) + ) + + switch response { + case .ok(let okResponse): + let data = try await Data(collecting: okResponse.body.any, upTo: .max) + + let apiResponse = try jsonDecoder.decode( + Components.Schemas.BaseResponseClothImagesPresignedUrlResponse.self, + from: data + ) + + guard let urls = apiResponse.result?.urls, !urls.isEmpty else { + throw ProfileAPIError.invalidResponse + } + + let presignedUrl = urls[0] + + // S3에 직접 업로드 + try await uploadImageToS3(presignedUrl: presignedUrl, imageData: imageData, contentMD5: md5Hash) + + return extractFinalUrl(from: presignedUrl) + + case .undocumented(statusCode: let code, _): + throw ProfileAPIError.serverError(statusCode: code, message: "이미지 업로드 실패 (상태코드: \(code))") + } + } + + private func uploadImageToS3(presignedUrl: String, imageData: Data, contentMD5: String) async throws { + guard let url = URL(string: presignedUrl) else { + throw ProfileAPIError.invalidUrl + } + + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type") + request.setValue(contentMD5, forHTTPHeaderField: "Content-MD5") + request.httpBody = imageData + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ProfileAPIError.invalidResponse + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw ProfileAPIError.s3UploadFailed(statusCode: httpResponse.statusCode) + } + } +} + +// MARK: - Private Helpers + +private extension ProfileAPIService { + func calculateMD5(from data: Data) -> String { + let digest = Insecure.MD5.hash(data: data) + return Data(digest).base64EncodedString() + } + + func extractFinalUrl(from presignedUrl: String) -> String { + guard let url = URL(string: presignedUrl), + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return presignedUrl + } + components.query = nil + return components.string ?? presignedUrl + } } // MARK: - Profile API Error @@ -112,6 +239,8 @@ final class ProfileAPIService: ProfileAPIServiceProtocol { enum ProfileAPIError: LocalizedError { case serverError(statusCode: Int, message: String) case invalidResponse + case invalidUrl + case s3UploadFailed(statusCode: Int) var errorDescription: String? { switch self { @@ -119,6 +248,10 @@ enum ProfileAPIError: LocalizedError { return "서버 오류 (\(statusCode)): \(message)" case .invalidResponse: return "올바르지 않은 응답 형식입니다" + case .invalidUrl: + return "유효하지 않은 URL입니다" + case .s3UploadFailed(let statusCode): + return "S3 업로드 실패 (상태 코드: \(statusCode))" } } } diff --git a/Codive/Features/Profile/MyProfile/Data/Repositories/ProfileRepositoryImpl.swift b/Codive/Features/Profile/MyProfile/Data/Repositories/ProfileRepositoryImpl.swift index 8803cba3..2f3b3899 100644 --- a/Codive/Features/Profile/MyProfile/Data/Repositories/ProfileRepositoryImpl.swift +++ b/Codive/Features/Profile/MyProfile/Data/Repositories/ProfileRepositoryImpl.swift @@ -5,6 +5,8 @@ // Created by Claude on 1/25/26. // +import Foundation + final class ProfileRepositoryImpl: ProfileRepository { private let dataSource: ProfileDataSourceProtocol @@ -19,4 +21,16 @@ final class ProfileRepositoryImpl: ProfileRepository { func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult { return try await dataSource.fetchFollows(memberId: memberId, isFollowing: isFollowing, lastFollowId: lastFollowId, size: size) } + + func updateProfile(nickname: String, bio: String, isPublic: Bool, currentImageUrl: String?) async throws -> MyProfileInfo { + return try await dataSource.updateProfile(nickname: nickname, bio: bio, isPublic: isPublic, currentImageUrl: currentImageUrl) + } + + func checkNicknameDuplicate(nickname: String) async throws -> Bool { + return try await dataSource.checkNicknameDuplicate(nickname: nickname) + } + + func uploadProfileImage(_ imageData: Data) async throws -> String { + return try await dataSource.uploadProfileImage(imageData) + } } diff --git a/Codive/Features/Profile/MyProfile/Domain/Protocols/ProfileRepository.swift b/Codive/Features/Profile/MyProfile/Domain/Protocols/ProfileRepository.swift index a88910c5..a5f69486 100644 --- a/Codive/Features/Profile/MyProfile/Domain/Protocols/ProfileRepository.swift +++ b/Codive/Features/Profile/MyProfile/Domain/Protocols/ProfileRepository.swift @@ -5,7 +5,12 @@ // Created by Claude on 1/25/26. // +import Foundation + protocol ProfileRepository { func fetchMyProfile() async throws -> MyProfileInfo func fetchFollows(memberId: Int, isFollowing: Bool, lastFollowId: Int64?, size: Int32) async throws -> FollowListResult + func updateProfile(nickname: String, bio: String, isPublic: Bool, currentImageUrl: String?) async throws -> MyProfileInfo + func checkNicknameDuplicate(nickname: String) async throws -> Bool + func uploadProfileImage(_ imageData: Data) async throws -> String } diff --git a/Codive/Features/Profile/MyProfile/Domain/UseCases/UpdateProfileUseCase.swift b/Codive/Features/Profile/MyProfile/Domain/UseCases/UpdateProfileUseCase.swift new file mode 100644 index 00000000..e1cfb197 --- /dev/null +++ b/Codive/Features/Profile/MyProfile/Domain/UseCases/UpdateProfileUseCase.swift @@ -0,0 +1,46 @@ +// +// UpdateProfileUseCase.swift +// Codive +// +// Created by Claude on 1/25/26. +// + +import Foundation + +protocol UpdateProfileUseCase { + func execute( + nickname: String, + bio: String, + isPublic: Bool, + imageData: Data? + ) async throws -> MyProfileInfo +} + +final class DefaultUpdateProfileUseCase: UpdateProfileUseCase { + private let repository: ProfileRepository + + init(repository: ProfileRepository) { + self.repository = repository + } + + func execute( + nickname: String, + bio: String, + isPublic: Bool, + imageData: Data? + ) async throws -> MyProfileInfo { + // 이미지가 있으면 먼저 업로드 + var imageUrlToUpdate: String? = nil + if let imageData = imageData { + imageUrlToUpdate = try await repository.uploadProfileImage(imageData) + } + + // 프로필 수정 + return try await repository.updateProfile( + nickname: nickname, + bio: bio, + isPublic: isPublic, + currentImageUrl: imageUrlToUpdate + ) + } +} diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift index 60f42bda..84092c44 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileSettingView.swift @@ -7,6 +7,7 @@ import SwiftUI import Combine +import PhotosUI struct ProfileSettingView: View { @ObservedObject private var navigationRouter: NavigationRouter @@ -32,8 +33,30 @@ struct ProfileSettingView: View { rightButton: .none ) + if viewModel.isLoadingProfile { + VStack { + ProgressView() + .tint(.black) + Text("프로필 정보 로딩 중...") + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.grayscale4) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView(showsIndicators: false) { VStack(spacing: 0) { + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .font(.codive_body3_medium) + .foregroundStyle(Color.Codive.point1) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color.Codive.point4) + .cornerRadius(8) + .padding(.top, 16) + } + profileImageSection .padding(.top, 32) @@ -43,31 +66,31 @@ struct ProfileSettingView: View { completeButton .padding(.top, 120) } + } + .opacity(viewModel.isLoading ? 0.5 : 1) + .disabled(viewModel.isLoading) } } .background(Color.white) .navigationBarHidden(true) + .onAppear { + Task { + await viewModel.loadCurrentProfile() + } + } } private var profileImageSection: some View { ZStack(alignment: .bottomTrailing) { - Group { - if let pickedProfileImage = viewModel.pickedProfileImage { - pickedProfileImage - .resizable() - .scaledToFill() - } else { - Image("Profile") - .resizable() - .scaledToFill() - } - } - .frame(width: 100, height: 100) - .clipShape(Circle()) + profileImageContent + .frame(width: 100, height: 100) + .clipShape(Circle()) - Button { - viewModel.onProfileImageTapped() - } label: { + PhotosPicker( + selection: $viewModel.selectedPhotoPickerItem, + matching: .images, + photoLibrary: .shared() + ) { Circle() .fill(Color.Codive.grayscale1) .frame(width: 28, height: 28) @@ -78,6 +101,11 @@ struct ProfileSettingView: View { } .buttonStyle(.plain) .offset(x: 6, y: 6) + .onChange(of: viewModel.selectedPhotoPickerItem) { newValue in + Task { + await viewModel.handlePhotoSelection(newValue) + } + } } .frame(maxWidth: .infinity) } @@ -138,6 +166,46 @@ struct ProfileSettingView: View { .padding(.horizontal, 20) } + private var profileImageContent: some View { + if let pickedProfileImage = viewModel.pickedProfileImage { + // 사용자가 방금 선택한 이미지 + return AnyView( + pickedProfileImage + .resizable() + .scaledToFill() + ) + } else if let imageUrl = viewModel.currentProfileImageUrl, !imageUrl.isEmpty { + // 저장된 프로필 이미지 URL + return AnyView( + AsyncImage(url: URL(string: imageUrl)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + case .empty: + ProgressView() + case .failure: + Image("Profile") + .resizable() + .scaledToFill() + @unknown default: + Image("Profile") + .resizable() + .scaledToFill() + } + } + ) + } else { + // 기본 이미지 (프로필 이미지가 없을 때) + return AnyView( + Image("Profile") + .resizable() + .scaledToFill() + ) + } + } + private var privacySection: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 6) { @@ -179,9 +247,9 @@ struct ProfileSettingView: View { private var completeButton: some View { CustomButton( - text: "설정 완료", + text: viewModel.isLoading ? "저장 중..." : "설정 완료", widthType: .fixed, - isEnabled: viewModel.canComplete + isEnabled: viewModel.canComplete && !viewModel.isLoading ) { viewModel.onCompleteTapped() } diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift index 1e1e4ed9..7d21c73d 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift @@ -7,6 +7,9 @@ import SwiftUI import Combine +import Photos +import PhotosUI +import UIKit @MainActor final class ProfileSettingViewModel: ObservableObject { @@ -16,6 +19,8 @@ final class ProfileSettingViewModel: ObservableObject { // MARK: - Dependencies private let navigationRouter: NavigationRouter + private let updateProfileUseCase: UpdateProfileUseCase + private let profileRepository: ProfileRepository // MARK: - Published Properties @Published var nickname: String = "" { @@ -61,12 +66,21 @@ final class ProfileSettingViewModel: ObservableObject { } @Published var pickedProfileImage: Image? = nil + @Published var selectedPhotoPickerItem: PhotosPickerItem? = nil + @Published var isLoading: Bool = false + @Published var errorMessage: String? = nil + @Published var currentProfileImageUrl: String? = nil @Published private(set) var canComplete: Bool = false + @Published var isLoadingProfile: Bool = false + + private var selectedImageData: Data? = nil // MARK: - Initializer - init(navigationRouter: NavigationRouter) { + init(navigationRouter: NavigationRouter, updateProfileUseCase: UpdateProfileUseCase, profileRepository: ProfileRepository) { self.navigationRouter = navigationRouter + self.updateProfileUseCase = updateProfileUseCase + self.profileRepository = profileRepository updateCanComplete() } @@ -124,21 +138,121 @@ final class ProfileSettingViewModel: ObservableObject { func runNicknameDuplicateCheck() { nicknameCheckStatus = .checking - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - let lowered = self.nickname.lowercased() - if lowered == "trendbox" || lowered == "ckj11" { - self.nicknameCheckStatus = .duplicated - } else { + Task { + do { + let isDuplicated = try await profileRepository.checkNicknameDuplicate(nickname: nickname) + DispatchQueue.main.async { + self.nicknameCheckStatus = isDuplicated ? .duplicated : .available + } + } catch { + DispatchQueue.main.async { + self.errorMessage = "닉네임 중복확인 실패: \(error.localizedDescription)" + self.nicknameCheckStatus = .none + } + } + } + } + + func loadCurrentProfile() async { + isLoadingProfile = true + + do { + let profileInfo = try await profileRepository.fetchMyProfile() + DispatchQueue.main.async { + self.nickname = profileInfo.nickname + self.intro = profileInfo.introduction ?? "" + self.isPublic = true // API에서 공개여부 정보가 있으면 적용 self.nicknameCheckStatus = .available + + // 프로필 이미지 URL 저장 (null이면 기본 이미지 사용) + self.currentProfileImageUrl = profileInfo.profileImageUrl + + self.updateCanComplete() + self.isLoadingProfile = false + } + } catch { + DispatchQueue.main.async { + self.errorMessage = "프로필 정보 로드 실패: \(error.localizedDescription)" + self.isLoadingProfile = false } } } func onProfileImageTapped() { - print("Profile image tapped") + requestPhotoLibraryAccess() + } + + private func requestPhotoLibraryAccess() { + PHPhotoLibrary.requestAuthorization { [weak self] status in + DispatchQueue.main.async { + switch status { + case .authorized, .limited: + self?.selectedPhotoPickerItem = nil + case .denied, .restricted: + self?.openAppSettings() + case .notDetermined: + break + @unknown default: + break + } + } + } + } + + private func openAppSettings() { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + + func handlePhotoSelection(_ item: PhotosPickerItem?) async { + guard let item = item else { return } + + do { + if let data = try await item.loadTransferable(type: Data.self) { + // 선택한 이미지 미리보기 표시 + if let uiImage = UIImage(data: data) { + DispatchQueue.main.async { + self.selectedImageData = data + self.pickedProfileImage = Image(uiImage: uiImage) + self.errorMessage = nil + } + } + } + } catch { + DispatchQueue.main.async { + self.errorMessage = "이미지 선택 실패: \(error.localizedDescription)" + } + } } func onCompleteTapped() { - navigationRouter.navigateBack() + Task { + await submitProfileUpdate() + } + } + + private func submitProfileUpdate() async { + isLoading = true + errorMessage = nil + + defer { + isLoading = false + } + + do { + let _ = try await updateProfileUseCase.execute( + nickname: nickname, + bio: intro, + isPublic: isPublic, + imageData: selectedImageData + ) + + DispatchQueue.main.async { + self.navigationRouter.navigateBack() + } + } catch { + errorMessage = "프로필 수정 실패: \(error.localizedDescription)" + } } } diff --git a/Project.swift b/Project.swift index 167f9397..900b0fa4 100644 --- a/Project.swift +++ b/Project.swift @@ -107,7 +107,8 @@ let project = Project( "storykompassauth", "kakaolink", "kakaotalk-5.9.7" - ] + ], + ] ), sources: [