From 8e43450c9f86676925a9f729c16174ce9fc7a6c7 Mon Sep 17 00:00:00 2001 From: kangddong Date: Mon, 12 Jan 2026 01:29:40 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20Board=20Edit,=20Report=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ABOUT_MODULE.MD | 687 ++++++++++++++++ CLAUDME.MD | 741 ++++++++++++++++++ .../Sources/DI/CommunityDIContainer.swift | 45 +- Community/Sources/Data/API/CommunityAPI.swift | 8 +- .../Sources/Data/API/CommunityAPIClient.swift | 54 ++ .../Sources/Data/DTO/ReportRequest.swift | 37 + .../CommunityRepositoryImpl.swift | 12 +- .../Sources/Domain/Entities/Report.swift | 27 + .../Repositories/CommunityRepository.swift | 12 + .../UseCases/UpdateBoardUseCaseImpl.swift | 46 ++ .../Community/CommunityView.swift | 74 +- .../Presentation/Detail/CommunityDetail.swift | 50 +- .../Detail/CommunityDetailViewModel.swift | 13 - .../CommunityReportView.swift | 75 +- .../Report/CommunityReportViewModel.swift | 58 ++ .../Write/CommunityWriteView.swift | 53 +- .../Write/CommunityWriteViewModel.swift | 211 ++++- DI/Sources/LoginDI/LoginDIContainer.swift | 3 +- Hambug/ContentView.swift | 10 +- Home/Sources/Presentation/HomeView.swift | 1 - .../NetworkServiceInterface.swift | 9 +- 21 files changed, 2076 insertions(+), 150 deletions(-) create mode 100644 ABOUT_MODULE.MD create mode 100644 CLAUDME.MD create mode 100644 Community/Sources/Data/DTO/ReportRequest.swift create mode 100644 Community/Sources/Domain/Entities/Report.swift create mode 100644 Community/Sources/Domain/UseCases/UpdateBoardUseCaseImpl.swift rename Community/Sources/Presentation/{Views/Write => Report}/CommunityReportView.swift (57%) create mode 100644 Community/Sources/Presentation/Report/CommunityReportViewModel.swift 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/Community/Sources/DI/CommunityDIContainer.swift b/Community/Sources/DI/CommunityDIContainer.swift index 704685f..5bb4adc 100644 --- a/Community/Sources/DI/CommunityDIContainer.swift +++ b/Community/Sources/DI/CommunityDIContainer.swift @@ -24,6 +24,10 @@ struct CommunityWriteAssembly: Assembly { 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 @@ -97,10 +101,6 @@ struct CommunityAssembly: Assembly { userDefaultsManager: resolver.resolve(UserDefaultsManager.self) ) } - -// container.register(CommunityReportViewModel.self) { resolver in -// CommunityReportViewModel.init -// } } } @@ -122,25 +122,11 @@ public final class CommunityDIContainer { public func makeCommunityViewModel() -> CommunityViewModel { return container.resolve(CommunityViewModel.self) } - - @MainActor - public func makeCommunityDetailViewModel() -> CommunityDetailViewModel { - return container.resolve(CommunityDetailViewModel.self) - } - - @MainActor - public func makeCommunityReportViewModel() -> CommunityReportViewModel { - return container.resolve(CommunityReportViewModel.self) - } - - public func resolve(_ type: T.Type) -> T { - return container.resolve(type) - } } extension CommunityDIContainer: CommunityWriteFactory { - public func makeWriteViewModel() -> CommunityWriteViewModel { - return container.resolve(CommunityWriteViewModel.self) + public func makeWriteViewModel() -> any CommunityWriteViewModelProtocol { + container.resolve(CommunityWriteViewModel.self) } } @@ -150,6 +136,25 @@ extension CommunityDIContainer: CommunityDetailFactory { 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]] = [ diff --git a/Community/Sources/Data/API/CommunityAPI.swift b/Community/Sources/Data/API/CommunityAPI.swift index 09dc96a..ae9c461 100644 --- a/Community/Sources/Data/API/CommunityAPI.swift +++ b/Community/Sources/Data/API/CommunityAPI.swift @@ -15,6 +15,8 @@ public enum BoardEndpoint: Endpoint { 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) @@ -31,6 +33,8 @@ public enum BoardEndpoint: Endpoint { switch self { 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: @@ -62,9 +66,9 @@ public enum BoardEndpoint: Endpoint { return .GET case .createBoard, .createComment, .toggleLike, .report: return .POST - case .updateComment: + case .updateBoard, .updateComment: return .PUT - case .deleteComment: + case .deleteBoard, .deleteComment: return .DELETE } } diff --git a/Community/Sources/Data/API/CommunityAPIClient.swift b/Community/Sources/Data/API/CommunityAPIClient.swift index c6b8b9a..18b6cbe 100644 --- a/Community/Sources/Data/API/CommunityAPIClient.swift +++ b/Community/Sources/Data/API/CommunityAPIClient.swift @@ -24,6 +24,16 @@ public protocol CommunityAPIClientInterface: Sendable { 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 @@ -108,6 +118,50 @@ public final class CommunityAPIClient: CommunityAPIClientInterface { .eraseToAnyPublisher() } } + + 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 + ) + .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 { 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 49b9a31..f6be76a 100644 --- a/Community/Sources/Data/Repositories/CommunityRepositoryImpl.swift +++ b/Community/Sources/Data/Repositories/CommunityRepositoryImpl.swift @@ -63,15 +63,17 @@ public final class CommunityRepositoryImpl: CommunityRepository { .mapError { $0 as Error } .async() } - - public func createBoard( + + public func updateBoard( + boardId: Int, title: String, content: String, - category: BoardCategory, + category: CommunityDomain.BoardCategory, images: [UIImage] - ) async throws -> Board { + ) async throws -> CommunityDomain.Board { return try await apiClient - .createBoard( + .updateBoard( + boardId: boardId, title: title, content: content, category: category.rawValue, 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/Repositories/CommunityRepository.swift b/Community/Sources/Domain/Repositories/CommunityRepository.swift index a81bdb3..da53386 100644 --- a/Community/Sources/Domain/Repositories/CommunityRepository.swift +++ b/Community/Sources/Domain/Repositories/CommunityRepository.swift @@ -23,6 +23,18 @@ public protocol CommunityRepository: Sendable { 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 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/Community/CommunityView.swift b/Community/Sources/Presentation/Community/CommunityView.swift index ae9ac48..fcddd8e 100644 --- a/Community/Sources/Presentation/Community/CommunityView.swift +++ b/Community/Sources/Presentation/Community/CommunityView.swift @@ -11,24 +11,32 @@ import CommunityDomain import SharedUI public protocol CommunityWriteFactory { - func makeWriteViewModel() -> CommunityWriteViewModel + func makeWriteViewModel() -> CommunityWriteViewModelProtocol +} + public protocol CommunityDetailFactory { func makeDetailViewModel() -> CommunityDetailViewModel } public struct CommunityView: View { @State private var viewModel: CommunityViewModel - private let factory: CommunityWriteFactory + private let writeFactory: CommunityWriteFactory private let detailFactory: CommunityDetailFactory + private let updateFactory: UpdateBoardFactory + private let reportFactory: ReportBoardFactory public init( viewModel: CommunityViewModel, - factory: CommunityWriteFactory + writeFactory: CommunityWriteFactory, detailFactory: CommunityDetailFactory, + updateFactory: UpdateBoardFactory, + reportFactory: ReportBoardFactory ) { self._viewModel = State(initialValue: viewModel) - self.factory = factory + self.writeFactory = writeFactory self.detailFactory = detailFactory + self.updateFactory = updateFactory + self.reportFactory = reportFactory } public var body: some View { @@ -66,12 +74,18 @@ public struct CommunityView: View { if viewModel.isListView { CommunityListView( boards: viewModel.filteredBoards, - detailFactory: detailFactory + detailFactory: detailFactory, + updateFactory: updateFactory, + reportFactory: reportFactory, + viewModel: viewModel ) } else { CommunityFeedView( boards: viewModel.filteredBoards, - detailFactory: detailFactory + detailFactory: detailFactory, + updateFactory: updateFactory, + reportFactory: reportFactory, + viewModel: viewModel ) } } @@ -86,7 +100,7 @@ public struct CommunityView: View { Spacer() NavigationLink( destination: CommunityWriteView( - viewModel: factory.makeWriteViewModel() + viewModel: writeFactory.makeWriteViewModel() ) ) { Color.bgPencil @@ -179,7 +193,25 @@ fileprivate struct CommunityFilterChip: 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) { @@ -187,7 +219,9 @@ struct CommunityListView: View { NavigationLink( destination: CommunityDetailView( viewModel: detailFactory.makeDetailViewModel(), - boardId: board.id + boardId: board.id, + updateFactory: updateFactory, + reportFactory: reportFactory )) { CommunityPostListCard(board: board) } @@ -223,16 +257,36 @@ struct CommunityListView: View { // 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 ForEach(Array(boards.enumerated()), id: \.element.id) { index, board in NavigationLink( destination: CommunityDetailView( viewModel: detailFactory.makeDetailViewModel(), - boardId: board.id + boardId: board.id, + updateFactory: updateFactory, + reportFactory: reportFactory ) ) { CommunityPostFeedCard(board: board) diff --git a/Community/Sources/Presentation/Detail/CommunityDetail.swift b/Community/Sources/Presentation/Detail/CommunityDetail.swift index 589a12e..42ce982 100644 --- a/Community/Sources/Presentation/Detail/CommunityDetail.swift +++ b/Community/Sources/Presentation/Detail/CommunityDetail.swift @@ -11,7 +11,10 @@ import CommunityDomain import SharedUI public protocol UpdateBoardFactory { - func makeViewModel() -> CommunityWriteViewModelProtocol + func makeViewModel(boardId: Int) -> CommunityWriteViewModelProtocol +} +public protocol ReportBoardFactory { + func makeViewModel(req: ReportRequest) -> CommunityReportViewModel } public struct CommunityDetailView: View { @@ -37,15 +40,18 @@ public struct CommunityDetailView: View { private let boardId: Int private let updateFactory: UpdateBoardFactory + private let reportFactory: ReportBoardFactory public init( viewModel: CommunityDetailViewModel, boardId: Int, - updateFactory: UpdateBoardFactory + updateFactory: UpdateBoardFactory, + reportFactory: ReportBoardFactory, ) { _viewModel = State(initialValue: viewModel) self.boardId = boardId self.updateFactory = updateFactory + self.reportFactory = reportFactory } public var body: some View { @@ -125,8 +131,15 @@ public struct CommunityDetailView: View { if let currentUserId = viewModel.currentUserId, Int64(comment.authorId) != currentUserId { NavigationLink( - // TODO: CommunityReportView 추가 - destination: Text("신고") + destination: CommunityReportView( + viewModel: reportFactory.makeViewModel( + req: CommunityDomain.ReportRequest.init( + targetId: comment.id, + targetType: .comment, + reason: "" + ) + ) + ) ) { Text("신고") } @@ -141,7 +154,7 @@ public struct CommunityDetailView: View { Int64(authorId) == currentUserId { NavigationLink( destination: CommunityWriteView( - viewModel: updateFactory.makeViewModel(), + viewModel: updateFactory.makeViewModel(boardId: boardId), title: viewModel.board?.title ?? "", content: viewModel.board?.content ?? "" ) @@ -159,9 +172,18 @@ public struct CommunityDetailView: View { if let currentUserId = viewModel.currentUserId, let authorId = viewModel.board?.authorId, Int64(authorId) != currentUserId { - Button("신고") { - reportTargetId = boardId - reportTargetType = .board + NavigationLink( + destination: CommunityReportView( + viewModel: reportFactory.makeViewModel( + req: CommunityDomain.ReportRequest.init( + targetId: boardId, + targetType: .board, + reason: "" + ) + ) + ) + ) { + Text("신고") } } @@ -446,18 +468,6 @@ public struct CommunityDetailView: View { ) } -// Button { -// Task { -// if let targetId = reportTargetId, let targetType = reportTargetType { -// await viewModel.reportContent(targetId: targetId, targetType: targetType, reason: reportReason) -// reportReason = "" -// reportTargetId = nil -// reportTargetType = nil -// } -// } - - - private func timeAgoDisplay(_ date: Date) -> String { let now = Date() let timeInterval = now.timeIntervalSince(date) diff --git a/Community/Sources/Presentation/Detail/CommunityDetailViewModel.swift b/Community/Sources/Presentation/Detail/CommunityDetailViewModel.swift index 9fb9819..a279c32 100644 --- a/Community/Sources/Presentation/Detail/CommunityDetailViewModel.swift +++ b/Community/Sources/Presentation/Detail/CommunityDetailViewModel.swift @@ -229,17 +229,4 @@ public final class CommunityDetailViewModel { print("❌ Toggle like error: \(error)") } } - - // MARK: - Report Methods - public func reportContent(targetId: Int, targetType: ReportTargetType, reason: String) async { - let request = ReportRequest(targetId: targetId, targetType: targetType, reason: reason) - - do { - try await reportContentUseCase.execute(request: request) - print("✅ Content reported") - } catch { - errorMessage = error.localizedDescription - print("❌ Report 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/Write/CommunityWriteView.swift b/Community/Sources/Presentation/Write/CommunityWriteView.swift index 60e6117..3f27768 100644 --- a/Community/Sources/Presentation/Write/CommunityWriteView.swift +++ b/Community/Sources/Presentation/Write/CommunityWriteView.swift @@ -9,34 +9,41 @@ import SwiftUI import DesignSystem import PhotosUI import CommunityDomain +import SharedUI public struct CommunityWriteView: View { @Environment(\.dismiss) private var dismiss - @State private var selectedCategory: Category = .자유잡담 + @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 - @StateObject private var keyboardObserver = KeyboardObserver() @FocusState private var focusedField: Field? - @State private var viewModel: CommunityWriteViewModel + @State private var photosPickerItems: [PhotosPickerItem] = [] - private let maxCharacterCount = 300 + private let maxCharacterCount: Int - public init(viewModel: CommunityWriteViewModel) { + 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 Category: String, CaseIterable { - case 자유잡담 = "자유잡담" - case 프랜차이즈 = "프랜차이즈" - case 수제버거 = "수제버거" - case 맛집추천 = "맛집추천" - } - + enum Field: Hashable { case title case content @@ -78,10 +85,10 @@ public struct CommunityWriteView: View { ) { focusedField = nil Task { - let success = await viewModel.createBoard( + let success = await viewModel.writeBoard( title: title, content: content, - category: selectedCategory.toBoardCategory() + category: selectedCategory ) if success { dismiss() @@ -99,6 +106,7 @@ public struct CommunityWriteView: View { } message: { Text("이미지 크기가 너무 큽니다. 다른 이미지를 선택해주세요.") } + .tabBarHidden(true) } .navigationBarHidden(true) } @@ -135,11 +143,11 @@ public struct CommunityWriteView: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - ForEach(Category.allCases, id: \.self) { category in + ForEach(BoardCategory.allCases, id: \.self) { category in Button { selectedCategory = category } label: { - Text(category.rawValue) + Text(category.displayName) .pretendard(.caption(.emphasis)) .foregroundColor(selectedCategory == category ? Color.primaryHambugRed : .textG600) .padding(.horizontal, 10) @@ -204,7 +212,7 @@ public struct CommunityWriteView: View { private var addImageButtonSection: some View { PhotosPicker( selection: $photosPickerItems, - maxSelectionCount: viewModel.maxImages - viewModel.selectedImages.count, + maxSelectionCount: viewModel.maxSelectionCount, matching: .images ) { Label(viewModel.imageCountText, systemImage: "camera") @@ -226,17 +234,6 @@ public struct CommunityWriteView: View { } } -extension CommunityWriteView.Category { - func toBoardCategory() -> BoardCategory { - switch self { - case .자유잡담: return .freeTalk - case .프랜차이즈: return .franchise - case .수제버거: return .handmade - case .맛집추천: return .recommendation - } - } -} - //#Preview { // let diContainer = CommunityDI.CommunityDIContainer() // CommunityWriteView(viewModel: diContainer.makeCommunityWriteViewModel()) diff --git a/Community/Sources/Presentation/Write/CommunityWriteViewModel.swift b/Community/Sources/Presentation/Write/CommunityWriteViewModel.swift index b66d9d7..e8e71a7 100644 --- a/Community/Sources/Presentation/Write/CommunityWriteViewModel.swift +++ b/Community/Sources/Presentation/Write/CommunityWriteViewModel.swift @@ -12,14 +12,57 @@ import Observation import CommunityDomain import Util -@Observable -public final class CommunityWriteViewModel { +public protocol CommunityWriteViewModelProtocol { + /// 선택된 이미지 목록 + var selectedImages: [SelectedImage] { get set } + + /// Photo picker 최대 선택 가능 갯수 + var maxSelectionCount: Int { get } + + /// 이미지 처리 중 상태 + var isProcessingImages: Bool { get set } + + /// 게시물 제출 중 상태 + var isSubmitting: Bool { get set } - // MARK: - Published State (auto-tracked by @Observable) + /// 이미지 크기 초과 알림 표시 여부 + 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 @@ -35,7 +78,7 @@ public final class CommunityWriteViewModel { // MARK: - Constants /// 최대 이미지 개수 - public let maxImages: Int = 5 + private let maxImages: Int = 5 // MARK: - Dependencies @@ -120,13 +163,6 @@ public final class CommunityWriteViewModel { // MARK: - Image Management - /// 이미지 삭제 (인덱스) - /// - Parameter index: 삭제할 이미지의 인덱스 - public func removeImage(at index: Int) { - guard index < selectedImages.count else { return } - selectedImages.remove(at: index) - } - /// 이미지 삭제 (ID) /// - Parameter id: 삭제할 이미지의 ID public func removeImage(id: UUID) { @@ -141,7 +177,7 @@ public final class CommunityWriteViewModel { /// - content: 내용 /// - category: 카테고리 /// - Returns: 성공 여부 - public func createBoard( + public func writeBoard( title: String, content: String, category: BoardCategory @@ -186,3 +222,154 @@ public final class CommunityWriteViewModel { 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/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/ContentView.swift b/Hambug/ContentView.swift index e9bef31..271a515 100644 --- a/Hambug/ContentView.swift +++ b/Hambug/ContentView.swift @@ -35,13 +35,17 @@ struct ContentView: View { var body: some View { CustomTabView(selectedTab: $selectedTab) { Group { - HomeView(viewModel: homeDIContainer.homeViewModel) - .tag(0) + NavigationStack { + HomeView(viewModel: homeDIContainer.homeViewModel) + } + .tag(0) NavigationStack { CommunityView( viewModel: communityDIContainer.makeCommunityViewModel(), - detailFactory: communityDIContainer + writeFactory: communityDIContainer, + detailFactory: communityDIContainer, + updateFactory: communityDIContainer ) } .tag(1) diff --git a/Home/Sources/Presentation/HomeView.swift b/Home/Sources/Presentation/HomeView.swift index df2fbad..fb55503 100644 --- a/Home/Sources/Presentation/HomeView.swift +++ b/Home/Sources/Presentation/HomeView.swift @@ -19,7 +19,6 @@ public struct HomeView: View { } public var body: some View { - ZStack { Color.bgG100 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 } From cd4a0edb11b9ce28c1f2f50bed5fae9c9ba9c4bd Mon Sep 17 00:00:00 2001 From: kangddong Date: Mon, 12 Jan 2026 02:30:41 +0900 Subject: [PATCH 2/8] =?UTF-8?q?design(add):HambugCommonAlertView=20?= =?UTF-8?q?=ED=8E=B8=EC=9D=98=20=EC=83=9D=EC=84=B1=EC=9E=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DesignSystem/HambugCommonAlertView.swift | 28 ++++++++++++++++++ .../Contents.json | 2 +- .../placeholder_profile.png | Bin .../community_pencil.imageset/Contents.json | 21 +++++++++++++ .../community_pencil.png | Bin 0 -> 243 bytes 5 files changed, 50 insertions(+), 1 deletion(-) rename "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" => Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/placeholder_profile.png (100%) create mode 100644 MyPage/Sources/Presentation/Assets.xcassets/community_pencil.imageset/Contents.json create mode 100644 MyPage/Sources/Presentation/Assets.xcassets/community_pencil.imageset/community_pencil.png diff --git a/Common/Sources/DesignSystem/HambugCommonAlertView.swift b/Common/Sources/DesignSystem/HambugCommonAlertView.swift index e9d3abb..102364b 100644 --- a/Common/Sources/DesignSystem/HambugCommonAlertView.swift +++ b/Common/Sources/DesignSystem/HambugCommonAlertView.swift @@ -106,6 +106,34 @@ public struct AlertButton { self.title = title self.action = action } + + public init( + _ type: AlertButtonType, + action: @escaping @MainActor () -> Void + ) { + self.title = type.title + self.action = action + } + + public enum AlertButtonType { + case ok + case cancel + case save + case accountDelete + + var title: String { + switch self { + case .ok: + return "확인" + case .cancel: + return "취소" + case .save: + return "저장" + case .accountDelete: + return "탈퇴" + } + } + } } #Preview { 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 index 8946336..de4e381 100644 --- a/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/Contents.json +++ b/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/Contents.json @@ -1,11 +1,11 @@ { "images" : [ { - "filename" : "기본 프로필 이미지.png", "idiom" : "universal", "scale" : "1x" }, { + "filename" : "기본 프로필 이미지 1.png", "idiom" : "universal", "scale" : "2x" }, 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/placeholder_profile.png similarity index 100% rename from "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" rename to Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/placeholder_profile.png diff --git a/MyPage/Sources/Presentation/Assets.xcassets/community_pencil.imageset/Contents.json b/MyPage/Sources/Presentation/Assets.xcassets/community_pencil.imageset/Contents.json new file mode 100644 index 0000000..bf8972c --- /dev/null +++ b/MyPage/Sources/Presentation/Assets.xcassets/community_pencil.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "community_pencil.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MyPage/Sources/Presentation/Assets.xcassets/community_pencil.imageset/community_pencil.png b/MyPage/Sources/Presentation/Assets.xcassets/community_pencil.imageset/community_pencil.png new file mode 100644 index 0000000000000000000000000000000000000000..39ca317ec36f5f9a5591eba915670c2230ca56fe GIT binary patch literal 243 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL zyA#8@b22Z19F}xPUq=Rpjs4tz5?O)#4o?@ykP61Pmk#nCP~c%c&~rghqET?Sr+Xrc zTLO!lNAnZAE|%IOHCj4PtEcfO9a)jJ#k=h2yG_ kv$NXl(Oxm5yFEJ3_~Mb<{%XehKsPaXy85}Sb4q9e00gvFGynhq literal 0 HcmV?d00001 From d7a6f5f4d55dff03b8f9b77561fa180fafcf0961 Mon Sep 17 00:00:00 2001 From: kangddong Date: Mon, 12 Jan 2026 02:32:40 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(di):=20DI=20package=20->=20MyPage=20pa?= =?UTF-8?q?ckage=20=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DI/Package.swift | 16 ---------------- Hambug/ContentView.swift | 11 ++++++++--- .../Sources/DI}/MyPageDIContainer.swift | 5 +++-- 3 files changed, 11 insertions(+), 21 deletions(-) rename {DI/Sources/MyPageDI => MyPage/Sources/DI}/MyPageDIContainer.swift (89%) diff --git a/DI/Package.swift b/DI/Package.swift index c49940e..3bbaa27 100644 --- a/DI/Package.swift +++ b/DI/Package.swift @@ -10,7 +10,6 @@ enum Config: String, CaseIterable { case app = "App" case intro = "Intro" case login = "Login" - case myPage = "MyPage" var name: String { switch self { @@ -42,7 +41,6 @@ let package = Package( .package(name: "Infrastructure", path: "../Infrastructure"), .package(name: "Intro", path: "../Intro"), .package(name: "Login", path: "../Login"), - .package(name: "MyPage", path: "../MyPage"), ], targets: [ .target(name: Config.interface.name), @@ -70,20 +68,6 @@ let package = Package( .target(config: .app), .product(name: "Login", package: "Login"), ] - ), - .target( - name: Config.myPage.name, - dependencies: [ - .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/Hambug/ContentView.swift b/Hambug/ContentView.swift index 271a515..134e847 100644 --- a/Hambug/ContentView.swift +++ b/Hambug/ContentView.swift @@ -45,14 +45,19 @@ struct ContentView: View { viewModel: communityDIContainer.makeCommunityViewModel(), writeFactory: communityDIContainer, detailFactory: communityDIContainer, - updateFactory: communityDIContainer + updateFactory: communityDIContainer, + reportFactory: communityDIContainer ) } .tag(1) - Text("MyPage") - .tag(2) + NavigationStack { + MyPageView( + viewModel: mypageDIContainer.makeMyPageViewModel() + ) + } + .tag(2) } .toolbar(.hidden, for: .tabBar) } diff --git a/DI/Sources/MyPageDI/MyPageDIContainer.swift b/MyPage/Sources/DI/MyPageDIContainer.swift similarity index 89% rename from DI/Sources/MyPageDI/MyPageDIContainer.swift rename to MyPage/Sources/DI/MyPageDIContainer.swift index bd00375..c46c767 100644 --- a/DI/Sources/MyPageDI/MyPageDIContainer.swift +++ b/MyPage/Sources/DI/MyPageDIContainer.swift @@ -7,6 +7,7 @@ import DIKit import AppDI +import NetworkInterface import NetworkImpl import MyPageDomain import MyPageData @@ -18,7 +19,7 @@ struct MyPageAssembly: Assembly { container.register(MyPageRepository.self) { resolver in MyPageRepositoryImpl( - networkService: NetworkServiceImpl() + networkService: resolver.resolve(NetworkServiceInterface.self) ) } @@ -46,7 +47,7 @@ public final class MyPageDIContainer { // MARK: - Initialization public init(appContainer: AppDIContainer? = nil) { let parent = appContainer ?? AppDIContainer.shared - self.container = GenericDIContainer(parent: parent.baseContainer) + self.container = AppDIContainer.shared.baseContainer MyPageAssembly().assemble(container: container) } From b4c7601d5c5ee82ca44ceb10bd5d81325b2740c8 Mon Sep 17 00:00:00 2001 From: kangddong Date: Mon, 12 Jan 2026 04:42:54 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(chore):=20userDidLogout=20=EB=85=B8?= =?UTF-8?q?=ED=8B=B0=20=ED=82=A4=20Util=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20MyPage=EC=97=90=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EB=90=98=EB=8A=94=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=84=B8=EC=84=9C=20=EC=9A=A9=EB=9F=89=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Contents.json | 2 +- .../placeholder_profile.png | Bin Common/Sources/Managers/AppStateManager.swift | 4 +++- Common/Sources/Managers/AppStorageKey.swift | 1 - Common/Sources/Managers/UserDefaultsManager.swift | 10 ++++++++++ Common/Sources/Util/ImageProcessor.swift | 6 +++--- Common/Sources/Util/NSNotification.Name+.swift | 12 ++++++++++++ Hambug/ContentView.swift | 1 + Hambug/RootView.swift | 1 + Infrastructure/Package.swift | 1 + .../Sources/NetworkImpl/AuthInterceptor.swift | 5 +---- MyPage/Package.swift | 12 ++++++++++++ 12 files changed, 45 insertions(+), 10 deletions(-) rename Common/Sources/DesignSystem/Resources/Image.xcassets/{placeholder_ProfileImage.imageset => placeholder_profile.imageset}/Contents.json (76%) rename Common/Sources/DesignSystem/Resources/Image.xcassets/{placeholder_ProfileImage.imageset => placeholder_profile.imageset}/placeholder_profile.png (100%) create mode 100644 Common/Sources/Util/NSNotification.Name+.swift diff --git a/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/Contents.json b/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_profile.imageset/Contents.json similarity index 76% rename from Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/Contents.json rename to Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_profile.imageset/Contents.json index de4e381..30c0889 100644 --- a/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/Contents.json +++ b/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_profile.imageset/Contents.json @@ -5,7 +5,7 @@ "scale" : "1x" }, { - "filename" : "기본 프로필 이미지 1.png", + "filename" : "placeholder_profile.png", "idiom" : "universal", "scale" : "2x" }, diff --git a/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/placeholder_profile.png b/Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_profile.imageset/placeholder_profile.png similarity index 100% rename from Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_ProfileImage.imageset/placeholder_profile.png rename to Common/Sources/DesignSystem/Resources/Image.xcassets/placeholder_profile.imageset/placeholder_profile.png diff --git a/Common/Sources/Managers/AppStateManager.swift b/Common/Sources/Managers/AppStateManager.swift index f553fad..3831dfb 100644 --- a/Common/Sources/Managers/AppStateManager.swift +++ b/Common/Sources/Managers/AppStateManager.swift @@ -66,10 +66,12 @@ public final class AppStateManager { state = .main } - // 로그아웃 (선택사항 - 나중에 구현 예정이지만 미리 추가) + // 로그아웃 public func logout() { do { try tokenStorage.clear() + UserDefaultsManager.shared.clearAll() + // TODO: - push key 추가시 삭제 해야할듯 ? state = .login } catch { print("⚠️ Failed to logout: \(error)") diff --git a/Common/Sources/Managers/AppStorageKey.swift b/Common/Sources/Managers/AppStorageKey.swift index bc8534e..724d792 100644 --- a/Common/Sources/Managers/AppStorageKey.swift +++ b/Common/Sources/Managers/AppStorageKey.swift @@ -10,7 +10,6 @@ import Foundation 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 d90c46d..aa89590 100644 --- a/Common/Sources/Managers/UserDefaultsManager.swift +++ b/Common/Sources/Managers/UserDefaultsManager.swift @@ -47,4 +47,14 @@ public final class UserDefaultsManager { @UDDefaultWrapper(key: .Storage.currentUserId, defaultValue: nil) public var currentUserId: Int64? + + func clearAll() { + let keys: [String] = [ + .Storage.currentUserId, + + ] + keys.forEach { + UserDefaults.standard.removeObject(forKey: $0) + } + } } diff --git a/Common/Sources/Util/ImageProcessor.swift b/Common/Sources/Util/ImageProcessor.swift index dffcd84..3ea6e43 100644 --- a/Common/Sources/Util/ImageProcessor.swift +++ b/Common/Sources/Util/ImageProcessor.swift @@ -13,10 +13,10 @@ public enum ImageProcessor { // MARK: - Constants /// 최대 파일 크기: 10MB - public static let maxFileSize: Int = 10 * 1024 * 1024 + public static let maxFileSize: Int = 2 * 1024 * 1024 - /// 최대 해상도: 1920px - public static let maxDimension: CGFloat = 1920 + /// 최대 해상도: 1280px + public static let maxDimension: CGFloat = 1280 /// 기본 압축 품질 public static let compressionQuality: CGFloat = 0.85 diff --git a/Common/Sources/Util/NSNotification.Name+.swift b/Common/Sources/Util/NSNotification.Name+.swift new file mode 100644 index 0000000..552df98 --- /dev/null +++ b/Common/Sources/Util/NSNotification.Name+.swift @@ -0,0 +1,12 @@ +// +// NSNotification.Name+.swift +// Common +// +// Created by 강동영 on 1/12/26. +// + +import Foundation.NSNotification + +public extension NSNotification.Name { + static let userDidLogout = NSNotification.Name("userDidLogout") +} diff --git a/Hambug/ContentView.swift b/Hambug/ContentView.swift index 134e847..11c4b4f 100644 --- a/Hambug/ContentView.swift +++ b/Hambug/ContentView.swift @@ -61,6 +61,7 @@ struct ContentView: View { } .toolbar(.hidden, for: .tabBar) } + .ignoresSafeArea(.keyboard) } } diff --git a/Hambug/RootView.swift b/Hambug/RootView.swift index 04d468f..e864d8b 100644 --- a/Hambug/RootView.swift +++ b/Hambug/RootView.swift @@ -15,6 +15,7 @@ import LoginDI import AppDI import IntroDI import NetworkImpl +import Util struct RootView: View { @Environment(AppStateManager.self) var appStateManager diff --git a/Infrastructure/Package.swift b/Infrastructure/Package.swift index 0bbb767..a397695 100644 --- a/Infrastructure/Package.swift +++ b/Infrastructure/Package.swift @@ -35,6 +35,7 @@ let package = Package( dependencies: [ .target(name: Config.networkInterface), .product(name: "DataSources", package: "Common"), + .product(name: "Util", package: "Common"), "Alamofire", ] ), diff --git a/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift b/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift index e92fc2d..abba0f7 100644 --- a/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift +++ b/Infrastructure/Sources/NetworkImpl/AuthInterceptor.swift @@ -9,7 +9,7 @@ import Foundation import DataSources import NetworkInterface - +import Util import Alamofire // MARK: - Auth Interceptor @@ -142,6 +142,3 @@ public final class AuthInterceptor: RequestInterceptor { } -public extension NSNotification.Name { - static let userDidLogout = NSNotification.Name("userDidLogout") -} diff --git a/MyPage/Package.swift b/MyPage/Package.swift index baa07a9..7342886 100644 --- a/MyPage/Package.swift +++ b/MyPage/Package.swift @@ -6,6 +6,7 @@ import PackageDescription enum Config: String, CaseIterable { static let name: String = "MyPage" + case di = "DI" case data = "Data" case domain = "Domain" case presentation = "Presentation" @@ -33,6 +34,17 @@ let package = Package( .package(name: "Infrastructure", path: "../Infrastructure") ], targets: [ + .target( + name: Config.di.name, + dependencies: [ + .target(config: .domain), + .target(config: .data), + .target(config: .presentation), + .product(name: "NetworkInterface", package: "Infrastructure"), + .product(name: "NetworkImpl", package: "Infrastructure") + ], + path: Config.di.path + ), .target( name: Config.data.name, dependencies: [ From e1a1697d30855a3ebfbb777d2a26d5a363ef0d9a Mon Sep 17 00:00:00 2001 From: kangddong Date: Mon, 12 Jan 2026 05:12:21 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat(domain):=20=EA=B3=B5=ED=86=B5=20User?= =?UTF-8?q?=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Common/Package.swift | 5 + Common/Sources/SharedDomain/User.swift | 35 +++++ MyPage/Package.swift | 1 + MyPage/Sources/Data/MyPage.swift | 2 - MyPage/Sources/Data/MyPageDTO.swift | 79 ++++++++++ .../Repositories/MyPageRepositoryImpl.swift | 137 ++++++++++++++++++ .../Repositories/MyPageRepository.swift | 1 + .../Domain/UseCases/MyPageUseCase.swift | 12 +- .../Presentation/MyPage/MyPageViewModel.swift | 6 + .../Presentation/MyPageViewModel.swift | 28 ---- 10 files changed, 274 insertions(+), 32 deletions(-) create mode 100644 Common/Sources/SharedDomain/User.swift delete mode 100644 MyPage/Sources/Data/MyPage.swift create mode 100644 MyPage/Sources/Data/MyPageDTO.swift create mode 100644 MyPage/Sources/Data/Repositories/MyPageRepositoryImpl.swift create mode 100644 MyPage/Sources/Presentation/MyPage/MyPageViewModel.swift delete mode 100644 MyPage/Sources/Presentation/MyPageViewModel.swift diff --git a/Common/Package.swift b/Common/Package.swift index 168956b..2cc80e0 100644 --- a/Common/Package.swift +++ b/Common/Package.swift @@ -31,6 +31,10 @@ let package = Package( name: "SharedUI", targets: ["SharedUI"] ), + .library( + name: "SharedDomain", + targets: ["SharedDomain"] + ), ], targets: [ .target( @@ -50,6 +54,7 @@ let package = Package( name: "SharedUI", dependencies: ["DesignSystem"] ), + .target(name: "SharedDomain"), .plugin( name: "ColorGenerator", diff --git a/Common/Sources/SharedDomain/User.swift b/Common/Sources/SharedDomain/User.swift new file mode 100644 index 0000000..9f2411d --- /dev/null +++ b/Common/Sources/SharedDomain/User.swift @@ -0,0 +1,35 @@ +// +// User.swift +// Common +// +// Created by 강동영 on 1/12/26. +// + + +public struct User { + public let userId: Int64 + public let nickname: String + public var profileImageURL: String + public let loginType: String + public let role: String + public let isRegister: Bool? + public let kakao: Bool + + public init( + userId: Int64 = 0, + nickname: String = "", + profileImageURL: String = "", + loginType: String = "", + role: String = "", + isRegister: Bool? = false, + kakao: Bool = false + ) { + self.userId = userId + self.nickname = nickname + self.profileImageURL = profileImageURL + self.loginType = loginType + self.role = role + self.isRegister = isRegister + self.kakao = kakao + } +} \ No newline at end of file diff --git a/MyPage/Package.swift b/MyPage/Package.swift index 7342886..3d355b5 100644 --- a/MyPage/Package.swift +++ b/MyPage/Package.swift @@ -49,6 +49,7 @@ let package = Package( name: Config.data.name, dependencies: [ .target(config: .domain), + .product(name: "SharedDomain", package: "Common"), .product(name: "NetworkInterface", package: "Infrastructure"), .product(name: "NetworkImpl", package: "Infrastructure") ], diff --git a/MyPage/Sources/Data/MyPage.swift b/MyPage/Sources/Data/MyPage.swift deleted file mode 100644 index 08b22b8..0000000 --- a/MyPage/Sources/Data/MyPage.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/MyPage/Sources/Data/MyPageDTO.swift b/MyPage/Sources/Data/MyPageDTO.swift new file mode 100644 index 0000000..36918b7 --- /dev/null +++ b/MyPage/Sources/Data/MyPageDTO.swift @@ -0,0 +1,79 @@ +// +// MyPageDTO.swift +// MyPage +// +// Created by 강동영 on 12/19/25. +// + +import SharedDomain + +// MARK: - Request DTOs +struct UpdateProfileRequest: Codable { + let userId: Int + let profileImageURL: String? +} + +struct UpdateNicknameRequest: Codable { + let nickname: String +} + +// MARK: - Response DTOs + +/// 회원 정보 조회 +struct UserProfileDTO: Decodable { + let userId: Int + let nickname: String + let profileImageUrl: String + let loginType: String + let role: String + let isRegister: Bool? + let kakao: Bool +} + +extension UserProfileDTO { + func toDomain() -> User { + return User( + userId: Int64(userId), + nickname: nickname, + profileImageURL: profileImageUrl, + loginType: loginType, + role: role, + isRegister: isRegister, + kakao: kakao + ) + } +} + +struct MyPostsResponse: Codable { + let success: Bool + let data: [MyPostItem] + let message: String + let code: Int +} + +struct MyCommentsResponse: Codable { + let success: Bool + let data: [MyCommentItem] + let message: String + let code: Int +} + +struct MyPostItem: Codable, Identifiable { + let id: Int + let title: String + let content: String? + let imageURL: String? + let likeCount: String + let commentCount: String + let createdAt: String + let category: String +} + +struct MyCommentItem: Codable, Identifiable { + let id: Int + let content: String + let createdAt: String + let postId: Int + let postTitle: String + let likeCount: String +} diff --git a/MyPage/Sources/Data/Repositories/MyPageRepositoryImpl.swift b/MyPage/Sources/Data/Repositories/MyPageRepositoryImpl.swift new file mode 100644 index 0000000..61398bf --- /dev/null +++ b/MyPage/Sources/Data/Repositories/MyPageRepositoryImpl.swift @@ -0,0 +1,137 @@ +// +// MyPageRepositoryImpl.swift +// Hambug +// +// Created by 강동영 on 10/30/25. +// + +import Foundation +import Combine +import UIKit +import MyPageDomain +import NetworkInterface +import SharedDomain + +// MARK: - MyPage Repository Implementation +public final class MyPageRepositoryImpl: MyPageRepository { + + private let networkService: NetworkServiceInterface + + public init(networkService: NetworkServiceInterface) { + self.networkService = networkService + } + + public func fetchProfile() async throws -> SharedDomain.User { + let endpoint = MyPageEndpoint.authMe + do { + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + .async() + + self.currentUserId = response.data.userId + return response.data.toDomain() + } catch { + print(error.localizedDescription) + throw error + } + } + + public func updateNickname(_ nickName: String) async { + guard let userId = currentUserId else { + return + } + + let endpoint = MyPageEndpoint.updateNickname(userID: userId, nickname: nickName) + do { + return try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + .map { _ in () } + .catch { _ in Just(()) } + .async() + } catch { + + } + + } + + public func changeProfileImage(_ image: UIImage?) async throws -> ProfileURL { + guard let userId = currentUserId else { + let error = NSError(domain: "incorrect user id", code: -1) + throw error + } + + let request = UpdateProfileRequest(userId: userId, profileImageURL: nil) + let endpoint = MyPageEndpoint.updateProfile(request) + + do { + if let image = image { + // Upload with multipart if image exists + return try await networkService.uploadMultipart( + endpoint, + images: [image], + responseType: SuccessResponse.self + ) + .map { $0.data.profileImageUrl } + .async() + } else { + // Send null request for default image + return try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + .map { $0.data.profileImageUrl } + .async() + } + } catch { + throw error + } + + } + + public func applyDefaultImage() async throws { + do { + _ = try await changeProfileImage(nil) + } catch { + throw error + } + + } + + public func logout() async { + let endpoint = MyPageEndpoint.logout + do { + return try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + .map { _ in () } + .catch { _ in Just(()) } + .async() + } catch { + + } + + } + + public func deleteAccount(provider: String) async { + let endpoint = MyPageEndpoint.deleteAccount(provider: provider) + do { + return try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + .map { _ in () } + .catch { _ in Just(()) } + .async() + } catch { + + } + + } + + private var currentUserId: Int? +} diff --git a/MyPage/Sources/Domain/Repositories/MyPageRepository.swift b/MyPage/Sources/Domain/Repositories/MyPageRepository.swift index 45957f6..bb4e04a 100644 --- a/MyPage/Sources/Domain/Repositories/MyPageRepository.swift +++ b/MyPage/Sources/Domain/Repositories/MyPageRepository.swift @@ -7,6 +7,7 @@ import Foundation import Combine +import SharedDomain // MARK: - MyPage Repository Interface public protocol MyPageRepository { diff --git a/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift b/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift index c6cfcc3..3e85f54 100644 --- a/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift +++ b/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift @@ -6,6 +6,7 @@ // import Foundation +import SharedDomain public protocol MyPageUseCase { func fetchProfile() @@ -16,6 +17,7 @@ public protocol MyPageUseCase { func logout() func deleteAccount() + func fetchProfile() async throws -> User } // MARK: - MyPage UseCase 구현체 @@ -26,8 +28,14 @@ public final class MyPageUseCaseImpl: MyPageUseCase { self.repository = repository } - public func fetchProfile() { - repository.fetchProfile(userId: 0) + public func fetchProfile() async throws -> User { + do { + return try await repository.fetchProfile() + } catch { + throw error + } + + } } public func updateNickname(_ nickName: String) {} diff --git a/MyPage/Sources/Presentation/MyPage/MyPageViewModel.swift b/MyPage/Sources/Presentation/MyPage/MyPageViewModel.swift new file mode 100644 index 0000000..c7bc81b --- /dev/null +++ b/MyPage/Sources/Presentation/MyPage/MyPageViewModel.swift @@ -0,0 +1,6 @@ +import SharedDomain + +@Observable +public final class MyPageViewModel { + var user: User? +} diff --git a/MyPage/Sources/Presentation/MyPageViewModel.swift b/MyPage/Sources/Presentation/MyPageViewModel.swift deleted file mode 100644 index 7edf466..0000000 --- a/MyPage/Sources/Presentation/MyPageViewModel.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// MyPageViewModel.swift -// Hambug -// -// Created by 강동영 on 10/28/25. -// - -import Combine -import MyPageDomain - -public final class MyPageViewModel: ObservableObject { - private let usecase: MyPageUseCase - - public init(usecase: MyPageUseCase) { - self.usecase = usecase - } - - func fetchProfile() { - - } - - func updateNickname(_ nickName: String) {} - func changeProfileImage() {} - func applyDefaultImage() {} - - func logout() {} - func deleteAccount() {} -} From 8547ec778f4b603047b7d44b6177bcbefe59ab5c Mon Sep 17 00:00:00 2001 From: kangddong Date: Mon, 12 Jan 2026 05:13:12 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feature(domain):=20repository,=20UseCase=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MyPage/Sources/Domain/MyPage.swift | 2 - .../Repositories/MyPageRepository.swift | 18 ++++---- .../Domain/UseCases/MyPageUseCase.swift | 45 +++++++++++++------ 3 files changed, 42 insertions(+), 23 deletions(-) delete mode 100644 MyPage/Sources/Domain/MyPage.swift diff --git a/MyPage/Sources/Domain/MyPage.swift b/MyPage/Sources/Domain/MyPage.swift deleted file mode 100644 index 08b22b8..0000000 --- a/MyPage/Sources/Domain/MyPage.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/MyPage/Sources/Domain/Repositories/MyPageRepository.swift b/MyPage/Sources/Domain/Repositories/MyPageRepository.swift index bb4e04a..953a9e5 100644 --- a/MyPage/Sources/Domain/Repositories/MyPageRepository.swift +++ b/MyPage/Sources/Domain/Repositories/MyPageRepository.swift @@ -7,16 +7,18 @@ import Foundation import Combine +import UIKit import SharedDomain // MARK: - MyPage Repository Interface public protocol MyPageRepository { - func fetchProfile(userId: Int) -> AnyPublisher - - func updateNickname(_ nickName: String) - func changeProfileImage() - func applyDefaultImage() - - func logout() - func deleteAccount() + typealias ProfileURL = String + func fetchProfile() async throws -> User + + func updateNickname(_ nickName: String) async + func changeProfileImage(_ image: UIImage?) async throws -> ProfileURL + func applyDefaultImage() async throws + + func logout() async + func deleteAccount(provider: String) async } diff --git a/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift b/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift index 3e85f54..331a6be 100644 --- a/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift +++ b/MyPage/Sources/Domain/UseCases/MyPageUseCase.swift @@ -6,18 +6,20 @@ // import Foundation +import Combine +import UIKit import SharedDomain +import Util public protocol MyPageUseCase { - func fetchProfile() - - func updateNickname(_ nickName: String) - func changeProfileImage() - func applyDefaultImage() - - func logout() - func deleteAccount() func fetchProfile() async throws -> User + + func updateNickname(_ nickName: String) async + func changeProfileImage(_ image: UIImage?) async throws -> String + func applyDefaultImage() async + + func logout() async + func deleteAccount(provider: String) async } // MARK: - MyPage UseCase 구현체 @@ -36,12 +38,29 @@ public final class MyPageUseCaseImpl: MyPageUseCase { } } + + public func updateNickname(_ nickName: String) async { + await repository.updateNickname(nickName) } - public func updateNickname(_ nickName: String) {} - public func changeProfileImage() {} - public func applyDefaultImage() {} + public func changeProfileImage(_ image: UIImage?) async throws -> String { + try await repository.changeProfileImage(image) + } - public func logout() {} - public func deleteAccount() {} + public func applyDefaultImage() async { + do { + try await repository.applyDefaultImage() + } catch { + print(error.localizedDescription) + } + + } + + public func logout() async { + await repository.logout() + } + + public func deleteAccount(provider: String) async { + await repository.deleteAccount(provider: provider) + } } From 9b6d7ccff129d87fe75e8bd1cdd108ca7fcedfca Mon Sep 17 00:00:00 2001 From: kangddong Date: Mon, 12 Jan 2026 05:13:43 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feature(Data):=20NetworkImpl,=20Endpoint=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NetworkImpl/NetworkServiceImpl.swift | 8 +- MyPage/Sources/Data/MyPageEndpoint.swift | 76 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 MyPage/Sources/Data/MyPageEndpoint.swift diff --git a/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift b/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift index 3fe9f8d..d6e9950 100644 --- a/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift +++ b/Infrastructure/Sources/NetworkImpl/NetworkServiceImpl.swift @@ -92,10 +92,16 @@ public final class NetworkServiceImpl: NetworkServiceInterface { responseType: T.Type ) -> AnyPublisher { do { + let urlRequest = try endpoint.createURLRequest() + +#if DEBUG + logger?.requestLogger(request: urlRequest) +#endif + guard let url = endpoint.createURL() else { throw NetworkError.invalidURL } - + return session.upload( multipartFormData: { multipartFormData in // 1. JSON body의 텍스트 필드 추가 (title, content, category) diff --git a/MyPage/Sources/Data/MyPageEndpoint.swift b/MyPage/Sources/Data/MyPageEndpoint.swift new file mode 100644 index 0000000..cf1c16e --- /dev/null +++ b/MyPage/Sources/Data/MyPageEndpoint.swift @@ -0,0 +1,76 @@ +// +// MyPageEndpoint.swift +// MyPage +// +// Created by 강동영 on 12/19/25. +// + +import Foundation +import NetworkInterface +import NetworkImpl + +// MARK: - MyPage Endpoints +enum MyPageEndpoint: Endpoint { + case authMe + case updateProfile(UpdateProfileRequest) + case updateNickname(userID: Int, nickname: String) + case logout + case deleteAccount(provider: String) + + var baseURL: String { + NetworkConfig.baseURL + } + + var path: String { + switch self { + case .authMe: + return "/api/v1/auth/me" + case let .updateProfile(param): + return "/api/v1/users/\(param.userId)/profile" + case let .updateNickname(id, _): + return "/api/v1/users/\(id)/nickname" + case .logout: + return "/api/v1/auth/logout" + case let .deleteAccount(provider): + return "/api/v1/auth/unlink/\(provider)" + } + } + + var method: HTTPMethod { + switch self { + case .authMe: + return .GET + case .updateProfile, .updateNickname: + return .PUT + case .logout, .deleteAccount: + return .POST + } + } + + var headers: [String: String] { + var headers: [String: String] = [:] + // TODO: Add Authorization header when auth is implemented + // headers["Authorization"] = "Bearer \(token)" + return headers + } + + var queryParameters: [String: Any] { + switch self { + default: + return [:] + } + } + + var body: Data? { + switch self { + case .updateProfile(let request): + // TODO: multi-part + return try? JSONEncoder().encode(request.profileImageURL) + case let .updateNickname(_, nickname): + let request = UpdateNicknameRequest(nickname: nickname) + return try? JSONEncoder().encode(request) + default: + return nil + } + } +} From a4b9a6d4d78fe607cdd67bb5c558fbe75387dbe4 Mon Sep 17 00:00:00 2001 From: kangddong Date: Mon, 12 Jan 2026 05:14:44 +0900 Subject: [PATCH 8/8] feat(Presentation): MyPage presentation --- .../MyPage/MyPage+LocalizedString.swift | 74 +++ .../Presentation/MyPage/MyPageCardView.swift | 103 +++++ .../Presentation/MyPage/MyPageView.swift | 431 ++++++++++++++++++ .../Presentation/MyPage/MyPageViewModel.swift | 134 ++++++ MyPage/Sources/Presentation/MyPageView.swift | 22 - 5 files changed, 742 insertions(+), 22 deletions(-) create mode 100644 MyPage/Sources/Presentation/MyPage/MyPage+LocalizedString.swift create mode 100644 MyPage/Sources/Presentation/MyPage/MyPageCardView.swift create mode 100644 MyPage/Sources/Presentation/MyPage/MyPageView.swift delete mode 100644 MyPage/Sources/Presentation/MyPageView.swift diff --git a/MyPage/Sources/Presentation/MyPage/MyPage+LocalizedString.swift b/MyPage/Sources/Presentation/MyPage/MyPage+LocalizedString.swift new file mode 100644 index 0000000..651625e --- /dev/null +++ b/MyPage/Sources/Presentation/MyPage/MyPage+LocalizedString.swift @@ -0,0 +1,74 @@ +// +// MyPage+LocalizedString.swift +// MyPage +// +// Created by 강동영 on 12/19/25. +// + + +import LocalizedString +import SwiftUI + +extension Text { + @inlinable + public init(myPage: KeyPath) { + let value = String.LocalizedString.MyPage.self[keyPath: myPage] + self.init(verbatim: value) + } +} + +extension String.LocalizedString { + enum Login { + static let hello: String = "안녕하세요." + static let hambug: String = "햄버그" + static let hambugSuffix: String = "입니다 :)" + static let descriptionOfSNS: String = "SNS 계정으로 간편 가입하기" + } + + public enum MyPage { + static let header: String = "마이페이지" + struct ActionSheetTitle2 { + static let profile = "프로필 설정" + } + public enum ActionSheetTitle { + static let profile = "프로필 설정" + static let changeNickname = "닉네임 변경" + static let changeImage = "프로필 이미지 변경" + static let defaultImage = "기본 이미지 적용" + } +// enum Strings { +// enum ActionSheetTitle { +// static let profile = "프로필 설정" +// static let changeNickname = "닉네임 변경" +// static let changeImage = "프로필 이미지 변경" +// static let defaultImage = "기본 이미지 적용" +// } +// +// enum CardTitle { +// static let activity = "활동 내역" +// static let logout = "로그아웃" +// static let accountDelete = "탈퇴하기" +// } +// +// enum PopupTitle { +// static let changeNickname = "닉네임 변경" +// static let logout = "로그아웃 하시겠어요?" +// static let deleteAccount = "정말 탈퇴하시겠어요?" +// static let deleteSuccess = "회원 탈퇴가 완료되었습니다." +// } +// +// enum PopupMessage { +// static let deleteAccount = "회원탈퇴 후 계정 복구가 불가능하며, 작성한 게시글과 댓글은 유지됩니다. 탈퇴하시겠습니까?" +// } +// } + } + + + enum MyPageBottomLineTextField { + enum PopupMessage { + static let isCorrected = "닉네임을 다시 확인해주세요" + } + } +} + + diff --git a/MyPage/Sources/Presentation/MyPage/MyPageCardView.swift b/MyPage/Sources/Presentation/MyPage/MyPageCardView.swift new file mode 100644 index 0000000..891e258 --- /dev/null +++ b/MyPage/Sources/Presentation/MyPage/MyPageCardView.swift @@ -0,0 +1,103 @@ +// +// MyPageCardView.swift +// MyPage +// +// Created by 강동영 on 12/19/25. +// + +import SwiftUI + +struct MyPageCardView: View { + private let title: String + private let image: ImageResource + private let action: @MainActor () -> Void + + private var foregroundColor: Color = .textG800 + + init( + config: Config, + foregroundColor: Color = .textG800, + action: @escaping @MainActor () -> Void + ) { + self.title = config.title + self.image = config.image + self.action = action + } + + init( + title: String, + image: ImageResource, + foregroundColor: Color = .textG800, + action: @escaping @MainActor () -> Void + ) { + self.title = title + self.image = image + self.action = action + } + + var body: some View { + Button { + action() + } label: { + HStack(spacing: 0) { + Label { + Text(title) + } icon: { + Image(image) + .resizable() + .frame(width: 16, height: 16) + } + .pretendard(.body(.base)) + .foregroundStyle(foregroundColor) + + Spacer() + + Image(.mypageTriangleRight) + .resizable() + .frame(width: 16, height: 16) + } + .myPageCardStyle() + + } + } +} + +extension MyPageCardView { + struct Config { + let title: String + let image: ImageResource + } +} + +extension MyPageCardView.Config { + static let activity: Self = .init(title: "활동 내역", image: .myCardActivities) + static let logout: Self = .init(title: "로그아웃", image: .myCardLogout) + static let accountDelete: Self = .init(title: "탈퇴하기", image: .myCardDeleteAccount) +} + +fileprivate extension MyPageCardView { + func foregroundColor(_ color: Color) -> Self { + var view = self + view.foregroundColor = color + return view + } +} + +fileprivate struct MyPageCardStyleModifier: ViewModifier { + func body(content: Content) -> some View { + content + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 20) + .padding(.horizontal, 20) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color.bgG100) + ) + } +} + +fileprivate extension View { + func myPageCardStyle() -> some View { + modifier(MyPageCardStyleModifier()) + } +} diff --git a/MyPage/Sources/Presentation/MyPage/MyPageView.swift b/MyPage/Sources/Presentation/MyPage/MyPageView.swift new file mode 100644 index 0000000..0430ca5 --- /dev/null +++ b/MyPage/Sources/Presentation/MyPage/MyPageView.swift @@ -0,0 +1,431 @@ +// +// MyPageView.swift +// Hambug +// +// Created by 강동영 on 10/27/25. +// + +import SwiftUI +import PhotosUI +import DesignSystem +import Util + +public struct MyPageView: View { + @Bindable var viewModel: MyPageViewModel + @State private var showMyActivitiesView: Bool = false + + // MARK: popup state + @State private var popupState: MyPageView.PopupState = .none + @State private var showInfoActionSheet: Bool = false + + // MARK: Photo picker state + @State private var selectedPhotoItem: PhotosPickerItem? + @State private var selectedImageData: Data? + @State private var showPhotoPicker: Bool = false + + public init(viewModel: MyPageViewModel) { + self._viewModel = Bindable(viewModel) + } + + public var body: some View { + NavigationStack { + ZStack { + Color.white + + VStack(spacing: 0) { + headerSection + imageSection + nicknameSection + navigationSection + + Spacer() + } + .navigationDestination(isPresented: $showMyActivitiesView, destination: { + MyActivitiesView() + }) + } + .confirmationDialog("프로필 편집", isPresented: $showInfoActionSheet, actions: { + Button(Strings.ActionSheetTitle.changeImage) { + showPhotoPicker = true + } + Button(Strings.ActionSheetTitle.defaultImage) { + viewModel.applyDefaultImage() + popupState = .none + } + Button(Strings.ActionSheetTitle.changeNickname) { + popupState = .changeNickname + } + Button("취소", role: .cancel) { + popupState = .none + } + }) + .photosPicker( + isPresented: $showPhotoPicker, + selection: $selectedPhotoItem, + matching: .images + ) + .onChange(of: selectedPhotoItem) { _, newItem in + Task { + guard let newItem = newItem, + let imageData = try? await newItem.loadTransferable(type: Data.self), + let image = UIImage(data: imageData) else { + return + } + + let originalSize = imageData.count + print("\(originalSize / (1024 * 1024))MB") + // 크기 검증 (10MB) + if originalSize > ImageProcessor.maxFileSize { + // 크기 초과 시 이미지 처리 시도 + if let processedData = ImageProcessor.process(image) { + // 처리 성공 - 압축된 이미지로 업로드 + selectedImageData = processedData + viewModel.changeProfileImage(processedData) + popupState = .none + } else { + // 처리 실패 - 알림 표시 + viewModel.showImageSizeAlert = true + } + } else { + // 크기가 괜찮으면 원본 데이터로 업로드 + selectedImageData = imageData + viewModel.changeProfileImage(imageData) + popupState = .none + } + } + } + .overlay(content: { + if popupState != .none && popupState != .infoAction { + currentPopup() + } + }) + .overlay(content: { + if viewModel.isLoading { + Color.black.opacity(0.3) + .ignoresSafeArea() + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.5) + } + }) + .alert("오류", isPresented: $viewModel.showError) { + Button("확인", role: .cancel) { + viewModel.showError = false + } + } message: { + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + } + } + .alert("이미지 크기 초과", isPresented: $viewModel.showImageSizeAlert) { + Button("확인", role: .cancel) { + viewModel.showImageSizeAlert = false + } + } message: { + Text("이미지 크기가 너무 큽니다. 10MB 이하의 이미지를 선택해주세요.") + } + } + .navigationBarHidden(true) + .onAppear { + viewModel.fetchProfile() + } + .onChange(of: viewModel.shouldNavigateToLogin) { _, shouldNavigate in + if shouldNavigate { + NotificationCenter.default.post(name: .userDidLogout, object: nil) + } + } + } + + // MARK: - Sections + private var headerSection: some View { + HStack { + Text(Strings.header) + .padding(.leading, 15) + .padding(.bottom, 15) + .pretendard(.title(.t2)) + .foregroundStyle(Color.textG900) + Spacer() + } + } + + private var imageSection: some View { + ProfileImageView( + with: viewModel.user?.profileImageURL ?? "", + width: 110, + height: 110 + ) + .applyCilpShape() + .overlay(content: { + Circle() + .stroke(Color.primaryHambugRed, lineWidth: 2) + .scaleEffect(1.08) + }) + .overlay(content: { + // 우측 하단 펜슬 버튼 + Color.bgPencil + .frame(width: 30, height: 30) + .clipShape(Circle()) + .overlay { + Image(.communityPencil) + .resizable() + .foregroundColor(.white) + .frame(width: 16, height: 16) + } + .offset(x: 40, y: 50) + + }) + .onTapGesture { + showInfoActionSheet = true + } + .padding(.top, 25) + } + + private var nicknameSection: some View { + Text(viewModel.profileNickName.isEmpty ? "nickName" : viewModel.profileNickName) + .pretendard(.body(.base)) + .foregroundStyle(Color.textG800) + .padding(.top, 22) + } + + private var navigationSection: some View { + VStack(spacing: 0) { + MyPageCardView(config: .activity) { + showMyActivitiesView = true + } + + Spacer() + .frame(height: 24) + + MyPageCardView(config: .logout) { + print("로그아웃") + popupState = .logout + } + + MyPageCardView(config: .accountDelete) { + print("탈퇴하기") + popupState = .accountDelete + } + .foregroundColor(.textR100) + } + .padding(.top, 40) + .padding(.leading, 32) + .padding(.trailing, 38) + } + + // MARK: - Popup + + @ViewBuilder + private func currentPopup() -> some View { + switch popupState { + case .none, .infoAction: + EmptyView() + case .changeNickname: + changeNicknamePopup + case .logout: + logoutPopup + case .accountDelete: + accountDeletePopup + case .accountDeleteSuccess: + accountDeleteSuccessPopup + } + } + + private var changeNicknamePopup: some View { + HambugCommonAlertView( + isPresented: Binding( + get: { popupState == .changeNickname }, + set: { if !$0 { popupState = .none }} + ), + content: { + VStack { + Text(Strings.PopupTitle.changeNickname) + .pretendard(.title(.t2)) + .foregroundStyle(Color.textG900) + .padding(.top, 16) + + MyPageBottomLineTextField( + title: $viewModel.currentNickName, + isCorrected: $viewModel.isCorrectedNickName + ) + .padding(.horizontal, 45) + } + + }, + secondaryButton: AlertButton(.cancel) { + print("취소") + + }, + primaryButton: AlertButton(.save) { + print("저장") + viewModel.updateNickname() + } + ) + } + + private var logoutPopup: some View { + HambugCommonAlertView( + isPresented: Binding( + get: { popupState == .logout }, + set: { if !$0 { popupState = .none }} + ), + content: { + VStack { + Text(Strings.PopupTitle.logout) + .pretendard(.title(.t2)) + .foregroundStyle(Color.textG900) + .padding(.top, 16) + } + .padding(.horizontal, 10) + + }, + secondaryButton: AlertButton(.cancel) { + print("취소") + + }, + primaryButton: AlertButton(.ok) { + print("확인") + viewModel.logout() + } + ) + } + + private var accountDeletePopup: some View { + HambugCommonAlertView( + isPresented: Binding( + get: { popupState == .accountDelete }, + set: { if !$0 { popupState = .none }} + ), + content: { + VStack { + Text(Strings.PopupTitle.deleteAccount) + .pretendard(.title(.t2)) + .foregroundStyle(Color.textG900) + .padding(.top, 16) + + Text(Strings.PopupMessage.deleteAccount) + .pretendard(.body(.small)) + .multilineTextAlignment(.center) + .foregroundStyle(Color.textG600) + .padding(.top, 16) + + } + .padding(.horizontal, 10) + + }, + secondaryButton: .init(.cancel) { + print("취소") + + }, + primaryButton: .init(.accountDelete) { + print("탈퇴") + viewModel.deleteAccount() + } + ) + } + + private var accountDeleteSuccessPopup: some View { + HambugCommonAlertView( + isPresented: Binding( + get: { popupState == .accountDeleteSuccess }, + set: { if !$0 { popupState = .none }} + ), + content: { + VStack { + Text(Strings.PopupTitle.deleteSuccess) + .pretendard(.title(.t2)) + .foregroundStyle(Color.textG900) + .padding(.top, 16) + } + .padding(.horizontal, 10) + + }, + secondaryButton: nil, + primaryButton: AlertButton(.ok) { + print("확인") + } + ) + } +} + +extension MyPageView { + enum PopupState { + case none + case infoAction + case changeNickname + case logout + case accountDelete + case accountDeleteSuccess + } +} + +#Preview { +// MyPageView(viewModel: .: MyPageDIContainer()) +} + +struct MyPageBottomLineTextField: View { + @Binding var title: String + @Binding var isCorrected: Bool + + var body: some View { + VStack(spacing: 4) { + TextField("", text: $title) + .pretendard(.body(.base)) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundColor(isCorrected ? Color.borderG500 : Color.borderR100), + alignment: .bottom + ) + + if !isCorrected { + Text(Strings.PopupMessage.isCorrected) + .pretendard(.caption(.base)) + .foregroundColor(Color.textR100) + } + } + } + + init( + title: Binding, + isCorrected: Binding + ) { + self._title = title + self._isCorrected = isCorrected + } +} + +extension MyPageView { + enum Strings { + static let header = "마이페이지" + + enum ActionSheetTitle { + static let profile = "프로필 설정" + static let changeNickname = "닉네임 변경" + static let changeImage = "프로필 이미지 변경" + static let defaultImage = "기본 이미지 적용" + } + + enum PopupTitle { + static let changeNickname = "닉네임 변경" + static let logout = "로그아웃 하시겠어요?" + static let deleteAccount = "정말 탈퇴하시겠어요?" + static let deleteSuccess = "회원 탈퇴가 완료되었습니다." + } + + enum PopupMessage { + static let deleteAccount = "회원탈퇴 후 계정 복구가 불가능하며, 작성한 게시글과 댓글은 유지됩니다. 탈퇴하시겠습니까?" + } + } +} + + +extension MyPageBottomLineTextField { + enum Strings { + enum PopupMessage { + static let isCorrected = "닉네임을 다시 확인해주세요" + } + } +} diff --git a/MyPage/Sources/Presentation/MyPage/MyPageViewModel.swift b/MyPage/Sources/Presentation/MyPage/MyPageViewModel.swift index c7bc81b..9518e25 100644 --- a/MyPage/Sources/Presentation/MyPage/MyPageViewModel.swift +++ b/MyPage/Sources/Presentation/MyPage/MyPageViewModel.swift @@ -1,6 +1,140 @@ +// +// MyPageViewModel.swift +// Hambug +// +// Created by 강동영 on 10/28/25. +// + +import Combine +import Foundation +import Observation +import UIKit +import MyPageDomain import SharedDomain @Observable public final class MyPageViewModel { + private let usecase: MyPageUseCase + private var cancellables: Set = [] + + var currentNickName: String = "" + var profileNickName: String = "" + var isCorrectedNickName: Bool = true var user: User? + + var isLoading: Bool = false + var errorMessage: String? + var showError: Bool = false + var shouldNavigateToLogin: Bool = false + var showImageSizeAlert: Bool = false + + public init(usecase: MyPageUseCase) { + self.usecase = usecase + } + + func fetchProfile() { + isLoading = true + usecase.fetchProfile() + .receive(on: DispatchQueue.main) + .sink { [weak self] user in + self?.isLoading = false + self?.user = user + self?.profileNickName = user.nickname + } + .store(in: &cancellables) + } + + func updateNickname() { + let trimmed = currentNickName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + isCorrectedNickName = false + return + } + + isCorrectedNickName = true + isLoading = true + usecase.updateNickname(trimmed) + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.isLoading = false + self?.profileNickName = trimmed + } + .store(in: &cancellables) + } + + func changeProfileImage(_ imageData: Data) { + guard let image = UIImage(data: imageData) else { + errorMessage = "이미지를 불러올 수 없습니다." + showError = true + return + } + + isLoading = true + usecase.changeProfileImage(image) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + self?.isLoading = false + + switch completion { + case .failure: + self?.errorMessage = "이미지 변경에 실패했습니다. 다시 시도해 주세요." + self?.showError = true + case .finished: +// self?.fetchProfile() + break + } + }, + receiveValue: { [weak self] in + self?.user?.profileImageURL = $0 + } + ) + .store(in: &cancellables) + } + + func applyDefaultImage() { + isLoading = true + usecase.applyDefaultImage() + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + self?.isLoading = false + + switch completion { + case .failure: + self?.errorMessage = "이미지 변경에 실패했습니다. 다시 시도해 주세요." + self?.showError = true + case .finished: +// self?.fetchProfile() + self?.user?.profileImageURL = "" + } + }, + receiveValue: {} + ) + .store(in: &cancellables) + } + + func logout() { + isLoading = true + usecase.logout() + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.isLoading = false + self?.shouldNavigateToLogin = true + } + .store(in: &cancellables) + } + + func deleteAccount() { + guard let provider = user?.loginType.lowercased(), !provider.isEmpty else { return } + + isLoading = true + usecase.deleteAccount(provider: provider) + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.isLoading = false + self?.shouldNavigateToLogin = true + } + .store(in: &cancellables) + } } diff --git a/MyPage/Sources/Presentation/MyPageView.swift b/MyPage/Sources/Presentation/MyPageView.swift deleted file mode 100644 index 2f1b23e..0000000 --- a/MyPage/Sources/Presentation/MyPageView.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// MyPageView.swift -// Hambug -// -// Created by 강동영 on 10/27/25. -// - -import SwiftUI - -public struct MyPageView: View { - @StateObject var viewModel: MyPageViewModel - - public var body: some View { - Text("My Page") - } - - public init(viewModel: MyPageViewModel) { - self._viewModel = StateObject(wrappedValue: viewModel) - } -} - -