diff --git a/Common/Sources/DesignSystem/AsyncThumbnailImage.swift b/Common/Sources/DesignSystem/AsyncThumbnailImage.swift new file mode 100644 index 0000000..7826fb0 --- /dev/null +++ b/Common/Sources/DesignSystem/AsyncThumbnailImage.swift @@ -0,0 +1,48 @@ +// +// AsyncThumbnailImage.swift +// Common +// +// Created by 강동영 on 1/12/26. +// + +import SwiftUI + +public struct AsyncThumbnailImage: View { + private let imageURL: String? + private let width: CGFloat? + private let height: CGFloat? + private let cornerRadius: CGFloat + + public init( + imageURL: String?, + width: CGFloat? = nil, + height: CGFloat? = nil, + cornerRadius: CGFloat = 8 + ) { + self.imageURL = imageURL + self.width = width + self.height = height + self.cornerRadius = cornerRadius + } + + public var body: some View { + content + .frame(width: width, height: height) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } + + @ViewBuilder + private var content: some View { + if let imageURL = imageURL, + !imageURL.isEmpty, + let url = URL(string: imageURL) { + AsyncImage(url: url) { phase in + if case .success(let image) = phase { + image + .resizable() + .aspectRatio(contentMode: .fill) + } + } + } + } +} diff --git a/Community/Package.swift b/Community/Package.swift index d2afff7..9b1c01c 100644 --- a/Community/Package.swift +++ b/Community/Package.swift @@ -28,6 +28,10 @@ let package = Package( name: Config.name, targets: Config.allCases.map(\.name) ), + .library( + name: "CommunityDomain", + targets: ["CommunityDomain"] + ), ], dependencies: [ .package(name: "DI", path: "../DI"), diff --git a/Community/Sources/DI/CommunityDIContainer.swift b/Community/Sources/DI/CommunityDIContainer.swift index 5bb4adc..1d5a7f1 100644 --- a/Community/Sources/DI/CommunityDIContainer.swift +++ b/Community/Sources/DI/CommunityDIContainer.swift @@ -34,17 +34,6 @@ struct CommunityWriteAssembly: Assembly { struct CommunityAssembly: Assembly { func assemble(container: GenericDIContainer) { - // NetworkService registration (only for mock mode) - // In normal mode, NetworkService comes from parent container -// if isMock { -// container.register(NetworkServiceInterface.self) { _ in -// let config = URLSessionConfiguration.ephemeral -// config.protocolClasses = [CommunityURLProtocol.self] -// setupURLProtocol() -// return NetworkServiceImpl(configuration: config) -// } -// } - // APIClient registration container.register(CommunityAPIClientInterface.self) { resolver in // if self.isMock { diff --git a/Community/Sources/Presentation/Assets.xcassets/community_comment.imageset/Contents.json b/Community/Sources/Presentation/Assets.xcassets/community_comment_fill.imageset/Contents.json similarity index 100% rename from Community/Sources/Presentation/Assets.xcassets/community_comment.imageset/Contents.json rename to Community/Sources/Presentation/Assets.xcassets/community_comment_fill.imageset/Contents.json diff --git a/Community/Sources/Presentation/Assets.xcassets/community_comment.imageset/community_comment.png b/Community/Sources/Presentation/Assets.xcassets/community_comment_fill.imageset/community_comment.png similarity index 100% rename from Community/Sources/Presentation/Assets.xcassets/community_comment.imageset/community_comment.png rename to Community/Sources/Presentation/Assets.xcassets/community_comment_fill.imageset/community_comment.png diff --git a/Community/Sources/Presentation/Community/CommunityView.swift b/Community/Sources/Presentation/Community/CommunityView.swift index fcddd8e..8410842 100644 --- a/Community/Sources/Presentation/Community/CommunityView.swift +++ b/Community/Sources/Presentation/Community/CommunityView.swift @@ -353,7 +353,7 @@ fileprivate struct CommunityPostListCard: View { .pretendard(.caption(.base)) .foregroundColor(Color.textG600) - Image(.communityComment) + Image(.communityCommentFill) .resizable() .frame(width: 12, height: 12) @@ -419,7 +419,7 @@ fileprivate struct CommunityPostFeedCard: View { .pretendard(.caption(.base)) .foregroundColor(Color.textG600) - Image(.communityComment) + Image(.communityCommentFill) .resizable() .frame(width: 12, height: 12) @@ -443,42 +443,4 @@ fileprivate struct CommunityPostFeedCard: View { } } -struct AsyncThumbnailImage: View { - private let imageURL: String? - private let width: CGFloat? - private let height: CGFloat? - private let cornerRadius: CGFloat - - init( - imageURL: String?, - width: CGFloat? = nil, - height: CGFloat? = nil, - cornerRadius: CGFloat = 8 - ) { - self.imageURL = imageURL - self.width = width - self.height = height - self.cornerRadius = cornerRadius - } - - var body: some View { - content - .frame(width: width, height: height) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - } - - @ViewBuilder - private var content: some View { - if let imageURL = imageURL, - !imageURL.isEmpty, - let url = URL(string: imageURL) { - AsyncImage(url: url) { phase in - if case .success(let image) = phase { - image - .resizable() - .aspectRatio(contentMode: .fill) - } - } - } - } -} + diff --git a/Community/Sources/Presentation/Detail/CommunityDetail.swift b/Community/Sources/Presentation/Detail/CommunityDetail.swift index 42ce982..6a756a0 100644 --- a/Community/Sources/Presentation/Detail/CommunityDetail.swift +++ b/Community/Sources/Presentation/Detail/CommunityDetail.swift @@ -312,7 +312,7 @@ public struct CommunityDetailView: View { } HStack(spacing: 4) { - Image(.communityComment) + Image(.communityCommentFill) .resizable() .frame(width: 20, height: 20) diff --git a/Hambug.xcodeproj/project.pbxproj b/Hambug.xcodeproj/project.pbxproj index e9e8ccd..0e25409 100644 --- a/Hambug.xcodeproj/project.pbxproj +++ b/Hambug.xcodeproj/project.pbxproj @@ -502,6 +502,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Hambug/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "햄버그"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -543,6 +545,8 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Hambug/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "햄버그"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; diff --git a/Hambug/ContentView.swift b/Hambug/ContentView.swift index 11c4b4f..cd56345 100644 --- a/Hambug/ContentView.swift +++ b/Hambug/ContentView.swift @@ -54,7 +54,8 @@ struct ContentView: View { NavigationStack { MyPageView( - viewModel: mypageDIContainer.makeMyPageViewModel() + viewModel: mypageDIContainer.makeMyPageViewModel(), + activitesFactory: mypageDIContainer ) } .tag(2) diff --git a/Hambug/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Hambug/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index ffdfe15..b257683 100644 --- a/Hambug/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Hambug/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,81 +1,10 @@ { "images" : [ { + "filename" : "app_icon-1024x1024 2.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" } ], "info" : { diff --git a/Hambug/Resources/Assets.xcassets/AppIcon.appiconset/app_icon-1024x1024 2.png b/Hambug/Resources/Assets.xcassets/AppIcon.appiconset/app_icon-1024x1024 2.png new file mode 100644 index 0000000..b919df0 Binary files /dev/null and b/Hambug/Resources/Assets.xcassets/AppIcon.appiconset/app_icon-1024x1024 2.png differ diff --git a/Hambug/Resources/Assets.xcassets/community_comment.imageset/community_comment.png b/Hambug/Resources/Assets.xcassets/community_comment.imageset/community_comment.png index ee596d5..59bd984 100644 Binary files a/Hambug/Resources/Assets.xcassets/community_comment.imageset/community_comment.png and b/Hambug/Resources/Assets.xcassets/community_comment.imageset/community_comment.png differ diff --git a/Hambug/Resources/Assets.xcassets/community_comment_fill.imageset/Contents.json b/Hambug/Resources/Assets.xcassets/community_comment_fill.imageset/Contents.json new file mode 100644 index 0000000..2825e92 --- /dev/null +++ b/Hambug/Resources/Assets.xcassets/community_comment_fill.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "community_comment_fill.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hambug/Resources/Assets.xcassets/community_comment_fill.imageset/community_comment_fill.png b/Hambug/Resources/Assets.xcassets/community_comment_fill.imageset/community_comment_fill.png new file mode 100644 index 0000000..df96e07 Binary files /dev/null and b/Hambug/Resources/Assets.xcassets/community_comment_fill.imageset/community_comment_fill.png differ diff --git a/Home/Sources/Data/DTO/TrendingPostResponse.swift b/Home/Sources/Data/DTO/TrendingPostResponse.swift index 6d3fe51..fb04582 100644 --- a/Home/Sources/Data/DTO/TrendingPostResponse.swift +++ b/Home/Sources/Data/DTO/TrendingPostResponse.swift @@ -8,6 +8,7 @@ import Foundation import HomeDomain import Util +import CommunityDomain public struct TrendingPostResponse: Decodable, Sendable { public let id: Int @@ -33,7 +34,7 @@ extension TrendingPostResponse { id: id, title: title, content: content, - category: category, + category: BoardCategory(rawValue: category) ?? .freeTalk, imageUrls: imageUrls, authorNickname: authorNickname, authorId: authorId, diff --git a/Home/Sources/Domain/Entities/TrendingPost.swift b/Home/Sources/Domain/Entities/TrendingPost.swift index 719517f..8e44c97 100644 --- a/Home/Sources/Domain/Entities/TrendingPost.swift +++ b/Home/Sources/Domain/Entities/TrendingPost.swift @@ -6,12 +6,13 @@ // import Foundation +import CommunityDomain public struct TrendingPost: Identifiable, Equatable, Sendable { public let id: Int public let title: String public let content: String - public let category: String + public let category: BoardCategory public let imageUrls: [String] public let authorNickname: String public let authorId: Int @@ -39,7 +40,7 @@ public struct TrendingPost: Identifiable, Equatable, Sendable { id: Int, title: String, content: String, - category: String, + category: BoardCategory, imageUrls: [String], authorNickname: String, authorId: Int, @@ -47,20 +48,20 @@ public struct TrendingPost: Identifiable, Equatable, Sendable { updatedAt: Date, viewCount: Int, likeCount: Int, commentCount: Int, isLiked: Bool) { - self.id = id - self.title = title - self.content = content - self.category = category - self.imageUrls = imageUrls - self.authorNickname = authorNickname - self.authorId = authorId - self.createdAt = createdAt - self.updatedAt = updatedAt - self.viewCount = viewCount - self.likeCount = likeCount - self.commentCount = commentCount - self.isLiked = isLiked - } + self.id = id + self.title = title + self.content = content + self.category = category + self.imageUrls = imageUrls + self.authorNickname = authorNickname + self.authorId = authorId + self.createdAt = createdAt + self.updatedAt = updatedAt + self.viewCount = viewCount + self.likeCount = likeCount + self.commentCount = commentCount + self.isLiked = isLiked + } } // MARK: - Sample Data @@ -72,7 +73,7 @@ extension TrendingPost { id: 1, title: "맥도날드 신메뉴 후기", content: "새로 나온 버거가 진짜 맛있어요!", - category: "리뷰", + category: .review, imageUrls: ["https://via.placeholder.com/400x300"], authorNickname: "햄버거러버", authorId: 101, @@ -87,7 +88,7 @@ extension TrendingPost { id: 2, title: "버거킹 할인 정보", content: "이번 주 버거킹 2+1 행사!", - category: "자유게시판", + category: .freeTalk, imageUrls: ["https://via.placeholder.com/400x300"], authorNickname: "버거헌터", authorId: 102, @@ -102,7 +103,7 @@ extension TrendingPost { id: 3, title: "수제버거 맛집 추천", content: "홍대 근처 숨은 맛집 발견했어요", - category: "추천", + category: .recommendation, imageUrls: ["https://via.placeholder.com/400x300"], authorNickname: "맛집탐험가", authorId: 103, diff --git a/Home/Sources/Presentation/HomeViewModel.swift b/Home/Sources/Presentation/HomeViewModel.swift index be0ad6c..0e90e05 100644 --- a/Home/Sources/Presentation/HomeViewModel.swift +++ b/Home/Sources/Presentation/HomeViewModel.swift @@ -103,11 +103,9 @@ public final class HomeViewModel { print("❌ 추천 버거 로드 실패: \(error.localizedDescription)") // Fallback: 샘플 데이터 제공 - recommendedBurgers = RecommendedBurger.sampleData print("ℹ️ 추천 버거 샘플 데이터 사용") } catch { burgersError = .networkFailure(underlying: error) - recommendedBurgers = RecommendedBurger.sampleData print("❌ 추천 버거 로드 실패 (알 수 없는 에러): \(error)") } } @@ -122,13 +120,8 @@ public final class HomeViewModel { } catch let error as HomeError { postsError = error print("❌ 트렌딩 포스트 로드 실패: \(error.localizedDescription)") - - // Fallback: 샘플 데이터 - trendingPosts = TrendingPost.sampleData - print("ℹ️ 트렌딩 포스트 샘플 데이터 사용") } catch { postsError = .networkFailure(underlying: error) - trendingPosts = TrendingPost.sampleData print("❌ 트렌딩 포스트 로드 실패 (알 수 없는 에러): \(error)") } } @@ -137,9 +130,9 @@ public final class HomeViewModel { if !hasError { print("✅ 홈 데이터 전체 로드 성공") } else if burgersError != nil && postsError != nil { - print("❌ 홈 데이터 전체 로드 실패 (샘플 데이터 사용)") + print("❌ 홈 데이터 전체 로드 실패") } else { - print("⚠️ 홈 데이터 부분 로드 성공 (일부 샘플 데이터 사용)") + print("⚠️ 홈 데이터 부분 로드 성공") } } } diff --git a/Home/Sources/Presentation/PostView.swift b/Home/Sources/Presentation/PostView.swift index 9fedd9a..02603d5 100644 --- a/Home/Sources/Presentation/PostView.swift +++ b/Home/Sources/Presentation/PostView.swift @@ -26,7 +26,7 @@ public struct PostView: View { .foregroundColor(.textG800) HStack { - Text(post.category) + Text(post.category.displayName) .pretendard(.caption(.emphasis)) .foregroundColor(.primaryHambugRed) @@ -64,7 +64,7 @@ public struct PostView: View { Rectangle() .frame(width: 56, height: 56) .cornerRadius(8) - .foregroundColor(.gray) + .foregroundColor(.white) } } } @@ -110,7 +110,7 @@ struct FeedPostView: View { id: 1, title: "글 제목입니다.", content: "글 내용입니다.", - category: "FREE_TALK", + category: .freeTalk, imageUrls: [], authorNickname: "테스트", authorId: 1, diff --git a/Infrastructure/Sources/NetworkImpl/NetworkLogger.swift b/Infrastructure/Sources/NetworkImpl/NetworkLogger.swift index 2249baf..153e2d2 100644 --- a/Infrastructure/Sources/NetworkImpl/NetworkLogger.swift +++ b/Infrastructure/Sources/NetworkImpl/NetworkLogger.swift @@ -7,9 +7,13 @@ import Foundation +public protocol NetworkLogable: Sendable { + func requestLogger(request: URLRequest) + func responseLogger(response: URLResponse, data: Data) +} // MARK: - Logger #if DEBUG -public final class NetworkLogger: Sendable { +public final class NetworkLogger: NetworkLogable { public init() {} public func requestLogger(request: URLRequest) { @@ -54,7 +58,7 @@ public final class NetworkLogger: Sendable { } } #else -public final class Logger { +public final class Logger: NetworkLogable { public init() {} public func requestLogger(request: URLRequest) {} public func responseLogger(response: URLResponse, data: Data) {} diff --git a/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift b/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift index d6e9950..d40654e 100644 --- a/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift +++ b/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift @@ -17,9 +17,9 @@ public final class NetworkServiceImpl: NetworkServiceInterface { // MARK: - Properties private let session: Session private let decoder: JSONDecoder - private let logger: NetworkLogger? + private let logger: NetworkLogable? - public init(session: Session = AF, interceptor: RequestInterceptor? = nil, logger: NetworkLogger? = nil) { + public init(session: Session = AF, interceptor: RequestInterceptor? = nil, logger: NetworkLogable? = nil) { if let interceptor { self.session = Session(interceptor: Interceptor(interceptors: [interceptor])) } else { @@ -34,7 +34,7 @@ public final class NetworkServiceImpl: NetworkServiceInterface { decoder.dateDecodingStrategy = .formatted(dateFormatter) } - public init(configuration: URLSessionConfiguration, logger: NetworkLogger? = nil) { + public init(configuration: URLSessionConfiguration, logger: NetworkLogable? = nil) { self.session = Session(configuration: configuration) self.logger = logger self.decoder = JSONDecoder() diff --git a/Infrastructure/Sources/NetworkInterface/Request/CursorPagingQuery.swift b/Infrastructure/Sources/NetworkInterface/Request/CursorPagingQuery.swift new file mode 100644 index 0000000..9ef1e58 --- /dev/null +++ b/Infrastructure/Sources/NetworkInterface/Request/CursorPagingQuery.swift @@ -0,0 +1,24 @@ +// +// CursorPagingQuery.swift +// Infrastructure +// +// Created by 강동영 on 1/12/26. +// + + +/// 재사용 되는 페이지네이션 쿼리 +public struct CursorPagingQuery: Encodable, Sendable { + public let lastId: Int? + public let limit: Int + public let order: String + + public init( + lastId: Int?, + limit: Int, + order: String + ) { + self.lastId = lastId + self.limit = limit + self.order = order + } +} diff --git a/MyPage/Package.swift b/MyPage/Package.swift index 3d355b5..71ddd80 100644 --- a/MyPage/Package.swift +++ b/MyPage/Package.swift @@ -31,7 +31,9 @@ let package = Package( ], dependencies: [ .package(name: "Common", path: "../Common"), - .package(name: "Infrastructure", path: "../Infrastructure") + .package(name: "Infrastructure", path: "../Infrastructure"), + .package(name: "Community", path: "../Community"), + .package(name: "DI", path: "../DI"), ], targets: [ .target( @@ -41,7 +43,9 @@ let package = Package( .target(config: .data), .target(config: .presentation), .product(name: "NetworkInterface", package: "Infrastructure"), - .product(name: "NetworkImpl", package: "Infrastructure") + .product(name: "NetworkImpl", package: "Infrastructure"), + .product(name: "DIKit", package: "DI"), + .product(name: "AppDI", package: "DI"), ], path: Config.di.path ), @@ -50,13 +54,18 @@ let package = Package( dependencies: [ .target(config: .domain), .product(name: "SharedDomain", package: "Common"), + .product(name: "Util", package: "Common"), .product(name: "NetworkInterface", package: "Infrastructure"), - .product(name: "NetworkImpl", package: "Infrastructure") + .product(name: "NetworkImpl", package: "Infrastructure"), + .product(name: "CommunityDomain", package: "Community") ], path: Config.data.path ), .target( name: Config.domain.name, + dependencies: [ + .product(name: "CommunityDomain", package: "Community") + ], path: Config.domain.path ), .target( @@ -64,7 +73,9 @@ let package = Package( dependencies: [ .target(config: .domain), .product(name: "LocalizedString", package: "Common"), - .product(name: "DesignSystem", package: "Common") + .product(name: "SharedUI", package: "Common"), + .product(name: "DesignSystem", package: "Common"), + .product(name: "CommunityDomain", package: "Community") ], path: Config.presentation.path ), diff --git a/MyPage/Sources/DI/MyPageDIContainer.swift b/MyPage/Sources/DI/MyPageDIContainer.swift index c46c767..ffc069b 100644 --- a/MyPage/Sources/DI/MyPageDIContainer.swift +++ b/MyPage/Sources/DI/MyPageDIContainer.swift @@ -14,27 +14,50 @@ import MyPageData import MyPagePresentation struct MyPageAssembly: Assembly { - + func assemble(container: GenericDIContainer) { - + + // Repository (singleton) container.register(MyPageRepository.self) { resolver in MyPageRepositoryImpl( networkService: resolver.resolve(NetworkServiceInterface.self) ) } - + + // Profile UseCase container.register(MyPageUseCase.self) { resolver in MyPageUseCaseImpl( repository: resolver.resolve(MyPageRepository.self) ) } - + + // Activities UseCases + container.register(GetMyBoardsUseCase.self) { resolver in + GetMyBoardsUseCaseImpl( + repository: resolver.resolve(MyPageRepository.self) + ) + } + + container.register(GetMyCommentsUseCase.self) { resolver in + GetMyCommentsUseCaseImpl( + repository: resolver.resolve(MyPageRepository.self) + ) + } + + // ViewModels container.register(MyPageViewModel.self) { resolver in MyPageViewModel( usecase: resolver.resolve(MyPageUseCase.self) ) } - + + container.register(MyActivitiesViewModel.self) { @MainActor resolver in + MyActivitiesViewModel( + getMyBoardsUseCase: resolver.resolve(GetMyBoardsUseCase.self), + getMyCommentsUseCase: resolver.resolve(GetMyCommentsUseCase.self) + ) + } + } } @@ -55,8 +78,14 @@ public final class MyPageDIContainer { public func makeMyPageViewModel() -> MyPageViewModel { return container.resolve(MyPageViewModel.self) } - + public func resolve(_ type: T.Type) -> T { return container.resolve(type) } } + +extension MyPageDIContainer: ActivitesFactory { + public func makeMyActivitiesViewModel() -> MyActivitiesViewModel { + return container.resolve(MyActivitiesViewModel.self) + } +} diff --git a/MyPage/Sources/Data/DTO/MyActivitiesDTO.swift b/MyPage/Sources/Data/DTO/MyActivitiesDTO.swift new file mode 100644 index 0000000..8175244 --- /dev/null +++ b/MyPage/Sources/Data/DTO/MyActivitiesDTO.swift @@ -0,0 +1,106 @@ +// +// MyActivitiesDTO.swift +// MyPage +// +// Created by Claude on 1/12/26. +// + +import Foundation +import CommunityDomain +import MyPageDomain +import Util + +// MARK: - MyBoards Response DTOs +public struct MyBoardsResponseDTO: Decodable, Sendable { + public let content: [MyBoardItemDTO] + public let nextCursorId: Int? + public let nextPage: Bool +} + +public struct MyBoardItemDTO: Decodable, Sendable { + public let id: Int64 + public let title: String + public let content: String + public let authorNickname: String + public let viewCount: Int64 + public let commentCount: Int64 + public let likeCount: Int64 + public let category: String + public let imageUrls: [String] + public let createAt: String +} + +// MARK: - MyComments Response DTOs +public struct MyCommentsResponseDTO: Decodable, Sendable { + public let content: [MyCommentItemDTO] + public let nextCursorId: Int? + public let nextPage: Bool +} + +public struct MyCommentItemDTO: Decodable, Sendable { + public let boardId: Int64 + public let title: String + public let commentId: Int64 + public let content: String + public let createdAt: String +} + +// MARK: - MyBoardsResponseDTO to Domain Mapping +extension MyBoardsResponseDTO { + func toDomain() -> BoardListData { + return BoardListData( + content: content.map { $0.toDomain() }, + nextCursorId: nextCursorId, + hasNextPage: nextPage + ) + } +} + +extension MyBoardItemDTO { + func toDomain() -> Board { + let dateFormatter = DateFormatter.iso8601WithMicroseconds + let createdDate = dateFormatter.date(from: createAt) ?? Date() + + return Board( + id: Int(id), + title: title, + content: content, + category: BoardCategory(rawValue: category) ?? .freeTalk, + imageUrls: imageUrls, + authorNickname: authorNickname, // API 미제공 + authorId: nil, + createdAt: createdDate, + updatedAt: createdDate, + viewCount: Int(viewCount), + likeCount: Int(likeCount), + commentCount: Int(commentCount), + isLiked: false + ) + } +} + +// MARK: - MyCommentsResponseDTO to Domain Mapping +extension MyCommentsResponseDTO { + func toDomain() -> MyCommentActivityListData { + return MyCommentActivityListData( + content: content.map { $0.toDomain() }, + nextCursorId: nextCursorId, + hasNextPage: nextPage + ) + } +} + +extension MyCommentItemDTO { + func toDomain() -> MyCommentActivity { + let dateFormatter = DateFormatter.iso8601WithMicroseconds + let createdDate = dateFormatter.date(from: createdAt) ?? Date() + + return MyCommentActivity( + commentId: commentId, + boardId: boardId, + boardTitle: title, + content: content, + createdAt: createdDate + ) + } +} diff --git a/MyPage/Sources/Data/MyPageEndpoint.swift b/MyPage/Sources/Data/MyPageEndpoint.swift index cf1c16e..54dfe9b 100644 --- a/MyPage/Sources/Data/MyPageEndpoint.swift +++ b/MyPage/Sources/Data/MyPageEndpoint.swift @@ -17,6 +17,9 @@ enum MyPageEndpoint: Endpoint { case logout case deleteAccount(provider: String) + case getMyBoards(query: CursorPagingQuery) + case getMyComments(query: CursorPagingQuery) + var baseURL: String { NetworkConfig.baseURL } @@ -33,12 +36,17 @@ enum MyPageEndpoint: Endpoint { return "/api/v1/auth/logout" case let .deleteAccount(provider): return "/api/v1/auth/unlink/\(provider)" + + case .getMyBoards: + return "/api/v1/my-pages/boards" + case .getMyComments: + return "/api/v1/my-pages/comments" } } var method: HTTPMethod { switch self { - case .authMe: + case .authMe, .getMyBoards, .getMyComments: return .GET case .updateProfile, .updateNickname: return .PUT @@ -49,13 +57,13 @@ enum MyPageEndpoint: Endpoint { var headers: [String: String] { var headers: [String: String] = [:] - // TODO: Add Authorization header when auth is implemented - // headers["Authorization"] = "Bearer \(token)" return headers } var queryParameters: [String: Any] { switch self { + case let .getMyBoards(dto), let .getMyComments(dto): + return queryEncoder.encode(dto) default: return [:] } diff --git a/MyPage/Sources/Data/Repositories/MyPageRepositoryImpl.swift b/MyPage/Sources/Data/Repositories/MyPageRepositoryImpl.swift index 61398bf..935cf0a 100644 --- a/MyPage/Sources/Data/Repositories/MyPageRepositoryImpl.swift +++ b/MyPage/Sources/Data/Repositories/MyPageRepositoryImpl.swift @@ -6,11 +6,11 @@ // import Foundation -import Combine import UIKit import MyPageDomain import NetworkInterface import SharedDomain +import CommunityDomain // MARK: - MyPage Repository Implementation public final class MyPageRepositoryImpl: MyPageRepository { @@ -45,12 +45,10 @@ public final class MyPageRepositoryImpl: MyPageRepository { let endpoint = MyPageEndpoint.updateNickname(userID: userId, nickname: nickName) do { - return try await networkService.request( + _ = try await networkService.request( endpoint, responseType: SuccessResponse.self ) - .map { _ in () } - .catch { _ in Just(()) } .async() } catch { @@ -104,12 +102,10 @@ public final class MyPageRepositoryImpl: MyPageRepository { public func logout() async { let endpoint = MyPageEndpoint.logout do { - return try await networkService.request( + _ = try await networkService.request( endpoint, responseType: SuccessResponse.self ) - .map { _ in () } - .catch { _ in Just(()) } .async() } catch { @@ -120,18 +116,41 @@ public final class MyPageRepositoryImpl: MyPageRepository { public func deleteAccount(provider: String) async { let endpoint = MyPageEndpoint.deleteAccount(provider: provider) do { - return try await networkService.request( + _ = try await networkService.request( endpoint, responseType: SuccessResponse.self ) - .map { _ in () } - .catch { _ in Just(()) } .async() } catch { - + } - + } - + + // MARK: - Activities + public func fetchMyBoards(lastId: Int?, limit: Int, order: String) async throws -> BoardListData { + let query = CursorPagingQuery(lastId: lastId, limit: limit, order: order) + let endpoint = MyPageEndpoint.getMyBoards(query: query) + + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ).async() + + return response.data.toDomain() + } + + public func fetchMyComments(lastId: Int?, limit: Int, order: String) async throws -> MyCommentActivityListData { + let query = CursorPagingQuery(lastId: lastId, limit: limit, order: order) + let endpoint = MyPageEndpoint.getMyComments(query: query) + + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ).async() + + return response.data.toDomain() + } + private var currentUserId: Int? } diff --git a/MyPage/Sources/Domain/Entities/MyCommentActivity.swift b/MyPage/Sources/Domain/Entities/MyCommentActivity.swift new file mode 100644 index 0000000..b04da5e --- /dev/null +++ b/MyPage/Sources/Domain/Entities/MyCommentActivity.swift @@ -0,0 +1,66 @@ +// +// MyCommentActivity.swift +// MyPage +// +// Created by Claude on 1/12/26. +// + +import Foundation + +// MARK: - MyCommentActivity Entity +public struct MyCommentActivity: Identifiable, Equatable, Sendable { + public let id: Int64 // commentId + public let boardId: Int64 + public let boardTitle: String + public let content: String + public let createdAt: String + + public init( + commentId: Int64, + boardId: Int64, + boardTitle: String, + content: String, + createdAt: Date + ) { + self.id = commentId + self.boardId = boardId + self.boardTitle = boardTitle + self.content = content + self.createdAt = Self.timeAgoDisplay(createdAt) + } + + private static func timeAgoDisplay(_ date: Date) -> String { + let now = Date() + let timeInterval = now.timeIntervalSince(date) + + if timeInterval < 60 { + return "방금 전" + } else if timeInterval < 3600 { + let minutes = Int(timeInterval / 60) + return "\(minutes)분 전" + } else if timeInterval < 86400 { + let hours = Int(timeInterval / 3600) + return "\(hours)시간 전" + } else if timeInterval < 604800 { + let days = Int(timeInterval / 86400) + return "\(days)일 전" + } else { + let formatter = DateFormatter() + formatter.dateFormat = "MM.dd" + return formatter.string(from: date) + } + } +} + +// MARK: - MyCommentActivity List Data (Pagination) +public struct MyCommentActivityListData: Sendable { + public let content: [MyCommentActivity] + public let nextCursorId: Int? + public let hasNextPage: Bool + + public init(content: [MyCommentActivity], nextCursorId: Int?, hasNextPage: Bool) { + self.content = content + self.nextCursorId = nextCursorId + self.hasNextPage = hasNextPage + } +} diff --git a/MyPage/Sources/Domain/Repositories/MyPageRepository.swift b/MyPage/Sources/Domain/Repositories/MyPageRepository.swift index 953a9e5..d8949d3 100644 --- a/MyPage/Sources/Domain/Repositories/MyPageRepository.swift +++ b/MyPage/Sources/Domain/Repositories/MyPageRepository.swift @@ -6,9 +6,9 @@ // import Foundation -import Combine import UIKit import SharedDomain +import CommunityDomain // MARK: - MyPage Repository Interface public protocol MyPageRepository { @@ -21,4 +21,8 @@ public protocol MyPageRepository { func logout() async func deleteAccount(provider: String) async + + // Activities + func fetchMyBoards(lastId: Int?, limit: Int, order: String) async throws -> BoardListData + func fetchMyComments(lastId: Int?, limit: Int, order: String) async throws -> MyCommentActivityListData } diff --git a/MyPage/Sources/Domain/UseCases/GetMyBoardsUseCase.swift b/MyPage/Sources/Domain/UseCases/GetMyBoardsUseCase.swift new file mode 100644 index 0000000..6d97355 --- /dev/null +++ b/MyPage/Sources/Domain/UseCases/GetMyBoardsUseCase.swift @@ -0,0 +1,27 @@ +// +// GetMyBoardsUseCase.swift +// MyPage +// +// Created by Claude on 1/12/26. +// + +import Foundation +import CommunityDomain + +// MARK: - GetMyBoardsUseCase Protocol +public protocol GetMyBoardsUseCase: Sendable { + func execute(lastId: Int?, limit: Int, order: String) async throws -> BoardListData +} + +// MARK: - GetMyBoardsUseCase Implementation +public final class GetMyBoardsUseCaseImpl: GetMyBoardsUseCase { + private let repository: MyPageRepository + + public init(repository: MyPageRepository) { + self.repository = repository + } + + public func execute(lastId: Int?, limit: Int, order: String) async throws -> BoardListData { + return try await repository.fetchMyBoards(lastId: lastId, limit: limit, order: order) + } +} diff --git a/MyPage/Sources/Domain/UseCases/GetMyCommentsUseCase.swift b/MyPage/Sources/Domain/UseCases/GetMyCommentsUseCase.swift new file mode 100644 index 0000000..76a8d20 --- /dev/null +++ b/MyPage/Sources/Domain/UseCases/GetMyCommentsUseCase.swift @@ -0,0 +1,26 @@ +// +// GetMyCommentsUseCase.swift +// MyPage +// +// Created by Claude on 1/12/26. +// + +import Foundation + +// MARK: - GetMyCommentsUseCase Protocol +public protocol GetMyCommentsUseCase: Sendable { + func execute(lastId: Int?, limit: Int, order: String) async throws -> MyCommentActivityListData +} + +// MARK: - GetMyCommentsUseCase Implementation +public final class GetMyCommentsUseCaseImpl: GetMyCommentsUseCase { + private let repository: MyPageRepository + + public init(repository: MyPageRepository) { + self.repository = repository + } + + public func execute(lastId: Int?, limit: Int, order: String) async throws -> MyCommentActivityListData { + return try await repository.fetchMyComments(lastId: lastId, limit: limit, order: order) + } +} diff --git a/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift b/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift index 331a6be..a7c429d 100644 --- a/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift +++ b/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift @@ -6,7 +6,6 @@ // import Foundation -import Combine import UIKit import SharedDomain import Util diff --git a/MyPage/Sources/Presentation/MyActivitiesView.swift b/MyPage/Sources/Presentation/MyActivitiesView.swift index 5a1c3d9..ab24dca 100644 --- a/MyPage/Sources/Presentation/MyActivitiesView.swift +++ b/MyPage/Sources/Presentation/MyActivitiesView.swift @@ -6,9 +6,333 @@ // import SwiftUI +import DesignSystem +import CommunityDomain +import MyPageDomain +import SharedUI + +public struct MyActivitiesView: View { + @State private var viewModel: MyActivitiesViewModel + + public init(viewModel: MyActivitiesViewModel) { + self._viewModel = State(initialValue: viewModel) + } + + public var body: some View { + NavigationStack { + VStack(spacing: 0) { + navigationBar + tabBar + contentView + } + .background(Color.bgG75) + } + .navigationBarHidden(true) + .refreshable { + viewModel.refreshCurrentTab() + } + } + + private var navigationBar: some View { + HambugNavigationView() { + Text("활동내역") + .pretendard(.title(.t2)) + .foregroundStyle(Color.textG900) + .padding(.leading, 8) + } + } + + private var tabBar: some View { + HStack(spacing: 0) { + TabButton( + title: "게시글", + isSelected: viewModel.selectedTab == .posts, + action: { viewModel.selectedTab = .posts } + ) + + TabButton( + title: "댓글", + isSelected: viewModel.selectedTab == .comments, + action: { viewModel.selectedTab = .comments } + ) + } + .padding(.horizontal, 16) + } + + @ViewBuilder + private var contentView: some View { + if viewModel.selectedTab == .posts { + MyBoardsListView( + boards: viewModel.myBoards, + isLoadingMore: viewModel.isLoadingMoreBoards, + onLoadMore: { index in + if index >= viewModel.myBoards.count - 3 { + Task { await viewModel.loadMoreBoards() } + } + } + ) + .padding(.horizontal, 20) + .padding(.vertical, 12) + } else { + MyCommentsListView( + comments: viewModel.myComments, + isLoadingMore: viewModel.isLoadingMoreComments, + onLoadMore: { index in + if index >= viewModel.myComments.count - 3 { + Task { await viewModel.loadMoreComments() } + } + } + ) + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + } +} + +// MARK: - MyActivitiesView's ActivityTab +extension MyActivitiesView { + enum ActivityTab { + case posts + case comments + } +} + +// MARK: - Tab Button +struct TabButton: View { + let title: String + let isSelected: Bool + let action: () -> Void -struct MyActivitiesView: View { var body: some View { - Text("MyActivities") + Button(action: action) { + VStack(spacing: 8) { + Text(title) + .pretendard(.body(.base)) + .foregroundColor(isSelected ? .primaryHambugRed : .textG600) + + Rectangle() + .frame(height: 2) + .foregroundColor(isSelected ? .primaryHambugRed : .borderG400) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } +} + +// MARK: - 게시글 리스트 뷰 +struct MyBoardsListView: View { + let boards: [Board] + let isLoadingMore: Bool + let onLoadMore: (Int) -> Void + + var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(boards.enumerated()), id: \.element.id) { index, board in + NavigationLink(destination: Text("Board Detail \(board.id)")) { + MyBoardListCard(board: board) + } + .buttonStyle(PlainButtonStyle()) + .onAppear { onLoadMore(index) } + } + + if isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 16) + } + } + } + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.white) + .shadow(color: Color.black.opacity(0.1), radius: 4.5, x: 0, y: 0) + ) + } +} + +// MARK: - 게시글 카드 +fileprivate struct MyBoardListCard: View { + let board: Board + + var body: some View { + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(board.title) + .pretendard(.body(.base)) + .foregroundColor(Color.textG800) + .lineLimit(1) + .truncationMode(.tail) + .padding(.trailing, 8) + + Text(board.createdAt) + .pretendard(.caption(.base)) + .foregroundColor(Color.textG600) + + Spacer() + } + + HStack(spacing: 4) { + Text(board.authorNickname ?? "authorNickname nil") + .pretendard(.caption(.base)) + .foregroundColor(Color.textG800) + + HStack(spacing: 4) { + Image(systemName: "heart.fill") + .foregroundColor(Color.textR100) + .font(.system(size: 12)) + + Text("\(board.likeCount)") + .pretendard(.caption(.base)) + .foregroundColor(Color.textG600) + + Image("community_comment_fill", bundle: .main) + .resizable() + .frame(width: 12, height: 12) + + Text("\(board.commentCount)") + .pretendard(.caption(.base)) + .foregroundColor(Color.textG600) + } + } + } + + AsyncThumbnailImage( + imageURL: board.imageUrls.first ?? "", + width: 50, + height: 50, + cornerRadius: 8 + ) + + } + .padding(16) + .background(Color.white) + } + } +} + +// MARK: - 댓글 리스트 뷰 +struct MyCommentsListView: View { + let comments: [MyCommentActivity] + let isLoadingMore: Bool + let onLoadMore: (Int) -> Void + + var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(comments.enumerated()), id: \.element.id) { index, comment in + NavigationLink(destination: Text("Board Detail \(comment.boardId)")) { + MyCommentActivityCard(comment: comment) + } + .buttonStyle(PlainButtonStyle()) + .onAppear { onLoadMore(index) } + } + + if isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 16) + } + } + } + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.white) + .shadow(color: Color.black.opacity(0.1), radius: 4.5, x: 0, y: 0) + ) + } +} + +// MARK: - 댓글 카드 +fileprivate struct MyCommentActivityCard: View { + let comment: MyCommentActivity + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(comment.boardTitle) + .pretendard(.body(.base)) + .foregroundColor(Color.textG800) + .lineLimit(1) + .truncationMode(.tail) + + Spacer() + + Text(comment.createdAt) + .pretendard(.caption(.base)) + .foregroundColor(Color.textG600) + } + + HStack(spacing: 4) { + Image("community_commnet", bundle: .main) + .foregroundColor(Color.textG600) + .font(.system(size: 12)) + + Text(comment.content) + .pretendard(.caption(.base)) + .foregroundColor(Color.textG600) + .lineLimit(2) + .truncationMode(.tail) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background(Color.white) + } +} + +// MARK: - Hambug Navigation View +struct HambugNavigationView: View { + @Environment(\.presentationMode) var presentationMode + @ViewBuilder var content: () -> Content + + var body: some View { + HStack { + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + Image(systemName: "chevron.left") + .foregroundColor(.textG900) + .frame(width: 24, height: 24) + } + .padding(.leading, 16) + + content() + + Spacer() + } + .padding(.vertical, 12) + } +} + +#Preview { + // Preview requires mocked dependencies + MyActivitiesView( + viewModel: MyActivitiesViewModel( + getMyBoardsUseCase: MockGetMyBoardsUseCase(), + getMyCommentsUseCase: MockGetMyCommentsUseCase() + ) + ) +} + +// MARK: - Mock UseCases for Preview +private final class MockGetMyBoardsUseCase: GetMyBoardsUseCase { + func execute(lastId: Int?, limit: Int, order: String) async throws -> BoardListData { + return BoardListData(content: [], nextCursorId: nil, hasNextPage: false) + } +} + +private final class MockGetMyCommentsUseCase: GetMyCommentsUseCase { + func execute(lastId: Int?, limit: Int, order: String) async throws -> MyCommentActivityListData { + return MyCommentActivityListData(content: [], nextCursorId: nil, hasNextPage: false) } } diff --git a/MyPage/Sources/Presentation/MyActivitiesViewModel.swift b/MyPage/Sources/Presentation/MyActivitiesViewModel.swift new file mode 100644 index 0000000..1e3e35f --- /dev/null +++ b/MyPage/Sources/Presentation/MyActivitiesViewModel.swift @@ -0,0 +1,162 @@ +// +// MyActivitiesViewModel.swift +// MyPage +// +// Created by 강동영 on 12/19/25. +// + +import Foundation +import CommunityDomain +import MyPageDomain + +@MainActor +@Observable +public final class MyActivitiesViewModel { + + // MARK: - Published Properties + public var myBoards: [Board] = [] + public var myComments: [MyCommentActivity] = [] + + public var isLoadingBoards: Bool = false + public var isLoadingMoreBoards: Bool = false + + public var isLoadingComments: Bool = false + public var isLoadingMoreComments: Bool = false + + public var errorMessage: String? = nil + public var selectedTab: ActivityTab = .posts + + // MARK: - Dependencies + private let getMyBoardsUseCase: GetMyBoardsUseCase + private let getMyCommentsUseCase: GetMyCommentsUseCase + + // MARK: - Private Properties + private var boardsCurrentPage: Int? = nil + private var boardsHasNextPage: Bool = true + private let pageSize: Int = 10 + + private var commentsCurrentPage: Int? = nil + private var commentsHasNextPage: Bool = true + + // MARK: - Initialization + public init( + getMyBoardsUseCase: GetMyBoardsUseCase, + getMyCommentsUseCase: GetMyCommentsUseCase + ) { + self.getMyBoardsUseCase = getMyBoardsUseCase + self.getMyCommentsUseCase = getMyCommentsUseCase + + Task { + await loadBoards() + await loadComments() + } + } + + // MARK: - Public Methods - Boards + public func loadBoards() async { + guard !isLoadingBoards else { return } + isLoadingBoards = true + errorMessage = nil + boardsCurrentPage = nil + boardsHasNextPage = true + + do { + let boardListData = try await getMyBoardsUseCase.execute( + lastId: boardsCurrentPage, + limit: pageSize, + order: "DESC" + ) + + myBoards = boardListData.content + boardsHasNextPage = boardListData.hasNextPage + boardsCurrentPage = boardListData.nextCursorId + } catch { + errorMessage = error.localizedDescription + } + + isLoadingBoards = false + } + + public func loadMoreBoards() async { + guard !isLoadingMoreBoards && boardsHasNextPage else { return } + isLoadingMoreBoards = true + + do { + let boardListData = try await getMyBoardsUseCase.execute( + lastId: boardsCurrentPage, + limit: pageSize, + order: "DESC" + ) + + myBoards.append(contentsOf: boardListData.content) + boardsHasNextPage = boardListData.hasNextPage + boardsCurrentPage = boardListData.nextCursorId + } catch { + print("❌ Load more boards error: \(error)") + } + + isLoadingMoreBoards = false + } + + // MARK: - Public Methods - Comments + public func loadComments() async { + guard !isLoadingComments else { return } + isLoadingComments = true + errorMessage = nil + commentsCurrentPage = nil + commentsHasNextPage = true + + do { + let commentListData = try await getMyCommentsUseCase.execute( + lastId: commentsCurrentPage, + limit: pageSize, + order: "DESC" + ) + + myComments = commentListData.content + commentsHasNextPage = commentListData.hasNextPage + commentsCurrentPage = commentListData.nextCursorId + } catch { + errorMessage = error.localizedDescription + } + + isLoadingComments = false + } + + public func loadMoreComments() async { + guard !isLoadingMoreComments && commentsHasNextPage else { return } + isLoadingMoreComments = true + + do { + let commentListData = try await getMyCommentsUseCase.execute( + lastId: commentsCurrentPage, + limit: pageSize, + order: "DESC" + ) + + myComments.append(contentsOf: commentListData.content) + commentsHasNextPage = commentListData.hasNextPage + commentsCurrentPage = commentListData.nextCursorId + } catch { + print("❌ Load more comments error: \(error)") + } + + isLoadingMoreComments = false + } + + public func refreshCurrentTab() { + Task { + if selectedTab == .posts { + await loadBoards() + } else { + await loadComments() + } + } + } + + // MARK: - ActivityTab Enum + public enum ActivityTab { + case posts + case comments + } +} diff --git a/MyPage/Sources/Presentation/MyPage/MyPageView.swift b/MyPage/Sources/Presentation/MyPage/MyPageView.swift index 0430ca5..f13b613 100644 --- a/MyPage/Sources/Presentation/MyPage/MyPageView.swift +++ b/MyPage/Sources/Presentation/MyPage/MyPageView.swift @@ -10,6 +10,10 @@ import PhotosUI import DesignSystem import Util +public protocol ActivitesFactory { + func makeMyActivitiesViewModel() -> MyActivitiesViewModel +} + public struct MyPageView: View { @Bindable var viewModel: MyPageViewModel @State private var showMyActivitiesView: Bool = false @@ -23,8 +27,14 @@ public struct MyPageView: View { @State private var selectedImageData: Data? @State private var showPhotoPicker: Bool = false - public init(viewModel: MyPageViewModel) { + private let activitesFactory: ActivitesFactory + + public init( + viewModel: MyPageViewModel, + activitesFactory: ActivitesFactory + ) { self._viewModel = Bindable(viewModel) + self.activitesFactory = activitesFactory } public var body: some View { @@ -41,7 +51,7 @@ public struct MyPageView: View { Spacer() } .navigationDestination(isPresented: $showMyActivitiesView, destination: { - MyActivitiesView() + MyActivitiesView(viewModel: activitesFactory.makeMyActivitiesViewModel()) }) } .confirmationDialog("프로필 편집", isPresented: $showInfoActionSheet, actions: { @@ -49,8 +59,10 @@ public struct MyPageView: View { showPhotoPicker = true } Button(Strings.ActionSheetTitle.defaultImage) { - viewModel.applyDefaultImage() - popupState = .none + Task { + await viewModel.applyDefaultImage() + popupState = .none + } } Button(Strings.ActionSheetTitle.changeNickname) { popupState = .changeNickname @@ -80,8 +92,9 @@ public struct MyPageView: View { if let processedData = ImageProcessor.process(image) { // 처리 성공 - 압축된 이미지로 업로드 selectedImageData = processedData - viewModel.changeProfileImage(processedData) + await viewModel.changeProfileImage(processedData) popupState = .none + } else { // 처리 실패 - 알림 표시 viewModel.showImageSizeAlert = true @@ -89,7 +102,7 @@ public struct MyPageView: View { } else { // 크기가 괜찮으면 원본 데이터로 업로드 selectedImageData = imageData - viewModel.changeProfileImage(imageData) + await viewModel.changeProfileImage(imageData) popupState = .none } } @@ -127,7 +140,9 @@ public struct MyPageView: View { } .navigationBarHidden(true) .onAppear { - viewModel.fetchProfile() + Task { + await viewModel.fetchProfile() + } } .onChange(of: viewModel.shouldNavigateToLogin) { _, shouldNavigate in if shouldNavigate { @@ -257,7 +272,7 @@ public struct MyPageView: View { }, primaryButton: AlertButton(.save) { print("저장") - viewModel.updateNickname() + Task { await viewModel.updateNickname() } } ) } @@ -284,7 +299,7 @@ public struct MyPageView: View { }, primaryButton: AlertButton(.ok) { print("확인") - viewModel.logout() + Task { await viewModel.logout() } } ) } @@ -318,7 +333,7 @@ public struct MyPageView: View { }, primaryButton: .init(.accountDelete) { print("탈퇴") - viewModel.deleteAccount() + Task { await viewModel.deleteAccount() } } ) } diff --git a/MyPage/Sources/Presentation/MyPage/MyPageViewModel.swift b/MyPage/Sources/Presentation/MyPage/MyPageViewModel.swift index 9518e25..e6d8b2d 100644 --- a/MyPage/Sources/Presentation/MyPage/MyPageViewModel.swift +++ b/MyPage/Sources/Presentation/MyPage/MyPageViewModel.swift @@ -5,7 +5,6 @@ // Created by 강동영 on 10/28/25. // -import Combine import Foundation import Observation import UIKit @@ -15,7 +14,6 @@ import SharedDomain @Observable public final class MyPageViewModel { private let usecase: MyPageUseCase - private var cancellables: Set = [] var currentNickName: String = "" var profileNickName: String = "" @@ -32,19 +30,19 @@ public final class MyPageViewModel { self.usecase = usecase } - func fetchProfile() { + func fetchProfile() async { isLoading = true - usecase.fetchProfile() - .receive(on: DispatchQueue.main) - .sink { [weak self] user in - self?.isLoading = false - self?.user = user - self?.profileNickName = user.nickname - } - .store(in: &cancellables) + do { + let response = try await usecase.fetchProfile() + isLoading = false + user = user + profileNickName = response.nickname + } catch { + + } } - func updateNickname() { + func updateNickname() async { let trimmed = currentNickName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { isCorrectedNickName = false @@ -53,16 +51,13 @@ public final class MyPageViewModel { isCorrectedNickName = true isLoading = true - usecase.updateNickname(trimmed) - .receive(on: DispatchQueue.main) - .sink { [weak self] in - self?.isLoading = false - self?.profileNickName = trimmed - } - .store(in: &cancellables) + + await usecase.updateNickname(trimmed) + isLoading = false + profileNickName = trimmed } - func changeProfileImage(_ imageData: Data) { + func changeProfileImage(_ imageData: Data) async { guard let image = UIImage(data: imageData) else { errorMessage = "이미지를 불러올 수 없습니다." showError = true @@ -70,71 +65,39 @@ public final class MyPageViewModel { } isLoading = true - usecase.changeProfileImage(image) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [weak self] completion in - self?.isLoading = false - - switch completion { - case .failure: - self?.errorMessage = "이미지 변경에 실패했습니다. 다시 시도해 주세요." - self?.showError = true - case .finished: -// self?.fetchProfile() - break - } - }, - receiveValue: { [weak self] in - self?.user?.profileImageURL = $0 - } - ) - .store(in: &cancellables) + + do { + let profileURL = try await usecase.changeProfileImage(image) + user?.profileImageURL = profileURL + } catch { + errorMessage = "이미지 변경에 실패했습니다. 다시 시도해 주세요." + showError = true + } + + isLoading = false } - func applyDefaultImage() { + func applyDefaultImage() async { isLoading = true - usecase.applyDefaultImage() - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [weak self] completion in - self?.isLoading = false - - switch completion { - case .failure: - self?.errorMessage = "이미지 변경에 실패했습니다. 다시 시도해 주세요." - self?.showError = true - case .finished: -// self?.fetchProfile() - self?.user?.profileImageURL = "" - } - }, - receiveValue: {} - ) - .store(in: &cancellables) + await usecase.applyDefaultImage() + isLoading = false + user?.profileImageURL = "" } - func logout() { + func logout() async { isLoading = true - usecase.logout() - .receive(on: DispatchQueue.main) - .sink { [weak self] in - self?.isLoading = false - self?.shouldNavigateToLogin = true - } - .store(in: &cancellables) + await usecase.logout() + + isLoading = false + shouldNavigateToLogin = true } - func deleteAccount() { + func deleteAccount() async { guard let provider = user?.loginType.lowercased(), !provider.isEmpty else { return } isLoading = true - usecase.deleteAccount(provider: provider) - .receive(on: DispatchQueue.main) - .sink { [weak self] in - self?.isLoading = false - self?.shouldNavigateToLogin = true - } - .store(in: &cancellables) + await usecase.deleteAccount(provider: provider) + isLoading = false + shouldNavigateToLogin = true } }