From e3356d74acb5431891288f352a4fb423b5b6ee19 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 31 Jan 2026 19:39:16 +0900 Subject: [PATCH 01/28] =?UTF-8?q?[#57]=20Cloth=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20API=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Closet/Data/ClothAPIService.swift | 4 ++-- .../Features/Profile/MyProfile/Data/ProfileAPIService.swift | 5 ++--- Codive/Features/Search/Data/SearchAPIService.swift | 4 ++-- Tuist/Package.resolved | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/Codive/Features/Closet/Data/ClothAPIService.swift b/Codive/Features/Closet/Data/ClothAPIService.swift index 5aba1cbc..e5ad36f0 100644 --- a/Codive/Features/Closet/Data/ClothAPIService.swift +++ b/Codive/Features/Closet/Data/ClothAPIService.swift @@ -99,8 +99,8 @@ extension ClothAPIService { } let requestBody = Components.Schemas.ClothImagesUploadRequest(payloads: payloads.map { $0.payload }) - let input = Operations.Cloth_getClothUploadPresignedUrl.Input(body: .json(requestBody)) - let response = try await client.Cloth_getClothUploadPresignedUrl(input) + let input = Operations.ClothAi_getClothUploadPresignedUrl.Input(body: .json(requestBody)) + let response = try await client.ClothAi_getClothUploadPresignedUrl(input) switch response { case .ok(let okResponse): diff --git a/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift index 102efd18..6f3c897f 100644 --- a/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift +++ b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift @@ -117,8 +117,7 @@ final class ProfileAPIService: ProfileAPIServiceProtocol { nickname: nickname, bio: bio, visibility: visibility, - profileImageUrl: currentImageUrl ?? "", - profileBackImageUrl: "" + profileImageUrl: currentImageUrl ?? "" ) let response = try await client.Member_updateProfile( @@ -164,7 +163,7 @@ final class ProfileAPIService: ProfileAPIServiceProtocol { let uploadPayload = Components.Schemas.ClothImagesUploadRequestPayload(fileExtension: .JPEG, md5Hashes: md5Hash) let requestBody = Components.Schemas.ClothImagesUploadRequest(payloads: [uploadPayload]) - let response = try await client.Cloth_getClothUploadPresignedUrl( + let response = try await client.ClothAi_getClothUploadPresignedUrl( .init(body: .json(requestBody)) ) diff --git a/Codive/Features/Search/Data/SearchAPIService.swift b/Codive/Features/Search/Data/SearchAPIService.swift index 0f96a5cc..5a3ad3a1 100644 --- a/Codive/Features/Search/Data/SearchAPIService.swift +++ b/Codive/Features/Search/Data/SearchAPIService.swift @@ -45,7 +45,7 @@ final class SearchAPIService: SearchAPIServiceProtocol { // MARK: - Search Users func searchUsers(keyword: String, page: Int64, size: Int32) async throws -> SearchUserResult { - let input = Operations.Search_searchUserByClokeyIdAndNickname.Input( + let input = Operations.Search_searchUserByNickname.Input( query: .init( keyword: keyword, page: page, @@ -53,7 +53,7 @@ final class SearchAPIService: SearchAPIServiceProtocol { ) ) - let response = try await client.Search_searchUserByClokeyIdAndNickname(input) + let response = try await client.Search_searchUserByNickname(input) switch response { case .ok(let okResponse): diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index a81f3cae..35b31219 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -16,7 +16,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "dfde09be34ac830a9efc93efd52296ff61c7fda2" + "revision" : "7c2b9056be470366810b6db812ce4f46ea409b5b" } }, { From b802ab24ad0c0842626aefc4b11f96fed362db71 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 31 Jan 2026 20:01:35 +0900 Subject: [PATCH 02/28] =?UTF-8?q?[#57]=20=EB=82=B4=EC=A0=95=EB=B3=B4=20Ema?= =?UTF-8?q?il=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/DIContainer/AppDIContainer.swift | 3 ++- Codive/DIContainer/ProfileDIContainer.swift | 6 +++++- Codive/DIContainer/SettingDIContainer.swift | 9 ++++++--- .../MyProfile/Data/ProfileAPIService.swift | 20 ++++++++++++++++--- .../Domain/Entities/ProfileEntity.swift | 1 + .../ViewModel/ProfileViewModel.swift | 2 ++ .../Presentation/View/SettingView.swift | 4 +++- .../ViewModel/SettingViewModel.swift | 8 +++++++- 8 files changed, 43 insertions(+), 10 deletions(-) diff --git a/Codive/DIContainer/AppDIContainer.swift b/Codive/DIContainer/AppDIContainer.swift index 8794f4f2..0c1d60bb 100644 --- a/Codive/DIContainer/AppDIContainer.swift +++ b/Codive/DIContainer/AppDIContainer.swift @@ -17,6 +17,7 @@ final class AppDIContainer { // MARK: - Domain DIContainers lazy var sharedDIContainer = SharedDIContainer() lazy var closetDIContainer = ClosetDIContainer(navigationRouter: navigationRouter) + lazy var profileDIContainer = ProfileDIContainer(navigationRouter: navigationRouter) // MARK: - Feature DIContainers func makeAuthDIContainer() -> AuthDIContainer { @@ -36,7 +37,7 @@ final class AppDIContainer { } func makeSettingDIContainer() -> SettingDIContainer { - return SettingDIContainer(appRouter: appRouter, navigationRouter: navigationRouter) + return SettingDIContainer(appRouter: appRouter, navigationRouter: navigationRouter, profileDIContainer: profileDIContainer) } func makeReportDIContainer() -> ReportDIContainer { diff --git a/Codive/DIContainer/ProfileDIContainer.swift b/Codive/DIContainer/ProfileDIContainer.swift index 7b2625ad..31b3827b 100644 --- a/Codive/DIContainer/ProfileDIContainer.swift +++ b/Codive/DIContainer/ProfileDIContainer.swift @@ -47,11 +47,15 @@ final class ProfileDIContainer { } // MARK: - ViewModels - func makeProfileViewModel() -> ProfileViewModel { + private lazy var profileViewModel: ProfileViewModel = { return ProfileViewModel( navigationRouter: navigationRouter, fetchMyProfileUseCase: makeFetchMyProfileUseCase() ) + }() + + func makeProfileViewModel() -> ProfileViewModel { + return profileViewModel } func makeFollowListViewModel(mode: FollowListMode, memberId: Int) -> FollowListViewModel { diff --git a/Codive/DIContainer/SettingDIContainer.swift b/Codive/DIContainer/SettingDIContainer.swift index 5cc147cf..8e6ce92d 100644 --- a/Codive/DIContainer/SettingDIContainer.swift +++ b/Codive/DIContainer/SettingDIContainer.swift @@ -14,17 +14,19 @@ final class SettingDIContainer { // MARK: - Dependencies private let appRouter: AppRouter private let navigationRouter: NavigationRouter + private let profileDIContainer: ProfileDIContainer // ViewFactory lazy var settingViewFactory = SettingViewFactory(settingDIContainer: self) - + // Data / Repository private let repository: SettingRepository // MARK: - Init - init(appRouter: AppRouter, navigationRouter: NavigationRouter) { + init(appRouter: AppRouter, navigationRouter: NavigationRouter, profileDIContainer: ProfileDIContainer) { self.appRouter = appRouter self.navigationRouter = navigationRouter + self.profileDIContainer = profileDIContainer let dataSource = SettingsDataSource() let repo = SettingsRepositoryImpl(dataSource: dataSource) @@ -66,7 +68,8 @@ final class SettingDIContainer { appRouter: appRouter, navigationRouter: navigationRouter, getPrefsUC: makeGetNotificationPrefsUseCase(), - updatePrefsUC: makeUpdateNotificationPrefsUseCase() + updatePrefsUC: makeUpdateNotificationPrefsUseCase(), + profileViewModel: profileDIContainer.makeProfileViewModel() ) } diff --git a/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift index 6f3c897f..4f95bcde 100644 --- a/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift +++ b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift @@ -40,9 +40,9 @@ final class ProfileAPIService: ProfileAPIServiceProtocol { case .ok(let okResponse): let data = try await Data(collecting: okResponse.body.any, upTo: .max) - // API 응답을 BaseResponseMemberInfoResponse로 decode + // API 응답을 BaseResponseMyInfoResponse로 decode let apiResponse = try jsonDecoder.decode( - Components.Schemas.BaseResponseMemberInfoResponse.self, + Components.Schemas.BaseResponseMyInfoResponse.self, from: data ) @@ -50,6 +50,19 @@ final class ProfileAPIService: ProfileAPIServiceProtocol { throw ProfileAPIError.invalidResponse } + // API 응답 로그 + print("=== 내 정보 API 응답 ===") + print("memberId: \(memberInfo.memberId ?? 0)") + print("nickname: \(memberInfo.nickname ?? "nil")") + print("email: \(memberInfo.email ?? "nil")") + print("bio: \(memberInfo.bio ?? "nil")") + print("followerCount: \(memberInfo.followerCount ?? 0)") + print("followingCount: \(memberInfo.followingCount ?? 0)") + print("profileImageUrl: \(memberInfo.profileImageUrl ?? "nil")") + print("isPublic: \(memberInfo.isPublic ?? false)") + print("isMe: \(memberInfo.isMe ?? false)") + print("======================") + guard let userId = memberInfo.memberId, let nickname = memberInfo.nickname else { throw ProfileAPIError.invalidResponse @@ -62,7 +75,8 @@ final class ProfileAPIService: ProfileAPIServiceProtocol { introduction: memberInfo.bio, profileImageUrl: memberInfo.profileImageUrl, followerCount: Int(memberInfo.followerCount ?? 0), - followingCount: Int(memberInfo.followingCount ?? 0) + followingCount: Int(memberInfo.followingCount ?? 0), + email: memberInfo.email ) case .undocumented(statusCode: let code, _): diff --git a/Codive/Features/Profile/MyProfile/Domain/Entities/ProfileEntity.swift b/Codive/Features/Profile/MyProfile/Domain/Entities/ProfileEntity.swift index e797aea6..872d6262 100644 --- a/Codive/Features/Profile/MyProfile/Domain/Entities/ProfileEntity.swift +++ b/Codive/Features/Profile/MyProfile/Domain/Entities/ProfileEntity.swift @@ -13,6 +13,7 @@ struct MyProfileInfo { let profileImageUrl: String? let followerCount: Int let followingCount: Int + let email: String? } struct FollowListResult { diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift index 39dd18c4..b9ad1664 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift @@ -17,6 +17,7 @@ class ProfileViewModel: ObservableObject { @Published var followerCount: Int = 0 @Published var followingCount: Int = 0 @Published var profileImageUrl: String? + @Published var email: String? // MARK: - State @Published var month: Date = Date() // 현재 표시 월 @@ -48,6 +49,7 @@ class ProfileViewModel: ObservableObject { self.followerCount = profileInfo.followerCount self.followingCount = profileInfo.followingCount self.profileImageUrl = profileInfo.profileImageUrl + self.email = profileInfo.email } catch { self.errorMessage = error.localizedDescription print("프로필 로드 실패: \(error.localizedDescription)") diff --git a/Codive/Features/Setting/Presentation/View/SettingView.swift b/Codive/Features/Setting/Presentation/View/SettingView.swift index 3c160447..c3c7efdf 100644 --- a/Codive/Features/Setting/Presentation/View/SettingView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingView.swift @@ -10,9 +10,11 @@ import SwiftUI struct SettingView: View { @ObservedObject private var vm: SettingViewModel + @ObservedObject private var profileViewModel: ProfileViewModel init(viewModel: SettingViewModel) { self.vm = viewModel + self.profileViewModel = viewModel.profileViewModel } var body: some View { @@ -54,7 +56,7 @@ struct SettingView: View { Image("kakao") .frame(width: 40, height: 40) - Text("email@xxxx.com") + Text(profileViewModel.email ?? "email@xxxx.com") .font(.codive_body1_regular) .foregroundStyle(Color.Codive.grayscale1) } diff --git a/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift index 4deac0ba..0e64528c 100644 --- a/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift +++ b/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift @@ -13,17 +13,20 @@ final class SettingViewModel: ObservableObject { private let navigationRouter: NavigationRouter private let getPrefsUC: GetNotificationPrefsUseCase private let updatePrefsUC: UpdateNotificationPrefsUseCase + let profileViewModel: ProfileViewModel init( appRouter: AppRouter, navigationRouter: NavigationRouter, getPrefsUC: GetNotificationPrefsUseCase, - updatePrefsUC: UpdateNotificationPrefsUseCase + updatePrefsUC: UpdateNotificationPrefsUseCase, + profileViewModel: ProfileViewModel ) { self.appRouter = appRouter self.navigationRouter = navigationRouter self.getPrefsUC = getPrefsUC self.updatePrefsUC = updatePrefsUC + self.profileViewModel = profileViewModel } // 초기 로드 @@ -32,6 +35,9 @@ final class SettingViewModel: ObservableObject { error = nil do { + // 프로필 정보 로드 + await profileViewModel.loadMyProfile() + let prefs = try await getPrefsUC.fetch() isPushOn = prefs.pushEnabled isMarketingOn = prefs.marketingOptIn From b9a544daa461c45b333e333c156c9833b8b64c9d Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 31 Jan 2026 20:07:33 +0900 Subject: [PATCH 03/28] =?UTF-8?q?[#57]=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20md=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 89 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..94f4ea4f --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,89 @@ +# Architecture Documentation + +이 문서는 **Codive-iOS** 프로젝트의 아키텍처 및 디자인 패턴에 대한 개요를 제공합니다. +이 프로젝트는 **SwiftUI**를 기반으로 하며, **MVVM (Model-View-ViewModel)** 패턴과 **Clean Architecture** 원칙을 따르고 있습니다. 또한 **DI Container**를 통한 의존성 주입과 **Router** 패턴을 통한 화면 전환 처리를 적용하여 모듈 간의 결합도를 낮추고 유지보수성을 높였습니다. + +--- + +## 🏗 Architectural Overview + +전체적인 구조는 **Clean Architecture**의 계층화된 접근 방식을 따르며, 데이터의 흐름은 단방향으로 관리됩니다. + +### Core Principles +1. **관심사의 분리 (Separation of Concerns):** 각 레이어는 명확한 역할을 가지며 서로 독립적으로 동작합니다. +2. **의존성 규칙 (Dependency Rule):** 의존성은 항상 **안쪽(Domain Layer)**을 향해야 합니다. Presentation이나 Data 레이어는 Domain 레이어를 알지만, Domain 레이어는 외부 레이어를 알지 못합니다. +3. **테스트 용이성 (Testability):** 비즈니스 로직(Domain)은 UI나 프레임워크와 분리되어 있어 독립적으로 테스트가 가능합니다. + +--- + +## 📂 Layer Structure (계층 구조) + +각 Feature(기능)는 다음과 같은 3개의 주요 레이어로 구성됩니다. + +### 1. Domain Layer (Inner Circle) +가장 안쪽에 위치하며, 비즈니스 로직을 담당합니다. 외부 라이브러리나 UI 프레임워크(SwiftUI, UIKit 등)에 의존하지 않는 순수 Swift 코드로 작성됩니다. +* **Entities:** 앱의 핵심 데이터 모델. +* **UseCases:** 비즈니스 로직을 실행하는 단위. Repository Interface를 사용하여 데이터를 요청합니다. +* **Interfaces (Repository Protocols):** Data Layer에서 구현해야 할 Repository의 추상화된 정의. + +### 2. Data Layer (Outer Circle) +실제 데이터 처리를 담당합니다. API 통신, 로컬 DB 접근 등을 수행하며 Domain Layer의 Repository Interface를 구현합니다. +* **Repositories (Implementation):** Domain Layer의 Repository Interface를 실제로 구현한 클래스. +* **DataSources:** Remote(API) 또는 Local(DB, UserDefaults) 데이터 소스. +* **DTOs (Data Transfer Objects):** API 응답 모델 (Domain Entity로 매핑되어 사용됨). + +### 3. Presentation Layer (Outer Circle) +사용자에게 데이터를 보여주고 입력을 받는 UI 계층입니다. +* **Views (SwiftUI):** UI를 구성하고 사용자의 입력을 받습니다. 비즈니스 로직을 직접 처리하지 않고 ViewModel에 위임합니다. +* **ViewModels:** View의 상태(State)를 관리하고, UseCase를 실행하여 데이터를 처리합니다. `@Published` 속성을 통해 View와 바인딩됩니다. + +--- + +## 🧩 Modularization (Folder Structure) + +프로젝트는 기능(Feature) 단위로 그룹화되어 있으며, 각 기능 내부는 Clean Architecture 레이어로 나뉩니다. + +``` +Codive/ +├── Application/ # 앱 진입점 및 초기 설정 (AppConfigurator, AppRootView) +├── Features/ # 기능별 모듈 +│ ├── Auth/ +│ │ ├── Domain/ # Entity, UseCase, Repository Interface +│ │ ├── Data/ # Repository Impl, DTO, API Service +│ │ └── Presentation/ # View, ViewModel +│ ├── Feed/ +│ ├── Home/ +│ └── ... +├── Shared/ # 공통 사용 모듈 (DesignSystem, Extensions, Network, Storage) +├── DIContainer/ # 의존성 주입 컨테이너 +└── Router/ # 화면 전환 및 내비게이션 로직 +``` + +--- + +## 🔌 Dependency Injection (DI) + +의존성 주입은 **DIContainer** 패턴을 사용하여 중앙에서 관리합니다. +* **AppDIContainer:** 앱 전체의 최상위 컨테이너로, 각 Feature의 DIContainer를 생성하고 관리합니다. +* **FeatureDIContainer (e.g., AuthDIContainer):** 각 기능 모듈에 필요한 UseCase, Repository, ViewModel 등의 인스턴스를 생성하고 주입합니다. +* 이를 통해 객체 간의 결합도를 낮추고 테스트 시 Mock 객체 주입을 용이하게 합니다. + +--- + +## 🚦 Navigation (Router Pattern) + +화면 전환 로직은 View에서 분리되어 **Router**와 **ViewFactory**가 담당합니다. +* **Router:** 내비게이션 스택을 관리하고 화면 전환을 수행합니다 (`AppRouter`, `NavigationRouter`). +* **ViewFactory:** 특정 화면(View)을 생성할 때 필요한 의존성(ViewModel 등)을 조립하여 View를 반환합니다. +* View는 `Router`를 통해 "어디로 갈지"만 요청하며, 실제 "어떻게 화면을 띄울지"는 Router가 처리합니다. + +--- + +## 🛠 Tech Stack & Tools + +* **Language:** Swift 6.0+ +* **UI Framework:** SwiftUI +* **Architecture:** MVVM + Clean Architecture +* **Build Tool:** Tuist +* **Networking:** CodiveAPI (swift-openapi-generator) +* **Reactive Programming:** Combine / Swift Concurrency (async/await) From 9b70ed6f54f34d9a3e6ca99837cea7cf5888defe Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 31 Jan 2026 20:18:16 +0900 Subject: [PATCH 04/28] =?UTF-8?q?[#57]=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20API=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=A0=84=ED=99=98=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/DIContainer/AppDIContainer.swift | 7 ++++++- Codive/DIContainer/SettingDIContainer.swift | 5 ++++- .../Data/Repositories/AuthRepositoryImpl.swift | 1 + .../Setting/Presentation/View/SettingView.swift | 17 ++++++++++++++++- .../ViewModel/SettingViewModel.swift | 13 +++++++++++++ Codive/Router/AppRouter.swift | 12 ++++++++++++ 6 files changed, 52 insertions(+), 3 deletions(-) diff --git a/Codive/DIContainer/AppDIContainer.swift b/Codive/DIContainer/AppDIContainer.swift index 0c1d60bb..66432de6 100644 --- a/Codive/DIContainer/AppDIContainer.swift +++ b/Codive/DIContainer/AppDIContainer.swift @@ -14,6 +14,11 @@ final class AppDIContainer { lazy var appRouter = AppRouter() lazy var navigationRouter = NavigationRouter() + init() { + // Router 간 의존성 설정 + appRouter.setNavigationRouter(navigationRouter) + } + // MARK: - Domain DIContainers lazy var sharedDIContainer = SharedDIContainer() lazy var closetDIContainer = ClosetDIContainer(navigationRouter: navigationRouter) @@ -37,7 +42,7 @@ final class AppDIContainer { } func makeSettingDIContainer() -> SettingDIContainer { - return SettingDIContainer(appRouter: appRouter, navigationRouter: navigationRouter, profileDIContainer: profileDIContainer) + return SettingDIContainer(appRouter: appRouter, navigationRouter: navigationRouter, profileDIContainer: profileDIContainer, authDIContainer: makeAuthDIContainer()) } func makeReportDIContainer() -> ReportDIContainer { diff --git a/Codive/DIContainer/SettingDIContainer.swift b/Codive/DIContainer/SettingDIContainer.swift index 8e6ce92d..ab45781b 100644 --- a/Codive/DIContainer/SettingDIContainer.swift +++ b/Codive/DIContainer/SettingDIContainer.swift @@ -15,6 +15,7 @@ final class SettingDIContainer { private let appRouter: AppRouter private let navigationRouter: NavigationRouter private let profileDIContainer: ProfileDIContainer + private let authDIContainer: AuthDIContainer // ViewFactory lazy var settingViewFactory = SettingViewFactory(settingDIContainer: self) @@ -23,10 +24,11 @@ final class SettingDIContainer { private let repository: SettingRepository // MARK: - Init - init(appRouter: AppRouter, navigationRouter: NavigationRouter, profileDIContainer: ProfileDIContainer) { + init(appRouter: AppRouter, navigationRouter: NavigationRouter, profileDIContainer: ProfileDIContainer, authDIContainer: AuthDIContainer) { self.appRouter = appRouter self.navigationRouter = navigationRouter self.profileDIContainer = profileDIContainer + self.authDIContainer = authDIContainer let dataSource = SettingsDataSource() let repo = SettingsRepositoryImpl(dataSource: dataSource) @@ -69,6 +71,7 @@ final class SettingDIContainer { navigationRouter: navigationRouter, getPrefsUC: makeGetNotificationPrefsUseCase(), updatePrefsUC: makeUpdateNotificationPrefsUseCase(), + authRepository: authDIContainer.authRepository, profileViewModel: profileDIContainer.makeProfileViewModel() ) } diff --git a/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift b/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift index 11e89e43..8d86ddcb 100644 --- a/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift +++ b/Codive/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift @@ -44,6 +44,7 @@ final class AuthRepositoryImpl: AuthRepository { func logout() async { await socialAuthService.logout() + try? KeychainManager.shared.clearAllTokens() } func saveTokens(accessToken: String, refreshToken: String) async throws { diff --git a/Codive/Features/Setting/Presentation/View/SettingView.swift b/Codive/Features/Setting/Presentation/View/SettingView.swift index c3c7efdf..1b66aaec 100644 --- a/Codive/Features/Setting/Presentation/View/SettingView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingView.swift @@ -11,6 +11,7 @@ struct SettingView: View { @ObservedObject private var vm: SettingViewModel @ObservedObject private var profileViewModel: ProfileViewModel + @State private var showLogoutAlert = false init(viewModel: SettingViewModel) { self.vm = viewModel @@ -159,8 +160,22 @@ struct SettingView: View { SettingRow(text: TextLiteral.Setting.inquiry) .padding(.bottom, 12) - SettingRow(text: TextLiteral.Setting.logout) + Button(action: { + showLogoutAlert = true + }) { + SettingRow(text: TextLiteral.Setting.logout) + } .padding(.bottom, 12) + .alert("로그아웃", isPresented: $showLogoutAlert) { + Button("취소", role: .cancel) { } + Button("로그아웃", role: .destructive) { + Task { + await vm.logout() + } + } + } message: { + Text("정말 로그아웃하시겠습니까?") + } SettingRow(text: TextLiteral.Setting.withdraw) } diff --git a/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift index 0e64528c..ffe57a69 100644 --- a/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift +++ b/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift @@ -13,6 +13,7 @@ final class SettingViewModel: ObservableObject { private let navigationRouter: NavigationRouter private let getPrefsUC: GetNotificationPrefsUseCase private let updatePrefsUC: UpdateNotificationPrefsUseCase + private let authRepository: AuthRepository let profileViewModel: ProfileViewModel init( @@ -20,12 +21,14 @@ final class SettingViewModel: ObservableObject { navigationRouter: NavigationRouter, getPrefsUC: GetNotificationPrefsUseCase, updatePrefsUC: UpdateNotificationPrefsUseCase, + authRepository: AuthRepository, profileViewModel: ProfileViewModel ) { self.appRouter = appRouter self.navigationRouter = navigationRouter self.getPrefsUC = getPrefsUC self.updatePrefsUC = updatePrefsUC + self.authRepository = authRepository self.profileViewModel = profileViewModel } @@ -77,4 +80,14 @@ final class SettingViewModel: ObservableObject { func navigateBack() { navigationRouter.navigateBack() } + + // MARK: - Logout + func logout() async { + // Domain/Data 레이어: 토큰 삭제 및 소셜 로그아웃 + await authRepository.logout() + + // Presentation 레이어: AppRouter가 모든 네비게이션 관리 + // (Router가 navigationRouter를 소유하고 navigateToRoot + 상태 변경 수행) + appRouter.logout() + } } diff --git a/Codive/Router/AppRouter.swift b/Codive/Router/AppRouter.swift index f8c2ae59..c2d7dff5 100644 --- a/Codive/Router/AppRouter.swift +++ b/Codive/Router/AppRouter.swift @@ -23,11 +23,18 @@ final class AppRouter: ObservableObject { @Published var currentAppState: AppState @Published var isLoading: Bool = false // 로딩 상태 + private weak var navigationRouter: NavigationRouter? + init() { // 앱 시작시 스플래시부터 시작 self.currentAppState = .splash } + /// NavigationRouter 등록 (AppDIContainer에서 호출) + func setNavigationRouter(_ router: NavigationRouter) { + self.navigationRouter = router + } + func finishSplash() { // 스플래시 종료 후 인증 화면으로 이동 // TODO: 로그인 상태 확인 로직 추가 (토큰 있으면 .main) @@ -52,7 +59,12 @@ final class AppRouter: ObservableObject { isLoading = false } + /// 로그아웃: 토큰 삭제 + 모든 네비게이션 리셋 + 로그인 화면으로 func logout() { + // 1. 모든 네비게이션 스택 제거 + navigationRouter?.navigateToRoot() + + // 2. 로그인 화면으로 상태 변경 currentAppState = .auth } } From 9c410ac7044aa16e742b8d5cbf0296615b03d6c5 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 31 Jan 2026 21:44:51 +0900 Subject: [PATCH 05/28] =?UTF-8?q?[#57]=20CommentView=20UI=20=EB=B0=8F=20AP?= =?UTF-8?q?I=20=EC=97=B0=EA=B2=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/DataSources/CommentDataSource.swift | 37 ++++++++++----- .../Presentation/View/CommentView.swift | 41 +++++++++++++---- .../ViewModel/CommentViewModel.swift | 46 +++++++++++++------ Codive/Shared/Domain/Entities/Comment.swift | 14 +++++- 4 files changed, 99 insertions(+), 39 deletions(-) diff --git a/Codive/Features/Comment/Data/DataSources/CommentDataSource.swift b/Codive/Features/Comment/Data/DataSources/CommentDataSource.swift index e2eeee65..f0dee8c5 100644 --- a/Codive/Features/Comment/Data/DataSources/CommentDataSource.swift +++ b/Codive/Features/Comment/Data/DataSources/CommentDataSource.swift @@ -20,17 +20,26 @@ protocol CommentDataSource { final class DefaultCommentDataSource: CommentDataSource { private let apiClient: Client private let jsonDecoder: JSONDecoder + private var currentUser: User init() { self.apiClient = CodiveAPIProvider.createClient( middlewares: [CodiveAuthMiddleware(provider: KeychainTokenProvider())] ) self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + self.currentUser = User(id: "", nickname: "현재 사용자", profileImageUrl: nil) } init(apiClient: Client) { self.apiClient = apiClient self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + self.currentUser = User(id: "", nickname: "현재 사용자", profileImageUrl: nil) + } + + init(apiClient: Client, currentUser: User) { + self.apiClient = apiClient + self.jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + self.currentUser = currentUser } func fetchComments(feedId: Int, page: Int) async throws -> (comments: [Comment], hasNext: Bool) { @@ -95,15 +104,13 @@ final class DefaultCommentDataSource: CommentDataSource { 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: [] + replies: nil ) return newComment @@ -142,7 +149,6 @@ final class DefaultCommentDataSource: CommentDataSource { } func postReply(feedId: Int, commentId: Int, content: String) async throws -> Comment { - // 대댓글 작성용 요청 let body = Components.Schemas.CommentCreateRequest( historyId: Int64(feedId), content: content @@ -167,15 +173,13 @@ final class DefaultCommentDataSource: CommentDataSource { 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: [] + replies: nil ) return newReply @@ -188,6 +192,9 @@ final class DefaultCommentDataSource: CommentDataSource { // MARK: - Mock Implementation final class MockCommentDataSource: CommentDataSource { + private var commentIdCounter = 1000 + private var replyIdCounter = 10000 + func fetchComments(feedId: Int, page: Int) async throws -> (comments: [Comment], hasNext: Bool) { try await Task.sleep(nanoseconds: 500_000_000) @@ -201,11 +208,14 @@ final class MockCommentDataSource: CommentDataSource { func postComment(feedId: Int, content: String) async throws -> Comment { try await Task.sleep(nanoseconds: 300_000_000) + commentIdCounter += 1 let newComment = Comment( - id: Int.random(in: 100...999), + id: commentIdCounter, content: content, - author: CommentMockData.users[2], // "CurrentUser" - isMine: true + author: CommentMockData.users[2], + isMine: true, + hasReplies: false, + replyCount: 0 ) return newComment } @@ -218,11 +228,14 @@ final class MockCommentDataSource: CommentDataSource { func postReply(feedId: Int, commentId: Int, content: String) async throws -> Comment { try await Task.sleep(nanoseconds: 300_000_000) + replyIdCounter += 1 let newReply = Comment( - id: Int.random(in: 100...999), + id: replyIdCounter, content: content, author: CommentMockData.users[2], - isMine: true + isMine: true, + hasReplies: false, + replyCount: 0 ) return newReply } diff --git a/Codive/Features/Comment/Presentation/View/CommentView.swift b/Codive/Features/Comment/Presentation/View/CommentView.swift index 8fec3fd2..0e5c7e88 100644 --- a/Codive/Features/Comment/Presentation/View/CommentView.swift +++ b/Codive/Features/Comment/Presentation/View/CommentView.swift @@ -40,12 +40,13 @@ struct CommentView: View { // MARK: - Comment List ScrollView { LazyVStack(alignment: .leading, spacing: 24) { - ForEach(viewModel.comments) { comment in + ForEach(viewModel.comments, id: \.id) { comment in CommentRow( comment: comment, replyingToCommentId: viewModel.replyingToCommentId, onReplyTap: { viewModel.setReplyingTo(commentId: $0) }, - onFetchRepliesTap: { viewModel.fetchReplies(for: $0) } + onFetchRepliesTap: { viewModel.fetchReplies(for: $0) }, + onFetchAllRepliesTap: { viewModel.fetchAllReplies(for: $0) } ) } if viewModel.isLoading { @@ -164,6 +165,7 @@ struct CommentRow: View { let replyingToCommentId: Int? let onReplyTap: (Int) -> Void let onFetchRepliesTap: (Int) -> Void + let onFetchAllRepliesTap: (Int) -> Void @State private var isExpanded: Bool = false @@ -215,21 +217,25 @@ struct CommentRow: View { } // MARK: 답글 더보기/숨기기 버튼 - if comment.hasReplies { + let actualReplyCount = comment.replies?.count ?? 0 + // replyCount가 0이어도 replies가 있으면 actual count 우선 + let totalReplyCount = max(comment.replyCount ?? 0, actualReplyCount) + + if comment.hasReplies || actualReplyCount > 0 { Button(action: { withAnimation(.easeOut(duration: 0.2)) { isExpanded.toggle() - if isExpanded && (comment.replies?.isEmpty ?? true) { + if isExpanded && actualReplyCount == 0 { onFetchRepliesTap(comment.id) } } }, label: { - if let replies = comment.replies, !replies.isEmpty { - Text(isExpanded ? TextLiteral.Comment.hideReplies : TextLiteral.Comment.repliesCount(replies.count)) + if isExpanded { + Text(TextLiteral.Comment.hideReplies) .font(.codive_body2_regular) .foregroundStyle(Color.Codive.grayscale4) } else { - Text(TextLiteral.Comment.repliesCount(1)) + Text(TextLiteral.Comment.repliesCount(totalReplyCount)) .font(.codive_body2_regular) .foregroundStyle(Color.Codive.grayscale4) } @@ -249,15 +255,29 @@ struct CommentRow: View { // MARK: 답글 리스트 if isExpanded, let replies = comment.replies { VStack(alignment: .leading, spacing: 20) { - ForEach(replies) { reply in + ForEach(Array(replies.enumerated()), id: \.offset) { _, reply in CommentRow( comment: reply, isReply: true, replyingToCommentId: replyingToCommentId, onReplyTap: onReplyTap, - onFetchRepliesTap: onFetchRepliesTap + onFetchRepliesTap: onFetchRepliesTap, + onFetchAllRepliesTap: onFetchAllRepliesTap ) } + + // "N개 더보기" 버튼 + if (replies.count) < (comment.replyCount ?? 0) { + let remainingCount = (comment.replyCount ?? 0) - replies.count + Button(action: { + onFetchAllRepliesTap(comment.id) + }) { + Text("\(remainingCount)개 더보기") + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale4) + } + .padding(.top, 8) + } } .padding(.top, 10) } @@ -322,7 +342,8 @@ struct CommentRow_Previews: PreviewProvider { comment: mockComment, replyingToCommentId: nil, onReplyTap: { _ in }, - onFetchRepliesTap: { _ in } + onFetchRepliesTap: { _ in }, + onFetchAllRepliesTap: { _ in } ) .previewDisplayName("댓글 아이템") } diff --git a/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift b/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift index 516cb872..afc616d4 100644 --- a/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift +++ b/Codive/Features/Comment/Presentation/ViewModel/CommentViewModel.swift @@ -98,12 +98,11 @@ final class CommentViewModel: ObservableObject { Task { do { - _ = try await postCommentUseCase.execute(feedId: feedId, content: content) + let newComment = try await postCommentUseCase.execute(feedId: feedId, content: content) self.currentCommentText = "" - // 댓글 작성 후 목록 다시 로드 (실제 사용자 정보 반영) - self.reloadComments() + // 새 댓글을 맨 위에 추가 (전체 리로드 대신) + self.comments.insert(newComment, at: 0) } catch { - // TODO: 에러 처리 print("Error posting comment: \(error)") } } @@ -121,17 +120,22 @@ final class CommentViewModel: ObservableObject { currentReplyText = "" } - func fetchReplies(for commentId: Int, page: Int = 0) { + func fetchReplies(for commentId: Int) { guard !isReplyLoading else { return } isReplyLoading = true Task { do { - let result = try await fetchRepliesUseCase.execute(commentId: commentId, page: page) + let result = try await fetchRepliesUseCase.execute(commentId: commentId, page: 0) - // commentId와 일치하는 댓글을 찾아 대댓글을 업데이트 + // commentId와 일치하는 댓글을 찾아 대댓글을 업데이트 (최대 10개) if let index = self.comments.firstIndex(where: { $0.id == commentId }) { - self.comments[index].replies = result.replies + var updatedComments = self.comments + let loadedReplies = Array(result.replies.prefix(10)) + updatedComments[index].replies = loadedReplies + updatedComments[index].replyPage = 0 + updatedComments[index].hasMoreReplies = result.replies.count > 10 + self.comments = updatedComments } } catch { print("Error fetching replies: \(error)") @@ -140,19 +144,23 @@ final class CommentViewModel: ObservableObject { } } - func reloadReplies(for commentId: Int) { + func fetchAllReplies(for commentId: Int) { + guard !isReplyLoading else { return } isReplyLoading = true Task { do { let result = try await fetchRepliesUseCase.execute(commentId: commentId, page: 0) - // commentId와 일치하는 댓글을 찾아 대댓글을 업데이트 + // commentId와 일치하는 댓글을 찾아 모든 대댓글을 업데이트 if let index = self.comments.firstIndex(where: { $0.id == commentId }) { - self.comments[index].replies = result.replies + var updatedComments = self.comments + updatedComments[index].replies = result.replies + updatedComments[index].hasMoreReplies = false + self.comments = updatedComments } } catch { - print("Error reloading replies: \(error)") + print("Error fetching all replies: \(error)") } self.isReplyLoading = false } @@ -164,14 +172,22 @@ final class CommentViewModel: ObservableObject { Task { do { - let newReply = try await postReplyUseCase.execute(feedId: feedId, commentId: commentId, content: content) + _ = try await postReplyUseCase.execute(feedId: feedId, commentId: commentId, content: content) self.currentReplyText = "" self.replyingToCommentId = nil - // 대댓글 작성 후 해당 댓글의 replies 배열에 추가 + // 대댓글 작성 후 최신 대댓글을 다시 조회 + let result = try await fetchRepliesUseCase.execute(commentId: commentId, page: 0) + if let index = self.comments.firstIndex(where: { $0.id == commentId }) { - self.comments[index].replies?.append(newReply) + var updatedComments = self.comments + let loadedReplies = Array(result.replies.prefix(10)) + updatedComments[index].replies = loadedReplies + updatedComments[index].replyPage = 0 + updatedComments[index].hasMoreReplies = result.replies.count > 10 + updatedComments[index].replyCount = result.replies.count + self.comments = updatedComments } } catch { print("Error posting reply: \(error)") diff --git a/Codive/Shared/Domain/Entities/Comment.swift b/Codive/Shared/Domain/Entities/Comment.swift index d7dca3c9..67391db9 100644 --- a/Codive/Shared/Domain/Entities/Comment.swift +++ b/Codive/Shared/Domain/Entities/Comment.swift @@ -16,9 +16,12 @@ public struct Comment: Identifiable, Equatable { // 댓글(Parent) 전용 필드 public let hasReplies: Bool + public var replyCount: Int? // UI 상태 관리를 위한 필드 public var replies: [Comment]? + public var replyPage: Int = 0 + public var hasMoreReplies: Bool = true public init( id: Int, @@ -26,14 +29,20 @@ public struct Comment: Identifiable, Equatable { author: User, isMine: Bool, hasReplies: Bool = false, - replies: [Comment]? = nil + replyCount: Int? = nil, + replies: [Comment]? = nil, + replyPage: Int = 0, + hasMoreReplies: Bool = true ) { self.id = id self.content = content self.author = author self.isMine = isMine self.hasReplies = hasReplies + self.replyCount = replyCount self.replies = replies + self.replyPage = replyPage + self.hasMoreReplies = hasMoreReplies } } @@ -53,7 +62,8 @@ extension Comment { author: user, isMine: apiResponse.isMine ?? false, hasReplies: apiResponse.replied ?? false, - replies: [] + replyCount: apiResponse.replyCount.flatMap { Int($0) }, + replies: nil ) } From 23b70c65088d635ac1e61ff620d9c238954cce81 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 31 Jan 2026 21:47:48 +0900 Subject: [PATCH 06/28] =?UTF-8?q?[#57]=20isMine=EC=9D=B4=20true=EC=9D=BC?= =?UTF-8?q?=20=EB=95=8C=EB=A7=8C=20=EB=8D=94=EB=B3=B4=EA=B8=B0=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Comment/Presentation/View/CommentView.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Codive/Features/Comment/Presentation/View/CommentView.swift b/Codive/Features/Comment/Presentation/View/CommentView.swift index 0e5c7e88..82bb4fa4 100644 --- a/Codive/Features/Comment/Presentation/View/CommentView.swift +++ b/Codive/Features/Comment/Presentation/View/CommentView.swift @@ -245,10 +245,12 @@ struct CommentRow: View { } .frame(maxWidth: .infinity, alignment: .leading) - Button(action: {}, label: { - Image("more") - .font(.system(size: 12)) - }) + if comment.isMine { + Button(action: {}, label: { + Image("more") + .font(.system(size: 12)) + }) + } } .padding(.leading, isReply ? 40 : 0) From cca4af37afde353630ff2db8d9eb2faac5af2569 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 31 Jan 2026 21:56:31 +0900 Subject: [PATCH 07/28] =?UTF-8?q?[#57]=20=EC=9D=BC=EB=B3=84=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20isMine=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= 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/HistoryAPIService.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift index fce7d9cf..d6788b87 100644 --- a/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift +++ b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift @@ -169,7 +169,7 @@ extension HistoryDetailDTO { hashtags: hashtags, createdAt: createdAtDate, likeCount: Int(likeCount), - isLiked: nil, + isLiked: isLiked, commentCount: Int(commentCount) ) } diff --git a/Codive/Features/Feed/Data/HistoryAPIService.swift b/Codive/Features/Feed/Data/HistoryAPIService.swift index d3f9b2f8..cd2e4d37 100644 --- a/Codive/Features/Feed/Data/HistoryAPIService.swift +++ b/Codive/Features/Feed/Data/HistoryAPIService.swift @@ -26,6 +26,7 @@ struct HistoryDetailDTO { let images: [HistoryImageDTO] let likeCount: Int64 let commentCount: Int64 + let isLiked: Bool let historyDate: String? let situationId: Int64? let situationName: String? @@ -165,6 +166,7 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { } ?? [], likeCount: result.likeCount ?? 0, commentCount: result.commentCount ?? 0, + isLiked: result.isMine ?? false, historyDate: result.historyDate, situationId: result.situationId, situationName: result.situationName, From 68c2dfe4cbb1f0eab188d322c19d8a9c776fb9a7 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 31 Jan 2026 23:56:35 +0900 Subject: [PATCH 08/28] =?UTF-8?q?[#57]=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=BA=98=EB=A6=B0=EB=8D=94=EC=97=90=20?= =?UTF-8?q?=EC=9B=94=EB=B3=84=20=EA=B8=B0=EB=A1=9D=20API=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 - MonthlyHistoryItem 모델 추가 - HistoryAPIService에 fetchMonthlyHistory 메서드 추가 - ProfileViewModel에서 월별 기록 로드 및 상태 관리 - CalendarMonthView에서 각 날짜에 첫 번째 기록 이미지 표시 - ProfileView에서 monthlyHistories 바인딩 추가 --- .../Feed/Data/HistoryAPIService.swift | 41 ++++++++++++ .../Domain/Entities/MonthlyHistoryItem.swift | 14 +++++ .../Presentation/View/ProfileView.swift | 6 +- .../ViewModel/ProfileViewModel.swift | 48 +++++++++++++- .../Components/CalendarMonthView.swift | 62 +++++++++++++++---- 5 files changed, 156 insertions(+), 15 deletions(-) create mode 100644 Codive/Features/Feed/Domain/Entities/MonthlyHistoryItem.swift diff --git a/Codive/Features/Feed/Data/HistoryAPIService.swift b/Codive/Features/Feed/Data/HistoryAPIService.swift index cd2e4d37..5d2279dc 100644 --- a/Codive/Features/Feed/Data/HistoryAPIService.swift +++ b/Codive/Features/Feed/Data/HistoryAPIService.swift @@ -15,6 +15,7 @@ protocol HistoryAPIServiceProtocol { func createHistory(request: HistoryCreateAPIRequest) async throws -> Int64 func fetchHistoryDetail(historyId: Int64) async throws -> HistoryDetailDTO func fetchClothTags(historyImageId: Int64) async throws -> [ClothTagDTO] + func fetchMonthlyHistory(memberId: Int64, year: Int32, month: Int32) async throws -> [MonthlyHistoryItem] } // MARK: - History Detail DTO @@ -225,6 +226,46 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { throw HistoryAPIError.serverError(statusCode: code) } } + + // MARK: - Fetch Monthly History + + func fetchMonthlyHistory(memberId: Int64, year: Int32, month: Int32) async throws -> [MonthlyHistoryItem] { + let input = Operations.History_getMonthlyHistory.Input( + path: .init(memberId: memberId), + query: .init(year: year, month: month) + ) + + let response = try await client.History_getMonthlyHistory(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.BaseResponseMonthlyHistoryResponse.self, + from: data + ) + + guard let payloads = decoded.result?.payloads else { + throw HistoryAPIError.noData + } + + return payloads.compactMap { payload in + guard let historyId = payload.historyId, + let imageUrl = payload.firstImageUrl, + let date = payload.historyDate else { + return nil + } + return MonthlyHistoryItem( + historyId: historyId, + firstImageUrl: imageUrl, + historyDate: date + ) + } + + case .undocumented(statusCode: let code, _): + throw HistoryAPIError.serverError(statusCode: code) + } + } } // MARK: - Response Types diff --git a/Codive/Features/Feed/Domain/Entities/MonthlyHistoryItem.swift b/Codive/Features/Feed/Domain/Entities/MonthlyHistoryItem.swift new file mode 100644 index 00000000..ec958817 --- /dev/null +++ b/Codive/Features/Feed/Domain/Entities/MonthlyHistoryItem.swift @@ -0,0 +1,14 @@ +// +// MonthlyHistoryItem.swift +// Codive +// +// Created by Claude Code on 1/31/26. +// + +import Foundation + +struct MonthlyHistoryItem { + let historyId: Int64 + let firstImageUrl: String + let historyDate: String // "2026-01-21" 형식 +} diff --git a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift index 614deabb..0f7d4c80 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/View/ProfileView.swift @@ -202,7 +202,11 @@ struct ProfileView: View { .foregroundStyle(Color.Codive.grayscale1) .padding(.horizontal, 20) - CalendarMonthView(month: $viewModel.month, selectedDate: $viewModel.selectedDate) + CalendarMonthView( + month: $viewModel.month, + selectedDate: $viewModel.selectedDate, + monthlyHistories: $viewModel.monthlyHistories + ) .padding(16) .frame(maxWidth: .infinity, alignment: .center) .background(Color.white) diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift index b9ad1664..c0b9869f 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift @@ -20,19 +20,32 @@ class ProfileViewModel: ObservableObject { @Published var email: String? // MARK: - State - @Published var month: Date = Date() // 현재 표시 월 + @Published var month: Date = Date() { + didSet { + Task { + await loadMonthlyHistories() + } + } + } @Published var selectedDate: Date? = Date() // 선택된 날짜 @Published var isLoading: Bool = false @Published var errorMessage: String? + @Published var monthlyHistories: [String: String] = [:] // "2026-01-21" -> imageUrl // MARK: - Dependencies private let navigationRouter: NavigationRouter private let fetchMyProfileUseCase: FetchMyProfileUseCase + private let historyAPIService: HistoryAPIServiceProtocol // MARK: - Initializer - init(navigationRouter: NavigationRouter, fetchMyProfileUseCase: FetchMyProfileUseCase) { + init( + navigationRouter: NavigationRouter, + fetchMyProfileUseCase: FetchMyProfileUseCase, + historyAPIService: HistoryAPIServiceProtocol = HistoryAPIService() + ) { self.navigationRouter = navigationRouter self.fetchMyProfileUseCase = fetchMyProfileUseCase + self.historyAPIService = historyAPIService } // MARK: - Loading @@ -56,6 +69,37 @@ class ProfileViewModel: ObservableObject { } isLoading = false + + // 프로필 로드 후 캘린더 데이터 로드 + await loadMonthlyHistories() + } + + func loadMonthlyHistories() async { + guard userId != 0 else { return } + + let calendar = Calendar.current + let year = Int32(calendar.component(.year, from: month)) + let monthValue = Int32(calendar.component(.month, from: month)) + + do { + let items = try await historyAPIService.fetchMonthlyHistory( + memberId: Int64(userId), + year: year, + month: monthValue + ) + + // 같은 날짜에 여러 기록이 있으면 첫 번째만 사용 + var historyMap: [String: String] = [:] + for item in items { + if historyMap[item.historyDate] == nil { + historyMap[item.historyDate] = item.firstImageUrl + } + } + + self.monthlyHistories = historyMap + } catch { + print("월별 기록 로드 실패: \(error.localizedDescription)") + } } // MARK: - Actions diff --git a/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift b/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift index 581a576c..d275581e 100644 --- a/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift +++ b/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift @@ -3,13 +3,19 @@ import SwiftUI struct CalendarMonthView: View { @Binding var month: Date @Binding var selectedDate: Date? + @Binding var monthlyHistories: [String: String] private let calendar = Calendar.current private let weekdaySymbols = ["일", "월", "화", "수", "목", "금", "토"] - init(month: Binding, selectedDate: Binding) { + init( + month: Binding, + selectedDate: Binding, + monthlyHistories: Binding<[String: String]> + ) { self._month = month self._selectedDate = selectedDate + self._monthlyHistories = monthlyHistories } private let cellSpacing: CGFloat = 6 // 요일 간 가로, 세로 간격 @@ -100,20 +106,45 @@ struct CalendarMonthView: View { Color.clear } else { let isSelected = isSameDay(item.date, selectedDate) - let weekday = calendar.component(.weekday, from: item.date) // 1=일 ... 7=토 + let weekday = calendar.component(.weekday, from: item.date) let isWeekend = (weekday == 1 || weekday == 7) - - Text("\(item.dayNumber)") - .font(.codive_body2_regular) - .foregroundStyle(isSelected ? Color.white : (isWeekend ? Color.Codive.grayscale3 : Color.Codive.grayscale1)) - .frame(width: dayCellWidth, height: dayCellHeight, alignment: .center) // 가운데 정렬 - .background { - if isSelected { - Circle() - .fill(Color.Codive.point1) - .frame(width: 28, height: 28) + let dateString = formatDate(item.date) + let imageUrl = monthlyHistories[dateString] + + VStack(spacing: 2) { + // 이미지 표시 (있는 경우) + if let urlString = imageUrl, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + .frame(width: dayCellWidth, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 8)) + case .empty, .failure: + Rectangle() + .fill(Color.Codive.grayscale7) + .frame(width: dayCellWidth, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 8)) + @unknown default: + EmptyView() + } } } + + // 날짜 숫자 + Text("\(item.dayNumber)") + .font(.codive_body2_regular) + .foregroundStyle(isSelected ? Color.white : (isWeekend ? Color.Codive.grayscale3 : Color.Codive.grayscale1)) + .background { + if isSelected { + Circle() + .fill(Color.Codive.point1) + .frame(width: 20, height: 20) + } + } + } } } .frame(width: dayCellWidth, height: dayCellHeight) @@ -125,6 +156,13 @@ struct CalendarMonthView: View { } } + // Helper: Date -> "2026-01-21" 형식 변환 + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + private func monthTitle(_ date: Date) -> String { let y = calendar.component(.year, from: date) let m = calendar.component(.month, from: date) From b8cb6207c3de19487597703fa492789bb6dfae2e Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 1 Feb 2026 00:13:57 +0900 Subject: [PATCH 09/28] =?UTF-8?q?[#57]=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=8B=AC=EB=A0=A5=EC=A1=B0=ED=9A=8C=20API?= =?UTF-8?q?=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 | 17 ++++++++-- .../Feed/Data/HistoryAPIService.swift | 17 ++++------ .../Repositories/HistoryRepositoryImpl.swift | 34 +++++++++++++++++++ .../Domain/Entities/MonthlyHistoryItem.swift | 8 +++++ .../Domain/Protocols/HistoryRepository.swift | 12 +++++++ .../UseCases/FetchMonthlyHistoryUseCase.swift | 20 +++++++++++ .../ViewModel/ProfileViewModel.swift | 8 ++--- 7 files changed, 99 insertions(+), 17 deletions(-) create mode 100644 Codive/Features/Feed/Data/Repositories/HistoryRepositoryImpl.swift create mode 100644 Codive/Features/Feed/Domain/Protocols/HistoryRepository.swift create mode 100644 Codive/Features/Feed/Domain/UseCases/FetchMonthlyHistoryUseCase.swift diff --git a/Codive/DIContainer/ProfileDIContainer.swift b/Codive/DIContainer/ProfileDIContainer.swift index 31b3827b..3a31be76 100644 --- a/Codive/DIContainer/ProfileDIContainer.swift +++ b/Codive/DIContainer/ProfileDIContainer.swift @@ -18,11 +18,15 @@ final class ProfileDIContainer { self.navigationRouter = navigationRouter } - // MARK: - API Service + // MARK: - API Services private lazy var profileAPIService: ProfileAPIServiceProtocol = { return ProfileAPIService() }() + private lazy var historyAPIService: HistoryAPIServiceProtocol = { + return HistoryAPIService() + }() + // MARK: - Data Sources private lazy var profileDataSource: ProfileDataSourceProtocol = { return ProfileDataSource(apiService: profileAPIService) @@ -33,6 +37,10 @@ final class ProfileDIContainer { return ProfileRepositoryImpl(dataSource: profileDataSource) }() + private lazy var historyRepository: HistoryRepository = { + return HistoryRepositoryImpl(historyAPIService: historyAPIService) + }() + // MARK: - UseCases func makeFetchMyProfileUseCase() -> FetchMyProfileUseCase { return DefaultFetchMyProfileUseCase(repository: profileRepository) @@ -46,11 +54,16 @@ final class ProfileDIContainer { return DefaultUpdateProfileUseCase(repository: profileRepository) } + func makeFetchMonthlyHistoryUseCase() -> FetchMonthlyHistoryUseCase { + return FetchMonthlyHistoryUseCase(historyRepository: historyRepository) + } + // MARK: - ViewModels private lazy var profileViewModel: ProfileViewModel = { return ProfileViewModel( navigationRouter: navigationRouter, - fetchMyProfileUseCase: makeFetchMyProfileUseCase() + fetchMyProfileUseCase: makeFetchMyProfileUseCase(), + fetchMonthlyHistoryUseCase: makeFetchMonthlyHistoryUseCase() ) }() diff --git a/Codive/Features/Feed/Data/HistoryAPIService.swift b/Codive/Features/Feed/Data/HistoryAPIService.swift index 5d2279dc..5e7a7fbf 100644 --- a/Codive/Features/Feed/Data/HistoryAPIService.swift +++ b/Codive/Features/Feed/Data/HistoryAPIService.swift @@ -15,7 +15,7 @@ protocol HistoryAPIServiceProtocol { func createHistory(request: HistoryCreateAPIRequest) async throws -> Int64 func fetchHistoryDetail(historyId: Int64) async throws -> HistoryDetailDTO func fetchClothTags(historyImageId: Int64) async throws -> [ClothTagDTO] - func fetchMonthlyHistory(memberId: Int64, year: Int32, month: Int32) async throws -> [MonthlyHistoryItem] + func fetchMonthlyHistory(memberId: Int64, year: Int32, month: Int32) async throws -> [MonthlyHistoryItemDTO] } // MARK: - History Detail DTO @@ -229,7 +229,7 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { // MARK: - Fetch Monthly History - func fetchMonthlyHistory(memberId: Int64, year: Int32, month: Int32) async throws -> [MonthlyHistoryItem] { + func fetchMonthlyHistory(memberId: Int64, year: Int32, month: Int32) async throws -> [MonthlyHistoryItemDTO] { let input = Operations.History_getMonthlyHistory.Input( path: .init(memberId: memberId), query: .init(year: year, month: month) @@ -250,15 +250,10 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { } return payloads.compactMap { payload in - guard let historyId = payload.historyId, - let imageUrl = payload.firstImageUrl, - let date = payload.historyDate else { - return nil - } - return MonthlyHistoryItem( - historyId: historyId, - firstImageUrl: imageUrl, - historyDate: date + MonthlyHistoryItemDTO( + historyId: payload.historyId, + firstImageUrl: payload.firstImageUrl, + historyDate: payload.historyDate ) } diff --git a/Codive/Features/Feed/Data/Repositories/HistoryRepositoryImpl.swift b/Codive/Features/Feed/Data/Repositories/HistoryRepositoryImpl.swift new file mode 100644 index 00000000..0955a03c --- /dev/null +++ b/Codive/Features/Feed/Data/Repositories/HistoryRepositoryImpl.swift @@ -0,0 +1,34 @@ +// +// HistoryRepositoryImpl.swift +// Codive +// +// Created by Claude Code on 1/31/26. +// + +import Foundation + +final class HistoryRepositoryImpl: HistoryRepository { + private let historyAPIService: HistoryAPIServiceProtocol + + init(historyAPIService: HistoryAPIServiceProtocol = HistoryAPIService()) { + self.historyAPIService = historyAPIService + } + + func fetchMonthlyHistory(memberId: Int64, year: Int32, month: Int32) async throws -> [MonthlyHistoryItem] { + let dtos = try await historyAPIService.fetchMonthlyHistory(memberId: memberId, year: year, month: month) + + // DTO를 Domain Entity로 변환 + return dtos.compactMap { dto in + guard let historyId = dto.historyId, + let imageUrl = dto.firstImageUrl, + let date = dto.historyDate else { + return nil + } + return MonthlyHistoryItem( + historyId: historyId, + firstImageUrl: imageUrl, + historyDate: date + ) + } + } +} diff --git a/Codive/Features/Feed/Domain/Entities/MonthlyHistoryItem.swift b/Codive/Features/Feed/Domain/Entities/MonthlyHistoryItem.swift index ec958817..5c01fe25 100644 --- a/Codive/Features/Feed/Domain/Entities/MonthlyHistoryItem.swift +++ b/Codive/Features/Feed/Domain/Entities/MonthlyHistoryItem.swift @@ -7,8 +7,16 @@ import Foundation +// MARK: - Domain Entity struct MonthlyHistoryItem { let historyId: Int64 let firstImageUrl: String let historyDate: String // "2026-01-21" 형식 } + +// MARK: - Data Transfer Object (DTO) +struct MonthlyHistoryItemDTO: Decodable { + let historyId: Int64? + let firstImageUrl: String? + let historyDate: String? +} diff --git a/Codive/Features/Feed/Domain/Protocols/HistoryRepository.swift b/Codive/Features/Feed/Domain/Protocols/HistoryRepository.swift new file mode 100644 index 00000000..088ddf4e --- /dev/null +++ b/Codive/Features/Feed/Domain/Protocols/HistoryRepository.swift @@ -0,0 +1,12 @@ +// +// HistoryRepository.swift +// Codive +// +// Created by Claude Code on 1/31/26. +// + +import Foundation + +protocol HistoryRepository { + func fetchMonthlyHistory(memberId: Int64, year: Int32, month: Int32) async throws -> [MonthlyHistoryItem] +} diff --git a/Codive/Features/Feed/Domain/UseCases/FetchMonthlyHistoryUseCase.swift b/Codive/Features/Feed/Domain/UseCases/FetchMonthlyHistoryUseCase.swift new file mode 100644 index 00000000..9f1bee37 --- /dev/null +++ b/Codive/Features/Feed/Domain/UseCases/FetchMonthlyHistoryUseCase.swift @@ -0,0 +1,20 @@ +// +// FetchMonthlyHistoryUseCase.swift +// Codive +// +// Created by Claude Code on 1/31/26. +// + +import Foundation + +final class FetchMonthlyHistoryUseCase { + private let historyRepository: HistoryRepository + + init(historyRepository: HistoryRepository) { + self.historyRepository = historyRepository + } + + func execute(memberId: Int64, year: Int32, month: Int32) async throws -> [MonthlyHistoryItem] { + try await historyRepository.fetchMonthlyHistory(memberId: memberId, year: year, month: month) + } +} diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift index c0b9869f..649b34af 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift @@ -35,17 +35,17 @@ class ProfileViewModel: ObservableObject { // MARK: - Dependencies private let navigationRouter: NavigationRouter private let fetchMyProfileUseCase: FetchMyProfileUseCase - private let historyAPIService: HistoryAPIServiceProtocol + private let fetchMonthlyHistoryUseCase: FetchMonthlyHistoryUseCase // MARK: - Initializer init( navigationRouter: NavigationRouter, fetchMyProfileUseCase: FetchMyProfileUseCase, - historyAPIService: HistoryAPIServiceProtocol = HistoryAPIService() + fetchMonthlyHistoryUseCase: FetchMonthlyHistoryUseCase ) { self.navigationRouter = navigationRouter self.fetchMyProfileUseCase = fetchMyProfileUseCase - self.historyAPIService = historyAPIService + self.fetchMonthlyHistoryUseCase = fetchMonthlyHistoryUseCase } // MARK: - Loading @@ -82,7 +82,7 @@ class ProfileViewModel: ObservableObject { let monthValue = Int32(calendar.component(.month, from: month)) do { - let items = try await historyAPIService.fetchMonthlyHistory( + let items = try await fetchMonthlyHistoryUseCase.execute( memberId: Int64(userId), year: year, month: monthValue From 825543cde0eeb2c26d0b95106c4e8ddaa16cba15 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 1 Feb 2026 00:48:07 +0900 Subject: [PATCH 10/28] =?UTF-8?q?[#57]=20=EB=8B=AC=EB=A0=A5=EC=9D=98=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=EC=85=80=20UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/View/OtherProfileView.swift | 6 ++- .../ViewModel/OtherProfileViewModel.swift | 1 + .../Components/CalendarMonthView.swift | 37 +++++++++---------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift index c8f61772..ae28dd4e 100644 --- a/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift +++ b/Codive/Features/Profile/OtherProfile/Presentation/View/OtherProfileView.swift @@ -205,7 +205,11 @@ struct OtherProfileView: View { .foregroundStyle(Color.Codive.grayscale1) .padding(.horizontal, 20) - CalendarMonthView(month: $viewModel.month, selectedDate: $viewModel.selectedDate) + CalendarMonthView( + month: $viewModel.month, + selectedDate: $viewModel.selectedDate, + monthlyHistories: $viewModel.monthlyHistories + ) .padding(16) .frame(maxWidth: .infinity, alignment: .center) .background(Color.white) diff --git a/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift b/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift index 42dfb787..eea3169f 100644 --- a/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift +++ b/Codive/Features/Profile/OtherProfile/Presentation/ViewModel/OtherProfileViewModel.swift @@ -20,6 +20,7 @@ class OtherProfileViewModel: ObservableObject { @Published var month: Date = Date() @Published var selectedDate: Date? = Date() @Published var isBlockMenuPresented: Bool = false + @Published var monthlyHistories: [String: String] = [:] // "2026-01-21" -> imageUrl // MARK: - Dependencies private let navigationRouter: NavigationRouter diff --git a/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift b/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift index d275581e..6ba98636 100644 --- a/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift +++ b/Codive/Features/Profile/Shared/Presentation/Components/CalendarMonthView.swift @@ -111,29 +111,25 @@ struct CalendarMonthView: View { let dateString = formatDate(item.date) let imageUrl = monthlyHistories[dateString] - VStack(spacing: 2) { - // 이미지 표시 (있는 경우) - if let urlString = imageUrl, let url = URL(string: urlString) { - AsyncImage(url: url) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFill() - .frame(width: dayCellWidth, height: 40) - .clipShape(RoundedRectangle(cornerRadius: 8)) - case .empty, .failure: - Rectangle() - .fill(Color.Codive.grayscale7) - .frame(width: dayCellWidth, height: 40) - .clipShape(RoundedRectangle(cornerRadius: 8)) - @unknown default: - EmptyView() - } + // 이미지 배경 (전체 셀을 덮음) + if let urlString = imageUrl, let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + .clipped() + case .empty, .failure: + EmptyView() + @unknown default: + EmptyView() } } + } - // 날짜 숫자 + // 날짜 숫자 - 가운데 (이미지가 없을 때만 보임) + if imageUrl == nil { Text("\(item.dayNumber)") .font(.codive_body2_regular) .foregroundStyle(isSelected ? Color.white : (isWeekend ? Color.Codive.grayscale3 : Color.Codive.grayscale1)) @@ -148,6 +144,7 @@ struct CalendarMonthView: View { } } .frame(width: dayCellWidth, height: dayCellHeight) + .clipShape(RoundedRectangle(cornerRadius: 8)) .contentShape(Rectangle()) .onTapGesture { if !item.isPlaceholder { From 4d8426ad2f77a10886e47bdbe7028606faffafe5 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 1 Feb 2026 17:10:54 +0900 Subject: [PATCH 11/28] =?UTF-8?q?[#57]=20=EC=84=A4=EC=A0=95=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Icon_folder/setting.imageset/setting.pdf | Bin 6030 -> 6008 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/Codive/Resources/Icons.xcassets/Icon_folder/setting.imageset/setting.pdf b/Codive/Resources/Icons.xcassets/Icon_folder/setting.imageset/setting.pdf index 01b1b557b13a8fc13e20027d332db6741c5ad3e3..90a57b441428908807b2f8e30f9218bd1de6bdc4 100644 GIT binary patch delta 2969 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 Date: Sun, 1 Feb 2026 17:30:44 +0900 Subject: [PATCH 12/28] =?UTF-8?q?[#57]=20=EC=84=A4=EC=A0=95=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=99=94=EB=A9=B4=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Core/Resources/TextLiteral.swift | 3 + Codive/DIContainer/SettingDIContainer.swift | 8 ++ .../Repositories/HistoryRepositoryImpl.swift | 2 +- .../Domain/Entities/MonthlyHistoryItem.swift | 2 +- .../Domain/Protocols/HistoryRepository.swift | 2 +- .../UseCases/FetchMonthlyHistoryUseCase.swift | 2 +- Codive/Features/Main/View/MainTabView.swift | 8 ++ .../Presentation/View/SettingView.swift | 66 ++++++++++---- .../Presentation/View/WithdrawView.swift | 85 ++++++++++++------- .../ViewModel/SettingViewModel.swift | 21 +++++ .../ViewModel/WithdrawViewModel.swift | 29 +++++++ .../ViewFactory/SettingViewFactory.swift | 3 + 12 files changed, 180 insertions(+), 51 deletions(-) create mode 100644 Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift diff --git a/Codive/Core/Resources/TextLiteral.swift b/Codive/Core/Resources/TextLiteral.swift index b645d46f..a2bbdb4d 100644 --- a/Codive/Core/Resources/TextLiteral.swift +++ b/Codive/Core/Resources/TextLiteral.swift @@ -236,6 +236,9 @@ enum TextLiteral { static let withdrawTitle = "계정 탈퇴" static let withdrawNotice = "탈퇴 전 아래 내용을 확인해주세요" static let withdrawButton = "계정 탈퇴하기" + static let withdrawButtonLoading = "탈퇴 중..." + static let withdrawConfirmTitle = "정말 탈퇴하시겠습니까?" + static let withdrawConfirmMessage = "한 번 탈퇴하면 계정과 모든 데이터는 복구할 수 없습니다." // Liked Records static let likedRecordsEmpty = "좋아요 한 기록이 없어요!" diff --git a/Codive/DIContainer/SettingDIContainer.swift b/Codive/DIContainer/SettingDIContainer.swift index ab45781b..204ca670 100644 --- a/Codive/DIContainer/SettingDIContainer.swift +++ b/Codive/DIContainer/SettingDIContainer.swift @@ -98,6 +98,10 @@ final class SettingDIContainer { ) } + func makeWithdrawViewModel() -> WithdrawViewModel { + WithdrawViewModel(navigationRouter: navigationRouter) + } + // MARK: - Views func makeSettingView() -> SettingView { SettingView(viewModel: self.makeSettingViewModel()) @@ -114,4 +118,8 @@ final class SettingDIContainer { func makeSettingBlockedView() -> SettingBlockedView { SettingBlockedView(vm: self.makeBlockedUsersViewModel()) } + + func makeWithdrawView() -> WithdrawView { + WithdrawView(vm: makeWithdrawViewModel()) + } } diff --git a/Codive/Features/Feed/Data/Repositories/HistoryRepositoryImpl.swift b/Codive/Features/Feed/Data/Repositories/HistoryRepositoryImpl.swift index 0955a03c..51905e34 100644 --- a/Codive/Features/Feed/Data/Repositories/HistoryRepositoryImpl.swift +++ b/Codive/Features/Feed/Data/Repositories/HistoryRepositoryImpl.swift @@ -2,7 +2,7 @@ // HistoryRepositoryImpl.swift // Codive // -// Created by Claude Code on 1/31/26. +// Created by 황상환 on 1/31/26. // import Foundation diff --git a/Codive/Features/Feed/Domain/Entities/MonthlyHistoryItem.swift b/Codive/Features/Feed/Domain/Entities/MonthlyHistoryItem.swift index 5c01fe25..d527ec9c 100644 --- a/Codive/Features/Feed/Domain/Entities/MonthlyHistoryItem.swift +++ b/Codive/Features/Feed/Domain/Entities/MonthlyHistoryItem.swift @@ -2,7 +2,7 @@ // MonthlyHistoryItem.swift // Codive // -// Created by Claude Code on 1/31/26. +// Created by 황상환 on 1/31/26. // import Foundation diff --git a/Codive/Features/Feed/Domain/Protocols/HistoryRepository.swift b/Codive/Features/Feed/Domain/Protocols/HistoryRepository.swift index 088ddf4e..bb1d40e5 100644 --- a/Codive/Features/Feed/Domain/Protocols/HistoryRepository.swift +++ b/Codive/Features/Feed/Domain/Protocols/HistoryRepository.swift @@ -2,7 +2,7 @@ // HistoryRepository.swift // Codive // -// Created by Claude Code on 1/31/26. +// Created by 황상환 on 1/31/26. // import Foundation diff --git a/Codive/Features/Feed/Domain/UseCases/FetchMonthlyHistoryUseCase.swift b/Codive/Features/Feed/Domain/UseCases/FetchMonthlyHistoryUseCase.swift index 9f1bee37..71f7886d 100644 --- a/Codive/Features/Feed/Domain/UseCases/FetchMonthlyHistoryUseCase.swift +++ b/Codive/Features/Feed/Domain/UseCases/FetchMonthlyHistoryUseCase.swift @@ -2,7 +2,7 @@ // FetchMonthlyHistoryUseCase.swift // Codive // -// Created by Claude Code on 1/31/26. +// Created by 황상환 on 1/31/26. // import Foundation diff --git a/Codive/Features/Main/View/MainTabView.swift b/Codive/Features/Main/View/MainTabView.swift index c502b247..0238604c 100644 --- a/Codive/Features/Main/View/MainTabView.swift +++ b/Codive/Features/Main/View/MainTabView.swift @@ -194,6 +194,14 @@ struct MainTabView: View { FavoriteCodiView(showHeart: showHeart, navigationRouter: navigationRouter) case .settings: settingDIContainer.makeSettingView() + case .settingLikedRecords: + settingDIContainer.makeSettingLikedView() + case .settingMyComments: + settingDIContainer.makeSettingCommentView() + case .settingBlockedUsers: + settingDIContainer.makeSettingBlockedView() + case .settingWithdraw: + settingDIContainer.makeWithdrawView() case .profileSetting: profileDIContainer.makeProfileSettingView() case .followList(let mode, let memberId): diff --git a/Codive/Features/Setting/Presentation/View/SettingView.swift b/Codive/Features/Setting/Presentation/View/SettingView.swift index 1b66aaec..1f148e33 100644 --- a/Codive/Features/Setting/Presentation/View/SettingView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingView.swift @@ -76,13 +76,31 @@ struct SettingView: View { .background(Color.Codive.grayscale1) .padding(.bottom, 16) - SettingRow(text: TextLiteral.Setting.likedRecords) - .padding(.bottom, 12) + Button(action: { + vm.navigateToLikedRecords() + }) { + SettingRow(text: TextLiteral.Setting.likedRecords) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .padding(.bottom, 12) - SettingRow(text: TextLiteral.Setting.myComments) - .padding(.bottom, 12) + Button(action: { + vm.navigateToMyComments() + }) { + SettingRow(text: TextLiteral.Setting.myComments) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .padding(.bottom, 12) - SettingRow(text: TextLiteral.Setting.blockedUsers) + Button(action: { + vm.navigateToBlockedUsers() + }) { + SettingRow(text: TextLiteral.Setting.blockedUsers) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) } } @@ -157,27 +175,41 @@ struct SettingView: View { } .padding(.bottom, 12) - SettingRow(text: TextLiteral.Setting.inquiry) - .padding(.bottom, 12) + Button(action: { + vm.navigateToInquiry() + }) { + SettingRow(text: TextLiteral.Setting.inquiry) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .padding(.bottom, 12) Button(action: { showLogoutAlert = true }) { SettingRow(text: TextLiteral.Setting.logout) } - .padding(.bottom, 12) - .alert("로그아웃", isPresented: $showLogoutAlert) { - Button("취소", role: .cancel) { } - Button("로그아웃", role: .destructive) { - Task { - await vm.logout() - } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .padding(.bottom, 12) + .alert("로그아웃", isPresented: $showLogoutAlert) { + Button("취소", role: .cancel) { } + Button("로그아웃", role: .destructive) { + Task { + await vm.logout() } - } message: { - Text("정말 로그아웃하시겠습니까?") } + } message: { + Text("정말 로그아웃하시겠습니까?") + } - SettingRow(text: TextLiteral.Setting.withdraw) + Button(action: { + vm.navigateToWithdraw() + }) { + SettingRow(text: TextLiteral.Setting.withdraw) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) } } } diff --git a/Codive/Features/Setting/Presentation/View/WithdrawView.swift b/Codive/Features/Setting/Presentation/View/WithdrawView.swift index 4d365184..62e87f01 100644 --- a/Codive/Features/Setting/Presentation/View/WithdrawView.swift +++ b/Codive/Features/Setting/Presentation/View/WithdrawView.swift @@ -8,44 +8,69 @@ import SwiftUI struct WithdrawView: View { - var body: some View { - CustomNavigationBar(title: TextLiteral.Setting.withdrawTitle) { - print("뒤로가기") - } - VStack { - HStack { - Image("orangeWarning") - .resizable() - .frame(width: 24, height: 24) - .padding(.leading, 20) + @ObservedObject private var vm: WithdrawViewModel + + init(vm: WithdrawViewModel) { + self._vm = ObservedObject(wrappedValue: vm) + } - Text(TextLiteral.Setting.withdrawNotice) - .font(.codive_title2) - .foregroundStyle(Color.Codive.grayscale1) + var body: some View { + VStack(spacing: 0) { + CustomNavigationBar(title: TextLiteral.Setting.withdrawTitle) { + vm.navigateBack() } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 32) - - Image("withdraw1") - .resizable() - .scaledToFit() - .padding(.bottom, 12) - .padding(.horizontal, 20) - Image("withdraw2") - .resizable() - .scaledToFit() - .padding(.horizontal, 20) - Spacer() + VStack { + HStack { + Image("orangeWarning") + .resizable() + .frame(width: 24, height: 24) + .padding(.leading, 20) - CustomButton(text: TextLiteral.Setting.withdrawButton, widthType: .fixed) { - print("계정 탈퇴하기") + Text(TextLiteral.Setting.withdrawNotice) + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 32) + + Image("withdraw1") + .resizable() + .scaledToFit() + .padding(.bottom, 12) + .padding(.horizontal, 20) + Image("withdraw2") + .resizable() + .scaledToFit() + .padding(.horizontal, 20) + + Spacer() + + CustomButton( + text: vm.isLoading ? TextLiteral.Setting.withdrawButtonLoading : TextLiteral.Setting.withdrawButton, + widthType: .fixed, + isEnabled: !vm.isLoading + ) { + vm.onWithdrawTapped() + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } + .navigationBarHidden(true) + .alert(TextLiteral.Setting.withdrawConfirmTitle, isPresented: $vm.showConfirmAlert) { + Button("취소", role: .cancel) { } + Button("탈퇴하기", role: .destructive) { + Task { + vm.confirmWithdraw() + } } - .padding(.horizontal, 20) + } message: { + Text(TextLiteral.Setting.withdrawConfirmMessage) } } } #Preview { - WithdrawView() + EmptyView() } diff --git a/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift index ffe57a69..cc4a73c9 100644 --- a/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift +++ b/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift @@ -81,6 +81,27 @@ final class SettingViewModel: ObservableObject { navigationRouter.navigateBack() } + func navigateToLikedRecords() { + navigationRouter.navigate(to: .settingLikedRecords) + } + + func navigateToMyComments() { + navigationRouter.navigate(to: .settingMyComments) + } + + func navigateToBlockedUsers() { + navigationRouter.navigate(to: .settingBlockedUsers) + } + + func navigateToInquiry() { + // TODO: 문의하기 화면으로 이동 + // 아직 구현되지 않은 화면입니다. + } + + func navigateToWithdraw() { + navigationRouter.navigate(to: .settingWithdraw) + } + // MARK: - Logout func logout() async { // Domain/Data 레이어: 토큰 삭제 및 소셜 로그아웃 diff --git a/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift new file mode 100644 index 00000000..9b500ad1 --- /dev/null +++ b/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift @@ -0,0 +1,29 @@ +import Foundation + +@MainActor +final class WithdrawViewModel: ObservableObject { + + @Published var isLoading: Bool = false + @Published var showConfirmAlert: Bool = false + + private let navigationRouter: NavigationRouter + + init(navigationRouter: NavigationRouter) { + self.navigationRouter = navigationRouter + } + + func onWithdrawTapped() { + showConfirmAlert = true + } + + func confirmWithdraw() { + isLoading = true + // TODO: 실제 탈퇴 API 호출 + // 이후 AppRouter를 통해 로그인 화면으로 이동 + isLoading = false + } + + func navigateBack() { + navigationRouter.navigateBack() + } +} diff --git a/Codive/Router/ViewFactory/SettingViewFactory.swift b/Codive/Router/ViewFactory/SettingViewFactory.swift index 026e8492..fbab047a 100644 --- a/Codive/Router/ViewFactory/SettingViewFactory.swift +++ b/Codive/Router/ViewFactory/SettingViewFactory.swift @@ -34,6 +34,9 @@ final class SettingViewFactory { case .settingBlockedUsers: settingDIContainer?.makeSettingBlockedView() + case .settingWithdraw: + settingDIContainer?.makeWithdrawView() + default: EmptyView() } From e702047a0516da3e1ccd711c3b2f83294ab3854c Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:34:11 +0900 Subject: [PATCH 13/28] =?UTF-8?q?[#57]=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=ED=95=9C=20=EA=B8=B0=EB=A1=9D=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/SettingDIContainer.swift | 15 ++- .../Setting/Data/DTOs/LikedHistoryDTO.swift | 30 ++++++ .../Data/DataSources/SettingDataSource.swift | 94 +++++++++++++----- .../Domain/Entities/SettingEntity.swift | 14 +-- .../Presentation/View/SettingLikedView.swift | 99 ++++++++++--------- 5 files changed, 176 insertions(+), 76 deletions(-) create mode 100644 Codive/Features/Setting/Data/DTOs/LikedHistoryDTO.swift diff --git a/Codive/DIContainer/SettingDIContainer.swift b/Codive/DIContainer/SettingDIContainer.swift index 204ca670..4f05951b 100644 --- a/Codive/DIContainer/SettingDIContainer.swift +++ b/Codive/DIContainer/SettingDIContainer.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI +import CodiveAPI @MainActor final class SettingDIContainer { @@ -16,6 +17,7 @@ final class SettingDIContainer { private let navigationRouter: NavigationRouter private let profileDIContainer: ProfileDIContainer private let authDIContainer: AuthDIContainer + private let apiClient: Client // ViewFactory lazy var settingViewFactory = SettingViewFactory(settingDIContainer: self) @@ -24,13 +26,22 @@ final class SettingDIContainer { private let repository: SettingRepository // MARK: - Init - init(appRouter: AppRouter, navigationRouter: NavigationRouter, profileDIContainer: ProfileDIContainer, authDIContainer: AuthDIContainer) { + init( + appRouter: AppRouter, + navigationRouter: NavigationRouter, + profileDIContainer: ProfileDIContainer, + authDIContainer: AuthDIContainer, + apiClient: Client = CodiveAPIProvider.createClient( + middlewares: [CodiveAuthMiddleware(provider: KeychainTokenProvider())] + ) + ) { self.appRouter = appRouter self.navigationRouter = navigationRouter self.profileDIContainer = profileDIContainer self.authDIContainer = authDIContainer + self.apiClient = apiClient - let dataSource = SettingsDataSource() + let dataSource = SettingsDataSource(apiClient: apiClient) let repo = SettingsRepositoryImpl(dataSource: dataSource) self.repository = repo } diff --git a/Codive/Features/Setting/Data/DTOs/LikedHistoryDTO.swift b/Codive/Features/Setting/Data/DTOs/LikedHistoryDTO.swift new file mode 100644 index 00000000..866c4d87 --- /dev/null +++ b/Codive/Features/Setting/Data/DTOs/LikedHistoryDTO.swift @@ -0,0 +1,30 @@ +import Foundation + +// MARK: - API Response DTOs + +struct LikedHistoryPreviewDTO: Decodable { + let id: Int64 + let imageUrl: String + let historyDate: Date + let lastLikeId: Int64 + + enum CodingKeys: String, CodingKey { + case id + case imageUrl + case historyDate + case lastLikeId + } +} + +struct SliceResponseDTO: Decodable { + let content: [LikedHistoryPreviewDTO] + let isLast: Bool +} + +struct BaseResponseSliceDTO: Decodable { + let isSuccess: Bool + let code: String + let message: String + let timeStamp: Date + let result: SliceResponseDTO +} diff --git a/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift b/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift index 18c0bc8b..45eccdb1 100644 --- a/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift +++ b/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift @@ -4,12 +4,14 @@ // import Foundation +import CodiveAPI -/// 네트워크 붙기 전까지 사용할 인메모리 스텁 final class SettingsDataSource { + // MARK: - Properties + private let apiClient: Client + // MARK: - In-memory stores - private var likedRecordsStore: [LikedRecord] = [] private var myCommentsStore: [MyComment] = [] private var blockedUsersStore: [BlockedUser] = [] private var notificationPrefsStore: NotificationPrefs = .init( @@ -24,21 +26,9 @@ final class SettingsDataSource { body: "작성한 게시물/댓글은 정책에 따라 익명화되거나 삭제될 수 있습니다.") ] - // MARK: - Init (샘플 데이터) - init() { - likedRecordsStore = (1...25).compactMap { i in - let urlString = "https://picsum.photos/id/\(i % 100)/200/200" - guard let url = URL(string: urlString) else { - assertionFailure("Stub URL invalid: \(urlString)") - return nil // 잘못된 건 그냥 버림 (더미 데이터니까) - } - - return LikedRecord( - postId: PostID(i), - thumbnailURL: url, - likedAt: Date().addingTimeInterval(TimeInterval(-i * 1_800)) - ) - } + // MARK: - Init + init(apiClient: Client = CodiveAPIProvider.createClient()) { + self.apiClient = apiClient // 내가 남긴 댓글 myCommentsStore = (1...17).map { i in @@ -68,11 +58,71 @@ final class SettingsDataSource { // MARK: - Liked Records func fetchLikedRecords(page: Int, pageSize: Int) async throws -> [LikedRecord] { - guard page > 0, pageSize > 0 else { return [] } - let start = (page - 1) * pageSize - let end = min(start + pageSize, likedRecordsStore.count) - guard start < end else { return [] } - return Array(likedRecordsStore[start.. 1 ? Int64((page - 1) * pageSize) : nil + let jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + + let response = try await apiClient.Like_getLikedHistories( + query: .init(lastLikeId: lastLikeId, size: Int32(pageSize)) + ) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + struct APIResponse: Decodable { + let isSuccess: Bool + let code: String + let message: String + let timeStamp: String + let result: Result + + struct Result: Decodable { + let content: [LikedHistoryDTO] + let isLast: Bool + } + } + + struct LikedHistoryDTO: Decodable { + let id: Int64 + let imageUrl: String + let historyDate: String + let lastLikeId: Int64? + } + + let apiResponse = try jsonDecoder.decode(APIResponse.self, from: data) + + guard apiResponse.isSuccess else { + throw NSError(domain: "API Error", code: -1, userInfo: [NSLocalizedDescriptionKey: apiResponse.message]) + } + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + + return apiResponse.result.content.map { dto in + let url = URL(string: dto.imageUrl) ?? URL(fileURLWithPath: "") + let historyDate = dateFormatter.date(from: dto.historyDate) ?? Date() + + return LikedRecord( + id: dto.id, + thumbnailURL: url, + historyDate: historyDate, + lastLikeId: dto.lastLikeId ?? 0 + ) + } + + 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("fetchLikedRecords error response [\(statusCode)]: \(responseBody)") + } + } + } + throw NSError(domain: "SettingDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch liked records"]) + } } // MARK: - My Comments diff --git a/Codive/Features/Setting/Domain/Entities/SettingEntity.swift b/Codive/Features/Setting/Domain/Entities/SettingEntity.swift index 6ab772ae..6c236e85 100644 --- a/Codive/Features/Setting/Domain/Entities/SettingEntity.swift +++ b/Codive/Features/Setting/Domain/Entities/SettingEntity.swift @@ -16,15 +16,17 @@ public struct SimpleUser: Hashable, Sendable { // 좋아요한 기록 public struct LikedRecord: Hashable, Sendable, Identifiable { - public let postId: PostID + public let id: Int64 public let thumbnailURL: URL - public let likedAt: Date - public init(postId: PostID, thumbnailURL: URL, likedAt: Date) { - self.postId = postId + public let historyDate: Date + public let lastLikeId: Int64 + + public init(id: Int64, thumbnailURL: URL, historyDate: Date, lastLikeId: Int64) { + self.id = id self.thumbnailURL = thumbnailURL - self.likedAt = likedAt + self.historyDate = historyDate + self.lastLikeId = lastLikeId } - public var id: PostID { postId } } // 내가 남긴 댓글 diff --git a/Codive/Features/Setting/Presentation/View/SettingLikedView.swift b/Codive/Features/Setting/Presentation/View/SettingLikedView.swift index 5f39215e..de693377 100644 --- a/Codive/Features/Setting/Presentation/View/SettingLikedView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingLikedView.swift @@ -10,60 +10,67 @@ import SwiftUI struct SettingLikedView: View { @StateObject var vm: LikedRecordsViewModel - private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3) - var body: some View { - Group { - if vm.isLoading && vm.items.isEmpty { - ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = vm.error, vm.items.isEmpty { - VStack(spacing: 12) { - Text(TextLiteral.Setting.loadFailed).font(.codive_title2) - Text(error.localizedDescription).font(.codive_body2_regular).foregroundStyle(.secondary) - CustomButton(text: TextLiteral.Setting.retry, widthType: .fixed) { - Task { await vm.refresh() } + GeometryReader { geometry in + Group { + if vm.isLoading && vm.items.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = vm.error, vm.items.isEmpty { + VStack(spacing: 12) { + Text(TextLiteral.Setting.loadFailed).font(.codive_title2) + Text(error.localizedDescription).font(.codive_body2_regular).foregroundStyle(.secondary) + CustomButton(text: TextLiteral.Setting.retry, widthType: .fixed) { + Task { await vm.refresh() } + } + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else if vm.items.isEmpty { + SettingsEmptyView( + title: TextLiteral.Setting.likedRecordsEmpty, + message: TextLiteral.Setting.likedRecordsEmptyMessage, + actionTitle: TextLiteral.Setting.goToFeed + ) { + /* 라우팅 */ } - }.frame(maxWidth: .infinity, maxHeight: .infinity) - } else if vm.items.isEmpty { - SettingsEmptyView( - title: TextLiteral.Setting.likedRecordsEmpty, - message: TextLiteral.Setting.likedRecordsEmptyMessage, - actionTitle: TextLiteral.Setting.goToFeed - ) { - /* 라우팅 */ - } - } else { - ScrollView { - LazyVGrid(columns: columns, spacing: 8) { - ForEach(vm.items) { item in - AsyncImage(url: item.thumbnailURL) { phase in - switch phase { - case .success(let img): - img.resizable().scaledToFill() - case .empty: - Color.Codive.main6 - case .failure: - Color.Codive.main6 - @unknown default: - Color.Codive.main6 + } else { + let itemWidth = geometry.size.width / 3 + let itemHeight = itemWidth * 4 / 3 + + ScrollView { + LazyVGrid( + columns: [ + GridItem(.fixed(itemWidth), spacing: 0), + GridItem(.fixed(itemWidth), spacing: 0), + GridItem(.fixed(itemWidth), spacing: 0) + ], + spacing: 0 + ) { + ForEach(vm.items) { item in + AsyncImage(url: item.thumbnailURL) { phase in + switch phase { + case .success(let img): + img.resizable().scaledToFill() + case .empty: + Color.Codive.main6 + case .failure: + Color.Codive.main6 + @unknown default: + Color.Codive.main6 + } + } + .frame(width: itemWidth, height: itemHeight) + .clipped() + .onTapGesture { + // 게시글 상세로 이동 } - } - .frame(height: 110) - .clipped() - .clipShape(RoundedRectangle(cornerRadius: 8)) - .onTapGesture { - // 게시글 상세로 이동 } } } - .padding(.horizontal, 16) - .padding(.top, 12) } } + .navigationTitle(TextLiteral.Setting.likedRecords) + .navigationBarTitleDisplayMode(.inline) + .task { await vm.refresh() } + .refreshable { await vm.refresh() } } - .navigationTitle(TextLiteral.Setting.likedRecords) - .navigationBarTitleDisplayMode(.inline) - .task { await vm.refresh() } - .refreshable { await vm.refresh() } } } From 95ba15e93af796304a132a885ed335737f5c68ce Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:40:41 +0900 Subject: [PATCH 14/28] =?UTF-8?q?[#57]=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=ED=95=9C=20=EA=B8=B0=EB=A1=9D=EC=97=90=EC=84=9C=20FeedDetailVi?= =?UTF-8?q?ew=EB=A1=9C=20=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 --- .../Setting/Presentation/View/SettingLikedView.swift | 2 +- .../Presentation/ViewModel/LikedRecordsViewModel.swift | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Codive/Features/Setting/Presentation/View/SettingLikedView.swift b/Codive/Features/Setting/Presentation/View/SettingLikedView.swift index de693377..496df1b2 100644 --- a/Codive/Features/Setting/Presentation/View/SettingLikedView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingLikedView.swift @@ -60,7 +60,7 @@ struct SettingLikedView: View { .frame(width: itemWidth, height: itemHeight) .clipped() .onTapGesture { - // 게시글 상세로 이동 + vm.navigateToFeedDetail(feedId: Int(item.id)) } } } diff --git a/Codive/Features/Setting/Presentation/ViewModel/LikedRecordsViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/LikedRecordsViewModel.swift index 75eef228..9756c07a 100644 --- a/Codive/Features/Setting/Presentation/ViewModel/LikedRecordsViewModel.swift +++ b/Codive/Features/Setting/Presentation/ViewModel/LikedRecordsViewModel.swift @@ -44,4 +44,9 @@ final class LikedRecordsViewModel: ObservableObject { items = [] } } + + @MainActor + func navigateToFeedDetail(feedId: Int) { + navigationRouter.navigate(to: .feedDetail(feedId: feedId)) + } } From f295d24f817071403a528e45f7383122e2fd7682 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 1 Feb 2026 22:43:00 +0900 Subject: [PATCH 15/28] =?UTF-8?q?[#57]=20=EB=82=B4=EA=B0=80=20=EB=82=A8?= =?UTF-8?q?=EA=B8=B4=20=EB=8C=93=EA=B8=80=20API=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/DataSources/SettingDataSource.swift | 118 ++++++++++++++---- .../Domain/Entities/SettingEntity.swift | 29 ++++- .../View/SettingCommentView.swift | 91 +++++++++++--- SettingCommentView_design.png | Bin 0 -> 46284 bytes 4 files changed, 194 insertions(+), 44 deletions(-) create mode 100644 SettingCommentView_design.png diff --git a/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift b/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift index 45eccdb1..cb87a0c9 100644 --- a/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift +++ b/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift @@ -12,7 +12,6 @@ final class SettingsDataSource { private let apiClient: Client // MARK: - In-memory stores - private var myCommentsStore: [MyComment] = [] private var blockedUsersStore: [BlockedUser] = [] private var notificationPrefsStore: NotificationPrefs = .init( pushEnabled: true, @@ -30,23 +29,6 @@ final class SettingsDataSource { init(apiClient: Client = CodiveAPIProvider.createClient()) { self.apiClient = apiClient - // 내가 남긴 댓글 - myCommentsStore = (1...17).map { i in - let author = SimpleUser( - userId: UserID(300 + i), - nickname: "닉네임\(i)", - handle: "user_\(i)", - avatarURL: nil - ) - return MyComment( - commentId: CommentID(i), - postId: PostID(10 + i), - author: author, - contentPreview: "내 댓글 내용 \(i)", - createdAt: Date().addingTimeInterval(TimeInterval(-i * 3_600)) - ) - } - // 차단한 계정 let u1 = SimpleUser(userId: UserID(101), nickname: "차단유저A", handle: "user_a", avatarURL: nil) let u2 = SimpleUser(userId: UserID(202), nickname: "차단유저B", handle: "user_b", avatarURL: nil) @@ -127,11 +109,101 @@ final class SettingsDataSource { // MARK: - My Comments func fetchMyComments(page: Int, pageSize: Int) async throws -> [MyComment] { - guard page > 0, pageSize > 0 else { return [] } - let start = (page - 1) * pageSize - let end = min(start + pageSize, myCommentsStore.count) - guard start < end else { return [] } - return Array(myCommentsStore[start.. 1 ? Int64((page - 1) * pageSize) : nil + let jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + + let response = try await apiClient.Comment_getMyComments( + query: .init(lastHistoryId: lastHistoryId, size: Int32(pageSize)) + ) + + switch response { + case .ok(let okResponse): + let httpBody = try okResponse.body.any + let data = try await Data(collecting: httpBody, upTo: .max) + + struct APIResponse: Decodable { + let isSuccess: Bool + let code: String + let message: String + let timeStamp: String + let result: Result + + struct Result: Decodable { + let content: [HistoryDTO] + let isLast: Bool + } + } + + struct HistoryDTO: Decodable { + let historyId: Int64 + let imageUrl: String + let nickname: String + let historyDate: String + let content: String + let payloads: [CommentPayloadDTO] + + struct CommentPayloadDTO: Decodable { + let commentId: Int64 + let content: String + } + } + + let apiResponse = try jsonDecoder.decode(APIResponse.self, from: data) + + guard apiResponse.isSuccess else { + throw NSError(domain: "API Error", code: -1, userInfo: [NSLocalizedDescriptionKey: apiResponse.message]) + } + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + + return apiResponse.result.content.map { historyDTO in + let author = SimpleUser( + userId: UserID(historyDTO.historyId), + nickname: historyDTO.nickname, + handle: "", + avatarURL: nil + ) + + let historyDate = dateFormatter.date(from: historyDTO.historyDate) ?? Date() + + // payloads를 replies로 변환 + let replies = historyDTO.payloads.map { payload in + CommentReply( + replyId: CommentID(payload.commentId), + author: SimpleUser( + userId: UserID(payload.commentId), + nickname: "", + handle: "", + avatarURL: nil + ), + content: payload.content, + createdAt: historyDate + ) + } + + return MyComment( + commentId: CommentID(historyDTO.historyId), + postId: PostID(historyDTO.historyId), + author: author, + contentPreview: historyDTO.content, + createdAt: historyDate, + replies: replies + ) + } + + 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("fetchMyComments error response [\(statusCode)]: \(responseBody)") + } + } + } + throw NSError(domain: "SettingDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch my comments"]) + } } // MARK: - Blocked Users diff --git a/Codive/Features/Setting/Domain/Entities/SettingEntity.swift b/Codive/Features/Setting/Domain/Entities/SettingEntity.swift index 6c236e85..d503f5e0 100644 --- a/Codive/Features/Setting/Domain/Entities/SettingEntity.swift +++ b/Codive/Features/Setting/Domain/Entities/SettingEntity.swift @@ -29,26 +29,51 @@ public struct LikedRecord: Hashable, Sendable, Identifiable { } } +// 댓글에 달린 답글 +public struct CommentReply: Hashable, Sendable, Identifiable { + public var id: CommentID { replyId } + public let replyId: CommentID + public let author: SimpleUser + public let content: String + public let createdAt: Date + + public init( + replyId: CommentID, + author: SimpleUser, + content: String, + createdAt: Date + ) { + self.replyId = replyId + self.author = author + self.content = content + self.createdAt = createdAt + } +} + // 내가 남긴 댓글 public struct MyComment: Hashable, Sendable, Identifiable { - public var id: CommentID { commentId } + public var id: CommentID { commentId } public let commentId: CommentID public let postId: PostID public let author: SimpleUser public let contentPreview: String public let createdAt: Date + public let replies: [CommentReply] + public init( commentId: CommentID, postId: PostID, author: SimpleUser, contentPreview: String, - createdAt: Date + createdAt: Date, + replies: [CommentReply] = [] ) { self.commentId = commentId self.postId = postId self.author = author self.contentPreview = contentPreview self.createdAt = createdAt + self.replies = replies } } diff --git a/Codive/Features/Setting/Presentation/View/SettingCommentView.swift b/Codive/Features/Setting/Presentation/View/SettingCommentView.swift index 5d4a0e23..ef2f6005 100644 --- a/Codive/Features/Setting/Presentation/View/SettingCommentView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingCommentView.swift @@ -41,30 +41,83 @@ struct SettingCommentView: View { } else { // 4) 정상 리스트 List { - ForEach(vm.items) { (comment: MyComment) in - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(comment.author.nickname) - .font(.codive_body1_bold) + ForEach(vm.items) { (comment: MyComment) in + VStack(alignment: .leading, spacing: 12) { + // 댓글 본문 + HStack(spacing: 12) { + // 프로필 이미지 + if let avatarURL = comment.author.avatarURL { + AsyncImage(url: avatarURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + case .empty, .failure: + Image(systemName: "person.circle.fill") + .font(.system(size: 32)) + .foregroundStyle(Color.Codive.grayscale5) + @unknown default: + Color.Codive.grayscale5 + } + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } else { + Image(systemName: "person.circle.fill") + .font(.system(size: 32)) + .foregroundStyle(Color.Codive.grayscale5) + } - Spacer() + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(comment.author.nickname) + .font(.codive_body1_bold) + .foregroundStyle(Color.Codive.grayscale1) - Text( - comment.createdAt.formatted( - date: .numeric, - time: .omitted - ) - ) - .font(.codive_body2_regular) - .foregroundStyle(Color.Codive.grayscale4) + Spacer() + + Text( + comment.createdAt.formatted( + date: .numeric, + time: .omitted + ) + ) + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale4) + } + + Text(comment.contentPreview) + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale1) + .lineLimit(1) + } } - Text(comment.contentPreview) - .font(.codive_body2_regular) - .foregroundStyle(Color.Codive.grayscale1) - .lineLimit(3) + // 답글 목록 + if !comment.replies.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ForEach(comment.replies) { reply in + HStack(alignment: .top, spacing: 8) { + Text("L") + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale4) + .frame(width: 16) + + VStack(alignment: .leading, spacing: 2) { + Text(reply.content) + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale1) + .lineLimit(2) + } + } + } + } + .padding(.top, 4) + } } - .padding(.vertical, 6) + .padding(.vertical, 12) + .listRowSeparator(.hidden) } } .listStyle(.plain) diff --git a/SettingCommentView_design.png b/SettingCommentView_design.png new file mode 100644 index 0000000000000000000000000000000000000000..5fe8a76ceb63b67a2d6a1837f232c2426fb41b28 GIT binary patch literal 46284 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 literal 0 HcmV?d00001 From c1165452eb37b19d954caad629774ed95b565769 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 1 Feb 2026 22:45:16 +0900 Subject: [PATCH 16/28] =?UTF-8?q?[#57]=20=EB=82=B4=EA=B0=80=20=EB=82=A8?= =?UTF-8?q?=EA=B8=B4=20=EB=8C=93=EA=B8=80=EC=97=90=EC=84=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EB=A1=9C=20=EC=97=B0?= =?UTF-8?q?=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 --- .../Setting/Presentation/View/SettingCommentView.swift | 3 +++ .../Presentation/ViewModel/MyCommentsViewModel.swift | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Codive/Features/Setting/Presentation/View/SettingCommentView.swift b/Codive/Features/Setting/Presentation/View/SettingCommentView.swift index ef2f6005..b5cc74f2 100644 --- a/Codive/Features/Setting/Presentation/View/SettingCommentView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingCommentView.swift @@ -118,6 +118,9 @@ struct SettingCommentView: View { } .padding(.vertical, 12) .listRowSeparator(.hidden) + .onTapGesture { + vm.navigateToFeedDetail(historyId: Int(comment.postId)) + } } } .listStyle(.plain) diff --git a/Codive/Features/Setting/Presentation/ViewModel/MyCommentsViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/MyCommentsViewModel.swift index 84496823..342fe301 100644 --- a/Codive/Features/Setting/Presentation/ViewModel/MyCommentsViewModel.swift +++ b/Codive/Features/Setting/Presentation/ViewModel/MyCommentsViewModel.swift @@ -34,7 +34,7 @@ final class MyCommentsViewModel: ObservableObject { isLoading = true error = nil defer { isLoading = false } - + do { let data = try await getCommentsUC.fetch(page: 1, size: pageSize) items = data @@ -43,4 +43,9 @@ final class MyCommentsViewModel: ObservableObject { items = [] } } + + @MainActor + func navigateToFeedDetail(historyId: Int) { + navigationRouter.navigate(to: .feedDetail(feedId: historyId)) + } } From 62ad572645c7626e39c4547c3a81646c82d10162 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 1 Feb 2026 22:54:55 +0900 Subject: [PATCH 17/28] =?UTF-8?q?[#57]=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=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Setting/Data/DTOs/LikedHistoryDTO.swift | 29 +---- .../Setting/Data/DTOs/MyCommentDTO.swift | 35 ++++++ .../Data/DataSources/SettingDataSource.swift | 103 +++--------------- .../Data/Mappers/SettingDTOMapper.swift | 60 ++++++++++ .../Domain/Entities/SettingError.swift | 39 +++++++ 5 files changed, 153 insertions(+), 113 deletions(-) create mode 100644 Codive/Features/Setting/Data/DTOs/MyCommentDTO.swift create mode 100644 Codive/Features/Setting/Data/Mappers/SettingDTOMapper.swift create mode 100644 Codive/Features/Setting/Domain/Entities/SettingError.swift diff --git a/Codive/Features/Setting/Data/DTOs/LikedHistoryDTO.swift b/Codive/Features/Setting/Data/DTOs/LikedHistoryDTO.swift index 866c4d87..0e46b5f0 100644 --- a/Codive/Features/Setting/Data/DTOs/LikedHistoryDTO.swift +++ b/Codive/Features/Setting/Data/DTOs/LikedHistoryDTO.swift @@ -1,30 +1,9 @@ import Foundation -// MARK: - API Response DTOs - -struct LikedHistoryPreviewDTO: Decodable { +// MARK: - Liked Records DTO +struct LikedHistoryDTO: Decodable { let id: Int64 let imageUrl: String - let historyDate: Date - let lastLikeId: Int64 - - enum CodingKeys: String, CodingKey { - case id - case imageUrl - case historyDate - case lastLikeId - } -} - -struct SliceResponseDTO: Decodable { - let content: [LikedHistoryPreviewDTO] - let isLast: Bool -} - -struct BaseResponseSliceDTO: Decodable { - let isSuccess: Bool - let code: String - let message: String - let timeStamp: Date - let result: SliceResponseDTO + let historyDate: String + let lastLikeId: Int64? } diff --git a/Codive/Features/Setting/Data/DTOs/MyCommentDTO.swift b/Codive/Features/Setting/Data/DTOs/MyCommentDTO.swift new file mode 100644 index 00000000..1a3c34e4 --- /dev/null +++ b/Codive/Features/Setting/Data/DTOs/MyCommentDTO.swift @@ -0,0 +1,35 @@ +// +// MyCommentDTO.swift +// Codive +// + +import Foundation + +// MARK: - API Response +struct MyCommentsAPIResponse: Decodable { + let isSuccess: Bool + let code: String + let message: String + let timeStamp: String + let result: MyCommentsResult + + struct MyCommentsResult: Decodable { + let content: [HistoryDTO] + let isLast: Bool + } +} + +// MARK: - History DTO +struct HistoryDTO: Decodable { + let historyId: Int64 + let imageUrl: String + let nickname: String + let historyDate: String + let content: String + let payloads: [CommentPayloadDTO] + + struct CommentPayloadDTO: Decodable { + let commentId: Int64 + let content: String + } +} diff --git a/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift b/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift index cb87a0c9..19979f09 100644 --- a/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift +++ b/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift @@ -52,30 +52,23 @@ final class SettingsDataSource { let httpBody = try okResponse.body.any let data = try await Data(collecting: httpBody, upTo: .max) - struct APIResponse: Decodable { + struct LikedRecordsResult: Decodable { + let content: [LikedHistoryDTO] + let isLast: Bool + } + + struct LikedRecordsAPIResponse: Decodable { let isSuccess: Bool let code: String let message: String let timeStamp: String - let result: Result - - struct Result: Decodable { - let content: [LikedHistoryDTO] - let isLast: Bool - } - } - - struct LikedHistoryDTO: Decodable { - let id: Int64 - let imageUrl: String - let historyDate: String - let lastLikeId: Int64? + let result: LikedRecordsResult } - let apiResponse = try jsonDecoder.decode(APIResponse.self, from: data) + let apiResponse = try jsonDecoder.decode(LikedRecordsAPIResponse.self, from: data) guard apiResponse.isSuccess else { - throw NSError(domain: "API Error", code: -1, userInfo: [NSLocalizedDescriptionKey: apiResponse.message]) + throw SettingError.apiError(message: apiResponse.message) } let dateFormatter = DateFormatter() @@ -83,15 +76,7 @@ final class SettingsDataSource { dateFormatter.locale = Locale(identifier: "en_US_POSIX") return apiResponse.result.content.map { dto in - let url = URL(string: dto.imageUrl) ?? URL(fileURLWithPath: "") - let historyDate = dateFormatter.date(from: dto.historyDate) ?? Date() - - return LikedRecord( - id: dto.id, - thumbnailURL: url, - historyDate: historyDate, - lastLikeId: dto.lastLikeId ?? 0 - ) + SettingDTOMapper.mapLikedHistoryDTOToLikedRecord(dto, with: dateFormatter) } default: @@ -103,7 +88,7 @@ final class SettingsDataSource { } } } - throw NSError(domain: "SettingDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch liked records"]) + throw SettingError.networkError } } @@ -121,37 +106,10 @@ final class SettingsDataSource { let httpBody = try okResponse.body.any let data = try await Data(collecting: httpBody, upTo: .max) - struct APIResponse: Decodable { - let isSuccess: Bool - let code: String - let message: String - let timeStamp: String - let result: Result - - struct Result: Decodable { - let content: [HistoryDTO] - let isLast: Bool - } - } - - struct HistoryDTO: Decodable { - let historyId: Int64 - let imageUrl: String - let nickname: String - let historyDate: String - let content: String - let payloads: [CommentPayloadDTO] - - struct CommentPayloadDTO: Decodable { - let commentId: Int64 - let content: String - } - } - - let apiResponse = try jsonDecoder.decode(APIResponse.self, from: data) + let apiResponse = try jsonDecoder.decode(MyCommentsAPIResponse.self, from: data) guard apiResponse.isSuccess else { - throw NSError(domain: "API Error", code: -1, userInfo: [NSLocalizedDescriptionKey: apiResponse.message]) + throw SettingError.apiError(message: apiResponse.message) } let dateFormatter = DateFormatter() @@ -159,38 +117,7 @@ final class SettingsDataSource { dateFormatter.locale = Locale(identifier: "en_US_POSIX") return apiResponse.result.content.map { historyDTO in - let author = SimpleUser( - userId: UserID(historyDTO.historyId), - nickname: historyDTO.nickname, - handle: "", - avatarURL: nil - ) - - let historyDate = dateFormatter.date(from: historyDTO.historyDate) ?? Date() - - // payloads를 replies로 변환 - let replies = historyDTO.payloads.map { payload in - CommentReply( - replyId: CommentID(payload.commentId), - author: SimpleUser( - userId: UserID(payload.commentId), - nickname: "", - handle: "", - avatarURL: nil - ), - content: payload.content, - createdAt: historyDate - ) - } - - return MyComment( - commentId: CommentID(historyDTO.historyId), - postId: PostID(historyDTO.historyId), - author: author, - contentPreview: historyDTO.content, - createdAt: historyDate, - replies: replies - ) + SettingDTOMapper.mapHistoryDTOToMyComment(historyDTO, with: dateFormatter) } default: @@ -202,7 +129,7 @@ final class SettingsDataSource { } } } - throw NSError(domain: "SettingDataSource", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch my comments"]) + throw SettingError.networkError } } diff --git a/Codive/Features/Setting/Data/Mappers/SettingDTOMapper.swift b/Codive/Features/Setting/Data/Mappers/SettingDTOMapper.swift new file mode 100644 index 00000000..56cd0bda --- /dev/null +++ b/Codive/Features/Setting/Data/Mappers/SettingDTOMapper.swift @@ -0,0 +1,60 @@ +// +// SettingDTOMapper.swift +// Codive +// + +import Foundation + +// Domain entities +// DTOs + +struct SettingDTOMapper { + + // MARK: - My Comments + static func mapHistoryDTOToMyComment(_ dto: HistoryDTO, with dateFormatter: DateFormatter) -> MyComment { + let author = SimpleUser( + userId: UserID(dto.historyId), + nickname: dto.nickname, + handle: "", + avatarURL: nil + ) + + let historyDate = dateFormatter.date(from: dto.historyDate) ?? Date() + + let replies = dto.payloads.map { payload in + CommentReply( + replyId: CommentID(payload.commentId), + author: SimpleUser( + userId: UserID(payload.commentId), + nickname: "", + handle: "", + avatarURL: nil + ), + content: payload.content, + createdAt: historyDate + ) + } + + return MyComment( + commentId: CommentID(dto.historyId), + postId: PostID(dto.historyId), + author: author, + contentPreview: dto.content, + createdAt: historyDate, + replies: replies + ) + } + + // MARK: - Liked Records + static func mapLikedHistoryDTOToLikedRecord(_ dto: LikedHistoryDTO, with dateFormatter: DateFormatter) -> LikedRecord { + let url = URL(string: dto.imageUrl) ?? URL(fileURLWithPath: "") + let historyDate = dateFormatter.date(from: dto.historyDate) ?? Date() + + return LikedRecord( + id: dto.id, + thumbnailURL: url, + historyDate: historyDate, + lastLikeId: dto.lastLikeId ?? 0 + ) + } +} diff --git a/Codive/Features/Setting/Domain/Entities/SettingError.swift b/Codive/Features/Setting/Domain/Entities/SettingError.swift new file mode 100644 index 00000000..bc4572b4 --- /dev/null +++ b/Codive/Features/Setting/Domain/Entities/SettingError.swift @@ -0,0 +1,39 @@ +// +// SettingError.swift +// Codive +// + +import Foundation + +enum SettingError: LocalizedError { + case apiError(message: String) + case decodingError(String) + case networkError + case unknown + + var errorDescription: String? { + switch self { + case .apiError(let message): + return message + case .decodingError(let details): + return "데이터 처리 오류: \(details)" + case .networkError: + return "네트워크 연결을 확인해주세요." + case .unknown: + return "알 수 없는 오류가 발생했습니다." + } + } + + var recoverySuggestion: String? { + switch self { + case .apiError: + return "다시 시도해주세요." + case .decodingError: + return "앱을 다시 시작해주세요." + case .networkError: + return "네트워크 연결을 확인하고 다시 시도해주세요." + case .unknown: + return "다시 시도해주세요." + } + } +} From 99b24741f8caac7f4f00abbd60fcad35fa147613 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 1 Feb 2026 23:12:53 +0900 Subject: [PATCH 18/28] =?UTF-8?q?[#57]=20=EC=B0=A8=EB=8B=A8=ED=95=9C=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20UI=20=EB=B0=8F=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Setting/Data/DTOs/BlockedUserDTO.swift | 29 ++++++++ .../Data/DataSources/SettingDataSource.swift | 67 +++++++++++++++---- .../Data/Mappers/SettingDTOMapper.swift | 14 ++++ .../View/SettingBlockedView.swift | 11 +-- 4 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 Codive/Features/Setting/Data/DTOs/BlockedUserDTO.swift diff --git a/Codive/Features/Setting/Data/DTOs/BlockedUserDTO.swift b/Codive/Features/Setting/Data/DTOs/BlockedUserDTO.swift new file mode 100644 index 00000000..04540b1a --- /dev/null +++ b/Codive/Features/Setting/Data/DTOs/BlockedUserDTO.swift @@ -0,0 +1,29 @@ +// +// BlockedUserDTO.swift +// Codive +// + +import Foundation + +// MARK: - Blocked Members API Response +struct BlockedMembersAPIResponse: Decodable { + let isSuccess: Bool + let code: String + let message: String + let timeStamp: String + let result: BlockedMembersResult + + struct BlockedMembersResult: Decodable { + let content: [BlockedMemberDTO] + let isLast: Bool + } +} + +// MARK: - Blocked Member DTO +struct BlockedMemberDTO: Decodable { + let userId: Int64 + let nickname: String + let handle: String + let profileImageUrl: String? + let blockedAt: String +} diff --git a/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift b/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift index 19979f09..3f4c756b 100644 --- a/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift +++ b/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift @@ -12,7 +12,6 @@ final class SettingsDataSource { private let apiClient: Client // MARK: - In-memory stores - private var blockedUsersStore: [BlockedUser] = [] private var notificationPrefsStore: NotificationPrefs = .init( pushEnabled: true, marketingOptIn: false @@ -28,14 +27,6 @@ final class SettingsDataSource { // MARK: - Init init(apiClient: Client = CodiveAPIProvider.createClient()) { self.apiClient = apiClient - - // 차단한 계정 - let u1 = SimpleUser(userId: UserID(101), nickname: "차단유저A", handle: "user_a", avatarURL: nil) - let u2 = SimpleUser(userId: UserID(202), nickname: "차단유저B", handle: "user_b", avatarURL: nil) - blockedUsersStore = [ - BlockedUser(user: u1, blockedAt: Date().addingTimeInterval(-86_400)), - BlockedUser(user: u2, blockedAt: Date().addingTimeInterval(-172_800)) - ] } // MARK: - Liked Records @@ -135,12 +126,64 @@ final class SettingsDataSource { // MARK: - Blocked Users func fetchBlockedUsers() async throws -> [BlockedUser] { - blockedUsersStore + let jsonDecoder = JSONDecoderFactory.makeAPIDecoder() + + let response = try await apiClient.Member_getBlockedMembers( + query: .init(size: 500) + ) + + 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(BlockedMembersAPIResponse.self, from: data) + + guard apiResponse.isSuccess else { + throw SettingError.apiError(message: apiResponse.message) + } + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + + return apiResponse.result.content.map { dto in + SettingDTOMapper.mapBlockedMemberDTOToBlockedUser(dto, with: dateFormatter) + } + + 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("fetchBlockedUsers error response [\(statusCode)]: \(responseBody)") + } + } + } + throw SettingError.networkError + } } func unblock(userId: UserID) async throws { - if let idx = blockedUsersStore.firstIndex(where: { $0.id == userId }) { - blockedUsersStore.remove(at: idx) + // API 호출로 차단 해제 + let response = try await apiClient.Member_toggleBlockStatus( + path: .init(memberId: Int64(userId)) + ) + + switch response { + case .ok: + // 성공 + return + 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("unblock error response [\(statusCode)]: \(responseBody)") + } + } + } + throw SettingError.networkError } } diff --git a/Codive/Features/Setting/Data/Mappers/SettingDTOMapper.swift b/Codive/Features/Setting/Data/Mappers/SettingDTOMapper.swift index 56cd0bda..6c558ae3 100644 --- a/Codive/Features/Setting/Data/Mappers/SettingDTOMapper.swift +++ b/Codive/Features/Setting/Data/Mappers/SettingDTOMapper.swift @@ -57,4 +57,18 @@ struct SettingDTOMapper { lastLikeId: dto.lastLikeId ?? 0 ) } + + // MARK: - Blocked Users + static func mapBlockedMemberDTOToBlockedUser(_ dto: BlockedMemberDTO, with dateFormatter: DateFormatter) -> BlockedUser { + let avatarURL = dto.profileImageUrl.flatMap { URL(string: $0) } + let author = SimpleUser( + userId: UserID(Int(dto.userId)), + nickname: dto.nickname, + handle: "", + avatarURL: avatarURL + ) + let blockedDate = dateFormatter.date(from: dto.blockedAt) ?? Date() + + return BlockedUser(user: author, blockedAt: blockedDate) + } } diff --git a/Codive/Features/Setting/Presentation/View/SettingBlockedView.swift b/Codive/Features/Setting/Presentation/View/SettingBlockedView.swift index cbe0dd6f..ebd69900 100644 --- a/Codive/Features/Setting/Presentation/View/SettingBlockedView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingBlockedView.swift @@ -24,7 +24,7 @@ struct SettingBlockedView: View { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = vm.error, vm.items.isEmpty { - VStack(spacing: 12) { + VStack { Text(TextLiteral.Setting.loadFailed) .font(.codive_title2) Text(error.localizedDescription) @@ -36,8 +36,8 @@ struct SettingBlockedView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if vm.items.isEmpty { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") + VStack { + Image("orangeWarning") .font(.system(size: 40)) .foregroundStyle(Color.Codive.main2) Text(TextLiteral.Setting.blockedUsersEmpty) @@ -49,11 +49,12 @@ struct SettingBlockedView: View { CustomUserRow( user: bu.user, buttonTitle: TextLiteral.Setting.unblock, - buttonStyle: .secondary + buttonStyle: .secondary ) { Task { await vm.tapUnblock(userId: bu.id) } } - .padding(.vertical, 4) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) } .listStyle(.plain) } From 0f9c714156e5b94d36f759d25273ab9bc1913c91 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 1 Feb 2026 23:33:32 +0900 Subject: [PATCH 19/28] =?UTF-8?q?[#57]=20=EA=B2=BD=EA=B3=A0=201=EC=B0=A8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Application/AppRootView.swift | 12 ++++------ .../Auth/Data/SocialAuthService.swift | 4 ++-- Codive/Features/Auth/Data/TokenService.swift | 2 +- .../Presentation/View/OnboardingView.swift | 7 +++--- .../View/TermsAgreementView.swift | 18 +++++++------- .../Components/ClothingCardView.swift | 3 +-- .../Presentation/View/CommentView.swift | 4 ++-- .../Data/DataSources/FeedDataSource.swift | 2 +- .../Feed/Data/HistoryAPIService.swift | 2 +- .../Add/ViewModel/RecordDetailViewModel.swift | 1 - .../FeedDetail/View/FeedDetailView.swift | 4 ++-- .../ViewModel/FeedDetailViewModel.swift | 1 - .../UseCases/UpdateProfileUseCase.swift | 2 +- .../ViewModel/ProfileSettingViewModel.swift | 12 +++++----- .../ViewModel/ProfileViewModel.swift | 6 ++--- .../ViewModel/SearchResultViewModel.swift | 2 +- .../Presentation/View/SettingView.swift | 24 +++++++++---------- .../Router/ViewFactory/AuthViewFactory.swift | 2 +- .../Shared/DesignSystem/Views/CodiCard.swift | 2 +- 19 files changed, 50 insertions(+), 60 deletions(-) diff --git a/Codive/Application/AppRootView.swift b/Codive/Application/AppRootView.swift index 4693653b..490b2dc8 100644 --- a/Codive/Application/AppRootView.swift +++ b/Codive/Application/AppRootView.swift @@ -35,11 +35,9 @@ struct AppRootView: View { authDIContainer.makeAuthFlowView() case .termsAgreement: - TermsAgreementView( - onComplete: { - appRouter.navigateToMain() - } - ) + TermsAgreementView { + appRouter.navigateToMain() + } case .main: MainTabView(appDIContainer: appDIContainer) @@ -75,8 +73,8 @@ struct AppRootView: View { return } - let accessToken = queryItems.first(where: { $0.name == "accessToken" })?.value - let refreshToken = queryItems.first(where: { $0.name == "refreshToken" })?.value + let accessToken = queryItems.first { $0.name == "accessToken" }?.value + let refreshToken = queryItems.first { $0.name == "refreshToken" }?.value guard let unwrappedAccessToken = accessToken, let unwrappedRefreshToken = refreshToken else { diff --git a/Codive/Features/Auth/Data/SocialAuthService.swift b/Codive/Features/Auth/Data/SocialAuthService.swift index 1c4bccab..7cbbee4f 100644 --- a/Codive/Features/Auth/Data/SocialAuthService.swift +++ b/Codive/Features/Auth/Data/SocialAuthService.swift @@ -65,8 +65,8 @@ final class SocialAuthService: NSObject, SocialAuthServiceProtocol { return } - let accessToken = queryItems.first(where: { $0.name == "accessToken" })?.value - let refreshToken = queryItems.first(where: { $0.name == "refreshToken" })?.value + let accessToken = queryItems.first { $0.name == "accessToken" }?.value + let refreshToken = queryItems.first { $0.name == "refreshToken" }?.value guard let accessToken = accessToken, let refreshToken = refreshToken else { diff --git a/Codive/Features/Auth/Data/TokenService.swift b/Codive/Features/Auth/Data/TokenService.swift index 3e61857b..6555082e 100644 --- a/Codive/Features/Auth/Data/TokenService.swift +++ b/Codive/Features/Auth/Data/TokenService.swift @@ -38,7 +38,7 @@ final class TokenService: TokenServiceProtocol { /// 키체인에 유효한 토큰이 있는지 확인 func hasValidTokens() -> Bool { - guard let _ = getAccessToken(), let _ = getRefreshToken() else { + guard getAccessToken() != nil, getRefreshToken() != nil else { return false } return true diff --git a/Codive/Features/Auth/Presentation/View/OnboardingView.swift b/Codive/Features/Auth/Presentation/View/OnboardingView.swift index b4fd0ca4..273af646 100644 --- a/Codive/Features/Auth/Presentation/View/OnboardingView.swift +++ b/Codive/Features/Auth/Presentation/View/OnboardingView.swift @@ -78,7 +78,7 @@ struct OnboardingView: View { VStack { Spacer() - VStack{ + VStack { // 온보딩 멘트 슬라이드 TabView(selection: $currentPage) { ForEach(0.. MyProfileInfo { // 이미지가 있으면 먼저 업로드 - var imageUrlToUpdate: String? = nil + var imageUrlToUpdate: String? if let imageData = imageData { imageUrlToUpdate = try await repository.uploadProfileImage(imageData) } diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift index 7d21c73d..e8e4285a 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileSettingViewModel.swift @@ -65,16 +65,16 @@ final class ProfileSettingViewModel: ObservableObject { } } - @Published var pickedProfileImage: Image? = nil - @Published var selectedPhotoPickerItem: PhotosPickerItem? = nil + @Published var pickedProfileImage: Image? + @Published var selectedPhotoPickerItem: PhotosPickerItem? @Published var isLoading: Bool = false - @Published var errorMessage: String? = nil - @Published var currentProfileImageUrl: String? = nil + @Published var errorMessage: String? + @Published var currentProfileImageUrl: String? @Published private(set) var canComplete: Bool = false @Published var isLoadingProfile: Bool = false - private var selectedImageData: Data? = nil + private var selectedImageData: Data? // MARK: - Initializer init(navigationRouter: NavigationRouter, updateProfileUseCase: UpdateProfileUseCase, profileRepository: ProfileRepository) { @@ -241,7 +241,7 @@ final class ProfileSettingViewModel: ObservableObject { } do { - let _ = try await updateProfileUseCase.execute( + _ = try await updateProfileUseCase.execute( nickname: nickname, bio: intro, isPublic: isPublic, diff --git a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift index 649b34af..38bd694d 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift @@ -90,10 +90,8 @@ class ProfileViewModel: ObservableObject { // 같은 날짜에 여러 기록이 있으면 첫 번째만 사용 var historyMap: [String: String] = [:] - for item in items { - if historyMap[item.historyDate] == nil { - historyMap[item.historyDate] = item.firstImageUrl - } + for item in items where historyMap[item.historyDate] == nil { + historyMap[item.historyDate] = item.firstImageUrl } self.monthlyHistories = historyMap diff --git a/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift b/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift index a60a98a1..d476693e 100644 --- a/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift +++ b/Codive/Features/Search/Presentation/ViewModel/SearchResultViewModel.swift @@ -45,7 +45,7 @@ final class SearchResultViewModel: ObservableObject { private func setupBindings() { $currentSort .removeDuplicates() - .sink { [weak self] newSort in + .sink { [weak self] _ in Task { await self?.loadPosts() } diff --git a/Codive/Features/Setting/Presentation/View/SettingView.swift b/Codive/Features/Setting/Presentation/View/SettingView.swift index 1f148e33..3342592f 100644 --- a/Codive/Features/Setting/Presentation/View/SettingView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingView.swift @@ -78,27 +78,27 @@ struct SettingView: View { Button(action: { vm.navigateToLikedRecords() - }) { + }, label: { SettingRow(text: TextLiteral.Setting.likedRecords) - } + }) .buttonStyle(.plain) .contentShape(Rectangle()) .padding(.bottom, 12) Button(action: { vm.navigateToMyComments() - }) { + }, label: { SettingRow(text: TextLiteral.Setting.myComments) - } + }) .buttonStyle(.plain) .contentShape(Rectangle()) .padding(.bottom, 12) Button(action: { vm.navigateToBlockedUsers() - }) { + }, label: { SettingRow(text: TextLiteral.Setting.blockedUsers) - } + }) .buttonStyle(.plain) .contentShape(Rectangle()) } @@ -177,18 +177,18 @@ struct SettingView: View { Button(action: { vm.navigateToInquiry() - }) { + }, label: { SettingRow(text: TextLiteral.Setting.inquiry) - } + }) .buttonStyle(.plain) .contentShape(Rectangle()) .padding(.bottom, 12) Button(action: { showLogoutAlert = true - }) { + }, label: { SettingRow(text: TextLiteral.Setting.logout) - } + }) .buttonStyle(.plain) .contentShape(Rectangle()) .padding(.bottom, 12) @@ -205,9 +205,9 @@ struct SettingView: View { Button(action: { vm.navigateToWithdraw() - }) { + }, label: { SettingRow(text: TextLiteral.Setting.withdraw) - } + }) .buttonStyle(.plain) .contentShape(Rectangle()) } diff --git a/Codive/Router/ViewFactory/AuthViewFactory.swift b/Codive/Router/ViewFactory/AuthViewFactory.swift index db025d9e..fd047a87 100644 --- a/Codive/Router/ViewFactory/AuthViewFactory.swift +++ b/Codive/Router/ViewFactory/AuthViewFactory.swift @@ -30,7 +30,7 @@ final class AuthViewFactory { Text("회원가입 화면") // 임시 case .termsAgreement: // AppRootView에서 직접 처리하므로 여기서는 사용되지 않음 - TermsAgreementView(onComplete: {}) + TermsAgreementView {} default: EmptyView() } diff --git a/Codive/Shared/DesignSystem/Views/CodiCard.swift b/Codive/Shared/DesignSystem/Views/CodiCard.swift index 535e690b..216111d8 100644 --- a/Codive/Shared/DesignSystem/Views/CodiCard.swift +++ b/Codive/Shared/DesignSystem/Views/CodiCard.swift @@ -27,7 +27,7 @@ struct CodiCard: View { var iconPadding: CGFloat = 12 var iconSize: CGFloat = 20 - var onCardTap: (() -> Void)? = nil + var onCardTap: (() -> Void)? init( imageURL: URL?, From fc082f9d7f99b0c54b525548c88cc4d7825d1fdb Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 1 Feb 2026 23:42:39 +0900 Subject: [PATCH 20/28] =?UTF-8?q?[#57]=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=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/SettingDIContainer.swift | 2 +- .../ViewModel/WithdrawViewModel.swift | 33 ++++++++++++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/Codive/DIContainer/SettingDIContainer.swift b/Codive/DIContainer/SettingDIContainer.swift index 4f05951b..477cb292 100644 --- a/Codive/DIContainer/SettingDIContainer.swift +++ b/Codive/DIContainer/SettingDIContainer.swift @@ -110,7 +110,7 @@ final class SettingDIContainer { } func makeWithdrawViewModel() -> WithdrawViewModel { - WithdrawViewModel(navigationRouter: navigationRouter) + WithdrawViewModel(navigationRouter: navigationRouter, appRouter: appRouter, apiClient: apiClient) } // MARK: - Views diff --git a/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift index 9b500ad1..11971ac5 100644 --- a/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift +++ b/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import CodiveAPI @MainActor final class WithdrawViewModel: ObservableObject { @@ -7,9 +8,13 @@ final class WithdrawViewModel: ObservableObject { @Published var showConfirmAlert: Bool = false private let navigationRouter: NavigationRouter + private let appRouter: AppRouter + private let apiClient: Client - init(navigationRouter: NavigationRouter) { + init(navigationRouter: NavigationRouter, appRouter: AppRouter, apiClient: Client = CodiveAPIProvider.createClient(middlewares: [CodiveAuthMiddleware(provider: KeychainTokenProvider())])) { self.navigationRouter = navigationRouter + self.appRouter = appRouter + self.apiClient = apiClient } func onWithdrawTapped() { @@ -18,9 +23,29 @@ final class WithdrawViewModel: ObservableObject { func confirmWithdraw() { isLoading = true - // TODO: 실제 탈퇴 API 호출 - // 이후 AppRouter를 통해 로그인 화면으로 이동 - isLoading = false + Task { + do { + let response = try await apiClient.Auth_withdrawMember() + + switch response { + case .ok: + // 탈퇴 성공 - 로그인 화면으로 이동 + await MainActor.run { + isLoading = false + appRouter.logout() + } + default: + await MainActor.run { + isLoading = false + } + } + } catch { + await MainActor.run { + isLoading = false + print("탈퇴 실패: \(error.localizedDescription)") + } + } + } } func navigateBack() { From 59c6894f52b8e5b458267159509553117228a725 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:16:32 +0900 Subject: [PATCH 21/28] =?UTF-8?q?[#57]=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/DIContainer/SettingDIContainer.swift | 10 ++++++- .../ViewModel/WithdrawViewModel.swift | 29 ++++++++----------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/Codive/DIContainer/SettingDIContainer.swift b/Codive/DIContainer/SettingDIContainer.swift index 477cb292..dba42fe8 100644 --- a/Codive/DIContainer/SettingDIContainer.swift +++ b/Codive/DIContainer/SettingDIContainer.swift @@ -75,6 +75,10 @@ final class SettingDIContainer { GetWithdrawNoticesUseCase(repository: repository) } + func makeWithdrawAccountUseCase() -> WithdrawAccountUseCase { + WithdrawAccountUseCase(repository: repository) + } + // MARK: - ViewModels func makeSettingViewModel() -> SettingViewModel { SettingViewModel( @@ -110,7 +114,11 @@ final class SettingDIContainer { } func makeWithdrawViewModel() -> WithdrawViewModel { - WithdrawViewModel(navigationRouter: navigationRouter, appRouter: appRouter, apiClient: apiClient) + WithdrawViewModel( + navigationRouter: navigationRouter, + appRouter: appRouter, + withdrawUC: makeWithdrawAccountUseCase() + ) } // MARK: - Views diff --git a/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift index 11971ac5..dc675c51 100644 --- a/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift +++ b/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift @@ -1,5 +1,4 @@ import Foundation -import CodiveAPI @MainActor final class WithdrawViewModel: ObservableObject { @@ -9,12 +8,16 @@ final class WithdrawViewModel: ObservableObject { private let navigationRouter: NavigationRouter private let appRouter: AppRouter - private let apiClient: Client + private let withdrawUC: WithdrawAccountUseCase - init(navigationRouter: NavigationRouter, appRouter: AppRouter, apiClient: Client = CodiveAPIProvider.createClient(middlewares: [CodiveAuthMiddleware(provider: KeychainTokenProvider())])) { + init( + navigationRouter: NavigationRouter, + appRouter: AppRouter, + withdrawUC: WithdrawAccountUseCase + ) { self.navigationRouter = navigationRouter self.appRouter = appRouter - self.apiClient = apiClient + self.withdrawUC = withdrawUC } func onWithdrawTapped() { @@ -25,19 +28,11 @@ final class WithdrawViewModel: ObservableObject { isLoading = true Task { do { - let response = try await apiClient.Auth_withdrawMember() - - switch response { - case .ok: - // 탈퇴 성공 - 로그인 화면으로 이동 - await MainActor.run { - isLoading = false - appRouter.logout() - } - default: - await MainActor.run { - isLoading = false - } + try await withdrawUC.execute() + + await MainActor.run { + isLoading = false + appRouter.logout() } } catch { await MainActor.run { From 73d45fe477c7ba73839f9c38322ca65dd159200a Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:23:32 +0900 Subject: [PATCH 22/28] =?UTF-8?q?[#57]=20=ED=83=88=ED=87=B4=20=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Auth/Presentation/View/AuthFlowView.swift | 6 +++--- .../Features/Setting/Presentation/View/WithdrawView.swift | 7 +++++++ .../Presentation/ViewModel/WithdrawViewModel.swift | 8 +++++++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Codive/Features/Auth/Presentation/View/AuthFlowView.swift b/Codive/Features/Auth/Presentation/View/AuthFlowView.swift index 76fce29e..793e341c 100644 --- a/Codive/Features/Auth/Presentation/View/AuthFlowView.swift +++ b/Codive/Features/Auth/Presentation/View/AuthFlowView.swift @@ -8,11 +8,11 @@ import SwiftUI struct AuthFlowView: View { - + @StateObject var navigationRouter: NavigationRouter private let authViewFactory: AuthViewFactory private let authDIContainer: AuthDIContainer - + init( authDIContainer: AuthDIContainer, authViewFactory: AuthViewFactory @@ -21,7 +21,7 @@ struct AuthFlowView: View { self.authViewFactory = authViewFactory self.authDIContainer = authDIContainer } - + var body: some View { NavigationStack(path: $navigationRouter.path) { authDIContainer.makeOnboardingView() diff --git a/Codive/Features/Setting/Presentation/View/WithdrawView.swift b/Codive/Features/Setting/Presentation/View/WithdrawView.swift index 62e87f01..4b749083 100644 --- a/Codive/Features/Setting/Presentation/View/WithdrawView.swift +++ b/Codive/Features/Setting/Presentation/View/WithdrawView.swift @@ -68,6 +68,13 @@ struct WithdrawView: View { } message: { Text(TextLiteral.Setting.withdrawConfirmMessage) } + .alert("탈퇴 완료", isPresented: $vm.showCompleteAlert) { + Button("확인") { + vm.confirmWithdrawComplete() + } + } message: { + Text("탈퇴가 완료되었습니다.") + } } } diff --git a/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift index dc675c51..566a0822 100644 --- a/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift +++ b/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift @@ -5,6 +5,7 @@ final class WithdrawViewModel: ObservableObject { @Published var isLoading: Bool = false @Published var showConfirmAlert: Bool = false + @Published var showCompleteAlert: Bool = false private let navigationRouter: NavigationRouter private let appRouter: AppRouter @@ -32,7 +33,7 @@ final class WithdrawViewModel: ObservableObject { await MainActor.run { isLoading = false - appRouter.logout() + showCompleteAlert = true } } catch { await MainActor.run { @@ -43,6 +44,11 @@ final class WithdrawViewModel: ObservableObject { } } + func confirmWithdrawComplete() { + showCompleteAlert = false + appRouter.logout() + } + func navigateBack() { navigationRouter.navigateBack() } From 76ad5a6568e73fd7fc4d9e66b33548850cbbe9ad Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:29:49 +0900 Subject: [PATCH 23/28] =?UTF-8?q?[#57]=20=EC=84=A4=EC=A0=95=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=97=AC=EB=B0=B1=EB=8F=84=20=ED=84=B0?= =?UTF-8?q?=EC=B9=98=20=EB=B0=98=EC=9D=91=ED=95=98=EA=B2=8C=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 --- Codive/Features/Setting/Presentation/View/SettingView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Codive/Features/Setting/Presentation/View/SettingView.swift b/Codive/Features/Setting/Presentation/View/SettingView.swift index 3342592f..9cceeecc 100644 --- a/Codive/Features/Setting/Presentation/View/SettingView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingView.swift @@ -231,5 +231,6 @@ private struct SettingRow: View { .font(.system(size: 16, weight: .semibold)) .foregroundStyle(Color.Codive.grayscale3) } + .contentShape(Rectangle()) } } From 67d56f64a8838e05c7d7f4bb5e82dc60d427a4b5 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:53:01 +0900 Subject: [PATCH 24/28] =?UTF-8?q?[#57]=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=95=9C=20=EA=B8=B0=EB=A1=9D=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EB=B2=84=ED=8A=BC=20=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Feed/Data/HistoryAPIService.swift | 2 +- Tuist/Package.resolved | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Codive/Features/Feed/Data/HistoryAPIService.swift b/Codive/Features/Feed/Data/HistoryAPIService.swift index a9cbadc7..c1828962 100644 --- a/Codive/Features/Feed/Data/HistoryAPIService.swift +++ b/Codive/Features/Feed/Data/HistoryAPIService.swift @@ -167,7 +167,7 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { } ?? [], likeCount: result.likeCount ?? 0, commentCount: result.commentCount ?? 0, - isLiked: result.isMine ?? false, + isLiked: result.liked ?? false, historyDate: result.historyDate, situationId: result.situationId, situationName: result.situationName, diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 35b31219..0e899ecc 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Alamofire/Alamofire.git", "state" : { - "revision" : "7be73f6c2b5cd90e40798b06ebd5da8f9f79cf88", - "version" : "5.11.0" + "revision" : "3f99050e75bbc6fe71fc323adabb039756680016", + "version" : "5.11.1" } }, { @@ -16,7 +16,7 @@ "location" : "https://github.com/Clokey-dev/CodiveAPI", "state" : { "branch" : "main", - "revision" : "7c2b9056be470366810b6db812ce4f46ea409b5b" + "revision" : "171b3eb47b30ac0aac6bd97bc403c9602137b441" } }, { From fed1687fbc9a1e55a95aae6d60a622189f7d3404 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:59:11 +0900 Subject: [PATCH 25/28] =?UTF-8?q?[#57]=20=EC=84=A4=EC=A0=95=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=94=BC=EB=93=9C=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Feed/Presentation/MainFeed/View/FeedView.swift | 7 +++++++ .../Setting/Presentation/View/SettingCommentView.swift | 2 +- .../Setting/Presentation/View/SettingLikedView.swift | 2 +- .../Presentation/ViewModel/LikedRecordsViewModel.swift | 5 +++++ .../Presentation/ViewModel/MyCommentsViewModel.swift | 5 +++++ 5 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift b/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift index f5a049a9..e2c85888 100644 --- a/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift +++ b/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift @@ -94,6 +94,13 @@ struct FeedView: View { } .presentationDetents([.height(500)]) } + .onAppear { + Task { + if viewModel.feeds.isEmpty { + await viewModel.loadFeeds() + } + } + } .task { if viewModel.feeds.isEmpty { await viewModel.loadFeeds() diff --git a/Codive/Features/Setting/Presentation/View/SettingCommentView.swift b/Codive/Features/Setting/Presentation/View/SettingCommentView.swift index b5cc74f2..f1b70fe9 100644 --- a/Codive/Features/Setting/Presentation/View/SettingCommentView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingCommentView.swift @@ -36,7 +36,7 @@ struct SettingCommentView: View { message: TextLiteral.Setting.myCommentsEmptyMessage, actionTitle: TextLiteral.Setting.goToFeed ) { - // 라우팅 + vm.navigateToFeedTab() } } else { // 4) 정상 리스트 diff --git a/Codive/Features/Setting/Presentation/View/SettingLikedView.swift b/Codive/Features/Setting/Presentation/View/SettingLikedView.swift index 496df1b2..51cd4f45 100644 --- a/Codive/Features/Setting/Presentation/View/SettingLikedView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingLikedView.swift @@ -29,7 +29,7 @@ struct SettingLikedView: View { message: TextLiteral.Setting.likedRecordsEmptyMessage, actionTitle: TextLiteral.Setting.goToFeed ) { - /* 라우팅 */ + vm.navigateToFeedTab() } } else { let itemWidth = geometry.size.width / 3 diff --git a/Codive/Features/Setting/Presentation/ViewModel/LikedRecordsViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/LikedRecordsViewModel.swift index 9756c07a..a30d4cce 100644 --- a/Codive/Features/Setting/Presentation/ViewModel/LikedRecordsViewModel.swift +++ b/Codive/Features/Setting/Presentation/ViewModel/LikedRecordsViewModel.swift @@ -49,4 +49,9 @@ final class LikedRecordsViewModel: ObservableObject { func navigateToFeedDetail(feedId: Int) { navigationRouter.navigate(to: .feedDetail(feedId: feedId)) } + + @MainActor + func navigateToFeedTab() { + navigationRouter.switchTabAndNavigate(to: .feed) + } } diff --git a/Codive/Features/Setting/Presentation/ViewModel/MyCommentsViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/MyCommentsViewModel.swift index 342fe301..71e7b7a4 100644 --- a/Codive/Features/Setting/Presentation/ViewModel/MyCommentsViewModel.swift +++ b/Codive/Features/Setting/Presentation/ViewModel/MyCommentsViewModel.swift @@ -48,4 +48,9 @@ final class MyCommentsViewModel: ObservableObject { func navigateToFeedDetail(historyId: Int) { navigationRouter.navigate(to: .feedDetail(feedId: historyId)) } + + @MainActor + func navigateToFeedTab() { + navigationRouter.switchTabAndNavigate(to: .feed) + } } From d7604d0b01c39a634b1eb722dd3e23bfda192038 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:15:17 +0900 Subject: [PATCH 26/28] =?UTF-8?q?[#57]=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81:?= =?UTF-8?q?=20DI=20=EC=8B=B1=EA=B8=80=ED=86=A4=ED=99=94,=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0,=20?= =?UTF-8?q?=EB=94=94=EB=B2=84=EA=B9=85=20=EB=A1=9C=EA=B7=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/DIContainer/AppDIContainer.swift | 11 +++-------- .../Feed/Presentation/MainFeed/View/FeedView.swift | 7 ------- .../Profile/MyProfile/Data/ProfileAPIService.swift | 3 ++- 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/Codive/DIContainer/AppDIContainer.swift b/Codive/DIContainer/AppDIContainer.swift index 66432de6..8f3d4f5c 100644 --- a/Codive/DIContainer/AppDIContainer.swift +++ b/Codive/DIContainer/AppDIContainer.swift @@ -23,14 +23,9 @@ final class AppDIContainer { lazy var sharedDIContainer = SharedDIContainer() lazy var closetDIContainer = ClosetDIContainer(navigationRouter: navigationRouter) lazy var profileDIContainer = ProfileDIContainer(navigationRouter: navigationRouter) - + lazy var authDIContainer = AuthDIContainer(appRouter: appRouter, navigationRouter: navigationRouter) + // MARK: - Feature DIContainers - func makeAuthDIContainer() -> AuthDIContainer { - return AuthDIContainer( - appRouter: appRouter, - navigationRouter: navigationRouter - ) - } func makeAddDIContainer() -> AddDIContainer { return AddDIContainer( @@ -42,7 +37,7 @@ final class AppDIContainer { } func makeSettingDIContainer() -> SettingDIContainer { - return SettingDIContainer(appRouter: appRouter, navigationRouter: navigationRouter, profileDIContainer: profileDIContainer, authDIContainer: makeAuthDIContainer()) + return SettingDIContainer(appRouter: appRouter, navigationRouter: navigationRouter, profileDIContainer: profileDIContainer, authDIContainer: authDIContainer) } func makeReportDIContainer() -> ReportDIContainer { diff --git a/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift b/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift index e2c85888..f5a049a9 100644 --- a/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift +++ b/Codive/Features/Feed/Presentation/MainFeed/View/FeedView.swift @@ -94,13 +94,6 @@ struct FeedView: View { } .presentationDetents([.height(500)]) } - .onAppear { - Task { - if viewModel.feeds.isEmpty { - await viewModel.loadFeeds() - } - } - } .task { if viewModel.feeds.isEmpty { await viewModel.loadFeeds() diff --git a/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift index 4f95bcde..1810fb8d 100644 --- a/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift +++ b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift @@ -50,7 +50,7 @@ final class ProfileAPIService: ProfileAPIServiceProtocol { throw ProfileAPIError.invalidResponse } - // API 응답 로그 + #if DEBUG print("=== 내 정보 API 응답 ===") print("memberId: \(memberInfo.memberId ?? 0)") print("nickname: \(memberInfo.nickname ?? "nil")") @@ -62,6 +62,7 @@ final class ProfileAPIService: ProfileAPIServiceProtocol { print("isPublic: \(memberInfo.isPublic ?? false)") print("isMe: \(memberInfo.isMe ?? false)") print("======================") + #endif guard let userId = memberInfo.memberId, let nickname = memberInfo.nickname else { From 4663217b5928a0c44e71a4d0efb0eb25ad1e858e Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:16:48 +0900 Subject: [PATCH 27/28] =?UTF-8?q?[#57]=20authDIContainer=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Application/AppRootView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Codive/Application/AppRootView.swift b/Codive/Application/AppRootView.swift index 490b2dc8..60fa0b47 100644 --- a/Codive/Application/AppRootView.swift +++ b/Codive/Application/AppRootView.swift @@ -19,7 +19,7 @@ struct AppRootView: View { init(appDIContainer: AppDIContainer) { self._appRouter = StateObject(wrappedValue: appDIContainer.appRouter) - self.authDIContainer = appDIContainer.makeAuthDIContainer() + self.authDIContainer = appDIContainer.authDIContainer self.appDIContainer = appDIContainer self._authRepository = State(wrappedValue: self.authDIContainer.authRepository) } From f9f5d4c5479e0eda7eaf501377fe80381812f95e Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Wed, 4 Feb 2026 00:16:45 +0900 Subject: [PATCH 28/28] =?UTF-8?q?[#57]=20=EB=82=B4=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Codive/Features/Setting/Data/DTOs/MyCommentDTO.swift | 4 ++-- Codive/Features/Setting/Data/Mappers/SettingDTOMapper.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Codive/Features/Setting/Data/DTOs/MyCommentDTO.swift b/Codive/Features/Setting/Data/DTOs/MyCommentDTO.swift index 1a3c34e4..27d3b2d3 100644 --- a/Codive/Features/Setting/Data/DTOs/MyCommentDTO.swift +++ b/Codive/Features/Setting/Data/DTOs/MyCommentDTO.swift @@ -25,11 +25,11 @@ struct HistoryDTO: Decodable { let imageUrl: String let nickname: String let historyDate: String - let content: String + let content: String? let payloads: [CommentPayloadDTO] struct CommentPayloadDTO: Decodable { let commentId: Int64 - let content: String + let content: String? } } diff --git a/Codive/Features/Setting/Data/Mappers/SettingDTOMapper.swift b/Codive/Features/Setting/Data/Mappers/SettingDTOMapper.swift index 6c558ae3..80321c5b 100644 --- a/Codive/Features/Setting/Data/Mappers/SettingDTOMapper.swift +++ b/Codive/Features/Setting/Data/Mappers/SettingDTOMapper.swift @@ -30,7 +30,7 @@ struct SettingDTOMapper { handle: "", avatarURL: nil ), - content: payload.content, + content: payload.content ?? "", createdAt: historyDate ) } @@ -39,7 +39,7 @@ struct SettingDTOMapper { commentId: CommentID(dto.historyId), postId: PostID(dto.historyId), author: author, - contentPreview: dto.content, + contentPreview: dto.content ?? "", createdAt: historyDate, replies: replies )