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) diff --git a/Codive/Application/AppRootView.swift b/Codive/Application/AppRootView.swift index 4693653b..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) } @@ -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/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/AppDIContainer.swift b/Codive/DIContainer/AppDIContainer.swift index 8794f4f2..8f3d4f5c 100644 --- a/Codive/DIContainer/AppDIContainer.swift +++ b/Codive/DIContainer/AppDIContainer.swift @@ -14,17 +14,18 @@ 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) - + 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( @@ -36,7 +37,7 @@ final class AppDIContainer { } func makeSettingDIContainer() -> SettingDIContainer { - return SettingDIContainer(appRouter: appRouter, navigationRouter: navigationRouter) + return SettingDIContainer(appRouter: appRouter, navigationRouter: navigationRouter, profileDIContainer: profileDIContainer, authDIContainer: authDIContainer) } func makeReportDIContainer() -> ReportDIContainer { diff --git a/Codive/DIContainer/ProfileDIContainer.swift b/Codive/DIContainer/ProfileDIContainer.swift index 7b2625ad..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,12 +54,21 @@ final class ProfileDIContainer { return DefaultUpdateProfileUseCase(repository: profileRepository) } + func makeFetchMonthlyHistoryUseCase() -> FetchMonthlyHistoryUseCase { + return FetchMonthlyHistoryUseCase(historyRepository: historyRepository) + } + // MARK: - ViewModels - func makeProfileViewModel() -> ProfileViewModel { + private lazy var profileViewModel: ProfileViewModel = { return ProfileViewModel( navigationRouter: navigationRouter, - fetchMyProfileUseCase: makeFetchMyProfileUseCase() + fetchMyProfileUseCase: makeFetchMyProfileUseCase(), + fetchMonthlyHistoryUseCase: makeFetchMonthlyHistoryUseCase() ) + }() + + 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..dba42fe8 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 { @@ -14,19 +15,33 @@ final class SettingDIContainer { // MARK: - Dependencies private let appRouter: AppRouter private let navigationRouter: NavigationRouter + private let profileDIContainer: ProfileDIContainer + private let authDIContainer: AuthDIContainer + private let apiClient: Client // 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, + 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 } @@ -60,13 +75,19 @@ final class SettingDIContainer { GetWithdrawNoticesUseCase(repository: repository) } + func makeWithdrawAccountUseCase() -> WithdrawAccountUseCase { + WithdrawAccountUseCase(repository: repository) + } + // MARK: - ViewModels func makeSettingViewModel() -> SettingViewModel { SettingViewModel( appRouter: appRouter, navigationRouter: navigationRouter, getPrefsUC: makeGetNotificationPrefsUseCase(), - updatePrefsUC: makeUpdateNotificationPrefsUseCase() + updatePrefsUC: makeUpdateNotificationPrefsUseCase(), + authRepository: authDIContainer.authRepository, + profileViewModel: profileDIContainer.makeProfileViewModel() ) } @@ -92,6 +113,14 @@ final class SettingDIContainer { ) } + func makeWithdrawViewModel() -> WithdrawViewModel { + WithdrawViewModel( + navigationRouter: navigationRouter, + appRouter: appRouter, + withdrawUC: makeWithdrawAccountUseCase() + ) + } + // MARK: - Views func makeSettingView() -> SettingView { SettingView(viewModel: self.makeSettingViewModel()) @@ -108,4 +137,8 @@ final class SettingDIContainer { func makeSettingBlockedView() -> SettingBlockedView { SettingBlockedView(vm: self.makeBlockedUsersViewModel()) } + + func makeWithdrawView() -> WithdrawView { + WithdrawView(vm: makeWithdrawViewModel()) + } } 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/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/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/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.. (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..7b5db906 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) } @@ -239,25 +245,41 @@ 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) // 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) + }, label: { + Text("\(remainingCount)개 더보기") + .font(.codive_body2_regular) + .foregroundStyle(Color.Codive.grayscale4) + }) + .padding(.top, 8) + } } .padding(.top, 10) } @@ -322,7 +344,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/Features/Feed/Data/DataSources/FeedDataSource.swift b/Codive/Features/Feed/Data/DataSources/FeedDataSource.swift index fce7d9cf..6b8dedf6 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) ) } @@ -392,4 +392,4 @@ final class EmptyFeedDataSource: FeedDataSource { [] } } -#endif \ No newline at end of file +#endif diff --git a/Codive/Features/Feed/Data/HistoryAPIService.swift b/Codive/Features/Feed/Data/HistoryAPIService.swift index d3f9b2f8..c1828962 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 -> [MonthlyHistoryItemDTO] } // MARK: - History Detail DTO @@ -26,6 +27,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,11 +167,12 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { } ?? [], likeCount: result.likeCount ?? 0, commentCount: result.commentCount ?? 0, + isLiked: result.liked ?? false, historyDate: result.historyDate, situationId: result.situationId, situationName: result.situationName, content: result.content, - hashtags: result.hashtags?.compactMap { $0 as? String }, + hashtags: result.hashtags?.compactMap { $0 }, styles: result.styles?.compactMap { style in guard let styleId = style.styleId, let styleName = style.styleName else { return nil } return HistoryStyleDTO(styleId: styleId, styleName: styleName) @@ -223,6 +226,41 @@ final class HistoryAPIService: HistoryAPIServiceProtocol { throw HistoryAPIError.serverError(statusCode: code) } } + + // MARK: - Fetch Monthly History + + 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) + ) + + 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 + MonthlyHistoryItemDTO( + historyId: payload.historyId, + firstImageUrl: payload.firstImageUrl, + historyDate: payload.historyDate + ) + } + + case .undocumented(statusCode: let code, _): + throw HistoryAPIError.serverError(statusCode: code) + } + } } // MARK: - Response Types diff --git a/Codive/Features/Feed/Data/Repositories/HistoryRepositoryImpl.swift b/Codive/Features/Feed/Data/Repositories/HistoryRepositoryImpl.swift new file mode 100644 index 00000000..51905e34 --- /dev/null +++ b/Codive/Features/Feed/Data/Repositories/HistoryRepositoryImpl.swift @@ -0,0 +1,34 @@ +// +// HistoryRepositoryImpl.swift +// Codive +// +// Created by 황상환 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 new file mode 100644 index 00000000..d527ec9c --- /dev/null +++ b/Codive/Features/Feed/Domain/Entities/MonthlyHistoryItem.swift @@ -0,0 +1,22 @@ +// +// MonthlyHistoryItem.swift +// Codive +// +// Created by 황상환 on 1/31/26. +// + +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..bb1d40e5 --- /dev/null +++ b/Codive/Features/Feed/Domain/Protocols/HistoryRepository.swift @@ -0,0 +1,12 @@ +// +// HistoryRepository.swift +// Codive +// +// Created by 황상환 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..71f7886d --- /dev/null +++ b/Codive/Features/Feed/Domain/UseCases/FetchMonthlyHistoryUseCase.swift @@ -0,0 +1,20 @@ +// +// FetchMonthlyHistoryUseCase.swift +// Codive +// +// Created by 황상환 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/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift b/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift index 60f48bd2..a18598fb 100644 --- a/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift +++ b/Codive/Features/Feed/Presentation/Add/ViewModel/RecordDetailViewModel.swift @@ -141,7 +141,6 @@ final class RecordDetailViewModel: ObservableObject { // 7. 성공 시 메인으로 isLoading = false navigationRouter.navigateToRoot() - } catch { isLoading = false errorMessage = error.localizedDescription diff --git a/Codive/Features/Feed/Presentation/FeedDetail/View/FeedDetailView.swift b/Codive/Features/Feed/Presentation/FeedDetail/View/FeedDetailView.swift index 4b5fb429..d5c2e418 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/View/FeedDetailView.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/View/FeedDetailView.swift @@ -139,12 +139,12 @@ struct FeedDetailView: View { .sheet(isPresented: Binding( get: { navigationRouter.sheetDestination != nil && isCommentSheet(navigationRouter.sheetDestination) }, set: { if !$0 { navigationRouter.dismissSheet() } } - ), content: { + )) { if case .comment(let feedId) = navigationRouter.sheetDestination { commentDIContainer.commentViewFactory.makeView(for: .comment(feedId: feedId)) .presentationDetents([.fraction(0.7), .large]) } - }) + } } // MARK: - Helper Methods diff --git a/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift b/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift index 32457709..41881a3a 100644 --- a/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift +++ b/Codive/Features/Feed/Presentation/FeedDetail/ViewModel/FeedDetailViewModel.swift @@ -75,7 +75,6 @@ final class FeedDetailViewModel: ObservableObject { // 각 이미지의 태그를 API로 가져오기 self.displayableTags = await loadTagsForImages(images: fetchedFeed.images) - } catch { errorMessage = TextLiteral.Feed.loadDetailFailed feed = nil 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/Profile/MyProfile/Data/ProfileAPIService.swift b/Codive/Features/Profile/MyProfile/Data/ProfileAPIService.swift index 102efd18..1810fb8d 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,20 @@ final class ProfileAPIService: ProfileAPIServiceProtocol { throw ProfileAPIError.invalidResponse } + #if DEBUG + 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("======================") + #endif + guard let userId = memberInfo.memberId, let nickname = memberInfo.nickname else { throw ProfileAPIError.invalidResponse @@ -62,7 +76,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, _): @@ -117,8 +132,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 +178,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/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/Domain/UseCases/UpdateProfileUseCase.swift b/Codive/Features/Profile/MyProfile/Domain/UseCases/UpdateProfileUseCase.swift index e542e6db..fbbe125a 100644 --- a/Codive/Features/Profile/MyProfile/Domain/UseCases/UpdateProfileUseCase.swift +++ b/Codive/Features/Profile/MyProfile/Domain/UseCases/UpdateProfileUseCase.swift @@ -30,7 +30,7 @@ final class DefaultUpdateProfileUseCase: UpdateProfileUseCase { imageData: Data? ) async throws -> 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/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/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 39dd18c4..38bd694d 100644 --- a/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift +++ b/Codive/Features/Profile/MyProfile/Presentation/ViewModel/ProfileViewModel.swift @@ -17,21 +17,35 @@ 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() // 현재 표시 월 + @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 fetchMonthlyHistoryUseCase: FetchMonthlyHistoryUseCase // MARK: - Initializer - init(navigationRouter: NavigationRouter, fetchMyProfileUseCase: FetchMyProfileUseCase) { + init( + navigationRouter: NavigationRouter, + fetchMyProfileUseCase: FetchMyProfileUseCase, + fetchMonthlyHistoryUseCase: FetchMonthlyHistoryUseCase + ) { self.navigationRouter = navigationRouter self.fetchMyProfileUseCase = fetchMyProfileUseCase + self.fetchMonthlyHistoryUseCase = fetchMonthlyHistoryUseCase } // MARK: - Loading @@ -48,12 +62,42 @@ 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)") } 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 fetchMonthlyHistoryUseCase.execute( + memberId: Int64(userId), + year: year, + month: monthValue + ) + + // 같은 날짜에 여러 기록이 있으면 첫 번째만 사용 + var historyMap: [String: String] = [:] + for item in items where historyMap[item.historyDate] == nil { + historyMap[item.historyDate] = item.firstImageUrl + } + + self.monthlyHistories = historyMap + } catch { + print("월별 기록 로드 실패: \(error.localizedDescription)") + } } // MARK: - Actions 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 581a576c..6ba98636 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,23 +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] + + // 이미지 배경 (전체 셀을 덮음) + 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)) + .background { + if isSelected { + Circle() + .fill(Color.Codive.point1) + .frame(width: 20, height: 20) + } + } + } } } .frame(width: dayCellWidth, height: dayCellHeight) + .clipShape(RoundedRectangle(cornerRadius: 8)) .contentShape(Rectangle()) .onTapGesture { if !item.isPlaceholder { @@ -125,6 +153,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) 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/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/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/DTOs/LikedHistoryDTO.swift b/Codive/Features/Setting/Data/DTOs/LikedHistoryDTO.swift new file mode 100644 index 00000000..0e46b5f0 --- /dev/null +++ b/Codive/Features/Setting/Data/DTOs/LikedHistoryDTO.swift @@ -0,0 +1,9 @@ +import Foundation + +// MARK: - Liked Records DTO +struct LikedHistoryDTO: Decodable { + let id: Int64 + let imageUrl: String + 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..27d3b2d3 --- /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 18c0bc8b..3f4c756b 100644 --- a/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift +++ b/Codive/Features/Setting/Data/DataSources/SettingDataSource.swift @@ -4,14 +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( pushEnabled: true, marketingOptIn: false @@ -24,74 +24,166 @@ 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 // 잘못된 건 그냥 버림 (더미 데이터니까) + // MARK: - Init + init(apiClient: Client = CodiveAPIProvider.createClient()) { + self.apiClient = apiClient + } + + // MARK: - Liked Records + func fetchLikedRecords(page: Int, pageSize: Int) async throws -> [LikedRecord] { + let lastLikeId: Int64? = page > 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 LikedRecordsResult: Decodable { + let content: [LikedHistoryDTO] + let isLast: Bool } - return LikedRecord( - postId: PostID(i), - thumbnailURL: url, - likedAt: Date().addingTimeInterval(TimeInterval(-i * 1_800)) - ) - } + struct LikedRecordsAPIResponse: Decodable { + let isSuccess: Bool + let code: String + let message: String + let timeStamp: String + let result: LikedRecordsResult + } - // 내가 남긴 댓글 - 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 apiResponse = try jsonDecoder.decode(LikedRecordsAPIResponse.self, from: data) - // 차단한 계정 - 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)) - ] - } + guard apiResponse.isSuccess else { + throw SettingError.apiError(message: apiResponse.message) + } - // 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.. [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) + + let apiResponse = try jsonDecoder.decode(MyCommentsAPIResponse.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 { historyDTO in + SettingDTOMapper.mapHistoryDTOToMyComment(historyDTO, 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("fetchMyComments error response [\(statusCode)]: \(responseBody)") + } + } + } + throw SettingError.networkError + } } // 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 new file mode 100644 index 00000000..80321c5b --- /dev/null +++ b/Codive/Features/Setting/Data/Mappers/SettingDTOMapper.swift @@ -0,0 +1,74 @@ +// +// 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 + ) + } + + // 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/Domain/Entities/SettingEntity.swift b/Codive/Features/Setting/Domain/Entities/SettingEntity.swift index 6ab772ae..d503f5e0 100644 --- a/Codive/Features/Setting/Domain/Entities/SettingEntity.swift +++ b/Codive/Features/Setting/Domain/Entities/SettingEntity.swift @@ -16,37 +16,64 @@ 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 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 var id: PostID { postId } } // 내가 남긴 댓글 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/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 "다시 시도해주세요." + } + } +} 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) } diff --git a/Codive/Features/Setting/Presentation/View/SettingCommentView.swift b/Codive/Features/Setting/Presentation/View/SettingCommentView.swift index 5d4a0e23..f1b70fe9 100644 --- a/Codive/Features/Setting/Presentation/View/SettingCommentView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingCommentView.swift @@ -36,35 +36,91 @@ struct SettingCommentView: View { message: TextLiteral.Setting.myCommentsEmptyMessage, actionTitle: TextLiteral.Setting.goToFeed ) { - // 라우팅 + vm.navigateToFeedTab() } } 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, 12) + .listRowSeparator(.hidden) + .onTapGesture { + vm.navigateToFeedDetail(historyId: Int(comment.postId)) } - .padding(.vertical, 6) } } .listStyle(.plain) diff --git a/Codive/Features/Setting/Presentation/View/SettingLikedView.swift b/Codive/Features/Setting/Presentation/View/SettingLikedView.swift index 5f39215e..51cd4f45 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 + ) { + vm.navigateToFeedTab() } - }.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 { + vm.navigateToFeedDetail(feedId: Int(item.id)) } - } - .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() } } } diff --git a/Codive/Features/Setting/Presentation/View/SettingView.swift b/Codive/Features/Setting/Presentation/View/SettingView.swift index 3c160447..9cceeecc 100644 --- a/Codive/Features/Setting/Presentation/View/SettingView.swift +++ b/Codive/Features/Setting/Presentation/View/SettingView.swift @@ -10,9 +10,12 @@ import SwiftUI struct SettingView: View { @ObservedObject private var vm: SettingViewModel + @ObservedObject private var profileViewModel: ProfileViewModel + @State private var showLogoutAlert = false init(viewModel: SettingViewModel) { self.vm = viewModel + self.profileViewModel = viewModel.profileViewModel } var body: some View { @@ -54,7 +57,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) } @@ -73,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() + }, label: { + SettingRow(text: TextLiteral.Setting.likedRecords) + }) + .buttonStyle(.plain) + .contentShape(Rectangle()) + .padding(.bottom, 12) - SettingRow(text: TextLiteral.Setting.myComments) - .padding(.bottom, 12) + Button(action: { + vm.navigateToMyComments() + }, label: { + SettingRow(text: TextLiteral.Setting.myComments) + }) + .buttonStyle(.plain) + .contentShape(Rectangle()) + .padding(.bottom, 12) - SettingRow(text: TextLiteral.Setting.blockedUsers) + Button(action: { + vm.navigateToBlockedUsers() + }, label: { + SettingRow(text: TextLiteral.Setting.blockedUsers) + }) + .buttonStyle(.plain) + .contentShape(Rectangle()) } } @@ -154,13 +175,41 @@ struct SettingView: View { } .padding(.bottom, 12) - SettingRow(text: TextLiteral.Setting.inquiry) - .padding(.bottom, 12) + Button(action: { + vm.navigateToInquiry() + }, label: { + SettingRow(text: TextLiteral.Setting.inquiry) + }) + .buttonStyle(.plain) + .contentShape(Rectangle()) + .padding(.bottom, 12) - SettingRow(text: TextLiteral.Setting.logout) - .padding(.bottom, 12) + Button(action: { + showLogoutAlert = true + }, label: { + SettingRow(text: TextLiteral.Setting.logout) + }) + .buttonStyle(.plain) + .contentShape(Rectangle()) + .padding(.bottom, 12) + .alert("로그아웃", isPresented: $showLogoutAlert) { + Button("취소", role: .cancel) { } + Button("로그아웃", role: .destructive) { + Task { + await vm.logout() + } + } + } message: { + Text("정말 로그아웃하시겠습니까?") + } - SettingRow(text: TextLiteral.Setting.withdraw) + Button(action: { + vm.navigateToWithdraw() + }, label: { + SettingRow(text: TextLiteral.Setting.withdraw) + }) + .buttonStyle(.plain) + .contentShape(Rectangle()) } } } @@ -182,5 +231,6 @@ private struct SettingRow: View { .font(.system(size: 16, weight: .semibold)) .foregroundStyle(Color.Codive.grayscale3) } + .contentShape(Rectangle()) } } diff --git a/Codive/Features/Setting/Presentation/View/WithdrawView.swift b/Codive/Features/Setting/Presentation/View/WithdrawView.swift index 4d365184..4b749083 100644 --- a/Codive/Features/Setting/Presentation/View/WithdrawView.swift +++ b/Codive/Features/Setting/Presentation/View/WithdrawView.swift @@ -8,44 +8,76 @@ 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) + + Text(TextLiteral.Setting.withdrawNotice) + .font(.codive_title2) + .foregroundStyle(Color.Codive.grayscale1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 32) - CustomButton(text: TextLiteral.Setting.withdrawButton, widthType: .fixed) { - print("계정 탈퇴하기") + 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() + } + } + } message: { + Text(TextLiteral.Setting.withdrawConfirmMessage) + } + .alert("탈퇴 완료", isPresented: $vm.showCompleteAlert) { + Button("확인") { + vm.confirmWithdrawComplete() } - .padding(.horizontal, 20) + } message: { + Text("탈퇴가 완료되었습니다.") } } } #Preview { - WithdrawView() + EmptyView() } diff --git a/Codive/Features/Setting/Presentation/ViewModel/LikedRecordsViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/LikedRecordsViewModel.swift index 75eef228..a30d4cce 100644 --- a/Codive/Features/Setting/Presentation/ViewModel/LikedRecordsViewModel.swift +++ b/Codive/Features/Setting/Presentation/ViewModel/LikedRecordsViewModel.swift @@ -44,4 +44,14 @@ final class LikedRecordsViewModel: ObservableObject { items = [] } } + + @MainActor + 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 84496823..71e7b7a4 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,14 @@ final class MyCommentsViewModel: ObservableObject { items = [] } } + + @MainActor + func navigateToFeedDetail(historyId: Int) { + navigationRouter.navigate(to: .feedDetail(feedId: historyId)) + } + + @MainActor + func navigateToFeedTab() { + navigationRouter.switchTabAndNavigate(to: .feed) + } } diff --git a/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift index 4deac0ba..cc4a73c9 100644 --- a/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift +++ b/Codive/Features/Setting/Presentation/ViewModel/SettingViewModel.swift @@ -13,17 +13,23 @@ final class SettingViewModel: ObservableObject { private let navigationRouter: NavigationRouter private let getPrefsUC: GetNotificationPrefsUseCase private let updatePrefsUC: UpdateNotificationPrefsUseCase + private let authRepository: AuthRepository + let profileViewModel: ProfileViewModel init( appRouter: AppRouter, navigationRouter: NavigationRouter, getPrefsUC: GetNotificationPrefsUseCase, - updatePrefsUC: UpdateNotificationPrefsUseCase + updatePrefsUC: UpdateNotificationPrefsUseCase, + authRepository: AuthRepository, + profileViewModel: ProfileViewModel ) { self.appRouter = appRouter self.navigationRouter = navigationRouter self.getPrefsUC = getPrefsUC self.updatePrefsUC = updatePrefsUC + self.authRepository = authRepository + self.profileViewModel = profileViewModel } // 초기 로드 @@ -32,6 +38,9 @@ final class SettingViewModel: ObservableObject { error = nil do { + // 프로필 정보 로드 + await profileViewModel.loadMyProfile() + let prefs = try await getPrefsUC.fetch() isPushOn = prefs.pushEnabled isMarketingOn = prefs.marketingOptIn @@ -71,4 +80,35 @@ final class SettingViewModel: ObservableObject { func navigateBack() { 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 레이어: 토큰 삭제 및 소셜 로그아웃 + await authRepository.logout() + + // Presentation 레이어: AppRouter가 모든 네비게이션 관리 + // (Router가 navigationRouter를 소유하고 navigateToRoot + 상태 변경 수행) + appRouter.logout() + } } diff --git a/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift b/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift new file mode 100644 index 00000000..566a0822 --- /dev/null +++ b/Codive/Features/Setting/Presentation/ViewModel/WithdrawViewModel.swift @@ -0,0 +1,55 @@ +import Foundation + +@MainActor +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 + private let withdrawUC: WithdrawAccountUseCase + + init( + navigationRouter: NavigationRouter, + appRouter: AppRouter, + withdrawUC: WithdrawAccountUseCase + ) { + self.navigationRouter = navigationRouter + self.appRouter = appRouter + self.withdrawUC = withdrawUC + } + + func onWithdrawTapped() { + showConfirmAlert = true + } + + func confirmWithdraw() { + isLoading = true + Task { + do { + try await withdrawUC.execute() + + await MainActor.run { + isLoading = false + showCompleteAlert = true + } + } catch { + await MainActor.run { + isLoading = false + print("탈퇴 실패: \(error.localizedDescription)") + } + } + } + } + + func confirmWithdrawComplete() { + showCompleteAlert = false + appRouter.logout() + } + + func navigateBack() { + navigationRouter.navigateBack() + } +} 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 01b1b557..90a57b44 100644 Binary files a/Codive/Resources/Icons.xcassets/Icon_folder/setting.imageset/setting.pdf and b/Codive/Resources/Icons.xcassets/Icon_folder/setting.imageset/setting.pdf differ 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 } } 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/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() } 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?, 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 ) } diff --git a/SettingCommentView_design.png b/SettingCommentView_design.png new file mode 100644 index 00000000..5fe8a76c Binary files /dev/null and b/SettingCommentView_design.png differ diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index a81f3cae..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" : "dfde09be34ac830a9efc93efd52296ff61c7fda2" + "revision" : "171b3eb47b30ac0aac6bd97bc403c9602137b441" } }, {