diff --git a/AGENTS.md b/AGENTS.md index 4e6224a..2f39d74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -443,7 +443,8 @@ public struct VoteSummary: Equatable { ... } // ← Entity 로 ``` 규칙: -- **모든 화면 모델 (struct/enum)** 은 `Projects/Domain/Entity/Sources/<도메인>/` 아래에 둔다 (`Home/Comment.swift`, `Home/Battle.swift` 등) +- **모든 화면 모델 (struct/enum)** 은 `Projects/Domain/Entity/Sources/<도메인>/` 아래에 둔다 (`Home/Battle/...`, `Profile/Credit/...` 등) +- **파일당 public 타입 하나 (SOLID, 필수)** — 한 파일에 `struct`/`enum` 을 여러 개 넣지 않는다. 타입마다 `타입명.swift` 로 분리하고, `extension` 은 해당 타입 파일에 함께 둔다. 기능별 하위 폴더로 폴더링한다 (예: `Profile/MyPage/{MyPage,MyProfile,MyTier}.swift`, `Profile/Credit/{CreditHistoryPage,CreditHistoryItem}.swift`) - Feature 안에는 `State` / `Action` / `Reducer` / `CancelID` 같은 **TCA 컴포넌트만** 둔다 - UI 분기용 enum (`CommentFilter`, `CommentSort`) 도 도메인 모델로 취급해 Entity 에 둔다 — 같은 도메인의 여러 화면에서 재사용 가능 - 서버 응답 매핑 init (`init(item: BattlePerspective, order: Int)`) · 정적 mocks · `.empty` 팩토리도 모두 Entity 쪽에서 정의 diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index 22c142b..d2c122e 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -17,6 +17,7 @@ public enum ModulePath { } // MARK: FeatureModule + public extension ModulePath { enum Presentations: String, CaseIterable { case Presentation @@ -28,15 +29,16 @@ public extension ModulePath { case Hifi case Web case Battle + case Profile public static let name: String = "Presentation" - + + case Notification } } +// MARK: - CoreDomainModule - -//MARK: - CoreDomainModule public extension ModulePath { enum Networks: String, CaseIterable { case Networking @@ -47,7 +49,8 @@ public extension ModulePath { } } -//MARK: - CoreMoudule +// MARK: - CoreMoudule + public extension ModulePath { enum Datas: String, CaseIterable { case Model @@ -60,8 +63,8 @@ public extension ModulePath { } } +// MARK: - CoreMoudule -//MARK: - CoreMoudule public extension ModulePath { enum Domains: String, CaseIterable { case Entity @@ -70,21 +73,17 @@ public extension ModulePath { case DataInterface case DomainInterface - public static let name: String = "Domain" } } - public extension ModulePath { enum Shareds: String, CaseIterable { case Shared case DesignSystem case Utill - + public static let name: String = "Shared" - case ThirdParty + case ThirdParty } } - - diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift index b8175a0..4c5ab33 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift @@ -8,21 +8,20 @@ import Foundation import ProjectDescription -extension String { - public static func appVersion(version: String = "1.0.0") -> String { +public extension String { + static func appVersion(version: String = "1.0.0") -> String { return version } - - public static func mainBundleID() -> String { + + static func mainBundleID() -> String { return Project.Environment.bundlePrefix } - - public static func appBuildVersion(buildVersion: String = "10") -> String { + + static func appBuildVersion(buildVersion: String = "23") -> String { return buildVersion } - - public static func appBundleID(name: String) -> String { - return Project.Environment.bundlePrefix+name + + static func appBundleID(name: String) -> String { + return Project.Environment.bundlePrefix + name } - } diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift index 421fb37..3539fcd 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift @@ -211,6 +211,10 @@ extension InfoPlistDictionary { merging(["GADApplicationIdentifier": .string(value)]) { _, new in new } } + func setRewardAdUnit(_ value: String) -> InfoPlistDictionary { + merging(["REWARD_AD_UNIT": .string(value)]) { _, new in new } + } + func setSKAdNetworkItems(_ identifiers: [String]) -> InfoPlistDictionary { merging([ "SKAdNetworkItems": .array( diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift index 2092c15..cee672b 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift @@ -48,6 +48,7 @@ public extension InfoPlist { .setGIDClientID("${GOOGLE_CLIENT_ID}") .setAdmobToken("${ADMOB_TOKEN}") .setGADApplicationId("${ADMOB_TOKEN}") + .setRewardAdUnit("$(REWARD_AD_UNIT)") .setKakaoRestApiKey() .setLSApplicationQueriesSchemes([ "kakaokompassauth", // 카카오톡 로그인 diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index 92f4600..4800efa 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -41,7 +41,8 @@ public final class AppDIManager: Sendable { .register { PerspectiveRepositoryImpl() as PerspectiveInterface } .register { SearchRepositoryImpl() as SearchInterface } .register { AudioPlayerRepositoryImpl() as AudioPlayerInterface } -// .register { ProfileRepositoryImpl() as ProfileInterface } + .register { ProfileRepositoryImpl() as ProfileInterface } + .register { NotificationRepositoryImpl() as NotificationInterface } // .register { AppUpdateRepositoryImpl() as AppUpdateInterface } // 🔐 OAuth Provider 계층 (PFW 조합 패턴) diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index a8fc472..d43f714 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -13,18 +13,17 @@ import Presentation @Reducer public struct AppReducer: Sendable { public init() {} - + @ObservableState public enum State { case splash(SplashFeature.State) case auth(AuthCoordinator.State) case mainTab(MainTabCoordinator.State) - - + public init() { self = .splash(SplashFeature.State()) } - + // Animation identifier for SwiftUI transitions var animationID: String { switch self { @@ -34,8 +33,9 @@ public struct AppReducer: Sendable { } } } - - //MARK: - Action + + // MARK: - Action + public enum Action: ViewAction { case view(View) case async(AsyncAction) @@ -43,55 +43,54 @@ public struct AppReducer: Sendable { case navigation(NavigationAction) case scope(ScopeAction) } - + @CasePathable public enum View { case presentView case presentRoot case presentAuth - } - - //MARK: - 앱내에서 사용하는 액션 + + // MARK: - 앱내에서 사용하는 액션 + public enum InnerAction: Equatable { case completeAuthTransition case completeMainTabTransition } - - //MARK: - 비동기 처리 액션 + + // MARK: - 비동기 처리 액션 + public enum AsyncAction: Equatable { case startNotificationListener case refreshTokenExpired } - - //MARK: - 네비게이션 연결 액션 - public enum NavigationAction: Equatable { - - } - - //MARK: - 스코프 액션 + + // MARK: - 네비게이션 연결 액션 + + public enum NavigationAction: Equatable {} + + // MARK: - 스코프 액션 + @CasePathable public enum ScopeAction { case splash(SplashFeature.Action) case auth(AuthCoordinator.Action) case mainTab(MainTabCoordinator.Action) - } - + @Dependency(\.continuousClock) var clock - + // 🎯 PFW 패턴: 강타입 최소 CancelID (3개로 축소) private enum CancelID: Hashable { case coordinator(CoordinatorType) case transition case refreshTokenListener - + enum CoordinatorType: Hashable { - case auth } } - + // 🎯 PFW 패턴: 최소한의 핵심 취소 (3개만) private func cancelAllCoordinatorEffects() -> Effect { return .merge([ @@ -99,11 +98,11 @@ public struct AppReducer: Sendable { // .cancel(id: CancelID.coordinator(.staff)), // .cancel(id: CancelID.coordinator(.member)), .cancel(id: CancelID.coordinator(.auth)), - + // ]) } - + // 🎯 PFW 패턴: 상태 변경 전에 effect 취소를 먼저 완료 private func startTransition(_ action: InnerAction) -> Effect { .concatenate( @@ -113,26 +112,26 @@ public struct AppReducer: Sendable { ) .cancellable(id: CancelID.transition, cancelInFlight: true) } - + // 제거됨: PFW 권장사항에 따라 단순화 - + public var body: some ReducerOf { // 🔥 TCA 해결책 4: Reduce를 ifCaseLet보다 먼저 배치하여 액션 필터링 우선 처리 Reduce { state, action in switch action { - case .view(let viewAction): + case let .view(viewAction): return handleViewAction(state: &state, action: viewAction) - - case .inner(let innerAction): + + case let .inner(innerAction): return handleInnerAction(state: &state, action: innerAction) - - case .async(let asyncAction): + + case let .async(asyncAction): return handleAsyncAction(state: &state, action: asyncAction) - - case .navigation(let navigationAction): + + case let .navigation(navigationAction): return handleNavigationAction(state: &state, action: navigationAction) - - case .scope(let scopeAction): + + case let .scope(scopeAction): // 🎯 PFW 패턴: 단순한 위임 - 복잡한 검증은 handleScopeAction에서 return handleScopeAction(state: &state, action: scopeAction) } @@ -148,9 +147,9 @@ public struct AppReducer: Sendable { MainTabCoordinator() } } - + private func handleViewAction( - state: inout State, + state _: inout State, action: View ) -> Effect { switch action { @@ -158,31 +157,29 @@ public struct AppReducer: Sendable { return .run { send in await send(.scope(.splash(.view(.onAppear)))) } - + case .presentRoot: return startTransition(.completeMainTabTransition) - + case .presentAuth: return startTransition(.completeAuthTransition) - - } } - + private func handleAsyncAction( - state: inout State, + state _: inout State, action: AsyncAction ) -> Effect { switch action { case .startNotificationListener: return setupRefreshTokenExpiredListener() .cancellable(id: CancelID.refreshTokenListener, cancelInFlight: true) - + case .refreshTokenExpired: return startTransition(.completeAuthTransition) } } - + private func handleInnerAction( state: inout State, action: InnerAction @@ -191,21 +188,20 @@ public struct AppReducer: Sendable { case .completeAuthTransition: state = .auth(.init()) return .none - + case .completeMainTabTransition: state = .mainTab(.init()) return .none - } } - + private func handleNavigationAction( - state: inout State, - action: NavigationAction + state _: inout State, + action _: NavigationAction ) -> Effect { return .none } - + // 🎯 PFW 철학: 단순하고 조합 가능한 상태 검증 private func isValidAction( _ action: ScopeAction, @@ -231,7 +227,7 @@ public struct AppReducer: Sendable { // 🎯 PFW 패턴: 단순한 네비게이션 처리 return handleScopeNavigation(action: action) } - + // 🎯 PFW 패턴: 네비게이션 로직 분리 private func handleScopeNavigation(action: ScopeAction) -> Effect { switch action { @@ -253,18 +249,20 @@ public struct AppReducer: Sendable { case .auth(.navigation(.presentMainTab)): return .send(.view(.presentRoot)) + // 로그아웃/탈퇴 → 로그인 화면으로 복귀 + case .mainTab(.delegate(.sessionEnded)): + return .send(.view(.presentAuth)) + default: return .none } } - - + private func isSplashState(_ state: State) -> Bool { guard case .splash = state else { return false } return true } - - + private func setupRefreshTokenExpiredListener() -> Effect { return .publisher { NotificationCenter.default @@ -273,5 +271,4 @@ public struct AppReducer: Sendable { } .cancellable(id: CancelID.refreshTokenListener, cancelInFlight: true) } - } diff --git a/Projects/Data/API/Sources/Base/PieckeDomain.swift b/Projects/Data/API/Sources/Base/PieckeDomain.swift index 5c17784..27654d8 100644 --- a/Projects/Data/API/Sources/Base/PieckeDomain.swift +++ b/Projects/Data/API/Sources/Base/PieckeDomain.swift @@ -18,6 +18,7 @@ public enum PieckeDomain { case comment case perspective case search + case notification } extension PieckeDomain: DomainType { @@ -43,6 +44,8 @@ extension PieckeDomain: DomainType { return "api/v1/perspectives/" case .search: return "api/v1/search/" + case .notification: + return "api/v1/notifications" } } } diff --git a/Projects/Data/API/Sources/Battle/BattleAPI.swift b/Projects/Data/API/Sources/Battle/BattleAPI.swift index e14a3e5..5038841 100644 --- a/Projects/Data/API/Sources/Battle/BattleAPI.swift +++ b/Projects/Data/API/Sources/Battle/BattleAPI.swift @@ -15,6 +15,7 @@ public enum BattleAPI { case perspectives(battleId: Int) case myPerspective(battleId: Int) case recommendations(battleId: Int) + case proposals public var description: String { switch self { @@ -36,6 +37,8 @@ public enum BattleAPI { return "\(battleId)/perspectives/me" case let .recommendations(battleId): return "\(battleId)/recommendations/interesting" + case .proposals: + return "proposals" } } } diff --git a/Projects/Data/API/Sources/Notification/NotificationAPI.swift b/Projects/Data/API/Sources/Notification/NotificationAPI.swift new file mode 100644 index 0000000..8ca83ab --- /dev/null +++ b/Projects/Data/API/Sources/Notification/NotificationAPI.swift @@ -0,0 +1,32 @@ +// +// NotificationAPI.swift +// API +// +// 알림(notifications) 엔드포인트 경로. (domain = "api/v1/notifications") +// + +import Foundation + +public enum NotificationAPI { + /// GET /api/v1/notifications + case list + /// GET /api/v1/notifications/{notificationId} + case detail(notificationId: Int) + /// POST /api/v1/notifications/{notificationId}/read + case read(notificationId: Int) + /// POST /api/v1/notifications/read-all + case readAll + + public var description: String { + switch self { + case .list: + return "" + case let .detail(notificationId): + return "/\(notificationId)" + case let .read(notificationId): + return "/\(notificationId)/read" + case .readAll: + return "/read-all" + } + } +} diff --git a/Projects/Data/API/Sources/Profile/ProfileAPI.swift b/Projects/Data/API/Sources/Profile/ProfileAPI.swift new file mode 100644 index 0000000..3d2cc44 --- /dev/null +++ b/Projects/Data/API/Sources/Profile/ProfileAPI.swift @@ -0,0 +1,32 @@ +// +// ProfileAPI.swift +// API +// + +import Foundation + +public enum ProfileAPI { + case mypage + case recap + case creditsHistory + case battleRecords + case contentActivities + case notificationSettings + + public var description: String { + switch self { + case .mypage: + return "mypage" + case .recap: + return "recap" + case .creditsHistory: + return "credits/history" + case .battleRecords: + return "battle-records" + case .contentActivities: + return "content-activities" + case .notificationSettings: + return "notification-settings" + } + } +} diff --git a/Projects/Data/Model/Sources/Battle/DTO/BattleProposalDataDTO.swift b/Projects/Data/Model/Sources/Battle/DTO/BattleProposalDataDTO.swift new file mode 100644 index 0000000..0a33780 --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/DTO/BattleProposalDataDTO.swift @@ -0,0 +1,21 @@ +// +// BattleProposalDataDTO.swift +// Model +// + +import Foundation + +public struct BattleProposalDataDTO: Decodable { + public let id: Int? + public let userId: Int? + public let nickname: String? + public let category: String? + public let topic: String? + public let positionA: String? + public let positionB: String? + public let description: String? + public let status: String? + public let createdAt: String? +} + +public typealias BattleProposalResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Battle/Mapper/BattleProposalDataDTO+.swift b/Projects/Data/Model/Sources/Battle/Mapper/BattleProposalDataDTO+.swift new file mode 100644 index 0000000..9059d9b --- /dev/null +++ b/Projects/Data/Model/Sources/Battle/Mapper/BattleProposalDataDTO+.swift @@ -0,0 +1,32 @@ +// +// BattleProposalDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension BattleProposalDataDTO { + func toDomain() -> BattleProposal { + BattleProposal( + id: id ?? 0, + userId: userId ?? 0, + nickname: nickname ?? "", + category: category ?? "", + topic: topic ?? "", + positionA: positionA ?? "", + positionB: positionB ?? "", + description: description ?? "", + status: status ?? "", + createdAt: createdAt.flatMap(Self.parseISO8601) + ) + } + + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} diff --git a/Projects/Data/Model/Sources/Notification/DTO/NotificationDataDTO.swift b/Projects/Data/Model/Sources/Notification/DTO/NotificationDataDTO.swift new file mode 100644 index 0000000..68eac91 --- /dev/null +++ b/Projects/Data/Model/Sources/Notification/DTO/NotificationDataDTO.swift @@ -0,0 +1,40 @@ +// +// NotificationDataDTO.swift +// Model +// +// `GET /api/v1/notifications` 및 단건 상세 응답 DTO. +// + +import Foundation + +public struct NotificationDataDTO: Decodable { + public let items: [NotificationItemDTO]? + public let hasNext: Bool? +} + +public struct NotificationItemDTO: Decodable { + public let notificationId: Int? + public let category: String? + public let detailCode: String? + public let title: String? + public let body: String? + public let referenceId: Int? + public let isRead: Bool? + public let createdAt: String? +} + +/// 단건 상세 (목록 항목 + readAt). +public struct NotificationDetailDTO: Decodable { + public let notificationId: Int? + public let category: String? + public let detailCode: String? + public let title: String? + public let body: String? + public let referenceId: Int? + public let isRead: Bool? + public let createdAt: String? + public let readAt: String? +} + +public typealias NotificationResponseDTO = BaseResponseDTO +public typealias NotificationDetailResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Notification/Mapper/NotificationDataDTO+.swift b/Projects/Data/Model/Sources/Notification/Mapper/NotificationDataDTO+.swift new file mode 100644 index 0000000..87a0372 --- /dev/null +++ b/Projects/Data/Model/Sources/Notification/Mapper/NotificationDataDTO+.swift @@ -0,0 +1,57 @@ +// +// NotificationDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension NotificationDataDTO { + func toDomain() -> NotificationPage { + NotificationPage( + items: (items ?? []).map { $0.toDomain() }, + hasNext: hasNext ?? false + ) + } +} + +public extension NotificationItemDTO { + func toDomain() -> NotificationItem { + NotificationItem( + notificationId: notificationId ?? 0, + category: NotificationCategory(rawValue: category ?? ""), + detailCode: detailCode ?? "", + title: title ?? "", + body: body ?? "", + referenceId: referenceId, + isRead: isRead ?? false, + createdAt: createdAt.flatMap(NotificationDateParser.parseISO8601) + ) + } +} + +public extension NotificationDetailDTO { + func toDomain() -> NotificationDetail { + NotificationDetail( + notificationId: notificationId ?? 0, + category: NotificationCategory(rawValue: category ?? ""), + detailCode: detailCode ?? "", + title: title ?? "", + body: body ?? "", + referenceId: referenceId, + isRead: isRead ?? false, + createdAt: createdAt.flatMap(NotificationDateParser.parseISO8601), + readAt: readAt.flatMap(NotificationDateParser.parseISO8601) + ) + } +} + +enum NotificationDateParser { + static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} diff --git a/Projects/Data/Model/Sources/Profile/DTO/BattleRecordDataDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/BattleRecordDataDTO.swift new file mode 100644 index 0000000..782e293 --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/DTO/BattleRecordDataDTO.swift @@ -0,0 +1,26 @@ +// +// BattleRecordDataDTO.swift +// Model +// +// `GET /api/v1/me/battle-records` 응답 DTO. +// + +import Foundation + +public struct BattleRecordDataDTO: Decodable { + public let items: [BattleRecordItemDTO]? + public let nextOffset: Int? + public let hasNext: Bool? +} + +public struct BattleRecordItemDTO: Decodable { + public let battleId: String? + public let recordId: String? + public let voteSide: String? + public let category: String? + public let title: String? + public let summary: String? + public let createdAt: String? +} + +public typealias BattleRecordResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Profile/DTO/ContentActivityDataDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/ContentActivityDataDTO.swift new file mode 100644 index 0000000..f868d9d --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/DTO/ContentActivityDataDTO.swift @@ -0,0 +1,36 @@ +// +// ContentActivityDataDTO.swift +// Model +// +// `GET /api/v1/me/content-activities` 응답 DTO. +// + +import Foundation + +public struct ContentActivityDataDTO: Decodable { + public let items: [ContentActivityItemDTO]? + public let nextOffset: Int? + public let hasNext: Bool? +} + +public struct ContentActivityItemDTO: Decodable { + public let activityId: String? + public let activityType: String? + public let perspectiveId: String? + public let battleId: String? + public let battleTitle: String? + public let author: ContentActivityAuthorDTO? + public let voteSide: String? + public let content: String? + public let likeCount: Int? + public let createdAt: String? +} + +public struct ContentActivityAuthorDTO: Decodable { + public let userTag: String? + public let nickname: String? + public let characterType: String? + public let characterImageUrl: String? +} + +public typealias ContentActivityResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Profile/DTO/CreditHistoryDataDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/CreditHistoryDataDTO.swift new file mode 100644 index 0000000..7dd96b5 --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/DTO/CreditHistoryDataDTO.swift @@ -0,0 +1,24 @@ +// +// CreditHistoryDataDTO.swift +// Model +// +// `GET /api/v1/me/credits/history` 응답 DTO. +// + +import Foundation + +public struct CreditHistoryDataDTO: Decodable { + public let items: [CreditHistoryItemDTO]? + public let nextOffset: Int? + public let hasNext: Bool? +} + +public struct CreditHistoryItemDTO: Decodable { + public let id: Int? + public let creditType: String? + public let amount: Int? + public let referenceId: Int? + public let createdAt: String? +} + +public typealias CreditHistoryResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Profile/DTO/MyPageDataDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/MyPageDataDTO.swift new file mode 100644 index 0000000..927754a --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/DTO/MyPageDataDTO.swift @@ -0,0 +1,40 @@ +// +// MyPageDataDTO.swift +// Model +// +// `GET /api/v1/me/mypage` 응답 DTO. +// + +import Foundation + +public struct MyPageDataDTO: Decodable { + // philosopher 는 미확정(배틀 5개 미만) 시 null 로 내려와 옵셔널로 둔다. + public let profile: MyProfileDTO? + public let philosopher: MyPhilosopherDTO? + public let tier: MyTierDTO? +} + +public struct MyProfileDTO: Decodable { + public let userTag: String? + public let nickname: String? + public let characterType: String? + public let characterLabel: String? + public let characterImageUrl: String? + public let mannerTemperature: Double? +} + +public struct MyPhilosopherDTO: Decodable { + public let philosopherType: String? + public let philosopherLabel: String? + public let typeName: String? + public let description: String? + public let imageUrl: String? +} + +public struct MyTierDTO: Decodable { + public let tierCode: String? + public let tierLabel: String? + public let currentPoint: Int? +} + +public typealias MyPageResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Profile/DTO/NotificationSettingsDataDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/NotificationSettingsDataDTO.swift new file mode 100644 index 0000000..b57801d --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/DTO/NotificationSettingsDataDTO.swift @@ -0,0 +1,19 @@ +// +// NotificationSettingsDataDTO.swift +// Model +// +// `GET/PATCH /api/v1/me/notification-settings` 응답 DTO. +// + +import Foundation + +public struct NotificationSettingsDataDTO: Decodable { + public let newBattleEnabled: Bool? + public let battleResultEnabled: Bool? + public let commentReplyEnabled: Bool? + public let newCommentEnabled: Bool? + public let contentLikeEnabled: Bool? + public let marketingEventEnabled: Bool? +} + +public typealias NotificationSettingsResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Profile/DTO/RecapDataDTO.swift b/Projects/Data/Model/Sources/Profile/DTO/RecapDataDTO.swift new file mode 100644 index 0000000..7747a9e --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/DTO/RecapDataDTO.swift @@ -0,0 +1,49 @@ +// +// RecapDataDTO.swift +// Model +// +// `GET /api/v1/me/recap` 응답 DTO. +// + +import Foundation + +public struct RecapDataDTO: Decodable { + public let myCard: RecapCardDTO? + public let bestMatchCard: RecapCardDTO? + public let worstMatchCard: RecapCardDTO? + public let scores: RecapScoresDTO? + public let preferenceReport: PreferenceReportDTO? +} + +public struct RecapCardDTO: Decodable { + public let philosopherType: String? + public let philosopherLabel: String? + public let typeName: String? + public let description: String? + public let keywordTags: [String]? + public let imageUrl: String? +} + +public struct RecapScoresDTO: Decodable { + public let principle: Double? + public let reason: Double? + public let individual: Double? + public let change: Double? + public let inner: Double? + public let ideal: Double? +} + +public struct PreferenceReportDTO: Decodable { + public let totalParticipation: Int? + public let opinionChanges: Int? + public let battleWinRate: Int? + public let favoriteTopics: [FavoriteTopicDTO]? +} + +public struct FavoriteTopicDTO: Decodable { + public let rank: Int? + public let participationCount: Int? + public let tagName: String? +} + +public typealias RecapResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Profile/Mapper/BattleRecordDataDTO+.swift b/Projects/Data/Model/Sources/Profile/Mapper/BattleRecordDataDTO+.swift new file mode 100644 index 0000000..d5d917e --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/Mapper/BattleRecordDataDTO+.swift @@ -0,0 +1,39 @@ +// +// BattleRecordDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension BattleRecordDataDTO { + func toDomain() -> BattleRecordPage { + BattleRecordPage( + items: (items ?? []).map { $0.toDomain() }, + nextOffset: nextOffset ?? 0, + hasNext: hasNext ?? false + ) + } +} + +public extension BattleRecordItemDTO { + func toDomain() -> BattleRecord { + BattleRecord( + battleId: battleId ?? "", + recordId: recordId ?? "", + voteSide: BattleVoteSide(rawValue: voteSide ?? ""), + category: category ?? "", + title: title ?? "", + summary: summary ?? "", + createdAt: createdAt.flatMap(Self.parseISO8601) + ) + } + + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} diff --git a/Projects/Data/Model/Sources/Profile/Mapper/ContentActivityDataDTO+.swift b/Projects/Data/Model/Sources/Profile/Mapper/ContentActivityDataDTO+.swift new file mode 100644 index 0000000..89afcb5 --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/Mapper/ContentActivityDataDTO+.swift @@ -0,0 +1,58 @@ +// +// ContentActivityDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension ContentActivityDataDTO { + func toDomain() -> ContentActivityPage { + ContentActivityPage( + items: (items ?? []).map { $0.toDomain() }, + nextOffset: nextOffset ?? 0, + hasNext: hasNext ?? false + ) + } +} + +public extension ContentActivityItemDTO { + func toDomain() -> ContentActivity { + ContentActivity( + activityId: activityId ?? "", + activityType: ContentActivityType(rawValue: activityType ?? ""), + perspectiveId: perspectiveId ?? "", + battleId: battleId ?? "", + battleTitle: battleTitle ?? "", + author: author?.toDomain() ?? ContentActivityAuthor( + userTag: "", + nickname: "", + characterType: "", + characterImageURL: "" + ), + voteSide: BattleVoteSide(rawValue: voteSide ?? ""), + content: content ?? "", + likeCount: likeCount ?? 0, + createdAt: createdAt.flatMap(Self.parseISO8601) + ) + } + + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} + +public extension ContentActivityAuthorDTO { + func toDomain() -> ContentActivityAuthor { + ContentActivityAuthor( + userTag: userTag ?? "", + nickname: nickname ?? "", + characterType: characterType ?? "", + characterImageURL: characterImageUrl ?? "" + ) + } +} diff --git a/Projects/Data/Model/Sources/Profile/Mapper/CreditHistoryDataDTO+.swift b/Projects/Data/Model/Sources/Profile/Mapper/CreditHistoryDataDTO+.swift new file mode 100644 index 0000000..240cdd1 --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/Mapper/CreditHistoryDataDTO+.swift @@ -0,0 +1,37 @@ +// +// CreditHistoryDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension CreditHistoryDataDTO { + func toDomain() -> CreditHistoryPage { + CreditHistoryPage( + items: (items ?? []).map { $0.toDomain() }, + nextOffset: nextOffset ?? 0, + hasNext: hasNext ?? false + ) + } +} + +public extension CreditHistoryItemDTO { + func toDomain() -> CreditHistoryItem { + CreditHistoryItem( + id: id ?? 0, + creditType: creditType ?? "", + amount: amount ?? 0, + referenceId: referenceId, + createdAt: createdAt.flatMap(Self.parseISO8601) + ) + } + + private static func parseISO8601(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} diff --git a/Projects/Data/Model/Sources/Profile/Mapper/MyPageDataDTO+.swift b/Projects/Data/Model/Sources/Profile/Mapper/MyPageDataDTO+.swift new file mode 100644 index 0000000..3075189 --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/Mapper/MyPageDataDTO+.swift @@ -0,0 +1,52 @@ +// +// MyPageDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension MyPageDataDTO { + func toDomain() -> MyPage { + MyPage( + profile: profile?.toDomain() ?? .empty, + philosopher: philosopher?.toDomain() ?? .empty, + tier: tier?.toDomain() ?? .empty + ) + } +} + +public extension MyProfileDTO { + func toDomain() -> MyProfile { + MyProfile( + userTag: userTag ?? "", + nickname: nickname ?? "", + characterType: characterType ?? "", + characterLabel: characterLabel ?? "", + characterImageURL: characterImageUrl ?? "", + mannerTemperature: mannerTemperature ?? 0 + ) + } +} + +public extension MyPhilosopherDTO { + func toDomain() -> MyPhilosopher { + MyPhilosopher( + philosopherType: philosopherType ?? "", + philosopherLabel: philosopherLabel ?? "", + typeName: typeName ?? "", + description: description ?? "", + imageURL: imageUrl ?? "" + ) + } +} + +public extension MyTierDTO { + func toDomain() -> MyTier { + MyTier( + tierCode: tierCode ?? "", + tierLabel: tierLabel ?? "", + currentPoint: currentPoint ?? 0 + ) + } +} diff --git a/Projects/Data/Model/Sources/Profile/Mapper/NotificationSettingsDataDTO+.swift b/Projects/Data/Model/Sources/Profile/Mapper/NotificationSettingsDataDTO+.swift new file mode 100644 index 0000000..189dc23 --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/Mapper/NotificationSettingsDataDTO+.swift @@ -0,0 +1,20 @@ +// +// NotificationSettingsDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension NotificationSettingsDataDTO { + func toDomain() -> NotificationSettings { + NotificationSettings( + newBattleEnabled: newBattleEnabled ?? false, + battleResultEnabled: battleResultEnabled ?? false, + commentReplyEnabled: commentReplyEnabled ?? false, + newCommentEnabled: newCommentEnabled ?? false, + contentLikeEnabled: contentLikeEnabled ?? false, + marketingEventEnabled: marketingEventEnabled ?? false + ) + } +} diff --git a/Projects/Data/Model/Sources/Profile/Mapper/RecapDataDTO+.swift b/Projects/Data/Model/Sources/Profile/Mapper/RecapDataDTO+.swift new file mode 100644 index 0000000..1a425cf --- /dev/null +++ b/Projects/Data/Model/Sources/Profile/Mapper/RecapDataDTO+.swift @@ -0,0 +1,66 @@ +// +// RecapDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension RecapDataDTO { + func toDomain() -> PhilosopherRecap { + PhilosopherRecap( + myCard: myCard?.toDomain() ?? .empty, + bestMatchCard: bestMatchCard?.toDomain() ?? .empty, + worstMatchCard: worstMatchCard?.toDomain() ?? .empty, + scores: scores?.toDomain() ?? .empty, + preferenceReport: preferenceReport?.toDomain() ?? .empty + ) + } +} + +public extension RecapCardDTO { + func toDomain() -> RecapCard { + RecapCard( + philosopherType: philosopherType ?? "", + philosopherLabel: philosopherLabel ?? "", + typeName: typeName ?? "", + description: description ?? "", + keywordTags: keywordTags ?? [], + imageURL: imageUrl ?? "" + ) + } +} + +public extension RecapScoresDTO { + func toDomain() -> RecapScores { + RecapScores( + principle: principle ?? 0, + reason: reason ?? 0, + individual: individual ?? 0, + change: change ?? 0, + inner: inner ?? 0, + ideal: ideal ?? 0 + ) + } +} + +public extension PreferenceReportDTO { + func toDomain() -> PreferenceReport { + PreferenceReport( + totalParticipation: totalParticipation ?? 0, + opinionChanges: opinionChanges ?? 0, + battleWinRate: battleWinRate ?? 0, + favoriteTopics: (favoriteTopics ?? []).map { $0.toDomain() } + ) + } +} + +public extension FavoriteTopicDTO { + func toDomain() -> FavoriteTopic { + FavoriteTopic( + rank: rank ?? 0, + tagName: tagName ?? "", + participationCount: participationCount ?? 0 + ) + } +} diff --git a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift index d4aa868..bbc8e78 100644 --- a/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Battle/BattleRepositoryImpl.swift @@ -195,4 +195,26 @@ public final class BattleRepositoryImpl: BattleInterface, @unchecked Sendable { return data.toDomain() } + + public func proposeBattle(_ draft: BattleProposalDraft) async throws -> BattleProposal { + let dto: BattleProposalResponseDTO = try await provider.request( + .createProposal( + body: BattleProposalRequest( + category: draft.category, + topic: draft.topic, + positionA: draft.positionA, + positionB: draft.positionB, + description: draft.description + ) + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "배틀 제안 응답이 비어 있습니다" + Log.error("[BattleRepositoryImpl] empty proposeBattle payload: \(message)") + throw BattleError.backendError(message) + } + + return data.toDomain() + } } diff --git a/Projects/Data/Repository/Sources/Notification/NotificationRepositoryImpl.swift b/Projects/Data/Repository/Sources/Notification/NotificationRepositoryImpl.swift new file mode 100644 index 0000000..2806fd5 --- /dev/null +++ b/Projects/Data/Repository/Sources/Notification/NotificationRepositoryImpl.swift @@ -0,0 +1,74 @@ +// +// NotificationRepositoryImpl.swift +// Repository +// + +import Foundation + +import DomainInterface +import Entity +import Model +import Service + +import LogMacro +import Moya + +@preconcurrency import AsyncMoya + +public final class NotificationRepositoryImpl: NotificationInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } + + public func fetchNotifications( + category: NotificationCategory, + page: Int, + size: Int + ) async throws -> NotificationPage { + let dto: NotificationResponseDTO = try await provider.request( + .list( + query: NotificationsQueryRequest( + category: category.rawValue, + page: page, + size: size + ) + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "알림 응답이 비어 있습니다" + Log.error("[NotificationRepositoryImpl] empty notifications payload: \(message)") + throw NotificationError.backendError(message) + } + + return data.toDomain() + } + + public func fetchNotificationDetail(notificationId: Int) async throws -> NotificationDetail { + let dto: NotificationDetailResponseDTO = try await provider.request( + .detail(notificationId: notificationId) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "알림 상세 응답이 비어 있습니다" + Log.error("[NotificationRepositoryImpl] empty notification detail payload: \(message)") + throw NotificationError.backendError(message) + } + + return data.toDomain() + } + + public func markAsRead(notificationId: Int) async throws { + let _: BaseResponseDTO = try await provider.request( + .read(notificationId: notificationId) + ) + } + + public func markAllAsRead() async throws { + let _: BaseResponseDTO = try await provider.request(.readAll) + } +} diff --git a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift new file mode 100644 index 0000000..d1d1c94 --- /dev/null +++ b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift @@ -0,0 +1,151 @@ +// +// ProfileRepositoryImpl.swift +// Repository +// + +import Foundation + +import DomainInterface +import Entity +import Model +import Service + +import LogMacro +import Moya + +@preconcurrency import AsyncMoya + +public final class ProfileRepositoryImpl: ProfileInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } + + public func fetchMyPage() async throws -> MyPage { + let dto: MyPageResponseDTO = try await provider.request(.mypage) + + guard let data = dto.data else { + let message = dto.error?.message ?? "마이페이지 응답이 비어 있습니다" + Log.error("[ProfileRepositoryImpl] empty myPage payload: \(message)") + throw ProfileError.backendError(message) + } + + return data.toDomain() + } + + public func fetchRecap() async throws -> PhilosopherRecap { + let dto: RecapResponseDTO = try await provider.request(.recap) + + // 배틀 5개 미만 사용자는 서버가 data: nil (200) 로 응답 → 잠금 상태. + // 에러로 던지지 않고 빈 recap(totalParticipation 0 → isLocked)으로 반환. + guard let data = dto.data else { + Log.info("[ProfileRepositoryImpl] recap 빈 응답 → 잠금 화면 반환") + return .empty + } + + return data.toDomain() + } + + public func fetchCreditHistory( + offset: Int, + size: Int + ) async throws -> CreditHistoryPage { + let dto: CreditHistoryResponseDTO = try await provider.request( + .creditsHistory(query: CreditHistoryQueryRequest(offset: offset, size: size)) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "크레딧 내역 응답이 비어 있습니다" + Log.error("[ProfileRepositoryImpl] empty creditHistory payload: \(message)") + throw ProfileError.backendError(message) + } + + return data.toDomain() + } + + public func fetchBattleRecords( + offset: Int, + size: Int, + voteSide: BattleVoteSide? + ) async throws -> BattleRecordPage { + let dto: BattleRecordResponseDTO = try await provider.request( + .battleRecords( + query: BattleRecordsQueryRequest( + offset: offset, + size: size, + voteSide: voteSide.flatMap { $0 == .unknown ? nil : $0.rawValue } + ) + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "배틀 기록 응답이 비어 있습니다" + Log.error("[ProfileRepositoryImpl] empty battleRecords payload: \(message)") + throw ProfileError.backendError(message) + } + + return data.toDomain() + } + + public func fetchContentActivities( + offset: Int, + size: Int, + activityType: ContentActivityType? + ) async throws -> ContentActivityPage { + let dto: ContentActivityResponseDTO = try await provider.request( + .contentActivities( + query: ContentActivitiesQueryRequest( + offset: offset, + size: size, + activityType: activityType.flatMap(\.rawValue) + ) + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "콘텐츠 활동 응답이 비어 있습니다" + Log.error("[ProfileRepositoryImpl] empty contentActivities payload: \(message)") + throw ProfileError.backendError(message) + } + + return data.toDomain() + } + + public func fetchNotificationSettings() async throws -> NotificationSettings { + let dto: NotificationSettingsResponseDTO = try await provider.request(.notificationSettings) + + guard let data = dto.data else { + let message = dto.error?.message ?? "알림 설정 응답이 비어 있습니다" + Log.error("[ProfileRepositoryImpl] empty notificationSettings payload: \(message)") + throw ProfileError.backendError(message) + } + + return data.toDomain() + } + + public func updateNotificationSettings(_ settings: NotificationSettings) async throws -> NotificationSettings { + let dto: NotificationSettingsResponseDTO = try await provider.request( + .updateNotificationSettings( + body: NotificationSettingsRequest( + newBattleEnabled: settings.newBattleEnabled, + battleResultEnabled: settings.battleResultEnabled, + commentReplyEnabled: settings.commentReplyEnabled, + newCommentEnabled: settings.newCommentEnabled, + contentLikeEnabled: settings.contentLikeEnabled, + marketingEventEnabled: settings.marketingEventEnabled + ) + ) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "알림 설정 응답이 비어 있습니다" + Log.error("[ProfileRepositoryImpl] empty updateNotificationSettings payload: \(message)") + throw ProfileError.backendError(message) + } + + return data.toDomain() + } +} diff --git a/Projects/Data/Service/Sources/Battle/BattleProposalRequest.swift b/Projects/Data/Service/Sources/Battle/BattleProposalRequest.swift new file mode 100644 index 0000000..480e204 --- /dev/null +++ b/Projects/Data/Service/Sources/Battle/BattleProposalRequest.swift @@ -0,0 +1,45 @@ +// +// BattleProposalRequest.swift +// Service +// + +import Foundation + +public struct BattleProposalRequest: Encodable { + public let category: String + public let topic: String + public let positionA: String + public let positionB: String + public let description: String + + public init( + category: String, + topic: String, + positionA: String, + positionB: String, + description: String + ) { + self.category = category + self.topic = topic + self.positionA = positionA + self.positionB = positionB + self.description = description + } + + private enum CodingKeys: String, CodingKey { + case category + case topic + case positionA + case positionB + case description + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(category, forKey: .category) + try container.encode(topic, forKey: .topic) + try container.encode(positionA, forKey: .positionA) + try container.encode(positionB, forKey: .positionB) + try container.encode(description, forKey: .description) + } +} diff --git a/Projects/Data/Service/Sources/Battle/BattleService.swift b/Projects/Data/Service/Sources/Battle/BattleService.swift index a77e396..94deef2 100644 --- a/Projects/Data/Service/Sources/Battle/BattleService.swift +++ b/Projects/Data/Service/Sources/Battle/BattleService.swift @@ -21,6 +21,7 @@ public enum BattleService { case createPerspective(battleId: Int, body: CreatePerspectiveRequest) case myPerspective(battleId: Int) case recommendations(battleId: Int) + case createProposal(body: BattleProposalRequest) } extension BattleService: BaseTargetType { @@ -50,6 +51,8 @@ extension BattleService: BaseTargetType { return BattleAPI.myPerspective(battleId: battleId).description case let .recommendations(battleId): return BattleAPI.recommendations(battleId: battleId).description + case .createProposal: + return BattleAPI.proposals.description } } @@ -59,7 +62,7 @@ extension BattleService: BaseTargetType { switch self { case .today, .detail, .scenario, .voteStats, .perspectives, .myPerspective, .recommendations: return .get - case .preVote, .postVote, .createPerspective: + case .preVote, .postVote, .createPerspective, .createProposal: return .post } } @@ -83,6 +86,8 @@ extension BattleService: BaseTargetType { return dict.isEmpty ? nil : dict case let .createPerspective(_, body): return body.toDictionary + case let .createProposal(body): + return body.toDictionary case .myPerspective: return nil case .recommendations: diff --git a/Projects/Data/Service/Sources/Notification/NotificationService.swift b/Projects/Data/Service/Sources/Notification/NotificationService.swift new file mode 100644 index 0000000..5eb4a9b --- /dev/null +++ b/Projects/Data/Service/Sources/Notification/NotificationService.swift @@ -0,0 +1,62 @@ +// +// NotificationService.swift +// Service +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum NotificationService { + case list(query: NotificationsQueryRequest) + case detail(notificationId: Int) + case read(notificationId: Int) + case readAll +} + +extension NotificationService: BaseTargetType { + public typealias Domain = PieckeDomain + + public var domain: PieckeDomain { .notification } + + public var urlPath: String { + switch self { + case .list: + return NotificationAPI.list.description + case let .detail(notificationId): + return NotificationAPI.detail(notificationId: notificationId).description + case let .read(notificationId): + return NotificationAPI.read(notificationId: notificationId).description + case .readAll: + return NotificationAPI.readAll.description + } + } + + public var error: [Int: AsyncMoya.NetworkError]? { nil } + + public var method: Moya.Method { + switch self { + case .list, .detail: + return .get + case .read, .readAll: + return .post + } + } + + public var parameters: [String: Any]? { + switch self { + case let .list(query): + guard let dict = query.toDictionary else { return nil } + return dict.isEmpty ? nil : dict + case .detail, .read, .readAll: + return nil + } + } + + public var headers: [String: String]? { + return APIHeader.baseHeader + } +} diff --git a/Projects/Data/Service/Sources/Notification/NotificationsQueryRequest.swift b/Projects/Data/Service/Sources/Notification/NotificationsQueryRequest.swift new file mode 100644 index 0000000..54a320d --- /dev/null +++ b/Projects/Data/Service/Sources/Notification/NotificationsQueryRequest.swift @@ -0,0 +1,25 @@ +// +// NotificationsQueryRequest.swift +// Service +// +// GET /api/v1/notifications 쿼리 파라미터. +// + +import Foundation + +public struct NotificationsQueryRequest: Encodable { + /// ALL / CONTENT / NOTICE / EVENT. + public let category: String? + public let page: Int? + public let size: Int? + + public init( + category: String? = nil, + page: Int? = nil, + size: Int? = nil + ) { + self.category = category + self.page = page + self.size = size + } +} diff --git a/Projects/Data/Service/Sources/Profile/BattleRecordsQueryRequest.swift b/Projects/Data/Service/Sources/Profile/BattleRecordsQueryRequest.swift new file mode 100644 index 0000000..3149100 --- /dev/null +++ b/Projects/Data/Service/Sources/Profile/BattleRecordsQueryRequest.swift @@ -0,0 +1,31 @@ +// +// BattleRecordsQueryRequest.swift +// Service +// +// GET /api/v1/me/battle-records 쿼리 파라미터. +// + +import Foundation + +public struct BattleRecordsQueryRequest: Encodable { + public let offset: Int? + public let size: Int? + /// 투표 진영 필터 (PRO / CON). nil 이면 전체. + public let voteSide: String? + + enum CodingKeys: String, CodingKey { + case offset + case size + case voteSide = "vote_side" + } + + public init( + offset: Int? = nil, + size: Int? = nil, + voteSide: String? = nil + ) { + self.offset = offset + self.size = size + self.voteSide = voteSide + } +} diff --git a/Projects/Data/Service/Sources/Profile/ContentActivitiesQueryRequest.swift b/Projects/Data/Service/Sources/Profile/ContentActivitiesQueryRequest.swift new file mode 100644 index 0000000..a0bf4b4 --- /dev/null +++ b/Projects/Data/Service/Sources/Profile/ContentActivitiesQueryRequest.swift @@ -0,0 +1,31 @@ +// +// ContentActivitiesQueryRequest.swift +// Service +// +// GET /api/v1/me/content-activities 쿼리 파라미터. +// + +import Foundation + +public struct ContentActivitiesQueryRequest: Encodable { + public let offset: Int? + public let size: Int? + /// 활동 유형 필터 (COMMENT / LIKE). nil 이면 전체. + public let activityType: String? + + enum CodingKeys: String, CodingKey { + case offset + case size + case activityType = "activity_type" + } + + public init( + offset: Int? = nil, + size: Int? = nil, + activityType: String? = nil + ) { + self.offset = offset + self.size = size + self.activityType = activityType + } +} diff --git a/Projects/Data/Service/Sources/Profile/CreditHistoryQueryRequest.swift b/Projects/Data/Service/Sources/Profile/CreditHistoryQueryRequest.swift new file mode 100644 index 0000000..1e6ba2a --- /dev/null +++ b/Projects/Data/Service/Sources/Profile/CreditHistoryQueryRequest.swift @@ -0,0 +1,21 @@ +// +// CreditHistoryQueryRequest.swift +// Service +// +// GET /api/v1/me/credits/history 쿼리 파라미터. +// + +import Foundation + +public struct CreditHistoryQueryRequest: Encodable { + public let offset: Int? + public let size: Int? + + public init( + offset: Int? = nil, + size: Int? = nil + ) { + self.offset = offset + self.size = size + } +} diff --git a/Projects/Data/Service/Sources/Profile/NotificationSettingsRequest.swift b/Projects/Data/Service/Sources/Profile/NotificationSettingsRequest.swift new file mode 100644 index 0000000..dcc6ed4 --- /dev/null +++ b/Projects/Data/Service/Sources/Profile/NotificationSettingsRequest.swift @@ -0,0 +1,33 @@ +// +// NotificationSettingsRequest.swift +// Service +// +// `PATCH /api/v1/me/notification-settings` 요청 body. +// + +import Foundation + +public struct NotificationSettingsRequest: Encodable { + public let newBattleEnabled: Bool + public let battleResultEnabled: Bool + public let commentReplyEnabled: Bool + public let newCommentEnabled: Bool + public let contentLikeEnabled: Bool + public let marketingEventEnabled: Bool + + public init( + newBattleEnabled: Bool, + battleResultEnabled: Bool, + commentReplyEnabled: Bool, + newCommentEnabled: Bool, + contentLikeEnabled: Bool, + marketingEventEnabled: Bool + ) { + self.newBattleEnabled = newBattleEnabled + self.battleResultEnabled = battleResultEnabled + self.commentReplyEnabled = commentReplyEnabled + self.newCommentEnabled = newCommentEnabled + self.contentLikeEnabled = contentLikeEnabled + self.marketingEventEnabled = marketingEventEnabled + } +} diff --git a/Projects/Data/Service/Sources/Profile/ProfileService.swift b/Projects/Data/Service/Sources/Profile/ProfileService.swift new file mode 100644 index 0000000..9ff10f1 --- /dev/null +++ b/Projects/Data/Service/Sources/Profile/ProfileService.swift @@ -0,0 +1,84 @@ +// +// ProfileService.swift +// Service +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum ProfileService { + case mypage + case recap + case creditsHistory(query: CreditHistoryQueryRequest) + case battleRecords(query: BattleRecordsQueryRequest) + case contentActivities(query: ContentActivitiesQueryRequest) + case notificationSettings + case updateNotificationSettings(body: NotificationSettingsRequest) +} + +extension ProfileService: BaseTargetType { + public typealias Domain = PieckeDomain + + public var domain: PieckeDomain { .profile } + + public var urlPath: String { + switch self { + case .mypage: + return ProfileAPI.mypage.description + case .recap: + return ProfileAPI.recap.description + case .creditsHistory: + return ProfileAPI.creditsHistory.description + case .battleRecords: + return ProfileAPI.battleRecords.description + case .contentActivities: + return ProfileAPI.contentActivities.description + case .notificationSettings: + return ProfileAPI.notificationSettings.description + case .updateNotificationSettings: + return ProfileAPI.notificationSettings.description + } + } + + public var error: [Int: AsyncMoya.NetworkError]? { nil } + + public var method: Moya.Method { + switch self { + case .mypage, .recap, .creditsHistory, .battleRecords, .contentActivities, .notificationSettings: + return .get + case .updateNotificationSettings: + return .patch + } + } + + public var parameters: [String: Any]? { + switch self { + case .mypage: + return nil + case .recap: + return nil + case let .creditsHistory(query): + guard let dict = query.toDictionary else { return nil } + return dict.isEmpty ? nil : dict + case let .battleRecords(query): + guard let dict = query.toDictionary else { return nil } + return dict.isEmpty ? nil : dict + case let .contentActivities(query): + guard let dict = query.toDictionary else { return nil } + return dict.isEmpty ? nil : dict + case .notificationSettings: + return nil + case let .updateNotificationSettings(body): + guard let dict = body.toDictionary else { return nil } + return dict.isEmpty ? nil : dict + } + } + + public var headers: [String: String]? { + return APIHeader.baseHeader + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift index db3452c..ff8c2e8 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/BattleInterface.swift @@ -34,6 +34,7 @@ public protocol BattleInterface: Sendable { ) async throws -> BattlePerspective func fetchMyPerspective(battleId: Int) async throws -> BattlePerspective? func fetchRecommendedBattles(battleId: Int) async throws -> RecommendedBattlePage + func proposeBattle(_ draft: BattleProposalDraft) async throws -> BattleProposal } public struct BattleRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift index 23eced7..d86add5 100644 --- a/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Battle/DefaultBattleRepositoryImpl.swift @@ -102,4 +102,19 @@ public struct DefaultBattleRepositoryImpl: BattleInterface { public func fetchRecommendedBattles(battleId _: Int) async throws -> RecommendedBattlePage { RecommendedBattlePage(items: [], nextCursor: nil, hasNext: false) } + + public func proposeBattle(_: BattleProposalDraft) async throws -> BattleProposal { + BattleProposal( + id: 0, + userId: 0, + nickname: "", + category: "", + topic: "", + positionA: "", + positionB: "", + description: "", + status: "", + createdAt: nil + ) + } } diff --git a/Projects/Domain/DomainInterface/Sources/Notification/DefaultNotificationRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Notification/DefaultNotificationRepositoryImpl.swift new file mode 100644 index 0000000..3fdebbc --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Notification/DefaultNotificationRepositoryImpl.swift @@ -0,0 +1,37 @@ +// +// DefaultNotificationRepositoryImpl.swift +// DomainInterface +// + +import Entity +import Foundation + +public struct DefaultNotificationRepositoryImpl: NotificationInterface { + public init() {} + + public func fetchNotifications( + category _: NotificationCategory, + page _: Int, + size _: Int + ) async throws -> NotificationPage { + NotificationPage(items: [], hasNext: false) + } + + public func fetchNotificationDetail(notificationId: Int) async throws -> NotificationDetail { + NotificationDetail( + notificationId: notificationId, + category: .all, + detailCode: "", + title: "", + body: "", + referenceId: nil, + isRead: false, + createdAt: nil, + readAt: nil + ) + } + + public func markAsRead(notificationId _: Int) async throws {} + + public func markAllAsRead() async throws {} +} diff --git a/Projects/Domain/DomainInterface/Sources/Notification/NotificationInterface.swift b/Projects/Domain/DomainInterface/Sources/Notification/NotificationInterface.swift new file mode 100644 index 0000000..c5ac150 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Notification/NotificationInterface.swift @@ -0,0 +1,38 @@ +// +// NotificationInterface.swift +// DomainInterface +// + +import Entity +import Foundation +import WeaveDI + +public protocol NotificationInterface: Sendable { + func fetchNotifications( + category: NotificationCategory, + page: Int, + size: Int + ) async throws -> NotificationPage + func fetchNotificationDetail(notificationId: Int) async throws -> NotificationDetail + func markAsRead(notificationId: Int) async throws + func markAllAsRead() async throws +} + +public struct NotificationRepositoryDependency: DependencyKey { + public static var liveValue: NotificationInterface { + UnifiedDI.resolve(NotificationInterface.self) ?? DefaultNotificationRepositoryImpl() + } + + public static var testValue: NotificationInterface { + UnifiedDI.resolve(NotificationInterface.self) ?? DefaultNotificationRepositoryImpl() + } + + public static var previewValue: NotificationInterface = liveValue +} + +public extension DependencyValues { + var notificationRepository: NotificationInterface { + get { self[NotificationRepositoryDependency.self] } + set { self[NotificationRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift new file mode 100644 index 0000000..35401b4 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Profile/DefaultProfileRepositoryImpl.swift @@ -0,0 +1,110 @@ +// +// DefaultProfileRepositoryImpl.swift +// DomainInterface +// + +import Entity +import Foundation + +public struct DefaultProfileRepositoryImpl: ProfileInterface { + public init() {} + + public func fetchMyPage() async throws -> MyPage { + MyPage( + profile: MyProfile( + userTag: "", + nickname: "", + characterType: "", + characterLabel: "", + characterImageURL: "", + mannerTemperature: 0 + ), + philosopher: MyPhilosopher( + philosopherType: "", + philosopherLabel: "", + typeName: "", + description: "", + imageURL: "" + ), + tier: MyTier( + tierCode: "", + tierLabel: "", + currentPoint: 0 + ) + ) + } + + public func fetchRecap() async throws -> PhilosopherRecap { + PhilosopherRecap( + myCard: RecapCard( + philosopherType: "", + philosopherLabel: "", + typeName: "", + description: "", + keywordTags: [], + imageURL: "" + ), + bestMatchCard: RecapCard( + philosopherType: "", + philosopherLabel: "", + typeName: "", + description: "", + keywordTags: [], + imageURL: "" + ), + worstMatchCard: RecapCard( + philosopherType: "", + philosopherLabel: "", + typeName: "", + description: "", + keywordTags: [], + imageURL: "" + ), + scores: RecapScores( + principle: 0, + reason: 0, + individual: 0, + change: 0, + inner: 0, + ideal: 0 + ), + preferenceReport: PreferenceReport( + totalParticipation: 0, + opinionChanges: 0, + battleWinRate: 0, + favoriteTopics: [] + ) + ) + } + + public func fetchCreditHistory( + offset _: Int, + size _: Int + ) async throws -> CreditHistoryPage { + CreditHistoryPage(items: [], nextOffset: 0, hasNext: false) + } + + public func fetchBattleRecords( + offset _: Int, + size _: Int, + voteSide _: BattleVoteSide? + ) async throws -> BattleRecordPage { + BattleRecordPage(items: [], nextOffset: 0, hasNext: false) + } + + public func fetchContentActivities( + offset _: Int, + size _: Int, + activityType _: ContentActivityType? + ) async throws -> ContentActivityPage { + ContentActivityPage(items: [], nextOffset: 0, hasNext: false) + } + + public func fetchNotificationSettings() async throws -> NotificationSettings { + NotificationSettings() + } + + public func updateNotificationSettings(_: NotificationSettings) async throws -> NotificationSettings { + NotificationSettings() + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift new file mode 100644 index 0000000..af998aa --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Profile/ProfileInterface.swift @@ -0,0 +1,48 @@ +// +// ProfileInterface.swift +// DomainInterface +// + +import Entity +import Foundation +import WeaveDI + +public protocol ProfileInterface: Sendable { + func fetchMyPage() async throws -> MyPage + func fetchRecap() async throws -> PhilosopherRecap + func fetchCreditHistory( + offset: Int, + size: Int + ) async throws -> CreditHistoryPage + func fetchBattleRecords( + offset: Int, + size: Int, + voteSide: BattleVoteSide? + ) async throws -> BattleRecordPage + func fetchContentActivities( + offset: Int, + size: Int, + activityType: ContentActivityType? + ) async throws -> ContentActivityPage + func fetchNotificationSettings() async throws -> NotificationSettings + func updateNotificationSettings(_ settings: NotificationSettings) async throws -> NotificationSettings +} + +public struct ProfileRepositoryDependency: DependencyKey { + public static var liveValue: ProfileInterface { + UnifiedDI.resolve(ProfileInterface.self) ?? DefaultProfileRepositoryImpl() + } + + public static var testValue: ProfileInterface { + UnifiedDI.resolve(ProfileInterface.self) ?? DefaultProfileRepositoryImpl() + } + + public static var previewValue: ProfileInterface = liveValue +} + +public extension DependencyValues { + var profileRepository: ProfileInterface { + get { self[ProfileRepositoryDependency.self] } + set { self[ProfileRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/Entity/Sources/Base.swift b/Projects/Domain/Entity/Sources/Base.swift deleted file mode 100644 index fc5212e..0000000 --- a/Projects/Domain/Entity/Sources/Base.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// base.swift -// DDDAttendance. -// -// Created by Roy on 2025-10-22 -// Copyright © 2025 DDD , Ltd., All rights reserved. -// - -import SwiftUI - -struct BaseView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("Hello, world!") - } - .padding() - } -} - diff --git a/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposal.swift b/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposal.swift new file mode 100644 index 0000000..693aaf1 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposal.swift @@ -0,0 +1,46 @@ +// +// BattleProposal.swift +// Entity +// +// 배틀 주제 제안 결과 (POST /api/v1/battles/proposals 응답). +// + +import Foundation + +public struct BattleProposal: Equatable, Identifiable { + public let id: Int + public let userId: Int + public let nickname: String + public let category: String + public let topic: String + public let positionA: String + public let positionB: String + public let description: String + /// 제안 상태 (예: `PENDING`). + public let status: String + public let createdAt: Date? + + public init( + id: Int, + userId: Int, + nickname: String, + category: String, + topic: String, + positionA: String, + positionB: String, + description: String, + status: String, + createdAt: Date? + ) { + self.id = id + self.userId = userId + self.nickname = nickname + self.category = category + self.topic = topic + self.positionA = positionA + self.positionB = positionB + self.description = description + self.status = status + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposalCategory.swift b/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposalCategory.swift new file mode 100644 index 0000000..ac2779e --- /dev/null +++ b/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposalCategory.swift @@ -0,0 +1,22 @@ +// +// BattleProposalCategory.swift +// Entity +// +// 배틀 주제 제안 카테고리 (POST /api/v1/battles/proposals 의 category). +// + +import Foundation + +public enum BattleProposalCategory: String, CaseIterable, Identifiable, Equatable { + case philosophy = "철학" + case literature = "문학" + case art = "예술" + case science = "과학" + case society = "사회" + case history = "역사" + + public var id: String { rawValue } + + /// 표시명 (= API 전송값). + public var title: String { rawValue } +} diff --git a/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposalDraft.swift b/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposalDraft.swift new file mode 100644 index 0000000..db79f22 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Battle/Proposal/BattleProposalDraft.swift @@ -0,0 +1,30 @@ +// +// BattleProposalDraft.swift +// Entity +// +// 배틀 주제 제안 입력 묶음 (POST /api/v1/battles/proposals 요청 값). +// + +import Foundation + +public struct BattleProposalDraft: Equatable { + public let category: String + public let topic: String + public let positionA: String + public let positionB: String + public let description: String + + public init( + category: String, + topic: String, + positionA: String, + positionB: String, + description: String + ) { + self.category = category + self.topic = topic + self.positionA = positionA + self.positionB = positionB + self.description = description + } +} diff --git a/Projects/Domain/Entity/Sources/Error/ProfileError.swift b/Projects/Domain/Entity/Sources/Error/ProfileError.swift new file mode 100644 index 0000000..6537385 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Error/ProfileError.swift @@ -0,0 +1,44 @@ +// +// ProfileError.swift +// Entity +// +// 프로필(마이페이지) 도메인 표준 에러. +// + +import Foundation + +public enum ProfileError: LocalizedError, Equatable { + case networkError(String) + case decodingError(String) + case noData + case unauthorized + case backendError(String) + case serverError(Int) + case unknown(String) + + public var errorDescription: String? { + switch self { + case let .networkError(message): + "네트워크 오류: \(message)" + case let .decodingError(message): + "데이터 파싱 오류: \(message)" + case .noData: + "프로필 데이터가 없습니다" + case .unauthorized: + "권한이 없습니다" + case let .backendError(message): + "프로필 처리 실패: \(message)" + case let .serverError(code): + "서버 오류 (코드: \(code))" + case let .unknown(message): + "알 수 없는 오류: \(message)" + } + } +} + +public extension ProfileError { + static func from(_ error: Error) -> ProfileError { + if let profileError = error as? ProfileError { return profileError } + return .unknown(error.localizedDescription) + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift b/Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift deleted file mode 100644 index bd45191..0000000 --- a/Projects/Domain/Entity/Sources/Home/Battle/BattleDetail.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// BattleDetail.swift -// Entity -// -// `GET /api/v1/battles/{battleId}` 응답 도메인 모델. -// - -import Foundation - -public struct BattleDetail: Equatable, Identifiable { - public let battleInfo: BattleInfo - public let description: String - public let shareUrl: String - public let userVoteStatus: UserVoteStatus - public let currentStep: BattleStep - public let categoryTags: [BattleTag] - public let philosopherTags: [BattleTag] - public let valueTags: [BattleTag] - - public var id: Int { battleInfo.battleId } - - public init( - battleInfo: BattleInfo, - description: String, - shareUrl: String, - userVoteStatus: UserVoteStatus, - currentStep: BattleStep, - categoryTags: [BattleTag], - philosopherTags: [BattleTag], - valueTags: [BattleTag] - ) { - self.battleInfo = battleInfo - self.description = description - self.shareUrl = shareUrl - self.userVoteStatus = userVoteStatus - self.currentStep = currentStep - self.categoryTags = categoryTags - self.philosopherTags = philosopherTags - self.valueTags = valueTags - } -} - -public struct BattleInfo: Equatable, Identifiable { - public let battleId: Int - public let title: String - public let summary: String - public let thumbnailUrl: String - public let viewCount: Int - public let participantsCount: Int - public let audioDuration: Int - public let tags: [BattleTag] - public let options: [BattleOption] - - public var id: Int { battleId } - - public init( - battleId: Int, - title: String, - summary: String, - thumbnailUrl: String, - viewCount: Int, - participantsCount: Int, - audioDuration: Int, - tags: [BattleTag], - options: [BattleOption] - ) { - self.battleId = battleId - self.title = title - self.summary = summary - self.thumbnailUrl = thumbnailUrl - self.viewCount = viewCount - self.participantsCount = participantsCount - self.audioDuration = audioDuration - self.tags = tags - self.options = options - } -} - -public struct BattleOption: Equatable, Identifiable, Hashable { - public let optionId: Int - public let label: String - public let title: String - public let stance: String - public let representative: String - public let imageUrl: String - public let tags: [BattleTag] - - public var id: Int { optionId } - - public init( - optionId: Int, - label: String, - title: String, - stance: String, - representative: String, - imageUrl: String, - tags: [BattleTag] - ) { - self.optionId = optionId - self.label = label - self.title = title - self.stance = stance - self.representative = representative - self.imageUrl = imageUrl - self.tags = tags - } -} - -public enum UserVoteStatus: String, Equatable, Hashable, CaseIterable { - case none = "NONE" - case pro = "PRO" - case con = "CON" - case unknown - - public init(rawValue: String) { - self = UserVoteStatus.allCases.first { $0.rawValue == rawValue } ?? .unknown - } -} - -public enum BattleStep: String, Equatable, Hashable, CaseIterable { - case none = "NONE" - case preVote = "PRE_VOTE" - case listening = "LISTENING" - case postVote = "POST_VOTE" - case finished = "FINISHED" - case completed = "COMPLETED" - case unknown - - public init(rawValue: String) { - self = BattleStep.allCases.first { $0.rawValue == rawValue } ?? .unknown - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleDetail.swift b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleDetail.swift new file mode 100644 index 0000000..c2af166 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleDetail.swift @@ -0,0 +1,41 @@ +// +// BattleDetail.swift +// Entity +// +// `GET /api/v1/battles/{battleId}` 응답 도메인 모델. +// + +import Foundation + +public struct BattleDetail: Equatable, Identifiable { + public let battleInfo: BattleInfo + public let description: String + public let shareUrl: String + public let userVoteStatus: UserVoteStatus + public let currentStep: BattleStep + public let categoryTags: [BattleTag] + public let philosopherTags: [BattleTag] + public let valueTags: [BattleTag] + + public var id: Int { battleInfo.battleId } + + public init( + battleInfo: BattleInfo, + description: String, + shareUrl: String, + userVoteStatus: UserVoteStatus, + currentStep: BattleStep, + categoryTags: [BattleTag], + philosopherTags: [BattleTag], + valueTags: [BattleTag] + ) { + self.battleInfo = battleInfo + self.description = description + self.shareUrl = shareUrl + self.userVoteStatus = userVoteStatus + self.currentStep = currentStep + self.categoryTags = categoryTags + self.philosopherTags = philosopherTags + self.valueTags = valueTags + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleInfo.swift b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleInfo.swift new file mode 100644 index 0000000..eb6fa33 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleInfo.swift @@ -0,0 +1,42 @@ +// +// BattleInfo.swift +// Entity +// + +import Foundation + +public struct BattleInfo: Equatable, Identifiable { + public let battleId: Int + public let title: String + public let summary: String + public let thumbnailUrl: String + public let viewCount: Int + public let participantsCount: Int + public let audioDuration: Int + public let tags: [BattleTag] + public let options: [BattleOption] + + public var id: Int { battleId } + + public init( + battleId: Int, + title: String, + summary: String, + thumbnailUrl: String, + viewCount: Int, + participantsCount: Int, + audioDuration: Int, + tags: [BattleTag], + options: [BattleOption] + ) { + self.battleId = battleId + self.title = title + self.summary = summary + self.thumbnailUrl = thumbnailUrl + self.viewCount = viewCount + self.participantsCount = participantsCount + self.audioDuration = audioDuration + self.tags = tags + self.options = options + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleOption.swift b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleOption.swift new file mode 100644 index 0000000..6f53981 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleOption.swift @@ -0,0 +1,36 @@ +// +// BattleOption.swift +// Entity +// + +import Foundation + +public struct BattleOption: Equatable, Identifiable, Hashable { + public let optionId: Int + public let label: String + public let title: String + public let stance: String + public let representative: String + public let imageUrl: String + public let tags: [BattleTag] + + public var id: Int { optionId } + + public init( + optionId: Int, + label: String, + title: String, + stance: String, + representative: String, + imageUrl: String, + tags: [BattleTag] + ) { + self.optionId = optionId + self.label = label + self.title = title + self.stance = stance + self.representative = representative + self.imageUrl = imageUrl + self.tags = tags + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleStep.swift b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleStep.swift new file mode 100644 index 0000000..233baf5 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Detail/BattleStep.swift @@ -0,0 +1,20 @@ +// +// BattleStep.swift +// Entity +// + +import Foundation + +public enum BattleStep: String, Equatable, Hashable, CaseIterable { + case none = "NONE" + case preVote = "PRE_VOTE" + case listening = "LISTENING" + case postVote = "POST_VOTE" + case finished = "FINISHED" + case completed = "COMPLETED" + case unknown + + public init(rawValue: String) { + self = BattleStep.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Detail/UserVoteStatus.swift b/Projects/Domain/Entity/Sources/Home/Battle/Detail/UserVoteStatus.swift new file mode 100644 index 0000000..056db1c --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Detail/UserVoteStatus.swift @@ -0,0 +1,17 @@ +// +// UserVoteStatus.swift +// Entity +// + +import Foundation + +public enum UserVoteStatus: String, Equatable, Hashable, CaseIterable { + case none = "NONE" + case pro = "PRO" + case con = "CON" + case unknown + + public init(rawValue: String) { + self = UserVoteStatus.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PhilosopherAvatar.swift b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PhilosopherAvatar.swift new file mode 100644 index 0000000..cece14b --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PhilosopherAvatar.swift @@ -0,0 +1,13 @@ +// +// PhilosopherAvatar.swift +// Entity +// + +import Foundation + +/// 사전 투표창 / 새로운 배틀 카드에서 사용되는 철학자 아바타. raw value 는 화면 표시 이름. +public enum PhilosopherAvatar: String, CaseIterable, Equatable, Hashable { + case plato = "플라톤" + case sartre = "사르트르" + case sunja = "순자" +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/PreVoteBattle.swift b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteBattle.swift similarity index 73% rename from Projects/Domain/Entity/Sources/Home/Battle/PreVoteBattle.swift rename to Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteBattle.swift index c60b040..429d1fc 100644 --- a/Projects/Domain/Entity/Sources/Home/Battle/PreVoteBattle.swift +++ b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteBattle.swift @@ -42,34 +42,6 @@ public struct PreVoteBattle: Equatable, Identifiable { } } -public struct PreVoteOption: Equatable, Identifiable, Hashable { - public let optionId: Int - public let representative: String - public let imageURL: String - public let stance: String - - public var id: Int { optionId } - - public init( - optionId: Int, - representative: String, - imageURL: String, - stance: String - ) { - self.optionId = optionId - self.representative = representative - self.imageURL = imageURL - self.stance = stance - } -} - -/// 사전 투표창 / 새로운 배틀 카드에서 사용되는 철학자 아바타. raw value 는 화면 표시 이름. -public enum PhilosopherAvatar: String, CaseIterable, Equatable, Hashable { - case plato = "플라톤" - case sartre = "사르트르" - case sunja = "순자" -} - public extension PreVoteBattle { static let mock = PreVoteBattle( battleId: 41, diff --git a/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteOption.swift b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteOption.swift new file mode 100644 index 0000000..186de3e --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteOption.swift @@ -0,0 +1,27 @@ +// +// PreVoteOption.swift +// Entity +// + +import Foundation + +public struct PreVoteOption: Equatable, Identifiable, Hashable { + public let optionId: Int + public let representative: String + public let imageURL: String + public let stance: String + + public var id: Int { optionId } + + public init( + optionId: Int, + representative: String, + imageURL: String, + stance: String + ) { + self.optionId = optionId + self.representative = representative + self.imageURL = imageURL + self.stance = stance + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteResult.swift b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteResult.swift new file mode 100644 index 0000000..88edf84 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteResult.swift @@ -0,0 +1,19 @@ +// +// PreVoteResult.swift +// Entity +// + +import Foundation + +public struct PreVoteResult: Equatable, Hashable { + public let voteId: Int + public let status: PreVoteResultStatus + + public init( + voteId: Int, + status: PreVoteResultStatus + ) { + self.voteId = voteId + self.status = status + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/PreVoteResult.swift b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteResultStatus.swift similarity index 55% rename from Projects/Domain/Entity/Sources/Home/Battle/PreVoteResult.swift rename to Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteResultStatus.swift index 5744485..e95839f 100644 --- a/Projects/Domain/Entity/Sources/Home/Battle/PreVoteResult.swift +++ b/Projects/Domain/Entity/Sources/Home/Battle/PreVote/PreVoteResultStatus.swift @@ -1,23 +1,10 @@ // -// PreVoteResult.swift +// PreVoteResultStatus.swift // Entity // import Foundation -public struct PreVoteResult: Equatable, Hashable { - public let voteId: Int - public let status: PreVoteResultStatus - - public init( - voteId: Int, - status: PreVoteResultStatus - ) { - self.voteId = voteId - self.status = status - } -} - public enum PreVoteResultStatus: String, Equatable, Hashable, CaseIterable { case none = "NONE" case created = "CREATED" diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattle.swift b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattle.swift new file mode 100644 index 0000000..131a6ee --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattle.swift @@ -0,0 +1,42 @@ +// +// RecommendedBattle.swift +// Entity +// +// `GET /api/v1/battles/{battleId}/recommendations/interesting` 응답 도메인 모델. +// 특정 배틀 기준 흥미로운 배틀 추천 목록 + 커서 페이지네이션. +// + +import Foundation + +public struct RecommendedBattle: Equatable, Identifiable, Hashable { + public let battleId: Int + public let title: String + public let summary: String + public let audioDuration: Int + public let viewCount: Int + public let tags: [RecommendedBattleTag] + public let participantsCount: Int + public let options: [RecommendedBattleOption] + + public var id: Int { battleId } + + public init( + battleId: Int, + title: String, + summary: String, + audioDuration: Int, + viewCount: Int, + tags: [RecommendedBattleTag], + participantsCount: Int, + options: [RecommendedBattleOption] + ) { + self.battleId = battleId + self.title = title + self.summary = summary + self.audioDuration = audioDuration + self.viewCount = viewCount + self.tags = tags + self.participantsCount = participantsCount + self.options = options + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattleOption.swift b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattleOption.swift new file mode 100644 index 0000000..3fc8df2 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattleOption.swift @@ -0,0 +1,30 @@ +// +// RecommendedBattleOption.swift +// Entity +// + +import Foundation + +public struct RecommendedBattleOption: Equatable, Hashable, Identifiable { + public let optionId: Int + public let title: String + public let stance: String + public let representative: String + public let imageUrl: String? + + public var id: Int { optionId } + + public init( + optionId: Int, + title: String, + stance: String, + representative: String, + imageUrl: String? + ) { + self.optionId = optionId + self.title = title + self.stance = stance + self.representative = representative + self.imageUrl = imageUrl + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattlePage.swift b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattlePage.swift new file mode 100644 index 0000000..cea9e6b --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattlePage.swift @@ -0,0 +1,22 @@ +// +// RecommendedBattlePage.swift +// Entity +// + +import Foundation + +public struct RecommendedBattlePage: Equatable { + public let items: [RecommendedBattle] + public let nextCursor: String? + public let hasNext: Bool + + public init( + items: [RecommendedBattle], + nextCursor: String?, + hasNext: Bool + ) { + self.items = items + self.nextCursor = nextCursor + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattleTag.swift b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattleTag.swift new file mode 100644 index 0000000..f766cc2 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/Recommend/RecommendedBattleTag.swift @@ -0,0 +1,18 @@ +// +// RecommendedBattleTag.swift +// Entity +// + +import Foundation + +public struct RecommendedBattleTag: Equatable, Hashable, Identifiable { + public let tagId: Int + public let name: String + + public var id: Int { tagId } + + public init(tagId: Int, name: String) { + self.tagId = tagId + self.name = name + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/RecommendedBattle.swift b/Projects/Domain/Entity/Sources/Home/Battle/RecommendedBattle.swift deleted file mode 100644 index bbd9c55..0000000 --- a/Projects/Domain/Entity/Sources/Home/Battle/RecommendedBattle.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// RecommendedBattle.swift -// Entity -// -// `GET /api/v1/battles/{battleId}/recommendations/interesting` 응답 도메인 모델. -// 특정 배틀 기준 흥미로운 배틀 추천 목록 + 커서 페이지네이션. -// - -import Foundation - -public struct RecommendedBattlePage: Equatable { - public let items: [RecommendedBattle] - public let nextCursor: String? - public let hasNext: Bool - - public init( - items: [RecommendedBattle], - nextCursor: String?, - hasNext: Bool - ) { - self.items = items - self.nextCursor = nextCursor - self.hasNext = hasNext - } -} - -public struct RecommendedBattle: Equatable, Identifiable, Hashable { - public let battleId: Int - public let title: String - public let summary: String - public let audioDuration: Int - public let viewCount: Int - public let tags: [RecommendedBattleTag] - public let participantsCount: Int - public let options: [RecommendedBattleOption] - - public var id: Int { battleId } - - public init( - battleId: Int, - title: String, - summary: String, - audioDuration: Int, - viewCount: Int, - tags: [RecommendedBattleTag], - participantsCount: Int, - options: [RecommendedBattleOption] - ) { - self.battleId = battleId - self.title = title - self.summary = summary - self.audioDuration = audioDuration - self.viewCount = viewCount - self.tags = tags - self.participantsCount = participantsCount - self.options = options - } -} - -public struct RecommendedBattleTag: Equatable, Hashable, Identifiable { - public let tagId: Int - public let name: String - - public var id: Int { tagId } - - public init(tagId: Int, name: String) { - self.tagId = tagId - self.name = name - } -} - -public struct RecommendedBattleOption: Equatable, Hashable, Identifiable { - public let optionId: Int - public let title: String - public let stance: String - public let representative: String - public let imageUrl: String? - - public var id: Int { optionId } - - public init( - optionId: Int, - title: String, - stance: String, - representative: String, - imageUrl: String? - ) { - self.optionId = optionId - self.title = title - self.stance = stance - self.representative = representative - self.imageUrl = imageUrl - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/BattleVoteStats.swift b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/BattleVoteStats.swift new file mode 100644 index 0000000..e05defa --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/BattleVoteStats.swift @@ -0,0 +1,25 @@ +// +// BattleVoteStats.swift +// Entity +// +// `GET /api/v1/battles/{battleId}/vote-stats` 응답 도메인 모델. +// 댓글 화면 상단의 옵션별 비율 막대 / 참여자 수 표시에 사용. +// + +import Foundation + +public struct BattleVoteStats: Equatable { + public let options: [BattleVoteStatsOption] + public let totalCount: Int + public let updatedAt: Date? + + public init( + options: [BattleVoteStatsOption], + totalCount: Int, + updatedAt: Date? + ) { + self.options = options + self.totalCount = totalCount + self.updatedAt = updatedAt + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/BattleVoteStats.swift b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/BattleVoteStatsOption.swift similarity index 59% rename from Projects/Domain/Entity/Sources/Home/Battle/BattleVoteStats.swift rename to Projects/Domain/Entity/Sources/Home/Battle/VoteStats/BattleVoteStatsOption.swift index 71d5fe9..4be8056 100644 --- a/Projects/Domain/Entity/Sources/Home/Battle/BattleVoteStats.swift +++ b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/BattleVoteStatsOption.swift @@ -1,29 +1,10 @@ // -// BattleVoteStats.swift +// BattleVoteStatsOption.swift // Entity // -// `GET /api/v1/battles/{battleId}/vote-stats` 응답 도메인 모델. -// 댓글 화면 상단의 옵션별 비율 막대 / 참여자 수 표시에 사용. -// import Foundation -public struct BattleVoteStats: Equatable { - public let options: [BattleVoteStatsOption] - public let totalCount: Int - public let updatedAt: Date? - - public init( - options: [BattleVoteStatsOption], - totalCount: Int, - updatedAt: Date? - ) { - self.options = options - self.totalCount = totalCount - self.updatedAt = updatedAt - } -} - public struct BattleVoteStatsOption: Equatable, Identifiable, Hashable { public let optionId: Int public let label: String? diff --git a/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/VoteOptionSummary.swift b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/VoteOptionSummary.swift new file mode 100644 index 0000000..6138adc --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/VoteOptionSummary.swift @@ -0,0 +1,31 @@ +// +// VoteOptionSummary.swift +// Entity +// + +import Foundation + +public struct VoteOptionSummary: Equatable { + public var optionId: Int + public var label: String + public var title: String + public var representative: String + public var imageUrl: String? + public var percentage: Double + + public init( + optionId: Int = 0, + label: String, + title: String, + representative: String, + imageUrl: String? = nil, + percentage: Double + ) { + self.optionId = optionId + self.label = label + self.title = title + self.representative = representative + self.imageUrl = imageUrl + self.percentage = percentage + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/VoteSummary.swift b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/VoteSummary.swift new file mode 100644 index 0000000..bad26b4 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Battle/VoteStats/VoteSummary.swift @@ -0,0 +1,36 @@ +// +// VoteSummary.swift +// Entity +// +// 댓글 화면 상단 투표 통계 모델. +// + +import Foundation + +public struct VoteSummary: Equatable { + public var changeBadgeTitle: String + public var optionA: VoteOptionSummary + public var optionB: VoteOptionSummary + + public init( + changeBadgeTitle: String, + optionA: VoteOptionSummary, + optionB: VoteOptionSummary + ) { + self.changeBadgeTitle = changeBadgeTitle + self.optionA = optionA + self.optionB = optionB + } + + public static let mock = VoteSummary( + changeBadgeTitle: "생각이 바뀌었어요", + optionA: .init(label: "A", title: "변기는 변기다", representative: "플라톤", percentage: 0.595), + optionB: .init(label: "B", title: "예술이다", representative: "사르트르", percentage: 0.405) + ) + + public static let empty = VoteSummary( + changeBadgeTitle: "생각이 바뀌었어요", + optionA: .init(label: "A", title: "", representative: "", percentage: 0), + optionB: .init(label: "B", title: "", representative: "", percentage: 0) + ) +} diff --git a/Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift b/Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift deleted file mode 100644 index 187e136..0000000 --- a/Projects/Domain/Entity/Sources/Home/Battle/VoteSummary.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// VoteSummary.swift -// Entity -// -// 댓글 화면 상단 투표 통계 모델. -// - -import Foundation - -public struct VoteSummary: Equatable { - public var changeBadgeTitle: String - public var optionA: VoteOptionSummary - public var optionB: VoteOptionSummary - - public init( - changeBadgeTitle: String, - optionA: VoteOptionSummary, - optionB: VoteOptionSummary - ) { - self.changeBadgeTitle = changeBadgeTitle - self.optionA = optionA - self.optionB = optionB - } - - public static let mock = VoteSummary( - changeBadgeTitle: "생각이 바뀌었어요", - optionA: .init(label: "A", title: "변기는 변기다", representative: "플라톤", percentage: 0.595), - optionB: .init(label: "B", title: "예술이다", representative: "사르트르", percentage: 0.405) - ) - - public static let empty = VoteSummary( - changeBadgeTitle: "생각이 바뀌었어요", - optionA: .init(label: "A", title: "", representative: "", percentage: 0), - optionB: .init(label: "B", title: "", representative: "", percentage: 0) - ) -} - -public struct VoteOptionSummary: Equatable { - public var optionId: Int - public var label: String - public var title: String - public var representative: String - public var imageUrl: String? - public var percentage: Double - - public init( - optionId: Int = 0, - label: String, - title: String, - representative: String, - imageUrl: String? = nil, - percentage: Double - ) { - self.optionId = optionId - self.label = label - self.title = title - self.representative = representative - self.imageUrl = imageUrl - self.percentage = percentage - } -} - -public enum CommentFilter: String, CaseIterable, Equatable { - case all - case optionA - case optionB - - public var title: String { - switch self { - case .all: "전체" - case .optionA: "A" - case .optionB: "B" - } - } - - /// 서버 쿼리에 보낼 optionLabel — `all` 은 nil. - public var queryLabel: String? { - switch self { - case .all: nil - case .optionA: "A" - case .optionB: "B" - } - } -} - -public enum CommentSort: String, CaseIterable, Equatable { - case popular - case latest - - public var title: String { - switch self { - case .popular: "인기순" - case .latest: "최신순" - } - } - - public var perspectiveSort: BattlePerspectiveSort { - switch self { - case .popular: .popular - case .latest: .latest - } - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift b/Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift deleted file mode 100644 index c3fd6a1..0000000 --- a/Projects/Domain/Entity/Sources/Home/Comment/BattlePerspective.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// BattlePerspective.swift -// Entity -// -// `GET /api/v1/battles/{battleId}/perspectives` 응답 도메인 모델. -// 댓글(=관점) 리스트 + 커서 페이지네이션. -// - -import Foundation - -public struct BattlePerspectivePage: Equatable { - public let items: [BattlePerspective] - public let nextCursor: String? - public let hasNext: Bool - - public init( - items: [BattlePerspective], - nextCursor: String?, - hasNext: Bool - ) { - self.items = items - self.nextCursor = nextCursor - self.hasNext = hasNext - } -} - -public struct BattlePerspective: Equatable, Identifiable, Hashable { - public let perspectiveId: Int - public let user: BattlePerspectiveUser - public let option: BattlePerspectiveOption - public let content: String - public let likeCount: Int - public let commentCount: Int - public let isLiked: Bool - public let isMyPerspective: Bool - public let createdAt: Date? - - public var id: Int { perspectiveId } - - public init( - perspectiveId: Int, - user: BattlePerspectiveUser, - option: BattlePerspectiveOption, - content: String, - likeCount: Int, - commentCount: Int, - isLiked: Bool, - isMyPerspective: Bool, - createdAt: Date? - ) { - self.perspectiveId = perspectiveId - self.user = user - self.option = option - self.content = content - self.likeCount = likeCount - self.commentCount = commentCount - self.isLiked = isLiked - self.isMyPerspective = isMyPerspective - self.createdAt = createdAt - } -} - -public struct BattlePerspectiveUser: Equatable, Hashable { - public let userTag: String - public let nickname: String - public let characterType: String - public let characterImageUrl: String? - - public init( - userTag: String, - nickname: String, - characterType: String, - characterImageUrl: String? - ) { - self.userTag = userTag - self.nickname = nickname - self.characterType = characterType - self.characterImageUrl = characterImageUrl - } -} - -public struct BattlePerspectiveOption: Equatable, Hashable, Identifiable { - public let optionId: Int - public let label: String? - public let title: String - public let stance: String - - public var id: Int { optionId } - - public init( - optionId: Int, - label: String?, - title: String, - stance: String - ) { - self.optionId = optionId - self.label = label - self.title = title - self.stance = stance - } -} - -public enum BattlePerspectiveSort: String, Equatable, Hashable, CaseIterable { - case popular - case latest - - public var queryValue: String { rawValue } - - public var title: String { - switch self { - case .popular: "인기순" - case .latest: "최신순" - } - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift b/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift deleted file mode 100644 index f5e5192..0000000 --- a/Projects/Domain/Entity/Sources/Home/Comment/Comment.swift +++ /dev/null @@ -1,286 +0,0 @@ -// -// Comment.swift -// Entity -// -// picke.pen `댓글화면` (j3GDzL) 매핑 도메인 모델. -// - -import Foundation - -public enum CommentOption: Equatable { - case a - case b - - public var label: String { - switch self { - case .a: "A" - case .b: "B" - } - } -} - -public struct CommentAuthor: Equatable, Hashable { - public let name: String - public let imageURL: String? - public let optionLabel: String? - - public init( - name: String, - imageURL: String? = nil, - optionLabel: String? = nil - ) { - self.name = name - self.imageURL = imageURL - self.optionLabel = optionLabel - } -} - -public struct Comment: Equatable, Identifiable, Hashable { - public let id: Int - public let author: CommentAuthor - public let text: String - public let createdAt: Date - public let likeCount: Int - public let replyCount: Int - public let isLiked: Bool - - public init( - id: Int, - author: CommentAuthor, - text: String, - createdAt: Date, - likeCount: Int, - replyCount: Int, - isLiked: Bool - ) { - self.id = id - self.author = author - self.text = text - self.createdAt = createdAt - self.likeCount = likeCount - self.replyCount = replyCount - self.isLiked = isLiked - } -} - -public struct CommentReplyItem: Equatable, Identifiable { - public let id: UUID - public var commentId: Int? - public var author: String - public var authorImageURL: String? - public var timeAgo: String - public var option: CommentOption - public var content: String - public var likeCount: Int - public var isLiked: Bool - public var isMine: Bool - public var createdOrder: Int - - public init( - id: UUID = UUID(), - commentId: Int? = nil, - author: String, - authorImageURL: String? = nil, - timeAgo: String, - option: CommentOption, - content: String, - likeCount: Int, - isLiked: Bool = false, - isMine: Bool = false, - createdOrder: Int - ) { - self.id = id - self.commentId = commentId - self.author = author - self.authorImageURL = authorImageURL - self.timeAgo = timeAgo - self.option = option - self.content = content - self.likeCount = likeCount - self.isLiked = isLiked - self.isMine = isMine - self.createdOrder = createdOrder - } - - /// 서버 PerspectiveComment 응답을 화면 모델로 변환. - public init( - item: PerspectiveComment, - parentOption: CommentOption, - order: Int - ) { - let id = UUID(uuidString: Self.deterministicUUID(commentId: item.commentId)) ?? UUID() - self.init( - id: id, - commentId: item.commentId, - author: item.user.nickname, - authorImageURL: item.user.characterImageUrl, - timeAgo: Self.relativeTimeString(from: item.createdAt), - option: parentOption, - content: item.content, - likeCount: item.likeCount, - isLiked: item.isLiked, - isMine: item.isMine, - createdOrder: order - ) - } - - private static func deterministicUUID(commentId: Int) -> String { - let hex = String(format: "%012X", commentId) - return "00000000-0000-0000-0002-\(hex)" - } - - private static func relativeTimeString(from date: Date?) -> String { - guard let date else { return "방금 전" } - let interval = Date().timeIntervalSince(date) - if interval < 60 { return "방금 전" } - if interval < 3600 { return "\(Int(interval / 60))분 전" } - if interval < 86400 { return "\(Int(interval / 3600))시간 전" } - return "\(Int(interval / 86400))일 전" - } - - public static func mocks(for option: CommentOption) -> [CommentReplyItem] { - [ - .init( - id: UUID(uuidString: "00000000-0000-0000-0001-000000000001") ?? UUID(), - author: "사색하는 사슴", - timeAgo: "2분 전", - option: option, - content: "네덜란드 사례를 일반화하기엔 무리가 있지 않나요? 한국의 사회문화적 맥락은 다릅니다.", - likeCount: 1340, - createdOrder: 3 - ), - .init( - id: UUID(uuidString: "00000000-0000-0000-0001-000000000002") ?? UUID(), - author: "논쟁하는 사자", - timeAgo: "2분 전", - option: option, - content: "제도 자체보다 사각지대를 줄이는 보완책을 같이 봐야 한다고 생각해요.", - likeCount: 534, - createdOrder: 2 - ), - .init( - id: UUID(uuidString: "00000000-0000-0000-0001-000000000003") ?? UUID(), - author: "질문하는 독자", - timeAgo: "5분 전", - option: option == .a ? .b : .a, - content: "반대 입장도 이해되지만, 개인의 자기결정권을 완전히 배제하기는 어렵지 않을까요?", - likeCount: 219, - createdOrder: 1 - ), - ] - } -} - -public enum CommentSortType: String, Equatable, Hashable, CaseIterable { - case popular = "POPULAR" - case latest = "LATEST" - - public var title: String { - switch self { - case .popular: "인기순" - case .latest: "최신순" - } - } -} - -public enum CommentTab: Equatable, Hashable { - case all - case option(label: String, title: String) - - public var title: String { - switch self { - case .all: "전체" - case let .option(_, title): title - } - } - - public var optionLabel: String? { - switch self { - case .all: nil - case let .option(label, _): label - } - } -} - -public struct CommentItem: Equatable, Identifiable { - public let id: UUID - public var perspectiveId: Int? - public var author: String - public var authorImageURL: String? - public var timeAgo: String - public var option: CommentOption - public var optionLabel: String? - public var content: String - public var replyCount: Int - public var likeCount: Int - public var isLiked: Bool - public var isMine: Bool - public var createdOrder: Int - - public init( - id: UUID = UUID(), - perspectiveId: Int? = nil, - author: String, - authorImageURL: String? = nil, - timeAgo: String, - option: CommentOption, - optionLabel: String? = nil, - content: String, - replyCount: Int, - likeCount: Int, - isLiked: Bool = false, - isMine: Bool = false, - createdOrder: Int - ) { - self.id = id - self.perspectiveId = perspectiveId - self.author = author - self.authorImageURL = authorImageURL - self.timeAgo = timeAgo - self.option = option - self.optionLabel = optionLabel - self.content = content - self.replyCount = replyCount - self.likeCount = likeCount - self.isLiked = isLiked - self.isMine = isMine - self.createdOrder = createdOrder - } - - /// API 응답 BattlePerspective 를 화면 모델로 변환. - public init( - item: BattlePerspective, - order: Int - ) { - let optionFallback: CommentOption = item.option.label == "B" ? .b : .a - self.init( - id: UUID(uuidString: Self.deterministicUUID(perspectiveId: item.perspectiveId)) ?? UUID(), - perspectiveId: item.perspectiveId, - author: item.user.nickname, - authorImageURL: item.user.characterImageUrl, - timeAgo: Self.relativeTimeString(from: item.createdAt), - option: optionFallback, - optionLabel: item.option.title, - content: item.content, - replyCount: item.commentCount, - likeCount: item.likeCount, - isLiked: item.isLiked, - isMine: item.isMyPerspective, - createdOrder: order - ) - } - - private static func deterministicUUID(perspectiveId: Int) -> String { - let hex = String(format: "%012X", perspectiveId) - return "00000000-0000-0000-0000-\(hex)" - } - - private static func relativeTimeString(from date: Date?) -> String { - guard let date else { return "방금 전" } - let interval = Date().timeIntervalSince(date) - if interval < 60 { return "방금 전" } - if interval < 3600 { return "\(Int(interval / 60))분 전" } - if interval < 86400 { return "\(Int(interval / 3600))시간 전" } - return "\(Int(interval / 86400))일 전" - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/Comment.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/Comment.swift new file mode 100644 index 0000000..95e8ec4 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/Comment.swift @@ -0,0 +1,36 @@ +// +// Comment.swift +// Entity +// +// picke.pen `댓글화면` (j3GDzL) 매핑 도메인 모델. +// + +import Foundation + +public struct Comment: Equatable, Identifiable, Hashable { + public let id: Int + public let author: CommentAuthor + public let text: String + public let createdAt: Date + public let likeCount: Int + public let replyCount: Int + public let isLiked: Bool + + public init( + id: Int, + author: CommentAuthor, + text: String, + createdAt: Date, + likeCount: Int, + replyCount: Int, + isLiked: Bool + ) { + self.id = id + self.author = author + self.text = text + self.createdAt = createdAt + self.likeCount = likeCount + self.replyCount = replyCount + self.isLiked = isLiked + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentAuthor.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentAuthor.swift new file mode 100644 index 0000000..8e381a2 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentAuthor.swift @@ -0,0 +1,22 @@ +// +// CommentAuthor.swift +// Entity +// + +import Foundation + +public struct CommentAuthor: Equatable, Hashable { + public let name: String + public let imageURL: String? + public let optionLabel: String? + + public init( + name: String, + imageURL: String? = nil, + optionLabel: String? = nil + ) { + self.name = name + self.imageURL = imageURL + self.optionLabel = optionLabel + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentFilter.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentFilter.swift new file mode 100644 index 0000000..eae8e94 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentFilter.swift @@ -0,0 +1,29 @@ +// +// CommentFilter.swift +// Entity +// + +import Foundation + +public enum CommentFilter: String, CaseIterable, Equatable { + case all + case optionA + case optionB + + public var title: String { + switch self { + case .all: "전체" + case .optionA: "A" + case .optionB: "B" + } + } + + /// 서버 쿼리에 보낼 optionLabel — `all` 은 nil. + public var queryLabel: String? { + switch self { + case .all: nil + case .optionA: "A" + case .optionB: "B" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentItem.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentItem.swift new file mode 100644 index 0000000..e11c8ba --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentItem.swift @@ -0,0 +1,89 @@ +// +// CommentItem.swift +// Entity +// + +import Foundation + +public struct CommentItem: Equatable, Identifiable { + public let id: UUID + public var perspectiveId: Int? + public var author: String + public var authorImageURL: String? + public var timeAgo: String + public var option: CommentOption + public var optionLabel: String? + public var content: String + public var replyCount: Int + public var likeCount: Int + public var isLiked: Bool + public var isMine: Bool + public var createdOrder: Int + + public init( + id: UUID = UUID(), + perspectiveId: Int? = nil, + author: String, + authorImageURL: String? = nil, + timeAgo: String, + option: CommentOption, + optionLabel: String? = nil, + content: String, + replyCount: Int, + likeCount: Int, + isLiked: Bool = false, + isMine: Bool = false, + createdOrder: Int + ) { + self.id = id + self.perspectiveId = perspectiveId + self.author = author + self.authorImageURL = authorImageURL + self.timeAgo = timeAgo + self.option = option + self.optionLabel = optionLabel + self.content = content + self.replyCount = replyCount + self.likeCount = likeCount + self.isLiked = isLiked + self.isMine = isMine + self.createdOrder = createdOrder + } + + /// API 응답 BattlePerspective 를 화면 모델로 변환. + public init( + item: BattlePerspective, + order: Int + ) { + let optionFallback: CommentOption = item.option.label == "B" ? .b : .a + self.init( + id: UUID(uuidString: Self.deterministicUUID(perspectiveId: item.perspectiveId)) ?? UUID(), + perspectiveId: item.perspectiveId, + author: item.user.nickname, + authorImageURL: item.user.characterImageUrl, + timeAgo: Self.relativeTimeString(from: item.createdAt), + option: optionFallback, + optionLabel: item.option.title, + content: item.content, + replyCount: item.commentCount, + likeCount: item.likeCount, + isLiked: item.isLiked, + isMine: item.isMyPerspective, + createdOrder: order + ) + } + + private static func deterministicUUID(perspectiveId: Int) -> String { + let hex = String(format: "%012X", perspectiveId) + return "00000000-0000-0000-0000-\(hex)" + } + + private static func relativeTimeString(from date: Date?) -> String { + guard let date else { return "방금 전" } + let interval = Date().timeIntervalSince(date) + if interval < 60 { return "방금 전" } + if interval < 3600 { return "\(Int(interval / 60))분 전" } + if interval < 86400 { return "\(Int(interval / 3600))시간 전" } + return "\(Int(interval / 86400))일 전" + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/CommentLikeResult.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentLikeResult.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/Comment/CommentLikeResult.swift rename to Projects/Domain/Entity/Sources/Home/Comment/Item/CommentLikeResult.swift diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentOption.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentOption.swift new file mode 100644 index 0000000..d03beef --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentOption.swift @@ -0,0 +1,18 @@ +// +// CommentOption.swift +// Entity +// + +import Foundation + +public enum CommentOption: Equatable { + case a + case b + + public var label: String { + switch self { + case .a: "A" + case .b: "B" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentReplyItem.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentReplyItem.swift new file mode 100644 index 0000000..c317cdf --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentReplyItem.swift @@ -0,0 +1,114 @@ +// +// CommentReplyItem.swift +// Entity +// + +import Foundation + +public struct CommentReplyItem: Equatable, Identifiable { + public let id: UUID + public var commentId: Int? + public var author: String + public var authorImageURL: String? + public var timeAgo: String + public var option: CommentOption + public var content: String + public var likeCount: Int + public var isLiked: Bool + public var isMine: Bool + public var createdOrder: Int + + public init( + id: UUID = UUID(), + commentId: Int? = nil, + author: String, + authorImageURL: String? = nil, + timeAgo: String, + option: CommentOption, + content: String, + likeCount: Int, + isLiked: Bool = false, + isMine: Bool = false, + createdOrder: Int + ) { + self.id = id + self.commentId = commentId + self.author = author + self.authorImageURL = authorImageURL + self.timeAgo = timeAgo + self.option = option + self.content = content + self.likeCount = likeCount + self.isLiked = isLiked + self.isMine = isMine + self.createdOrder = createdOrder + } + + /// 서버 PerspectiveComment 응답을 화면 모델로 변환. + public init( + item: PerspectiveComment, + parentOption: CommentOption, + order: Int + ) { + let id = UUID(uuidString: Self.deterministicUUID(commentId: item.commentId)) ?? UUID() + self.init( + id: id, + commentId: item.commentId, + author: item.user.nickname, + authorImageURL: item.user.characterImageUrl, + timeAgo: Self.relativeTimeString(from: item.createdAt), + option: parentOption, + content: item.content, + likeCount: item.likeCount, + isLiked: item.isLiked, + isMine: item.isMine, + createdOrder: order + ) + } + + private static func deterministicUUID(commentId: Int) -> String { + let hex = String(format: "%012X", commentId) + return "00000000-0000-0000-0002-\(hex)" + } + + private static func relativeTimeString(from date: Date?) -> String { + guard let date else { return "방금 전" } + let interval = Date().timeIntervalSince(date) + if interval < 60 { return "방금 전" } + if interval < 3600 { return "\(Int(interval / 60))분 전" } + if interval < 86400 { return "\(Int(interval / 3600))시간 전" } + return "\(Int(interval / 86400))일 전" + } + + public static func mocks(for option: CommentOption) -> [CommentReplyItem] { + [ + .init( + id: UUID(uuidString: "00000000-0000-0000-0001-000000000001") ?? UUID(), + author: "사색하는 사슴", + timeAgo: "2분 전", + option: option, + content: "네덜란드 사례를 일반화하기엔 무리가 있지 않나요? 한국의 사회문화적 맥락은 다릅니다.", + likeCount: 1340, + createdOrder: 3 + ), + .init( + id: UUID(uuidString: "00000000-0000-0000-0001-000000000002") ?? UUID(), + author: "논쟁하는 사자", + timeAgo: "2분 전", + option: option, + content: "제도 자체보다 사각지대를 줄이는 보완책을 같이 봐야 한다고 생각해요.", + likeCount: 534, + createdOrder: 2 + ), + .init( + id: UUID(uuidString: "00000000-0000-0000-0001-000000000003") ?? UUID(), + author: "질문하는 독자", + timeAgo: "5분 전", + option: option == .a ? .b : .a, + content: "반대 입장도 이해되지만, 개인의 자기결정권을 완전히 배제하기는 어렵지 않을까요?", + likeCount: 219, + createdOrder: 1 + ), + ] + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentSort.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentSort.swift new file mode 100644 index 0000000..4a2a45f --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentSort.swift @@ -0,0 +1,25 @@ +// +// CommentSort.swift +// Entity +// + +import Foundation + +public enum CommentSort: String, CaseIterable, Equatable { + case popular + case latest + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } + + public var perspectiveSort: BattlePerspectiveSort { + switch self { + case .popular: .popular + case .latest: .latest + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentSortType.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentSortType.swift new file mode 100644 index 0000000..d2e3165 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentSortType.swift @@ -0,0 +1,18 @@ +// +// CommentSortType.swift +// Entity +// + +import Foundation + +public enum CommentSortType: String, Equatable, Hashable, CaseIterable { + case popular = "POPULAR" + case latest = "LATEST" + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentTab.swift b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentTab.swift new file mode 100644 index 0000000..0e70e9e --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Item/CommentTab.swift @@ -0,0 +1,25 @@ +// +// CommentTab.swift +// Entity +// + +import Foundation + +public enum CommentTab: Equatable, Hashable { + case all + case option(label: String, title: String) + + public var title: String { + switch self { + case .all: "전체" + case let .option(_, title): title + } + } + + public var optionLabel: String? { + switch self { + case .all: nil + case let .option(label, _): label + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspective.swift b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspective.swift new file mode 100644 index 0000000..e3958c3 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspective.swift @@ -0,0 +1,45 @@ +// +// BattlePerspective.swift +// Entity +// +// `GET /api/v1/battles/{battleId}/perspectives` 응답 도메인 모델. +// 댓글(=관점) 리스트 + 커서 페이지네이션. +// + +import Foundation + +public struct BattlePerspective: Equatable, Identifiable, Hashable { + public let perspectiveId: Int + public let user: BattlePerspectiveUser + public let option: BattlePerspectiveOption + public let content: String + public let likeCount: Int + public let commentCount: Int + public let isLiked: Bool + public let isMyPerspective: Bool + public let createdAt: Date? + + public var id: Int { perspectiveId } + + public init( + perspectiveId: Int, + user: BattlePerspectiveUser, + option: BattlePerspectiveOption, + content: String, + likeCount: Int, + commentCount: Int, + isLiked: Bool, + isMyPerspective: Bool, + createdAt: Date? + ) { + self.perspectiveId = perspectiveId + self.user = user + self.option = option + self.content = content + self.likeCount = likeCount + self.commentCount = commentCount + self.isLiked = isLiked + self.isMyPerspective = isMyPerspective + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveOption.swift b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveOption.swift new file mode 100644 index 0000000..0c909c2 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveOption.swift @@ -0,0 +1,27 @@ +// +// BattlePerspectiveOption.swift +// Entity +// + +import Foundation + +public struct BattlePerspectiveOption: Equatable, Hashable, Identifiable { + public let optionId: Int + public let label: String? + public let title: String + public let stance: String + + public var id: Int { optionId } + + public init( + optionId: Int, + label: String?, + title: String, + stance: String + ) { + self.optionId = optionId + self.label = label + self.title = title + self.stance = stance + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectivePage.swift b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectivePage.swift new file mode 100644 index 0000000..19ccb40 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectivePage.swift @@ -0,0 +1,22 @@ +// +// BattlePerspectivePage.swift +// Entity +// + +import Foundation + +public struct BattlePerspectivePage: Equatable { + public let items: [BattlePerspective] + public let nextCursor: String? + public let hasNext: Bool + + public init( + items: [BattlePerspective], + nextCursor: String?, + hasNext: Bool + ) { + self.items = items + self.nextCursor = nextCursor + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveSort.swift b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveSort.swift new file mode 100644 index 0000000..b54f295 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveSort.swift @@ -0,0 +1,20 @@ +// +// BattlePerspectiveSort.swift +// Entity +// + +import Foundation + +public enum BattlePerspectiveSort: String, Equatable, Hashable, CaseIterable { + case popular + case latest + + public var queryValue: String { rawValue } + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveUser.swift b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveUser.swift new file mode 100644 index 0000000..cb56a35 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/Perspective/BattlePerspectiveUser.swift @@ -0,0 +1,25 @@ +// +// BattlePerspectiveUser.swift +// Entity +// + +import Foundation + +public struct BattlePerspectiveUser: Equatable, Hashable { + public let userTag: String + public let nickname: String + public let characterType: String + public let characterImageUrl: String? + + public init( + userTag: String, + nickname: String, + characterType: String, + characterImageUrl: String? + ) { + self.userTag = userTag + self.nickname = nickname + self.characterType = characterType + self.characterImageUrl = characterImageUrl + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift deleted file mode 100644 index 6943d63..0000000 --- a/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// PerspectiveComment.swift -// Entity -// -// `GET /api/v1/perspectives/{perspectiveId}/comments/labeled` 등 대댓글 API 도메인 모델. -// - -import Foundation - -public struct PerspectiveCommentPage: Equatable { - public let items: [PerspectiveComment] - public let nextCursor: String? - public let hasNext: Bool - - public init( - items: [PerspectiveComment], - nextCursor: String?, - hasNext: Bool - ) { - self.items = items - self.nextCursor = nextCursor - self.hasNext = hasNext - } -} - -public struct PerspectiveComment: Equatable, Identifiable, Hashable { - public let commentId: Int - public let user: PerspectiveCommentUser - public let stance: String - public let content: String - public let likeCount: Int - public let isLiked: Bool - public let isMine: Bool - public let createdAt: Date? - - public var id: Int { commentId } - - public init( - commentId: Int, - user: PerspectiveCommentUser, - stance: String, - content: String, - likeCount: Int, - isLiked: Bool, - isMine: Bool, - createdAt: Date? - ) { - self.commentId = commentId - self.user = user - self.stance = stance - self.content = content - self.likeCount = likeCount - self.isLiked = isLiked - self.isMine = isMine - self.createdAt = createdAt - } -} - -public struct PerspectiveCommentUser: Equatable, Hashable { - public let userTag: String - public let nickname: String - public let characterType: String - public let characterImageUrl: String? - - public init( - userTag: String, - nickname: String, - characterType: String, - characterImageUrl: String? - ) { - self.userTag = userTag - self.nickname = nickname - self.characterType = characterType - self.characterImageUrl = characterImageUrl - } -} - -/// 대댓글 작성/수정 응답. -public struct PerspectiveCommentMutationResult: Equatable, Hashable { - public let commentId: Int - public let content: String - public let updatedAt: Date? - - public init( - commentId: Int, - content: String, - updatedAt: Date? - ) { - self.commentId = commentId - self.content = content - self.updatedAt = updatedAt - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveComment.swift b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveComment.swift new file mode 100644 index 0000000..3140025 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveComment.swift @@ -0,0 +1,41 @@ +// +// PerspectiveComment.swift +// Entity +// +// `GET /api/v1/perspectives/{perspectiveId}/comments/labeled` 등 대댓글 API 도메인 모델. +// + +import Foundation + +public struct PerspectiveComment: Equatable, Identifiable, Hashable { + public let commentId: Int + public let user: PerspectiveCommentUser + public let stance: String + public let content: String + public let likeCount: Int + public let isLiked: Bool + public let isMine: Bool + public let createdAt: Date? + + public var id: Int { commentId } + + public init( + commentId: Int, + user: PerspectiveCommentUser, + stance: String, + content: String, + likeCount: Int, + isLiked: Bool, + isMine: Bool, + createdAt: Date? + ) { + self.commentId = commentId + self.user = user + self.stance = stance + self.content = content + self.likeCount = likeCount + self.isLiked = isLiked + self.isMine = isMine + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentMutationResult.swift b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentMutationResult.swift new file mode 100644 index 0000000..76f0b98 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentMutationResult.swift @@ -0,0 +1,23 @@ +// +// PerspectiveCommentMutationResult.swift +// Entity +// + +import Foundation + +/// 대댓글 작성/수정 응답. +public struct PerspectiveCommentMutationResult: Equatable, Hashable { + public let commentId: Int + public let content: String + public let updatedAt: Date? + + public init( + commentId: Int, + content: String, + updatedAt: Date? + ) { + self.commentId = commentId + self.content = content + self.updatedAt = updatedAt + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentPage.swift b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentPage.swift new file mode 100644 index 0000000..9c33051 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentPage.swift @@ -0,0 +1,22 @@ +// +// PerspectiveCommentPage.swift +// Entity +// + +import Foundation + +public struct PerspectiveCommentPage: Equatable { + public let items: [PerspectiveComment] + public let nextCursor: String? + public let hasNext: Bool + + public init( + items: [PerspectiveComment], + nextCursor: String?, + hasNext: Bool + ) { + self.items = items + self.nextCursor = nextCursor + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentUser.swift b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentUser.swift new file mode 100644 index 0000000..bf141d6 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Comment/PerspectiveComment/PerspectiveCommentUser.swift @@ -0,0 +1,25 @@ +// +// PerspectiveCommentUser.swift +// Entity +// + +import Foundation + +public struct PerspectiveCommentUser: Equatable, Hashable { + public let userTag: String + public let nickname: String + public let characterType: String + public let characterImageUrl: String? + + public init( + userTag: String, + nickname: String, + characterType: String, + characterImageUrl: String? + ) { + self.userTag = userTag + self.nickname = nickname + self.characterType = characterType + self.characterImageUrl = characterImageUrl + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift b/Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift index 92dd94e..58bac6f 100644 --- a/Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift +++ b/Projects/Domain/Entity/Sources/Home/Core/BattleTag.swift @@ -25,26 +25,3 @@ public struct BattleTag: Equatable, Identifiable, Hashable { self.type = type } } - -public enum TagType: String, Equatable, Hashable, Decodable { - case philosopher = "PHILOSOPHER" - case category = "CATEGORY" - case era = "ERA" - case value = "VALUE" - case unknown - - public init(rawValue: String) { - switch rawValue { - case "PHILOSOPHER": self = .philosopher - case "CATEGORY": self = .category - case "ERA": self = .era - case "VALUE": self = .value - default: self = .unknown - } - } - - public init(from decoder: Decoder) throws { - let raw = try decoder.singleValueContainer().decode(String.self) - self = TagType(rawValue: raw) - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Core/TagType.swift b/Projects/Domain/Entity/Sources/Home/Core/TagType.swift new file mode 100644 index 0000000..2f0e6b4 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Core/TagType.swift @@ -0,0 +1,29 @@ +// +// TagType.swift +// Entity +// + +import Foundation + +public enum TagType: String, Equatable, Hashable, Decodable { + case philosopher = "PHILOSOPHER" + case category = "CATEGORY" + case era = "ERA" + case value = "VALUE" + case unknown + + public init(rawValue: String) { + switch rawValue { + case "PHILOSOPHER": self = .philosopher + case "CATEGORY": self = .category + case "ERA": self = .era + case "VALUE": self = .value + default: self = .unknown + } + } + + public init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = TagType(rawValue: raw) + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Explore/ExploreCategory.swift b/Projects/Domain/Entity/Sources/Home/Explore/ExploreCategory.swift new file mode 100644 index 0000000..6ea3048 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Explore/ExploreCategory.swift @@ -0,0 +1,38 @@ +// +// ExploreCategory.swift +// Entity +// + +import Foundation + +public enum ExploreCategory: String, Equatable, Hashable, CaseIterable, Identifiable { + case all + case philosophy + case literature + case art + case science + case society + case history + + public var id: String { rawValue } + + public var title: String { + switch self { + case .all: "전체" + case .philosophy: "철학" + case .literature: "문학" + case .art: "예술" + case .science: "과학" + case .society: "사회" + case .history: "역사" + } + } + + /// 검색 API `category` 쿼리 값 (대문자 영문 enum). 전체는 nil(필터 없음). + public var queryValue: String? { + switch self { + case .all: nil + default: rawValue.uppercased() + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Explore/ExploreItem.swift b/Projects/Domain/Entity/Sources/Home/Explore/ExploreItem.swift index 0199d0a..8ca57ea 100644 --- a/Projects/Domain/Entity/Sources/Home/Explore/ExploreItem.swift +++ b/Projects/Domain/Entity/Sources/Home/Explore/ExploreItem.swift @@ -7,22 +7,6 @@ import Foundation -public struct ExploreItemPage: Equatable { - public let items: [ExploreItem] - public let nextOffset: Int? - public let hasNext: Bool - - public init( - items: [ExploreItem], - nextOffset: Int?, - hasNext: Bool - ) { - self.items = items - self.nextOffset = nextOffset - self.hasNext = hasNext - } -} - public struct ExploreItem: Equatable, Identifiable, Hashable { public let id: Int public let category: String @@ -50,50 +34,3 @@ public struct ExploreItem: Equatable, Identifiable, Hashable { self.imageURL = imageURL } } - -public enum ExploreCategory: String, Equatable, Hashable, CaseIterable, Identifiable { - case all - case philosophy - case literature - case art - case science - case society - case history - - public var id: String { rawValue } - - public var title: String { - switch self { - case .all: "전체" - case .philosophy: "철학" - case .literature: "문학" - case .art: "예술" - case .science: "과학" - case .society: "사회" - case .history: "역사" - } - } - - /// 검색 API `category` 쿼리 값 (대문자 영문 enum). 전체는 nil(필터 없음). - public var queryValue: String? { - switch self { - case .all: nil - default: rawValue.uppercased() - } - } -} - -public enum ExploreSort: String, Equatable, Hashable, CaseIterable { - case popular - case latest - - public var title: String { - switch self { - case .popular: "인기순" - case .latest: "최신순" - } - } - - /// 검색 API `sort` 쿼리 값 (대문자 영문 enum: POPULAR / LATEST). - public var queryValue: String { rawValue.uppercased() } -} diff --git a/Projects/Domain/Entity/Sources/Home/Explore/ExploreItemPage.swift b/Projects/Domain/Entity/Sources/Home/Explore/ExploreItemPage.swift new file mode 100644 index 0000000..8d3a723 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Explore/ExploreItemPage.swift @@ -0,0 +1,22 @@ +// +// ExploreItemPage.swift +// Entity +// + +import Foundation + +public struct ExploreItemPage: Equatable { + public let items: [ExploreItem] + public let nextOffset: Int? + public let hasNext: Bool + + public init( + items: [ExploreItem], + nextOffset: Int?, + hasNext: Bool + ) { + self.items = items + self.nextOffset = nextOffset + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Explore/ExploreSort.swift b/Projects/Domain/Entity/Sources/Home/Explore/ExploreSort.swift new file mode 100644 index 0000000..12a1a55 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Explore/ExploreSort.swift @@ -0,0 +1,21 @@ +// +// ExploreSort.swift +// Entity +// + +import Foundation + +public enum ExploreSort: String, Equatable, Hashable, CaseIterable { + case popular + case latest + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } + + /// 검색 API `sort` 쿼리 값 (대문자 영문 enum: POPULAR / LATEST). + public var queryValue: String { rawValue.uppercased() } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift index ab2522c..6836735 100644 --- a/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift +++ b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario+.swift @@ -1,5 +1,5 @@ // -// BattleScenario+Timeline.swift +// BattleScenario+.swift // Entity // // 시나리오 타임라인 계산. @@ -14,8 +14,21 @@ public extension BattleScenario { return TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 } - /// 노드 종료 시간(초) = 노드 시작 + audioDuration. + /// 노드 종료 시간(초). + /// 다음 노드(autoNext 또는 인터랙티브 분기)의 시작 시각이 실제 오디오 경계이므로 우선 사용한다. + /// (audioDuration 합산은 실제 오디오와 어긋나, 선택지가 마지막 대사 도중에 떠 음성이 끊기는 문제가 있음) + /// 다음 노드가 없으면(클로징) 노드 시작 + audioDuration. func nodeEndTime(for node: ScenarioNode) -> TimeInterval { + let nextNodeIds: [Int] = { + if let auto = node.autoNextNodeId { return [auto] } + return node.interactiveOptions.map(\.nextNodeId) + }() + let nextStarts = nextNodeIds.compactMap { id in + nodes.first(where: { $0.nodeId == id })?.scripts.map(\.startTimeMs).min() + } + if let nextStart = nextStarts.min() { + return TimeInterval(nextStart) / 1000 + } let start = TimeInterval(node.scripts.map(\.startTimeMs).min() ?? 0) / 1000 return start + TimeInterval(node.audioDuration) } diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift index 55d100e..a2a3122 100644 --- a/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift +++ b/Projects/Domain/Entity/Sources/Home/Scenario/BattleScenario.swift @@ -39,111 +39,3 @@ public struct BattleScenario: Equatable, Identifiable { self.nodes = nodes } } - -public struct ScenarioPhilosopher: Equatable, Hashable, Identifiable { - public let label: String - public let name: String - public let stance: String - public let imageUrl: String - - public var id: String { label } - - public init( - label: String, - name: String, - stance: String, - imageUrl: String - ) { - self.label = label - self.name = name - self.stance = stance - self.imageUrl = imageUrl - } -} - -public struct ScenarioNode: Equatable, Identifiable { - public let nodeId: Int - public let nodeName: String - public let audioDuration: Int - public let autoNextNodeId: Int? - public let scripts: [ScenarioScript] - public let interactiveOptions: [ScenarioInteractiveOption] - - public var id: Int { nodeId } - - public init( - nodeId: Int, - nodeName: String, - audioDuration: Int, - autoNextNodeId: Int?, - scripts: [ScenarioScript], - interactiveOptions: [ScenarioInteractiveOption] - ) { - self.nodeId = nodeId - self.nodeName = nodeName - self.audioDuration = audioDuration - self.autoNextNodeId = autoNextNodeId - self.scripts = scripts - self.interactiveOptions = interactiveOptions - } -} - -public struct ScenarioScript: Equatable, Identifiable, Hashable { - public let scriptId: Int - public let startTimeMs: Int - public let speakerType: ScenarioSpeakerType - public let speakerName: String - public let text: String - - public var id: Int { scriptId } - - public init( - scriptId: Int, - startTimeMs: Int, - speakerType: ScenarioSpeakerType, - speakerName: String, - text: String - ) { - self.scriptId = scriptId - self.startTimeMs = startTimeMs - self.speakerType = speakerType - self.speakerName = speakerName - self.text = text - } -} - -public struct ScenarioInteractiveOption: Equatable, Hashable, Identifiable { - public let label: String - public let nextNodeId: Int - - public var id: String { "\(label)-\(nextNodeId)" } - - public init( - label: String, - nextNodeId: Int - ) { - self.label = label - self.nextNodeId = nextNodeId - } -} - -public enum ScenarioSpeakerType: String, Equatable, Hashable, CaseIterable { - case a = "A" - case b = "B" - case narrator = "NARRATOR" - case philosopher = "PHILOSOPHER" - case unknown - - public init(rawValue: String) { - self = ScenarioSpeakerType.allCases.first { $0.rawValue == rawValue } ?? .unknown - } -} - -public enum RecommendedPathKey: String, Equatable, Hashable, CaseIterable { - case common = "COMMON" - case unknown - - public init(rawValue: String) { - self = RecommendedPathKey.allCases.first { $0.rawValue == rawValue } ?? .unknown - } -} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatMessage.swift b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatMessage.swift new file mode 100644 index 0000000..c54b6b5 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatMessage.swift @@ -0,0 +1,32 @@ +// +// ChatMessage.swift +// Entity +// +// 채팅방(`/Users/suhwonji/Desktop/와이어프레임/채팅방.pdf` + .pen `k3lIx`) 메시지 모델. +// + +import Foundation + +public struct ChatMessage: Equatable, Identifiable, Hashable { + public let messageId: UUID + public let speaker: ChatSpeaker + public let text: String + /// 시나리오 스크립트의 시작 시각 (밀리초). 오디오 재생 진행도에 따라 + /// 활성 메시지로 자동 스크롤할 때 사용. mock 데이터 / 시간 정보가 없는 + /// 경우엔 nil. + public let startTimeMs: Int? + + public var id: UUID { messageId } + + public init( + messageId: UUID = UUID(), + speaker: ChatSpeaker, + text: String, + startTimeMs: Int? = nil + ) { + self.messageId = messageId + self.speaker = speaker + self.text = text + self.startTimeMs = startTimeMs + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/ChatMessage.swift b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatRoomBundle.swift similarity index 65% rename from Projects/Domain/Entity/Sources/Home/Scenario/ChatMessage.swift rename to Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatRoomBundle.swift index 9c55d51..fdc4aaf 100644 --- a/Projects/Domain/Entity/Sources/Home/Scenario/ChatMessage.swift +++ b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatRoomBundle.swift @@ -1,63 +1,10 @@ // -// ChatMessage.swift +// ChatRoomBundle.swift // Entity // -// 채팅방(`/Users/suhwonji/Desktop/와이어프레임/채팅방.pdf` + .pen `k3lIx`) 메시지 모델. -// import Foundation -public enum ChatSpeakerSide: Equatable, Hashable { - case left - case right - case center -} - -public struct ChatSpeaker: Equatable, Identifiable, Hashable { - public let label: String? - public let name: String - public let imageURL: String? - public let side: ChatSpeakerSide - - public var id: String { "\(label ?? name)-\(side)" } - - public init( - label: String? = nil, - name: String, - imageURL: String? = nil, - side: ChatSpeakerSide - ) { - self.label = label - self.name = name - self.imageURL = imageURL - self.side = side - } -} - -public struct ChatMessage: Equatable, Identifiable, Hashable { - public let messageId: UUID - public let speaker: ChatSpeaker - public let text: String - /// 시나리오 스크립트의 시작 시각 (밀리초). 오디오 재생 진행도에 따라 - /// 활성 메시지로 자동 스크롤할 때 사용. mock 데이터 / 시간 정보가 없는 - /// 경우엔 nil. - public let startTimeMs: Int? - - public var id: UUID { messageId } - - public init( - messageId: UUID = UUID(), - speaker: ChatSpeaker, - text: String, - startTimeMs: Int? = nil - ) { - self.messageId = messageId - self.speaker = speaker - self.text = text - self.startTimeMs = startTimeMs - } -} - public struct ChatRoomBundle: Equatable { public let battleTitle: String public let totalDuration: TimeInterval diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatSpeaker.swift b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatSpeaker.swift new file mode 100644 index 0000000..c9c5f2d --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatSpeaker.swift @@ -0,0 +1,27 @@ +// +// ChatSpeaker.swift +// Entity +// + +import Foundation + +public struct ChatSpeaker: Equatable, Identifiable, Hashable { + public let label: String? + public let name: String + public let imageURL: String? + public let side: ChatSpeakerSide + + public var id: String { "\(label ?? name)-\(side)" } + + public init( + label: String? = nil, + name: String, + imageURL: String? = nil, + side: ChatSpeakerSide + ) { + self.label = label + self.name = name + self.imageURL = imageURL + self.side = side + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatSpeakerSide.swift b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatSpeakerSide.swift new file mode 100644 index 0000000..fdf327f --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/Chat/ChatSpeakerSide.swift @@ -0,0 +1,12 @@ +// +// ChatSpeakerSide.swift +// Entity +// + +import Foundation + +public enum ChatSpeakerSide: Equatable, Hashable { + case left + case right + case center +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/RecommendedPathKey.swift b/Projects/Domain/Entity/Sources/Home/Scenario/RecommendedPathKey.swift new file mode 100644 index 0000000..3c86032 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/RecommendedPathKey.swift @@ -0,0 +1,15 @@ +// +// RecommendedPathKey.swift +// Entity +// + +import Foundation + +public enum RecommendedPathKey: String, Equatable, Hashable, CaseIterable { + case common = "COMMON" + case unknown + + public init(rawValue: String) { + self = RecommendedPathKey.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioInteractiveOption.swift b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioInteractiveOption.swift new file mode 100644 index 0000000..289288f --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioInteractiveOption.swift @@ -0,0 +1,21 @@ +// +// ScenarioInteractiveOption.swift +// Entity +// + +import Foundation + +public struct ScenarioInteractiveOption: Equatable, Hashable, Identifiable { + public let label: String + public let nextNodeId: Int + + public var id: String { "\(label)-\(nextNodeId)" } + + public init( + label: String, + nextNodeId: Int + ) { + self.label = label + self.nextNodeId = nextNodeId + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioNode.swift b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioNode.swift new file mode 100644 index 0000000..e8c45f7 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioNode.swift @@ -0,0 +1,33 @@ +// +// ScenarioNode.swift +// Entity +// + +import Foundation + +public struct ScenarioNode: Equatable, Identifiable { + public let nodeId: Int + public let nodeName: String + public let audioDuration: Int + public let autoNextNodeId: Int? + public let scripts: [ScenarioScript] + public let interactiveOptions: [ScenarioInteractiveOption] + + public var id: Int { nodeId } + + public init( + nodeId: Int, + nodeName: String, + audioDuration: Int, + autoNextNodeId: Int?, + scripts: [ScenarioScript], + interactiveOptions: [ScenarioInteractiveOption] + ) { + self.nodeId = nodeId + self.nodeName = nodeName + self.audioDuration = audioDuration + self.autoNextNodeId = autoNextNodeId + self.scripts = scripts + self.interactiveOptions = interactiveOptions + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioPhilosopher.swift b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioPhilosopher.swift new file mode 100644 index 0000000..46b4f6c --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioPhilosopher.swift @@ -0,0 +1,27 @@ +// +// ScenarioPhilosopher.swift +// Entity +// + +import Foundation + +public struct ScenarioPhilosopher: Equatable, Hashable, Identifiable { + public let label: String + public let name: String + public let stance: String + public let imageUrl: String + + public var id: String { label } + + public init( + label: String, + name: String, + stance: String, + imageUrl: String + ) { + self.label = label + self.name = name + self.stance = stance + self.imageUrl = imageUrl + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioScript.swift b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioScript.swift new file mode 100644 index 0000000..e02e1a7 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioScript.swift @@ -0,0 +1,30 @@ +// +// ScenarioScript.swift +// Entity +// + +import Foundation + +public struct ScenarioScript: Equatable, Identifiable, Hashable { + public let scriptId: Int + public let startTimeMs: Int + public let speakerType: ScenarioSpeakerType + public let speakerName: String + public let text: String + + public var id: Int { scriptId } + + public init( + scriptId: Int, + startTimeMs: Int, + speakerType: ScenarioSpeakerType, + speakerName: String, + text: String + ) { + self.scriptId = scriptId + self.startTimeMs = startTimeMs + self.speakerType = speakerType + self.speakerName = speakerName + self.text = text + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioSpeakerType.swift b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioSpeakerType.swift new file mode 100644 index 0000000..cf76940 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Scenario/ScenarioSpeakerType.swift @@ -0,0 +1,18 @@ +// +// ScenarioSpeakerType.swift +// Entity +// + +import Foundation + +public enum ScenarioSpeakerType: String, Equatable, Hashable, CaseIterable { + case a = "A" + case b = "B" + case narrator = "NARRATOR" + case philosopher = "PHILOSOPHER" + case unknown + + public init(rawValue: String) { + self = ScenarioSpeakerType.allCases.first { $0.rawValue == rawValue } ?? .unknown + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Section/BestBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/Battle/BestBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/Section/BestBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/Battle/BestBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/Section/HeroBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/Battle/HeroBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/Section/HeroBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/Battle/HeroBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/Section/HotBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/Battle/HotBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/Section/HotBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/Battle/HotBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/Section/NewBattle.swift b/Projects/Domain/Entity/Sources/Home/Section/Battle/NewBattle.swift similarity index 100% rename from Projects/Domain/Entity/Sources/Home/Section/NewBattle.swift rename to Projects/Domain/Entity/Sources/Home/Section/Battle/NewBattle.swift diff --git a/Projects/Domain/Entity/Sources/Home/Section/Vote/VoteOption.swift b/Projects/Domain/Entity/Sources/Home/Section/Vote/VoteOption.swift new file mode 100644 index 0000000..5a38268 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Section/Vote/VoteOption.swift @@ -0,0 +1,21 @@ +// +// VoteOption.swift +// Entity +// + +import Foundation + +public struct VoteOption: Equatable, Identifiable, Hashable { + public let label: String + public let title: String + + public var id: String { label } + + public init( + label: String, + title: String + ) { + self.label = label + self.title = title + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Section/VoteQuestion.swift b/Projects/Domain/Entity/Sources/Home/Section/Vote/VoteQuestion.swift similarity index 83% rename from Projects/Domain/Entity/Sources/Home/Section/VoteQuestion.swift rename to Projects/Domain/Entity/Sources/Home/Section/Vote/VoteQuestion.swift index be4ae3b..e191f26 100644 --- a/Projects/Domain/Entity/Sources/Home/Section/VoteQuestion.swift +++ b/Projects/Domain/Entity/Sources/Home/Section/Vote/VoteQuestion.swift @@ -39,21 +39,6 @@ public struct VoteQuestion: Equatable, Identifiable { public var suffix: String { titleSuffix } } -public struct VoteOption: Equatable, Identifiable, Hashable { - public let label: String - public let title: String - - public var id: String { label } - - public init( - label: String, - title: String - ) { - self.label = label - self.title = title - } -} - public extension VoteQuestion { static let mock = VoteQuestion( battleId: 41, diff --git a/Projects/Domain/Entity/Sources/Notification/NotificationCategory.swift b/Projects/Domain/Entity/Sources/Notification/NotificationCategory.swift new file mode 100644 index 0000000..e0bf422 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Notification/NotificationCategory.swift @@ -0,0 +1,36 @@ +// +// NotificationCategory.swift +// Entity +// +// 알림 카테고리 (탭 + 항목 분류). API category 파라미터와 1:1. +// + +import Foundation + +public enum NotificationCategory: String, Equatable, CaseIterable, Identifiable { + case all = "ALL" + case content = "CONTENT" + case notice = "NOTICE" + case event = "EVENT" + + public var id: String { rawValue } + + /// 탭 표시명. + public var title: String { + switch self { + case .all: "전체" + case .content: "콘텐츠" + case .notice: "공지사항" + case .event: "이벤트" + } + } + + public init(rawValue: String) { + switch rawValue.uppercased() { + case "CONTENT": self = .content + case "NOTICE": self = .notice + case "EVENT": self = .event + default: self = .all + } + } +} diff --git a/Projects/Domain/Entity/Sources/Notification/NotificationDetail.swift b/Projects/Domain/Entity/Sources/Notification/NotificationDetail.swift new file mode 100644 index 0000000..f06b0d3 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Notification/NotificationDetail.swift @@ -0,0 +1,44 @@ +// +// NotificationDetail.swift +// Entity +// +// 알림 단건 상세 — `GET /api/v1/notifications/{id}` (목록 항목 + readAt). +// + +import Foundation + +public struct NotificationDetail: Equatable, Identifiable { + public let notificationId: Int + public let category: NotificationCategory + public let detailCode: String + public let title: String + public let body: String + public let referenceId: Int? + public let isRead: Bool + public let createdAt: Date? + public let readAt: Date? + + public var id: Int { notificationId } + + public init( + notificationId: Int, + category: NotificationCategory, + detailCode: String, + title: String, + body: String, + referenceId: Int?, + isRead: Bool, + createdAt: Date?, + readAt: Date? + ) { + self.notificationId = notificationId + self.category = category + self.detailCode = detailCode + self.title = title + self.body = body + self.referenceId = referenceId + self.isRead = isRead + self.createdAt = createdAt + self.readAt = readAt + } +} diff --git a/Projects/Domain/Entity/Sources/Notification/NotificationError.swift b/Projects/Domain/Entity/Sources/Notification/NotificationError.swift new file mode 100644 index 0000000..f46770b --- /dev/null +++ b/Projects/Domain/Entity/Sources/Notification/NotificationError.swift @@ -0,0 +1,44 @@ +// +// NotificationError.swift +// Entity +// +// 알림 도메인 표준 에러. +// + +import Foundation + +public enum NotificationError: LocalizedError, Equatable { + case networkError(String) + case decodingError(String) + case noData + case unauthorized + case backendError(String) + case serverError(Int) + case unknown(String) + + public var errorDescription: String? { + switch self { + case let .networkError(message): + "네트워크 오류: \(message)" + case let .decodingError(message): + "데이터 파싱 오류: \(message)" + case .noData: + "알림 데이터가 없습니다" + case .unauthorized: + "권한이 없습니다" + case let .backendError(message): + "알림 처리 실패: \(message)" + case let .serverError(code): + "서버 오류 (코드: \(code))" + case let .unknown(message): + "알 수 없는 오류: \(message)" + } + } +} + +public extension NotificationError { + static func from(_ error: Error) -> NotificationError { + if let notificationError = error as? NotificationError { return notificationError } + return .unknown(error.localizedDescription) + } +} diff --git a/Projects/Domain/Entity/Sources/Notification/NotificationItem.swift b/Projects/Domain/Entity/Sources/Notification/NotificationItem.swift new file mode 100644 index 0000000..3ff2d98 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Notification/NotificationItem.swift @@ -0,0 +1,69 @@ +// +// NotificationItem.swift +// Entity +// +// 알림 단건 — `GET /api/v1/notifications` items. +// + +import Foundation + +public struct NotificationItem: Equatable, Identifiable { + public let notificationId: Int + public let category: NotificationCategory + /// 세부 유형 코드 (서버 정의 문자열). + public let detailCode: String + /// 상단 보조 문구 (예: "새로운 배틀이 시작되었어요"). + public let title: String + /// 본문 (예: "“AI가 만든 그림도 예술인가?”에 지금 참여해보세요!"). + public let body: String + /// 연관 리소스 id (배틀/공지 등). 없을 수 있음. + public let referenceId: Int? + public let isRead: Bool + public let createdAt: Date? + + public var id: Int { notificationId } + + public init( + notificationId: Int, + category: NotificationCategory, + detailCode: String, + title: String, + body: String, + referenceId: Int?, + isRead: Bool, + createdAt: Date? + ) { + self.notificationId = notificationId + self.category = category + self.detailCode = detailCode + self.title = title + self.body = body + self.referenceId = referenceId + self.isRead = isRead + self.createdAt = createdAt + } + + /// 카테고리 기반 표시 아이콘 (SF Symbol). + public var iconSystemName: String { + switch category { + case .content: "flame" + case .notice: "speaker.wave.2" + case .event: "gift" + case .all: "bell" + } + } + + /// 읽음 처리된 사본. + public func markedAsRead() -> NotificationItem { + NotificationItem( + notificationId: notificationId, + category: category, + detailCode: detailCode, + title: title, + body: body, + referenceId: referenceId, + isRead: true, + createdAt: createdAt + ) + } +} diff --git a/Projects/Domain/Entity/Sources/Notification/NotificationPage.swift b/Projects/Domain/Entity/Sources/Notification/NotificationPage.swift new file mode 100644 index 0000000..80616e9 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Notification/NotificationPage.swift @@ -0,0 +1,21 @@ +// +// NotificationPage.swift +// Entity +// +// `GET /api/v1/notifications` 응답 (page 기반 페이지네이션). +// + +import Foundation + +public struct NotificationPage: Equatable { + public let items: [NotificationItem] + public let hasNext: Bool + + public init( + items: [NotificationItem], + hasNext: Bool + ) { + self.items = items + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleRecord.swift b/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleRecord.swift new file mode 100644 index 0000000..6490914 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleRecord.swift @@ -0,0 +1,41 @@ +// +// BattleRecord.swift +// Entity +// + +import Foundation + +public struct BattleRecord: Equatable, Identifiable { + public let battleId: String + public let recordId: String + public let voteSide: BattleVoteSide + public let category: String + public let title: String + public let summary: String + public let createdAt: Date? + + public var id: String { recordId } + + /// 카테고리 태그 표시 (예: `#철학`). + public var categoryTag: String { + category.isEmpty ? "" : "#\(category)" + } + + public init( + battleId: String, + recordId: String, + voteSide: BattleVoteSide, + category: String, + title: String, + summary: String, + createdAt: Date? + ) { + self.battleId = battleId + self.recordId = recordId + self.voteSide = voteSide + self.category = category + self.title = title + self.summary = summary + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleRecordPage.swift b/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleRecordPage.swift new file mode 100644 index 0000000..b96171d --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleRecordPage.swift @@ -0,0 +1,24 @@ +// +// BattleRecordPage.swift +// Entity +// +// `GET /api/v1/me/battle-records` 응답 (offset 페이지네이션). +// + +import Foundation + +public struct BattleRecordPage: Equatable { + public let items: [BattleRecord] + public let nextOffset: Int + public let hasNext: Bool + + public init( + items: [BattleRecord], + nextOffset: Int, + hasNext: Bool + ) { + self.items = items + self.nextOffset = nextOffset + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleVoteSide.swift b/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleVoteSide.swift new file mode 100644 index 0000000..084980d --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/BattleRecord/BattleVoteSide.swift @@ -0,0 +1,21 @@ +// +// BattleVoteSide.swift +// Entity +// + +import Foundation + +/// 투표 진영 (PRO=찬성 / CON=반대). +public enum BattleVoteSide: String, Equatable { + case pro = "PRO" + case con = "CON" + case unknown = "" + + public init(rawValue: String) { + switch rawValue.uppercased() { + case "PRO": self = .pro + case "CON": self = .con + default: self = .unknown + } + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivity.swift b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivity.swift new file mode 100644 index 0000000..1dc7ec2 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivity.swift @@ -0,0 +1,56 @@ +// +// ContentActivity.swift +// Entity +// +// `GET /api/v1/me/content-activities` 항목 (내가 단/좋아요한 댓글). +// + +import Foundation + +public struct ContentActivity: Equatable, Identifiable { + public let activityId: String + public let activityType: ContentActivityType + public let perspectiveId: String + public let battleId: String + public let battleTitle: String + public let author: ContentActivityAuthor + public let voteSide: BattleVoteSide + public let content: String + public let likeCount: Int + public let createdAt: Date? + + public var id: String { activityId } + + /// 의견 칩 텍스트 (찬성의견 / 반대의견). + public var stanceText: String { + switch voteSide { + case .pro: "찬성의견" + case .con: "반대의견" + case .unknown: "" + } + } + + public init( + activityId: String, + activityType: ContentActivityType, + perspectiveId: String, + battleId: String, + battleTitle: String, + author: ContentActivityAuthor, + voteSide: BattleVoteSide, + content: String, + likeCount: Int, + createdAt: Date? + ) { + self.activityId = activityId + self.activityType = activityType + self.perspectiveId = perspectiveId + self.battleId = battleId + self.battleTitle = battleTitle + self.author = author + self.voteSide = voteSide + self.content = content + self.likeCount = likeCount + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityAuthor.swift b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityAuthor.swift new file mode 100644 index 0000000..a2e6238 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityAuthor.swift @@ -0,0 +1,25 @@ +// +// ContentActivityAuthor.swift +// Entity +// + +import Foundation + +public struct ContentActivityAuthor: Equatable { + public let userTag: String + public let nickname: String + public let characterType: String + public let characterImageURL: String + + public init( + userTag: String, + nickname: String, + characterType: String, + characterImageURL: String + ) { + self.userTag = userTag + self.nickname = nickname + self.characterType = characterType + self.characterImageURL = characterImageURL + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityPage.swift b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityPage.swift new file mode 100644 index 0000000..24194b7 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityPage.swift @@ -0,0 +1,24 @@ +// +// ContentActivityPage.swift +// Entity +// +// `GET /api/v1/me/content-activities` 응답 (offset 페이지네이션). +// + +import Foundation + +public struct ContentActivityPage: Equatable { + public let items: [ContentActivity] + public let nextOffset: Int + public let hasNext: Bool + + public init( + items: [ContentActivity], + nextOffset: Int, + hasNext: Bool + ) { + self.items = items + self.nextOffset = nextOffset + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityType.swift b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityType.swift new file mode 100644 index 0000000..77f3355 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/ContentActivity/ContentActivityType.swift @@ -0,0 +1,29 @@ +// +// ContentActivityType.swift +// Entity +// + +import Foundation + +/// 콘텐츠 활동 유형 (탭). +public enum ContentActivityType: String, Equatable, CaseIterable, Identifiable { + case comment = "COMMENT" + case like = "LIKE" + + public var id: String { rawValue } + + /// 탭 표시명. + public var title: String { + switch self { + case .comment: "내 댓글" + case .like: "좋아요" + } + } + + public init(rawValue: String) { + switch rawValue.uppercased() { + case "LIKE": self = .like + default: self = .comment + } + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Credit/CreditHistoryItem.swift b/Projects/Domain/Entity/Sources/Profile/Credit/CreditHistoryItem.swift new file mode 100644 index 0000000..93ace99 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Credit/CreditHistoryItem.swift @@ -0,0 +1,53 @@ +// +// CreditHistoryItem.swift +// Entity +// + +import Foundation + +public struct CreditHistoryItem: Equatable, Identifiable { + public let id: Int + /// 크레딧 유형 코드 (예: `TODAY_CREDIT`). + public let creditType: String + /// 변동 포인트. 양수=적립, 음수=사용. + public let amount: Int + public let referenceId: Int? + public let createdAt: Date? + + public init( + id: Int, + creditType: String, + amount: Int, + referenceId: Int?, + createdAt: Date? + ) { + self.id = id + self.creditType = creditType + self.amount = amount + self.referenceId = referenceId + self.createdAt = createdAt + } + + /// 적립 여부 (양수). + public var isEarned: Bool { amount >= 0 } + + /// 금액 표시 (예: `+ 10P` / `- 5P`). + public var amountText: String { + "\(isEarned ? "+" : "-") \(abs(amount))P" + } + + /// 적립/사용 라벨. + public var statusText: String { isEarned ? "적립" : "사용" } + + /// 크레딧 유형 표시명. + public var title: String { + switch creditType { + case "TODAY_CREDIT", "FREE_CREDIT", "SIGNUP_CREDIT": + return "무료 충전" + case "BATTLE_PARTICIPATION", "BATTLE_VOTE", "BATTLE": + return "배틀 참여" + default: + return isEarned ? "포인트 적립" : "포인트 사용" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Credit/CreditHistoryPage.swift b/Projects/Domain/Entity/Sources/Profile/Credit/CreditHistoryPage.swift new file mode 100644 index 0000000..8f73fed --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Credit/CreditHistoryPage.swift @@ -0,0 +1,24 @@ +// +// CreditHistoryPage.swift +// Entity +// +// `GET /api/v1/me/credits/history` 응답 (offset 페이지네이션). +// + +import Foundation + +public struct CreditHistoryPage: Equatable { + public let items: [CreditHistoryItem] + public let nextOffset: Int + public let hasNext: Bool + + public init( + items: [CreditHistoryItem], + nextOffset: Int, + hasNext: Bool + ) { + self.items = items + self.nextOffset = nextOffset + self.hasNext = hasNext + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/MyPage/MyPage.swift b/Projects/Domain/Entity/Sources/Profile/MyPage/MyPage.swift new file mode 100644 index 0000000..59e79e9 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/MyPage/MyPage.swift @@ -0,0 +1,24 @@ +// +// MyPage.swift +// Entity +// +// `GET /api/v1/me/mypage` 응답 도메인 모델 (프로필 / 철학자 / 티어). +// + +import Foundation + +public struct MyPage: Equatable { + public let profile: MyProfile + public let philosopher: MyPhilosopher + public let tier: MyTier + + public init( + profile: MyProfile, + philosopher: MyPhilosopher, + tier: MyTier + ) { + self.profile = profile + self.philosopher = philosopher + self.tier = tier + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/MyPage/MyPhilosopher.swift b/Projects/Domain/Entity/Sources/Profile/MyPage/MyPhilosopher.swift new file mode 100644 index 0000000..2f53f06 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/MyPage/MyPhilosopher.swift @@ -0,0 +1,38 @@ +// +// MyPhilosopher.swift +// Entity +// + +import Foundation + +public struct MyPhilosopher: Equatable { + /// 철학자 유형 코드 (예: `SOCRATES`). + public let philosopherType: String + public let philosopherLabel: String + /// 유형명 (예: `??형`). + public let typeName: String + public let description: String + public let imageURL: String + + public init( + philosopherType: String, + philosopherLabel: String, + typeName: String, + description: String, + imageURL: String + ) { + self.philosopherType = philosopherType + self.philosopherLabel = philosopherLabel + self.typeName = typeName + self.description = description + self.imageURL = imageURL + } + + public static let empty = MyPhilosopher( + philosopherType: "", + philosopherLabel: "", + typeName: "", + description: "", + imageURL: "" + ) +} diff --git a/Projects/Domain/Entity/Sources/Profile/MyPage/MyProfile.swift b/Projects/Domain/Entity/Sources/Profile/MyPage/MyProfile.swift new file mode 100644 index 0000000..71cb4fb --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/MyPage/MyProfile.swift @@ -0,0 +1,43 @@ +// +// MyProfile.swift +// Entity +// + +import Foundation + +public struct MyProfile: Equatable { + /// 사용자 코드 (`@` 표기에 사용). + public let userTag: String + public let nickname: String + /// 캐릭터 유형 코드 (예: `OWL`). + public let characterType: String + public let characterLabel: String + public let characterImageURL: String + /// 매너 온도. + public let mannerTemperature: Double + + public init( + userTag: String, + nickname: String, + characterType: String, + characterLabel: String, + characterImageURL: String, + mannerTemperature: Double + ) { + self.userTag = userTag + self.nickname = nickname + self.characterType = characterType + self.characterLabel = characterLabel + self.characterImageURL = characterImageURL + self.mannerTemperature = mannerTemperature + } + + public static let empty = MyProfile( + userTag: "", + nickname: "", + characterType: "", + characterLabel: "", + characterImageURL: "", + mannerTemperature: 0 + ) +} diff --git a/Projects/Domain/Entity/Sources/Profile/MyPage/MyTier.swift b/Projects/Domain/Entity/Sources/Profile/MyPage/MyTier.swift new file mode 100644 index 0000000..3d37756 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/MyPage/MyTier.swift @@ -0,0 +1,26 @@ +// +// MyTier.swift +// Entity +// + +import Foundation + +public struct MyTier: Equatable { + /// 티어 코드 (예: `WANDERER`). + public let tierCode: String + public let tierLabel: String + /// 보유 포인트. + public let currentPoint: Int + + public init( + tierCode: String, + tierLabel: String, + currentPoint: Int + ) { + self.tierCode = tierCode + self.tierLabel = tierLabel + self.currentPoint = currentPoint + } + + public static let empty = MyTier(tierCode: "", tierLabel: "", currentPoint: 0) +} diff --git a/Projects/Domain/Entity/Sources/Profile/Notice/NoticeTab.swift b/Projects/Domain/Entity/Sources/Profile/Notice/NoticeTab.swift new file mode 100644 index 0000000..a5726e5 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Notice/NoticeTab.swift @@ -0,0 +1,23 @@ +// +// NoticeTab.swift +// Entity +// +// 공지사항 · 이벤트 탭. +// + +import Foundation + +public enum NoticeTab: String, CaseIterable, Identifiable, Equatable { + case notice = "NOTICE" + case event = "EVENT" + + public var id: String { rawValue } + + /// 탭 표시명. + public var title: String { + switch self { + case .notice: "공지사항" + case .event: "이벤트" + } + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettingKey.swift b/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettingKey.swift new file mode 100644 index 0000000..e27f74b --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettingKey.swift @@ -0,0 +1,48 @@ +// +// NotificationSettingKey.swift +// Entity +// + +import Foundation + +/// 알림 설정 항목. +public enum NotificationSettingKey: String, CaseIterable, Identifiable, Equatable { + case newBattle + case battleResult + case commentReply + case newComment + case contentLike + case marketingEvent + + public var id: String { rawValue } + + public var title: String { + switch self { + case .newBattle: "새 배틀 알림" + case .battleResult: "투표 결과 알림" + case .commentReply: "내 댓글에 답글 알림" + case .newComment: "새 댓글 알림" + case .contentLike: "좋아요 알림" + case .marketingEvent: "이벤트 및 소식 알림" + } + } + + public var subtitle: String { + switch self { + case .newBattle: "관심 분야의 새로운 배틀이 열리면 알려드려요" + case .battleResult: "참여한 배틀의 최종 결과를 알려드려요" + case .commentReply: "내 의견에 답글이 달리면 알려드려요" + case .newComment: "참여한 배틀에 새 댓글이 달리면 알려드려요" + case .contentLike: "내 의견에 좋아요가 눌리면 알려드려요" + case .marketingEvent: "다양한 이벤트와 새로운 소식을 알려드려요" + } + } + + public var section: NotificationSettingSection { + switch self { + case .newBattle, .battleResult: .feature + case .commentReply, .newComment, .contentLike: .social + case .marketingEvent: .marketing + } + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettingSection.swift b/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettingSection.swift new file mode 100644 index 0000000..9cac506 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettingSection.swift @@ -0,0 +1,28 @@ +// +// NotificationSettingSection.swift +// Entity +// + +import Foundation + +/// 알림 설정 섹션. +public enum NotificationSettingSection: String, CaseIterable, Identifiable, Equatable { + case feature + case social + case marketing + + public var id: String { rawValue } + + public var title: String { + switch self { + case .feature: "기능별 알림 설정" + case .social: "소셜 알림 설정" + case .marketing: "마케팅 알림 설정" + } + } + + /// 섹션에 속한 항목들. + public var keys: [NotificationSettingKey] { + NotificationSettingKey.allCases.filter { $0.section == self } + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettings.swift b/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettings.swift new file mode 100644 index 0000000..f557190 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Notification/NotificationSettings.swift @@ -0,0 +1,59 @@ +// +// NotificationSettings.swift +// Entity +// +// `GET/PATCH /api/v1/me/notification-settings` 도메인 모델 (6개 토글). +// + +import Foundation + +public struct NotificationSettings: Equatable { + public var newBattleEnabled: Bool + public var battleResultEnabled: Bool + public var commentReplyEnabled: Bool + public var newCommentEnabled: Bool + public var contentLikeEnabled: Bool + public var marketingEventEnabled: Bool + + public init( + newBattleEnabled: Bool = false, + battleResultEnabled: Bool = false, + commentReplyEnabled: Bool = false, + newCommentEnabled: Bool = false, + contentLikeEnabled: Bool = false, + marketingEventEnabled: Bool = false + ) { + self.newBattleEnabled = newBattleEnabled + self.battleResultEnabled = battleResultEnabled + self.commentReplyEnabled = commentReplyEnabled + self.newCommentEnabled = newCommentEnabled + self.contentLikeEnabled = contentLikeEnabled + self.marketingEventEnabled = marketingEventEnabled + } + + /// 키별 on/off 조회. + public func isOn(_ key: NotificationSettingKey) -> Bool { + switch key { + case .newBattle: newBattleEnabled + case .battleResult: battleResultEnabled + case .commentReply: commentReplyEnabled + case .newComment: newCommentEnabled + case .contentLike: contentLikeEnabled + case .marketingEvent: marketingEventEnabled + } + } + + /// 키별 on/off 설정 (불변 갱신). + public func setting(_ key: NotificationSettingKey, to value: Bool) -> NotificationSettings { + var copy = self + switch key { + case .newBattle: copy.newBattleEnabled = value + case .battleResult: copy.battleResultEnabled = value + case .commentReply: copy.commentReplyEnabled = value + case .newComment: copy.newCommentEnabled = value + case .contentLike: copy.contentLikeEnabled = value + case .marketingEvent: copy.marketingEventEnabled = value + } + return copy + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/FavoriteTopic.swift b/Projects/Domain/Entity/Sources/Profile/Recap/FavoriteTopic.swift new file mode 100644 index 0000000..280d67f --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Recap/FavoriteTopic.swift @@ -0,0 +1,25 @@ +// +// FavoriteTopic.swift +// Entity +// + +import Foundation + +public struct FavoriteTopic: Equatable, Identifiable { + public let rank: Int + public let tagName: String + public let participationCount: Int + + public var id: Int { rank } + + /// `#` 태그 표시. + public var tagText: String { + tagName.hasPrefix("#") ? tagName : "#\(tagName)" + } + + public init(rank: Int, tagName: String, participationCount: Int) { + self.rank = rank + self.tagName = tagName + self.participationCount = participationCount + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/PhilosopherRecap.swift b/Projects/Domain/Entity/Sources/Profile/Recap/PhilosopherRecap.swift new file mode 100644 index 0000000..a37211e --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Recap/PhilosopherRecap.swift @@ -0,0 +1,39 @@ +// +// PhilosopherRecap.swift +// Entity +// +// `GET /api/v1/me/recap` 응답 — 나의 철학자 유형 리캡. +// + +import Foundation + +public struct PhilosopherRecap: Equatable { + public let myCard: RecapCard + public let bestMatchCard: RecapCard + public let worstMatchCard: RecapCard + public let scores: RecapScores + public let preferenceReport: PreferenceReport + + public init( + myCard: RecapCard, + bestMatchCard: RecapCard, + worstMatchCard: RecapCard, + scores: RecapScores, + preferenceReport: PreferenceReport + ) { + self.myCard = myCard + self.bestMatchCard = bestMatchCard + self.worstMatchCard = worstMatchCard + self.scores = scores + self.preferenceReport = preferenceReport + } + + /// 잠금/빈 응답용 — totalParticipation 0 → 잠금 판정. + public static let empty = PhilosopherRecap( + myCard: .empty, + bestMatchCard: .empty, + worstMatchCard: .empty, + scores: .empty, + preferenceReport: .empty + ) +} diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/PreferenceReport.swift b/Projects/Domain/Entity/Sources/Profile/Recap/PreferenceReport.swift new file mode 100644 index 0000000..5520237 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Recap/PreferenceReport.swift @@ -0,0 +1,35 @@ +// +// PreferenceReport.swift +// Entity +// +// 내 취향 리포트 (통계 + 선호 주제 랭킹). +// + +import Foundation + +public struct PreferenceReport: Equatable { + public let totalParticipation: Int + public let opinionChanges: Int + /// 배틀 승률 (%). + public let battleWinRate: Int + public let favoriteTopics: [FavoriteTopic] + + public init( + totalParticipation: Int, + opinionChanges: Int, + battleWinRate: Int, + favoriteTopics: [FavoriteTopic] + ) { + self.totalParticipation = totalParticipation + self.opinionChanges = opinionChanges + self.battleWinRate = battleWinRate + self.favoriteTopics = favoriteTopics + } + + public static let empty = PreferenceReport( + totalParticipation: 0, + opinionChanges: 0, + battleWinRate: 0, + favoriteTopics: [] + ) +} diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/RecapCard.swift b/Projects/Domain/Entity/Sources/Profile/Recap/RecapCard.swift new file mode 100644 index 0000000..b010b8a --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Recap/RecapCard.swift @@ -0,0 +1,43 @@ +// +// RecapCard.swift +// Entity +// +// 철학자 카드 (내 카드 / 궁합 best·worst). +// + +import Foundation + +public struct RecapCard: Equatable { + public let philosopherType: String + public let philosopherLabel: String + /// 유형명 (예: `칸트형`). + public let typeName: String + public let description: String + public let keywordTags: [String] + public let imageURL: String + + public init( + philosopherType: String, + philosopherLabel: String, + typeName: String, + description: String, + keywordTags: [String], + imageURL: String + ) { + self.philosopherType = philosopherType + self.philosopherLabel = philosopherLabel + self.typeName = typeName + self.description = description + self.keywordTags = keywordTags + self.imageURL = imageURL + } + + public static let empty = RecapCard( + philosopherType: "", + philosopherLabel: "", + typeName: "", + description: "", + keywordTags: [], + imageURL: "" + ) +} diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/RecapScoreAxis.swift b/Projects/Domain/Entity/Sources/Profile/Recap/RecapScoreAxis.swift new file mode 100644 index 0000000..40c7d87 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Recap/RecapScoreAxis.swift @@ -0,0 +1,19 @@ +// +// RecapScoreAxis.swift +// Entity +// + +import Foundation + +public struct RecapScoreAxis: Equatable, Identifiable { + public let label: String + /// 0~100. + public let value: Double + + public var id: String { label } + + public init(label: String, value: Double) { + self.label = label + self.value = value + } +} diff --git a/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift b/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift new file mode 100644 index 0000000..b63c11c --- /dev/null +++ b/Projects/Domain/Entity/Sources/Profile/Recap/RecapScores.swift @@ -0,0 +1,61 @@ +// +// RecapScores.swift +// Entity +// +// 성향 분석 6축 점수 (0~100). +// + +import Foundation + +public struct RecapScores: Equatable { + public let principle: Double + public let reason: Double + public let individual: Double + public let change: Double + public let inner: Double + public let ideal: Double + + public init( + principle: Double, + reason: Double, + individual: Double, + change: Double, + inner: Double, + ideal: Double + ) { + self.principle = principle + self.reason = reason + self.individual = individual + self.change = change + self.inner = inner + self.ideal = ideal + } + + public static let empty = RecapScores( + principle: 0, reason: 0, individual: 0, change: 0, inner: 0, ideal: 0 + ) + + /// 레이더 각도 순서 (원칙↑ → 시계방향: 이성·개인·변화·내면·직관). + public var axes: [RecapScoreAxis] { + [ + RecapScoreAxis(label: "원칙", value: principle), + RecapScoreAxis(label: "이성", value: reason), + RecapScoreAxis(label: "개인", value: individual), + RecapScoreAxis(label: "변화", value: change), + RecapScoreAxis(label: "내면", value: inner), + RecapScoreAxis(label: "직관", value: ideal), + ] + } + + /// 점수 바 2열 그리드 순서 (picke.pen: 원칙·이성 / 개인·변화 / 내면·이상). + public var gridAxes: [RecapScoreAxis] { + [ + RecapScoreAxis(label: "원칙", value: principle), + RecapScoreAxis(label: "이성", value: reason), + RecapScoreAxis(label: "개인", value: individual), + RecapScoreAxis(label: "변화", value: change), + RecapScoreAxis(label: "내면", value: inner), + RecapScoreAxis(label: "이상", value: ideal), + ] + } +} diff --git a/Projects/Domain/UseCase/Project.swift b/Projects/Domain/UseCase/Project.swift index 63d0f4d..495b88e 100644 --- a/Projects/Domain/UseCase/Project.swift +++ b/Projects/Domain/UseCase/Project.swift @@ -1,18 +1,21 @@ +import DependencyPackagePlugin +import DependencyPlugin import Foundation import ProjectDescription -import DependencyPlugin import ProjectTemplatePlugin -import DependencyPackagePlugin let project = Project.makeModule( name: "UseCase", bundleId: .appBundleID(name: ".UseCase"), product: .staticFramework, - settings: .settings(), + settings: .settings(), dependencies: [ .Domain(implements: .DomainInterface), .SPM.composableArchitecture, .SPM.weaveDI, + .SPM.mixpanel, + .SPM.mixpanelSessionReplay, + .SPM.googleMobileAds, ], sources: ["Sources/**"], hasTests: true diff --git a/Projects/Domain/UseCase/Sources/Ad/RewardedAdClient.swift b/Projects/Domain/UseCase/Sources/Ad/RewardedAdClient.swift new file mode 100644 index 0000000..cf8ac68 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Ad/RewardedAdClient.swift @@ -0,0 +1,115 @@ +// +// RewardedAdClient.swift +// UseCase +// +// 무료 충전 — GoogleMobileAds 리워드 동영상 광고(네이티브). +// 광고 클릭 시 랜딩(웹뷰)은 SDK 인앱 브라우저로 자동 처리되며, +// rootViewController 만 올바르게 넘겨주면 된다. +// + +import Foundation + +import ComposableArchitecture +import LogMacro + +import GoogleMobileAds +import UIKit + +public struct RewardedAdClient: Sendable { + /// 리워드 광고를 로드/표시하고 보상 획득 여부를 반환. + public var showRewardedAd: @Sendable () async -> Bool + + public init(showRewardedAd: @escaping @Sendable () async -> Bool) { + self.showRewardedAd = showRewardedAd + } +} + +extension RewardedAdClient: DependencyKey { + public static let liveValue = RewardedAdClient( + showRewardedAd: { await RewardedAdPresenter().present() } + ) + + public static let testValue = RewardedAdClient(showRewardedAd: { false }) + public static let previewValue = testValue +} + +public extension DependencyValues { + var rewardedAdClient: RewardedAdClient { + get { self[RewardedAdClient.self] } + set { self[RewardedAdClient.self] = newValue } + } +} + +/// 리워드 광고 1회 표시를 책임지는 프레젠터. 표시 종료(보상/닫힘/실패)까지 self 를 유지한다. +@MainActor +private final class RewardedAdPresenter: NSObject, FullScreenContentDelegate { + // 리워드 광고 유닛 ID — xcconfig(REWARD_AD_UNIT) → Info.plist → Bundle 에서 주입. + // 값이 없으면 Google 테스트 ID 로 폴백. + private let adUnitID: String = { + let key = Bundle.main.object(forInfoDictionaryKey: "REWARD_AD_UNIT") as? String + if let key, !key.isEmpty { return key } + return "ca-app-pub-3940256099942544/1712485313" + }() + + private var rewardedAd: RewardedAd? + private var continuation: CheckedContinuation? + private var earnedReward = false + private var retainSelf: RewardedAdPresenter? + + func present() async -> Bool { + do { + let ad = try await RewardedAd.load(with: adUnitID, request: Request()) + rewardedAd = ad + ad.fullScreenContentDelegate = self + + guard let rootViewController = Self.topViewController() else { + Log.error("[RewardedAd] rootViewController 를 찾지 못했습니다") + return false + } + + return await withCheckedContinuation { continuation in + self.continuation = continuation + self.retainSelf = self + ad.present(from: rootViewController) { [weak self] in + self?.earnedReward = true + Log.debug("[RewardedAd] 보상 획득") + } + } + } catch { + Log.error("[RewardedAd] 광고 로드 실패: \(error.localizedDescription)") + return false + } + } + + // MARK: FullScreenContentDelegate + + func adDidDismissFullScreenContent(_: FullScreenPresentingAd) { + finish(earnedReward) + } + + func ad(_: FullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) { + Log.error("[RewardedAd] 광고 표시 실패: \(error.localizedDescription)") + finish(false) + } + + private func finish(_ result: Bool) { + continuation?.resume(returning: result) + continuation = nil + rewardedAd = nil + retainSelf = nil + } + + /// 현재 화면 최상단(모달 포함) ViewController — 광고/클릭 랜딩 표시 기준. + private static func topViewController() -> UIViewController? { + let keyWindow = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first(where: \.isKeyWindow) + + var top = keyWindow?.rootViewController + while let presented = top?.presentedViewController { + top = presented + } + return top + } +} diff --git a/Projects/Domain/UseCase/Sources/Analytics/AnalyticsUseCase.swift b/Projects/Domain/UseCase/Sources/Analytics/AnalyticsUseCase.swift new file mode 100644 index 0000000..429a15e --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Analytics/AnalyticsUseCase.swift @@ -0,0 +1,183 @@ +// +// AnalyticsUseCase.swift +// UseCase +// +// Mixpanel 트래킹 — PICKé 핵심 이벤트 명세서 기준. +// 이벤트를 쪼개지 않고 하나의 카테고리 + 속성값으로 구분(무료 플랜 한도 절약). +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro +import Mixpanel +import MixpanelSessionReplay + +// MARK: - 이벤트 정의 (명세서) + +public enum AnalyticsEvent: Sendable { + /// 소셜 가입 완료 및 메인 진입 시. + case signUp(method: String) + /// 배틀 각 단계 완료 시 (pre_vote / audio_end / post_vote). + case battleStep(BattleStepData) + /// 리포트 조회/공유 클릭 시. + case reportAction(ReportActionData) + /// 댓글 등록 성공 시. + case communityAction(CommunityActionData) + /// 보상형 광고 시청 완료 시. + case adRevenue(placement: String) +} + +public enum BattleStep: String, Sendable { + case preVote = "pre_vote" + case audioEnd = "audio_end" + case postVote = "post_vote" +} + +public struct BattleStepData: Sendable { + public let stepName: BattleStep + public let contentID: String + /// 선택지(좌/우 등). 없으면 미전송. + public let choice: String? + /// 사전→사후 투표 변경 여부. post_vote 에서만 유의미. + public let isChanged: Bool? + + public init( + stepName: BattleStep, + contentID: String, + choice: String? = nil, + isChanged: Bool? = nil + ) { + self.stepName = stepName + self.contentID = contentID + self.choice = choice + self.isChanged = isChanged + } +} + +public enum ReportActionType: String, Sendable { + case view + case share +} + +public struct ReportActionData: Sendable { + public let actionType: ReportActionType + /// 대표 지표(최상위 철학자 유형 등). 없으면 미전송. + public let topIndicator: String? + + public init(actionType: ReportActionType, topIndicator: String? = nil) { + self.actionType = actionType + self.topIndicator = topIndicator + } +} + +public struct CommunityActionData: Sendable { + public let contentID: String + public let commentLength: Int + + public init(contentID: String, commentLength: Int) { + self.contentID = contentID + self.commentLength = commentLength + } +} + +// MARK: - UseCase + +public struct AnalyticsUseCase: Sendable { + /// 로그인 성공 직후 유저 고유 ID 를 Mixpanel 에 연결. + public var identify: @Sendable (_ userID: String, _ method: String?) -> Void + /// 핵심 퍼널 이벤트 트래킹. + public var track: @Sendable (_ event: AnalyticsEvent) -> Void + + public init( + identify: @escaping @Sendable (_ userID: String, _ method: String?) -> Void, + track: @escaping @Sendable (_ event: AnalyticsEvent) -> Void + ) { + self.identify = identify + self.track = track + } +} + +extension AnalyticsUseCase: DependencyKey { + public static let liveValue = AnalyticsUseCase( + identify: { userID, method in + guard !userID.isEmpty else { return } + + let mixpanel = Mixpanel.mainInstance() + mixpanel.identify(distinctId: userID) + MPSessionReplay.getInstance()?.identify(distinctId: userID) + + var properties: Properties = [:] + if let method, !method.isEmpty { + properties["provider"] = method + } + if !properties.isEmpty { + mixpanel.people.set(properties: properties) + } + }, + track: { event in + let mixpanel = Mixpanel.mainInstance() + let name = eventName(event) + let properties = eventProperties(event) + #logDebug("Mixpanel track", ["event": name, "properties": String(describing: properties)]) + mixpanel.track(event: name, properties: properties) + } + ) + + public static let testValue = AnalyticsUseCase(identify: { _, _ in }, track: { _ in }) + public static let previewValue = testValue + + private static func eventName(_ event: AnalyticsEvent) -> String { + switch event { + case .signUp: "sign_up" + case .battleStep: "battle_step" + case .reportAction: "report_action" + case .communityAction: "community_action" + case .adRevenue: "ad_revenue" + } + } + + private static func eventProperties(_ event: AnalyticsEvent) -> Properties { + switch event { + case let .signUp(method): + return ["method": method] + + case let .battleStep(data): + var properties: Properties = [ + "step_name": data.stepName.rawValue, + "content_id": data.contentID, + ] + if let choice = data.choice { + properties["choice"] = choice + } + if let isChanged = data.isChanged { + properties["is_changed"] = isChanged + } + return properties + + case let .reportAction(data): + var properties: Properties = ["action_type": data.actionType.rawValue] + if let topIndicator = data.topIndicator { + properties["top_indicator"] = topIndicator + } + return properties + + case let .communityAction(data): + return [ + "content_id": data.contentID, + "comment_length": data.commentLength, + ] + + case let .adRevenue(placement): + return ["placement": placement] + } + } +} + +public extension DependencyValues { + var analyticsUseCase: AnalyticsUseCase { + get { self[AnalyticsUseCase.self] } + set { self[AnalyticsUseCase.self] = newValue } + } +} diff --git a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift index b3bb82c..c5af1b2 100644 --- a/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift +++ b/Projects/Domain/UseCase/Sources/Battle/BattleUseCase.swift @@ -80,6 +80,10 @@ public struct BattleUseCaseImpl: BattleInterface { public func fetchRecommendedBattles(battleId: Int) async throws -> RecommendedBattlePage { return try await battleRepository.fetchRecommendedBattles(battleId: battleId) } + + public func proposeBattle(_ draft: BattleProposalDraft) async throws -> BattleProposal { + return try await battleRepository.proposeBattle(draft) + } } extension BattleUseCaseImpl: DependencyKey { diff --git a/Projects/Domain/UseCase/Sources/Notification/NotificationUseCase.swift b/Projects/Domain/UseCase/Sources/Notification/NotificationUseCase.swift new file mode 100644 index 0000000..497da7e --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Notification/NotificationUseCase.swift @@ -0,0 +1,54 @@ +// +// NotificationUseCase.swift +// UseCase +// + +import Foundation + +import DomainInterface +import Entity + +import ComposableArchitecture + +public struct NotificationUseCaseImpl: NotificationInterface { + @Dependency(\.notificationRepository) private var notificationRepository + + public init() {} + + public func fetchNotifications( + category: NotificationCategory, + page: Int, + size: Int + ) async throws -> NotificationPage { + return try await notificationRepository.fetchNotifications( + category: category, + page: page, + size: size + ) + } + + public func fetchNotificationDetail(notificationId: Int) async throws -> NotificationDetail { + return try await notificationRepository.fetchNotificationDetail(notificationId: notificationId) + } + + public func markAsRead(notificationId: Int) async throws { + try await notificationRepository.markAsRead(notificationId: notificationId) + } + + public func markAllAsRead() async throws { + try await notificationRepository.markAllAsRead() + } +} + +extension NotificationUseCaseImpl: DependencyKey { + public static var liveValue = NotificationUseCaseImpl() + public static var testValue = NotificationUseCaseImpl() + public static var previewValue = NotificationUseCaseImpl() +} + +public extension DependencyValues { + var notificationUseCase: NotificationUseCaseImpl { + get { self[NotificationUseCaseImpl.self] } + set { self[NotificationUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift new file mode 100644 index 0000000..fd5c953 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Profile/ProfileUseCase.swift @@ -0,0 +1,77 @@ +// +// ProfileUseCase.swift +// UseCase +// + +import Foundation + +import DomainInterface +import Entity + +import ComposableArchitecture + +public struct ProfileUseCaseImpl: ProfileInterface { + @Dependency(\.profileRepository) private var profileRepository + + public init() {} + + public func fetchMyPage() async throws -> MyPage { + return try await profileRepository.fetchMyPage() + } + + public func fetchRecap() async throws -> PhilosopherRecap { + return try await profileRepository.fetchRecap() + } + + public func fetchCreditHistory( + offset: Int, + size: Int + ) async throws -> CreditHistoryPage { + return try await profileRepository.fetchCreditHistory(offset: offset, size: size) + } + + public func fetchBattleRecords( + offset: Int, + size: Int, + voteSide: BattleVoteSide? + ) async throws -> BattleRecordPage { + return try await profileRepository.fetchBattleRecords( + offset: offset, + size: size, + voteSide: voteSide + ) + } + + public func fetchContentActivities( + offset: Int, + size: Int, + activityType: ContentActivityType? + ) async throws -> ContentActivityPage { + return try await profileRepository.fetchContentActivities( + offset: offset, + size: size, + activityType: activityType + ) + } + + public func fetchNotificationSettings() async throws -> NotificationSettings { + return try await profileRepository.fetchNotificationSettings() + } + + public func updateNotificationSettings(_ settings: NotificationSettings) async throws -> NotificationSettings { + return try await profileRepository.updateNotificationSettings(settings) + } +} + +extension ProfileUseCaseImpl: DependencyKey { + public static var liveValue = ProfileUseCaseImpl() + public static var testValue = ProfileUseCaseImpl() + public static var previewValue = ProfileUseCaseImpl() +} + +public extension DependencyValues { + var profileUseCase: ProfileUseCaseImpl { + get { self[ProfileUseCaseImpl.self] } + set { self[ProfileUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift b/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift index 472f0a2..7b26add 100644 --- a/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift +++ b/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift @@ -87,6 +87,7 @@ public struct LoginFeature { @Dependency(\.appleManger) var appleLoginManger @Dependency(\.unifiedOAuthUseCase) var unifiedOAuthUseCase @Dependency(\.continuousClock) var clock + @Dependency(\.analyticsUseCase) var analyticsUseCase public var body: some Reducer { BindingReducer() @@ -198,6 +199,13 @@ extension LoginFeature { case let .success(loginEntity): state.loginEntity = loginEntity + // 로그인 성공 직후 유저 고유 ID(userTag) 를 Mixpanel 에 연결. + analyticsUseCase.identify(loginEntity.userTag, loginEntity.provider.rawValue) + // 신규 가입(메인 진입) 시 sign_up. + if loginEntity.isNewUser { + analyticsUseCase.track(.signUp(method: loginEntity.provider.rawValue)) + } + guard loginEntity.isNewUser else { return .send(.delegate(.presentMainTab)) } diff --git a/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift b/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift index 349b25a..38039b2 100644 --- a/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift +++ b/Projects/Presentation/Battle/Sources/Main/View/BattleView.swift @@ -339,7 +339,7 @@ private extension BattleView { .scaledToFit() .frame(width: 135, height: 90) - Text("오늘의 배틀이 없습니다") + Text("아직 빠른 배틀이 선정되지 않았어요\n 조금만 기다려주세요!") .pretendardCustomFont(textStyle: .bodyMedium) .foregroundStyle(.beige300) } diff --git a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift index ac3132c..074e43d 100644 --- a/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift +++ b/Projects/Presentation/Chat/Sources/ChatRoom/Reducer/ChatRoomFeature.swift @@ -221,6 +221,7 @@ public struct ChatRoomFeature { @Dependency(\.battleUseCase) private var battleUseCase @Dependency(\.audioPlayer) private var audioPlayer + @Dependency(\.analyticsUseCase) private var analyticsUseCase public var body: some Reducer { BindingReducer() @@ -486,6 +487,9 @@ extension ChatRoomFeature { if !state.hasFinishedListening { state.hasFinishedListening = true UserDefaults.standard.set(true, forKey: State.listenedKey(battleId: state.battleId)) + analyticsUseCase.track( + .battleStep(BattleStepData(stepName: .audioEnd, contentID: "\(state.battleId)")) + ) } state.isPlaying = false if !state.hasPresentedFinalVoteAlert { @@ -524,12 +528,15 @@ extension ChatRoomFeature { else { return nil } let nodeEndTime = state.nodeEndTime(for: currentNode) - guard time >= nodeEndTime - 0.25 else { return nil } + guard time >= nodeEndTime else { return nil } if !currentNode.interactiveOptions.isEmpty { guard !state.isWaitingForNodeSelection else { return nil } state.isWaitingForNodeSelection = true state.isPlaying = false + // 선택지 노출 시점에 이 노드의 대사가 전부 드러나도록 currentTime 을 노드 끝으로 고정 + // (음성이 끝나기 전 일찍 멈춰 글이 튀어 보이던 문제 방지). + state.currentTime = max(state.currentTime, nodeEndTime) return .run { [player = audioPlayer] _ in await player.pause() } diff --git a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift index b7009e3..fff5152 100644 --- a/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift +++ b/Projects/Presentation/Chat/Sources/Comment/Reducer/CommentFeature.swift @@ -155,6 +155,7 @@ public struct CommentFeature { @Dependency(\.battleUseCase) private var battleUseCase @Dependency(\.commentUseCase) private var commentUseCase @Dependency(\.perspectiveUseCase) private var perspectiveUseCase + @Dependency(\.analyticsUseCase) private var analyticsUseCase public var body: some Reducer { BindingReducer() @@ -427,11 +428,16 @@ extension CommentFeature { } }() // 관점 등록: POST /battles/{id}/perspectives, body {content, optionId} - return .run { [battle = battleUseCase] send in + return .run { [battle = battleUseCase, analyticsUseCase] send in let result = await Result { try await battle.createPerspective(battleId: battleId, content: content, optionId: optionId) } .mapError(BattleError.from) + if case .success = result { + analyticsUseCase.track( + .communityAction(CommunityActionData(contentID: "\(battleId)", commentLength: content.count)) + ) + } return await send(.inner(.createCommentResponse(result))) } .cancellable(id: CancelID.createComment, cancelInFlight: false) @@ -569,7 +575,16 @@ extension CommentFeature { case let .createCommentResponse(result): state.isSubmitting = false switch result { - case .success: + case let .success(perspective): + // 서버는 댓글을 "유저가 투표한 진영"에 저장한다(요청 optionId 무시). 응답의 실제 진영 + // (perspective.option)으로 필터를 전환해 방금 쓴 댓글이 그 진영 탭에서 보이도록 한다. + let votedOptionId = perspective.option.optionId + state.myOptionId = votedOptionId + if votedOptionId == state.voteSummary.optionA.optionId { + state.selectedFilter = .optionA + } else if votedOptionId == state.voteSummary.optionB.optionId { + state.selectedFilter = .optionB + } return .send(.async(.fetchPerspectives(reset: true))) case let .failure(error): Log.error("[CommentFeature] createComment failed: \(error.localizedDescription)") diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index 622034c..f3d7e04 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -261,6 +261,12 @@ private extension CommentView { } } .frame(maxWidth: .infinity) + // 전체 탭을 가로지르는 연속 베이스 라인 (셀별로 끊겨 보이던 문제 해결). + .overlay(alignment: .bottom) { + Rectangle() + .fill(.neutral200) + .frame(height: 1) + } } @ViewBuilder @@ -295,8 +301,8 @@ private extension CommentView { .contentShape(Rectangle()) .overlay(alignment: .bottom) { Rectangle() - .fill(isSelected ? Color.primary500 : Color.neutral200) - .frame(height: isSelected ? 2.5 : 1) + .fill(isSelected ? Color.primary500 : Color.clear) + .frame(height: 2.5) } } .buttonStyle(.plain) diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index 337a731..d9a2b17 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -119,6 +119,7 @@ public struct PreVoteFeature { @Dependency(\.battleUseCase) private var battleUseCase @Dependency(\.perspectiveUseCase) private var perspectiveUseCase + @Dependency(\.analyticsUseCase) private var analyticsUseCase public var body: some Reducer { BindingReducer() @@ -346,6 +347,9 @@ extension PreVoteFeature { state.isSubmitting = false switch result { case let .success(voteResult): + analyticsUseCase.track( + .battleStep(BattleStepData(stepName: .preVote, contentID: "\(state.battleId)")) + ) return .send(.delegate(.voteSubmitted(battleId: state.battleId, voteMode: .pre, result: voteResult))) case let .failure(error): Log.error("[PreVoteFeature] submitPreVote failed: \(error.localizedDescription)") @@ -360,6 +364,9 @@ extension PreVoteFeature { state.isSubmitting = false switch result { case let .success(voteResult): + analyticsUseCase.track( + .battleStep(BattleStepData(stepName: .postVote, contentID: "\(state.battleId)")) + ) return .send(.delegate(.voteSubmitted(battleId: state.battleId, voteMode: .post, result: voteResult))) case let .failure(error): // 최종투표는 1회만 가능 — 이미 투표한 경우 서버가 500. diff --git a/Projects/Presentation/Hifi/Project.swift b/Projects/Presentation/Hifi/Project.swift index 50aba6c..e4ae631 100644 --- a/Projects/Presentation/Hifi/Project.swift +++ b/Projects/Presentation/Hifi/Project.swift @@ -11,6 +11,7 @@ let project = Project.makeAppModule( settings: .settings(), dependencies: [ .Presentation(implements: .Chat), + .Presentation(implements: .Notification), .Shared(implements: .Shared), .Domain(implements: .UseCase), .SPM.composableArchitecture, diff --git a/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift b/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift index 7598c76..6ded6b1 100644 --- a/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift +++ b/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift @@ -9,6 +9,7 @@ import Foundation import Chat import ComposableArchitecture +import Notification import TCAFlow @FlowCoordinator(screen: "HifiScreen", navigation: true) @@ -75,6 +76,15 @@ extension HifiCoordinator { // 큐레이팅 X — 탐색 루트로 복귀 return .send(.view(.backToRootAction)) + // 알림(종) 아이콘 → 알림받기 화면 진입. + case .routeAction(_, action: .hifi(.delegate(.openNotification))): + state.routes.push(.notification(.init())) + return .none + + // 알림받기 백탭 → 뒤로. + case .routeAction(_, action: .notification(.delegate(.dismiss))): + return .send(.view(.backAction)) + default: return .none } @@ -101,6 +111,7 @@ extension HifiCoordinator { public enum HifiScreen { case hifi(HifiFeature) case chat(ChatCoordinator) + case notification(NotificationCoordinator) } } diff --git a/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift b/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift index 4f37b53..76e8aff 100644 --- a/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift +++ b/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift @@ -9,6 +9,7 @@ import SwiftUI import Chat import ComposableArchitecture +import Notification import TCAFlow public struct HifiCoordinatorView: View { @@ -25,6 +26,9 @@ public struct HifiCoordinatorView: View { HifiView(store: hifiStore) case let .chat(chatStore): ChatCoordinatorView(store: chatStore) + case let .notification(notificationStore): + NotificationCoordinatorView(store: notificationStore) + .toolbar(.hidden, for: .tabBar) } } } diff --git a/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift b/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift index a6e9100..5e29f52 100644 --- a/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift +++ b/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift @@ -45,6 +45,7 @@ public struct HifiFeature { case sortTapped(ExploreSort) case itemTapped(id: Int) case reachedBottom + case notificationTapped } public enum AsyncAction: Equatable { @@ -57,6 +58,8 @@ public struct HifiFeature { public enum DelegateAction: Equatable { case openBattle(battleId: Int) + /// 알림(종) 아이콘 → 알림받기 화면. + case openNotification } nonisolated enum CancelID: Hashable { @@ -110,6 +113,9 @@ extension HifiFeature { case let .itemTapped(id): return .send(.delegate(.openBattle(battleId: id))) + case .notificationTapped: + return .send(.delegate(.openNotification)) + case .reachedBottom: // 무한 스크롤: 다음 페이지가 있고 로딩 중이 아니면 추가 로드. guard state.hasNext, !state.isLoading else { return .none } diff --git a/Projects/Presentation/Hifi/Sources/View/HifiView.swift b/Projects/Presentation/Hifi/Sources/View/HifiView.swift index f1b3ce8..e9ab49a 100644 --- a/Projects/Presentation/Hifi/Sources/View/HifiView.swift +++ b/Projects/Presentation/Hifi/Sources/View/HifiView.swift @@ -23,7 +23,7 @@ public struct HifiView: View { public var body: some View { VStack(spacing: 0) { - HifiHeaderView {} + HifiHeaderView { send(.notificationTapped) } categoryTabs() diff --git a/Projects/Presentation/Home/Project.swift b/Projects/Presentation/Home/Project.swift index 4375ac2..40ac940 100644 --- a/Projects/Presentation/Home/Project.swift +++ b/Projects/Presentation/Home/Project.swift @@ -14,7 +14,8 @@ let project = Project.makeAppModule( .SPM.kingfisher, .Domain(implements: .UseCase), .Shared(implements: .Shared), - .Presentation(implements: .Chat) + .Presentation(implements: .Chat), + .Presentation(implements: .Notification), ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index 94aa6f1..0dbe160 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -9,6 +9,7 @@ import Foundation import Chat import ComposableArchitecture +import Notification import TCAFlow @FlowCoordinator(screen: "HomeScreen", navigation: true) @@ -75,6 +76,15 @@ extension HomeCoordinator { // 큐레이팅 X — 홈 루트로 복귀 return .send(.view(.backToRootAction)) + // 알림(종) 아이콘 → 알림받기 화면 진입. + case .routeAction(_, action: .home(.delegate(.openNotification))): + state.routes.push(.notification(.init())) + return .none + + // 알림받기 백탭 → 뒤로. + case .routeAction(_, action: .notification(.delegate(.dismiss))): + return .send(.view(.backAction)) + default: return .none } @@ -101,6 +111,7 @@ extension HomeCoordinator { public enum HomeScreen { case home(HomeFeature) case chat(ChatCoordinator) + case notification(NotificationCoordinator) } } diff --git a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift index 6b728fb..eb24f4f 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift @@ -11,6 +11,7 @@ import SwiftUI import Chat import ComposableArchitecture +import Notification import TCAFlow public struct HomeCoordinatorView: View { @@ -28,6 +29,9 @@ public struct HomeCoordinatorView: View { case let .chat(chatStore): ChatCoordinatorView(store: chatStore) .toolbar(.hidden, for: .tabBar) + case let .notification(notificationStore): + NotificationCoordinatorView(store: notificationStore) + .toolbar(.hidden, for: .tabBar) } } } diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift index 00cbe01..bbac45d 100644 --- a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -7,10 +7,10 @@ import ComposableArchitecture import DomainInterface -import UseCase import Entity import Foundation import LogMacro +import UseCase @Reducer public struct HomeFeature { @@ -29,6 +29,9 @@ public struct HomeFeature { public var votes: [VoteQuestion] = [] public var newBattles: [NewBattle] = [] + /// 종 아이콘 빨간점 — 미읽음 알림 존재 여부 (알림 화면과 전역 공유). + @Shared(.inMemory("HasUnreadNotification")) public var hasUnreadNotification: Bool = false + public var currentQuiz: QuizQuestion? { quizzes.first } public var currentVote: VoteQuestion? { votes.first } @@ -53,6 +56,7 @@ public struct HomeFeature { case hotBattleTapped(HotBattle) case bestBattleTapped(BestBattle) case newBattleTapped(NewBattle) + case notificationTapped } public enum Section: Equatable { @@ -72,6 +76,10 @@ public struct HomeFeature { public enum DelegateAction: Equatable { case presentPreVote(battleId: Int) + /// "더보기" → 탐색 탭으로 이동. + case moveToExplore + /// 알림(종) 아이콘 → 알림받기 화면. + case openNotification } nonisolated enum CancelID: Hashable { @@ -114,7 +122,7 @@ extension HomeFeature { return .send(.async(.fetchHome)) case .seeMoreTapped: - return .none + return .send(.delegate(.moveToExplore)) case let .voteTapped(question): return .send(.delegate(.presentPreVote(battleId: question.battleId))) @@ -130,6 +138,9 @@ extension HomeFeature { case let .newBattleTapped(battle): return .send(.delegate(.presentPreVote(battleId: battle.battleId))) + + case .notificationTapped: + return .send(.delegate(.openNotification)) } } @@ -182,8 +193,8 @@ extension HomeFeature { action: DelegateAction ) -> Effect { switch action { - case .presentPreVote: - .none + case .presentPreVote, .moveToExplore, .openNotification: + return .none } } } diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift index db34097..6fb0c1f 100644 --- a/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift @@ -11,6 +11,7 @@ import DesignSystem /// 홈 화면 최상단 GNB 위 헤더 (PicKé 로고 + 알림 아이콘). struct HomeHeaderView: View { + var hasUnread: Bool = false let onNotificationTapped: () -> Void var body: some View { @@ -27,6 +28,14 @@ struct HomeHeaderView: View { .resizable() .scaledToFit() .frame(width: 24, height: 24) + .overlay(alignment: .topTrailing) { + if hasUnread { + Circle() + .fill(.errorDefault) + .frame(width: 6, height: 6) + .offset(x: 1, y: -1) + } + } } } .padding(.horizontal, 24) diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift index 1a7c9dd..6ce4ffe 100644 --- a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -22,7 +22,7 @@ public struct HomeView: View { public var body: some View { VStack(spacing: 0) { - HomeHeaderView { /* TODO: 알림 화면 */ } // sticky — 스크롤 영향 없음 + HomeHeaderView(hasUnread: store.hasUnreadNotification) { send(.notificationTapped) } // sticky — 스크롤 영향 없음 ScrollView(showsIndicators: false) { if shouldShowSkeleton { diff --git a/Projects/Presentation/MainTab/Project.swift b/Projects/Presentation/MainTab/Project.swift index 4010a39..c3127fb 100644 --- a/Projects/Presentation/MainTab/Project.swift +++ b/Projects/Presentation/MainTab/Project.swift @@ -1,8 +1,8 @@ +import DependencyPackagePlugin +import DependencyPlugin import Foundation import ProjectDescription -import DependencyPlugin import ProjectTemplatePlugin -import DependencyPackagePlugin let project = Project.makeAppModule( name: "MainTab", @@ -16,7 +16,8 @@ let project = Project.makeAppModule( .Shared(implements: .DesignSystem), .Presentation(implements: .Home), .Presentation(implements: .Hifi), - .Presentation(implements: .Battle) + .Presentation(implements: .Battle), + .Presentation(implements: .Profile), ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift index a1f326e..5e59675 100644 --- a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift +++ b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift @@ -14,6 +14,7 @@ import Battle import DesignSystem import Hifi import Home +import Profile /// 픽케 메인 탭 코디네이터. /// 모든 탭은 우선 HomeCoordinator 로 채워두고, 각 기능 (Explore / QuickBattle / MyPage) @@ -56,7 +57,7 @@ public struct MainTabCoordinator { public var homeState: HomeCoordinator.State public var exploreState: HifiCoordinator.State public var quickBattleState: BattleCoordinator.State - public var myPageState: HomeCoordinator.State + public var myPageState: ProfileCoordinator.State public init(selectedTab: Int = Tab.home.rawValue) { self.selectedTab = selectedTab @@ -74,7 +75,13 @@ public struct MainTabCoordinator { case home(HomeCoordinator.Action) case explore(HifiCoordinator.Action) case quickBattle(BattleCoordinator.Action) - case myPage(HomeCoordinator.Action) + case myPage(ProfileCoordinator.Action) + case delegate(DelegateAction) + } + + public enum DelegateAction: Equatable { + /// 로그아웃/탈퇴 완료 → 루트(App)에서 로그인 화면으로 전환. + case sessionEnded } public var body: some ReducerOf { @@ -88,7 +95,7 @@ public struct MainTabCoordinator { BattleCoordinator() } Scope(state: \.myPageState, action: \.myPage) { - HomeCoordinator() + ProfileCoordinator() } Reduce { state, action in @@ -114,9 +121,38 @@ public struct MainTabCoordinator { state.selectedTab = state.previousTab return .none + // 마이 상단 백탭 → 직전 탭으로 복귀 + case .myPage(.router(.routeAction(_, action: .profile(.delegate(.backToHome))))): + state.selectedTab = state.previousTab + return .none + + // 설정 → 로그아웃/탈퇴 완료 → App 으로 세션 종료 전파 + case .myPage(.router(.routeAction(_, action: .settings(.delegate(.sessionEnded))))): + return .send(.delegate(.sessionEnded)) + + // 홈 "더보기" → 탐색 탭으로 이동 + case .home(.router(.routeAction(_, action: .home(.delegate(.moveToExplore))))): + if state.selectedTab != Tab.explore.rawValue { state.previousTab = state.selectedTab } + state.selectedTab = Tab.explore.rawValue + return .none + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + case .home, .explore, .quickBattle, .myPage: return .none } } } + + /// delegate 는 부모(App)가 관찰 — MainTab 은 발행만 하고 여기서는 가로채지 않는다. + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .sessionEnded: + return .none + } + } } diff --git a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift index f6f8b0a..dd1c8ab 100644 --- a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift +++ b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift @@ -12,6 +12,7 @@ import Battle import DesignSystem import Hifi import Home +import Profile import TCAFlow import ComposableArchitecture @@ -129,7 +130,7 @@ extension MainTabView { ) case .myPage: - HomeCoordinatorView( + ProfileCoordinatorView( store: store.scope(state: \.myPageState, action: \.myPage) ) diff --git a/Projects/Presentation/Notification/Project.swift b/Projects/Presentation/Notification/Project.swift new file mode 100644 index 0000000..6bf31e4 --- /dev/null +++ b/Projects/Presentation/Notification/Project.swift @@ -0,0 +1,19 @@ +import DependencyPackagePlugin +import DependencyPlugin +import Foundation +import ProjectDescription +import ProjectTemplatePlugin + +let project = Project.makeAppModule( + name: "Notification", + bundleId: .appBundleID(name: ".Notification"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .Domain(implements: .UseCase), + .Shared(implements: .Shared), + .SPM.composableArchitecture, + .SPM.tcaFlow, + ], + sources: ["Sources/**"] +) diff --git a/Projects/Presentation/Notification/Sources/Base.swift b/Projects/Presentation/Notification/Sources/Base.swift new file mode 100644 index 0000000..e2ae350 --- /dev/null +++ b/Projects/Presentation/Notification/Sources/Base.swift @@ -0,0 +1,21 @@ +// +// Base.swift +// DDDAttendance. +// +// Created by Roy on 2026-06-10 +// Copyright © 2026 DDD , Ltd., All rights reserved. +// + +import SwiftUI + +struct BaseView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundColor(.accentColor) + Text("Hello, world!") + } + .padding() + } +} diff --git a/Projects/Presentation/Notification/Sources/Coordinator/Reducer/NotificationCoordinator.swift b/Projects/Presentation/Notification/Sources/Coordinator/Reducer/NotificationCoordinator.swift new file mode 100644 index 0000000..4f2e0ba --- /dev/null +++ b/Projects/Presentation/Notification/Sources/Coordinator/Reducer/NotificationCoordinator.swift @@ -0,0 +1,119 @@ +// +// NotificationCoordinator.swift +// Notification +// +// 알림 모듈 진입점. 루트는 알림받기 목록(NotificationFeature). +// 상세 화면 연결 시 NotificationScreen 에 case 추가. +// + +import Foundation + +import ComposableArchitecture +import TCAFlow + +@FlowCoordinator(screen: "NotificationScreen", navigation: true) +public struct NotificationCoordinator { + public init() {} + + @ObservableState + public struct State: Equatable { + public var routes: [Route] + + public init() { + routes = [.root(.notification(.init()), embedInNavigationView: true)] + } + } + + @CasePathable + public enum Action { + case router(IndexedRouterActionOf) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case navigation(NavigationAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case backAction + case backToRootAction + } + + public enum AsyncAction: Equatable {} + public enum InnerAction: Equatable {} + public enum NavigationAction: Equatable {} + + public enum DelegateAction: Equatable { + /// 알림 모듈 전체를 빠져나가 부모 스택으로 복귀. + case dismiss + } + + func handleRoute( + state: inout State, + action: Action + ) -> Effect { + switch action { + case let .router(routeAction): + routerAction(state: &state, action: routeAction) + case let .view(viewAction): + handleViewAction(state: &state, action: viewAction) + case .async, .inner, .navigation: + .none + case let .delegate(delegateAction): + handleDelegateAction(state: &state, action: delegateAction) + } + } +} + +extension NotificationCoordinator { + private func routerAction( + state _: inout State, + action: IndexedRouterActionOf + ) -> Effect { + switch action { + // 목록(루트) 백탭 → 알림 모듈 종료(부모로 복귀). + case .routeAction(_, action: .notification(.delegate(.dismiss))): + return .send(.delegate(.dismiss)) + + default: + return .none + } + } + + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backAction: + state.routes.goBack() + return .none + case .backToRootAction: + state.routes.goBackToRoot() + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + } + } +} + +// swiftformat:disable extensionAccessControl +extension NotificationCoordinator { + @Reducer + public enum NotificationScreen { + case notification(NotificationFeature) + } +} + +// swiftformat:enable extensionAccessControl + +extension NotificationCoordinator.NotificationScreen.State: Equatable {} diff --git a/Projects/Presentation/Notification/Sources/Coordinator/View/NotificationCoordinatorView.swift b/Projects/Presentation/Notification/Sources/Coordinator/View/NotificationCoordinatorView.swift new file mode 100644 index 0000000..08104f0 --- /dev/null +++ b/Projects/Presentation/Notification/Sources/Coordinator/View/NotificationCoordinatorView.swift @@ -0,0 +1,28 @@ +// +// NotificationCoordinatorView.swift +// Notification +// + +import Foundation + +import SwiftUI + +import ComposableArchitecture +import TCAFlow + +public struct NotificationCoordinatorView: View { + @Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + TCAFlowRouter(store.scope(state: \.routes, action: \.router)) { screen in + switch screen.case { + case let .notification(notificationStore): + NotificationView(store: notificationStore) + } + } + } +} diff --git a/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift b/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift new file mode 100644 index 0000000..cb8370c --- /dev/null +++ b/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift @@ -0,0 +1,226 @@ +// +// NotificationFeature.swift +// Notification +// +// 알림받기 — picke.pen `알림받기`. +// 카테고리 탭(전체/콘텐츠/공지사항/이벤트), GET /api/v1/notifications (page 페이지네이션), +// 탭 시 읽음 처리 / 모두 읽음. +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro +import UseCase + +@Reducer +public struct NotificationFeature { + public init() {} + + static let pageSize = 20 + + @ObservableState + public struct State: Equatable { + public var selectedTab: NotificationCategory = .all + public var isLoading: Bool = false + public var isLoadingMore: Bool = false + public var items: [NotificationItem] = [] + public var page: Int = 0 + public var hasNext: Bool = false + + /// 홈/프로필 종 아이콘 빨간점 — 미읽음 알림 존재 여부 (전역 공유). + @Shared(.inMemory("HasUnreadNotification")) public var hasUnreadNotification: Bool = false + + public var hasUnread: Bool { + items.contains { !$0.isRead } + } + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backTapped + case tabSelected(NotificationCategory) + case reachedBottom + case readAllTapped + case notificationTapped(NotificationItem) + } + + public enum AsyncAction: Equatable { + case fetch(reset: Bool) + case markRead(notificationId: Int) + case markAll + } + + public enum InnerAction: Equatable { + case notificationsResponse(Result, reset: Bool) + } + + public enum DelegateAction: Equatable { + case dismiss + } + + nonisolated enum CancelID: Hashable { + case fetch + } + + @Dependency(\.notificationUseCase) private var notificationUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension NotificationFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard state.items.isEmpty else { return .none } + return .send(.async(.fetch(reset: true))) + + case .backTapped: + return .send(.delegate(.dismiss)) + + case let .tabSelected(tab): + guard tab != state.selectedTab else { return .none } + state.selectedTab = tab + state.items = [] + state.page = 0 + state.hasNext = false + return .send(.async(.fetch(reset: true))) + + case .reachedBottom: + guard state.hasNext, !state.isLoadingMore, !state.isLoading else { return .none } + return .send(.async(.fetch(reset: false))) + + case .readAllTapped: + guard state.hasUnread else { return .none } + state.items = state.items.map { $0.markedAsRead() } + // 모두 읽음 → 홈/프로필 빨간점 제거. + state.$hasUnreadNotification.withLock { $0 = false } + return .send(.async(.markAll)) + + case let .notificationTapped(item): + // 상세 화면 연결 전: 탭 시 읽음 처리만. + guard !item.isRead else { return .none } + if let index = state.items.firstIndex(where: { $0.id == item.id }) { + state.items[index] = state.items[index].markedAsRead() + } + updateUnreadBadge(state: &state) + return .send(.async(.markRead(notificationId: item.notificationId))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case let .fetch(reset): + if reset { + state.isLoading = true + } else { + state.isLoadingMore = true + } + let page = reset ? 0 : state.page + let category = state.selectedTab + return .run { [useCase = notificationUseCase] send in + let result = await Result { + try await useCase.fetchNotifications( + category: category, + page: page, + size: Self.pageSize + ) + } + .mapError(NotificationError.from) + return await send(.inner(.notificationsResponse(result, reset: reset))) + } + .cancellable(id: CancelID.fetch, cancelInFlight: true) + + case let .markRead(notificationId): + return .run { [useCase = notificationUseCase] _ in + try? await useCase.markAsRead(notificationId: notificationId) + } + + case .markAll: + return .run { [useCase = notificationUseCase] _ in + try? await useCase.markAllAsRead() + } + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .notificationsResponse(result, reset): + state.isLoading = false + state.isLoadingMore = false + switch result { + case let .success(pageData): + if reset { + state.items = pageData.items + } else { + state.items.append(contentsOf: pageData.items) + } + state.hasNext = pageData.hasNext + if pageData.hasNext { state.page += 1 } + updateUnreadBadge(state: &state) + case let .failure(error): + Log.error("[NotificationFeature] fetchNotifications failed: \(error.localizedDescription)") + } + return .none + } + } + + /// 전체 탭 기준 미읽음 존재 여부를 전역 빨간점 플래그에 반영. + /// (카테고리 필터 탭에서는 부분 정보이므로 갱신하지 않는다.) + private func updateUnreadBadge(state: inout State) { + guard state.selectedTab == .all else { return } + let hasUnread = state.hasUnread + state.$hasUnreadNotification.withLock { $0 = hasUnread } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + } + } +} diff --git a/Projects/Presentation/Notification/Sources/Main/View/Components/NotificationSkeletonView.swift b/Projects/Presentation/Notification/Sources/Main/View/Components/NotificationSkeletonView.swift new file mode 100644 index 0000000..e8846da --- /dev/null +++ b/Projects/Presentation/Notification/Sources/Main/View/Components/NotificationSkeletonView.swift @@ -0,0 +1,56 @@ +// +// NotificationSkeletonView.swift +// Notification +// +// 알림받기 로딩 스켈레톤 — 공통 SkeletonBlock(light). +// + +import SwiftUI + +import DesignSystem + +struct NotificationSkeletonView: View { + var body: some View { + ScrollView { + VStack(spacing: 8) { + ForEach(0 ..< 7, id: \.self) { _ in + HStack(alignment: .center, spacing: 16) { + SkeletonBlock(cornerRadius: 12, tone: .light) + .frame(width: 24, height: 24) + + VStack(alignment: .leading, spacing: 6) { + HStack { + block(width: 120, height: 12) + Spacer(minLength: 0) + block(width: 40, height: 12) + } + block(width: nil, maxWidth: true, height: 16) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + } + .padding(.top, 8) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + } + + @ViewBuilder + private func block( + width: CGFloat?, + maxWidth: Bool = false, + height: CGFloat + ) -> some View { + SkeletonBlock(cornerRadius: 4, tone: .light) + .frame(width: width) + .frame(maxWidth: maxWidth ? .infinity : nil) + .frame(height: height) + } +} diff --git a/Projects/Presentation/Notification/Sources/Main/View/NotificationView.swift b/Projects/Presentation/Notification/Sources/Main/View/NotificationView.swift new file mode 100644 index 0000000..2c6389e --- /dev/null +++ b/Projects/Presentation/Notification/Sources/Main/View/NotificationView.swift @@ -0,0 +1,192 @@ +// +// NotificationView.swift +// Notification +// +// 알림받기 UI — picke.pen `알림받기`. +// App Bar(뒤로/알림/모두 읽음) + 카테고리 탭 + 알림 카드 리스트 + 무한 스크롤. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity +import Utill + +@ViewAction(for: NotificationFeature.self) +public struct NotificationView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar( + onBack: { send(.backTapped) }, + centerTitle: "알림" + ) { + Button { send(.readAllTapped) } label: { + Text("모두 읽음") + .pretendardFont(family: .Regular, size: 14) + .foregroundStyle(.gray300) + } + .buttonStyle(.plain) + } + .foregroundStyle(.gray500) + + tabBar() + + Group { + if store.isLoading { + NotificationSkeletonView() + } else { + content() + } + } + .simultaneousGesture(tabSwipeGesture()) + } + .background(Color.bgDefault.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + } + + /// 좌우 드래그 → 인접 탭 전환. + func tabSwipeGesture() -> some Gesture { + DragGesture(minimumDistance: 20) + .onEnded { value in + let horizontal = value.translation.width + let vertical = value.translation.height + guard abs(horizontal) > abs(vertical), abs(horizontal) > 50 else { return } + let tabs = NotificationCategory.allCases + guard let index = tabs.firstIndex(of: store.selectedTab) else { return } + if horizontal < 0, index < tabs.count - 1 { + send(.tabSelected(tabs[index + 1])) + } else if horizontal > 0, index > 0 { + send(.tabSelected(tabs[index - 1])) + } + } + } +} + +private extension NotificationView { + // MARK: 카테고리 탭 (전체/콘텐츠/공지사항/이벤트) + + @ViewBuilder + func tabBar() -> some View { + HStack(spacing: 8) { + ForEach(NotificationCategory.allCases) { tab in + tabPill(tab) + } + Spacer(minLength: 0) + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + + @ViewBuilder + func tabPill(_ tab: NotificationCategory) -> some View { + let isSelected = store.selectedTab == tab + Button { + send(.tabSelected(tab)) + } label: { + Text(tab.title) + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(isSelected ? .beige50 : .primary500) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background(isSelected ? Color.primary500 : Color.primary50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(isSelected ? Color.clear : Color.primary500, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + // MARK: 리스트 + + @ViewBuilder + func content() -> some View { + if store.items.isEmpty { + PickeEmptyStateView(message: "받은 알림이 없어요") + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(store.items) { item in + notificationRow(item) + .onAppear { + if item.id == store.items.last?.id { + send(.reachedBottom) + } + } + } + + if store.isLoadingMore { + ProgressView().padding(.vertical, 8) + } + } + .padding(.top, 8) + .padding(.bottom, 32) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + } + } + + @ViewBuilder + func notificationRow(_ item: NotificationItem) -> some View { + Button { + send(.notificationTapped(item)) + } label: { + HStack(alignment: .center, spacing: 16) { + Image(systemName: item.iconSystemName) + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(.neutral900) + .frame(width: 24, height: 24) + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text(item.title) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + .lineLimit(1) + + Spacer(minLength: 0) + + Text(item.createdAt.relativeKoreanString) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + .fixedSize() + } + + Text(item.body) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.gray500) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + .overlay(alignment: .topTrailing) { + if !item.isRead { + RoundedRectangle(cornerRadius: 3) + .fill(.primary500) + .frame(width: 4, height: 4) + .padding(.top, 8) + .padding(.trailing, 8) + } + } + } + .buttonStyle(.plain) + } +} diff --git a/Projects/Presentation/Notification/Tests/Sources/NotificationTests.swift b/Projects/Presentation/Notification/Tests/Sources/NotificationTests.swift new file mode 100644 index 0000000..7d4136b --- /dev/null +++ b/Projects/Presentation/Notification/Tests/Sources/NotificationTests.swift @@ -0,0 +1,24 @@ +// +// NotificationTests.swift +// Presentation.NotificationTests +// +// Created by Roy on 2026-06-10. +// + +@testable import Notification +import Testing + +struct NotificationTests { + @Test + func notificationExample() { + // This is an example of a test case. + #expect(true) + } + + @Test + func notificationLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } +} diff --git a/Projects/Presentation/Profile/Project.swift b/Projects/Presentation/Profile/Project.swift new file mode 100644 index 0000000..eac5717 --- /dev/null +++ b/Projects/Presentation/Profile/Project.swift @@ -0,0 +1,21 @@ +import DependencyPackagePlugin +import DependencyPlugin +import Foundation +import ProjectDescription +import ProjectTemplatePlugin + +let project = Project.makeAppModule( + name: "Profile", + bundleId: .appBundleID(name: ".Profile"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .Domain(implements: .UseCase), + .Shared(implements: .Shared), + .SPM.composableArchitecture, + .SPM.tcaFlow, + .Presentation(implements: .Web), + .Presentation(implements: .Notification), + ], + sources: ["Sources/**"] +) diff --git a/Projects/Presentation/Profile/Sources/BattleProposal/Reducer/BattleProposalFeature.swift b/Projects/Presentation/Profile/Sources/BattleProposal/Reducer/BattleProposalFeature.swift new file mode 100644 index 0000000..68c7c3a --- /dev/null +++ b/Projects/Presentation/Profile/Sources/BattleProposal/Reducer/BattleProposalFeature.swift @@ -0,0 +1,217 @@ +// +// BattleProposalFeature.swift +// Profile +// +// 배틀 주제 제안(배틀 만들기) — picke.pen `배틀 주제 제안`. +// 카테고리/주제/양측입장/부가설명 입력 → POST /api/v1/battles/proposals. +// + +import Foundation + +import ComposableArchitecture +import DesignSystem +import Entity +import LogMacro +import UseCase + +@Reducer +public struct BattleProposalFeature { + public init() {} + + static let descriptionLimit = 200 + + @ObservableState + public struct State: Equatable { + public var selectedCategory: BattleProposalCategory = .philosophy + public var topic: String = "" + public var positionA: String = "" + public var positionB: String = "" + public var description: String = "" + public var isSubmitting: Bool = false + @Presents public var customAlert: CustomAlertState? + + public init() {} + + /// 필수 항목(주제/양측 입장) 충족 시 제안 가능. + public var isSubmitEnabled: Bool { + !topic.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !positionA.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !positionB.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !isSubmitting + } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case scope(ScopeAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) + } + + @CasePathable + public enum View { + case backTapped + case categorySelected(BattleProposalCategory) + case submitTapped + } + + public enum AsyncAction: Equatable { + case submit + } + + public enum InnerAction: Equatable { + case submitResponse(Result) + } + + public enum DelegateAction: Equatable { + case dismiss + /// 제안 성공 → 화면 닫기. + case proposed(BattleProposal) + } + + nonisolated enum CancelID: Hashable { + case submit + } + + @Dependency(\.battleUseCase) private var battleUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + // 부가 설명 200자 제한. + if state.description.count > Self.descriptionLimit { + state.description = String(state.description.prefix(Self.descriptionLimit)) + } + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .scope(scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } + } +} + +extension BattleProposalFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backTapped: + return .send(.delegate(.dismiss)) + + case let .categorySelected(category): + state.selectedCategory = category + return .none + + case .submitTapped: + guard state.isSubmitEnabled else { return .none } + return .send(.async(.submit)) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .submit: + state.isSubmitting = true + let draft = BattleProposalDraft( + category: state.selectedCategory.title, + topic: state.topic, + positionA: state.positionA, + positionB: state.positionB, + description: state.description + ) + return .run { [useCase = battleUseCase] send in + let result = await Result { + try await useCase.proposeBattle(draft) + } + .mapError(BattleError.from) + return await send(.inner(.submitResponse(result))) + } + .cancellable(id: CancelID.submit, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .submitResponse(result): + state.isSubmitting = false + switch result { + case .success: + // 제안 성공 → 완료 팝업 노출 (확인 시 화면 닫음). + state.customAlert = .alert( + title: "제안이 완료되었어요", + message: "검토 후 배틀로 등록되면 알려드릴게요", + confirmTitle: "확인", + cancelTitle: "" + ) + return .none + case let .failure(error): + Log.error("[BattleProposalFeature] proposeBattle failed: \(error.localizedDescription)") + return .none + } + } + } + + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case let .customAlert(alertAction): + switch alertAction { + case .presented(.confirmTapped): + state.customAlert = nil + return .send(.delegate(.dismiss)) + case .presented(.cancelTapped): + state.customAlert = nil + return .none + case .dismiss: + state.customAlert = nil + return .none + } + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + case .proposed: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/BattleProposal/View/BattleProposalView.swift b/Projects/Presentation/Profile/Sources/BattleProposal/View/BattleProposalView.swift new file mode 100644 index 0000000..7139d9a --- /dev/null +++ b/Projects/Presentation/Profile/Sources/BattleProposal/View/BattleProposalView.swift @@ -0,0 +1,223 @@ +// +// BattleProposalView.swift +// Profile +// +// 배틀 주제 제안(배틀 만들기) UI — picke.pen `배틀 주제 제안`. +// 카테고리 칩 + 주제 + 양측 입장(A/B) + 부가 설명 + 제안하기(-30P). +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +@ViewAction(for: BattleProposalFeature.self) +public struct BattleProposalView: View { + @Bindable public var store: StoreOf + @FocusState private var isInputFocused: Bool + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "배틀 만들기") + .foregroundStyle(.gray500) + + ScrollView { + VStack(spacing: 16) { + categoryField() + topicField() + stanceField() + descriptionField() + submitButton() + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + .background( + Color.beige200 + .onTapGesture { + isInputFocused = false + } + ) + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + } +} + +private extension BattleProposalView { + // MARK: 공통 라벨 + + @ViewBuilder + func fieldLabel(_ text: String) -> some View { + Text(text) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray400) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: 카테고리 + + @ViewBuilder + func categoryField() -> some View { + VStack(alignment: .leading, spacing: 4) { + fieldLabel("카테고리 *") + + HStack(spacing: 0) { + ForEach(Array(BattleProposalCategory.allCases.enumerated()), id: \.element.id) { index, category in + let isSelected = store.selectedCategory == category + Button { + send(.categorySelected(category)) + } label: { + Text(category.title) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(isSelected ? .beige50 : .gray300) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(isSelected ? Color.primary500 : Color.beige50) + .overlay(alignment: .trailing) { + if !isSelected, index < BattleProposalCategory.allCases.count - 1 { + Rectangle().fill(.beige600).frame(width: 1) + } + } + } + .buttonStyle(.plain) + } + } + .clipShape(RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + } + + // MARK: 주제 + + @ViewBuilder + func topicField() -> some View { + VStack(alignment: .leading, spacing: 4) { + fieldLabel("주제 *") + inputField(text: $store.topic, placeholder: "논쟁이 될만한 주제를 한 줄로 써주세요") + } + } + + // MARK: 양측 입장 + + @ViewBuilder + func stanceField() -> some View { + VStack(alignment: .leading, spacing: 4) { + fieldLabel("양측 입장 *") + + HStack(spacing: 8) { + Text("A") + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.primary500) + .frame(width: 20) + inputField(text: $store.positionA, placeholder: "첫 번째 입장을 입력하세요") + } + + HStack(spacing: 8) { + Text("B") + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.neutral900) + .frame(width: 20) + inputField(text: $store.positionB, placeholder: "두 번째 입장을 입력하세요") + } + } + } + + // MARK: 입력 필드 (한 줄) + + @ViewBuilder + func inputField(text: Binding, placeholder: String) -> some View { + ZStack(alignment: .leading) { + if text.wrappedValue.isEmpty { + Text(placeholder) + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(.gray300) + } + TextField("", text: text) + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(.gray800) + .focused($isInputFocused) + } + .padding(.leading, 8) + .frame(height: 44) + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + + // MARK: 부가 설명 + + @ViewBuilder + func descriptionField() -> some View { + VStack(alignment: .leading, spacing: 4) { + fieldLabel("부가 설명 (선택)") + + VStack(alignment: .trailing, spacing: 4) { + ZStack(alignment: .topLeading) { + if store.description.isEmpty { + Text("이 주제를 제안하는 이유나 배경을 자유롭게 써주세요") + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.gray300) + .padding(.top, 8) + .padding(.leading, 4) + } + TextEditor(text: $store.description) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.gray800) + .scrollContentBackground(.hidden) + .frame(height: 60) + .focused($isInputFocused) + } + + Text("\(store.description.count)/200") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.gray400) + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + } + + // MARK: 제안하기 + + @ViewBuilder + func submitButton() -> some View { + Button { + send(.submitTapped) + } label: { + Text("제안하기 (-30P)") + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.beige50) + .frame(maxWidth: .infinity) + .padding(.vertical, 17) + .background( + store.isSubmitEnabled ? Color.primary500 : Color.primary200, + in: RoundedRectangle(cornerRadius: 2) + ) + } + .buttonStyle(.plain) + .disabled(!store.isSubmitEnabled) + .padding(.top, 8) + } +} diff --git a/Projects/Presentation/Profile/Sources/BattleRecord/Reducer/BattleRecordFeature.swift b/Projects/Presentation/Profile/Sources/BattleRecord/Reducer/BattleRecordFeature.swift new file mode 100644 index 0000000..1f57405 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/BattleRecord/Reducer/BattleRecordFeature.swift @@ -0,0 +1,172 @@ +// +// BattleRecordFeature.swift +// Profile +// +// 내 배틀 기록 — picke.pen `내 배틀기록`. +// GET /api/v1/me/battle-records (offset 기반 페이지네이션). +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro +import UseCase + +@Reducer +public struct BattleRecordFeature { + public init() {} + + static let pageSize = 20 + + @ObservableState + public struct State: Equatable { + public var isLoading: Bool = false + public var isLoadingMore: Bool = false + public var items: [BattleRecord] = [] + public var nextOffset: Int = 0 + public var hasNext: Bool = false + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backTapped + case reachedBottom + case recordTapped(BattleRecord) + } + + public enum AsyncAction: Equatable { + case fetch(reset: Bool) + } + + public enum InnerAction: Equatable { + case recordsResponse(Result, reset: Bool) + } + + public enum DelegateAction: Equatable { + case dismiss + /// 기록 탭 → 배틀 진입. + case openRecord(battleId: String) + } + + nonisolated enum CancelID: Hashable { + case fetch + } + + @Dependency(\.profileUseCase) private var profileUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension BattleRecordFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard state.items.isEmpty else { return .none } + return .send(.async(.fetch(reset: true))) + + case .backTapped: + return .send(.delegate(.dismiss)) + + case .reachedBottom: + guard state.hasNext, !state.isLoadingMore, !state.isLoading else { return .none } + return .send(.async(.fetch(reset: false))) + + case let .recordTapped(record): + return .send(.delegate(.openRecord(battleId: record.battleId))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case let .fetch(reset): + if reset { + state.isLoading = true + } else { + state.isLoadingMore = true + } + let offset = reset ? 0 : state.nextOffset + return .run { [useCase = profileUseCase] send in + let result = await Result { + try await useCase.fetchBattleRecords(offset: offset, size: Self.pageSize, voteSide: nil) + } + .mapError(ProfileError.from) + return await send(.inner(.recordsResponse(result, reset: reset))) + } + .cancellable(id: CancelID.fetch, cancelInFlight: reset) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .recordsResponse(result, reset): + state.isLoading = false + state.isLoadingMore = false + switch result { + case let .success(page): + if reset { + state.items = page.items + } else { + state.items.append(contentsOf: page.items) + } + state.nextOffset = page.nextOffset + state.hasNext = page.hasNext + case let .failure(error): + Log.error("[BattleRecordFeature] fetchBattleRecords failed: \(error.localizedDescription)") + } + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + case .openRecord: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordView.swift b/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordView.swift new file mode 100644 index 0000000..a1c1a53 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/BattleRecord/View/BattleRecordView.swift @@ -0,0 +1,121 @@ +// +// BattleRecordView.swift +// Profile +// +// 내 배틀 기록 UI — picke.pen `내 배틀기록`. +// App Bar + 기록 카드(뱃지/제목/요약/날짜) 리스트 + 무한 스크롤. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity +import Utill + +@ViewAction(for: BattleRecordFeature.self) +public struct BattleRecordView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "내 배틀 기록") + .foregroundStyle(.gray500) + + if store.isLoading { + BattleRecordSkeletonView() + } else { + content() + } + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + } +} + +private extension BattleRecordView { + @ViewBuilder + func content() -> some View { + if store.items.isEmpty { + emptyState() + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(store.items) { record in + recordCard(record) + .onAppear { + if record.id == store.items.last?.id { + send(.reachedBottom) + } + } + } + + if store.isLoadingMore { + ProgressView() + .padding(.vertical, 8) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + } + } + + @ViewBuilder + func recordCard(_ record: BattleRecord) -> some View { + Button { + send(.recordTapped(record)) + } label: { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + if !record.categoryTag.isEmpty { + Text(record.categoryTag) + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.primary500) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + } + + Text(record.title) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.gray500) + .lineLimit(1) + } + + Text(record.summary) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.gray400) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + + Text(record.createdAt.yearMonthDayDot) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.beige600, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + @ViewBuilder + func emptyState() -> some View { + PickeEmptyStateView(message: "아직 배틀 기록이 없어요") + } +} diff --git a/Projects/Presentation/Profile/Sources/BattleRecord/View/Components/BattleRecordSkeletonView.swift b/Projects/Presentation/Profile/Sources/BattleRecord/View/Components/BattleRecordSkeletonView.swift new file mode 100644 index 0000000..9e34177 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/BattleRecord/View/Components/BattleRecordSkeletonView.swift @@ -0,0 +1,51 @@ +// +// BattleRecordSkeletonView.swift +// Profile +// +// 내 배틀 기록 로딩 스켈레톤 — 공통 SkeletonBlock(light) 사용. +// + +import SwiftUI + +import DesignSystem + +struct BattleRecordSkeletonView: View { + var body: some View { + ScrollView { + VStack(spacing: 12) { + ForEach(0 ..< 6, id: \.self) { _ in + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + block(width: 40, height: 18) + block(width: 150, height: 16) + } + block(width: nil, maxWidth: true, height: 32) + block(width: 72, height: 12) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.beige600, lineWidth: 1) + ) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + } + + @ViewBuilder + private func block( + width: CGFloat?, + maxWidth: Bool = false, + height: CGFloat + ) -> some View { + SkeletonBlock(cornerRadius: 4, tone: .light) + .frame(width: width) + .frame(maxWidth: maxWidth ? .infinity : nil) + .frame(height: height) + } +} diff --git a/Projects/Presentation/Profile/Sources/ContentActivity/Reducer/ContentActivityFeature.swift b/Projects/Presentation/Profile/Sources/ContentActivity/Reducer/ContentActivityFeature.swift new file mode 100644 index 0000000..0c19b09 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/ContentActivity/Reducer/ContentActivityFeature.swift @@ -0,0 +1,179 @@ +// +// ContentActivityFeature.swift +// Profile +// +// 내 콘텐츠 활동 — picke.pen `내 콘텐츠활동_댓글/좋아요`. +// 내 댓글 / 좋아요 탭, GET /api/v1/me/content-activities (offset 페이지네이션). +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro +import UseCase + +@Reducer +public struct ContentActivityFeature { + public init() {} + + static let pageSize = 20 + + @ObservableState + public struct State: Equatable { + public var selectedTab: ContentActivityType = .comment + public var isLoading: Bool = false + public var isLoadingMore: Bool = false + public var items: [ContentActivity] = [] + public var nextOffset: Int = 0 + public var hasNext: Bool = false + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backTapped + case tabSelected(ContentActivityType) + case reachedBottom + } + + public enum AsyncAction: Equatable { + case fetch(reset: Bool) + } + + public enum InnerAction: Equatable { + case activitiesResponse(Result, reset: Bool) + } + + public enum DelegateAction: Equatable { + case dismiss + } + + nonisolated enum CancelID: Hashable { + case fetch + } + + @Dependency(\.profileUseCase) private var profileUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension ContentActivityFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard state.items.isEmpty else { return .none } + return .send(.async(.fetch(reset: true))) + + case .backTapped: + return .send(.delegate(.dismiss)) + + case let .tabSelected(tab): + guard tab != state.selectedTab else { return .none } + state.selectedTab = tab + state.items = [] + state.nextOffset = 0 + state.hasNext = false + return .send(.async(.fetch(reset: true))) + + case .reachedBottom: + guard state.hasNext, !state.isLoadingMore, !state.isLoading else { return .none } + return .send(.async(.fetch(reset: false))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case let .fetch(reset): + if reset { + state.isLoading = true + } else { + state.isLoadingMore = true + } + let offset = reset ? 0 : state.nextOffset + let activityType = state.selectedTab + return .run { [useCase = profileUseCase] send in + let result = await Result { + try await useCase.fetchContentActivities( + offset: offset, + size: Self.pageSize, + activityType: activityType + ) + } + .mapError(ProfileError.from) + return await send(.inner(.activitiesResponse(result, reset: reset))) + } + .cancellable(id: CancelID.fetch, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .activitiesResponse(result, reset): + state.isLoading = false + state.isLoadingMore = false + switch result { + case let .success(page): + if reset { + state.items = page.items + } else { + state.items.append(contentsOf: page.items) + } + state.nextOffset = page.nextOffset + state.hasNext = page.hasNext + case let .failure(error): + Log.error("[ContentActivityFeature] fetchContentActivities failed: \(error.localizedDescription)") + } + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/ContentActivity/View/Components/ContentActivitySkeletonView.swift b/Projects/Presentation/Profile/Sources/ContentActivity/View/Components/ContentActivitySkeletonView.swift new file mode 100644 index 0000000..66edef3 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/ContentActivity/View/Components/ContentActivitySkeletonView.swift @@ -0,0 +1,59 @@ +// +// ContentActivitySkeletonView.swift +// Profile +// +// 내 콘텐츠 활동 로딩 스켈레톤 — 공통 SkeletonBlock(light). +// + +import SwiftUI + +import DesignSystem + +struct ContentActivitySkeletonView: View { + var body: some View { + ScrollView { + VStack(spacing: 12) { + ForEach(0 ..< 5, id: \.self) { _ in + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + SkeletonBlock(cornerRadius: 18, tone: .light) + .frame(width: 36, height: 36) + VStack(alignment: .leading, spacing: 4) { + block(width: 120, height: 14) + block(width: 50, height: 10) + } + Spacer(minLength: 0) + } + block(width: nil, maxWidth: true, height: 36) + HStack { + Spacer() + block(width: 40, height: 12) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.beige600, lineWidth: 1) + ) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + } + + @ViewBuilder + private func block( + width: CGFloat?, + maxWidth: Bool = false, + height: CGFloat + ) -> some View { + SkeletonBlock(cornerRadius: 4, tone: .light) + .frame(width: width) + .frame(maxWidth: maxWidth ? .infinity : nil) + .frame(height: height) + } +} diff --git a/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift b/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift new file mode 100644 index 0000000..e3f28d3 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/ContentActivity/View/ContentActivityView.swift @@ -0,0 +1,216 @@ +// +// ContentActivityView.swift +// Profile +// +// 내 콘텐츠 활동 UI — picke.pen `내 콘텐츠활동_댓글/좋아요`. +// App Bar + 탭바(내 댓글/좋아요) + 카드 리스트 + 무한 스크롤. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity +import Kingfisher +import Utill + +@ViewAction(for: ContentActivityFeature.self) +public struct ContentActivityView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "내 콘텐츠 활동") + .foregroundStyle(.gray500) + + tabBar() + + Group { + if store.isLoading { + ContentActivitySkeletonView() + } else { + content() + } + } + // 좌우 스와이프로 탭 전환 (CommentView 와 동일한 제스처 UX) + .simultaneousGesture(tabSwipeGesture()) + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + } + + /// 좌우 드래그 → 인접 탭 전환. + func tabSwipeGesture() -> some Gesture { + DragGesture(minimumDistance: 20) + .onEnded { value in + let horizontal = value.translation.width + let vertical = value.translation.height + guard abs(horizontal) > abs(vertical), abs(horizontal) > 50 else { return } + let tabs = ContentActivityType.allCases + guard let index = tabs.firstIndex(of: store.selectedTab) else { return } + if horizontal < 0, index < tabs.count - 1 { + send(.tabSelected(tabs[index + 1])) + } else if horizontal > 0, index > 0 { + send(.tabSelected(tabs[index - 1])) + } + } + } +} + +private extension ContentActivityView { + // MARK: 탭바 (내 댓글 / 좋아요) + + @ViewBuilder + func tabBar() -> some View { + HStack(spacing: 0) { + ForEach(ContentActivityType.allCases) { tab in + tabButton(tab) + } + } + .overlay(alignment: .bottom) { + Rectangle().fill(.neutral200).frame(height: 1) + } + } + + @ViewBuilder + func tabButton(_ tab: ContentActivityType) -> some View { + let isSelected = store.selectedTab == tab + Button { + send(.tabSelected(tab)) + } label: { + Text(tab.title) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(isSelected ? .primary500 : .gray300) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .contentShape(Rectangle()) + .overlay(alignment: .bottom) { + Rectangle() + .fill(isSelected ? Color.primary500 : Color.clear) + .frame(height: 3) + } + } + .buttonStyle(.plain) + } + + // MARK: 리스트 + + @ViewBuilder + func content() -> some View { + if store.items.isEmpty { + PickeEmptyStateView(message: emptyMessage) + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(store.items) { item in + activityCard(item) + .onAppear { + if item.id == store.items.last?.id { + send(.reachedBottom) + } + } + } + + if store.isLoadingMore { + ProgressView().padding(.vertical, 8) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + } + } + + var emptyMessage: String { + switch store.selectedTab { + case .comment: "작성한 댓글이 없어요" + case .like: "좋아요한 댓글이 없어요" + } + } + + @ViewBuilder + func activityCard(_ item: ContentActivity) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + avatar(item.author) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(item.author.nickname) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.gray500) + .lineLimit(1) + + if !item.stanceText.isEmpty { + Text(item.stanceText) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.primary500) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + } + } + + Text(item.createdAt.relativeKoreanString) + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.gray300) + } + + Spacer(minLength: 0) + } + + Text(item.content) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.gray400) + .lineLimit(4) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 4) { + Spacer(minLength: 0) + Image(systemName: "heart") + .font(.system(size: 13)) + .foregroundStyle(.gray300) + Text("\(item.likeCount)") + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.beige600, lineWidth: 1) + ) + } + + @ViewBuilder + func avatar(_ author: ContentActivityAuthor) -> some View { + // 디자인(Z5YAW): 항상 beige600 원 배경 위에 캐릭터/기본 아이콘. + ZStack { + Circle().fill(.beige600) + + if !author.characterImageURL.isEmpty, let url = URL(string: author.characterImageURL) { + KFImage(url) + .resizable() + .scaledToFit() + .padding(3) + } else { + Image(systemName: "cat.fill") + .font(.system(size: 16)) + .foregroundStyle(.gray300) + } + } + .frame(width: 36, height: 36) + .clipShape(Circle()) + } +} diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift new file mode 100644 index 0000000..a777b26 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift @@ -0,0 +1,205 @@ +// +// ProfileCoordinator.swift +// Profile +// +// 마이 탭 코디네이터. 루트는 ProfileFeature, 편집/세부 화면은 추후 push. +// + +import Foundation + +import ComposableArchitecture +import Entity +import Notification +import TCAFlow +import Web + +@FlowCoordinator(screen: "ProfileScreen", navigation: true) +public struct ProfileCoordinator { + public init() {} + + @ObservableState + public struct State: Equatable { + public var routes: [Route] + + public init() { + routes = [.root(.profile(.init()), embedInNavigationView: true)] + } + } + + @CasePathable + public enum Action { + case router(IndexedRouterActionOf) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case navigation(NavigationAction) + } + + @CasePathable + public enum View { + case backAction + case backToRootAction + } + + public enum AsyncAction: Equatable {} + public enum InnerAction: Equatable {} + public enum NavigationAction: Equatable {} + + func handleRoute( + state: inout State, + action: Action + ) -> Effect { + switch action { + case let .router(routeAction): + routerAction(state: &state, action: routeAction) + case let .view(viewAction): + handleViewAction(state: &state, action: viewAction) + case .async, .inner, .navigation: + .none + } + } +} + +extension ProfileCoordinator { + private func routerAction( + state: inout State, + action: IndexedRouterActionOf + ) -> Effect { + switch action { + // 프로필 편집 / 포인트 충전 / 철학자 / 메뉴 — 세부 화면 추가 시 분기 확장. + case .routeAction(_, action: .profile(.delegate(.editProfile))): + return .none + + // 포인트(크레딧) 충전 영역 탭 → 포인트 내역 화면 진입. + case .routeAction(_, action: .profile(.delegate(.chargePoint))): + state.routes.push(.pointHistory(.init())) + return .none + + // 포인트 내역에서 주제 제안 → 배틀 만들기 화면 진입. + case .routeAction(_, action: .pointHistory(.delegate(.suggestTopic))): + state.routes.push(.battleProposal(.init())) + return .none + + // 배틀 만들기 닫기(취소/제안 완료) → 뒤로. + case .routeAction(_, action: .battleProposal(.delegate(.dismiss))): + return .send(.view(.backAction)) + + // 포인트 내역 상단 백탭 → 뒤로. + case .routeAction(_, action: .pointHistory(.delegate(.dismiss))): + return .send(.view(.backAction)) + + // 나의 철학자 유형 카드 탭 → 리캡 화면 진입. + case .routeAction(_, action: .profile(.delegate(.openPhilosopher))): + state.routes.push(.recap(.init())) + return .none + + // 리캡 백탭 → 뒤로. + case .routeAction(_, action: .recap(.delegate(.dismiss))): + return .send(.view(.backAction)) + + // 설정 아이콘 → 설정 화면 진입.리고 + case .routeAction(_, action: .profile(.delegate(.openSettings))): + state.routes.push(.settings(.init())) + return .none + + // 알림(종) 아이콘 → 알림받기 화면 진입. + case .routeAction(_, action: .profile(.delegate(.openNotification))): + state.routes.push(.notification(.init())) + return .none + + // 알림받기 백탭 → 뒤로. + case .routeAction(_, action: .notification(.delegate(.dismiss))): + return .send(.view(.backAction)) + + // 설정 상단 백탭 → 뒤로. + case .routeAction(_, action: .settings(.delegate(.dismiss))): + return .send(.view(.backAction)) + + // 알림 설정 → 알림 설정 화면. + case .routeAction(_, action: .settings(.delegate(.openNotificationSettings))): + state.routes.push(.notificationSetting(.init())) + return .none + + // 알림 설정 백탭 → 뒤로. + case .routeAction(_, action: .notificationSetting(.delegate(.dismiss))): + return .send(.view(.backAction)) + + // 개인정보 처리방침 → 웹뷰. + case .routeAction(_, action: .settings(.delegate(.openPrivacy))): + state.routes.push(.web(.init(url: TermsDocument.privacy.urlString))) + return .none + + // 서비스 약관 → 웹뷰. + case .routeAction(_, action: .settings(.delegate(.openTerms))): + state.routes.push(.web(.init(url: TermsDocument.service.urlString))) + return .none + + // 웹뷰 뒤로. + case .routeAction(_, action: .web(.backToRoot)): + return .send(.view(.backAction)) + + // 메뉴 선택 — 화면 진입 분기. + case let .routeAction(_, action: .profile(.delegate(.menuSelected(item)))): + switch item { + case .battleHistory: + state.routes.push(.battleRecord(.init())) + case .contentActivity: + state.routes.push(.contentActivity(.init())) + case .noticeEvent: + state.routes.push(.notice(.init())) + } + return .none + + // 내 배틀 기록 백탭 → 뒤로. + case .routeAction(_, action: .battleRecord(.delegate(.dismiss))): + return .send(.view(.backAction)) + + // 내 콘텐츠 활동 백탭 → 뒤로. + case .routeAction(_, action: .contentActivity(.delegate(.dismiss))): + return .send(.view(.backAction)) + + // 공지사항·이벤트 백탭 → 뒤로. + case .routeAction(_, action: .notice(.delegate(.dismiss))): + return .send(.view(.backAction)) + + default: + return .none + } + } + + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backAction: + state.routes.goBack() + return .none + case .backToRootAction: + state.routes.goBackToRoot() + return .none + } + } +} + +// swiftformat:disable extensionAccessControl +extension ProfileCoordinator { + @Reducer + public enum ProfileScreen { + case profile(ProfileFeature) + case pointHistory(PointHistoryFeature) + case settings(SettingsFeature) + case battleRecord(BattleRecordFeature) + case contentActivity(ContentActivityFeature) + case notice(NoticeFeature) + case notificationSetting(NotificationSettingFeature) + case battleProposal(BattleProposalFeature) + case recap(RecapFeature) + case notification(NotificationCoordinator) + case web(WebReducer) + } +} + +// swiftformat:enable extensionAccessControl + +extension ProfileCoordinator.ProfileScreen.State: Equatable {} diff --git a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift new file mode 100644 index 0000000..48a0ccc --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift @@ -0,0 +1,51 @@ +// +// ProfileCoordinatorView.swift +// Profile +// + +import Foundation + +import SwiftUI + +import ComposableArchitecture +import Notification +import TCAFlow +import Web + +public struct ProfileCoordinatorView: View { + @Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + TCAFlowRouter(store.scope(state: \.routes, action: \.router)) { screen in + switch screen.case { + case let .profile(profileStore): + ProfileView(store: profileStore) + case let .pointHistory(pointHistoryStore): + PointHistoryView(store: pointHistoryStore) + case let .settings(settingsStore): + SettingsView(store: settingsStore) + case let .battleRecord(battleRecordStore): + BattleRecordView(store: battleRecordStore) + case let .contentActivity(contentActivityStore): + ContentActivityView(store: contentActivityStore) + case let .notice(noticeStore): + NoticeView(store: noticeStore) + case let .notificationSetting(notificationStore): + NotificationSettingView(store: notificationStore) + case let .battleProposal(battleProposalStore): + BattleProposalView(store: battleProposalStore) + case let .recap(recapStore): + RecapView(store: recapStore) + case let .notification(notificationStore): + NotificationCoordinatorView(store: notificationStore) + .toolbar(.hidden, for: .tabBar) + case let .web(webStore): + WebView(store: webStore) + } + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift new file mode 100644 index 0000000..1e7c31b --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift @@ -0,0 +1,255 @@ +// +// ProfileFeature.swift +// Profile +// +// 마이페이지 루트 기능 — picke.pen `마이페이지_잠금`. +// 프로필 카드(닉네임/잠금) + 포인트 충전 + 나의 철학자 유형 + 메뉴 리스트. +// 프로필/포인트 조회 API 연동 전까지는 기본(목) 값 노출. +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro +import UseCase + +@Reducer +public struct ProfileFeature { + public init() {} + + /// 마이페이지 메뉴 항목 — picke.pen 기준. + public enum MenuItem: String, CaseIterable, Equatable, Identifiable { + case battleHistory = "내 배틀 기록" + case contentActivity = "내 콘텐츠 활동" + case noticeEvent = "공지방 · 이벤트" + + public var id: String { rawValue } + } + + @ObservableState + public struct State: Equatable { + public var isLoading: Bool = false + /// 닉네임 — /me/mypage 응답에서 주입. + public var nickname: String = "" + /// 사용자 코드 (앞에 `@` 표기) — /me/mypage 응답에서 주입. + public var userCode: String = "" + /// 보유 포인트 — /me/mypage 응답에서 주입. + public var point: Int = 0 + /// 나의 철학자 유형명(예: `칸트형`) — 미확정 시 nil → `??형`. + public var philosopherType: String? + /// 철학자 라벨(예: `원칙주의자`). + public var philosopherLabel: String = "" + /// 철학자 이미지 URL. + public var philosopherImageURL: String? + /// 프로필 이미지 URL (없으면 기본 아바타). + public var profileImageURL: String? + /// 메뉴 목록. + public var menuItems: [MenuItem] = MenuItem.allCases + + /// 종 아이콘 빨간점 — 미읽음 알림 존재 여부 (알림 화면과 전역 공유). + @Shared(.inMemory("HasUnreadNotification")) public var hasUnreadNotification: Bool = false + + public init() {} + + /// 철학자 유형 미확정(잠금) 여부. + public var isPhilosopherLocked: Bool { + philosopherType?.isEmpty ?? true + } + + /// 표시용 철학자 유형 — 잠금 시 `??형`, 아니면 `유형명 · 라벨`. + public var philosopherDisplay: String { + guard let type = philosopherType, !type.isEmpty else { return "??형" } + return philosopherLabel.isEmpty ? type : "\(type) · \(philosopherLabel)" + } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backTapped + case notificationTapped + case settingsTapped + case profileTapped + case chargePointTapped + case freeChargeTapped + case philosopherTapped + case menuTapped(MenuItem) + } + + public enum AsyncAction: Equatable { + case fetchProfile + } + + public enum InnerAction: Equatable { + case myPageResponse(Result) + } + + public enum DelegateAction: Equatable { + /// 상단 백탭 → 직전 탭으로 복귀. + case backToHome + /// 알림함 이동. + case openNotification + /// 설정 화면 이동. + case openSettings + /// 프로필 카드 탭 → 편집. + case editProfile + /// 포인트 충전. + case chargePoint + /// 나의 철학자 유형 상세. + case openPhilosopher + /// 메뉴 항목 선택. + case menuSelected(MenuItem) + } + + nonisolated enum CancelID: Hashable { + case fetchProfile + } + + @Dependency(\.profileUseCase) private var profileUseCase + @Dependency(\.rewardedAdClient) private var rewardedAdClient + @Dependency(\.analyticsUseCase) private var analyticsUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension ProfileFeature { + private func handleViewAction( + state _: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + return .send(.async(.fetchProfile)) + + case .backTapped: + return .send(.delegate(.backToHome)) + + case .notificationTapped: + return .send(.delegate(.openNotification)) + + case .settingsTapped: + return .send(.delegate(.openSettings)) + + case .profileTapped: + return .send(.delegate(.editProfile)) + + case .chargePointTapped: + return .send(.delegate(.chargePoint)) + + case .freeChargeTapped: + // 무료 충전 → 리워드 광고 표시, 보상 획득 시 ad_revenue 트래킹 + 포인트 갱신. + return .run { [rewardedAdClient, analyticsUseCase] send in + let earned = await rewardedAdClient.showRewardedAd() + if earned { + analyticsUseCase.track(.adRevenue(placement: "충전소")) + await send(.async(.fetchProfile)) + } + } + + case .philosopherTapped: + return .send(.delegate(.openPhilosopher)) + + case let .menuTapped(item): + return .send(.delegate(.menuSelected(item))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetchProfile: + state.isLoading = true + return .run { [useCase = profileUseCase] send in + let result = await Result { + try await useCase.fetchMyPage() + } + .mapError(ProfileError.from) + return await send(.inner(.myPageResponse(result))) + } + .cancellable(id: CancelID.fetchProfile, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .myPageResponse(result): + state.isLoading = false + switch result { + case let .success(myPage): + state.nickname = myPage.profile.nickname + state.userCode = myPage.profile.userTag + state.point = myPage.tier.currentPoint + state.philosopherType = myPage.philosopher.typeName.isEmpty ? nil : myPage.philosopher.typeName + state.philosopherLabel = myPage.philosopher.philosopherLabel + state.philosopherImageURL = myPage.philosopher.imageURL.isEmpty ? nil : myPage.philosopher.imageURL + state.profileImageURL = myPage.profile.characterImageURL.isEmpty ? nil : myPage.profile.characterImageURL + case let .failure(error): + Log.error("[ProfileFeature] fetchMyPage failed: \(error.localizedDescription)") + } + return .none + } + } + + /// delegate 는 부모(ProfileCoordinator / MainTab)가 처리 — Feature 는 발행만 한다. + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .backToHome: + return .none + + case .openNotification: + return .none + + case .openSettings: + return .none + + case .editProfile: + return .none + + case .chargePoint: + return .none + + case .openPhilosopher: + return .none + + case .menuSelected: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift b/Projects/Presentation/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift new file mode 100644 index 0000000..0e47584 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Main/View/Components/ProfileSkeletonView.swift @@ -0,0 +1,63 @@ +// +// ProfileSkeletonView.swift +// Profile +// +// 마이페이지 로딩 스켈레톤 — 라이트(beige) 배경에 맞춘 옅은 shimmer. +// + +import SwiftUI + +import DesignSystem + +struct ProfileSkeletonView: View { + var body: some View { + VStack(spacing: 20) { + // 프로필 카드 자리 + HStack(spacing: 12) { + block(width: 52, height: 52, radius: 26) + VStack(alignment: .leading, spacing: 8) { + block(width: 140, height: 16) + block(width: 80, height: 13) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + + VStack(spacing: 16) { + block(maxWidth: true, height: 64, radius: 8) // 포인트 충전 버튼 + block(maxWidth: true, height: 72, radius: 8) // 나의 철학자 유형 + } + .padding(.horizontal, 16) + + // 메뉴 리스트 자리 + VStack(spacing: 0) { + ForEach(0 ..< 3, id: \.self) { _ in + HStack { + block(width: 100, height: 16) + Spacer() + } + .padding(.vertical, 20) + .overlay(alignment: .bottom) { + Rectangle().fill(.beige600).frame(height: 1) + } + } + } + .padding(.horizontal, 16) + + Spacer(minLength: 0) + } + } + + @ViewBuilder + private func block( + width: CGFloat? = nil, + maxWidth: Bool = false, + height: CGFloat, + radius: CGFloat = 4 + ) -> some View { + SkeletonBlock(cornerRadius: radius, tone: .light) + .frame(width: width) + .frame(maxWidth: maxWidth ? .infinity : nil) + .frame(height: height) + } +} diff --git a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift new file mode 100644 index 0000000..e97c8e0 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift @@ -0,0 +1,277 @@ +// +// ProfileView.swift +// Profile +// +// 마이페이지 루트 UI — picke.pen `마이페이지_잠금`. +// 프로필 카드 + 포인트 충전 버튼 + 나의 철학자 유형 + 메뉴 리스트. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Kingfisher + +@ViewAction(for: ProfileFeature.self) +public struct ProfileView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + topBar() + + if store.isLoading { + ProfileSkeletonView() + } else { + // xr63n: 카드 그룹 ↔ 메뉴 그룹 gap 20 + VStack(spacing: 20) { + // T3oil: 카드 3개 gap 16, 좌우 16 + VStack(spacing: 16) { + profileCard() + chargeButton() + philosopherCard() + } + .padding(.horizontal, 16) + + // HFFUM: 메뉴 리스트 좌우 16 + menuList() + .padding(.horizontal, 16) + + Spacer(minLength: 0) + } + } + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .onAppear { send(.onAppear) } + } +} + +private extension ProfileView { + // MARK: 상단 바 (알림 / 설정) + + @ViewBuilder + func topBar() -> some View { + HStack(spacing: 6) { + Spacer() + + Button { send(.notificationTapped) } label: { + Image(systemName: "bell") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(.neutral900) + .frame(width: 24, height: 24) + .overlay(alignment: .topTrailing) { + if store.hasUnreadNotification { + Circle() + .fill(.errorDefault) + .frame(width: 6, height: 6) + .offset(x: 1, y: -1) + } + } + } + + Button { send(.settingsTapped) } label: { + Image(systemName: "gearshape") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(.neutral900) + .frame(width: 24, height: 24) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + + // MARK: 프로필 카드 + + @ViewBuilder + func profileCard() -> some View { + Button { + send(.profileTapped) + } label: { + HStack(spacing: 12) { + avatar() + + VStack(alignment: .leading, spacing: 2) { + Text(store.nickname) + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.gray800) + + Text("@\(store.userCode)") + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.gray300) + } + + Spacer(minLength: 0) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + func avatar() -> some View { + // 디자인(oFtBQ): 항상 beige600 원 배경 위에 캐릭터 이미지/기본 아이콘을 올린다. + ZStack { + Circle().fill(.beige600) + + if let urlString = store.profileImageURL, let url = URL(string: urlString) { + KFImage(url) + .resizable() + .scaledToFit() + .padding(4) + } else { + Image(systemName: "cat.fill") + .font(.system(size: 24)) + .foregroundStyle(.gray300) + } + } + .frame(width: 52, height: 52) + .clipShape(Circle()) + } + + // MARK: 포인트 충전 버튼 + + @ViewBuilder + func chargeButton() -> some View { + HStack(spacing: 6) { + // 좌측(포인트 뱃지 + 보유 포인트) → 포인트 내역 상세 + Button { + send(.chargePointTapped) + } label: { + HStack(spacing: 6) { + ZStack { + Circle().fill(.secondary300) + Text("P") + .pretendardFont(family: .Bold, size: 11) + .foregroundStyle(.gray800) + } + .frame(width: 24, height: 24) + + Text("내 포인트 \(store.point)") + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.beige50) + + Spacer(minLength: 8) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + // 무료 충전 → 리워드 광고 + Button { + send(.freeChargeTapped) + } label: { + Text("무료 충전") + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.gray800) + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(.secondary300, in: RoundedRectangle(cornerRadius: 2)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity) + .background(.primary800, in: RoundedRectangle(cornerRadius: 8)) + } + + // MARK: 나의 철학자 유형 + + @ViewBuilder + func philosopherCard() -> some View { + Button { + send(.philosopherTapped) + } label: { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(.beige200) + if store.isPhilosopherLocked { + // 배틀 5개 미만(미확정) → 잠금 이미지 + Image(asset: .lock) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } else if let urlString = store.philosopherImageURL, let url = URL(string: urlString) { + KFImage(url) + .resizable() + .scaledToFit() + .padding(4) + } else { + Image(systemName: "brain.head.profile") + .font(.system(size: 20)) + .foregroundStyle(.gray300) + } + } + .frame(width: 40, height: 40) + + VStack(alignment: .leading, spacing: 4) { + Text("나의 철학자 유형") + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.gray300) + + Text(store.philosopherDisplay) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.gray700) + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.neutral900) + } + .padding(16) + .frame(maxWidth: .infinity) + .background(.beige400, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.beige600, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + // MARK: 메뉴 리스트 + + @ViewBuilder + func menuList() -> some View { + VStack(spacing: 0) { + ForEach(store.menuItems) { item in + menuRow(item) + } + } + } + + @ViewBuilder + func menuRow(_ item: ProfileFeature.MenuItem) -> some View { + Button { + send(.menuTapped(item)) + } label: { + HStack { + Text(item.rawValue) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.gray800) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.neutral900) + } + .padding(.vertical, 20) + .contentShape(Rectangle()) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } + .buttonStyle(.plain) + } +} diff --git a/Projects/Presentation/Profile/Sources/Notice/Reducer/NoticeFeature.swift b/Projects/Presentation/Profile/Sources/Notice/Reducer/NoticeFeature.swift new file mode 100644 index 0000000..9f48dc4 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Notice/Reducer/NoticeFeature.swift @@ -0,0 +1,81 @@ +// +// NoticeFeature.swift +// Profile +// +// 공지사항 · 이벤트 — 탭 전환(공지사항/이벤트). API 미구현으로 빈 콘텐츠. +// + +import Foundation + +import ComposableArchitecture +import Entity + +@Reducer +public struct NoticeFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var selectedTab: NoticeTab = .notice + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case backTapped + case tabSelected(NoticeTab) + } + + public enum DelegateAction: Equatable { + case dismiss + } + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension NoticeFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backTapped: + return .send(.delegate(.dismiss)) + + case let .tabSelected(tab): + state.selectedTab = tab + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift b/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift new file mode 100644 index 0000000..38a3e73 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Notice/View/NoticeView.swift @@ -0,0 +1,98 @@ +// +// NoticeView.swift +// Profile +// +// 공지사항 · 이벤트 UI — App Bar + 탭바(공지사항/이벤트) + 빈 콘텐츠. +// API 미구현 — 현재는 항상 빈 상태. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +@ViewAction(for: NoticeFeature.self) +public struct NoticeView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "공지사항 · 이벤트") + .foregroundStyle(.gray500) + + tabBar() + + PickeEmptyStateView(message: emptyMessage) + // 좌우 스와이프로 탭 전환 + .contentShape(Rectangle()) + .simultaneousGesture(tabSwipeGesture()) + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + } + + /// 좌우 드래그 → 인접 탭 전환. + func tabSwipeGesture() -> some Gesture { + DragGesture(minimumDistance: 20) + .onEnded { value in + let horizontal = value.translation.width + let vertical = value.translation.height + guard abs(horizontal) > abs(vertical), abs(horizontal) > 50 else { return } + let tabs = NoticeTab.allCases + guard let index = tabs.firstIndex(of: store.selectedTab) else { return } + if horizontal < 0, index < tabs.count - 1 { + send(.tabSelected(tabs[index + 1])) + } else if horizontal > 0, index > 0 { + send(.tabSelected(tabs[index - 1])) + } + } + } +} + +private extension NoticeView { + @ViewBuilder + func tabBar() -> some View { + HStack(spacing: 0) { + ForEach(NoticeTab.allCases) { tab in + tabButton(tab) + } + } + .overlay(alignment: .bottom) { + Rectangle().fill(.neutral200).frame(height: 1) + } + } + + @ViewBuilder + func tabButton(_ tab: NoticeTab) -> some View { + let isSelected = store.selectedTab == tab + Button { + send(.tabSelected(tab)) + } label: { + Text(tab.title) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(isSelected ? .primary500 : .gray300) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .contentShape(Rectangle()) + .overlay(alignment: .bottom) { + Rectangle() + .fill(isSelected ? Color.primary500 : Color.clear) + .frame(height: 3) + } + } + .buttonStyle(.plain) + } + + var emptyMessage: String { + switch store.selectedTab { + case .notice: "새로운 공지사항이 없습니다" + case .event: "새로운 이벤트가 없습니다" + } + } +} diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift b/Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift new file mode 100644 index 0000000..1bbe6f4 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/NotificationSetting/Reducer/NotificationSettingFeature.swift @@ -0,0 +1,160 @@ +// +// NotificationSettingFeature.swift +// Profile +// +// 알림 설정 — picke.pen `알림 설정`. +// GET /me/notification-settings 로드 + 토글 변경 시 PATCH (낙관적 갱신). +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro +import UseCase + +@Reducer +public struct NotificationSettingFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var isLoading: Bool = false + public var settings: NotificationSettings = .init() + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backTapped + case toggle(NotificationSettingKey) + } + + public enum AsyncAction: Equatable { + case fetch + case update(NotificationSettings) + } + + public enum InnerAction: Equatable { + case settingsResponse(Result) + } + + public enum DelegateAction: Equatable { + case dismiss + } + + nonisolated enum CancelID: Hashable { + case fetch + case update + } + + @Dependency(\.profileUseCase) private var profileUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension NotificationSettingFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + return .send(.async(.fetch)) + + case .backTapped: + return .send(.delegate(.dismiss)) + + case let .toggle(key): + // 낙관적 갱신 후 PATCH. + let updated = state.settings.setting(key, to: !state.settings.isOn(key)) + state.settings = updated + return .send(.async(.update(updated))) + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetch: + state.isLoading = true + return .run { [useCase = profileUseCase] send in + let result = await Result { + try await useCase.fetchNotificationSettings() + } + .mapError(ProfileError.from) + return await send(.inner(.settingsResponse(result))) + } + .cancellable(id: CancelID.fetch, cancelInFlight: true) + + case let .update(settings): + return .run { [useCase = profileUseCase] send in + let result = await Result { + try await useCase.updateNotificationSettings(settings) + } + .mapError(ProfileError.from) + return await send(.inner(.settingsResponse(result))) + } + .cancellable(id: CancelID.update, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .settingsResponse(result): + state.isLoading = false + switch result { + case let .success(settings): + state.settings = settings + case let .failure(error): + Log.error("[NotificationSettingFeature] settings request failed: \(error.localizedDescription)") + } + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift b/Projects/Presentation/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift new file mode 100644 index 0000000..c54ee74 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/NotificationSetting/View/Components/NotificationSettingSkeletonView.swift @@ -0,0 +1,52 @@ +// +// NotificationSettingSkeletonView.swift +// Profile +// +// 알림 설정 로딩 스켈레톤 — 공통 SkeletonBlock(light). +// + +import SwiftUI + +import DesignSystem + +struct NotificationSettingSkeletonView: View { + /// 섹션별 행 개수 (기능별 2 / 소셜 3 / 마케팅 1). + private let sectionRowCounts = [2, 3, 1] + + var body: some View { + VStack(spacing: 20) { + ForEach(Array(sectionRowCounts.enumerated()), id: \.offset) { _, rowCount in + VStack(alignment: .leading, spacing: 4) { + block(width: 90, height: 12) + + VStack(spacing: 0) { + ForEach(0 ..< rowCount, id: \.self) { _ in + HStack(spacing: 4) { + VStack(alignment: .leading, spacing: 4) { + block(width: 120, height: 13) + block(width: 180, height: 11) + } + Spacer(minLength: 8) + SkeletonBlock(cornerRadius: 9, tone: .light) + .frame(width: 32, height: 18) + } + .padding(.vertical, 16) + .overlay(alignment: .bottom) { + Rectangle().fill(.beige600).frame(height: 1) + } + } + } + } + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + .frame(maxHeight: .infinity, alignment: .top) + } + + @ViewBuilder + private func block(width: CGFloat, height: CGFloat) -> some View { + SkeletonBlock(cornerRadius: 4, tone: .light) + .frame(width: width, height: height) + } +} diff --git a/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift b/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift new file mode 100644 index 0000000..958ec2d --- /dev/null +++ b/Projects/Presentation/Profile/Sources/NotificationSetting/View/NotificationSettingView.swift @@ -0,0 +1,113 @@ +// +// NotificationSettingView.swift +// Profile +// +// 알림 설정 UI — picke.pen `알림 설정`. +// App Bar + 섹션(기능별/소셜/마케팅) + 토글 행. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +@ViewAction(for: NotificationSettingFeature.self) +public struct NotificationSettingView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "알림 설정") + .foregroundStyle(.gray500) + + if store.isLoading { + NotificationSettingSkeletonView() + } else { + ScrollView { + VStack(spacing: 20) { + ForEach(NotificationSettingSection.allCases) { section in + sectionView(section) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + } + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .onAppear { send(.onAppear) } + } +} + +private extension NotificationSettingView { + @ViewBuilder + func sectionView(_ section: NotificationSettingSection) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(section.title) + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.gray300) + + VStack(spacing: 0) { + ForEach(section.keys) { key in + settingRow(key) + } + } + } + } + + @ViewBuilder + func settingRow(_ key: NotificationSettingKey) -> some View { + HStack(spacing: 4) { + VStack(alignment: .leading, spacing: 4) { + Text(key.title) + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(.gray800) + + Text(key.subtitle) + .pretendardFont(family: .Regular, size: 11) + .foregroundStyle(.gray300) + } + + Spacer(minLength: 8) + + Button { + send(.toggle(key)) + } label: { + NotificationToggle(isOn: store.settings.isOn(key)) + } + .buttonStyle(.plain) + } + .padding(.vertical, 16) + .overlay(alignment: .bottom) { + Rectangle().fill(.beige600).frame(height: 1) + } + } +} + +/// picke.pen 토글 (32×18, ON=primary500 / OFF=neutral200, knob 14). +private struct NotificationToggle: View { + let isOn: Bool + + var body: some View { + ZStack(alignment: isOn ? .trailing : .leading) { + Capsule() + .fill(isOn ? Color.primary500 : Color.neutral200) + + Circle() + .fill(.beige50) + .frame(width: 14, height: 14) + .padding(2) + } + .frame(width: 32, height: 18) + .animation(.easeInOut(duration: 0.15), value: isOn) + } +} diff --git a/Projects/Presentation/Profile/Sources/PointHistory/Reducer/PointHistoryFeature.swift b/Projects/Presentation/Profile/Sources/PointHistory/Reducer/PointHistoryFeature.swift new file mode 100644 index 0000000..938ea6c --- /dev/null +++ b/Projects/Presentation/Profile/Sources/PointHistory/Reducer/PointHistoryFeature.swift @@ -0,0 +1,211 @@ +// +// PointHistoryFeature.swift +// Profile +// +// 포인트(크레딧) 내역 — picke.pen `포인트 내역`. +// GET /api/v1/me/credits/history (offset 기반 페이지네이션). +// + +import Foundation + +import ComposableArchitecture +import DesignSystem +import Entity +import LogMacro +import UseCase + +@Reducer +public struct PointHistoryFeature { + public init() {} + + static let pageSize = 20 + + @ObservableState + public struct State: Equatable { + public var isLoading: Bool = false + public var isLoadingMore: Bool = false + public var items: [CreditHistoryItem] = [] + public var nextOffset: Int = 0 + public var hasNext: Bool = false + @Presents public var customAlert: CustomAlertState? + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case scope(ScopeAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backTapped + case reachedBottom + case suggestTopicTapped + } + + public enum AsyncAction: Equatable { + case fetch(reset: Bool) + } + + public enum InnerAction: Equatable { + case historyResponse(Result, reset: Bool) + } + + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) + } + + public enum DelegateAction: Equatable { + case dismiss + /// 주제(배틀) 제안 화면 진입. + case suggestTopic + } + + nonisolated enum CancelID: Hashable { + case fetch + } + + @Dependency(\.profileUseCase) private var profileUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .scope(scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } + } +} + +extension PointHistoryFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard state.items.isEmpty else { return .none } + return .send(.async(.fetch(reset: true))) + + case .backTapped: + return .send(.delegate(.dismiss)) + + case .reachedBottom: + guard state.hasNext, !state.isLoadingMore, !state.isLoading else { return .none } + return .send(.async(.fetch(reset: false))) + + case .suggestTopicTapped: + state.customAlert = .suggestTopic() + return .none + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case let .fetch(reset): + if reset { + state.isLoading = true + } else { + state.isLoadingMore = true + } + let offset = reset ? 0 : state.nextOffset + return .run { [useCase = profileUseCase] send in + let result = await Result { + try await useCase.fetchCreditHistory(offset: offset, size: Self.pageSize) + } + .mapError(ProfileError.from) + return await send(.inner(.historyResponse(result, reset: reset))) + } + .cancellable(id: CancelID.fetch, cancelInFlight: reset) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .historyResponse(result, reset): + state.isLoading = false + state.isLoadingMore = false + switch result { + case let .success(page): + if reset { + state.items = page.items + } else { + state.items.append(contentsOf: page.items) + } + state.nextOffset = page.nextOffset + state.hasNext = page.hasNext + case let .failure(error): + Log.error("[PointHistoryFeature] fetchCreditHistory failed: \(error.localizedDescription)") + } + return .none + } + } + + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case let .customAlert(alertAction): + switch alertAction { + case let .presented(customAlertAction): + switch customAlertAction { + case .confirmTapped: + // 제안하기 → 주제 제안 화면 진입 (추후 연동). + state.customAlert = nil + return .send(.delegate(.suggestTopic)) + case .cancelTapped: + state.customAlert = nil + return .none + } + case .dismiss: + state.customAlert = nil + return .none + } + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + case .suggestTopic: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift b/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift new file mode 100644 index 0000000..f9ffe4d --- /dev/null +++ b/Projects/Presentation/Profile/Sources/PointHistory/View/Components/PointHistorySkeletonView.swift @@ -0,0 +1,48 @@ +// +// PointHistorySkeletonView.swift +// Profile +// +// 포인트 내역 로딩 스켈레톤 — 라이트(beige) 배경 shimmer. +// + +import SwiftUI + +import DesignSystem + +struct PointHistorySkeletonView: View { + var body: some View { + ScrollView { + VStack(spacing: 16) { + ForEach(0 ..< 8, id: \.self) { _ in + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + block(width: 80, height: 14) + block(width: 64, height: 12) + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 8) { + block(width: 48, height: 14) + block(width: 28, height: 12) + } + } + .padding(16) + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.beige600, lineWidth: 1) + ) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + } + + @ViewBuilder + private func block(width: CGFloat, height: CGFloat) -> some View { + SkeletonBlock(cornerRadius: 4, tone: .light) + .frame(width: width, height: height) + } +} diff --git a/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift b/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift new file mode 100644 index 0000000..72beec4 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/PointHistory/View/PointHistoryView.swift @@ -0,0 +1,128 @@ +// +// PointHistoryView.swift +// Profile +// +// 포인트(크레딧) 내역 UI — picke.pen `포인트 내역`. +// App Bar(백/타이틀) + 내역 리스트(좌: 유형·날짜 / 우: 금액·적립·사용). +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity +import Utill + +@ViewAction(for: PointHistoryFeature.self) +public struct PointHistoryView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + appBar() + + if store.isLoading { + PointHistorySkeletonView() + } else { + content() + } + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + .onAppear { send(.onAppear) } + } +} + +private extension PointHistoryView { + // MARK: App Bar + + @ViewBuilder + func appBar() -> some View { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "포인트 내역") { + // 배틀(주제) 제안 팝업 열기 + Button { send(.suggestTopicTapped) } label: { + Image(asset: .history) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + } + .foregroundStyle(.gray500) + } + + // MARK: 내역 리스트 + + @ViewBuilder + func content() -> some View { + if store.items.isEmpty { + emptyState() + } else { + ScrollView { + LazyVStack(spacing: 16) { + ForEach(store.items) { item in + historyRow(item) + .onAppear { + if item.id == store.items.last?.id { + send(.reachedBottom) + } + } + } + + if store.isLoadingMore { + ProgressView() + .padding(.vertical, 8) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + } + } + + @ViewBuilder + func historyRow(_ item: CreditHistoryItem) -> some View { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text(item.title) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.neutral900) + + Text(item.createdAt.yearMonthDayDot) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + } + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 6) { + Text(item.amountText) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(item.isEarned ? .primary500 : .gray500) + + Text(item.statusText) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray300) + } + } + .padding(16) + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.beige600, lineWidth: 1) + ) + } + + @ViewBuilder + func emptyState() -> some View { + PickeEmptyStateView(message: "포인트 내역이 없어요") + } +} diff --git a/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift b/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift new file mode 100644 index 0000000..a979dd5 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/Reducer/RecapFeature.swift @@ -0,0 +1,180 @@ +// +// RecapFeature.swift +// Profile +// +// 나의 철학자 유형(리캡) — picke.pen `나의 철학자 유형`. +// GET /api/v1/me/recap 로드 + 공유. +// + +import Foundation + +import ComposableArchitecture +import Entity +import LogMacro +import UseCase + +@Reducer +public struct RecapFeature { + public init() {} + + /// 소비한 배틀이 이 수 미만이면 잠금. (서버 미제공 — 클라이언트 상수) + static let unlockThreshold = 5 + + @ObservableState + public struct State: Equatable { + public var isLoading: Bool = false + public var recap: PhilosopherRecap? + /// 애플 시스템 공유 시트 트리거. + public var shareItem: ShareItem? + + public init() {} + + /// 소비한 배틀(총 참여) < 5 → 잠금. + public var isLocked: Bool { + guard let recap else { return false } + return recap.preferenceReport.totalParticipation < RecapFeature.unlockThreshold + } + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + case backTapped + case shareTapped + } + + public enum AsyncAction: Equatable { + case fetch + } + + public enum InnerAction: Equatable { + case recapResponse(Result) + } + + public enum DelegateAction: Equatable { + case dismiss + case share(PhilosopherRecap) + } + + nonisolated enum CancelID: Hashable { + case fetch + } + + @Dependency(\.profileUseCase) private var profileUseCase + @Dependency(\.analyticsUseCase) private var analyticsUseCase + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension RecapFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard state.recap == nil else { return .none } + return .send(.async(.fetch)) + + case .backTapped: + return .send(.delegate(.dismiss)) + + case .shareTapped: + guard let recap = state.recap else { return .none } + let text = [ + "나의 철학자 유형: \(recap.myCard.typeName)", + recap.myCard.description, + recap.myCard.keywordTags.map { "#\($0)" }.joined(separator: " "), + ] + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + var items: [Any] = [text] + if !recap.myCard.imageURL.isEmpty, let url = URL(string: recap.myCard.imageURL) { + items.append(url) + } + state.shareItem = ShareItem(items: items) + analyticsUseCase.track( + .reportAction(ReportActionData(actionType: .share, topIndicator: recap.myCard.typeName)) + ) + return .none + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetch: + state.isLoading = true + return .run { [useCase = profileUseCase] send in + let result = await Result { + try await useCase.fetchRecap() + } + .mapError(ProfileError.from) + return await send(.inner(.recapResponse(result))) + } + .cancellable(id: CancelID.fetch, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .recapResponse(result): + state.isLoading = false + switch result { + case let .success(recap): + state.recap = recap + analyticsUseCase.track( + .reportAction(ReportActionData(actionType: .view, topIndicator: recap.myCard.typeName)) + ) + case let .failure(error): + Log.error("[RecapFeature] fetchRecap failed: \(error.localizedDescription)") + } + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + case .share: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift new file mode 100644 index 0000000..9bce801 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapLockedView.swift @@ -0,0 +1,115 @@ +// +// RecapLockedView.swift +// Profile +// +// 나의 철학자 유형 잠금 화면 — picke.pen `잠긴화면_콘텐츠 소비 5개 미만`. +// 분석 기록 부족 안내 카드 + 블러 처리된 성향 분석 + 잠금 해제 안내. +// + +import SwiftUI + +import DesignSystem +import Entity + +struct RecapLockedView: View { + /// 잠금 장식용 레이더(블러) — picke.pen 잠금 그래프 라벨/형태(거의 꽉 찬 육각형). + private let placeholderAxes: [RecapScoreAxis] = [ + RecapScoreAxis(label: "원칙", value: 95), + RecapScoreAxis(label: "논리", value: 88), + RecapScoreAxis(label: "일관성", value: 85), + RecapScoreAxis(label: "공감", value: 80), + RecapScoreAxis(label: "실용", value: 88), + RecapScoreAxis(label: "직관", value: 88), + ] + + var body: some View { + ScrollView { + VStack(spacing: 24) { + lockedCard + tendencyLockedSection + } + .padding(.top, 20) + .padding(.horizontal, 16) + .padding(.bottom, 32) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + } +} + +private extension RecapLockedView { + // MARK: 잠금 카드 (??형 + 안내) + + var lockedCard: some View { + VStack(spacing: 24) { + VStack(spacing: 6) { + Text("나의 철학자 유형") + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.primary500) + Text("??형") + .pretendardFont(family: .SemiBold, size: 24) + .foregroundStyle(.gray800) + } + + ZStack { + Circle().fill(.gray50) + Image(asset: .lock) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + } + .frame(width: 68, height: 68) + + Text("아직 분석할 기록이 부족해요.\n배틀에 참여하면 성향을 확인할 수 있어요!") + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.gray800) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + .padding(.vertical, 20) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .padding(.horizontal, 24) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + .overlay(alignment: .top) { + Rectangle().fill(.primary500).frame(height: 3) + } + .clipShape(RoundedRectangle(cornerRadius: 2)) + } + + // MARK: 성향 분석 (블러 + 잠금 안내) + + var tendencyLockedSection: some View { + VStack(spacing: 12) { + Text("성향 분석") + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.gray800) + + ZStack { + RecapRadarChart(axes: placeholderAxes) + .frame(height: 172) + .opacity(0.4) + .blur(radius: 4.375) + .allowsHitTesting(false) + + Text("배틀 5개에 참여하시면\n잠금을 풀 수 있어요!") + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.gray800) + .multilineTextAlignment(.center) + } + .padding(.vertical, 16) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 2)) + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapMatchCard.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapMatchCard.swift new file mode 100644 index 0000000..0bd9a7d --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapMatchCard.swift @@ -0,0 +1,76 @@ +// +// RecapMatchCard.swift +// Profile +// +// 궁합 유형 카드 (BEST / WORST) — 아바타 + 유형명 + 설명. +// (잠금 화면에서도 재사용) +// + +import SwiftUI + +import DesignSystem +import Entity +import Kingfisher + +public struct RecapMatchCard: View { + private let card: RecapCard + private let isBest: Bool + + public init(card: RecapCard, isBest: Bool) { + self.card = card + self.isBest = isBest + } + + public var body: some View { + VStack(spacing: 8) { + HStack(spacing: 4) { + Image(systemName: isBest ? "hand.thumbsup" : "hand.thumbsdown") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(isBest ? .secondary500 : .gray400) + Text(isBest ? "BEST" : "WORST") + .pretendardFont(family: .Bold, size: 10) + .foregroundStyle(isBest ? .secondary500 : .gray400) + } + + avatar + + VStack(spacing: 6) { + Text(card.typeName) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.gray500) + + Text(card.description) + .pretendardFont(family: .Regular, size: 11) + .foregroundStyle(.gray300) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + } + .padding(16) + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + + @ViewBuilder + private var avatar: some View { + ZStack { + Circle().fill(.beige600) + if !card.imageURL.isEmpty, let url = URL(string: card.imageURL) { + KFImage(url) + .resizable() + .scaledToFit() + .padding(4) + } else { + Image(systemName: "brain.head.profile") + .font(.system(size: 18)) + .foregroundStyle(.gray300) + } + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } +} diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapPhilosopherCard.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapPhilosopherCard.swift new file mode 100644 index 0000000..f40e895 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapPhilosopherCard.swift @@ -0,0 +1,95 @@ +// +// RecapPhilosopherCard.swift +// Profile +// +// 내 철학자 유형 카드 — 상단 액센트 라인 + 유형명 + 아바타 + 설명 + 키워드 뱃지. +// (잠금 화면에서도 재사용) +// + +import SwiftUI + +import DesignSystem +import Entity +import Kingfisher + +public struct RecapPhilosopherCard: View { + private let card: RecapCard + + public init(card: RecapCard) { + self.card = card + } + + public var body: some View { + VStack(spacing: 24) { + VStack(spacing: 6) { + Text("나의 철학자 유형") + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.primary500) + + Text(card.typeName) + .pretendardFont(family: .SemiBold, size: 24) + .foregroundStyle(.gray500) + } + + avatar + + VStack(spacing: 32) { + Text(card.description) + .pretendardFont(family: .Regular, size: 14) + .foregroundStyle(.gray400) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + if !card.keywordTags.isEmpty { + HStack(spacing: 8) { + ForEach(card.keywordTags, id: \.self) { tag in + Text(tag) + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.primary500) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.primary100, lineWidth: 1) + ) + } + } + } + } + } + .padding(.vertical, 16) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + .overlay(alignment: .top) { + Rectangle() + .fill(.primary500) + .frame(height: 3) + } + .clipShape(RoundedRectangle(cornerRadius: 2)) + } + + @ViewBuilder + private var avatar: some View { + ZStack { + Circle().fill(.beige600) + if !card.imageURL.isEmpty, let url = URL(string: card.imageURL) { + KFImage(url) + .resizable() + .scaledToFit() + .padding(6) + } else { + Image(systemName: "brain.head.profile") + .font(.system(size: 30)) + .foregroundStyle(.gray300) + } + } + .frame(width: 68, height: 68) + .clipShape(Circle()) + } +} diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift new file mode 100644 index 0000000..d24cda2 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapRadarChart.swift @@ -0,0 +1,140 @@ +// +// RecapRadarChart.swift +// Profile +// +// 성향 분석 6축 레이더 차트 — picke.pen `graph` 노드 정합. +// 세로로 약간 긴 육각형(폭/높이 = 0.846) + 4겹 그리드 + 스포크 + 데이터 폴리곤(중심→값 펼침 애니메이션) + 꼭짓점 점. +// + +import SwiftUI + +import DesignSystem +import Entity + +public struct RecapRadarChart: View { + private let axes: [RecapScoreAxis] + private let rings: Int = 4 + + /// picke.pen 육각형 단위 꼭짓점 (수직 반지름=1, 수평 0.846, 측면 y=±0.559). + /// 순서: 원칙↑ · 이성 · 개인 · 변화↓ · 내면 · 직관 (axes 순서와 동일). + private let unit: [CGPoint] = [ + CGPoint(x: 0, y: -1), + CGPoint(x: 0.846, y: -0.559), + CGPoint(x: 0.846, y: 0.559), + CGPoint(x: 0, y: 1), + CGPoint(x: -0.846, y: 0.559), + CGPoint(x: -0.846, y: -0.559), + ] + + @State private var progress: CGFloat = 0 + + public init(axes: [RecapScoreAxis]) { + self.axes = axes + } + + public var body: some View { + GeometryReader { geo in + let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2) + let radius = min(geo.size.width, geo.size.height) / 2 * 0.78 + let ratios = axes.map { clamp($0.value / 100) } + + ZStack { + // 4겹 그리드 육각형 + ForEach(1 ... rings, id: \.self) { ring in + hexPath(center: center, radius: radius * CGFloat(ring) / CGFloat(rings)) + .stroke(.beige600, lineWidth: 1) + } + + // 스포크 + ForEach(unit.indices, id: \.self) { index in + Path { path in + path.move(to: center) + path.addLine(to: vertex(center: center, radius: radius, index: index)) + } + .stroke(.beige600, lineWidth: 1) + } + + // 데이터 폴리곤 (animatableData 로 중심→값 실제 보간) + RadarPolygon(ratios: ratios, unit: unit, progress: progress) + .fill(.primary500.opacity(0.08)) + RadarPolygon(ratios: ratios, unit: unit, progress: progress) + .stroke(.primary500, lineWidth: 1.5) + + // 꼭짓점 점 (6px) + ForEach(Array(axes.enumerated()), id: \.offset) { index, axis in + let ratio = clamp(axis.value / 100) * progress + Circle() + .fill(.primary500) + .frame(width: 6, height: 6) + .position(vertex(center: center, radius: radius * ratio, index: index)) + } + + // 축 라벨 (원칙: 600/neutral900, 나머지: 500/gray400) + ForEach(Array(axes.enumerated()), id: \.offset) { index, axis in + Text(axis.label) + .pretendardFont(family: index == 0 ? .SemiBold : .Medium, size: 10) + .foregroundStyle(index == 0 ? .neutral900 : .gray400) + .fixedSize() + .position(labelPosition(center: center, radius: radius, index: index)) + } + } + .onAppear { + progress = 0 + withAnimation(.easeOut(duration: 0.9)) { progress = 1 } + } + } + } +} + +private extension RecapRadarChart { + func clamp(_ value: Double) -> CGFloat { + CGFloat(max(0, min(1, value))) + } + + func vertex(center: CGPoint, radius: CGFloat, index: Int) -> CGPoint { + let u = unit[index] + return CGPoint(x: center.x + radius * u.x, y: center.y + radius * u.y) + } + + func labelPosition(center: CGPoint, radius: CGFloat, index: Int) -> CGPoint { + let u = unit[index] + let r = radius + 16 + return CGPoint(x: center.x + r * u.x, y: center.y + r * u.y) + } + + /// 단위 꼭짓점 기반 육각형. + func hexPath(center: CGPoint, radius: CGFloat) -> Path { + Path { path in + for index in unit.indices { + let p = vertex(center: center, radius: radius, index: index) + if index == 0 { path.move(to: p) } else { path.addLine(to: p) } + } + path.closeSubpath() + } + } +} + +/// 데이터 폴리곤 Shape — progress(animatableData)로 중심→값을 부드럽게 보간. +private struct RadarPolygon: Shape { + let ratios: [CGFloat] + let unit: [CGPoint] + var progress: CGFloat + + var animatableData: CGFloat { + get { progress } + set { progress = newValue } + } + + func path(in rect: CGRect) -> Path { + let center = CGPoint(x: rect.midX, y: rect.midY) + let radius = min(rect.width, rect.height) / 2 * 0.78 + var path = Path() + for index in unit.indices { + let r = radius * ratios[index] * progress + let point = CGPoint(x: center.x + r * unit[index].x, y: center.y + r * unit[index].y) + if index == 0 { path.move(to: point) } else { path.addLine(to: point) } + } + path.closeSubpath() + return path + } +} diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapScoreBar.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapScoreBar.swift new file mode 100644 index 0000000..b7f2e11 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapScoreBar.swift @@ -0,0 +1,61 @@ +// +// RecapScoreBar.swift +// Profile +// +// 성향 6축 점수 미니 바 — 채워지는 애니메이션. +// + +import SwiftUI + +import DesignSystem +import Entity + +public struct RecapScoreBar: View { + private let axis: RecapScoreAxis + private let trackWidth: CGFloat = 48 + + @State private var progress: CGFloat = 0 + + public init(axis: RecapScoreAxis) { + self.axis = axis + } + + public var body: some View { + HStack(spacing: 8) { + Text(axis.label) + .pretendardFont(family: .Medium, size: 10) + .foregroundStyle(.neutral900) + .fixedSize() + + Spacer(minLength: 4) + + ZStack(alignment: .leading) { + Capsule() + .fill(.primary100) + .frame(width: trackWidth, height: 4) + Capsule() + .fill(.primary500) + .frame(width: trackWidth * ratio * progress, height: 4) + } + .frame(width: trackWidth) + + Text("\(Int(axis.value.rounded()))") + .pretendardFont(family: .SemiBold, size: 11) + .foregroundStyle(.neutral900) + .fixedSize() + .frame(minWidth: 18, alignment: .trailing) + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity) + .background(.beige400, in: RoundedRectangle(cornerRadius: 2)) + .onAppear { + progress = 0 + withAnimation(.easeOut(duration: 0.7)) { progress = 1 } + } + } + + private var ratio: CGFloat { + max(0, min(1, axis.value / 100)) + } +} diff --git a/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapSkeletonView.swift b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapSkeletonView.swift new file mode 100644 index 0000000..741d477 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/View/Components/RecapSkeletonView.swift @@ -0,0 +1,107 @@ +// +// RecapSkeletonView.swift +// Profile +// +// 나의 철학자 유형(리캡) 로딩 스켈레톤 — 공통 SkeletonBlock(light). +// + +import SwiftUI + +import DesignSystem + +struct RecapSkeletonView: View { + var body: some View { + ScrollView { + VStack(spacing: 24) { + // 내 카드 + cardBox { + VStack(spacing: 16) { + block(width: 100, height: 13) + block(width: 120, height: 24) + SkeletonBlock(cornerRadius: 34, tone: .light).frame(width: 68, height: 68) + block(width: nil, maxWidth: true, height: 40) + HStack(spacing: 8) { + ForEach(0 ..< 3, id: \.self) { _ in block(width: 56, height: 20) } + } + } + .padding(20) + } + + // 성향 분석 + VStack(spacing: 12) { + block(width: 70, height: 13) + cardBox { + VStack(spacing: 12) { + SkeletonBlock(cornerRadius: 8, tone: .light).frame(height: 160) + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { + ForEach(0 ..< 6, id: \.self) { _ in block(width: nil, maxWidth: true, height: 28) } + } + } + .padding(.vertical, 16) + .padding(.horizontal, 20) + } + } + + // 취향 리포트 + VStack(spacing: 12) { + block(width: 90, height: 13) + cardBox { + VStack(spacing: 16) { + HStack(spacing: 24) { + ForEach(0 ..< 3, id: \.self) { _ in + VStack(spacing: 6) { block(width: 30, height: 16); block(width: 44, height: 10) } + .frame(maxWidth: .infinity) + } + } + ForEach(0 ..< 4, id: \.self) { _ in block(width: nil, maxWidth: true, height: 16) } + } + .padding(16) + } + } + + // 궁합 + VStack(spacing: 12) { + block(width: 60, height: 13) + HStack(spacing: 8) { + ForEach(0 ..< 2, id: \.self) { _ in + cardBox { + VStack(spacing: 8) { + block(width: 40, height: 12) + SkeletonBlock(cornerRadius: 20, tone: .light).frame(width: 40, height: 40) + block(width: 60, height: 13) + block(width: nil, maxWidth: true, height: 22) + } + .padding(16) + } + } + } + } + + SkeletonBlock(cornerRadius: 2, tone: .light).frame(height: 52) + } + .padding(.top, 20) + .padding(.horizontal, 16) + .padding(.bottom, 32) + } + .scrollIndicators(.hidden) + } + + @ViewBuilder + private func cardBox(@ViewBuilder _ content: () -> some View) -> some View { + content() + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + + @ViewBuilder + private func block(width: CGFloat?, maxWidth: Bool = false, height: CGFloat) -> some View { + SkeletonBlock(cornerRadius: 4, tone: .light) + .frame(width: width) + .frame(maxWidth: maxWidth ? .infinity : nil) + .frame(height: height) + } +} diff --git a/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift b/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift new file mode 100644 index 0000000..e9f280e --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Recap/View/RecapView.swift @@ -0,0 +1,222 @@ +// +// RecapView.swift +// Profile +// +// 나의 철학자 유형(리캡) UI — picke.pen `나의 철학자 유형`. +// 내 카드 + 성향 분석(레이더/바) + 내 취향 리포트 + 궁합 유형 + 공유하기. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +@ViewAction(for: RecapFeature.self) +public struct RecapView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "나의 철학자 유형") { + Button { send(.shareTapped) } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(.neutral900) + .frame(width: 24, height: 24) + } + } + .foregroundStyle(.gray500) + + if let recap = store.recap { + if store.isLocked { + RecapLockedView() + } else { + content(recap) + } + } else { + RecapSkeletonView() + } + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .sheet(item: $store.shareItem) { item in + ShareSheet(items: item.items) + .presentationDetents([.fraction(0.5)]) + } + .onAppear { send(.onAppear) } + } +} + +private extension RecapView { + @ViewBuilder + func content(_ recap: PhilosopherRecap) -> some View { + ScrollView { + VStack(spacing: 24) { + RecapPhilosopherCard(card: recap.myCard) + tendencySection(recap.scores) + reportSection(recap.preferenceReport) + matchSection(recap) + shareButton() + } + .padding(.top, 20) + .padding(.horizontal, 16) + .padding(.bottom, 32) + } + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + } + + // MARK: 공통 섹션 (헤딩 + 카드) + + @ViewBuilder + func sectionHeading(_ title: String) -> some View { + Text(title) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.gray800) + .frame(maxWidth: .infinity, alignment: .center) + } + + @ViewBuilder + func card(@ViewBuilder _ content: () -> some View) -> some View { + content() + .frame(maxWidth: .infinity) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige600, lineWidth: 1) + ) + } + + // MARK: 성향 분석 + + @ViewBuilder + func tendencySection(_ scores: RecapScores) -> some View { + VStack(spacing: 12) { + sectionHeading("성향 분석") + card { + VStack(spacing: 8) { + RecapRadarChart(axes: scores.axes) + .frame(height: 172) + + LazyVGrid( + columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)], + spacing: 8 + ) { + // picke.pen 그리드 순서: 원칙·이성 / 개인·변화 / 내면·이상 + ForEach(scores.gridAxes) { axis in + RecapScoreBar(axis: axis) + } + } + } + .padding(.vertical, 16) + .padding(.horizontal, 20) + } + } + } + + // MARK: 내 취향 리포트 + + @ViewBuilder + func reportSection(_ report: PreferenceReport) -> some View { + VStack(spacing: 12) { + sectionHeading("내 취향 리포트") + card { + VStack(spacing: 20) { + HStack(spacing: 0) { + statCell(value: "\(report.totalParticipation)", label: "총 참여", divider: true) + statCell(value: "\(report.opinionChanges)", label: "의견 전환", divider: true) + statCell(value: "\(report.battleWinRate)%", label: "배틀 승률", divider: false) + } + .padding(.horizontal, 16) + + VStack(spacing: 0) { + ForEach(report.favoriteTopics) { topic in + topicRow(topic) + } + } + } + .padding(.top, 16) + } + } + } + + @ViewBuilder + func statCell(value: String, label: String, divider: Bool) -> some View { + VStack(spacing: 0) { + Text(value) + .pretendardFont(family: .Bold, size: 16) + .foregroundStyle(.gray800) + Text(label) + .pretendardFont(family: .Medium, size: 10) + .foregroundStyle(.gray300) + } + .frame(maxWidth: .infinity) + .overlay(alignment: .trailing) { + if divider { + Rectangle().fill(.beige600).frame(width: 1, height: 28) + } + } + } + + @ViewBuilder + func topicRow(_ topic: FavoriteTopic) -> some View { + HStack(spacing: 6) { + Text(String(format: "%02d", topic.rank)) + .pretendardFont(family: .Bold, size: 10) + .foregroundStyle(.secondary500) + Text(topic.tagText) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.gray800) + Spacer(minLength: 6) + Text("\(topic.participationCount)회") + .pretendardFont(family: .Medium, size: 10) + .foregroundStyle(.gray300) + } + .padding(.vertical, 12) + .padding(.horizontal, 16) + .overlay(alignment: .top) { + Rectangle().fill(.beige600).frame(height: 1) + } + } + + // MARK: 궁합 유형 + + @ViewBuilder + func matchSection(_ recap: PhilosopherRecap) -> some View { + VStack(spacing: 12) { + sectionHeading("궁합 유형") + HStack(spacing: 8) { + RecapMatchCard(card: recap.bestMatchCard, isBest: true) + RecapMatchCard(card: recap.worstMatchCard, isBest: false) + } + } + } + + // MARK: 공유하기 + + @ViewBuilder + func shareButton() -> some View { + Button { + send(.shareTapped) + } label: { + HStack(spacing: 6) { + Text("공유하기") + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.beige50) + Image(systemName: "square.and.arrow.up") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.beige50) + } + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(.primary500, in: RoundedRectangle(cornerRadius: 2)) + } + .buttonStyle(.plain) + } +} diff --git a/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift b/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift new file mode 100644 index 0000000..8169ea9 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift @@ -0,0 +1,252 @@ +// +// SettingsFeature.swift +// Profile +// +// 설정 — picke.pen `설정`. +// 메뉴(알림설정/개인정보 처리방침/서비스 약관/로그아웃/회원 탈퇴) + 로그아웃·탈퇴 확인 팝업. +// 팝업은 DesignSystem 의 CustomAlert(.logout/.withdraw) 사용. +// 로그아웃/탈퇴 시 AuthUseCase 호출 → Keychain 초기화 → 로그인 화면 복귀. +// + +import Foundation + +import ComposableArchitecture +import DesignSystem +import Entity +import LogMacro +import UseCase + +@Reducer +public struct SettingsFeature { + public init() {} + + public enum MenuItem: String, CaseIterable, Equatable, Identifiable { + case notification = "알림설정" + case privacy = "개인정보 처리방침" + case terms = "서비스 약관" + case logout = "로그아웃" + case withdraw = "회원 탈퇴" + + public var id: String { rawValue } + } + + /// 확인 팝업 대기 동작 (confirm 시 무엇을 실행할지). + public enum PendingAction: Equatable { + case logout + case withdraw + } + + @ObservableState + public struct State: Equatable { + public var menuItems: [MenuItem] = MenuItem.allCases + public var pending: PendingAction? + public var isProcessing: Bool = false + @Presents public var customAlert: CustomAlertState? + + public init() {} + } + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case scope(ScopeAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case backTapped + case menuTapped(MenuItem) + } + + public enum AsyncAction: Equatable { + case performLogout + case performWithdraw + } + + public enum InnerAction: Equatable { + case sessionCleared + } + + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) + } + + public enum DelegateAction: Equatable { + case dismiss + case openNotificationSettings + case openPrivacy + case openTerms + /// 로그아웃/탈퇴 완료 → 로그인 화면으로. + case sessionEnded + } + + nonisolated enum CancelID: Hashable { + case auth + } + + @Dependency(\.authUseCase) private var authUseCase + @Dependency(\.keychainManager) private var keychainManager + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .scope(scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + + case let .delegate(delegateAction): + return handleDelegateAction(state: &state, action: delegateAction) + } + } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } + } +} + +extension SettingsFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .backTapped: + return .send(.delegate(.dismiss)) + + case let .menuTapped(item): + switch item { + case .notification: + return .send(.delegate(.openNotificationSettings)) + case .privacy: + return .send(.delegate(.openPrivacy)) + case .terms: + return .send(.delegate(.openTerms)) + case .logout: + state.pending = .logout + state.customAlert = .logout() + return .none + case .withdraw: + state.pending = .withdraw + state.customAlert = .withdraw() + return .none + } + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .performLogout: + guard !state.isProcessing else { return .none } + state.isProcessing = true + return .run { send in + do { + _ = try await authUseCase.logout() + } catch { + Log.error("[SettingsFeature] logout failed: \(error.localizedDescription)") + } + await send(.inner(.sessionCleared)) + } + .cancellable(id: CancelID.auth, cancelInFlight: true) + + case .performWithdraw: + guard !state.isProcessing else { return .none } + state.isProcessing = true + let token = keychainManager.refreshToken() ?? keychainManager.accessToken() ?? "" + return .run { send in + do { + _ = try await authUseCase.withDraw(token: token) + } catch { + Log.error("[SettingsFeature] withdraw failed: \(error.localizedDescription)") + } + await send(.inner(.sessionCleared)) + } + .cancellable(id: CancelID.auth, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case .sessionCleared: + state.isProcessing = false + // 서버 호출 성공/실패와 무관하게 로컬 세션은 정리하고 로그인으로 전환. + keychainManager.clear() + return .send(.delegate(.sessionEnded)) + } + } + + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case let .customAlert(alertAction): + switch alertAction { + case let .presented(customAlertAction): + switch customAlertAction { + case .confirmTapped: + let pending = state.pending + state.pending = nil + state.customAlert = nil + switch pending { + case .logout: + return .send(.async(.performLogout)) + case .withdraw: + return .send(.async(.performWithdraw)) + case .none: + return .none + } + + case .cancelTapped: + state.pending = nil + state.customAlert = nil + return .none + } + + case .dismiss: + state.pending = nil + state.customAlert = nil + return .none + } + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action { + case .dismiss: + return .none + case .openNotificationSettings: + return .none + case .openPrivacy: + return .none + case .openTerms: + return .none + case .sessionEnded: + return .none + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Settings/View/SettingsView.swift b/Projects/Presentation/Profile/Sources/Settings/View/SettingsView.swift new file mode 100644 index 0000000..97048d0 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Settings/View/SettingsView.swift @@ -0,0 +1,81 @@ +// +// SettingsView.swift +// Profile +// +// 설정 UI — picke.pen `설정`. +// App Bar(백/타이틀) + 메뉴 리스트 + 로그아웃/탈퇴 확인 팝업. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem + +@ViewAction(for: SettingsFeature.self) +public struct SettingsView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + appBar() + menuList() + Spacer(minLength: 0) + } + .background(Color.beige200.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + } +} + +private extension SettingsView { + // MARK: App Bar + + @ViewBuilder + func appBar() -> some View { + PickeNavigationBar(onBack: { send(.backTapped) }, centerTitle: "설정") + .foregroundStyle(.gray500) + } + + // MARK: 메뉴 리스트 + + @ViewBuilder + func menuList() -> some View { + VStack(spacing: 0) { + ForEach(store.menuItems) { item in + menuRow(item) + } + } + .padding(.top, 12) + .padding(.horizontal, 16) + } + + @ViewBuilder + func menuRow(_ item: SettingsFeature.MenuItem) -> some View { + Button { + send(.menuTapped(item)) + } label: { + HStack { + Text(item.rawValue) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(item == .withdraw ? .gray500 : .gray800) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.neutral900) + } + .padding(.vertical, 20) + .contentShape(Rectangle()) + .overlay(alignment: .bottom) { + Rectangle().fill(.beige600).frame(height: 1) + } + } + .buttonStyle(.plain) + } +} diff --git a/Projects/Presentation/Profile/Tests/Sources/ProfileTests.swift b/Projects/Presentation/Profile/Tests/Sources/ProfileTests.swift new file mode 100644 index 0000000..f375825 --- /dev/null +++ b/Projects/Presentation/Profile/Tests/Sources/ProfileTests.swift @@ -0,0 +1,24 @@ +// +// ProfileTests.swift +// Presentation.ProfileTests +// +// Created by Roy on 2026-06-08. +// + +@testable import Profile +import Testing + +struct ProfileTests { + @Test + func profileExample() { + // This is an example of a test case. + #expect(true) + } + + @Test + func profileLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } +} diff --git a/Projects/Presentation/Web/Sources/View/WebView.swift b/Projects/Presentation/Web/Sources/View/WebView.swift index 19b9996..bcfdf9f 100644 --- a/Projects/Presentation/Web/Sources/View/WebView.swift +++ b/Projects/Presentation/Web/Sources/View/WebView.swift @@ -20,7 +20,7 @@ public struct WebView: View { public var body: some View { ZStack { - Color.basicBlack + Color.beige50 .edgesIgnoringSafeArea(.all) VStack { @@ -30,7 +30,7 @@ public struct WebView: View { PickeNavigationBar(onBack: { store.send(.backToRoot) }) { Color.clear.frame(width: 24, height: 24) } - .foregroundStyle(.beige50) + .foregroundStyle(.neutral900) Spacer() .frame(height: 20) @@ -40,5 +40,7 @@ public struct WebView: View { } .navigationBarBackButtonHidden(true) } + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) } } diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/history.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/history.imageset/Contents.json new file mode 100644 index 0000000..44dcee4 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/history.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "history.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/history.imageset/history.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/history.imageset/history.svg new file mode 100644 index 0000000..7feec14 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/history.imageset/history.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/lock.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/lock.imageset/Contents.json new file mode 100644 index 0000000..cdd8a97 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/lock.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "제목-없음-2 1.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/lock.imageset/\354\240\234\353\252\251-\354\227\206\354\235\214-2 1.png" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/lock.imageset/\354\240\234\353\252\251-\354\227\206\354\235\214-2 1.png" new file mode 100644 index 0000000..228e9c3 Binary files /dev/null and "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/lock.imageset/\354\240\234\353\252\251-\354\227\206\354\235\214-2 1.png" differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/vs.imageset/Contents.json similarity index 100% rename from Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/Contents.json rename to Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/vs.imageset/Contents.json diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/versus_home.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/vs.imageset/versus_home.svg similarity index 100% rename from Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/vs.imageset/versus_home.svg rename to Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Profile/vs.imageset/versus_home.svg diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index 6869683..da75d39 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift @@ -50,4 +50,9 @@ public enum ImageAsset: String { case avatarSunja case none + + // MARK: - 프로필 + + case history + case lock } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift index 1de4fa0..98ed2d3 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomAlertState.swift @@ -38,6 +38,9 @@ public enum CustomAlertStyle: Equatable { case report case alreadyWatched case deleteConfirm + case logout + case withdraw + case suggestTopic } @CasePathable @@ -122,4 +125,37 @@ public extension CustomAlertState where Action == CustomAlertAction { style: .alreadyWatched ) } + + /// 로그아웃 확인 — 확정(로그아웃)=왼쪽 밝은 버튼, 취소(유지)=오른쪽 primary 버튼. + static func logout() -> CustomAlertState { + CustomAlertState( + title: "로그아웃 시 원활한 이용이 어려울 수 있습니다.\n그럼에도 로그아웃하시겠습니까?", + confirmTitle: "네, 로그아웃합니다", + cancelTitle: "그대로 있을게요", + isDestructive: true, + style: .logout + ) + } + + /// 회원 탈퇴 확인 — 확정(탈퇴)=왼쪽 밝은 버튼, 취소(유지)=오른쪽 primary 버튼. + static func withdraw() -> CustomAlertState { + CustomAlertState( + title: "탈퇴 시 지금까지의 이용기록이 영구 삭제 됩니다.\n그럼에도 탈퇴하시겠습니까?", + confirmTitle: "네, 탈퇴합니다", + cancelTitle: "그대로 있을게요", + isDestructive: true, + style: .withdraw + ) + } + + /// 주제 제안 — 제안하기=오른쪽 primary 버튼, 뒤로가기=왼쪽 밝은 버튼. + static func suggestTopic() -> CustomAlertState { + CustomAlertState( + title: "나만의 배틀을 제안해주세요", + message: "-30P를 사용해 원하는 주제를 제안하고,\n채택되면 +100P를 돌려받아요.", + confirmTitle: "제안하기", + cancelTitle: "뒤로가기", + style: .suggestTopic + ) + } } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift index 869fe43..43536f4 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Alert/CustomPopup/CustomConfirmationPopupView.swift @@ -60,7 +60,7 @@ struct CustomConfirmationPopup: View { switch style { case .confirmation: return 20 - case .finalVote, .report, .alreadyWatched, .deleteConfirm: + case .finalVote, .report, .alreadyWatched, .deleteConfirm, .logout, .withdraw, .suggestTopic: return 0 } } @@ -78,6 +78,106 @@ struct CustomConfirmationPopup: View { alreadyWatchedContent case .deleteConfirm: deleteConfirmContent + case .logout, .withdraw: + logoutWithdrawContent + case .suggestTopic: + suggestTopicContent + } + } + + /// 로그아웃/탈퇴 — 본문(title) + 확정(왼쪽 밝은) / 취소(오른쪽 primary). + private var logoutWithdrawContent: some View { + pickeAlertCard(opacity: 0.9) { + VStack(spacing: 16) { + pickeBodyText(title) + + pickeTwoButtonRow( + leftTitle: confirmTitle, leftAction: onConfirm, + rightTitle: cancelTitle, rightAction: onCancel + ) + } + } + } + + /// 주제 제안 — 타이틀 + 본문 + 뒤로가기(왼쪽 밝은) / 제안하기(오른쪽 primary). + private var suggestTopicContent: some View { + pickeAlertCard { + VStack(spacing: 16) { + VStack(spacing: 10) { + Text(title) + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.primary800) + .multilineTextAlignment(.center) + + if !message.isEmpty { + pickeBodyText(message) + } + } + + pickeTwoButtonRow( + leftTitle: cancelTitle, leftAction: onCancel, + rightTitle: confirmTitle, rightAction: onConfirm + ) + } + } + } + + // MARK: picke 공통 팝업 헬퍼 + + /// 베이지 카드 + primary 보더 컨테이너 (width 313, top padding 20). + private func pickeAlertCard( + opacity: Double = 1, + @ViewBuilder content: () -> some View + ) -> some View { + content() + .padding(.top, 20) + .frame(width: 313) + .background(.beige500, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.primary500, lineWidth: 1.5) + ) + .opacity(opacity) + .clipShape(RoundedRectangle(cornerRadius: 2)) + .onTapGesture {} + } + + /// 본문 텍스트 (14/Medium, primary800, 가운데, 좌우 20). + private func pickeBodyText(_ text: String) -> some View { + Text(text) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.primary800) + .lineSpacing(14 * 0.4) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + } + + /// 좌(밝은)·우(primary) 2버튼 행. 좌/우 의미는 호출부가 결정. + private func pickeTwoButtonRow( + leftTitle: String, leftAction: @escaping () -> Void, + rightTitle: String, rightAction: @escaping () -> Void + ) -> some View { + HStack(spacing: 0) { + Button(action: leftAction) { + Text(leftTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.primary800) + .frame(maxWidth: .infinity) + .padding(.vertical, 17) + .background(.secondary50, in: Rectangle()) + } + .buttonStyle(.plain) + + Button(action: rightAction) { + Text(rightTitle) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.secondary50) + .frame(maxWidth: .infinity) + .padding(.vertical, 17) + .background(.primary500, in: Rectangle()) + } + .buttonStyle(.plain) } } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Empty/PickeEmptyStateView.swift b/Projects/Shared/DesignSystem/Sources/UI/Empty/PickeEmptyStateView.swift new file mode 100644 index 0000000..f1a98e1 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Empty/PickeEmptyStateView.swift @@ -0,0 +1,39 @@ +// +// PickeEmptyStateView.swift +// DesignSystem +// +// 공통 빈 상태 뷰 — noDataLogo + 안내 문구. (배틀 모듈 emptyState 패턴 공통화) +// + +import SwiftUI + +public struct PickeEmptyStateView: View { + private let message: String + private let imageAsset: ImageAsset + private let imageSize: CGSize + + public init( + message: String, + imageAsset: ImageAsset = .noDataLogo, + imageSize: CGSize = CGSize(width: 135, height: 90) + ) { + self.message = message + self.imageAsset = imageAsset + self.imageSize = imageSize + } + + public var body: some View { + VStack(spacing: 8) { + Image(asset: imageAsset) + .resizable() + .scaledToFit() + .frame(width: imageSize.width, height: imageSize.height) + + Text(message) + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.gray300) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/UI/Navigaion/PickeNavigationBar.swift b/Projects/Shared/DesignSystem/Sources/UI/Navigaion/PickeNavigationBar.swift index 382d463..c023219 100644 --- a/Projects/Shared/DesignSystem/Sources/UI/Navigaion/PickeNavigationBar.swift +++ b/Projects/Shared/DesignSystem/Sources/UI/Navigaion/PickeNavigationBar.swift @@ -16,15 +16,18 @@ import SwiftUI public struct PickeNavigationBar: View { private let onBack: (() -> Void)? private let centerIcon: Image? + private let centerTitle: String? private let trailing: () -> Trailing public init( onBack: (() -> Void)? = nil, centerIcon: Image? = nil, + centerTitle: String? = nil, @ViewBuilder trailing: @escaping () -> Trailing ) { self.onBack = onBack self.centerIcon = centerIcon + self.centerTitle = centerTitle self.trailing = trailing } @@ -56,7 +59,11 @@ public struct PickeNavigationBar: View { @ViewBuilder private var centerArea: some View { - if let centerIcon { + if let centerTitle { + Text(centerTitle) + .pretendardFont(family: .SemiBold, size: 16) + .kerning(-0.4) + } else if let centerIcon { centerIcon .font(.system(size: 16, weight: .semibold)) .frame(width: 24, height: 24) @@ -69,8 +76,9 @@ public struct PickeNavigationBar: View { public extension PickeNavigationBar where Trailing == EmptyView { init( onBack: (() -> Void)? = nil, - centerIcon: Image? = nil + centerIcon: Image? = nil, + centerTitle: String? = nil ) { - self.init(onBack: onBack, centerIcon: centerIcon) { EmptyView() } + self.init(onBack: onBack, centerIcon: centerIcon, centerTitle: centerTitle) { EmptyView() } } } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Skeleton/SkeletonBlock.swift b/Projects/Shared/DesignSystem/Sources/UI/Skeleton/SkeletonBlock.swift new file mode 100644 index 0000000..bf9e148 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Skeleton/SkeletonBlock.swift @@ -0,0 +1,65 @@ +// +// SkeletonBlock.swift +// DesignSystem +// +// 공통 스켈레톤 블록 — base + shimmer 그라데이션. (라이트/다크 배경 모두 지원) +// + +import SwiftUI + +public struct SkeletonBlock: View { + /// 배경 톤 — 라이트(beige) / 다크 화면에 맞춰 base·shimmer 색을 고른다. + public enum Tone { + case light + case dark + + var base: Color { + switch self { + case .light: Color.black.opacity(0.06) + case .dark: Color.white.opacity(0.08) + } + } + + var shimmer: Color { + switch self { + case .light: Color.white.opacity(0.55) + case .dark: Color.white.opacity(0.20) + } + } + } + + private let cornerRadius: CGFloat + private let tone: Tone + + @State private var phase: CGFloat = -1 + + public init( + cornerRadius: CGFloat = 4, + tone: Tone = .light + ) { + self.cornerRadius = cornerRadius + self.tone = tone + } + + public var body: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(tone.base) + .overlay { + LinearGradient( + stops: [ + .init(color: tone.shimmer.opacity(0), location: 0), + .init(color: tone.shimmer, location: 0.5), + .init(color: tone.shimmer.opacity(0), location: 1), + ], + startPoint: UnitPoint(x: phase, y: 0.5), + endPoint: UnitPoint(x: phase + 1, y: 0.5) + ) + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .onAppear { + withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { + phase = 2 + } + } + } +} diff --git a/Projects/Shared/Utill/Sources/Extension/Date+.swift b/Projects/Shared/Utill/Sources/Extension/Date+.swift new file mode 100644 index 0000000..841af62 --- /dev/null +++ b/Projects/Shared/Utill/Sources/Extension/Date+.swift @@ -0,0 +1,44 @@ +// +// Date+.swift +// Utill +// +// 날짜 포맷 유틸. +// + +import Foundation + +public extension Date { + /// 지정 포맷 문자열로 변환 (ko_KR 고정). + func toString(format: String) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = format + return formatter.string(from: self) + } + + /// `yyyy.M.d` 형식 (예: 2026.4.10). + var yearMonthDayDot: String { + toString(format: "yyyy.M.d") + } + + /// 한국어 상대 시간 (방금 전 / N분 전 / N시간 전 / N일 전). + var relativeKoreanString: String { + let interval = Date().timeIntervalSince(self) + if interval < 60 { return "방금 전" } + if interval < 3600 { return "\(Int(interval / 60))분 전" } + if interval < 86400 { return "\(Int(interval / 3600))시간 전" } + return "\(Int(interval / 86400))일 전" + } +} + +public extension Date? { + /// nil 이면 빈 문자열. + var yearMonthDayDot: String { + self?.yearMonthDayDot ?? "" + } + + /// nil 이면 빈 문자열. + var relativeKoreanString: String { + self?.relativeKoreanString ?? "" + } +} diff --git a/README.md b/README.md index b30e5bc..b160810 100644 --- a/README.md +++ b/README.md @@ -357,10 +357,13 @@ tuist test # 전체 테스트 실행 ### 🚀 배포 ```bash +export MATCH_KEYCHAIN_PASSWORD="" bundle exec fastlane ios QA # TestFlight 업로드 bundle exec fastlane ios release # App Store 배포 ``` +`MATCH_KEYCHAIN_PASSWORD`가 설정되어 있으면 fastlane이 `match_keychain`을 먼저 unlock해서 macOS 키체인 비밀번호 팝업을 줄입니다. + ## 📄 라이선스 이 프로젝트는 **MIT 라이선스** 하에 배포됩니다. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 3949fc9..346d6ef 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -11,6 +11,7 @@ BUNDLE_ID = "io.Picke.co" OUTPUT_DIR = "./fastlane/output" IPA_FILENAME = "#{APP_NAME}.ipa" TEAM_ID = ENV["TEAM_ID"] || "N94CS4N6VR" +MATCH_KEYCHAIN_NAME = ENV["MATCH_KEYCHAIN_NAME"] || "match_keychain" # 릴리스용 변수 (누락되어 있던 부분 보완) APP_RELEASE_NAME = APP_NAME @@ -19,9 +20,30 @@ APP_RELEASE_SCHEME = SCHEME APP_STAGE_NAME = STAGE_SCHEME APP_STAGE_SCHEME = STAGE_SCHEME -platform :ios do +def unlock_match_keychain + keychain_password = ENV["MATCH_KEYCHAIN_PASSWORD"].to_s + if keychain_password.empty? + UI.important("MATCH_KEYCHAIN_PASSWORD is missing. macOS may ask for the match keychain password.") + return + end + keychain_path = File.expand_path("~/Library/Keychains/#{MATCH_KEYCHAIN_NAME}-db") + unless File.exist?(keychain_path) + UI.message("match keychain does not exist yet: #{keychain_path}") + return + end + unlock_keychain( + path: keychain_path, + password: keychain_password, + add_to_search_list: true + ) +end + +platform :ios do + before_all do + unlock_match_keychain + end # 🚀 TestFlight 업로드 (Debug) desc "Upload to TestFlight (Debug)" @@ -35,13 +57,57 @@ platform :ios do in_house: false ) + # 빌드 번호 자동 증가 — TestFlight 최신 빌드 번호 + 1 + begin + current_build_number = latest_testflight_build_number( + app_identifier: BUNDLE_ID, + platform: "ios" + ) + puts "📱 TestFlight 최신 빌드 번호: #{current_build_number}" + rescue => e + puts "⚠️ TestFlight 빌드 번호 조회 실패, 기본값 1 사용: #{e.message}" + current_build_number = 1 + end + + begin + live_build_number = app_store_build_number( + app_identifier: BUNDLE_ID, + platform: "ios" + ) + puts "🏪 App Store 라이브 빌드 번호: #{live_build_number}" + current_build_number = [current_build_number, live_build_number].max + rescue => e + puts "⚠️ App Store 빌드 번호 조회 실패: #{e.message}" + end + + new_build_number = current_build_number + 1 + puts "🔢 새로운 빌드 번호: #{new_build_number}" + + # Extension+String.swift 의 빌드 번호 갱신 (tuist generate 시 CFBundleVersion 으로 반영) + extension_file_path = "../Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift" + extension_content = File.read(extension_file_path) + extension_content = extension_content.gsub( + /public static func appBuildVersion\(buildVersion: String = "\d+"\) -> String \{/, + "public static func appBuildVersion(buildVersion: String = \"#{new_build_number}\") -> String {" + ) + File.write(extension_file_path, extension_content) + puts "✅ 빌드 번호를 #{new_build_number}로 업데이트 완료" + + # 갱신된 빌드 번호 반영을 위해 Tuist 강제 재생성 + Dir.chdir("..") do + puts "🔧 Tuist workspace 재생성 중..." + sh("tuist generate --no-open") + puts "✅ Tuist workspace 재생성 완료" + end match( type: "appstore", readonly: true, git_url: "git@github.com:Roy-wonji/FastlaneMatch.git", storage_mode: "git", - app_identifier: ["io.Picke.co"] + app_identifier: ["io.Picke.co"], + keychain_name: MATCH_KEYCHAIN_NAME, + keychain_password: ENV["MATCH_KEYCHAIN_PASSWORD"] ) ipa_path = nil @@ -203,7 +269,9 @@ platform :ios do readonly: true, git_url: "git@github.com:Roy-wonji/FastlaneMatch.git", storage_mode: "git", - app_identifier: ["io.Picke.co"] + app_identifier: ["io.Picke.co"], + keychain_name: MATCH_KEYCHAIN_NAME, + keychain_password: ENV["MATCH_KEYCHAIN_PASSWORD"] ) ipa_path = nil diff --git a/fastlane/Matchfile b/fastlane/Matchfile index 53e8f14..4370553 100644 --- a/fastlane/Matchfile +++ b/fastlane/Matchfile @@ -7,6 +7,7 @@ force_for_new_certificates(true) shallow_clone(true) skip_certificate_matching(true) skip_confirmation(true) +keychain_name(ENV["MATCH_KEYCHAIN_NAME"] || "match_keychain") keychain_password(ENV["MATCH_KEYCHAIN_PASSWORD"]) # .env 파일에서 API 키가 있으면 임시 JSON 파일 생성 후 사용 완료시 자동 삭제