diff --git a/ABOUT_MODULE.MD b/ABOUT_MODULE.MD new file mode 100644 index 0000000..12cc30e --- /dev/null +++ b/ABOUT_MODULE.MD @@ -0,0 +1,687 @@ +# Hambug iOS - 모듈 구조 + +> SPM 기반 모듈러 아키텍처 상세 가이드 + +## 목차 +- [전체 모듈 구조](#전체-모듈-구조) +- [Common 모듈](#common-모듈) +- [Infrastructure 모듈](#infrastructure-모듈) +- [기능 모듈](#기능-모듈) +- [DI 모듈](#di-모듈) +- [모듈 의존성 규칙](#모듈-의존성-규칙) +- [새 모듈 생성 가이드](#새-모듈-생성-가이드) + +--- + +## 전체 모듈 구조 + +``` +Hambug-iOS/ +├── Hambug/ # 메인 앱 (호스트) +│ ├── HambugApp.swift # 앱 진입점 +│ ├── RootView.swift # 상태 기반 루트 네비게이션 +│ └── ContentView.swift # 메인 탭 뷰 +│ +├── Common/ # 공통 모듈 (모든 모듈이 의존 가능) +│ ├── Managers # 앱 상태, 유틸리티 매니저 +│ ├── DesignSystem # 색상, 폰트, 타이포그래피 +│ ├── DataSources # Keychain, UserDefaults +│ ├── LocalizedString # 로컬라이제이션 +│ ├── Util # 공통 유틸리티, Extension +│ └── SharedUI # 공유 UI 컴포넌트 +│ +├── Infrastructure/ # 인프라 레이어 +│ ├── NetworkInterface/ # 네트워크 프로토콜 정의 +│ └── NetworkImpl/ # Alamofire 기반 구현체 +│ +├── Home/ # 홈 기능 모듈 +├── Community/ # 커뮤니티 기능 모듈 +├── MyPage/ # 마이페이지 기능 모듈 +├── Login/ # 로그인 기능 모듈 +├── Intro/ # 온보딩/스플래시 +│ +├── 3rdParty/ # 서드파티 SDK 래퍼 +│ └── KakaoLogin # Kakao SDK 래퍼 +│ +└── DI/ # 의존성 주입 컨테이너 + ├── DIKit # Generic DI Container + ├── AppDI # 앱 레벨 DI + ├── IntroDI + ├── LoginDI + ├── HomeDI + ├── CommunityDI + └── MyPageDI +``` + +### 의존성 그래프 + +``` + Hambug (Main App) + │ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ + Home Community MyPage + │ │ │ + └─────────────────┼─────────────────┘ + ▼ + DI (DIKit + AppDI) + │ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ + Infrastructure Common 3rdParty + (NetworkInterface) (모든 공통) (KakaoLogin) + │ + ▼ + NetworkImpl + (Alamofire) +``` + +--- + +## Common 모듈 + +**위치**: `/Common/` + +**역할**: 모든 모듈에서 공통으로 사용하는 유틸리티, UI 컴포넌트, 디자인 시스템 + +### Package.swift + +```swift +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "Common", + platforms: [.iOS(.v17)], + products: [ + .library(name: "Managers", targets: ["Managers"]), + .library(name: "DesignSystem", targets: ["DesignSystem"]), + .library(name: "DataSources", targets: ["DataSources"]), + .library(name: "LocalizedString", targets: ["LocalizedString"]), + .library(name: "Util", targets: ["Util"]), + .library(name: "SharedUI", targets: ["SharedUI"]), + ], + dependencies: [ + // KeychainAccess for secure storage + ], + targets: [ + .target(name: "Managers", dependencies: []), + .target(name: "DesignSystem", resources: [.process("Resources")]), + .target(name: "DataSources", dependencies: ["KeychainAccess"]), + .target(name: "LocalizedString", dependencies: []), + .target(name: "Util", dependencies: []), + .target(name: "SharedUI", dependencies: ["DesignSystem"]), + ] +) +``` + +### 주요 컴포넌트 + +#### Managers +- `AppStateManager`: 앱 상태 관리 (splash → onboarding → login → main) +- `UserDefaultsManager`: UserDefaults 래퍼 +- `FontManager`: 커스텀 폰트 관리 + +#### DesignSystem +- `Color.xcassets`: 브랜드 컬러 +- `Font/`: 커스텀 폰트 파일 +- `Typography.swift`: 타이포그래피 정의 +- `View+Keyboard.swift`: 키보드 관련 extension + +#### DataSources +- `KeychainTokenStorage`: 토큰 저장 (Keychain) +- `TokenStorage` protocol + +#### SharedUI +- `CustomTabView/`: 커스텀 탭 바 컴포넌트 +- `HambugTab`: 탭 정의 +- `TabBarVisibilityKey`: 탭 바 숨김/표시 environment key + +#### Util +- `Combine+Ext.swift`: Publisher → async/await 브릿지 +- 기타 공통 extension + +**의존성**: 없음 (최하위 레이어) + +--- + +## Infrastructure 모듈 + +**위치**: `/Infrastructure/` + +**역할**: 네트워크 레이어 추상화 + +### Package.swift + +```swift +let package = Package( + name: "Infrastructure", + platforms: [.iOS(.v17)], + products: [ + .library(name: "NetworkInterface", targets: ["NetworkInterface"]), + .library(name: "NetworkImpl", targets: ["NetworkImpl"]), + ], + dependencies: [ + .package(name: "Common", path: "../Common"), + .package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0"), + ], + targets: [ + .target(name: "NetworkInterface", dependencies: ["Common"]), + .target(name: "NetworkImpl", dependencies: ["NetworkInterface", "Alamofire"]), + ] +) +``` + +### 구조 + +``` +Infrastructure/ +└── Sources/ + ├── NetworkInterface/ + │ ├── NetworkServiceInterface.swift (프로토콜) + │ ├── Endpoint.swift (요청 추상화) + │ ├── NetworkError.swift + │ ├── NetworkConfig.swift + │ └── Responses/ + │ ├── SuccessResponse.swift + │ └── EmptyResponse.swift + └── NetworkImpl/ + ├── NetworkServiceImpl.swift (Alamofire 구현) + ├── AuthInterceptor.swift (토큰 관리) + ├── TokenRefreshEndpoint.swift + └── NetworkLogger.swift (DEBUG only) +``` + +### 핵심 컴포넌트 + +#### NetworkServiceInterface +```swift +public protocol NetworkServiceInterface: Sendable { + func request(_ endpoint: any Endpoint, responseType: T.Type) + -> AnyPublisher + + func uploadMultipart( + _ endpoint: any Endpoint, + images: [UIImage], + responseType: T.Type + ) -> AnyPublisher +} +``` + +#### Endpoint Protocol +```swift +public protocol Endpoint: Sendable { + var baseURL: String { get } + var path: String { get } + var method: HTTPMethod { get } + var headers: [String: String] { get } + var queryParameters: [String: Any] { get } + var body: Data? { get } +} +``` + +**의존성**: Common (DataSources, Managers) + +--- + +## 기능 모듈 + +각 기능 모듈은 Clean Architecture의 3개 레이어로 구성됩니다. + +### 표준 구조 + +``` +/ +├── Package.swift +└── Sources/ + ├── DI/ + │ └── DIContainer.swift + ├── Domain/ + │ ├── Entities/ + │ ├── Repositories/ (프로토콜) + │ ├── UseCases/ + │ └── Errors/ + ├── Data/ + │ ├── Repositories/ (구현체) + │ ├── API/ + │ └── DTO/ + └── Presentation/ + ├── View.swift + ├── ViewModel.swift + └── Subviews/ +``` + +### Home 모듈 + +**위치**: `/Home/` + +**역할**: 홈 화면 (추천 버거, 인기 게시글) + +#### Package.swift +```swift +let package = Package( + name: "Home", + platforms: [.iOS(.v17)], + products: [ + .library(name: "HomeDomain", targets: ["HomeDomain"]), + .library(name: "HomeData", targets: ["HomeData"]), + .library(name: "HomePresentation", targets: ["HomePresentation"]), + .library(name: "HomeDI", targets: ["HomeDI"]), + ], + dependencies: [ + .package(name: "Common", path: "../Common"), + .package(name: "Infrastructure", path: "../Infrastructure"), + .package(name: "DI", path: "../DI"), + ], + targets: [ + .target(name: "HomeDomain", dependencies: []), + .target(name: "HomeData", dependencies: ["HomeDomain", "NetworkInterface"]), + .target(name: "HomePresentation", dependencies: ["HomeDomain", "DesignSystem", "SharedUI"]), + .target(name: "HomeDI", dependencies: ["HomeDomain", "HomeData", "HomePresentation", "DIKit"]), + ] +) +``` + +**주요 파일**: +- Domain: `RecommendedBurger.swift`, `TrendingPost.swift`, `HomeUseCase.swift` +- Data: `HomeViewRepositoryImpl.swift`, `HomeAPI.swift` +- Presentation: `HomeView.swift`, `HomeViewModel.swift` + +### Community 모듈 + +**위치**: `/Community/` + +**역할**: 커뮤니티 게시판 (목록, 상세, 작성, 댓글, 좋아요, 신고) + +#### Package.swift +```swift +let package = Package( + name: "Community", + platforms: [.iOS(.v17)], + products: [ + .library(name: "CommunityDomain", targets: ["CommunityDomain"]), + .library(name: "CommunityData", targets: ["CommunityData"]), + .library(name: "CommunityPresentation", targets: ["CommunityPresentation"]), + .library(name: "CommunityDI", targets: ["CommunityDI"]), + ], + dependencies: [ + .package(name: "Common", path: "../Common"), + .package(name: "Infrastructure", path: "../Infrastructure"), + .package(name: "DI", path: "../DI"), + ], + targets: [ + .target(name: "CommunityDomain", dependencies: []), + .target(name: "CommunityData", dependencies: ["CommunityDomain", "NetworkInterface"]), + .target(name: "CommunityPresentation", dependencies: ["CommunityDomain", "DesignSystem", "SharedUI", "Util"]), + .target(name: "CommunityDI", dependencies: ["CommunityDomain", "CommunityData", "CommunityPresentation", "DIKit"]), + ] +) +``` + +**주요 파일**: +- Domain: `Board.swift`, `Comment.swift`, `Like.swift`, `Report.swift`, `GetBoardsUseCase.swift`, `CommentUseCase.swift` +- Data: `CommunityRepositoryImpl.swift`, `CommunityAPIClient.swift`, `BoardResponse.swift` +- Presentation: `CommunityView.swift`, `CommunityDetailView.swift`, `CommunityWriteView.swift` + +**특징**: +- 페이지네이션 지원 (`BoardListData`) +- Multipart 업로드 (이미지) +- Factory 패턴 (하위 ViewModel 생성) + +### MyPage 모듈 + +**위치**: `/MyPage/` + +**역할**: 사용자 프로필 및 설정 + +(구조는 Home/Community와 동일) + +### Login 모듈 + +**위치**: `/Login/` + +**역할**: 로그인/회원가입 (Kakao 로그인) + +**특징**: +- 3rdParty (KakaoLogin) 의존 + +### Intro 모듈 + +**위치**: `/Intro/` + +**역할**: 온보딩 및 스플래시 화면 + +**특징**: +- Domain/Data 레이어 없음 (순수 Presentation) +- 2개 product: `Onboarding`, `Splash` + +```swift +let package = Package( + name: "Intro", + products: [ + .library(name: "Onboarding", targets: ["Onboarding"]), + .library(name: "Splash", targets: ["Splash"]), + ], + dependencies: [ + .package(name: "Common", path: "../Common"), + ], + targets: [ + .target(name: "Onboarding", dependencies: ["DesignSystem", "Managers"]), + .target(name: "Splash", dependencies: ["DesignSystem", "Managers"]), + ] +) +``` + +--- + +## DI 모듈 + +**위치**: `/DI/` + +**역할**: 의존성 주입 컨테이너 + +### Package.swift + +```swift +let package = Package( + name: "DI", + platforms: [.iOS(.v17)], + products: [ + .library(name: "DIKit", targets: ["DIKit"]), + .library(name: "AppDI", targets: ["AppDI"]), + .library(name: "IntroDI", targets: ["IntroDI"]), + .library(name: "LoginDI", targets: ["LoginDI"]), + .library(name: "HomeDI", targets: ["HomeDI"]), + .library(name: "MyPageDI", targets: ["MyPageDI"]), + ], + dependencies: [ + .package(name: "Common", path: "../Common"), + .package(name: "Infrastructure", path: "../Infrastructure"), + ], + targets: [ + .target(name: "DIKit", dependencies: []), + .target(name: "AppDI", dependencies: ["DIKit", "Common", "NetworkImpl"]), + .target(name: "IntroDI", dependencies: ["DIKit", "AppDI"]), + // 각 기능별 DI도 동일한 구조 + ] +) +``` + +### 구조 + +``` +DI/ +└── Sources/ + ├── DIKit/ + │ ├── DIContainer.swift + │ ├── DIContainer+Assembly.swift + │ └── Assembly.swift + ├── AppDI/ + │ └── AppDIContainer.swift + ├── IntroDI/ + │ └── IntroDIContainer.swift + ├── LoginDI/ + ├── HomeDI/ + ├── MyPageDI/ + └── CommunityDI/ +``` + +**특징**: +- DIKit: Generic DI Container (재사용 가능) +- AppDI: 앱 레벨 싱글톤 서비스 등록 +- 각 기능 DI: AppDI를 부모로 하는 자식 컨테이너 + +--- + +## 모듈 의존성 규칙 + +### ✅ 허용 + +``` +기능 모듈 (Home, Community, ...) → Common +기능 모듈 Data 레이어 → Infrastructure +기능 모듈 → DI +모든 모듈 → Common +``` + +### ❌ 금지 + +``` +Common → 기능 모듈 (순환 의존성) +기능 모듈 → 다른 기능 모듈 (기능 간 직접 의존) +Domain 레이어 → Infrastructure (레이어 역전) +Presentation → Data (레이어 건너뛰기) +``` + +### 의존성 방향 + +``` +상위 (구체적) + ↓ + 기능 모듈 + ↓ +Infrastructure / DI + ↓ + Common + ↓ +하위 (추상적) +``` + +### 레이어 간 의존성 + +``` +Presentation → Domain ← Data + ↓ ↓ + DesignSystem Infrastructure + ↓ ↓ + Common (Util) +``` + +--- + +## 새 모듈 생성 가이드 + +### 1. 디렉토리 생성 + +```bash +mkdir MyNewFeature +cd MyNewFeature +mkdir -p Sources/MyNewFeatureDomain/{Entities,Repositories,UseCases,Errors} +mkdir -p Sources/MyNewFeatureData/{Repositories,API,DTO} +mkdir -p Sources/MyNewFeaturePresentation +mkdir -p Sources/MyNewFeatureDI +``` + +### 2. Package.swift 작성 + +```swift +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "MyNewFeature", + platforms: [.iOS(.v17)], + products: [ + .library(name: "MyNewFeatureDomain", targets: ["MyNewFeatureDomain"]), + .library(name: "MyNewFeatureData", targets: ["MyNewFeatureData"]), + .library(name: "MyNewFeaturePresentation", targets: ["MyNewFeaturePresentation"]), + .library(name: "MyNewFeatureDI", targets: ["MyNewFeatureDI"]), + ], + dependencies: [ + .package(name: "Common", path: "../Common"), + .package(name: "Infrastructure", path: "../Infrastructure"), + .package(name: "DI", path: "../DI"), + ], + targets: [ + // Domain: 의존성 없음 + .target( + name: "MyNewFeatureDomain", + dependencies: [] + ), + + // Data: Domain + Infrastructure + .target( + name: "MyNewFeatureData", + dependencies: [ + "MyNewFeatureDomain", + .product(name: "NetworkInterface", package: "Infrastructure"), + ] + ), + + // Presentation: Domain + Common + .target( + name: "MyNewFeaturePresentation", + dependencies: [ + "MyNewFeatureDomain", + .product(name: "DesignSystem", package: "Common"), + .product(name: "SharedUI", package: "Common"), + .product(name: "Util", package: "Common"), + ] + ), + + // DI: 모든 레이어 + DIKit + .target( + name: "MyNewFeatureDI", + dependencies: [ + "MyNewFeatureDomain", + "MyNewFeatureData", + "MyNewFeaturePresentation", + .product(name: "DIKit", package: "DI"), + .product(name: "AppDI", package: "DI"), + ] + ), + ] +) +``` + +### 3. DIContainer 생성 + +```swift +// Sources/MyNewFeatureDI/MyNewFeatureDIContainer.swift + +import DIKit +import AppDI +import MyNewFeatureDomain +import MyNewFeatureData +import MyNewFeaturePresentation +import NetworkInterface + +struct MyNewFeatureAssembly: Assembly { + func assemble(container: GenericDIContainer) { + // API Client + container.register(MyNewFeatureAPIClientInterface.self) { resolver in + MyNewFeatureAPIClient(networkService: resolver.resolve(NetworkServiceInterface.self)) + } + + // Repository + container.register(MyNewFeatureRepository.self) { resolver in + MyNewFeatureRepositoryImpl(apiClient: resolver.resolve(MyNewFeatureAPIClientInterface.self)) + } + + // UseCase + container.register(MyNewFeatureUseCase.self) { resolver in + MyNewFeatureUseCaseImpl(repository: resolver.resolve(MyNewFeatureRepository.self)) + } + + // ViewModel + container.register(MyNewFeatureViewModel.self) { @MainActor resolver in + MyNewFeatureViewModel(useCase: resolver.resolve(MyNewFeatureUseCase.self)) + } + } +} + +public final class MyNewFeatureDIContainer { + private let container: GenericDIContainer + + public init(appContainer: AppDIContainer = .shared) { + self.container = GenericDIContainer(parent: appContainer.baseContainer) + MyNewFeatureAssembly().assemble(container: container) + } + + @MainActor + public func makeViewModel() -> MyNewFeatureViewModel { + return container.resolve(MyNewFeatureViewModel.self) + } +} +``` + +### 4. Xcode 프로젝트에 추가 + +1. Xcode에서 `Hambug.xcodeproj` 열기 +2. File > Add Package Dependencies > Add Local... +3. MyNewFeature 폴더 선택 +4. Target (Hambug) > Build Phases > Link Binary With Libraries에 추가 + +### 5. 메인 앱에서 사용 + +```swift +// ContentView.swift + +let myNewFeatureDI = MyNewFeatureDIContainer(appContainer: appContainer) + +MyNewFeatureView(viewModel: myNewFeatureDI.makeViewModel()) +``` + +--- + +## 모듈 간 통신 + +### ❌ 잘못된 방법 (직접 의존) + +```swift +// Home 모듈에서 +import CommunityDomain // ❌ 기능 모듈 간 직접 의존 금지 + +func navigateToCommunity() { + // ... +} +``` + +### ✅ 올바른 방법 (공통 인터페이스) + +```swift +// Common/Util에 정의 +public protocol Navigator { + func navigateTo(route: AppRoute) +} + +public enum AppRoute { + case home + case community(boardId: Int?) + case myPage +} + +// 각 모듈은 Navigator 프로토콜에만 의존 +``` + +--- + +## 요약 + +### 핵심 원칙 + +1. **모듈 독립성**: 각 모듈은 독립적으로 빌드 가능 +2. **의존성 역전**: 상위 레이어는 하위 레이어에만 의존 +3. **순환 의존 금지**: 모듈 간 순환 참조 절대 불가 +4. **Common 공유**: 공통 기능은 Common 모듈로 +5. **레이어 분리**: Domain/Data/Presentation 엄격 분리 + +### 의존성 체크리스트 + +- [ ] Domain 레이어는 외부 의존성 없음 (순수 Swift) +- [ ] Data 레이어는 Infrastructure에만 의존 +- [ ] Presentation은 Domain + Common에만 의존 +- [ ] 기능 모듈은 다른 기능 모듈에 직접 의존하지 않음 +- [ ] 순환 의존성 없음 + +### 참고 파일 + +| 모듈 | Package.swift 위치 | +|------|---------------------| +| Common | `/Common/Package.swift` | +| Infrastructure | `/Infrastructure/Package.swift` | +| Home | `/Home/Package.swift` | +| Community | `/Community/Package.swift` | +| DI | `/DI/Package.swift` | + +**모듈 구조에 대한 질문이 있으면 이 문서를 참조하세요!** diff --git a/CLAUDME.MD b/CLAUDME.MD new file mode 100644 index 0000000..d9825ae --- /dev/null +++ b/CLAUDME.MD @@ -0,0 +1,741 @@ +# Hambug iOS - 개발 가이드 + +> AI 어시스턴트와 개발자를 위한 핵심 패턴 및 컨벤션 가이드 + +## 목차 +- [프로젝트 개요](#프로젝트-개요) +- [Clean Architecture](#clean-architecture) +- [Swift 6 패턴](#swift-6-패턴) +- [네이밍 컨벤션](#네이밍-컨벤션) +- [에러 처리](#에러-처리) +- [네트워크 레이어](#네트워크-레이어) +- [의존성 주입](#의존성-주입) +- [동시성 패턴](#동시성-패턴) +- [개발 가이드라인](#개발-가이드라인) +- [주요 파일 참조](#주요-파일-참조) +- [트러블슈팅](#트러블슈팅) + +**📦 모듈 구조는 [ABOUT_MODULE.MD](./ABOUT_MODULE.MD) 참조** + +--- + +## 프로젝트 개요 + +**Hambug**는 햄버거 애호가를 위한 커뮤니티 iOS 앱입니다. + +### 기술 스택 +- **UI**: SwiftUI +- **아키텍처**: SPM 모듈러 + Clean Architecture +- **동시성**: async/await (primary), Combine (network layer) +- **네트워킹**: Alamofire +- **의존성 주입**: 커스텀 DIKit +- **최소 버전**: iOS 17.0, Swift 6 + +### 핵심 원칙 +1. **모듈화**: 기능별 SPM 모듈 분리 +2. **Clean Architecture**: Domain/Data/Presentation 레이어 분리 +3. **의존성 역전**: 프로토콜 기반 추상화 +4. **타입 안전**: Sendable, async/await 준수 + +--- + +## Clean Architecture + +각 기능 모듈은 3개 레이어로 구성: + +``` +/ +├── Domain/ # 순수 비즈니스 로직 (의존성 없음) +├── Data/ # Repository 구현, API, DTO +└── Presentation/# ViewModel, View +``` + +### 1. Domain 레이어 + +#### Entity 패턴 +```swift +public struct Board: Identifiable, Equatable, Sendable { + public let id: Int + public let title: String + public var commentCount: Int // 가변 프로퍼티 가능 + + // 도메인 로직 포함 가능 + private static func timeAgoDisplay(_ date: Date) -> String { } +} +``` + +**핵심**: +- `Sendable`, `Identifiable`, `Equatable` 준수 +- Computed properties로 UI 로직 캡슐화 +- Preview용 `sampleData` static 프로퍼티 + +**참조**: `Community/Sources/Domain/Entities/Board.swift` + +#### Repository 프로토콜 +```swift +public protocol CommunityRepository: Sendable { + func fetchBoards(lastId: Int?, limit: Int, order: SortOrder) async throws -> BoardListData + func createBoard(title: String, content: String, category: BoardCategory, images: [UIImage]) async throws -> Board +} +``` + +**핵심**: +- `Sendable` 준수 +- async/await 기반 +- 도메인 타입 반환 (DTO 아님) + +#### UseCase 패턴 +```swift +public protocol GetBoardsUseCase: Sendable { + func execute(lastId: Int?, limit: Int, order: SortOrder) async throws -> BoardListData +} + +public final class GetBoardsUseCaseImpl: GetBoardsUseCase { + private let repository: CommunityRepository + + public func execute(...) async throws -> BoardListData { + let data = try await repository.fetchBoards(...) + // 비즈니스 로직 검증 + guard !data.content.isEmpty else { + throw CommunityError.emptyBoards + } + return data + } +} +``` + +**핵심**: +- Protocol + Implementation 패턴 +- 데이터 검증 및 변환 +- 도메인 에러 throw + +**참조**: `Community/Sources/Domain/UseCases/GetBoardsUseCaseImpl.swift` + +#### Domain Error +```swift +public enum HomeError: Error, LocalizedError { + case networkFailure(underlying: Error) + case emptyRecommendedBurgers + case invalidBurgerData(reason: String) + + public var errorDescription: String? { + switch self { + case .networkFailure: + return "네트워크 연결을 확인해주세요." + case .invalidBurgerData(let reason): + return "버거 데이터 오류: \(reason)" + } + } + + public var severity: ErrorSeverity { + switch self { + case .networkFailure: return .high + case .emptyRecommendedBurgers: return .medium + case .invalidBurgerData: return .low + } + } +} +``` + +**참조**: `Home/Sources/Domain/Errors/HomeError.swift` + +### 2. Data 레이어 + +#### DTO + Mapper 패턴 +```swift +public struct BoardResponseDTO: Decodable, Sendable { + public let id: Int + public let title: String + public let createdAt: String // ISO8601 +} + +extension BoardResponseDTO { + func toDomain() -> Board { + return Board( + id: id, + title: title, + createdAt: DateFormatter.iso8601.date(from: createdAt) ?? Date() + ) + } +} +``` + +**핵심**: DTO는 네트워크 전용, `toDomain()`으로 변환 + +**참조**: `Community/Sources/Data/DTO/BoardResponse.swift` + +#### API Client +```swift +public protocol CommunityAPIClientInterface: Sendable { + func fetchBoards(...) -> AnyPublisher +} + +public final class CommunityAPIClient: CommunityAPIClientInterface { + private let networkService: NetworkServiceInterface + + public func fetchBoards(...) -> AnyPublisher { + return networkService.request( + BoardEndpoint.boards(query), + responseType: SuccessResponse.self + ) + .map(\.data) // 래퍼 제거 + .eraseToAnyPublisher() + } +} +``` + +**핵심**: Combine Publisher 반환, DTO 타입 사용 + +**참조**: `Community/Sources/Data/API/CommunityAPIClient.swift` + +#### Repository 구현체 +```swift +public final class CommunityRepositoryImpl: CommunityRepository { + private let apiClient: CommunityAPIClientInterface + + public func fetchBoards(...) async throws -> BoardListData { + return try await apiClient + .fetchBoards(...) + .map { $0.toDomain() } // DTO → Domain + .mapError { $0 as Error } + .async() // Publisher → async/await + } +} +``` + +**핵심**: `.async()` extension으로 Combine → async/await 변환 + +**참조**: +- Repository: `Community/Sources/Data/Repositories/CommunityRepositoryImpl.swift` +- Bridge: `Common/Sources/Util/Combine+Ext.swift` + +### 3. Presentation 레이어 + +#### ViewModel 패턴 +```swift +@Observable +@MainActor +public final class HomeViewModel { + private let homeUseCase: HomeUseCase + + // State + public var data: [Model] = [] + public var isLoading = false + public var error: HomeError? + + public init(homeUseCase: HomeUseCase) { + self.homeUseCase = homeUseCase + Task { await loadData() } + } + + public func loadData() async { + isLoading = true + defer { isLoading = false } + + do { + data = try await homeUseCase.fetchData() + print("✅ 데이터 로드 성공") + } catch let error as HomeError { + self.error = error + data = Model.sampleData // Fallback + print("❌ 로드 실패: \(error.localizedDescription)") + } + } +} +``` + +**핵심**: +- `@Observable` + `@MainActor` +- `public final class` +- Task 초기화로 자동 로딩 +- 에러 시 샘플 데이터 폴백 +- 이모지 로그 (✅❌ℹ️⚠️) + +**참조**: `Home/Sources/Presentation/HomeViewModel.swift` + +#### View 패턴 +```swift +public struct CommunityView: View { + @State private var viewModel: CommunityViewModel + private let writeFactory: CommunityWriteFactory // Factory 패턴 + + public init(viewModel: CommunityViewModel, writeFactory: CommunityWriteFactory) { + self._viewModel = State(initialValue: viewModel) + self.writeFactory = writeFactory + } + + public var body: some View { + NavigationStack { + // ... + } + .refreshable { + await viewModel.loadData() + } + } +} +``` + +**핵심**: ViewModel은 `@State`, Factory로 하위 ViewModel 생성 + +--- + +## Swift 6 패턴 + +### @Observable 매크로 + +```swift +// ✅ 최신 +@Observable +@MainActor +public final class ViewModel { + public var data: [Model] = [] // 자동 추적 +} + +// ❌ Deprecated +class ViewModel: ObservableObject { + @Published var data: [Model] = [] +} +``` + +### Sendable 프로토콜 + +모든 Entity, Protocol, DTO는 `Sendable` 준수: + +```swift +public struct Board: Identifiable, Sendable { } +public protocol HomeUseCase: Sendable { } +public struct ResponseDTO: Decodable, Sendable { } +``` + +### async/await 동시성 + +```swift +// 기본 +public func loadData() async { + isLoading = true + defer { isLoading = false } + data = try await useCase.execute() +} + +// 병렬 +async let result1 = load1() +async let result2 = load2() +await result1 +await result2 + +// Task 초기화 +public init(useCase: UseCase) { + Task { await loadData() } +} +``` + +### Combine → async/await 브릿지 + +**위치**: `Common/Sources/Util/Combine+Ext.swift` + +```swift +public extension Publisher where Output: Sendable { + func async() async throws -> Output { + try await withCheckedThrowingContinuation { continuation in + var cancellable: AnyCancellable? + cancellable = first().sink { ... } + } + } +} +``` + +**사용**: Repository에서 `publisher.async()` + +--- + +## 네이밍 컨벤션 + +### 파일명 + +| 타입 | 패턴 | 예시 | +|------|------|------| +| ViewModel | `ViewModel.swift` | `HomeViewModel.swift` | +| UseCase | `UseCaseImpl.swift` | `GetBoardsUseCaseImpl.swift` | +| Repository | `RepositoryImpl.swift` | `CommunityRepositoryImpl.swift` | +| API Client | `APIClient.swift` | `CommunityAPIClient.swift` | +| Endpoint | `API.swift` | `HomeAPI.swift` | +| DTO | `Response.swift` | `BoardResponse.swift` | +| Entity | `.swift` | `Board.swift` | +| Error | `Error.swift` | `HomeError.swift` | + +### 변수/함수명 + +```swift +// 로딩 상태 +public var isLoading: Bool +public var isBurgersLoading: Bool + +// 에러 +public var errorMessage: String? +public var burgersError: HomeError? + +// 비동기 함수 +public func loadData() async +public func fetchBoards() async throws -> [Board] +public func createBoard(...) async throws -> Board +``` + +--- + +## 에러 처리 + +### 플로우 + +``` +NetworkError (Infrastructure) + ↓ +DomainError (Repository 매핑) + ↓ +ViewModel catch → UI 표시 or 샘플 데이터 폴백 +``` + +### ViewModel 에러 처리 패턴 + +```swift +do { + data = try await useCase.execute() + print("✅ 성공") +} catch let error as HomeError { + self.error = error + data = Model.sampleData // Fallback + print("❌ 실패: \(error.localizedDescription)") +} catch { + self.error = .networkFailure(underlying: error) + data = Model.sampleData +} +``` + +--- + +## 네트워크 레이어 + +### 아키텍처 + +``` +ViewModel → UseCase → Repository → APIClient → NetworkService (Alamofire) +``` + +### 핵심 컴포넌트 + +| 컴포넌트 | 파일 | +|----------|------| +| NetworkServiceInterface | `Infrastructure/Sources/NetworkInterface/NetworkServiceInterface.swift` | +| NetworkServiceImpl | `Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift` | +| AuthInterceptor | `Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift` | +| KeychainTokenStorage | `Common/Sources/DataSources/KeychainTokenStorage.swift` | + +### 요청 흐름 + +1. **Endpoint 정의** +```swift +public enum HomeEndpoint: Endpoint { + case recommendedBurgers + public var path: String { "/api/v1/burgers/recommended" } + public var method: HTTPMethod { .GET } +} +``` + +2. **APIClient 호출** (Combine) +```swift +networkService.request(HomeEndpoint.recommendedBurgers, responseType: SuccessResponse<[DTO]>.self) + .map(\.data) +``` + +3. **Repository 변환** (DTO → Domain + async) +```swift +try await apiClient.fetch().map { $0.toDomain() }.async() +``` + +4. **UseCase 검증** +```swift +let data = try await repository.fetch() +guard !data.isEmpty else { throw Error.empty } +``` + +5. **ViewModel 상태 업데이트** + +--- + +## 의존성 주입 + +### DIKit 패턴 + +**위치**: `DI/Sources/DIKit/` + +```swift +public protocol Assembly { + func assemble(container: GenericDIContainer) +} + +public enum Scope { + case singleton // 한 번 생성, 캐싱 + case transient // 매번 새로 생성 +} +``` + +### App DI Container + +**위치**: `DI/Sources/AppDI/AppDIContainer.swift` + +```swift +struct AppAssembly: Assembly { + func assemble(container: GenericDIContainer) { + container.register(NetworkServiceInterface.self, scope: .singleton) { resolver in + NetworkServiceImpl(interceptor: AuthInterceptor(...)) + } + } +} + +@Observable +public final class AppDIContainer: @unchecked Sendable { + public static let shared = AppDIContainer() +} +``` + +### 기능 DI Container + +```swift +public final class CommunityDIContainer { + private let container: GenericDIContainer + + public init(appContainer: AppDIContainer = .shared) { + // 부모 컨테이너 상속 + self.container = GenericDIContainer(parent: appContainer.baseContainer) + CommunityAssembly().assemble(container: container) + } + + @MainActor + public func makeViewModel() -> CommunityViewModel { + return container.resolve(CommunityViewModel.self) + } +} +``` + +**참조**: `Community/Sources/DI/CommunityDIContainer.swift` + +### Factory 패턴 + +```swift +public protocol CommunityWriteFactory { + func makeWriteViewModel() -> CommunityWriteViewModel +} + +extension CommunityDIContainer: CommunityWriteFactory { + public func makeWriteViewModel() -> CommunityWriteViewModel { + return container.resolve(CommunityWriteViewModel.self) + } +} +``` + +--- + +## 동시성 패턴 + +### 기본 패턴 +```swift +public func loadData() async { + isLoading = true + defer { isLoading = false } + data = try await useCase.execute() +} +``` + +### 병렬 로딩 +```swift +async let result1 = load1() +async let result2 = load2() +await result1 +await result2 +``` + +### Task 취소 +```swift +private var loadTask: Task? + +loadTask?.cancel() +loadTask = Task { await load() } +``` + +--- + +## 개발 가이드라인 + +### ✅ 권장 (DO) + +**아키텍처**: +- ViewModel에 `@Observable` + `@MainActor` +- Entity를 `Sendable`로 +- Domain에서 async/await +- Protocol + Implementation 패턴 +- 도메인별 에러 enum +- 에러 시 샘플 데이터 폴백 + +**코드 스타일**: +- MARK 주석으로 섹션 구분 +- 이모지 로그 (✅❌ℹ️⚠️) +- 한글 에러 메시지 + +### ❌ 금지 (DON'T) + +- `@ObservedObject`, `@Published` 사용 +- force-unwrapping (`!`) +- 순환 모듈 의존성 +- Domain에 Presentation 로직 혼합 +- 동기 네트워크 호출 +- `Sendable` 요구사항 무시 + +--- + +## 주요 파일 참조 + +### 앱 구조 +- 앱 진입점: `Hambug/HambugApp.swift` +- 루트 네비게이션: `Hambug/RootView.swift` +- 메인 콘텐츠: `Hambug/ContentView.swift` + +### DI +- App DI: `DI/Sources/AppDI/AppDIContainer.swift` +- DIKit: `DI/Sources/DIKit/DIContainer+Assembly.swift` + +### Network +- Interface: `Infrastructure/Sources/NetworkInterface/NetworkServiceInterface.swift` +- Implementation: `Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift` +- Token Storage: `Common/Sources/DataSources/KeychainTokenStorage.swift` + +### Common +- Combine Bridge: `Common/Sources/Util/Combine+Ext.swift` +- DesignSystem: `Common/Sources/DesignSystem/` +- CustomTabView: `Common/Sources/SharedUI/CustomTabView/CustomTabView.swift` + +### 기능 모듈 예시 +- Home ViewModel: `Home/Sources/Presentation/HomeViewModel.swift` +- Home Error: `Home/Sources/Domain/Errors/HomeError.swift` +- Community ViewModel: `Community/Sources/Presentation/Community/CommunityViewModel.swift` +- Board Entity: `Community/Sources/Domain/Entities/Board.swift` + +--- + +## 트러블슈팅 + +### 1. Sendable 에러 +``` +Type '...' does not conform to protocol 'Sendable' +``` + +**해결**: Entity/Protocol/DTO에 `Sendable` 추가 +```swift +public struct Board: Identifiable, Sendable { } +public protocol UseCase: Sendable { } +``` + +### 2. MainActor 에러 +``` +Call to main actor-isolated initializer in a synchronous context +``` + +**해결**: DI Container에서 `@MainActor` 명시 +```swift +container.register(ViewModel.self) { @MainActor resolver in + ViewModel(...) +} +``` + +### 3. @Published 에러 +``` +Cannot convert 'Published<...>.Publisher' +``` + +**해결**: `@Observable` 사용 +```swift +// ❌ +@Published var data: [Model] + +// ✅ +@Observable +class ViewModel { + var data: [Model] +} +``` + +### 4. 샘플 데이터가 계속 표시됨 + +**확인**: +1. 콘솔 로그 (❌ 에러 메시지) +2. `error` 프로퍼티 확인 +3. 네트워크 연결 +4. 토큰 유효성 + +### 5. Module not found + +**해결**: +```bash +# Xcode +File > Packages > Reset Package Caches + +# 터미널 +rm -rf .build +xcodebuild -resolvePackageDependencies +``` + +--- + +## 마이그레이션 노트 + +### ObservableObject → @Observable + +**구식 (iOS 13-16)**: +```swift +class ViewModel: ObservableObject { + @Published var data: [Model] = [] +} + +struct View: View { + @ObservedObject var viewModel: ViewModel +} +``` + +**최신 (iOS 17+)**: +```swift +@Observable +@MainActor +final class ViewModel { + var data: [Model] = [] +} + +struct View: View { + @State private var viewModel: ViewModel +} +``` + +--- + +## 현재 개발 상태 + +- **활성 브랜치**: `feature/community` +- **메인 브랜치**: `develop` +- **최근 작업**: Community list/detail/write, CustomTabView, Report/Comment/Like + +--- + +## 참고 문서 + +- 📦 **모듈 구조**: [ABOUT_MODULE.MD](./ABOUT_MODULE.MD) + +--- + +## 요약 + +**Hambug iOS 핵심 원칙**: +1. SPM 모듈화 + Clean Architecture +2. Swift 6 (@Observable, Sendable, async/await) +3. 프로토콜 기반 추상화 +4. 도메인 에러 + 샘플 데이터 폴백 +5. DIKit 계층적 DI +6. async/await 우선, Combine은 네트워크만 + +**파일을 읽을 때는 실제 파일을 직접 읽으세요. 여기는 패턴 참조용입니다!** diff --git a/Common/Package.swift b/Common/Package.swift index c0dab6b..168956b 100644 --- a/Common/Package.swift +++ b/Common/Package.swift @@ -27,6 +27,10 @@ let package = Package( name: "Util", targets: ["Util"] ), + .library( + name: "SharedUI", + targets: ["SharedUI"] + ), ], targets: [ .target( @@ -42,6 +46,10 @@ let package = Package( .target(name: "DataSources"), .target(name: "LocalizedString"), .target(name: "Util"), + .target( + name: "SharedUI", + dependencies: ["DesignSystem"] + ), .plugin( name: "ColorGenerator", diff --git a/Common/Sources/DesignSystem/ProfileImageView.swift b/Common/Sources/DesignSystem/ProfileImageView.swift new file mode 100644 index 0000000..87ba084 --- /dev/null +++ b/Common/Sources/DesignSystem/ProfileImageView.swift @@ -0,0 +1,54 @@ +// +// ProfileImageView.swift +// Common +// +// Created by 강동영 on 1/11/26. +// + +import SwiftUI + +public struct ProfileImageView: View { + private let imageUrlString: String + private var width: CGFloat + private var height: CGFloat + private var fillColor: Color + + public init( + with imageUrlString: String, + width: CGFloat = 32, + height: CGFloat = 32, + fillColor: Color = Color.bgG200 + ) { + self.imageUrlString = imageUrlString + self.width = width + self.height = height + self.fillColor = fillColor + } + + public var body: some View { + AsyncImage(url: URL(string: imageUrlString)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: width, height: height) + case .empty, .failure: + Image(.placeholderProfile) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: width, height: height) + @unknown default: + Image(.placeholderProfile) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: width, height: height) } + } + } +} + +public extension ProfileImageView { + func applyCilpShape() -> some View { + self.clipShape(Circle()) + } +} diff --git a/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/Contents.json b/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/Contents.json new file mode 100644 index 0000000..8946336 --- /dev/null +++ b/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "기본 프로필 이미지.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git "a/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/\352\270\260\353\263\270 \355\224\204\353\241\234\355\225\204 \354\235\264\353\257\270\354\247\200.png" "b/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/\352\270\260\353\263\270 \355\224\204\353\241\234\355\225\204 \354\235\264\353\257\270\354\247\200.png" new file mode 100644 index 0000000..a0f87fa Binary files /dev/null and "b/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/\352\270\260\353\263\270 \355\224\204\353\241\234\355\225\204 \354\235\264\353\257\270\354\247\200.png" differ diff --git a/Common/Sources/Managers/AppStorageKey.swift b/Common/Sources/Managers/AppStorageKey.swift index c5ebe71..bc8534e 100644 --- a/Common/Sources/Managers/AppStorageKey.swift +++ b/Common/Sources/Managers/AppStorageKey.swift @@ -11,6 +11,7 @@ public extension String { struct Storage { static let hasSeenOnboarding = "hasSeenOnboarding" static let userResponse = "userResponse" + static let currentUserId = "currentUserId" } } diff --git a/Common/Sources/Managers/UserDefaultsManager.swift b/Common/Sources/Managers/UserDefaultsManager.swift index 5e76d35..d90c46d 100644 --- a/Common/Sources/Managers/UserDefaultsManager.swift +++ b/Common/Sources/Managers/UserDefaultsManager.swift @@ -7,24 +7,44 @@ import Foundation +// Helper protocol for optional handling +protocol OptionalProtocol { + var isNil: Bool { get } +} + +extension Optional: OptionalProtocol { + var isNil: Bool { self == nil } +} + @propertyWrapper -struct UDDefaultWrapper { +public struct UDDefaultWrapper { private let ud = UserDefaults.standard - var key: String - var defaultValue: T - var wrappedValue: T { + let key: String + let defaultValue: T + + public var wrappedValue: T { get { return ud.value(forKey: key) as? T ?? defaultValue } - set { - ud.setValue(newValue, forKey: key) + nonmutating set { + if let optional = newValue as? (any OptionalProtocol), optional.isNil { + ud.removeObject(forKey: key) + } else { + ud.setValue(newValue, forKey: key) + } ud.synchronize() } } } -public struct UserDefaultsManager { +public final class UserDefaultsManager { public static let shared = UserDefaultsManager() + + private init() {} + @UDDefaultWrapper(key: .Storage.hasSeenOnboarding, defaultValue: false) var isOnboardingCompleted: Bool + + @UDDefaultWrapper(key: .Storage.currentUserId, defaultValue: nil) + public var currentUserId: Int64? } diff --git a/Common/Sources/SharedUI/CustomTabView/CustomTabView.swift b/Common/Sources/SharedUI/CustomTabView/CustomTabView.swift new file mode 100644 index 0000000..5dd769e --- /dev/null +++ b/Common/Sources/SharedUI/CustomTabView/CustomTabView.swift @@ -0,0 +1,98 @@ +// +// CustomTabView.swift +// Hambug +// +// Created by 강동영 on 12/12/25. +// + +import DesignSystem +import SwiftUI + +public struct CustomTabView: View { + private let tabConfig: [HambugTab] + @ViewBuilder let content: Content + + @Binding private var selectedTab: Int + @State private var isTabBarHidden: Bool = false + + public var body: some View { + ZStack { + // Content area + TabView(selection: $selectedTab) { + content + .syncTabBarVisibility(with: $isTabBarHidden) + } + + VStack { + Spacer() + if !isTabBarHidden { tabView } + } + .ignoresSafeArea(.container, edges: .bottom) + + + } + } + + /// Custom Tab Bar + private var tabView: some View { + HStack(spacing: 0) { + ForEach(tabConfig) { tab in + TabBarItem( + config: tab, + isSelected: selectedTab == tab.id + ) { + selectedTab = tab.id + } + } + } + .frame(height: UIScreen.main.bounds.height * 0.11) + .background( + Color.white + .cornerRadius(30, corners: [.topLeft, .topRight]) + ) + .shadow(color: .black.opacity(0.1), radius: 10, y: -5) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + public init( + selectedTab: Binding, + tabConfig: [HambugTab] = HambugTab.allCases, + @ViewBuilder content: () -> Content + ) { + self._selectedTab = selectedTab + self.tabConfig = tabConfig + self.content = content() + } +} + + +// MARK: Style 관련 객체들 +fileprivate extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } +} + +struct RoundedCorner: Shape { + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> SwiftUI.Path { + let path = UIBezierPath( + roundedRect: rect, + byRoundingCorners: corners, + cornerRadii: CGSize(width: radius, height: radius) + ) + return SwiftUI.Path(path.cgPath) + } +} + + +// +//#Preview { +// @Previewable @State var selectedTab = 0 +// +// CustomTabView(selectedTab: $selectedTab) { +// Text("Preview") +// } +//} diff --git a/Common/Sources/SharedUI/CustomTabView/HambugTab.swift b/Common/Sources/SharedUI/CustomTabView/HambugTab.swift new file mode 100644 index 0000000..187475f --- /dev/null +++ b/Common/Sources/SharedUI/CustomTabView/HambugTab.swift @@ -0,0 +1,77 @@ +// +// HambugTab.swift +// Common +// +// Created by 강동영 on 1/9/26. +// + +import SwiftUI + +// MARK: CustomTabView 의 HambugTab +extension CustomTabView { + public enum HambugTab: Int, CaseIterable, Identifiable { + case home = 0 + case community = 1 + case myPage = 2 + + public var id: Int { rawValue } + + var iconName: String { + switch self { + case .home: + "tab_home" + case .community: + "tab_community" + case .myPage: + "tab_user" + } + } + + var title: String { + switch self { + case .home: + "홈" + case .community: + "커뮤니티" + case .myPage: + "마이" + } + } + } +} + +// MARK: TabBarItem +extension CustomTabView { + struct TabBarItem: View { + private let config: HambugTab + private let isSelected: Bool + private let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + Image(config.iconName) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 21, height: 21) + + Text(config.title) + .pretendard(.caption(.emphasis)) + } + .foregroundColor(isSelected ? .primaryHambugRed : .borderG400) + .frame(maxWidth: .infinity) + } + } + + init( + config: HambugTab, + isSelected: Bool, + action: @escaping () -> Void + ) { + self.config = config + self.isSelected = isSelected + self.action = action + } + } +} diff --git a/Common/Sources/SharedUI/CustomTabView/TabBarVisibilityKey.swift b/Common/Sources/SharedUI/CustomTabView/TabBarVisibilityKey.swift new file mode 100644 index 0000000..4c25a08 --- /dev/null +++ b/Common/Sources/SharedUI/CustomTabView/TabBarVisibilityKey.swift @@ -0,0 +1,56 @@ +// +// TabBarVisibilityKey.swift +// Common +// +// Created by 강동영 on 1/9/26. +// + +import SwiftUI + +// MARK: - TabBar Visibility Environment +private struct TabBarVisibilityKey: EnvironmentKey { + static let defaultValue: Binding = .constant(false) +} + +extension EnvironmentValues { + var tabBarVisibility: Binding { + get { self[TabBarVisibilityKey.self] } + set { self[TabBarVisibilityKey.self] = newValue } + } +} + +public extension View { + /// 커스텀 탭바를 숨기거나 표시합니다. + /// - Parameter hidden: true면 탭바를 숨기고, false면 탭바를 표시합니다. + func tabBarHidden(_ hidden: Bool) -> some View { + self + .onAppear { + // View가 나타날 때 preference를 강제로 다시 전파 + // 이렇게 하면 NavigationStack에서 pop 시에도 제대로 갱신됨 + } + .preference(key: TabBarVisibilityPreference.self, value: hidden) + } +} + +private struct TabBarVisibilityPreference: PreferenceKey { + static var defaultValue: Bool = false + + static func reduce(value: inout Bool, nextValue: () -> Bool) { + // 자식 View의 preference가 우선 + // true(숨김)가 있으면 true를 우선시 + let next = nextValue() + if next { + value = next + } + } +} + +extension View { + func syncTabBarVisibility(with binding: Binding) -> some View { + self.onPreferenceChange(TabBarVisibilityPreference.self) { newValue in + withAnimation(.easeInOut(duration: 0.2)) { + binding.wrappedValue = newValue + } + } + } +} diff --git a/Common/Sources/Util/DateFormatter+Util.swift b/Common/Sources/Util/DateFormatter+Util.swift new file mode 100644 index 0000000..483ac4b --- /dev/null +++ b/Common/Sources/Util/DateFormatter+Util.swift @@ -0,0 +1,18 @@ +// +// DateFormatter+Util.swift +// Common +// +// Created by 강동영 on 1/9/26. +// + +import Foundation + +public extension DateFormatter { + static let iso8601WithMicroseconds: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") + return formatter + }() +} diff --git a/Common/Sources/Util/ImageProcessor.swift b/Common/Sources/Util/ImageProcessor.swift new file mode 100644 index 0000000..dffcd84 --- /dev/null +++ b/Common/Sources/Util/ImageProcessor.swift @@ -0,0 +1,92 @@ +// +// ImageProcessor.swift +// Common +// +// Created by 강동영 on 1/8/26. +// + +import UIKit + +/// 이미지 리사이징 및 압축 유틸리티 +public enum ImageProcessor { + + // MARK: - Constants + + /// 최대 파일 크기: 10MB + public static let maxFileSize: Int = 10 * 1024 * 1024 + + /// 최대 해상도: 1920px + public static let maxDimension: CGFloat = 1920 + + /// 기본 압축 품질 + public static let compressionQuality: CGFloat = 0.85 + + // MARK: - Public Methods + + /// 이미지를 처리 (리사이징 + 압축) + /// - Parameter image: 원본 UIImage + /// - Returns: 처리된 JPEG Data (10MB 이하), 실패 시 nil + public static func process(_ image: UIImage) -> Data? { + // 1. 해상도가 초과하면 리사이징 + let resizedImage = resize(image, maxDimension: maxDimension) + + // 2. JPEG 압축 + guard var imageData = resizedImage.jpegData(compressionQuality: compressionQuality) else { + return nil + } + + // 3. 10MB 초과 시 품질을 낮추며 재압축 + var currentQuality = compressionQuality + while imageData.count > maxFileSize && currentQuality > 0.5 { + currentQuality -= 0.1 + guard let compressedData = resizedImage.jpegData(compressionQuality: currentQuality) else { + break + } + imageData = compressedData + } + + // 4. 최종 크기 검증 + return imageData.count <= maxFileSize ? imageData : nil + } + + /// 예상 파일 크기 계산 (RGBA 기준) + /// - Parameter image: UIImage + /// - Returns: 예상 크기 (bytes) + public static func estimatedSize(of image: UIImage) -> Int { + return Int(image.size.width * image.size.height * 4) + } + + // MARK: - Private Methods + + /// 이미지 해상도 조정 (비율 유지) + /// - Parameters: + /// - image: 원본 이미지 + /// - maxDimension: 최대 너비/높이 + /// - Returns: 리사이징된 이미지 + private static func resize(_ image: UIImage, maxDimension: CGFloat) -> UIImage { + let size = image.size + + // 이미 범위 내에 있으면 원본 반환 + if size.width <= maxDimension && size.height <= maxDimension { + return image + } + + // 새로운 크기 계산 (비율 유지) + let aspectRatio = size.width / size.height + let newSize: CGSize + + if size.width > size.height { + // 가로가 더 긴 경우 + newSize = CGSize(width: maxDimension, height: maxDimension / aspectRatio) + } else { + // 세로가 더 긴 경우 + newSize = CGSize(width: maxDimension * aspectRatio, height: maxDimension) + } + + // 리사이징 수행 + let renderer = UIGraphicsImageRenderer(size: newSize) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + } +} diff --git a/Community/Package.swift b/Community/Package.swift index 9457e48..d2afff7 100644 --- a/Community/Package.swift +++ b/Community/Package.swift @@ -6,6 +6,7 @@ import PackageDescription enum Config: String, CaseIterable { static let name: String = "Community" + case di = "DI" case data = "Data" case domain = "Domain" case presentation = "Presentation" @@ -29,42 +30,86 @@ let package = Package( ), ], dependencies: [ + .package(name: "DI", path: "../DI"), .package(name: "Common", path: "../Common"), .package(name: "Infrastructure", path: "../Infrastructure"), ], targets: [ + .target( + config: .di, + dependencies: [ + .target(config: .domain), + .target(config: .data), + .target(config: .presentation), + .product(name: "DI", package: "DI"), + .product(name: "AppDI", package: "DI"), + .product(name: "NetworkInterface", package: "Infrastructure"), + .product(name: "NetworkImpl", package: "Infrastructure"), + ], + ), // Domain: 독립적 (외부 의존성 없음) .target( - name: Config.domain.name, + config: .domain, dependencies: [ .product(name: "NetworkInterface", package: "Infrastructure"), ], - path: Config.domain.path ), // Data: Domain, Network에 의존 .target( - name: Config.data.name, + config: .data, dependencies: [ .target(config: .domain), + .product(name: "Util", package: "Common"), .product(name: "NetworkInterface", package: "Infrastructure"), .product(name: "NetworkImpl", package: "Infrastructure"), ], - path: Config.data.path ), - // Presentation: Domain, DesignSystem에 의존 + // Presentation: Domain, DesignSystem, Layout에 의존 .target( - name: Config.presentation.name, + config: .presentation, dependencies: [ .target(config: .domain), .product(name: "DesignSystem", package: "Common"), + .product(name: "SharedUI", package: "Common"), ], - path: Config.presentation.path ), ] ) +extension Target { + static func target( + config: Config, + dependencies: [Dependency] = [], + exclude: [String] = [], + sources: [String]? = nil, + resources: [Resource]? = nil, + publicHeadersPath: String? = nil, + packageAccess: Bool = false, + cSettings: [CSetting]? = nil, + cxxSettings: [CXXSetting]? = nil, + swiftSettings: [SwiftSetting]? = nil, + linkerSettings: [LinkerSetting]? = nil, + plugins: [PluginUsage]? = nil, + ) -> Target { + return .target( + name: config.name, + dependencies: dependencies, + path: config.path, + exclude: exclude, + sources: sources, + resources: resources, + publicHeadersPath: publicHeadersPath, + packageAccess: packageAccess, + cSettings: cSettings, + cxxSettings: cxxSettings, + swiftSettings: swiftSettings, + linkerSettings: linkerSettings, + plugins: plugins) + } +} + extension Target.Dependency { static func target(config: Config) -> Self { return .target(name: config.name) diff --git a/Community/Sources/DI/CommunityDIContainer.swift b/Community/Sources/DI/CommunityDIContainer.swift new file mode 100644 index 0000000..5bb4adc --- /dev/null +++ b/Community/Sources/DI/CommunityDIContainer.swift @@ -0,0 +1,215 @@ +// +// CommunityDIContainer.swift +// Hambug +// +// Created by 강동영 on 10/17/25. +// + +import Foundation +import DIKit +import AppDI +import NetworkInterface +import NetworkImpl +import CommunityDomain +import CommunityData +import CommunityPresentation +import Managers + +struct CommunityWriteAssembly: Assembly { + func assemble(container: GenericDIContainer) { + container.register(CreateBoardUseCase.self) { resolver in + CreateBoardUseCaseImpl(repository: resolver.resolve(CommunityRepository.self)) + } + + container.register(CommunityWriteViewModel.self) { resolver in + CommunityWriteViewModel(createBoardUseCase: resolver.resolve(CreateBoardUseCase.self)) + } + + container.register(UpdateBoardUseCase.self) { resolver in + UpdateBoardUseCaseImpl(repository: resolver.resolve(CommunityRepository.self)) + } + } +} +// MARK: - Community 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 { +// return MockCommunityAPIClient() +// } else { + return CommunityAPIClient(networkService: resolver.resolve(NetworkServiceInterface.self)) +// } + } + + // Repository registration + container.register(CommunityRepository.self) { resolver in + CommunityRepositoryImpl(apiClient: resolver.resolve(CommunityAPIClientInterface.self)) + } + + // UseCase registration + container.register(GetBoardsUseCase.self) { resolver in + GetBoardsUseCaseImpl(repository: resolver.resolve(CommunityRepository.self)) + } + + container.register(GetBoardsByCategoryUseCase.self) { resolver in + GetBoardsByCategoryUseCaseImpl(repository: resolver.resolve(CommunityRepository.self)) + } + + // MARK: - CommunityViewModel registration + container.register(CommunityViewModel.self) { @MainActor resolver in + CommunityViewModel( + getBoardsUseCase: resolver.resolve(GetBoardsUseCase.self), + getBoardsByCategoryUseCase: resolver.resolve(GetBoardsByCategoryUseCase.self) + ) + } + + container.register(BoardDetailUseCase.self) { resolver in + BoardDetailUseCaseImpl(repository: resolver.resolve(CommunityRepository.self)) + } + + container.register(CommentUseCase.self) { resolver in + CommentUseCaseImpl(repository: resolver.resolve(CommunityRepository.self)) + } + + container.register(LikeUseCase.self) { resolver in + LikeUseCaseImpl(repository: resolver.resolve(CommunityRepository.self)) + } + + container.register(ReportContentUseCase.self) { resolver in + ReportContentUseCaseImpl(repository: resolver.resolve(CommunityRepository.self)) + } + + container.register(CommunityDetailViewModel.self) { @MainActor resolver in + CommunityDetailViewModel( + boardDetailUseCase: resolver.resolve(BoardDetailUseCase.self), + commentUseCase: resolver.resolve(CommentUseCase.self), + likeUseCase: resolver.resolve(LikeUseCase.self), + reportContentUseCase: resolver.resolve(ReportContentUseCase.self), + userDefaultsManager: resolver.resolve(UserDefaultsManager.self) + ) + } + } +} + +// MARK: - Community DI Container +public final class CommunityDIContainer { + + // MARK: - Properties + private let container: GenericDIContainer + + // MARK: - Initialization + public init(appContainer: AppDIContainer = .shared) { + self.container = GenericDIContainer(parent: appContainer.baseContainer) + CommunityAssembly().assemble(container: container) + CommunityWriteAssembly().assemble(container: container) + } + + // MARK: - Factory Methods + @MainActor + public func makeCommunityViewModel() -> CommunityViewModel { + return container.resolve(CommunityViewModel.self) + } +} + +extension CommunityDIContainer: CommunityWriteFactory { + public func makeWriteViewModel() -> any CommunityWriteViewModelProtocol { + container.resolve(CommunityWriteViewModel.self) + } +} + +extension CommunityDIContainer: CommunityDetailFactory { + @MainActor + public func makeDetailViewModel() -> CommunityDetailViewModel { + return container.resolve(CommunityDetailViewModel.self) + } +} + +extension CommunityDIContainer: UpdateBoardFactory { + public func makeViewModel(boardId: Int) -> CommunityWriteViewModelProtocol { + return UpdateBoardViewModel( + boardId: boardId, + updateBoardUseCase: container.resolve(UpdateBoardUseCase.self) + ) + } +} + +extension CommunityDIContainer: ReportBoardFactory { + public func makeViewModel(req: ReportRequest) -> CommunityReportViewModel { + return CommunityReportViewModel( + usecase: container.resolve(ReportContentUseCase.self), + reportInfo: req + ) + } +} + +// MARK: - Mock Setup +private func setupURLProtocol() { + let boardsData: [[String: Any]] = [ + [ + "id": 1, + "imageURL": "https://example.com/image1.jpg", + "title": "첫 번째 게시글", + "nickName": "햄버거러버", + "createdAt": "2024-10-17T10:00:00.000Z", + "likeCount": "15", + "commnetCount": "3" + ], + [ + "id": 2, + "imageURL": "https://example.com/image2.jpg", + "title": "맛있는 햄버거 추천", + "nickName": "음식탐험가", + "createdAt": "2024-10-17T09:30:00.000Z", + "likeCount": "23", + "commnetCount": "7" + ] + ] + + let boardsResponse: [String: Any] = [ + "success": true, + "data": boardsData, + "message": "성공", + "code": 200 + ] + + let boardsResponseData = try! JSONSerialization.data(withJSONObject: boardsResponse, options: []) + + let boardDetailResponse: [String: Any] = [ + "id": 1, + "imageURL": "https://example.com/image1.jpg", + "title": "첫 번째 게시글", + "content": "이것은 첫 번째 게시글의 상세 내용입니다.", + "nickName": "햄버거러버", + "createdAt": "2024-10-17T10:00:00.000Z", + "likeCount": "15", + "commnetCount": "3" + ] + + let boardDetailResponseData = try! JSONSerialization.data(withJSONObject: boardDetailResponse, options: []) + + CommunityURLProtocol.successMock = [ + "/api/v1/boards": (200, boardsResponseData), + "/api/v1/boards?category=FREE_TALK": (200, boardsResponseData), + "/api/v1/boards?category=FRANCHISE": (200, boardsResponseData), + "/api/v1/boards?category=HANDMADE": (200, boardsResponseData), + "/api/v1/boards?category=RECOMMENDATION": (200, boardsResponseData) + ] +} + +//#Preview { +// let diContainer = CommunityDIContainer() +// CommunityView(viewModel: diContainer.makeCommunityViewModel(), diContainer: diContainer) +//} diff --git a/Community/Sources/Data/API/CommunityAPI.swift b/Community/Sources/Data/API/CommunityAPI.swift index 8d6614d..ae9c461 100644 --- a/Community/Sources/Data/API/CommunityAPI.swift +++ b/Community/Sources/Data/API/CommunityAPI.swift @@ -7,45 +7,104 @@ import Foundation import NetworkInterface +import CommunityDomain -// MARK: - Board Endpoints (Moya Style) +// MARK: - Board Endpoints public enum BoardEndpoint: Endpoint { - case boards - case boardsBy(_ category: Category) + case boards(CursorPagingQuery) + case boardsByCategory(CategoryPagingQuery) + case createBoard(BoardRequestDTO) + case boardDetail(boardId: Int) + case updateBoard(boardId: Int, body: BoardRequestDTO) + case deleteBoard(boardId: Int) + case comments(boardId: Int, query: CursorPagingQuery) + case createComment(boardId: Int, content: String) + case updateComment(boardId: Int, commentId: Int, content: String) + case deleteComment(boardId: Int, commentId: Int) + case likeInfo(boardId: Int) + case toggleLike(boardId: Int) + case report(ReportRequestDTO) public var baseURL: String { - return "https://hambug.p-e.kr/api/v1" + return NetworkConfig.baseURL + "/api/v1" } - + public var path: String { switch self { - case .boards, .boardsBy(_): + case .createBoard(let dto): + return dto.imageUrls.isEmpty ? "/boards" : "/boards/with-image" + case .updateBoard(let id, let dto): + return dto.imageUrls.isEmpty ? "/boards/\(id)" : "/boards/\(id)/with-image" + case .boards: return "/boards" + case .boardsByCategory: + return "/boards/category" + case .boardDetail(let boardId): + return "/boards/\(boardId)" + case .deleteBoard(let boardId): + return "/boards/\(boardId)" + case .comments(let boardId, _): + return "/boards/\(boardId)/comments" + case .createComment(let boardId, _): + return "/boards/\(boardId)/comments" + case .updateComment(let boardId, let commentId, _): + return "/boards/\(boardId)/comments/\(commentId)" + case .deleteComment(let boardId, let commentId): + return "/boards/\(boardId)/comments/\(commentId)" + case .likeInfo(let boardId): + return "/boards/\(boardId)/likes" + case .toggleLike(let boardId): + return "/boards/\(boardId)/likes" + case .report: + return "/reports" } } - + public var method: HTTPMethod { switch self { - default: + case .boards, .boardsByCategory, .boardDetail, .comments, .likeInfo: return .GET + case .createBoard, .createComment, .toggleLike, .report: + return .POST + case .updateBoard, .updateComment: + return .PUT + case .deleteBoard, .deleteComment: + return .DELETE } } - + public var headers: [String: String] { - var headers: [String: String] = [:] - return headers + return [:] } - + public var queryParameters: [String: Any] { switch self { - case .boards: + case let .boards(dto): + return queryEncoder.encode(dto) + case let .boardsByCategory(dto): + return queryEncoder.encode(dto) + case let .comments(_, dto): + return queryEncoder.encode(dto) + default: return [:] - case .boardsBy(let category): - return ["category": category.rawValue] } } - + public var body: Data? { - return nil + let encoder = JSONEncoder() + switch self { + case .createBoard(let dto), .updateBoard(_, let dto): + return try? encoder.encode(dto) + case .createComment(_, let content): + let body = ["content": content] + return try? encoder.encode(body) + case .updateComment(_, _, let content): + let body = ["content": content] + return try? encoder.encode(body) + case .report(let request): + return try? encoder.encode(request) + default: + return nil + } } } diff --git a/Community/Sources/Data/API/CommunityAPIClient.swift b/Community/Sources/Data/API/CommunityAPIClient.swift index a69319b..18b6cbe 100644 --- a/Community/Sources/Data/API/CommunityAPIClient.swift +++ b/Community/Sources/Data/API/CommunityAPIClient.swift @@ -8,11 +8,45 @@ import Foundation import Combine import NetworkInterface +import CommunityDomain +import UIKit // MARK: - Community API Client Interface -public protocol CommunityAPIClientInterface { - func fetchBoards() -> AnyPublisher<[BoardListResponse], NetworkError> - func fetchBoardsByCategory(_ category: Category) -> AnyPublisher<[BoardResponse], NetworkError> +public protocol CommunityAPIClientInterface: Sendable { + func fetchBoards(lastId: Int?, limit: Int, order: String) -> AnyPublisher + func fetchBoardsByCategory(category: String, lastId: Int?, limit: Int, order: String) -> AnyPublisher + func fetchBoardDetail(boardId: Int) -> AnyPublisher + + // Board creation + func createBoard( + title: String, + content: String, + category: String, + images: [UIImage] + ) -> AnyPublisher + + func updateBoard( + boardId: Int, + title: String, + content: String, + category: String, + images: [UIImage] + ) -> AnyPublisher + + func deleteBoard(boardId: Int) -> AnyPublisher + + // Comments + func fetchComments(boardId: Int, lastId: Int?, limit: Int, order: String) -> AnyPublisher + func createComment(boardId: Int, content: String) -> AnyPublisher + func updateComment(boardId: Int, commentId: Int, content: String) -> AnyPublisher + func deleteComment(boardId: Int, commentId: Int) -> AnyPublisher + + // Likes + func fetchLikeInfo(boardId: Int) -> AnyPublisher + func toggleLike(boardId: Int) -> AnyPublisher + + // Report + func reportContent(request: ReportRequestDTO) -> AnyPublisher } // MARK: - Community API Client Implementation @@ -24,72 +58,175 @@ public final class CommunityAPIClient: CommunityAPIClientInterface { self.networkService = networkService } - public func fetchBoards() -> AnyPublisher<[BoardListResponse], NetworkError> { - return networkService.request(BoardEndpoint.boards, responseType: [BoardListResponse].self) + public func fetchBoards(lastId: Int?, limit: Int, order: String) -> AnyPublisher { + let query: CursorPagingQuery = .init(lastId: lastId, limit: limit, order: order) + return networkService.request( + BoardEndpoint.boards(query), + responseType: SuccessResponse.self + ) + .map(\.data) + .eraseToAnyPublisher() } - - public func fetchBoardsByCategory(_ category: Category) -> AnyPublisher<[BoardResponse], NetworkError> { - switch category { - case .all, .freeTalk: - return networkService.request(BoardEndpoint.boardsBy(category), responseType: [BoardListResponse].self) - - case .franchise, .handmade, .recommendation: - return networkService.request(BoardEndpoint.boardsBy(category), responseType: [BoardFeedResponse].self) - } - + + public func fetchBoardsByCategory(category: String, lastId: Int?, limit: Int, order: String) -> AnyPublisher { + let query: CategoryPagingQuery = .init(category: category, cursor: .init(lastId: lastId, limit: limit, order: order)) + return networkService.request( + BoardEndpoint.boardsByCategory(query), + responseType: SuccessResponse.self + ) + .map(\.data) + .eraseToAnyPublisher() } -} -// MARK: - Mock API -public final class MockCommunityAPIClient: CommunityAPIClientInterface { - - public init() {} - - public func fetchBoards() -> AnyPublisher<[BoardListResponse], NetworkError> { - let mockData = [ - BoardListResponse( - id: 1, - imageURL: "https://example.com/image1.jpg", - title: "첫 번째 게시글", - nickName: "햄버거러버", - content: nil, - createdAt: Date(), - likeCount: "15", - commnetCount: "3" - ), - BoardListResponse( - id: 2, - imageURL: "https://example.com/image2.jpg", - title: "맛있는 햄버거 추천", - nickName: "음식탐험가", - content: nil, - createdAt: Date(), - likeCount: "23", - commnetCount: "7" + public func fetchBoardDetail(boardId: Int) -> AnyPublisher { + return networkService.request( + BoardEndpoint.boardDetail(boardId: boardId), + responseType: SuccessResponse.self + ) + .map(\.data) + .eraseToAnyPublisher() + } + + public func createBoard( + title: String, + content: String, + category: String, + images: [UIImage] + ) -> AnyPublisher { + let dto = BoardRequestDTO( + title: title, + content: content, + category: category, + imageUrls: [] // multipart에서는 사용 안함 + ) + + let endpoint = BoardEndpoint.createBoard(dto) + + if images.isEmpty { + // 이미지 없으면 일반 JSON request + return networkService.request(endpoint, responseType: SuccessResponse.self) + .map(\.data) + .eraseToAnyPublisher() + } else { + // 이미지 있으면 multipart upload + return networkService.uploadMultipart( + endpoint, + images: images, + responseType: SuccessResponse.self ) - ] - - return Just(mockData) - .setFailureType(to: NetworkError.self) + .map(\.data) .eraseToAnyPublisher() + } } - public func fetchBoardsByCategory(_ category: Category) -> AnyPublisher<[BoardFeedResponse], NetworkError> { - let mockData = [ - BoardFeedResponse( - id: 1, - imageURL: "https://example.com/image1.jpg", - title: "첫 번째 게시글", - nickName: "햄버거러버", - content: "이것은 첫 번째 게시글의 상세 내용입니다.", - createdAt: Date(), - likeCount: "15", - commnetCount: "3" + public func updateBoard( + boardId: Int, + title: String, + content: String, + category: String, + images: [UIImage] + ) -> AnyPublisher { + let dto = BoardRequestDTO( + title: title, + content: content, + category: category, + imageUrls: [] // multipart에서는 사용 안함 + ) + + let endpoint = BoardEndpoint.updateBoard(boardId: boardId, body: dto) + + if images.isEmpty { + // 이미지 없으면 일반 JSON request + return networkService.request(endpoint, responseType: SuccessResponse.self) + .map(\.data) + .eraseToAnyPublisher() + } else { + // 이미지 있으면 multipart upload + return networkService.uploadMultipart( + endpoint, + images: images, + responseType: SuccessResponse.self ) - ] - - return Just(mockData) - .setFailureType(to: NetworkError.self) + .map(\.data) .eraseToAnyPublisher() + } + } + + public func deleteBoard(boardId: Int) -> AnyPublisher { + let endpoint = BoardEndpoint.deleteBoard(boardId: boardId) + + return networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + .map { _ in () } + .eraseToAnyPublisher() + } + + // MARK: - Comments + public func fetchComments(boardId: Int, lastId: Int?, limit: Int, order: String) -> AnyPublisher { + let query: CursorPagingQuery = .init(lastId: lastId, limit: limit, order: order) + return networkService.request( + BoardEndpoint.comments(boardId: boardId, query: query), + responseType: SuccessResponse.self + ) + .map(\.data) + .eraseToAnyPublisher() + } + + public func createComment(boardId: Int, content: String) -> AnyPublisher { + return networkService.request( + BoardEndpoint.createComment(boardId: boardId, content: content), + responseType: SuccessResponse.self + ) + .map(\.data) + .eraseToAnyPublisher() + } + + public func updateComment(boardId: Int, commentId: Int, content: String) -> AnyPublisher { + return networkService.request( + BoardEndpoint.updateComment(boardId: boardId, commentId: commentId, content: content), + responseType: SuccessResponse.self + ) + .map(\.data) + .eraseToAnyPublisher() + } + + public func deleteComment(boardId: Int, commentId: Int) -> AnyPublisher { + return networkService.request( + BoardEndpoint.deleteComment(boardId: boardId, commentId: commentId), + responseType: SuccessResponse.self + ) + .map { _ in () } + .eraseToAnyPublisher() + } + + // MARK: - Likes + public func fetchLikeInfo(boardId: Int) -> AnyPublisher { + return networkService.request( + BoardEndpoint.likeInfo(boardId: boardId), + responseType: SuccessResponse.self + ) + .map(\.data) + .eraseToAnyPublisher() + } + + public func toggleLike(boardId: Int) -> AnyPublisher { + return networkService.request( + BoardEndpoint.toggleLike(boardId: boardId), + responseType: SuccessResponse.self + ) + .map(\.data) + .eraseToAnyPublisher() + } + + // MARK: - Report + public func reportContent(request: ReportRequestDTO) -> AnyPublisher { + return networkService.request( + BoardEndpoint.report(request), + responseType: SuccessResponse.self + ) + .map { _ in () } + .eraseToAnyPublisher() } } diff --git a/Community/Sources/Data/DTO/BoardDetailResponseDTO.swift b/Community/Sources/Data/DTO/BoardDetailResponseDTO.swift new file mode 100644 index 0000000..e87fefc --- /dev/null +++ b/Community/Sources/Data/DTO/BoardDetailResponseDTO.swift @@ -0,0 +1,65 @@ +// +// BoardDetailResponseDTO.swift +// Community +// +// Created by 강동영 on 1/11/26. +// + +import Foundation +import CommunityDomain + +// MARK: - Board Response DTO +public struct BoardDetailResponseDTO: Decodable, Sendable { + public let id: Int + public let title: String + public let content: String + public let category: String + public let imageUrls: [String] + public let authorNickname: String + public let authorProfileImageUrl: String? + public let authorId: Int + public let createdAt: String + public let updatedAt: String + public let viewCount: Int + public let likeCount: Int + public let commentCount: Int + public let isLiked: Bool + + private enum CodingKeys: String, CodingKey { + case id, title, content, category + case imageUrls + case authorNickname + case authorProfileImageUrl + case authorId + case createdAt + case updatedAt + case viewCount + case likeCount + case commentCount + case isLiked + } +} + +// MARK: - DTO to Domain Mapper +extension BoardDetailResponseDTO { + func toDomain() -> Board { + let dateFormatter = DateFormatter.iso8601WithMicroseconds + + return Board( + id: id, + title: title, + content: content, + category: BoardCategory(rawValue: category) ?? .freeTalk, + imageUrls: imageUrls, + authorNickname: authorNickname, + authorProfileImageUrl: authorProfileImageUrl, + authorId: authorId, + createdAt: dateFormatter.date(from: createdAt) ?? Date(), + updatedAt: dateFormatter.date(from: updatedAt) ?? Date(), + viewCount: viewCount, + likeCount: likeCount, + commentCount: commentCount, + isLiked: isLiked + ) + } +} diff --git a/Community/Sources/Data/DTO/BoardRequest.swift b/Community/Sources/Data/DTO/BoardRequest.swift new file mode 100644 index 0000000..c0bfdf0 --- /dev/null +++ b/Community/Sources/Data/DTO/BoardRequest.swift @@ -0,0 +1,28 @@ +// +// BoardRequest.swift +// Community +// +// Created by 강동영 on 1/8/26. +// + +import Foundation + +// MARK: - Report Request DTO +public struct BoardRequestDTO: Encodable, Sendable { + public let title: String + public let content: String + public let category: String + public let imageUrls: [String] + + public init( + title: String, + content: String, + category: String, + imageUrls: [String] + ) { + self.title = title + self.content = content + self.category = category + self.imageUrls = imageUrls + } +} diff --git a/Community/Sources/Data/DTO/BoardResponse.swift b/Community/Sources/Data/DTO/BoardResponse.swift index b00b99e..a18c290 100644 --- a/Community/Sources/Data/DTO/BoardResponse.swift +++ b/Community/Sources/Data/DTO/BoardResponse.swift @@ -7,41 +7,80 @@ import Foundation import CommunityDomain +import Util -// MARK: - Unified Board Response Model -public struct BoardResponse: Decodable { +// MARK: - Board Response DTO +public struct BoardResponseDTO: Decodable, Sendable { public let id: Int - public let imageURL: String public let title: String - public let nickName: String - public let content: String? // 목록 조회시 nil, 카테고리에 따라 조회시 값 - public let createdAt: Date - public let likeCount: String - public let commnetCount: String + public let content: String + public let category: String + public let imageUrls: [String] + public let authorNickname: String? + public let authorId: Int? + public let createdAt: String + public let updatedAt: String + public let viewCount: Int + public let likeCount: Int + public let commentCount: Int + public let isLiked: Bool + + private enum CodingKeys: String, CodingKey { + case id, title, content, category + case imageUrls + case authorNickname + case authorId + case createdAt + case updatedAt + case viewCount + case likeCount + case commentCount + case isLiked + } } -extension BoardResponse { +// MARK: - Board List Data DTO (Pagination) +public struct BoardListDataDTO: Decodable, Sendable { + public let content: [BoardResponseDTO] + public let nextCursorId: Int? + public let nextPage: Bool + + private enum CodingKeys: String, CodingKey { + case content + case nextCursorId = "netCursorId" + case nextPage + } +} + +// MARK: - DTO to Domain Mapper +extension BoardResponseDTO { func toDomain() -> Board { + let dateFormatter = DateFormatter.iso8601WithMicroseconds + return Board( id: id, - imageURL: imageURL, title: title, - nickName: nickName, - content: content ?? "", - createdAt: createdAt.timeAgoDisplay(), + content: content, + category: BoardCategory(rawValue: category) ?? .freeTalk, + imageUrls: imageUrls, + authorNickname: authorNickname, + authorId: authorId, + createdAt: dateFormatter.date(from: createdAt) ?? Date(), + updatedAt: dateFormatter.date(from: updatedAt) ?? Date(), + viewCount: viewCount, likeCount: likeCount, - commnetCount: commnetCount + commentCount: commentCount, + isLiked: isLiked ) } } -public typealias BoardListResponse = BoardResponse -public typealias BoardFeedResponse = BoardResponse - -public enum Category: String { - case all = "ALL" - case freeTalk = "FREE_TALK" // 자유잡담 - case franchise = "FRANCHISE" - case handmade = "HANDMADE" - case recommendation = "RECOMMENDATION" +extension BoardListDataDTO { + func toDomain() -> BoardListData { + return BoardListData( + content: content.map { $0.toDomain() }, + nextCursorId: nextCursorId, + hasNextPage: nextPage + ) + } } diff --git a/Community/Sources/Data/DTO/CommentResponse.swift b/Community/Sources/Data/DTO/CommentResponse.swift new file mode 100644 index 0000000..73f2421 --- /dev/null +++ b/Community/Sources/Data/DTO/CommentResponse.swift @@ -0,0 +1,71 @@ +// +// CommentResponse.swift +// Hambug +// +// Created by 강동영 on 01/07/26. +// + +import Foundation +import CommunityDomain +import Util + +// MARK: - Comment Response DTO +public struct CommentResponseDTO: Decodable, Sendable { + public let id: Int + public let content: String + public let authorId: Int + public let authorNickname: String + public let authorProfileImageUrl: String? + public let createdAt: String + public let updatedAt: String + + private enum CodingKeys: String, CodingKey { + case id + case content + case authorId + case authorNickname + case authorProfileImageUrl + case createdAt + case updatedAt + } +} + +// MARK: - Comment List Data DTO (Pagination) +public struct CommentListDataDTO: Decodable, Sendable { + public let content: [CommentResponseDTO] + public let nextCursorId: Int? + public let nextPage: Bool + + private enum CodingKeys: String, CodingKey { + case content + case nextCursorId = "netCursorId" + case nextPage + } +} + +// MARK: - DTO to Domain Mapper +extension CommentResponseDTO { + func toDomain() -> Comment { + let dateFormatter = DateFormatter.iso8601WithMicroseconds + + return Comment( + id: id, + content: content, + authorId: authorId, + authorNickname: authorNickname, + authorProfileImageUrl: authorProfileImageUrl, + createdAt: dateFormatter.date(from: createdAt) ?? Date(), + updatedAt: dateFormatter.date(from: updatedAt) ?? Date() + ) + } +} + +extension CommentListDataDTO { + func toDomain() -> CommentListData { + return CommentListData( + content: content.map { $0.toDomain() }, + nextCursorId: nextCursorId, + hasNextPage: nextPage + ) + } +} diff --git a/Community/Sources/Data/DTO/LikeResponse.swift b/Community/Sources/Data/DTO/LikeResponse.swift new file mode 100644 index 0000000..a6bcc59 --- /dev/null +++ b/Community/Sources/Data/DTO/LikeResponse.swift @@ -0,0 +1,33 @@ +// +// LikeResponse.swift +// Hambug +// +// Created by 강동영 on 01/07/26. +// + +import Foundation +import CommunityDomain + +// MARK: - Like Response DTO +public struct LikeResponseDTO: Decodable, Sendable { + public let boardId: Int + public let likeCount: Int + public let liked: Bool + + private enum CodingKeys: String, CodingKey { + case boardId + case likeCount + case liked + } +} + +// MARK: - DTO to Domain Mapper +extension LikeResponseDTO { + func toDomain() -> LikeInfo { + return LikeInfo( + boardId: boardId, + likeCount: likeCount, + isLiked: liked + ) + } +} diff --git a/Community/Sources/Data/DTO/Querys.swift b/Community/Sources/Data/DTO/Querys.swift new file mode 100644 index 0000000..5863f29 --- /dev/null +++ b/Community/Sources/Data/DTO/Querys.swift @@ -0,0 +1,34 @@ +// +// Querys.swift +// Community +// +// Created by 강동영 on 1/9/26. +// + +import Foundation + + +/// 재사용 되는 페이지네이션 쿼리 +public struct CursorPagingQuery: Encodable, Sendable { + let lastId: Int? + let limit: Int + let order: String +} + +/// CategoryPagingQuery +public struct CategoryPagingQuery: Encodable, Sendable { + let category: String + let cursor: CursorPagingQuery + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(category, forKey: .category) + try container.encodeIfPresent(cursor.lastId, forKey: .lastId) + try container.encode(cursor.limit, forKey: .limit) + try container.encode(cursor.order, forKey: .order) + } + + enum CodingKeys: String, CodingKey { + case category, lastId, limit, order + } +} diff --git a/Community/Sources/Data/DTO/ReportRequest.swift b/Community/Sources/Data/DTO/ReportRequest.swift new file mode 100644 index 0000000..7df764e --- /dev/null +++ b/Community/Sources/Data/DTO/ReportRequest.swift @@ -0,0 +1,37 @@ +// +// ReportRequest.swift +// Hambug +// +// Created by 강동영 on 01/07/26. +// + +import Foundation +import CommunityDomain + +// MARK: - Report Request DTO +public struct ReportRequestDTO: Encodable, Sendable { + public let targetId: Int + public let targetType: String // BOARD, COMMENT + public let reason: String + + public init(targetId: Int, targetType: String, reason: String) { + self.targetId = targetId + self.targetType = targetType + self.reason = reason + } + + private enum CodingKeys: String, CodingKey { + case targetId + case targetType + case reason + } +} + +// MARK: - Domain to DTO Mapper +extension ReportRequestDTO { + public init(from domain: CommunityDomain.ReportRequest) { + self.targetId = domain.targetId + self.targetType = domain.targetType.rawValue + self.reason = domain.reason + } +} diff --git a/Community/Sources/Data/Repositories/CommunityRepositoryImpl.swift b/Community/Sources/Data/Repositories/CommunityRepositoryImpl.swift index 749e520..f6be76a 100644 --- a/Community/Sources/Data/Repositories/CommunityRepositoryImpl.swift +++ b/Community/Sources/Data/Repositories/CommunityRepositoryImpl.swift @@ -9,9 +9,11 @@ import Foundation import Combine import CommunityDomain import NetworkInterface +import Util +import UIKit // MARK: - Community Repository Implementation -public final class CommunityRepositoryImpl: CommunityRepositoryInterface { +public final class CommunityRepositoryImpl: CommunityRepository { private let apiClient: CommunityAPIClientInterface @@ -19,19 +21,131 @@ public final class CommunityRepositoryImpl: CommunityRepositoryInterface { self.apiClient = apiClient } - public func fetchBoards() -> AnyPublisher<[Board], NetworkError> { - return apiClient.fetchBoards() - .map { boardResponses in - return boardResponses.map { $0.toDomain() } - } - .eraseToAnyPublisher() + public func fetchBoards(lastId: Int?, limit: Int, order: CommunityDomain.SortOrder) async throws -> BoardListData { + return try await apiClient + .fetchBoards(lastId: lastId, limit: limit, order: order.rawValue) + .map { $0.toDomain() } + .mapError { $0 as Error } + .async() + } + + public func fetchBoardsByCategory(_ category: BoardCategory, lastId: Int?, limit: Int, order: CommunityDomain.SortOrder) async throws -> BoardListData { + return try await apiClient + .fetchBoardsByCategory(category: category.rawValue, lastId: lastId, limit: limit, order: order.rawValue) + .map { $0.toDomain() } + .mapError { $0 as Error } + .async() + } + + // MARK: - Board CRUD + public func createBoard( + title: String, + content: String, + category: BoardCategory, + images: [UIImage] + ) async throws -> Board { + return try await apiClient + .createBoard( + title: title, + content: content, + category: category.rawValue, + images: images + ) + .map { $0.toDomain() } + .mapError { $0 as Error } + .async() + } + + public func fetchBoardDetail(boardId: Int) async throws -> Board { + return try await apiClient + .fetchBoardDetail(boardId: boardId) + .map { $0.toDomain() } + .mapError { $0 as Error } + .async() } - public func fetchBoardsByCategory(_ category: Category) -> AnyPublisher<[Board], NetworkError> { - return apiClient.fetchBoardsByCategory(category) - .map { boardResponses in - return boardResponses.map { $0.toDomain() } - } - .eraseToAnyPublisher() + public func updateBoard( + boardId: Int, + title: String, + content: String, + category: CommunityDomain.BoardCategory, + images: [UIImage] + ) async throws -> CommunityDomain.Board { + return try await apiClient + .updateBoard( + boardId: boardId, + title: title, + content: content, + category: category.rawValue, + images: images + ) + .map { $0.toDomain() } + .mapError { $0 as Error } + .async() + } + + public func deleteBoard(boardId: Int) async throws { + return try await apiClient + .deleteBoard(boardId: boardId) + .mapError { $0 as Error } + .async() + } + + // MARK: - Comments + public func fetchComments(boardId: Int, lastId: Int?, limit: Int, order: CommunityDomain.SortOrder) async throws -> CommentListData { + return try await apiClient + .fetchComments(boardId: boardId, lastId: lastId, limit: limit, order: order.rawValue) + .map { $0.toDomain() } + .mapError { $0 as Error } + .async() + } + + public func createComment(boardId: Int, content: String) async throws -> Comment { + return try await apiClient + .createComment(boardId: boardId, content: content) + .map { $0.toDomain() } + .mapError { $0 as Error } + .async() + } + + public func updateComment(boardId: Int, commentId: Int, content: String) async throws -> Comment { + return try await apiClient + .updateComment(boardId: boardId, commentId: commentId, content: content) + .map { $0.toDomain() } + .mapError { $0 as Error } + .async() + } + + public func deleteComment(boardId: Int, commentId: Int) async throws { + return try await apiClient + .deleteComment(boardId: boardId, commentId: commentId) + .mapError { $0 as Error } + .async() + } + + // MARK: - Likes + public func fetchLikeInfo(boardId: Int) async throws -> LikeInfo { + return try await apiClient + .fetchLikeInfo(boardId: boardId) + .map { $0.toDomain() } + .mapError { $0 as Error } + .async() + } + + public func toggleLike(boardId: Int) async throws -> LikeInfo { + return try await apiClient + .toggleLike(boardId: boardId) + .map { $0.toDomain() } + .mapError { $0 as Error } + .async() + } + + // MARK: - Report + public func reportContent(request: CommunityDomain.ReportRequest) async throws { + let dto = ReportRequestDTO(from: request) + return try await apiClient + .reportContent(request: dto) + .mapError { $0 as Error } + .async() } } diff --git a/Community/Sources/Domain/Entities/Board.swift b/Community/Sources/Domain/Entities/Board.swift index b19a44e..e53f3bb 100644 --- a/Community/Sources/Domain/Entities/Board.swift +++ b/Community/Sources/Domain/Entities/Board.swift @@ -7,61 +7,82 @@ import Foundation +// MARK: - Board Category +public enum BoardCategory: String, Codable, Sendable, CaseIterable { + case freeTalk = "FREE_TALK" + case review = "REVIEW" + case recommendation = "RECOMMENDATION" + + public var displayName: String { + switch self { + case .freeTalk: + return "자유잡담" + case .review: + return "햄버거리뷰" + case .recommendation: + return "맛집추천" + } + } +} + +// MARK: - Sort Order +public enum SortOrder: String, Sendable { + case asc = "ASC" + case desc = "DESC" +} + // MARK: - Board Entity -public struct Board: Identifiable, Equatable { +public struct Board: Identifiable, Equatable, Sendable { public let id: Int - public let imageURL: String public let title: String - public let nickName: String public let content: String + public let category: BoardCategory + public let imageUrls: [String] + public let authorNickname: String? + public let authorProfileImageUrl: String? + public let authorId: Int? public let createdAt: String - public let likeCount: String - public let commnetCount: String + public let updatedAt: String + public let viewCount: Int + public let likeCount: Int + public var commentCount: Int + public let isLiked: Bool - public init(id: Int, imageURL: String, title: String, nickName: String, content: String, createdAt: String, likeCount: String, commnetCount: String) { + public init( + id: Int, + title: String, + content: String, + category: BoardCategory, + imageUrls: [String], + authorNickname: String?, + authorProfileImageUrl: String? = nil, + authorId: Int?, + createdAt: Date, + updatedAt: Date, + viewCount: Int, + likeCount: Int, + commentCount: Int, + isLiked: Bool + ) { self.id = id - self.imageURL = imageURL self.title = title - self.nickName = nickName self.content = content - self.createdAt = createdAt + self.category = category + self.imageUrls = imageUrls + self.authorNickname = authorNickname + self.authorProfileImageUrl = authorProfileImageUrl + self.authorId = authorId + self.createdAt = Self.timeAgoDisplay(createdAt) + self.updatedAt = Self.timeAgoDisplay(updatedAt) + self.viewCount = viewCount self.likeCount = likeCount - self.commnetCount = commnetCount - } - - // Preview용 샘플 데이터 - public static var sampleData: [Board] { - return [Board( - id: 1, - imageURL: "", - title: "다들 햄최몇인가요12345678910121231314?", - nickName: "닉네임1", - content: "요즘 햄버거 맛집이 어디인지 궁금해요!", - createdAt: "1분 전", - likeCount: "11", - commnetCount: "10" - )] + _sampleData - } - - private static let _sampleData: [Board] = (2...18).map { - Board( - id: $0, - imageURL: "", - title: "다들 햄최몇인가요?", - nickName: "닉네임\($0)", - content: "간단한 내용입니다.", - createdAt: "2분 전", - likeCount: "\($0)\($0+1)", - commnetCount: "\($0+1)\($0)" - ) + self.commentCount = commentCount + self.isLiked = isLiked } -} -// MARK: - Date Extension for Time Ago Display -public extension Date { - func timeAgoDisplay() -> String { + private static func timeAgoDisplay(_ date: Date) -> String { let now = Date() - let timeInterval = now.timeIntervalSince(self) + let timeInterval = now.timeIntervalSince(date) if timeInterval < 60 { return "방금 전" @@ -77,7 +98,63 @@ public extension Date { } else { let formatter = DateFormatter() formatter.dateFormat = "MM.dd" - return formatter.string(from: self) + return formatter.string(from: date) } } } + +extension Board { + // Preview용 샘플 데이터 + public static var sampleData: [Board] { + let now = Date() + return [Board( + id: 1, + title: "다들 햄최몇인가요12345678910121231314?", + content: "요즘 햄버거 맛집이 어디인지 궁금해요!", + category: .freeTalk, + imageUrls: [], + authorNickname: "닉네임1", + authorProfileImageUrl: nil, + authorId: 1, + createdAt: now.addingTimeInterval(-60), + updatedAt: now.addingTimeInterval(-60), + viewCount: 100, + likeCount: 11, + commentCount: 10, + isLiked: false + )] + _sampleData + } + + private static let _sampleData: [Board] = (2...18).map { + let now = Date() + return Board( + id: $0, + title: "다들 햄최몇인가요?", + content: "간단한 내용입니다.", + category: .freeTalk, + imageUrls: [], + authorNickname: "닉네임\($0)", + authorProfileImageUrl: nil, + authorId: $0, + createdAt: now.addingTimeInterval(-120), + updatedAt: now.addingTimeInterval(-120), + viewCount: $0 * 10, + likeCount: $0, + commentCount: $0 + 1, + isLiked: false + ) + } +} + +// MARK: - Board List Data (Pagination) +public struct BoardListData: Sendable { + public let content: [Board] + public let nextCursorId: Int? + public let hasNextPage: Bool + + public init(content: [Board], nextCursorId: Int?, hasNextPage: Bool) { + self.content = content + self.nextCursorId = nextCursorId + self.hasNextPage = hasNextPage + } +} diff --git a/Community/Sources/Domain/Entities/Comment.swift b/Community/Sources/Domain/Entities/Comment.swift new file mode 100644 index 0000000..cba7d84 --- /dev/null +++ b/Community/Sources/Domain/Entities/Comment.swift @@ -0,0 +1,50 @@ +// +// Comment.swift +// Hambug +// +// Created by 강동영 on 01/07/26. +// + +import Foundation + +// MARK: - Comment Entity +public struct Comment: Identifiable, Equatable, Sendable { + public let id: Int + public let content: String + public let authorId: Int + public let authorNickname: String + public let authorProfileImageUrl: String? + public let createdAt: Date + public let updatedAt: Date + + public init( + id: Int, + content: String, + authorId: Int, + authorNickname: String, + authorProfileImageUrl: String?, + createdAt: Date, + updatedAt: Date + ) { + self.id = id + self.content = content + self.authorId = authorId + self.authorNickname = authorNickname + self.authorProfileImageUrl = authorProfileImageUrl + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +// MARK: - Comment List Data (Pagination) +public struct CommentListData: Sendable { + public let content: [Comment] + public let nextCursorId: Int? + public let hasNextPage: Bool + + public init(content: [Comment], nextCursorId: Int?, hasNextPage: Bool) { + self.content = content + self.nextCursorId = nextCursorId + self.hasNextPage = hasNextPage + } +} diff --git a/Community/Sources/Domain/Entities/Like.swift b/Community/Sources/Domain/Entities/Like.swift new file mode 100644 index 0000000..35cf9e9 --- /dev/null +++ b/Community/Sources/Domain/Entities/Like.swift @@ -0,0 +1,21 @@ +// +// Like.swift +// Hambug +// +// Created by 강동영 on 01/07/26. +// + +import Foundation + +// MARK: - Like Info +public struct LikeInfo: Equatable, Sendable { + public let boardId: Int + public let likeCount: Int + public let isLiked: Bool + + public init(boardId: Int, likeCount: Int, isLiked: Bool) { + self.boardId = boardId + self.likeCount = likeCount + self.isLiked = isLiked + } +} diff --git a/Community/Sources/Domain/Entities/Report.swift b/Community/Sources/Domain/Entities/Report.swift new file mode 100644 index 0000000..46feaa3 --- /dev/null +++ b/Community/Sources/Domain/Entities/Report.swift @@ -0,0 +1,27 @@ +// +// Report.swift +// Hambug +// +// Created by 강동영 on 01/07/26. +// + +import Foundation + +// MARK: - Report Target Type +public enum ReportTargetType: String, Codable, Sendable { + case board = "BOARD" + case comment = "COMMENT" +} + +// MARK: - Report Request +public struct ReportRequest: Sendable { + public let targetId: Int + public let targetType: ReportTargetType + public let reason: String + + public init(targetId: Int, targetType: ReportTargetType, reason: String) { + self.targetId = targetId + self.targetType = targetType + self.reason = reason + } +} diff --git a/Community/Sources/Domain/Entities/SelectedImage.swift b/Community/Sources/Domain/Entities/SelectedImage.swift new file mode 100644 index 0000000..ff12c98 --- /dev/null +++ b/Community/Sources/Domain/Entities/SelectedImage.swift @@ -0,0 +1,47 @@ +// +// SelectedImage.swift +// Community +// +// Created by 강동영 on 1/8/26. +// + +import Foundation +import UIKit + +/// 사용자가 선택한 이미지를 나타내는 엔티티 +public struct SelectedImage: Identifiable, Sendable { + + /// 고유 ID + public let id: UUID + + /// 이미지 객체 (Sendable을 위해 @unchecked 사용) + public let image: UIImage + + /// 원본 파일 크기 (bytes) + public let originalSize: Int + + /// 파일 이름 + public let fileName: String + + /// 초기화 + /// - Parameters: + /// - id: 고유 ID (기본값: UUID()) + /// - image: UIImage 객체 + /// - originalSize: 원본 크기 (bytes) + /// - fileName: 파일 이름 + public init( + id: UUID = UUID(), + image: UIImage, + originalSize: Int, + fileName: String + ) { + self.id = id + self.image = image + self.originalSize = originalSize + self.fileName = fileName + } +} + +// MARK: - Sendable Conformance +// UIImage는 기본적으로 Sendable이 아니지만, 이미지 처리 시 불변으로 사용하므로 안전 +extension UIImage: @unchecked Sendable {} diff --git a/Community/Sources/Domain/Repositories/CommunityRepository.swift b/Community/Sources/Domain/Repositories/CommunityRepository.swift new file mode 100644 index 0000000..da53386 --- /dev/null +++ b/Community/Sources/Domain/Repositories/CommunityRepository.swift @@ -0,0 +1,51 @@ +// +// CommunityRepositoryInterface.swift +// Hambug +// +// Created by 강동영 on 10/17/25. +// + +import Foundation +import NetworkInterface +import UIKit + +// MARK: - Community Repository Interface +public protocol CommunityRepository: Sendable { + // 게시글 조회 + func fetchBoards(lastId: Int?, limit: Int, order: SortOrder) async throws -> BoardListData + func fetchBoardsByCategory(_ category: BoardCategory, lastId: Int?, limit: Int, order: SortOrder) async throws -> BoardListData + func fetchBoardDetail(boardId: Int) async throws -> Board + + // 게시글 생성 + func createBoard( + title: String, + content: String, + category: BoardCategory, + images: [UIImage] + ) async throws -> Board + + // 게시글 수정 + func updateBoard( + boardId: Int, + title: String, + content: String, + category: BoardCategory, + images: [UIImage] + ) async throws -> Board + + // 게시글 삭제 + func deleteBoard(boardId: Int) async throws + + // 댓글 + func fetchComments(boardId: Int, lastId: Int?, limit: Int, order: SortOrder) async throws -> CommentListData + func createComment(boardId: Int, content: String) async throws -> Comment + func updateComment(boardId: Int, commentId: Int, content: String) async throws -> Comment + func deleteComment(boardId: Int, commentId: Int) async throws + + // 좋아요 + func fetchLikeInfo(boardId: Int) async throws -> LikeInfo + func toggleLike(boardId: Int) async throws -> LikeInfo + + // 신고 + func reportContent(request: ReportRequest) async throws +} diff --git a/Community/Sources/Domain/Repositories/CommunityRepositoryInterface.swift b/Community/Sources/Domain/Repositories/CommunityRepositoryInterface.swift deleted file mode 100644 index d08cfaf..0000000 --- a/Community/Sources/Domain/Repositories/CommunityRepositoryInterface.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// CommunityRepositoryInterface.swift -// Hambug -// -// Created by 강동영 on 10/17/25. -// - -import Foundation -import Combine -import NetworkInterface - -// MARK: - Community Repository Interface -public protocol CommunityRepositoryInterface { - func fetchBoards() -> AnyPublisher<[Board], NetworkError> -} diff --git a/Community/Sources/Domain/UseCases/CommentUseCase.swift b/Community/Sources/Domain/UseCases/CommentUseCase.swift new file mode 100644 index 0000000..2ab86e2 --- /dev/null +++ b/Community/Sources/Domain/UseCases/CommentUseCase.swift @@ -0,0 +1,42 @@ +// +// CommentUseCase.swift +// Hambug +// +// Created by Claude on 01/10/26. +// + +import Foundation + +// MARK: - Comment UseCase Protocol +public protocol CommentUseCase: Sendable { + func getComments(boardId: Int, lastId: Int?, limit: Int, order: SortOrder) async throws -> CommentListData + func createComment(boardId: Int, content: String) async throws -> Comment + func updateComment(boardId: Int, commentId: Int, content: String) async throws -> Comment + func deleteComment(boardId: Int, commentId: Int) async throws +} + +// MARK: - Comment UseCase Implementation +public final class CommentUseCaseImpl: CommentUseCase { + + private let repository: CommunityRepository + + public init(repository: CommunityRepository) { + self.repository = repository + } + + public func getComments(boardId: Int, lastId: Int?, limit: Int, order: SortOrder) async throws -> CommentListData { + return try await repository.fetchComments(boardId: boardId, lastId: lastId, limit: limit, order: order) + } + + public func createComment(boardId: Int, content: String) async throws -> Comment { + return try await repository.createComment(boardId: boardId, content: content) + } + + public func updateComment(boardId: Int, commentId: Int, content: String) async throws -> Comment { + return try await repository.updateComment(boardId: boardId, commentId: commentId, content: content) + } + + public func deleteComment(boardId: Int, commentId: Int) async throws { + try await repository.deleteComment(boardId: boardId, commentId: commentId) + } +} diff --git a/Community/Sources/Domain/UseCases/CreateBoardUseCaseImpl.swift b/Community/Sources/Domain/UseCases/CreateBoardUseCaseImpl.swift new file mode 100644 index 0000000..ccbcfe2 --- /dev/null +++ b/Community/Sources/Domain/UseCases/CreateBoardUseCaseImpl.swift @@ -0,0 +1,43 @@ +// +// CreateBoardUseCase.swift +// Community +// +// Created by 강동영 on 1/8/26. +// + +import Foundation +import UIKit + +// MARK: - Create Board UseCase Interface +public protocol CreateBoardUseCase: Sendable { + func execute( + title: String, + content: String, + category: BoardCategory, + images: [UIImage] + ) async throws -> Board +} + +// MARK: - Create Board UseCase Implementation +public final class CreateBoardUseCaseImpl: CreateBoardUseCase { + + private let repository: CommunityRepository + + public init(repository: CommunityRepository) { + self.repository = repository + } + + public func execute( + title: String, + content: String, + category: BoardCategory, + images: [UIImage] + ) async throws -> Board { + return try await repository.createBoard( + title: title, + content: content, + category: category, + images: images + ) + } +} diff --git a/Community/Sources/Domain/UseCases/GetBoardDetailUseCase.swift b/Community/Sources/Domain/UseCases/GetBoardDetailUseCase.swift new file mode 100644 index 0000000..26bfc8e --- /dev/null +++ b/Community/Sources/Domain/UseCases/GetBoardDetailUseCase.swift @@ -0,0 +1,56 @@ +// +// GetBoardDetailUseCase.swift +// Hambug +// +// Created by 강동영 on 01/07/26. +// + +import Foundation +import UIKit + +// MARK: - Get Board Detail UseCase Interface +public protocol BoardDetailUseCase: Sendable { + func getBoard(boardId: Int) async throws -> Board + + func updateBoard( + boardId: Int, + title: String, + content: String, + category: BoardCategory, + images: [UIImage] + ) async throws -> Board + + func deleteBoard(boardId: Int) async throws +} +public final class BoardDetailUseCaseImpl: BoardDetailUseCase { + + private let repository: CommunityRepository + + public init(repository: CommunityRepository) { + self.repository = repository + } + + public func getBoard(boardId: Int) async throws -> Board { + return try await repository.fetchBoardDetail(boardId: boardId) + } + + public func updateBoard( + boardId: Int, + title: String, + content: String, + category: BoardCategory, + images: [UIImage] + ) async throws -> Board { + return try await repository.updateBoard( + boardId: boardId, + title: title, + content: content, + category: category, + images: images + ) + } + + public func deleteBoard(boardId: Int) async throws { + return try await repository.deleteBoard(boardId: boardId) + } +} diff --git a/Community/Sources/Domain/UseCases/GetBoardsByCategoryUseCaseImpl.swift b/Community/Sources/Domain/UseCases/GetBoardsByCategoryUseCaseImpl.swift new file mode 100644 index 0000000..6417bd5 --- /dev/null +++ b/Community/Sources/Domain/UseCases/GetBoardsByCategoryUseCaseImpl.swift @@ -0,0 +1,27 @@ +// +// GetBoardsByCategoryUseCase.swift +// Hambug +// +// Created by 강동영 on 01/07/26. +// + +import Foundation + +// MARK: - Get Boards By Category UseCase Interface +public protocol GetBoardsByCategoryUseCase: Sendable { + func execute(category: BoardCategory, lastId: Int?, limit: Int, order: SortOrder) async throws -> BoardListData +} + +// MARK: - Get Boards By Category UseCase Implementation +public final class GetBoardsByCategoryUseCaseImpl: GetBoardsByCategoryUseCase { + + private let repository: CommunityRepository + + public init(repository: CommunityRepository) { + self.repository = repository + } + + public func execute(category: BoardCategory, lastId: Int?, limit: Int, order: SortOrder) async throws -> BoardListData { + return try await repository.fetchBoardsByCategory(category, lastId: lastId, limit: limit, order: order) + } +} diff --git a/Community/Sources/Domain/UseCases/GetBoardsUseCase.swift b/Community/Sources/Domain/UseCases/GetBoardsUseCase.swift deleted file mode 100644 index 58f4f49..0000000 --- a/Community/Sources/Domain/UseCases/GetBoardsUseCase.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// GetBoardsUseCase.swift -// Hambug -// -// Created by 강동영 on 10/17/25. -// - -import Foundation -import Combine -import NetworkInterface - -// MARK: - Get Boards UseCase Interface -public protocol GetBoardsUseCaseInterface { - func execute() -> AnyPublisher<[Board], NetworkError> -} - -// MARK: - Get Boards UseCase Implementation -public final class GetBoardsUseCase: GetBoardsUseCaseInterface { - - private let repository: CommunityRepositoryInterface - - public init(repository: CommunityRepositoryInterface) { - self.repository = repository - } - - public func execute() -> AnyPublisher<[Board], NetworkError> { - return repository.fetchBoards() - .map { boards in - // 비즈니스 로직 적용 (예: 정렬, 필터링 등) - return boards.sorted { $0.createdAt > $1.createdAt } - } - .eraseToAnyPublisher() - } -} diff --git a/Community/Sources/Domain/UseCases/GetBoardsUseCaseImpl.swift b/Community/Sources/Domain/UseCases/GetBoardsUseCaseImpl.swift new file mode 100644 index 0000000..28fbf05 --- /dev/null +++ b/Community/Sources/Domain/UseCases/GetBoardsUseCaseImpl.swift @@ -0,0 +1,27 @@ +// +// GetBoardsUseCase.swift +// Hambug +// +// Created by 강동영 on 10/17/25. +// + +import Foundation + +// MARK: - Get Boards UseCase Interface +public protocol GetBoardsUseCase: Sendable { + func execute(lastId: Int?, limit: Int, order: SortOrder) async throws -> BoardListData +} + +// MARK: - Get Boards UseCase Implementation +public final class GetBoardsUseCaseImpl: GetBoardsUseCase { + + private let repository: CommunityRepository + + public init(repository: CommunityRepository) { + self.repository = repository + } + + public func execute(lastId: Int?, limit: Int, order: SortOrder) async throws -> BoardListData { + return try await repository.fetchBoards(lastId: lastId, limit: limit, order: order) + } +} diff --git a/Community/Sources/Domain/UseCases/LikeUseCase.swift b/Community/Sources/Domain/UseCases/LikeUseCase.swift new file mode 100644 index 0000000..4140d7c --- /dev/null +++ b/Community/Sources/Domain/UseCases/LikeUseCase.swift @@ -0,0 +1,32 @@ +// +// LikeUseCase.swift +// Hambug +// +// Created by Claude on 01/10/26. +// + +import Foundation + +// MARK: - Like UseCase Protocol +public protocol LikeUseCase: Sendable { + func getLikeInfo(boardId: Int) async throws -> LikeInfo + func toggleLike(boardId: Int) async throws -> LikeInfo +} + +// MARK: - Like UseCase Implementation +public final class LikeUseCaseImpl: LikeUseCase { + + private let repository: CommunityRepository + + public init(repository: CommunityRepository) { + self.repository = repository + } + + public func getLikeInfo(boardId: Int) async throws -> LikeInfo { + return try await repository.fetchLikeInfo(boardId: boardId) + } + + public func toggleLike(boardId: Int) async throws -> LikeInfo { + return try await repository.toggleLike(boardId: boardId) + } +} diff --git a/Community/Sources/Domain/UseCases/ReportContentUseCaseImpl.swift b/Community/Sources/Domain/UseCases/ReportContentUseCaseImpl.swift new file mode 100644 index 0000000..a37d6c9 --- /dev/null +++ b/Community/Sources/Domain/UseCases/ReportContentUseCaseImpl.swift @@ -0,0 +1,27 @@ +// +// ReportContentUseCase.swift +// Hambug +// +// Created by 강동영 on 01/07/26. +// + +import Foundation + +// MARK: - Report Content UseCase Interface +public protocol ReportContentUseCase: Sendable { + func execute(request: ReportRequest) async throws +} + +// MARK: - Report Content UseCase Implementation +public final class ReportContentUseCaseImpl: ReportContentUseCase { + + private let repository: CommunityRepository + + public init(repository: CommunityRepository) { + self.repository = repository + } + + public func execute(request: ReportRequest) async throws { + try await repository.reportContent(request: request) + } +} diff --git a/Community/Sources/Domain/UseCases/UpdateBoardUseCaseImpl.swift b/Community/Sources/Domain/UseCases/UpdateBoardUseCaseImpl.swift new file mode 100644 index 0000000..42c4fea --- /dev/null +++ b/Community/Sources/Domain/UseCases/UpdateBoardUseCaseImpl.swift @@ -0,0 +1,46 @@ +// +// UpdateBoardUseCaseImpl.swift +// Community +// +// Created by 강동영 on 1/11/26. +// + +import Foundation +import UIKit + +// MARK: - Create Board UseCase Interface +public protocol UpdateBoardUseCase: Sendable { + func execute( + boardId: Int, + title: String, + content: String, + category: BoardCategory, + images: [UIImage] + ) async throws -> Board +} + +// MARK: - Create Board UseCase Implementation +public final class UpdateBoardUseCaseImpl: UpdateBoardUseCase { + + private let repository: CommunityRepository + + public init(repository: CommunityRepository) { + self.repository = repository + } + + public func execute( + boardId: Int, + title: String, + content: String, + category: BoardCategory, + images: [UIImage] + ) async throws -> Board { + return try await repository.updateBoard( + boardId: boardId, + title: title, + content: content, + category: category, + images: images + ) + } +} diff --git a/Community/Sources/Presentation/Views/CommunityView.swift b/Community/Sources/Presentation/Community/CommunityView.swift similarity index 61% rename from Community/Sources/Presentation/Views/CommunityView.swift rename to Community/Sources/Presentation/Community/CommunityView.swift index a3a2854..fcddd8e 100644 --- a/Community/Sources/Presentation/Views/CommunityView.swift +++ b/Community/Sources/Presentation/Community/CommunityView.swift @@ -8,23 +8,45 @@ import SwiftUI import DesignSystem import CommunityDomain +import SharedUI -public struct CommunityView: View { - @StateObject private var viewModel: CommunityViewModel +public protocol CommunityWriteFactory { + func makeWriteViewModel() -> CommunityWriteViewModelProtocol +} - public init(viewModel: CommunityViewModel) { - self._viewModel = StateObject(wrappedValue: viewModel) +public protocol CommunityDetailFactory { + func makeDetailViewModel() -> CommunityDetailViewModel +} + +public struct CommunityView: View { + @State private var viewModel: CommunityViewModel + private let writeFactory: CommunityWriteFactory + private let detailFactory: CommunityDetailFactory + private let updateFactory: UpdateBoardFactory + private let reportFactory: ReportBoardFactory + + public init( + viewModel: CommunityViewModel, + writeFactory: CommunityWriteFactory, + detailFactory: CommunityDetailFactory, + updateFactory: UpdateBoardFactory, + reportFactory: ReportBoardFactory + ) { + self._viewModel = State(initialValue: viewModel) + self.writeFactory = writeFactory + self.detailFactory = detailFactory + self.updateFactory = updateFactory + self.reportFactory = reportFactory } public var body: some View { - NavigationView { ZStack { VStack(spacing: 0) { Color.primaryHambugRed .frame(height: UIScreen.main.bounds.height * 0.25) Color.bgG75 } - .ignoresSafeArea(.all, edges: .top) + .ignoresSafeArea(.container, edges: .vertical) VStack(spacing: 0) { // 헤더 @@ -50,9 +72,21 @@ public struct CommunityView: View { // 컨텐츠 영역 ZStack { if viewModel.isListView { - CommunityListView(boards: viewModel.filteredBoards) + CommunityListView( + boards: viewModel.filteredBoards, + detailFactory: detailFactory, + updateFactory: updateFactory, + reportFactory: reportFactory, + viewModel: viewModel + ) } else { - CommunityFeedView(boards: viewModel.filteredBoards) + CommunityFeedView( + boards: viewModel.filteredBoards, + detailFactory: detailFactory, + updateFactory: updateFactory, + reportFactory: reportFactory, + viewModel: viewModel + ) } } } @@ -64,7 +98,11 @@ public struct CommunityView: View { Spacer() HStack { Spacer() - NavigationLink(destination: CommunityWriteView()) { + NavigationLink( + destination: CommunityWriteView( + viewModel: writeFactory.makeWriteViewModel() + ) + ) { Color.bgPencil .frame(width: 45, height: 45) .clipShape(Circle()) @@ -80,11 +118,12 @@ public struct CommunityView: View { } } } + .safeAreaPadding(.bottom, 100) .refreshable { viewModel.refreshBoards() } .navigationBarHidden(true) - } + .tabBarHidden(false) } } @@ -153,45 +192,123 @@ fileprivate struct CommunityFilterChip: View { // MARK: - List View struct CommunityListView: View { let boards: [Board] + let detailFactory: CommunityDetailFactory + let updateFactory: UpdateBoardFactory + let reportFactory: ReportBoardFactory + @State private var viewModel: CommunityViewModel + + init( + boards: [Board], + detailFactory: CommunityDetailFactory, + updateFactory: UpdateBoardFactory, + reportFactory: ReportBoardFactory, + viewModel: CommunityViewModel + ) { + self.boards = boards + self.detailFactory = detailFactory + self.updateFactory = updateFactory + self.reportFactory = reportFactory + self._viewModel = State(initialValue: viewModel) + } + var body: some View { ScrollView { LazyVStack(spacing: 0) { - ForEach(boards) { board in - NavigationLink(destination: CommunityDetailView()) { + ForEach(Array(boards.enumerated()), id: \.element.id) { index, board in + NavigationLink( + destination: CommunityDetailView( + viewModel: detailFactory.makeDetailViewModel(), + boardId: board.id, + updateFactory: updateFactory, + reportFactory: reportFactory + )) { CommunityPostListCard(board: board) } .buttonStyle(PlainButtonStyle()) + .onAppear { + // 마지막에서 3개 전부터 미리 로드 시작 + if index >= boards.count - 3 { + Task { + await viewModel.loadMoreBoards() + } + } + } + } + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 16) } } + .cornerRadius(8) + .shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1) } - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color.white) - .padding(-2) - .shadow( - color: Color.black.opacity(0.1), - radius: 4.5, - x: 0, - y: 0 - ) - ) - .background(Color.white) + .scrollIndicators(.hidden) + .cornerRadius(8) + .shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1) } } // MARK: - Feed View fileprivate struct CommunityFeedView: View { let boards: [Board] + let detailFactory: CommunityDetailFactory + let updateFactory: UpdateBoardFactory + let reportFactory: ReportBoardFactory + @State private var viewModel: CommunityViewModel + + init( + boards: [Board], + detailFactory: CommunityDetailFactory, + updateFactory: UpdateBoardFactory, + reportFactory: ReportBoardFactory, + viewModel: CommunityViewModel + ) { + self.boards = boards + self.detailFactory = detailFactory + self.updateFactory = updateFactory + self.reportFactory = reportFactory + self._viewModel = State(initialValue: viewModel) + } + var body: some View { ScrollView { LazyVStack(spacing: 16) { - ForEach(boards) { board in - NavigationLink(destination: CommunityDetailView()) { + ForEach(Array(boards.enumerated()), id: \.element.id) { index, board in + NavigationLink( + destination: CommunityDetailView( + viewModel: detailFactory.makeDetailViewModel(), + boardId: board.id, + updateFactory: updateFactory, + reportFactory: reportFactory + ) + ) { CommunityPostFeedCard(board: board) } .buttonStyle(PlainButtonStyle()) + .onAppear { + // 마지막에서 3개 전부터 미리 로드 시작 + if index >= boards.count - 3 { + Task { + await viewModel.loadMoreBoards() + } + } + } + } + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 16) } } .padding(.top, 8) @@ -214,53 +331,49 @@ fileprivate struct CommunityPostListCard: View { .lineLimit(1) .truncationMode(.tail) .padding(.trailing, 8) - + Text(board.createdAt) .pretendard(.caption(.base)) .foregroundColor(Color.textG600) - + Spacer() } - + HStack(spacing: 4) { - Text(board.nickName) + Text(board.authorNickname) .pretendard(.caption(.base)) .foregroundColor(Color.textG800) - + HStack(spacing: 4) { Image(systemName: "heart.fill") .foregroundColor(Color.textR100) .font(.system(size: 12)) - - Text(board.likeCount) + + Text("\(board.likeCount)") .pretendard(.caption(.base)) .foregroundColor(Color.textG600) - + Image(.communityComment) .resizable() .frame(width: 12, height: 12) - - Text(board.commnetCount) + + Text("\(board.commentCount)") .pretendard(.caption(.base)) .foregroundColor(Color.textG600) } } } - + AsyncThumbnailImage( - imageURL: board.imageURL, + imageURL: board.imageUrls.first ?? "", width: 50, height: 50, cornerRadius: 8 ) } - .padding(.horizontal, 16) - .padding(.vertical, 16) + .padding(16) .background(Color.white) - - Divider() - .background(Color.borderG300) } } } @@ -268,12 +381,12 @@ fileprivate struct CommunityPostListCard: View { // MARK: - Feed Card fileprivate struct CommunityPostFeedCard: View { let board: Board - + var body: some View { VStack(alignment: .leading, spacing: 12) { - + AsyncThumbnailImage( - imageURL: board.imageURL, + imageURL: board.imageUrls.first ?? "", height: 192, cornerRadius: 8 ) @@ -284,33 +397,33 @@ fileprivate struct CommunityPostFeedCard: View { .foregroundColor(Color.textG800) .multilineTextAlignment(.leading) .lineLimit(1) - + Spacer() - + Text(board.createdAt) .pretendard(.caption(.base)) .foregroundColor(Color.textG600) } - + HStack { - Text(board.nickName) + Text(board.authorNickname) .pretendard(.caption(.base)) .foregroundColor(Color.textG800) - + HStack(spacing: 4) { Image(systemName: "heart.fill") .foregroundColor(Color.textR100) .font(.system(size: 12)) - - Text(board.likeCount) + + Text("\(board.likeCount)") .pretendard(.caption(.base)) .foregroundColor(Color.textG600) - + Image(.communityComment) .resizable() .frame(width: 12, height: 12) - - Text(board.commnetCount) + + Text("\(board.commentCount)") .pretendard(.caption(.base)) .foregroundColor(Color.textG600) } @@ -330,25 +443,6 @@ fileprivate struct CommunityPostFeedCard: View { } } -// MARK: - Helper Functions -private func timeAgoString(from 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 { - let days = Int(timeInterval / 86400) - return "\(days)일 전" - } -} - struct AsyncThumbnailImage: View { private let imageURL: String? private let width: CGFloat? diff --git a/Community/Sources/Presentation/Community/CommunityViewModel.swift b/Community/Sources/Presentation/Community/CommunityViewModel.swift new file mode 100644 index 0000000..50bdba1 --- /dev/null +++ b/Community/Sources/Presentation/Community/CommunityViewModel.swift @@ -0,0 +1,194 @@ +// +// CommunityViewModel.swift +// Hambug +// +// Created by 강동영 on 10/17/25. +// + +import Foundation +import SwiftUI +import CommunityDomain + +public extension CommunityViewModel { + enum Category: CaseIterable { + case all + case board + case review + case recommend + + var title: String { + switch self { + case .all: + "전체" + case .board: + "자유잡담" + case .review: + "햄버거리뷰" + case .recommend: + "맛집추천" + } + } + + var boardCategory: BoardCategory? { + switch self { + case .all: + return nil + case .board: + return .freeTalk + case .review: + return .review + case .recommend: + return .recommendation + } + } + + public var isListView: Bool { + switch self { + case .all, .board: true + case .review, .recommend: false + } + } + } +} + +// MARK: - Community ViewModel +@MainActor +@Observable +public final class CommunityViewModel { + + // MARK: - Published Properties + public var boards: [Board] = [] + public var isLoading: Bool = false + public var isLoadingMore: Bool = false + public var errorMessage: String? = nil + public var selectedCategory: Category = .all + + public var isListView: Bool { + switch selectedCategory { + case .all, .board: true + case .review, .recommend: false + } + } + + // MARK: - Dependencies + private let getBoardsUseCase: GetBoardsUseCase + private let getBoardsByCategoryUseCase: GetBoardsByCategoryUseCase + + // MARK: - Private Properties + private let categories: [Category] = Category.allCases + private var currentPage: Int? = nil + private var hasNextPage: Bool = true + private let pageSize: Int = 10 + + // MARK: - Initialization + public init( + getBoardsUseCase: GetBoardsUseCase, + getBoardsByCategoryUseCase: GetBoardsByCategoryUseCase + ) { + self.getBoardsUseCase = getBoardsUseCase + self.getBoardsByCategoryUseCase = getBoardsByCategoryUseCase + Task { + await loadBoards() + } + } + + // MARK: - Public Methods + public func loadBoards() async { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + currentPage = nil + hasNextPage = true + + do { + let boardListData: BoardListData + + if let category = selectedCategory.boardCategory { + boardListData = try await getBoardsByCategoryUseCase.execute( + category: category, + lastId: currentPage, + limit: pageSize, + order: .desc + ) + } else { + boardListData = try await getBoardsUseCase.execute( + lastId: currentPage, + limit: pageSize, + order: .desc + ) + } + + boards = boardListData.content + hasNextPage = boardListData.hasNextPage + if let nextCursor = boardListData.nextCursorId { + currentPage = nextCursor + } + + print("✅ API Success: \(boardListData.content.count) boards loaded") + } catch { + errorMessage = error.localizedDescription + print("❌ API Error: \(error)") + } + + isLoading = false + } + + public func loadMoreBoards() async { + guard !isLoadingMore && hasNextPage else { return } + isLoadingMore = true + + do { + let boardListData: BoardListData + + if let category = selectedCategory.boardCategory { + boardListData = try await getBoardsByCategoryUseCase.execute( + category: category, + lastId: currentPage, + limit: pageSize, + order: .desc + ) + } else { + boardListData = try await getBoardsUseCase.execute( + lastId: currentPage, + limit: pageSize, + order: .desc + ) + } + + boards.append(contentsOf: boardListData.content) + hasNextPage = boardListData.hasNextPage + if let nextCursor = boardListData.nextCursorId { + currentPage = nextCursor + } + + print("✅ Load more success: \(boardListData.content.count) boards added") + } catch { + print("❌ Load more error: \(error)") + } + + isLoadingMore = false + } + + public func refreshBoards() { + Task { + await loadBoards() + } + } + + public func selectCategory(_ category: Category) { + selectedCategory = category + Task { + await loadBoards() + } + } + + // MARK: - Computed Properties + public var filteredBoards: [Board] { + boards +// return boards.isEmpty ? Board.sampleData : boards + } + + public var categoryList: [Category] { + return categories + } +} diff --git a/Community/Sources/Presentation/Views/Write/BorderTextEditor.swift b/Community/Sources/Presentation/Component/BorderTextEditor.swift similarity index 77% rename from Community/Sources/Presentation/Views/Write/BorderTextEditor.swift rename to Community/Sources/Presentation/Component/BorderTextEditor.swift index 95f05fd..7c1a8c4 100644 --- a/Community/Sources/Presentation/Views/Write/BorderTextEditor.swift +++ b/Community/Sources/Presentation/Component/BorderTextEditor.swift @@ -7,17 +7,19 @@ import SwiftUI -public struct BorderTextEditor: View { +public struct BorderTextEditor: View { private let contentPlaceholder = "자유롭게 이야기를 나눠보세요" private let maxCharacterCount: Int @State private var characterCount: Int = 0 @Binding var content: String + var focusedField: FocusState.Binding? + var field: Field? private var isCharacterMax: Bool { characterCount >= maxCharacterCount } - + public var body: some View { VStack(spacing: 2) { if isCharacterMax { @@ -28,14 +30,14 @@ public struct BorderTextEditor: View { .foregroundColor(.textR100) } } - + ZStack(alignment: .topLeading) { RoundedRectangle(cornerRadius: 8) .stroke(isCharacterMax ? Color.borderR100 : Color.borderG400, lineWidth: 1) .background(Color.bgG75) .frame(minHeight: 234) - - TextEditor(text: $content) + + let textEditor = TextEditor(text: $content) .pretendard(.body(.small)) .padding(.horizontal, 12) .padding(.vertical, 10) @@ -48,7 +50,13 @@ public struct BorderTextEditor: View { characterCount = maxCharacterCount } } - + + if let focusedField = focusedField, let field = field { + textEditor.focused(focusedField, equals: field) + } else { + textEditor + } + if content.isEmpty { Text(contentPlaceholder) .pretendard(.body(.small)) @@ -59,12 +67,16 @@ public struct BorderTextEditor: View { } } } - + init( maxCharacterCount: Int = 300, - content: Binding + content: Binding, + focusedField: FocusState.Binding? = nil, + field: Field? = nil ) { self.maxCharacterCount = maxCharacterCount self._content = content + self.focusedField = focusedField + self.field = field } } diff --git a/Community/Sources/Presentation/Component/BottomLineTextField.swift b/Community/Sources/Presentation/Component/BottomLineTextField.swift new file mode 100644 index 0000000..8801f3b --- /dev/null +++ b/Community/Sources/Presentation/Component/BottomLineTextField.swift @@ -0,0 +1,43 @@ +// +// BottomLineTextField.swift +// Hambug +// +// Created by 강동영 on 10/28/25. +// + +import SwiftUI + +public struct BottomLineTextField: View { + @Binding var title: String + var focusedField: FocusState.Binding? + var field: Field? + + public var body: some View { + let textField = TextField("", text: $title) + .pretendard(.body(.base)) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundColor(Color.borderG400), + alignment: .bottom + ) + + if let focusedField = focusedField, let field = field { + textField.focused(focusedField, equals: field) + } else { + textField + } + } + + public init( + title: Binding, + focusedField: FocusState.Binding? = nil, + field: Field? = nil + ) { + self._title = title + self.focusedField = focusedField + self.field = field + } +} diff --git a/Community/Sources/Presentation/Component/RequiredText.swift b/Community/Sources/Presentation/Component/RequiredText.swift new file mode 100644 index 0000000..0f21d25 --- /dev/null +++ b/Community/Sources/Presentation/Component/RequiredText.swift @@ -0,0 +1,27 @@ +// +// RequiredText.swift +// Community +// +// Created by 강동영 on 1/8/26. +// + +import SwiftUI + +struct RequiredText: View { + private let title: String + + init(_ title: String) { + self.title = title + } + + var body: some View { + HStack(spacing: 0) { + Text(title) + .pretendard(.body(.bEmphasis)) + .foregroundColor(.textG900) + Text("*") + .pretendard(.body(.bEmphasis)) + .foregroundColor(.primaryHambugRed) + } + } +} diff --git a/Community/Sources/Presentation/Detail/CommunityDetail.swift b/Community/Sources/Presentation/Detail/CommunityDetail.swift new file mode 100644 index 0000000..42ce982 --- /dev/null +++ b/Community/Sources/Presentation/Detail/CommunityDetail.swift @@ -0,0 +1,520 @@ +// +// CommunityDetail.swift +// Hambug +// +// Created by 강동영 on 10/18/25. +// + +import SwiftUI +import DesignSystem +import CommunityDomain +import SharedUI + +public protocol UpdateBoardFactory { + func makeViewModel(boardId: Int) -> CommunityWriteViewModelProtocol +} +public protocol ReportBoardFactory { + func makeViewModel(req: ReportRequest) -> CommunityReportViewModel +} + +public struct CommunityDetailView: View { + @Environment(\.dismiss) private var dismiss + @State private var viewModel: CommunityDetailViewModel + @State private var commentText: String = "" + @State private var showDeletePopup: Bool = false + + // Comment action states + @State private var selectedComment: Comment? + @State private var showCommentActionSheet: Bool = false + @State private var showDeleteCommentAlert: Bool = false + @State private var editingCommentId: Int? + @State private var editingCommentText: String = "" + + // Board action states + @State private var showBoardActionSheet: Bool = false + + // Report states + @State private var reportTargetId: Int? + @State private var reportTargetType: ReportTargetType? + @State private var reportReason: String = "" + + private let boardId: Int + private let updateFactory: UpdateBoardFactory + private let reportFactory: ReportBoardFactory + + public init( + viewModel: CommunityDetailViewModel, + boardId: Int, + updateFactory: UpdateBoardFactory, + reportFactory: ReportBoardFactory, + ) { + _viewModel = State(initialValue: viewModel) + self.boardId = boardId + self.updateFactory = updateFactory + self.reportFactory = reportFactory + } + + public var body: some View { + NavigationView { + VStack(spacing: 0) { + navigationBar + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + postHeader + postContent + + if !(viewModel.board?.imageUrls.isEmpty ?? true) { + imageSection + } + + likeCommentSection + Divider() + .background(Color.borderG300) + .padding(.vertical, 16) + commentSection + } + .padding(.horizontal, 16) + } + + commentInputSection + } + .background(Color.bgWhite) + .overlay( + HambugCommonAlertView( + isPresented: $showDeletePopup, + content: { + Text("게시물을 삭제하시겠어요?") + .pretendard(.title(.t2)) + .foregroundStyle(Color.textG900) + .padding(.top, 16) + }, + secondaryButton: AlertButton(title: "취소") { + print("취소") + + }, + primaryButton: AlertButton(title: "삭제") { + print("삭제") + Task { + await viewModel.deleteBoard(boardId: boardId) + dismiss() + } + } + ) + .opacity(showDeletePopup ? 1 : 0) + ) + .tabBarHidden(true) + .onAppear { + Task { + await viewModel.loadBoardDetail(boardId: boardId) + await viewModel.loadComments(boardId: boardId) + await viewModel.loadLikeInfo(boardId: boardId) + } + } + } + .navigationBarHidden(true) + .confirmationDialog("댓글", isPresented: $showCommentActionSheet, presenting: selectedComment) { comment in + // Only show edit/delete buttons if the current user is the author + if let currentUserId = viewModel.currentUserId, + Int64(comment.authorId) == currentUserId { + Button("수정") { + editingCommentId = comment.id + editingCommentText = comment.content + } + Button("삭제", role: .destructive) { + selectedComment = comment + showDeleteCommentAlert = true + } + } + + // Only show report button if the current user is NOT the author + if let currentUserId = viewModel.currentUserId, + Int64(comment.authorId) != currentUserId { + NavigationLink( + destination: CommunityReportView( + viewModel: reportFactory.makeViewModel( + req: CommunityDomain.ReportRequest.init( + targetId: comment.id, + targetType: .comment, + reason: "" + ) + ) + ) + ) { + Text("신고") + } + } + + Button("취소", role: .cancel) {} + } + .confirmationDialog("게시물", isPresented: $showBoardActionSheet) { + // Only show delete button if the current user is the author + if let currentUserId = viewModel.currentUserId, + let authorId = viewModel.board?.authorId, + Int64(authorId) == currentUserId { + NavigationLink( + destination: CommunityWriteView( + viewModel: updateFactory.makeViewModel(boardId: boardId), + title: viewModel.board?.title ?? "", + content: viewModel.board?.content ?? "" + ) + ) { + Button("수정") { + } + } + + Button("삭제", role: .destructive) { + showDeletePopup = true + } + } + + // Only show report button if the current user is NOT the author + if let currentUserId = viewModel.currentUserId, + let authorId = viewModel.board?.authorId, + Int64(authorId) != currentUserId { + NavigationLink( + destination: CommunityReportView( + viewModel: reportFactory.makeViewModel( + req: CommunityDomain.ReportRequest.init( + targetId: boardId, + targetType: .board, + reason: "" + ) + ) + ) + ) { + Text("신고") + } + } + + Button("취소", role: .cancel) {} + } + .alert("댓글 삭제", isPresented: $showDeleteCommentAlert, presenting: selectedComment) { comment in + Button("취소", role: .cancel) {} + Button("삭제", role: .destructive) { + Task { + await viewModel.deleteComment(boardId: boardId, commentId: comment.id) + } + } + } message: { _ in + Text("댓글을 삭제하시겠어요?") + } + } + + private var navigationBar: some View { + HStack { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.iconG800) + } + + ProfileImageView(with: viewModel.board?.authorProfileImageUrl ?? "") + .applyCilpShape() + + Text(viewModel.board?.authorNickname ?? "") + .pretendard(.title(.t2)) + .foregroundColor(.textG900) + + Spacer() + + EllipsisButton { + showBoardActionSheet = true + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.bgWhite) + } + + private var postHeader: some View { + VStack(alignment: .leading, spacing: 8) { + Text(viewModel.board?.title ?? "") + .pretendard(.title(.t1)) + .foregroundColor(.textG900) + .multilineTextAlignment(.leading) + + Text(viewModel.board?.createdAt ?? "") + .pretendard(.caption(.base)) + .foregroundColor(.textG600) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 20) + } + + private var postContent: some View { + Text(viewModel.board?.content ?? "") + .pretendard(.body(.base)) + .foregroundColor(.textG800) + .lineLimit(nil) + .multilineTextAlignment(.leading) + .allowsTightening(true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 16) + } + + private var imageSection: some View { + ScrollView(.horizontal) { + HStack(spacing: 12) { + ForEach(viewModel.board?.imageUrls ?? [], id: \.self) { imageUrl in + AsyncImage(url: URL(string: imageUrl)) { phase in + switch phase { + case .empty: + Rectangle() + .fill(Color.bgG200) + .frame(width: 270, height: 270) + .overlay { + ProgressView() + } + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 270, height: 270) + .clipped() + case .failure: + Rectangle() + .fill(Color.bgG200) + .frame(width: 270, height: 270) + @unknown default: + Rectangle() + .fill(Color.bgG200) + .frame(width: 270, height: 270) + } + } + .cornerRadius(8) + } + } + } + .scrollIndicators(.hidden) + .padding(.top, 20) + + } + + private var likeCommentSection: some View { + HStack(spacing: 16) { + Button { + Task { + await viewModel.toggleLike(boardId: boardId) + } + } label: { + HStack(spacing: 4) { + Image(systemName: (viewModel.likeInfo?.isLiked ?? false) ? "heart.fill" : "heart") + .font(.system(size: 20)) + .foregroundColor(.primaryHambugRed) + + Text("\(viewModel.likeInfo?.likeCount ?? 0)") + .pretendard(.caption(.base)) + .foregroundColor(.textG600) + } + } + + HStack(spacing: 4) { + Image(.communityComment) + .resizable() + .frame(width: 20, height: 20) + + Text("\(viewModel.board?.commentCount ?? 0)") + .pretendard(.caption(.base)) + .foregroundColor(.textG600) + } + + Spacer() + } + .padding(.top, 20) + } + + private var commentSection: some View { + VStack(alignment: .leading, spacing: 16) { + // 코멘트 헤더 + Text("댓글 \(viewModel.comments.count)") + .pretendard(.body(.bEmphasis)) + .foregroundColor(.textG900) + + if viewModel.comments.isEmpty { + // 댓글이 없을 때 + VStack(spacing: 8) { + Spacer() + .frame(height: 40) + Text("첫 댓글을 남겨보세요") + .pretendard(.body(.base)) + .foregroundColor(.textG600) + Spacer() + .frame(height: 40) + } + .frame(maxWidth: .infinity) + } else { + // 댓글이 있을 때 + ForEach(Array(viewModel.comments.enumerated()), id: \.element.id) { index, comment in + commentRow(comment: comment) + .onAppear { + if index == viewModel.comments.count - 1 { + Task { + await viewModel.loadMoreComments(boardId: boardId) + } + } + } + } + } + + if viewModel.isLoadingMoreComments { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 8) + } + } + } + + private func commentRow(comment: Comment) -> some View { + HStack(alignment: .top, spacing: 12) { + ProfileImageView(with: comment.authorProfileImageUrl ?? "") + .applyCilpShape() + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(comment.authorNickname) + .pretendard(.body(.sEmphasis)) + .foregroundColor(.textG900) + + Spacer() + + EllipsisButton { + selectedComment = comment + showCommentActionSheet = true + } + } + + Text(timeAgoDisplay(comment.createdAt)) + .pretendard(.caption(.base)) + .foregroundColor(.textG600) + + if editingCommentId == comment.id { + HStack { + TextField("댓글 수정", text: $editingCommentText) + .pretendard(.body(.base)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.bgG100) + ) + + Button("완료") { + Task { + await viewModel.updateComment(boardId: boardId, commentId: comment.id, content: editingCommentText) + editingCommentId = nil + editingCommentText = "" + } + } + .pretendard(.body(.sEmphasis)) + .foregroundColor(.primaryHambugRed) + + Button("취소") { + editingCommentId = nil + editingCommentText = "" + } + .pretendard(.body(.base)) + .foregroundColor(.textG600) + } + .padding(.top, 4) + } else if !comment.content.isEmpty { + Text(comment.content) + .pretendard(.body(.base)) + .foregroundColor(.textG800) + .multilineTextAlignment(.leading) + .padding(.top, 4) + } + } + } + } + + private var commentInputSection: some View { + HStack(spacing: 12) { + TextField("댓글을 입력하세요", text: $commentText) + .pretendard(.body(.base)) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(Color.bgG100) + ) + + Button { + if !commentText.isEmpty { + Task { + await viewModel.createComment(boardId: boardId, content: commentText) + commentText = "" + } + } + } label: { + Image(systemName: "paperplane.fill") + .font(.system(size: 16)) + .foregroundColor(.primaryHambugRed) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.bgWhite) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundColor(.borderG300), + alignment: .top + ) + } + + private 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) + } + } +} + +struct EllipsisButton: View { + private let action: () -> Void + var body: some View { + Button { + action() + } label: { + Color.bgEllipsis + .frame(width: 24, height: 24) + .clipShape(Circle()) + .overlay { + Image(systemName: "ellipsis") + .rotationEffect(.degrees(90.0)) + .font(.system(size: 14)) + .foregroundColor(.iconG600) + } + } + } + + init(action: @escaping @MainActor () -> Void) { + self.action = action + } +} + +// Preview requires mock ViewModel with all dependencies +// #Preview { +// CommunityDetailView(viewModel: mockViewModel, boardId: 1) +// } diff --git a/Community/Sources/Presentation/Detail/CommunityDetailViewModel.swift b/Community/Sources/Presentation/Detail/CommunityDetailViewModel.swift new file mode 100644 index 0000000..a279c32 --- /dev/null +++ b/Community/Sources/Presentation/Detail/CommunityDetailViewModel.swift @@ -0,0 +1,232 @@ +// +// CommunityDetailViewModel.swift +// Hambug +// +// Created by 강동영 on 01/07/26. +// + +import Foundation +import SwiftUI +import CommunityDomain +import Managers + +// MARK: - Community Detail ViewModel +@MainActor +@Observable +public final class CommunityDetailViewModel { + + // MARK: - Published Properties + public var board: Board? + public var comments: [Comment] = [] + public var likeInfo: LikeInfo? + public var isLoadingBoard: Bool = false + public var isLoadingComments: Bool = false + public var isLoadingMoreComments: Bool = false + public var errorMessage: String? = nil + + // MARK: - Dependencies + private let boardDetailUseCase: BoardDetailUseCase + private let commentUseCase: CommentUseCase + private let likeUseCase: LikeUseCase + private let reportContentUseCase: ReportContentUseCase + private let userDefaultsManager: UserDefaultsManager + + // MARK: - Private Properties + private var currentCommentPage: Int? = nil + private var hasNextCommentPage: Bool = true + private let commentPageSize: Int = 10 + + // MARK: - Computed Properties + public var currentUserId: Int64? { + userDefaultsManager.currentUserId + } + + // MARK: - Initialization + public init( + boardDetailUseCase: BoardDetailUseCase, + commentUseCase: CommentUseCase, + likeUseCase: LikeUseCase, + reportContentUseCase: ReportContentUseCase, + userDefaultsManager: UserDefaultsManager + ) { + self.boardDetailUseCase = boardDetailUseCase + self.commentUseCase = commentUseCase + self.likeUseCase = likeUseCase + self.reportContentUseCase = reportContentUseCase + self.userDefaultsManager = userDefaultsManager + } + + // MARK: - Board Detail Methods + public func loadBoardDetail(boardId: Int) async { + guard !isLoadingBoard else { return } + isLoadingBoard = true + errorMessage = nil + + do { + + board = try await boardDetailUseCase.getBoard(boardId: boardId) + print("✅ Board detail loaded: \(board?.title ?? "")") + } catch { + errorMessage = error.localizedDescription + print("❌ Board detail error: \(error)") + } + + isLoadingBoard = false + } + + public func deleteBoard(boardId: Int) async { + errorMessage = nil + + do { + try await boardDetailUseCase.deleteBoard(boardId: boardId) + } catch { + errorMessage = error.localizedDescription + print("❌ Board detail error: \(error)") + } + } + + // MARK: - Comment Methods + public func loadComments(boardId: Int) async { + guard !isLoadingComments else { return } + isLoadingComments = true + errorMessage = nil + currentCommentPage = nil + hasNextCommentPage = true + + do { + let commentListData = try await commentUseCase.getComments( + boardId: boardId, + lastId: currentCommentPage, + limit: commentPageSize, + order: .desc + ) + + comments = commentListData.content + hasNextCommentPage = commentListData.hasNextPage + if let nextCursor = commentListData.nextCursorId { + currentCommentPage = nextCursor + } + + print("✅ Comments loaded: \(commentListData.content.count) comments") + } catch { + errorMessage = error.localizedDescription + print("❌ Comments error: \(error)") + } + + isLoadingComments = false + } + + public func loadMoreComments(boardId: Int) async { + guard !isLoadingMoreComments && hasNextCommentPage else { return } + isLoadingMoreComments = true + + do { + let commentListData = try await commentUseCase.getComments( + boardId: boardId, + lastId: currentCommentPage, + limit: commentPageSize, + order: .desc + ) + + comments.append(contentsOf: commentListData.content) + hasNextCommentPage = commentListData.hasNextPage + if let nextCursor = commentListData.nextCursorId { + currentCommentPage = nextCursor + } + + print("✅ Load more comments success: \(commentListData.content.count) comments added") + } catch { + print("❌ Load more comments error: \(error)") + } + + isLoadingMoreComments = false + } + + public func createComment(boardId: Int, content: String) async { + guard !content.isEmpty else { return } + + do { + let newComment = try await commentUseCase.createComment(boardId: boardId, content: content) + comments.insert(newComment, at: 0) + + // Update board comment count + if let currentBoard = board { + var newBoard = currentBoard + newBoard.commentCount += 1 + board = newBoard + } + + print("✅ Comment created: \(newComment.content)") + } catch { + errorMessage = error.localizedDescription + print("❌ Create comment error: \(error)") + } + } + + public func updateComment(boardId: Int, commentId: Int, content: String) async { + guard !content.isEmpty else { return } + + do { + let updatedComment = try await commentUseCase.updateComment( + boardId: boardId, + commentId: commentId, + content: content + ) + + if let index = comments.firstIndex(where: { $0.id == commentId }) { + comments[index] = updatedComment + } + + print("✅ Comment updated: \(updatedComment.content)") + } catch { + errorMessage = error.localizedDescription + print("❌ Update comment error: \(error)") + } + } + + public func deleteComment(boardId: Int, commentId: Int) async { + do { + try await commentUseCase.deleteComment(boardId: boardId, commentId: commentId) + comments.removeAll { $0.id == commentId } + + // Update board comment count + if let currentBoard = board { + var newBoard = currentBoard + newBoard.commentCount = max(0, newBoard.commentCount - 1) + board = newBoard + } + + print("✅ Comment deleted") + } catch { + errorMessage = error.localizedDescription + print("❌ Delete comment error: \(error)") + } + } + + // MARK: - Like Methods + public func loadLikeInfo(boardId: Int) async { + do { + likeInfo = try await likeUseCase.getLikeInfo(boardId: boardId) + print("✅ Like info loaded: \(likeInfo?.likeCount ?? 0) likes") + } catch { + print("❌ Like info error: \(error)") + } + } + + public func toggleLike(boardId: Int) async { + do { + let updatedLikeInfo = try await likeUseCase.toggleLike(boardId: boardId) + likeInfo = updatedLikeInfo + + // Update board like info + if let currentBoard = board { + board = currentBoard + } + + print("✅ Like toggled: \(updatedLikeInfo.isLiked ? "liked" : "unliked")") + } catch { + errorMessage = error.localizedDescription + print("❌ Toggle like error: \(error)") + } + } +} diff --git a/Community/Sources/Presentation/Views/Write/CommunityReportView.swift b/Community/Sources/Presentation/Report/CommunityReportView.swift similarity index 57% rename from Community/Sources/Presentation/Views/Write/CommunityReportView.swift rename to Community/Sources/Presentation/Report/CommunityReportView.swift index e23d157..e90bc07 100644 --- a/Community/Sources/Presentation/Views/Write/CommunityReportView.swift +++ b/Community/Sources/Presentation/Report/CommunityReportView.swift @@ -7,28 +7,39 @@ import SwiftUI import DesignSystem - + public struct CommunityReportView: View { + @State var viewModel: CommunityReportViewModel @Environment(\.dismiss) private var dismiss - @State private var title: String = "" - @State private var content: String = "" + @FocusState private var focusedField: Field? - private let maxCharacterCount = 300 + enum Field: Hashable { + case title + case content + } - public init() {} + public init(viewModel: CommunityReportViewModel) { + self._viewModel = State(initialValue: viewModel) + } public var body: some View { - NavigationView { + VStack(spacing: 0) { + navigationBar + VStack(spacing: 0) { - navigationBar - ScrollView { VStack(alignment: .leading, spacing: 24) { titleSection contentSection } - .padding(.horizontal, 16) .padding(.top, 20) + .background( + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + focusedField = nil + } + ) } Spacer() @@ -37,11 +48,15 @@ public struct CommunityReportView: View { title: "신고 등록", style: .body(.bEmphasis) ) { + focusedField = nil // Handle submit action } + .padding(.bottom, 20) + .disabled(!viewModel.canSubmit) } - .background(Color.bgWhite) + .padding(.horizontal, 18) } + .background(Color.bgWhite) .navigationBarHidden(true) } @@ -73,38 +88,30 @@ public struct CommunityReportView: View { private var titleSection: some View { VStack(alignment: .leading, spacing: 12) { - HStack { - Text("제목") - .pretendard(.body(.bEmphasis)) - .foregroundColor(.textG900) - Text("*") - .pretendard(.body(.bEmphasis)) - .foregroundColor(.primaryHambugRed) - } - - BottomLineTextField(title: $title) + RequiredText("제목") + + BottomLineTextField( + title: $viewModel.title, + focusedField: $focusedField, + field: .title + ) } } private var contentSection: some View { VStack(alignment: .leading, spacing: 12) { - HStack { - Text("내용") - .pretendard(.body(.bEmphasis)) - .foregroundColor(.textG900) - Text("*") - .pretendard(.body(.bEmphasis)) - .foregroundColor(.primaryHambugRed) - } - + RequiredText("내용") + BorderTextEditor( - maxCharacterCount: maxCharacterCount, - content: $content + maxCharacterCount: viewModel.maxCharacterCount, + content: $viewModel.content, + focusedField: $focusedField, + field: .content ) } } } -#Preview { - CommunityReportView() -} +//#Preview { +// CommunityReportView(viewModel: CommunityReportViewModel()) +//} diff --git a/Community/Sources/Presentation/Report/CommunityReportViewModel.swift b/Community/Sources/Presentation/Report/CommunityReportViewModel.swift new file mode 100644 index 0000000..ef5474e --- /dev/null +++ b/Community/Sources/Presentation/Report/CommunityReportViewModel.swift @@ -0,0 +1,58 @@ +// +// CommunityReportViewModel.swift +// Community +// +// Created by 강동영 on 1/8/26. +// + + +import Foundation +import Observation +import CommunityDomain + +@Observable +public class CommunityReportViewModel { + private let usecase: ReportContentUseCase + private let targetId: Int + private let targetType: ReportTargetType + + private let _maxCharacterCount = 300 + + var title: String = "" + var content: String = "" + var maxCharacterCount: Int { _maxCharacterCount } + + // 게시물 제출 중 상태 + public var isSubmitting: Bool = false + + /// 제출 가능 여부 + public var canSubmit: Bool { !isSubmitting } + + public var errorMessage: String? = nil + + public init( + usecase: ReportContentUseCase, + reportInfo: ReportRequest + ) { + self.usecase = usecase + self.targetId = reportInfo.targetId + self.targetType = reportInfo.targetType + } + + // MARK: - Report Methods + func report() async { + let request: ReportRequest = .init( + targetId: targetId, + targetType: targetType, + reason: "\(title): \(content)" + ) + + do { + try await usecase.execute(request: request) + print("✅ Content reported") + } catch { + errorMessage = error.localizedDescription + print("❌ Report error: \(error)") + } + } +} diff --git a/Community/Sources/Presentation/ViewModels/CommunityViewModel.swift b/Community/Sources/Presentation/ViewModels/CommunityViewModel.swift deleted file mode 100644 index d7bd2bf..0000000 --- a/Community/Sources/Presentation/ViewModels/CommunityViewModel.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// CommunityViewModel.swift -// Hambug -// -// Created by 강동영 on 10/17/25. -// - -import Foundation -import Combine -import SwiftUI -import CommunityDomain - -public extension CommunityViewModel { - enum Category: CaseIterable { - case all - case board - case review - case recommend - - var title: String { - switch self { - case .all: - "전체" - case .board: - "자유잡담" - case .review: - "햄버거리뷰" - case .recommend: - "맛집추천" - } - } - - public var isListView: Bool { - switch self { - case .all, .board: true - case .review, .recommend: false - } - } - } -} -// MARK: - Community ViewModel -public final class CommunityViewModel: ObservableObject { - - // MARK: - Published Properties - @Published public var boards: [Board] = [] - @Published public var isLoading: Bool = false - @Published public var errorMessage: String? = nil - @Published public var selectedCategory: Category = .all - - public var isListView: Bool { - switch selectedCategory { - case .all, .board: true - case .review, .recommend: false - } - } - - // MARK: - Dependencies - private let getBoardsUseCase: GetBoardsUseCaseInterface - - // MARK: - Private Properties - private var cancellables = Set() - private let categories: [Category] = Category.allCases - - // MARK: - Initialization - public init(getBoardsUseCase: GetBoardsUseCaseInterface) { - self.getBoardsUseCase = getBoardsUseCase - loadBoards() - } - - // MARK: - Public Methods - public func loadBoards() { - isLoading = true - errorMessage = nil - - getBoardsUseCase.execute() - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [weak self] completion in - self?.isLoading = false - - if case .failure(let error) = completion { - self?.errorMessage = error.localizedDescription - print("❌ API Error: \(error)") - - // API 실패 시 샘플 데이터 사용 - self?.boards = Board.sampleData - } - }, - receiveValue: { [weak self] boards in - print("✅ API Success: \(boards.count) boards loaded") - self?.boards = boards - } - ) - .store(in: &cancellables) - } - - public func refreshBoards() { - loadBoards() - } - - public func selectCategory(_ category: Category) { - selectedCategory = category - } - - // MARK: - Computed Properties - public var filteredBoards: [Board] { - if selectedCategory == .all { - return boards.isEmpty ? Board.sampleData : boards - } else { - // TODO: 실제 카테고리 필터링 로직 구현 - return boards.isEmpty ? Board.sampleData : boards - } - } - - public var categoryList: [Category] { - return categories - } -} diff --git a/Community/Sources/Presentation/Views/CommunityDetail.swift b/Community/Sources/Presentation/Views/CommunityDetail.swift deleted file mode 100644 index 212c157..0000000 --- a/Community/Sources/Presentation/Views/CommunityDetail.swift +++ /dev/null @@ -1,303 +0,0 @@ -// -// CommunityDetailView.swift -// Hambug -// -// Created by 강동영 on 10/18/25. -// - -import SwiftUI -import DesignSystem -import CommunityDomain - -public struct CommunityDetailView: View { - @Environment(\.dismiss) private var dismiss - @State private var commentText: String = "" - @State private var isLiked: Bool = false - @State private var likeCount: Int = 1 - @State private var commentCount: Int = 6 - @State private var showDeletePopup: Bool = false - - public init() {} - - let post = PostData( - title: "제목이 들어가는 공간입니다.", - content: """ - 햄버거, 간단히 버거는 속 재료를 잘라낸 빵이나 롤빵 안에 넣어 만든다. 패티에는 종종 치즈, 양상추, 토마토, 양파, 피클, 베이컨, 고추 등이 함께 제공되며, 케첩, 머스터드, 마요네즈, 렐리시 또는 "특별 소스"와 같은 양념이 곁들여지고, - 종종 참깨빵에 담겨 나온다 - """, - timestamp: "15분 전", - author: "익명이여기", - images: ["sample_image_1", "sample_image_2"] - ) - - let comments = [ - CommentData(author: "상하이버거", content: "댓글 내용이 들어가는 곳입니다. 햄버거 참말로 맛있겠네요.", timestamp: "15분 전"), - CommentData(author: "상하이버거", content: "댓글 내용이 들어가는 곳입니다. 햄버거 참말로 맛있겠네요.", timestamp: "15분 전"), - ] - - public var body: some View { - NavigationView { - VStack(spacing: 0) { - navigationBar - - ScrollView { - VStack(alignment: .leading, spacing: 0) { - postHeader - postContent - imageSection - likeCommentSection - Divider() - .background(Color.borderG300) - .padding(.vertical, 16) - commentSection - } - .padding(.horizontal, 16) - } - - commentInputSection - } - .background(Color.bgWhite) - .overlay( - HambugCommonAlertView( - isPresented: $showDeletePopup, - content: { - Text("게시물을 삭제하시겠어요?") - .pretendard(.title(.t2)) - .foregroundStyle(Color.textG900) - .padding(.top, 16) - }, - secondaryButton: AlertButton(title: "취소") { - print("취소") - - }, - primaryButton: AlertButton(title: "삭제") { - print("삭제") - - } - ) - .opacity(showDeletePopup ? 1 : 0) - ) - } - .navigationBarHidden(true) - } - - private var navigationBar: some View { - HStack { - Button { - dismiss() - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 18, weight: .medium)) - .foregroundColor(.iconG800) - } - - - Circle() - .fill(Color.bgG200) - .frame(width: 32, height: 32) - - Text("익명이") - .pretendard(.title(.t2)) - .foregroundColor(.textG900) - - Spacer() - - EllipsisButton { - showDeletePopup = true - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color.bgWhite) - } - - private var postHeader: some View { - VStack(alignment: .leading, spacing: 8) { - Text(post.title) - .pretendard(.title(.t1)) - .foregroundColor(.textG900) - .multilineTextAlignment(.leading) - - Text(post.timestamp) - .pretendard(.caption(.base)) - .foregroundColor(.textG600) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 20) - } - - private var postContent: some View { - Text(post.content) - .pretendard(.body(.base)) - .foregroundColor(.textG800) - .lineLimit(nil) - .multilineTextAlignment(.leading) - .allowsTightening(true) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 16) - } - - private var imageSection: some View { - ScrollView(.horizontal) { - HStack(spacing: 12) { - ForEach(0..<3, id: \.self) { index in - Rectangle() - .fill(Color.bgG200) - .frame(width: 270, height: 270) - .cornerRadius(8) - } - } - } - .scrollIndicators(.hidden) - .padding(.top, 20) - - } - - private var likeCommentSection: some View { - HStack(spacing: 16) { - Button { - isLiked.toggle() - likeCount += isLiked ? 1 : -1 - } label: { - HStack(spacing: 4) { - Image(systemName: isLiked ? "heart.fill" : "heart") - .font(.system(size: 20)) - .foregroundColor(.primaryHambugRed) - - Text("\(likeCount)") - .pretendard(.caption(.base)) - .foregroundColor(.textG600) - } - } - - HStack(spacing: 4) { - Image(.communityComment) - .resizable() - .frame(width: 20, height: 20) - - Text("\(commentCount)") - .pretendard(.caption(.base)) - .foregroundColor(.textG600) - } - - Spacer() - } - .padding(.top, 20) - } - - private var commentSection: some View { - VStack(alignment: .leading, spacing: 16) { - ForEach(comments.indices, id: \.self) { index in - commentRow(comment: comments[index]) - } - } - } - - private func commentRow(comment: CommentData) -> some View { - HStack(alignment: .top, spacing: 12) { - Circle() - .fill(Color.bgG200) - .frame(width: 32, height: 32) - - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(comment.author) - .pretendard(.body(.sEmphasis)) - .foregroundColor(.textG900) - - Spacer() - - EllipsisButton { - - } - } - - Text(comment.timestamp) - .pretendard(.caption(.base)) - .foregroundColor(.textG600) - - if !comment.content.isEmpty { - Text(comment.content) - .pretendard(.body(.base)) - .foregroundColor(.textG800) - .multilineTextAlignment(.leading) - .padding(.top, 4) - } - } - } - } - - private var commentInputSection: some View { - HStack(spacing: 12) { - TextField("댓글을 입력하세요", text: $commentText) - .pretendard(.body(.base)) - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(Color.bgG100) - ) - - Button { - // Handle send comment - if !commentText.isEmpty { - commentText = "" - } - } label: { - Image(systemName: "paperplane.fill") - .font(.system(size: 16)) - .foregroundColor(.primaryHambugRed) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color.bgWhite) - .overlay( - Rectangle() - .frame(height: 1) - .foregroundColor(.borderG300), - alignment: .top - ) - } -} - -struct EllipsisButton: View { - private let action: () -> Void - var body: some View { - Button { - action() - } label: { - Color.bgEllipsis - .frame(width: 24, height: 24) - .clipShape(Circle()) - .overlay { - Image(systemName: "ellipsis") - .rotationEffect(.degrees(90.0)) - .font(.system(size: 14)) - .foregroundColor(.iconG600) - } - } - } - - init(action: @escaping @MainActor () -> Void) { - self.action = action - } -} - -struct PostData { - let title: String - let content: String - let timestamp: String - let author: String - let images: [String] -} - -struct CommentData { - let author: String - let content: String - let timestamp: String -} - -#Preview { - CommunityDetailView() -} diff --git a/Community/Sources/Presentation/Views/Write/BottomLineTextField.swift b/Community/Sources/Presentation/Views/Write/BottomLineTextField.swift deleted file mode 100644 index 15697ee..0000000 --- a/Community/Sources/Presentation/Views/Write/BottomLineTextField.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// BottomLineTextField.swift -// Hambug -// -// Created by 강동영 on 10/28/25. -// - -import SwiftUI - -public struct BottomLineTextField: View { - @Binding var title: String - - public var body: some View { - TextField("", text: $title) - .pretendard(.body(.base)) - .padding(.horizontal, 16) - .padding(.vertical, 14) - .overlay( - Rectangle() - .frame(height: 1) - .foregroundColor(Color.borderG400), - alignment: .bottom - ) - } - - public init(title: Binding) { - self._title = title - } -} diff --git a/Community/Sources/Presentation/Views/Write/CommunityWriteView.swift b/Community/Sources/Presentation/Views/Write/CommunityWriteView.swift deleted file mode 100644 index f37ddce..0000000 --- a/Community/Sources/Presentation/Views/Write/CommunityWriteView.swift +++ /dev/null @@ -1,241 +0,0 @@ -// -// CommunityWriteView.swift -// Hambug -// -// Created by 강동영 on 10/18/25. -// - -import SwiftUI -import DesignSystem - -public struct CommunityWriteView: View { - @Environment(\.dismiss) private var dismiss - @State private var selectedCategory: Category = .자유잡담 - @State private var title: String = "" - @State private var content: String = "" - @State private var characterCount: Int = 0 - - private let maxCharacterCount = 300 - - public init() {} - - private var isCharacterMax: Bool { - characterCount >= maxCharacterCount - } - enum Category: String, CaseIterable { - case 자유잡담 = "자유잡담" - case 프랜차이즈 = "프랜차이즈" - case 수제버거 = "수제버거" - case 맛집추천 = "맛집추천" - } - - public var body: some View { - NavigationView { - VStack(spacing: 0) { - navigationBar - - ScrollView { - VStack(alignment: .leading, spacing: 24) { - categorySelection - titleSection - contentSection - imageAttachmentSection - - } - .padding(.horizontal, 16) - .padding(.top, 20) - - VStack(alignment: .center) { - addImageButtonSection - } - .padding(.horizontal, 16) - .padding(.top, 20) - } - - Spacer() - - PrimaryButton( - title: "등록", - style: .body(.bEmphasis) - ) { - // Handle submit action - } - } - .background(Color.bgWhite) - } - .navigationBarHidden(true) - } - - private var navigationBar: some View { - HStack { - Button { - dismiss() - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 18, weight: .medium)) - .foregroundColor(.iconG800) - } - - Spacer() - - Text("게시물 작성") - .pretendard(.title(.t2)) - .foregroundColor(.textG900) - - Spacer() - - Color.clear - .frame(width: 18, height: 18) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color.bgWhite) - } - - private var categorySelection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 0) { - Text("카테고리") - .pretendard(.body(.bEmphasis)) - .foregroundColor(.textG900) - - Text("*") - .pretendard(.body(.bEmphasis)) - .foregroundColor(.primaryHambugRed) - } - - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(Category.allCases, id: \.self) { category in - Button { - selectedCategory = category - } label: { - Text(category.rawValue) - .pretendard(.caption(.emphasis)) - .foregroundColor(selectedCategory == category ? Color.primaryHambugRed : .textG600) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 2) - .fill(Color.white) - .stroke(selectedCategory == category ? Color.primaryHambugRed : Color.borderG300, lineWidth: 1.0) - ) - } - } - } - .padding(.horizontal, 16) - } - .padding(.horizontal, -16) - } - } - - private var titleSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 0) { - Text("제목") - .pretendard(.body(.bEmphasis)) - .foregroundColor(.textG900) - Text("*") - .pretendard(.body(.bEmphasis)) - .foregroundColor(.primaryHambugRed) - } - - BottomLineTextField(title: $title) - } - } - - private let contentPlaceholder: String = "자유롭게 이야기를 나눠보세요" - private var contentSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 0) { - Text("내용") - .pretendard(.body(.bEmphasis)) - .foregroundColor(.textG900) - Text("*") - .pretendard(.body(.bEmphasis)) - .foregroundColor(.primaryHambugRed) - } - - BorderTextEditor( - maxCharacterCount: maxCharacterCount, - content: $content - ) - } - } - - private var imageAttachmentSection: some View { - HStack(spacing: 8) { - AddedImageView {} - AddedImageView {} - - Spacer() - } - } - private var addImageButtonSection: some View { - Button { - - } label: { - Label("사진추가 (2/5)", systemImage: "camera") - .frame(maxWidth: .infinity) - .frame(height: 40) - .foregroundColor(Color.primaryHambugRed) - .background( - RoundedRectangle(cornerRadius: 0) - .stroke(Color.primaryHambugRed, lineWidth: 1) - ) - } - - - } -} - -#Preview { - CommunityWriteView() -} - - -fileprivate struct CommunityWriteFilterChip: View { - let category: String - let isSelected: Bool - - var body: some View { - Text(category) - .pretendard(.caption(.emphasis)) - .foregroundColor(isSelected ? Color.primaryHambugRed : .textG600) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 2) - .fill(Color.white) - .stroke(isSelected ? Color.primaryHambugRed : Color.borderG300, lineWidth: 1.0) - ) - } -} - -struct AddedImageView: View { - private let action: () -> Void - var body: some View { - ZStack(alignment: .topTrailing) { - // 메인 사각형 영역 - RoundedRectangle(cornerRadius: 8) - .fill(Color.bgG100) - .frame(width: 80, height: 80) - - // X 버튼 (우측 상단) - Button(action: { - action() - }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(Color.iconG600) - .frame(width: 20, height: 20) - .padding(10) - } - .offset(x: 18, y: -18) // 사각형 밖으로 살짝 나오게 - } - } - - init(action: @escaping @MainActor () -> Void) { - self.action = action - } -} diff --git a/Community/Sources/Presentation/Write/CommunityWriteView.swift b/Community/Sources/Presentation/Write/CommunityWriteView.swift new file mode 100644 index 0000000..3f27768 --- /dev/null +++ b/Community/Sources/Presentation/Write/CommunityWriteView.swift @@ -0,0 +1,300 @@ +// +// CommunityWriteView.swift +// Hambug +// +// Created by 강동영 on 10/18/25. +// + +import SwiftUI +import DesignSystem +import PhotosUI +import CommunityDomain +import SharedUI + +public struct CommunityWriteView: View { + @Environment(\.dismiss) private var dismiss + @State private var viewModel: CommunityWriteViewModelProtocol + + @State private var selectedCategory: BoardCategory = .freeTalk + @State private var title: String = "" + @State private var content: String = "" + @State private var characterCount: Int = 0 + @FocusState private var focusedField: Field? + + @State private var photosPickerItems: [PhotosPickerItem] = [] + + private let maxCharacterCount: Int + + public init( + viewModel: CommunityWriteViewModelProtocol, + title: String = "", + content: String = "", + characterCount: Int = 0, + maxCharacterCount: Int = 300 + ) { + self.viewModel = viewModel + + self.title = title + self.content = content + self.characterCount = content.count + self.maxCharacterCount = maxCharacterCount + } + + private var isCharacterMax: Bool { + characterCount >= maxCharacterCount + } + + enum Field: Hashable { + case title + case content + } + + public var body: some View { + NavigationView { + VStack(spacing: 0) { + navigationBar + + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + categorySelection + titleSection + contentSection + imageAttachmentSection + } + .padding(.top, 20) + .background( + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + focusedField = nil + } + ) + + VStack(alignment: .center) { + addImageButtonSection + } + .padding(.top, 20) + } + + Spacer() + + PrimaryButton( + title: viewModel.isSubmitting ? "등록 중..." : "등록", + style: .body(.bEmphasis) + ) { + focusedField = nil + Task { + let success = await viewModel.writeBoard( + title: title, + content: content, + category: selectedCategory + ) + if success { + dismiss() + } + } + } + .padding(.bottom, 20) + .disabled(!viewModel.canSubmit) + } + .padding(.horizontal, 20) + } + .background(Color.bgWhite) + .alert("이미지 크기 초과", isPresented: $viewModel.showImageSizeAlert) { + Button("확인", role: .cancel) { } + } message: { + Text("이미지 크기가 너무 큽니다. 다른 이미지를 선택해주세요.") + } + .tabBarHidden(true) + } + .navigationBarHidden(true) + } + + private var navigationBar: some View { + HStack { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.iconG800) + } + + Spacer() + + Text("게시물 작성") + .pretendard(.title(.t2)) + .foregroundColor(.textG900) + + Spacer() + + Color.clear + .frame(width: 18, height: 18) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.bgWhite) + } + + private var categorySelection: some View { + VStack(alignment: .leading, spacing: 12) { + RequiredText("카테고리") + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(BoardCategory.allCases, id: \.self) { category in + Button { + selectedCategory = category + } label: { + Text(category.displayName) + .pretendard(.caption(.emphasis)) + .foregroundColor(selectedCategory == category ? Color.primaryHambugRed : .textG600) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 2) + .fill(Color.white) + .stroke(selectedCategory == category ? Color.primaryHambugRed : Color.borderG300, lineWidth: 1.0) + ) + } + } + } + } + } + } + + private var titleSection: some View { + VStack(alignment: .leading, spacing: 12) { + RequiredText("제목") + + BottomLineTextField( + title: $title, + focusedField: $focusedField, + field: .title + ) + } + } + + private let contentPlaceholder: String = "자유롭게 이야기를 나눠보세요" + private var contentSection: some View { + VStack(alignment: .leading, spacing: 12) { + RequiredText("내용") + + BorderTextEditor( + maxCharacterCount: maxCharacterCount, + content: $content, + focusedField: $focusedField, + field: .content + ) + } + } + + private var imageAttachmentSection: some View { + Group { + if !viewModel.selectedImages.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(viewModel.selectedImages) { selectedImage in + AddedImageView( + image: selectedImage.image, + onDelete: { + viewModel.removeImage(id: selectedImage.id) + } + ) + } + } + } + } + } + } + + private var addImageButtonSection: some View { + PhotosPicker( + selection: $photosPickerItems, + maxSelectionCount: viewModel.maxSelectionCount, + matching: .images + ) { + Label(viewModel.imageCountText, systemImage: "camera") + .frame(maxWidth: .infinity) + .frame(height: 40) + .foregroundColor(Color.primaryHambugRed) + .background( + RoundedRectangle(cornerRadius: 0) + .stroke(Color.primaryHambugRed, lineWidth: 1) + ) + } + .onChange(of: photosPickerItems) { _, newValue in + Task { + await viewModel.handleImageSelection(newValue) + photosPickerItems.removeAll() + } + } + .disabled(!viewModel.canAddMoreImages || viewModel.isProcessingImages) + } +} + +//#Preview { +// let diContainer = CommunityDI.CommunityDIContainer() +// CommunityWriteView(viewModel: diContainer.makeCommunityWriteViewModel()) +//} + + +fileprivate struct CommunityWriteFilterChip: View { + let category: String + let isSelected: Bool + + var body: some View { + Text(category) + .pretendard(.caption(.emphasis)) + .foregroundColor(isSelected ? Color.primaryHambugRed : .textG600) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 2) + .fill(Color.white) + .stroke(isSelected ? Color.primaryHambugRed : Color.borderG300, lineWidth: 1.0) + ) + } +} + +struct AddedImageView: View { + let image: UIImage? + private let action: () -> Void + + var body: some View { + ZStack(alignment: .topTrailing) { + // 이미지 또는 플레이스홀더 + if let image = image { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 80, height: 80) + .clipped() + .cornerRadius(8) + } else { + RoundedRectangle(cornerRadius: 8) + .fill(Color.bgG100) + .frame(width: 80, height: 80) + } + + // X 버튼 (우측 상단) + Button(action: { + action() + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(Color.iconG600) + .frame(width: 20, height: 20) + .padding(10) + } + .offset(x: 20, y: -20) + } + .padding(.top, 8) + .padding(.trailing, 8) + } + + init(image: UIImage? = nil, onDelete action: @escaping @MainActor () -> Void) { + self.image = image + self.action = action + } +} diff --git a/Community/Sources/Presentation/Write/CommunityWriteViewModel.swift b/Community/Sources/Presentation/Write/CommunityWriteViewModel.swift new file mode 100644 index 0000000..e8e71a7 --- /dev/null +++ b/Community/Sources/Presentation/Write/CommunityWriteViewModel.swift @@ -0,0 +1,375 @@ +// +// CommunityWriteViewModel.swift +// Community +// +// Created by 강동영 on 1/8/26. +// + +import Foundation +import SwiftUI +import PhotosUI +import Observation +import CommunityDomain +import Util + +public protocol CommunityWriteViewModelProtocol { + /// 선택된 이미지 목록 + var selectedImages: [SelectedImage] { get set } + + /// Photo picker 최대 선택 가능 갯수 + var maxSelectionCount: Int { get } + + /// 이미지 처리 중 상태 + var isProcessingImages: Bool { get set } + + /// 게시물 제출 중 상태 + var isSubmitting: Bool { get set } + + /// 이미지 크기 초과 알림 표시 여부 + var showImageSizeAlert: Bool { get set } + + /// 이미지 추가 가능 여부 + var canAddMoreImages: Bool { get } + + /// 이미지 카운터 텍스트 + var imageCountText: String { get } + + /// 제출 가능 여부 + var canSubmit: Bool { get } + + /// PhotosPicker에서 선택한 이미지 처리 + /// - Parameter items: 선택된 PhotosPickerItem 배열 + func handleImageSelection(_ items: [PhotosPickerItem]) async + + /// 이미지 삭제 (ID) + /// - Parameter id: 삭제할 이미지의 ID + func removeImage(id: UUID) + + func writeBoard( + title: String, + content: String, + category: BoardCategory + ) async -> Bool +} + +@Observable +public final class CommunityWriteViewModel: CommunityWriteViewModelProtocol { + // MARK: - Published State (auto-tracked by @Observable) + /// 선택된 이미지 목록 + public var selectedImages: [SelectedImage] = [] + + /// Photo picker 최대 선택 가능 갯수 + public var maxSelectionCount: Int { + maxImages - selectedImages.count + } + + /// 이미지 처리 중 상태 + public var isProcessingImages: Bool = false + + /// 게시물 제출 중 상태 + public var isSubmitting: Bool = false + + /// 에러 메시지 + public var errorMessage: String? = nil + + /// 이미지 크기 초과 알림 표시 여부 + public var showImageSizeAlert: Bool = false + + // MARK: - Constants + + /// 최대 이미지 개수 + private let maxImages: Int = 5 + + // MARK: - Dependencies + + private let createBoardUseCase: CreateBoardUseCase + + // MARK: - Computed Properties + + /// 이미지 추가 가능 여부 + public var canAddMoreImages: Bool { + selectedImages.count < maxImages + } + + /// 이미지 카운터 텍스트 + public var imageCountText: String { + "사진추가 (\(selectedImages.count)/\(maxImages))" + } + + /// 제출 가능 여부 + public var canSubmit: Bool { + !isSubmitting && !isProcessingImages + } + + // MARK: - Initialization + + public init(createBoardUseCase: CreateBoardUseCase) { + self.createBoardUseCase = createBoardUseCase + } + + // MARK: - Image Selection + + /// PhotosPicker에서 선택한 이미지 처리 + /// - Parameter items: 선택된 PhotosPickerItem 배열 + public func handleImageSelection(_ items: [PhotosPickerItem]) async { + guard canAddMoreImages else { + return + } + + isProcessingImages = true + errorMessage = nil + + // 사용 가능한 슬롯 계산 + let availableSlots = maxImages - selectedImages.count + let itemsToProcess = Array(items.prefix(availableSlots)) + + for item in itemsToProcess { + guard let imageData = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: imageData) else { + continue + } + + let originalSize = imageData.count + let fileName = "image_\(UUID().uuidString).jpg" + + // 크기 검증 (10MB) + if originalSize > ImageProcessor.maxFileSize { + // 처리 시도 + if let _ = ImageProcessor.process(image) { + // 처리 성공 + let selectedImage = SelectedImage( + image: image, + originalSize: originalSize, + fileName: fileName + ) + selectedImages.append(selectedImage) + } else { + // 처리 실패 + showImageSizeAlert = true + } + } else { + // 크기가 괜찮으면 바로 추가 + let selectedImage = SelectedImage( + image: image, + originalSize: originalSize, + fileName: fileName + ) + selectedImages.append(selectedImage) + } + } + + isProcessingImages = false + } + + // MARK: - Image Management + + /// 이미지 삭제 (ID) + /// - Parameter id: 삭제할 이미지의 ID + public func removeImage(id: UUID) { + selectedImages.removeAll { $0.id == id } + } + + // MARK: - Board Creation + + /// 게시물 생성 + /// - Parameters: + /// - title: 제목 + /// - content: 내용 + /// - category: 카테고리 + /// - Returns: 성공 여부 + public func writeBoard( + title: String, + content: String, + category: BoardCategory + ) async -> Bool { + guard canSubmit else { return false } + guard !title.isEmpty && !content.isEmpty else { + errorMessage = "제목과 내용을 입력해주세요" + return false + } + + isSubmitting = true + errorMessage = nil + + do { + let images = selectedImages.map { $0.image } + + _ = try await createBoardUseCase.execute( + title: title, + content: content, + category: category, + images: images + ) + + print("✅ Board created successfully with \(images.count) images") + isSubmitting = false + return true + + } catch { + errorMessage = "게시글 작성에 실패했습니다: \(error.localizedDescription)" + print("❌ Board creation error: \(error)") + isSubmitting = false + return false + } + } + + // MARK: - Reset + + /// 상태 초기화 + public func reset() { + selectedImages.removeAll() + errorMessage = nil + showImageSizeAlert = false + } +} + +@Observable +public final class UpdateBoardViewModel: CommunityWriteViewModelProtocol { + /// 최대 이미지 개수 + private let maxImages: Int = 5 + + // MARK: - Dependencies + + private let updateBoardUseCase: UpdateBoardUseCase + private let boardId: Int + + public init(boardId: Int, updateBoardUseCase: UpdateBoardUseCase) { + self.boardId = boardId + self.updateBoardUseCase = updateBoardUseCase + } + + public var selectedImages: [CommunityDomain.SelectedImage] = [] + + /// Photo picker 최대 선택 가능 갯수 + public var maxSelectionCount: Int { + maxImages - selectedImages.count + } + + /// 이미지 처리 중 상태 + public var isProcessingImages: Bool = false + + /// 게시물 제출 중 상태 + public var isSubmitting: Bool = false + + /// 에러 메시지 + public var errorMessage: String? = nil + + /// 이미지 크기 초과 알림 표시 여부 + public var showImageSizeAlert: Bool = false + + /// 이미지 추가 가능 여부 + public var canAddMoreImages: Bool { + selectedImages.count < maxImages + } + + /// 이미지 카운터 텍스트 + public var imageCountText: String { + "사진추가 (\(selectedImages.count)/\(maxImages))" + } + + /// 제출 가능 여부 + public var canSubmit: Bool { + !isSubmitting && !isProcessingImages + } + + public func handleImageSelection(_ items: [PhotosPickerItem]) async { + guard canAddMoreImages else { + return + } + + isProcessingImages = true + errorMessage = nil + + // 사용 가능한 슬롯 계산 + let availableSlots = maxImages - selectedImages.count + let itemsToProcess = Array(items.prefix(availableSlots)) + + for item in itemsToProcess { + guard let imageData = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: imageData) else { + continue + } + + let originalSize = imageData.count + let fileName = "image_\(UUID().uuidString).jpg" + + // 크기 검증 (10MB) + if originalSize > ImageProcessor.maxFileSize { + // 처리 시도 + if let _ = ImageProcessor.process(image) { + // 처리 성공 + let selectedImage = SelectedImage( + image: image, + originalSize: originalSize, + fileName: fileName + ) + selectedImages.append(selectedImage) + } else { + // 처리 실패 + showImageSizeAlert = true + } + } else { + // 크기가 괜찮으면 바로 추가 + let selectedImage = SelectedImage( + image: image, + originalSize: originalSize, + fileName: fileName + ) + selectedImages.append(selectedImage) + } + } + + isProcessingImages = false + } + + public func removeImage(id: UUID) { + selectedImages.removeAll { $0.id == id } + } + + public func writeBoard( + title: String, + content: String, + category: BoardCategory + ) async -> Bool { + guard canSubmit else { return false } + + guard !title.isEmpty && !content.isEmpty else { + errorMessage = "제목과 내용을 입력해주세요" + return false + } + + isSubmitting = true + errorMessage = nil + + do { + let images = selectedImages.map { $0.image } + + _ = try await updateBoardUseCase.execute( + boardId: boardId, + title: title, + content: content, + category: category, + images: images + ) + + print("✅ Board created successfully with \(images.count) images") + isSubmitting = false + return true + + } catch { + errorMessage = "게시글 작성에 실패했습니다: \(error.localizedDescription)" + print("❌ Board creation error: \(error)") + isSubmitting = false + return false + } + } + + // MARK: - Reset + + /// 상태 초기화 + public func reset() { + selectedImages.removeAll() + errorMessage = nil + showImageSizeAlert = false + } +} diff --git a/DI/Package.swift b/DI/Package.swift index 44180be..8b7c706 100644 --- a/DI/Package.swift +++ b/DI/Package.swift @@ -11,7 +11,6 @@ enum Config: String, CaseIterable { case intro = "Intro" case login = "Login" case myPage = "MyPage" - case community = "Community" var name: String { switch self { @@ -44,7 +43,6 @@ let package = Package( .package(name: "Intro", path: "../Intro"), .package(name: "Login", path: "../Login"), .package(name: "MyPage", path: "../MyPage"), - .package(name: "Community", path: "../Community"), ], targets: [ .target(name: Config.interface.name), @@ -79,13 +77,6 @@ let package = Package( .target(config: .app), .product(name: "MyPage", package: "MyPage"), ] - ), - .target( - name: Config.community.name, - dependencies: [ - .target(config: .app), - .product(name: "Community", package: "Community"), - ] ) ] ) diff --git a/DI/Sources/CommunityDI/CommunityDIContainer.swift b/DI/Sources/CommunityDI/CommunityDIContainer.swift deleted file mode 100644 index 3a355aa..0000000 --- a/DI/Sources/CommunityDI/CommunityDIContainer.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// CommunityDIContainer.swift -// Hambug -// -// Created by 강동영 on 10/17/25. -// - -import Foundation -import DataSources -import NetworkInterface -import NetworkImpl -import DIKit -import AppDI -import CommunityDomain -import CommunityData -import CommunityPresentation - -// MARK: - Community Assembly -struct CommunityAssembly: Assembly { - private let isMock: Bool - - init(isMock: Bool = false) { - self.isMock = isMock - } - - 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 { - return MockCommunityAPIClient() - } else { - return CommunityAPIClient(networkService: resolver.resolve(NetworkServiceInterface.self)) - } - } - - // Repository registration - container.register(CommunityRepositoryInterface.self) { resolver in - CommunityRepositoryImpl(apiClient: resolver.resolve(CommunityAPIClientInterface.self)) - } - - // UseCase registration - container.register(GetBoardsUseCaseInterface.self) { resolver in - GetBoardsUseCase(repository: resolver.resolve(CommunityRepositoryInterface.self)) - } - - // ViewModel registration - container.register(CommunityViewModel.self) { resolver in - CommunityViewModel(getBoardsUseCase: resolver.resolve(GetBoardsUseCaseInterface.self)) - } - } -} - -// MARK: - Community DI Container -public final class CommunityDIContainer { - - // MARK: - Properties - private let container: GenericDIContainer - - // MARK: - Initialization - public init(appContainer: AppDIContainer? = nil, isMock: Bool = false) { - // In mock mode: no parent (creates own NetworkService) - // In normal mode: use AppDIContainer as parent - if isMock { - self.container = GenericDIContainer(parent: nil) - } else { - let parent = appContainer ?? AppDIContainer.shared - self.container = GenericDIContainer(parent: parent.baseContainer) - } - - CommunityAssembly(isMock: isMock).assemble(container: container) - } - - // MARK: - Factory Methods - public func makeCommunityViewModel() -> CommunityViewModel { - return container.resolve(CommunityViewModel.self) - } - - public func resolve(_ type: T.Type) -> T { - return container.resolve(type) - } -} - -// MARK: - Mock Setup -private func setupURLProtocol() { - let boardsData: [[String: Any]] = [ - [ - "id": 1, - "imageURL": "https://example.com/image1.jpg", - "title": "첫 번째 게시글", - "nickName": "햄버거러버", - "createdAt": "2024-10-17T10:00:00.000Z", - "likeCount": "15", - "commnetCount": "3" - ], - [ - "id": 2, - "imageURL": "https://example.com/image2.jpg", - "title": "맛있는 햄버거 추천", - "nickName": "음식탐험가", - "createdAt": "2024-10-17T09:30:00.000Z", - "likeCount": "23", - "commnetCount": "7" - ] - ] - - let boardsResponse: [String: Any] = [ - "success": true, - "data": boardsData, - "message": "성공", - "code": 200 - ] - - let boardsResponseData = try! JSONSerialization.data(withJSONObject: boardsResponse, options: []) - - let boardDetailResponse: [String: Any] = [ - "id": 1, - "imageURL": "https://example.com/image1.jpg", - "title": "첫 번째 게시글", - "content": "이것은 첫 번째 게시글의 상세 내용입니다.", - "nickName": "햄버거러버", - "createdAt": "2024-10-17T10:00:00.000Z", - "likeCount": "15", - "commnetCount": "3" - ] - - let boardDetailResponseData = try! JSONSerialization.data(withJSONObject: boardDetailResponse, options: []) - - CommunityURLProtocol.successMock = [ - "/api/v1/boards": (200, boardsResponseData), - "/api/v1/boards?category=FREE_TALK": (200, boardsResponseData), - "/api/v1/boards?category=FRANCHISE": (200, boardsResponseData), - "/api/v1/boards?category=HANDMADE": (200, boardsResponseData), - "/api/v1/boards?category=RECOMMENDATION": (200, boardsResponseData) - ] -} diff --git a/DI/Sources/LoginDI/LoginDIContainer.swift b/DI/Sources/LoginDI/LoginDIContainer.swift index b3531e4..aedaa61 100644 --- a/DI/Sources/LoginDI/LoginDIContainer.swift +++ b/DI/Sources/LoginDI/LoginDIContainer.swift @@ -30,7 +30,8 @@ struct LoginAssembly: Assembly { container.register(LoginRepository.self) { resolver in LoginRepositoryImpl( networkService: resolver.resolve(NetworkServiceInterface.self), - tokenStorage: resolver.resolve(TokenStorage.self) + tokenStorage: resolver.resolve(TokenStorage.self), + userDefaultsManager: resolver.resolve(UserDefaultsManager.self) ) } diff --git a/Hambug.xcodeproj/project.pbxproj b/Hambug.xcodeproj/project.pbxproj index f0b6bd2..e9e8ccd 100644 --- a/Hambug.xcodeproj/project.pbxproj +++ b/Hambug.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ B513799B2EE2ED8F00DAF2F7 /* Common.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = B513799A2EE2ED8F00DAF2F7 /* Common.xcconfig */; }; B52285C92E88469C00678ECC /* Managers in Frameworks */ = {isa = PBXBuildFile; productRef = B52285C82E88469C00678ECC /* Managers */; }; B52286032E884E5B00678ECC /* DesignSystem in Frameworks */ = {isa = PBXBuildFile; productRef = B52286022E884E5B00678ECC /* DesignSystem */; }; + B53086D12F115A3800586850 /* SharedUI in Frameworks */ = {isa = PBXBuildFile; productRef = B53086D02F115A3800586850 /* SharedUI */; }; B54128772EF1CD3100CC4938 /* Login in Frameworks */ = {isa = PBXBuildFile; productRef = B54128762EF1CD3100CC4938 /* Login */; }; B5412A882EF277A200CC4938 /* NetworkImpl in Frameworks */ = {isa = PBXBuildFile; productRef = B5412A872EF277A200CC4938 /* NetworkImpl */; }; B5412A8A2EF277A200CC4938 /* NetworkInterface in Frameworks */ = {isa = PBXBuildFile; productRef = B5412A892EF277A200CC4938 /* NetworkInterface */; }; @@ -108,6 +109,7 @@ B5412A882EF277A200CC4938 /* NetworkImpl in Frameworks */, B54134FD2EF5B09D00CC4938 /* Community in Frameworks */, B5412A8A2EF277A200CC4938 /* NetworkInterface in Frameworks */, + B53086D12F115A3800586850 /* SharedUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -141,12 +143,12 @@ B513799A2EE2ED8F00DAF2F7 /* Common.xcconfig */, B5E822EB2EA2791900F3E10E /* Intro */, B52285C62E8844BD00678ECC /* Common */, - 915BC5FD2E3CBD5A0062B78E /* Projects */, 915BC5D42E3CB9B50062B78E /* Hambug */, 915BC5E32E3CB9B80062B78E /* HambugTests */, 915BC5ED2E3CB9B80062B78E /* HambugUITests */, B52285C72E88469C00678ECC /* Frameworks */, 915BC5D32E3CB9B50062B78E /* Products */, + B53086BD2F10FB9C00586850 /* Recovered References */, ); sourceTree = ""; }; @@ -167,6 +169,14 @@ name = Frameworks; sourceTree = ""; }; + B53086BD2F10FB9C00586850 /* Recovered References */ = { + isa = PBXGroup; + children = ( + 915BC5FD2E3CBD5A0062B78E /* Projects */, + ); + name = "Recovered References"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -200,6 +210,7 @@ B54134FE2EF5B0A400CC4938 /* Home */, B541357F2EF5B4CC00CC4938 /* KakaoLogin */, B5EDA8F72F0B9C0C002F72B9 /* DIKit */, + B53086D02F115A3800586850 /* SharedUI */, ); productName = Hambug; productReference = 915BC5D22E3CB9B50062B78E /* Hambug.app */; @@ -700,6 +711,10 @@ isa = XCSwiftPackageProductDependency; productName = DesignSystem; }; + B53086D02F115A3800586850 /* SharedUI */ = { + isa = XCSwiftPackageProductDependency; + productName = SharedUI; + }; B54128762EF1CD3100CC4938 /* Login */ = { isa = XCSwiftPackageProductDependency; productName = Login; diff --git a/Hambug/ContentView.swift b/Hambug/ContentView.swift index 221ae30..e58c5de 100644 --- a/Hambug/ContentView.swift +++ b/Hambug/ContentView.swift @@ -11,8 +11,9 @@ import HomePresentation import HomeDI import CommunityPresentation import CommunityDI -import MyPagePresentation -import MyPageDI +import SharedUI +//import MyPagePresentation +//import MyPageDI struct ContentView: View { @Environment(AppDIContainer.self) var appContainer @@ -25,22 +26,36 @@ struct ContentView: View { CommunityDIContainer(appContainer: appContainer) } - private var mypageDIContainer: MyPageDIContainer { - MyPageDIContainer(appContainer: appContainer) - } +// private var mypageDIContainer: MyPageDIContainer { +// MyPageDIContainer(appContainer: appContainer) +// } @State private var selectedTab: Int = 0 var body: some View { CustomTabView(selectedTab: $selectedTab) { - HomeView(viewModel: homeDIContainer.homeViewModel) + Group { + NavigationStack { + HomeView(viewModel: homeDIContainer.homeViewModel) + } .tag(0) - - CommunityView(viewModel: communityDIContainer.makeCommunityViewModel()) + + NavigationStack { + CommunityView( + viewModel: communityDIContainer.makeCommunityViewModel(), + writeFactory: communityDIContainer, + detailFactory: communityDIContainer, + updateFactory: communityDIContainer, + reportFactory: communityDIContainer + ) + } .tag(1) - - MyPageView(viewModel: mypageDIContainer.makeMyPageViewModel()) - .tag(2) + + + Text("MyPage") + .tag(2) + } + .toolbar(.hidden, for: .tabBar) } } } diff --git a/Home/Package.swift b/Home/Package.swift index 17ef8eb..8e82dab 100644 --- a/Home/Package.swift +++ b/Home/Package.swift @@ -31,7 +31,7 @@ let package = Package( ], dependencies: [ .package(name: "Common", path: "../Common"), - .package(name: "DIKit", path: "../DI"), + .package(name: "DI", path: "../DI"), .package(name: "Infrastructure", path: "../Infrastructure"), ], targets: [ @@ -41,8 +41,8 @@ let package = Package( .target(config: .domain), .target(config: .data), .target(config: .presentation), - .product(name: "DI", package: "DIKit"), - .product(name: "AppDI", package: "DIKit"), + .product(name: "DI", package: "DI"), + .product(name: "AppDI", package: "DI"), .product(name: "Managers", package: "Common"), .product(name: "DataSources", package: "Common"), ], @@ -60,12 +60,13 @@ let package = Package( ], ), - // Presentation: Domain, DesignSystem에 의존 + // Presentation: Domain, DesignSystem, SharedUI에 의존 .target( config: .presentation, dependencies: [ .target(config: .domain), .product(name: "DesignSystem", package: "Common"), + .product(name: "SharedUI", package: "Common"), ], ), ] diff --git a/Home/Sources/Data/DTO/TrendingPostResponse.swift b/Home/Sources/Data/DTO/TrendingPostResponse.swift index 9262b04..6d3fe51 100644 --- a/Home/Sources/Data/DTO/TrendingPostResponse.swift +++ b/Home/Sources/Data/DTO/TrendingPostResponse.swift @@ -7,6 +7,7 @@ import Foundation import HomeDomain +import Util public struct TrendingPostResponse: Decodable, Sendable { public let id: Int @@ -25,23 +26,23 @@ public struct TrendingPostResponse: Decodable, Sendable { } extension TrendingPostResponse { - public func toDomain() -> TrendingPost { - let formatter = ISO8601DateFormatter() - - return TrendingPost( - id: id, - title: title, - content: content, - category: category, - imageUrls: imageUrls, - authorNickname: authorNickname, - authorId: authorId, - createdAt: formatter.date(from: createdAt) ?? Date(), - updatedAt: formatter.date(from: updatedAt) ?? Date(), - viewCount: viewCount, - likeCount: likeCount, - commentCount: commentCount, - isLiked: isLiked - ) - } + public func toDomain() -> TrendingPost { + let formatter = DateFormatter.iso8601WithMicroseconds + + return TrendingPost( + id: id, + title: title, + content: content, + category: category, + imageUrls: imageUrls, + authorNickname: authorNickname, + authorId: authorId, + createdAt: formatter.date(from: createdAt) ?? Date(), + updatedAt: formatter.date(from: updatedAt) ?? Date(), + viewCount: viewCount, + likeCount: likeCount, + commentCount: commentCount, + isLiked: isLiked + ) + } } diff --git a/Home/Sources/Presentation/HomeView.swift b/Home/Sources/Presentation/HomeView.swift index 5f7f8c7..3e1a232 100644 --- a/Home/Sources/Presentation/HomeView.swift +++ b/Home/Sources/Presentation/HomeView.swift @@ -8,6 +8,7 @@ import SwiftUI import HomeDomain import DesignSystem +import SharedUI public struct HomeView: View { @@ -18,7 +19,6 @@ public struct HomeView: View { } public var body: some View { - ZStack { Color.bgG100 @@ -40,7 +40,8 @@ public struct HomeView: View { } .padding(.top, 50) } - .ignoresSafeArea() + .ignoresSafeArea(.container, edges: .top) + .tabBarHidden(false) } } diff --git a/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift b/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift index 7a513f2..e92fc2d 100644 --- a/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift +++ b/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift @@ -135,7 +135,9 @@ public final class AuthInterceptor: RequestInterceptor { private func handleLogout() { try? tokenManager.clear() - NotificationCenter.default.post(name: .userDidLogout, object: nil) + Task { @MainActor in + NotificationCenter.default.post(name: .userDidLogout, object: nil) + } } } diff --git a/Infrastructure/Sources/NetworkImpl/NetworkLogger.swift b/Infrastructure/Sources/NetworkImpl/NetworkLogger.swift index e4dd4a2..2249baf 100644 --- a/Infrastructure/Sources/NetworkImpl/NetworkLogger.swift +++ b/Infrastructure/Sources/NetworkImpl/NetworkLogger.swift @@ -9,7 +9,7 @@ import Foundation // MARK: - Logger #if DEBUG -public final class NetworkLogger { +public final class NetworkLogger: Sendable { public init() {} public func requestLogger(request: URLRequest) { diff --git a/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift b/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift index 1d62c81..3fe9f8d 100644 --- a/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift +++ b/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift @@ -8,6 +8,7 @@ import Foundation import Combine import NetworkInterface +import UIKit import Alamofire @@ -84,7 +85,80 @@ public final class NetworkServiceImpl: NetworkServiceInterface { .eraseToAnyPublisher() } } - + + public func uploadMultipart( + _ endpoint: any Endpoint, + images: [UIImage], + responseType: T.Type + ) -> AnyPublisher { + do { + guard let url = endpoint.createURL() else { + throw NetworkError.invalidURL + } + + return session.upload( + multipartFormData: { multipartFormData in + // 1. JSON body의 텍스트 필드 추가 (title, content, category) + if let body = endpoint.body, + let jsonObject = try? JSONSerialization.jsonObject(with: body) as? [String: Any] { + for (key, value) in jsonObject { + if let stringValue = "\(value)".data(using: .utf8) { + multipartFormData.append(stringValue, withName: key) + } + } + } + + // 2. 이미지 파일 추가 + for (index, image) in images.enumerated() { + // ImageProcessor는 Util 모듈에 있으므로 여기서는 기본 압축 사용 + if let imageData = image.jpegData(compressionQuality: 0.85) { + let fileName = "image_\(index)_\(UUID().uuidString).jpg" + multipartFormData.append( + imageData, + withName: "images", + fileName: fileName, + mimeType: "image/jpeg" + ) + } + } + }, + to: url, + method: alamofireMethod(from: endpoint.method), + headers: alamofireHeaders(from: endpoint.headers) + ) + .validate() + .publishData() + .tryMap { [weak self] response in + guard let self = self else { throw NetworkError.networkError(NSError()) } + + if let error = response.error { + throw self.mapAlamofireError(error) + } + + guard let data = response.data else { + throw NetworkError.noData + } + +#if DEBUG + if let httpResponse = response.response { + self.logger?.responseLogger(response: httpResponse, data: data) + } +#endif + + return data + } + .decode(type: T.self, decoder: decoder) + .mapError { error in + self.mapError(error) + } + .eraseToAnyPublisher() + + } catch { + return Fail(error: mapError(error)) + .eraseToAnyPublisher() + } + } + // MARK: - Private Methods private func alamofireMethod(from httpMethod: NetworkInterface.HTTPMethod) -> Alamofire.HTTPMethod { switch httpMethod { diff --git a/Infrastructure/Sources/NetworkInterface/EndPointEncoder.swift b/Infrastructure/Sources/NetworkInterface/EndPointEncoder.swift new file mode 100644 index 0000000..b38dc94 --- /dev/null +++ b/Infrastructure/Sources/NetworkInterface/EndPointEncoder.swift @@ -0,0 +1,25 @@ +// +// EndPointEncoder.swift +// Infrastructure +// +// Created by 강동영 on 1/9/26. +// + +import Foundation + +public protocol EndPointEncoder { + func encode(_ value: Encodable) -> [String: Any] +} + +public struct DefaultQueryEncoder: EndPointEncoder { + public init() {} + public func encode(_ value: Encodable) -> [String: Any] { + let encoder = JSONEncoder() + guard + let data = try? encoder.encode(value), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return [:] } + + return object + } +} diff --git a/Infrastructure/Sources/NetworkInterface/Endpoint.swift b/Infrastructure/Sources/NetworkInterface/Endpoint.swift index 9c2ac02..f096c68 100644 --- a/Infrastructure/Sources/NetworkInterface/Endpoint.swift +++ b/Infrastructure/Sources/NetworkInterface/Endpoint.swift @@ -23,10 +23,13 @@ public protocol Endpoint: Sendable { var method: HTTPMethod { get } var headers: [String: String] { get } var queryParameters: [String: Any] { get } + var queryEncoder: EndPointEncoder { get } var body: Data? { get } } public extension Endpoint { + var queryEncoder: EndPointEncoder { DefaultQueryEncoder() } + var defaultHeaders: [String: String] { [ "Content-Type": "application/json", diff --git a/Infrastructure/Sources/NetworkInterface/NetworkConfig.swift b/Infrastructure/Sources/NetworkInterface/NetworkConfig.swift index 5f35b38..7b5e44a 100644 --- a/Infrastructure/Sources/NetworkInterface/NetworkConfig.swift +++ b/Infrastructure/Sources/NetworkInterface/NetworkConfig.swift @@ -8,7 +8,6 @@ import Foundation public struct NetworkConfig { - public static let baseURL: String = Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as! String - + public static let baseURL: String = (Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as? String) ?? "" private init() {} } diff --git a/Infrastructure/Sources/NetworkInterface/NetworkServiceInterface.swift b/Infrastructure/Sources/NetworkInterface/NetworkServiceInterface.swift index 24a31de..1d2da3e 100644 --- a/Infrastructure/Sources/NetworkInterface/NetworkServiceInterface.swift +++ b/Infrastructure/Sources/NetworkInterface/NetworkServiceInterface.swift @@ -7,8 +7,15 @@ import Foundation import Combine +import UIKit // MARK: - Network Interface Protocol -public protocol NetworkServiceInterface { +public protocol NetworkServiceInterface: Sendable { func request(_ endpoint: any Endpoint, responseType: T.Type) -> AnyPublisher + + func uploadMultipart( + _ endpoint: any Endpoint, + images: [UIImage], + responseType: T.Type + ) -> AnyPublisher } diff --git a/Login/Sources/Data/LoginRepositoryImpl.swift b/Login/Sources/Data/LoginRepositoryImpl.swift index ef60f15..5a60fde 100644 --- a/Login/Sources/Data/LoginRepositoryImpl.swift +++ b/Login/Sources/Data/LoginRepositoryImpl.swift @@ -10,14 +10,21 @@ import DataSources import LoginDomain import NetworkInterface import Util +import Managers public final class LoginRepositoryImpl: LoginRepository { private let networkService: NetworkServiceInterface private let tokenStorage: TokenStorage + private let userDefaultsManager: UserDefaultsManager - public init(networkService: NetworkServiceInterface, tokenStorage: TokenStorage) { + public init( + networkService: NetworkServiceInterface, + tokenStorage: TokenStorage, + userDefaultsManager: UserDefaultsManager + ) { self.networkService = networkService self.tokenStorage = tokenStorage + self.userDefaultsManager = userDefaultsManager } public func login(request: SocialLoginRequest) async throws { @@ -42,7 +49,12 @@ public final class LoginRepositoryImpl: LoginRepository { accessToken: apiResponse.data.token.accessToken, refreshToken: apiResponse.data.token.refreshToken ) + + // Save user ID to UserDefaults + userDefaultsManager.currentUserId = apiResponse.data.user.userId + print("✅ Tokens saved to Keychain") + print("✅ User ID saved: \(apiResponse.data.user.userId)") print("✅ accessToken: \(apiResponse.data.token.accessToken,)") print("✅ refreshToken \(apiResponse.data.token.refreshToken)") } diff --git a/Projects/Components/CustomTabView.swift b/Projects/Components/CustomTabView.swift deleted file mode 100644 index 6e3acda..0000000 --- a/Projects/Components/CustomTabView.swift +++ /dev/null @@ -1,154 +0,0 @@ -// -// CustomTabView.swift -// Hambug -// -// Created by 강동영 on 12/12/25. -// - -import DesignSystem -import SwiftUI - -struct CustomTabView: View { - private let tabConfig: [HambugTab] - @ViewBuilder let content: Content - - @Binding private var selectedTab: Int - - var body: some View { - VStack(spacing: 0) { - // Content area - TabView(selection: $selectedTab) { - content - } - .tabViewStyle(.page(indexDisplayMode: .never)) - - // Custom Tab Bar - HStack(spacing: 0) { - ForEach(tabConfig) { tab in - TabBarItem( - config: tab, - isSelected: selectedTab == tab.id - ) { - selectedTab = tab.id - } - } - } - .frame(height: UIScreen.main.bounds.height * 0.11) - .background( - Color.white - .cornerRadius(30, corners: [.topLeft, .topRight]) - ) - .shadow(color: .black.opacity(0.1), radius: 10, y: -5) - } - .ignoresSafeArea(.all, edges: .vertical) - } - - init( - selectedTab: Binding, - tabConfig: [HambugTab] = HambugTab.allCases, - @ViewBuilder content: () -> Content, - ) { - self._selectedTab = selectedTab - self.tabConfig = tabConfig - self.content = content() - } -} - -// MARK: CustomTabView 의 HambugTab -extension CustomTabView { - enum HambugTab: Int, CaseIterable, Identifiable { - case home = 0 - case community = 1 - case myPage = 2 - - var id: Int { rawValue } - - var iconName: String { - switch self { - case .home: - "tab_home" - case .community: - "tab_community" - case .myPage: - "tab_user" - } - } - - var title: String { - switch self { - case .home: - "홈" - case .community: - "커뮤니티" - case .myPage: - "마이" - } - } - } -} - -// MARK: TabBarItem -extension CustomTabView { - fileprivate struct TabBarItem: View { - private let config: HambugTab - private let isSelected: Bool - private let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(spacing: 8) { - Image(config.iconName) - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 21, height: 21) - - Text(config.title) - .pretendard(.caption(.emphasis)) - } - .foregroundColor(isSelected ? .primaryHambugRed : .borderG400) - .frame(maxWidth: .infinity) - } - } - - init( - config: HambugTab, - isSelected: Bool, - action: @escaping () -> Void - ) { - self.config = config - self.isSelected = isSelected - self.action = action - } - } -} - - -// MARK: Style 관련 객체들 -fileprivate extension View { - func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { - clipShape(RoundedCorner(radius: radius, corners: corners)) - } -} - -struct RoundedCorner: Shape { - var radius: CGFloat = .infinity - var corners: UIRectCorner = .allCorners - - func path(in rect: CGRect) -> SwiftUI.Path { - let path = UIBezierPath( - roundedRect: rect, - byRoundingCorners: corners, - cornerRadii: CGSize(width: radius, height: radius) - ) - return SwiftUI.Path(path.cgPath) - } -} - -#Preview { - @Previewable @State var selectedTab = 0 - - CustomTabView(selectedTab: $selectedTab) { - Text("Preview") - } -}