diff --git a/AGENTS.md b/AGENTS.md index 68f0332..c6522af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,7 +178,28 @@ Color.bgSubtle.edgesIgnoringSafeArea(.all) .foregroundStyle(Color.neutral900) ``` -`SwiftUI.Color` 의 정적 멤버로 디자인 토큰 (`neutral50` … `neutral900`, `primary50` … `primary900`, `secondary50` …, `bgSubtle`) 이 등록되어 있어 `.foregroundStyle / .fill / .background / .tint` 등에서 모두 점 단축형 사용 가능. +`SwiftUI.Color` 의 정적 멤버로 디자인 토큰 (`neutral50` … `neutral900`, `primary50` … `primary900`, `secondary50` …, `beige50` … `beige900`, `bgSubtle`) 이 등록되어 있어 **ShapeStyle 을 받는 모든 modifier 에서 점 단축형 사용**: + +```swift +// ✅ 점 단축형 — ShapeStyle 컨텍스트 모두 적용 +.foregroundStyle(.neutral900) +.fill(.beige200) +.stroke(.beige700, lineWidth: 1) +.background(.beige50, in: RoundedRectangle(cornerRadius: 2)) +.tint(.primary500) + +// ❌ 금지 — Color 타입 명시 +.foregroundStyle(Color.neutral900) +.fill(Color.beige200) +.stroke(Color.beige700, lineWidth: 1) +.background(Color.beige50, in: ...) +``` + +> **예외:** `Color` 가 View 자체로 쓰여 메서드 체이닝을 받는 경우는 그대로 둔다. +> ```swift +> Color.beige50.ignoresSafeArea() // ✅ View 로 쓰임 — Color 명시 필요 +> Color.neutral500.opacity(0.4) // ✅ View 로 쓰임 — Color 명시 필요 +> ``` #### 🖼 이미지 — `Image(asset: .xxx)` + 데이터 모델은 `ImageAsset` 타입 @@ -203,6 +224,36 @@ Image(ImageAsset.onboarding1.rawValue) 2. `ImageAsset` enum 에 `case ` 추가 (raw value = imageset 폴더명과 동일) 3. `tuist generate` 로 리소스 재인덱싱 +#### 🧲 Store 보유 — `@Bindable private var store: StoreOf` 고정 + +TCA View 가 `store` 를 들고 있을 때는 **항상 `@Bindable`** 로 선언한다. 단순 표시뿐이라도 future-proof 하기 위해 동일. + +```swift +// ✅ 올바른 패턴 +public struct OnBoardingView: View { + @Bindable var store: StoreOf +} + +public struct MainTabView: View { + @Bindable private var store: StoreOf +} + +// ❌ 금지 — 그냥 let / var +public struct TabFeatureView: View { + let store: StoreOf // ← @Bindable 누락 + var store: StoreOf // ← 동일하게 누락 +} +``` + +근거: +- `$store.binding` 형태가 필요한 시점이 거의 반드시 옴 (TabView selection, TextField, NavigationDestination 등) +- `@Bindable` 은 read-only 사용 시에도 비용이 없고, 후에 binding 이 추가될 때 시그니처 변경 없이 받음 +- LoginView / OnBoardingView / MainTabView / AuthCoordinatorView 모두 이 규칙 따름 + +가시성: +- 외부에서 store 를 주입받는 표면은 `public` 또는 그대로 두고, +- 그 외 내부에서만 쓸 store 는 `private` 으로 가린다 (`@Bindable private var store`) + #### 🧮 텍스트 / 라벨 — `body` 안에 인라인 표현 금지, State computed 로 표시용 파생값은 View 가 아니라 `State` 의 computed property 로 정의해서 View 에서는 그대로 꺼내기만 한다. @@ -228,6 +279,141 @@ private var primaryButtonTitle: String { } ``` +#### 🪟 State 초기값 — inline default + `public init() {}` 만 노출 + +`@ObservableState` 의 `State` 는 프로퍼티마다 **inline default 값**을 박고, `public init() {}` 만 외부에 노출한다. 긴 파라미터 리스트의 `public init(x:, y:, ...)` 는 쓰지 않는다. + +```swift +// ✅ 올바른 패턴 — 외부는 .init() 만 호출, 변경은 reducer 내부에서 +@ObservableState +public struct State: Equatable { + public var isLoading: Bool = false + public var newNotice: Bool = false + public var heroes: [HeroBattle] = [] + public var heroIndex: Int = 0 + public var hotBattles: [HotBattle] = [] + + public var currentHero: HeroBattle? { heroes[safe: heroIndex] } + + public init() {} +} + +// ❌ 금지 — 모든 필드를 init 파라미터로 펼침 +public init( + isLoading: Bool = false, + newNotice: Bool = false, + heroes: [HeroBattle] = [], + heroIndex: Int = 0, + hotBattles: [HotBattle] = [] +) { + self.isLoading = isLoading + // … +} +``` + +근거: +- 호출처는 `Feature.State()` 한 줄이면 충분 — 사용 시점에 노이즈 없음 +- 초기값은 한 곳에서만 정의 — 프로퍼티 추가/제거 시 init 도 따라 고칠 일 없음 +- 테스트/프리뷰에서 다른 값을 넣고 싶으면 `var state = Feature.State(); state.heroes = ... ` 로 직접 mutate +- Shared / Presents / AppStorage 도 동일하게 inline 으로 선언 (`@Shared(...) var foo: Foo = .empty`) + +레퍼런스: `HomeFeature.State`, attendance `ProfileFeature.State` + +#### ⚡ AsyncAction — `Result { try await }` + `mapError` + 단일 `Response` Inner 액션 + +`do/catch + 별도 Loaded / Failed 액션` 분리하지 말고, `Result` 로 감싸서 단일 `xxxResponse(Result)` Inner 액션으로 보낸다. State 캡쳐는 `[키 = state.xxx]` 형태. + +```swift +// ✅ 올바른 패턴 +public enum InnerAction: Equatable { + case homeResponse(Result) +} + +case .fetchHome: + state.isLoading = true + return .run { [repository = homeRepository] send in + let result = await Result { + try await repository.fetchHome() + } + .mapError(AuthError.from) + return await send(.inner(.homeResponse(result))) + } + .cancellable(id: CancelID.fetchHome, cancelInFlight: true) + +// 핸들러에서 한 자리에서 success/failure 분기 +case let .homeResponse(result): + state.isLoading = false + switch result { + case let .success(bundle): /* state 갱신 */ + case let .failure(error): Log.error("\(error.localizedDescription)") + } + return .none + +// ❌ 금지 — do/catch 로 두 액션을 발사 +return .run { send in + do { + let bundle = try await repository.fetchHome() + await send(.inner(.homeLoaded(bundle))) // ← 분리됨 + } catch { + await send(.inner(.homeFailed(error.localizedDescription))) + } +} +``` + +규칙: +- 성공/실패 상태 머지 → 하나의 `xxxResponse(Result)` 케이스 +- 에러 타입은 `AuthError` 로 통일하고 `AuthError.from(_:)` 로 변환 (이미 Entity 에 정의됨) +- 캡쳐는 `[repository = self.repository, userSession = state.userSession]` 처럼 명시 +- `.cancellable(id: CancelID.xxx, cancelInFlight: true)` 로 중복 호출 방지 +- 레퍼런스: `AuthUseCaseImpl.withDraw` / `HomeFeature.fetchHome` + +#### 🔌 RepositoryImpl — Provider 선언 패턴 + +Repository 구현체의 `MoyaProvider` 는 `let` 으로 직접 선언하고, init 기본값으로 `.default` / `.authorized` 팩토리를 그대로 사용한다. `Optional + nil 합치기`나 `MoyaProviderPool` 인다이렉션 금지. + +```swift +// ✅ 올바른 패턴 — 단일 provider (인증 필요) +public final class HomeRepositoryImpl: HomeInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } +} + +// ✅ 올바른 패턴 — default + authorized 두 개 필요 (로그인/로그아웃 분리) +public final class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { + private let provider: MoyaProvider + private let authProvider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.default, + authProvider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + self.authProvider = authProvider + } +} + +// ❌ 금지 — Optional + nil 합치기 + Pool 인다이렉션 +public init( + provider: MoyaProvider? = nil, + authProvider: MoyaProvider? = nil +) { + self.provider = provider ?? MoyaProviderPool.shared.defaultProvider(for: AuthService.self) + self.authProvider = authProvider ?? MoyaProviderPool.shared.authorizedProvider(for: AuthService.self) +} +``` + +규칙: +- 토큰 인증이 필요한 API → `.authorized` (OptimizedSessionManager 인터셉터 부착) +- 로그인/회원가입 같이 헤더 없는 API → `.default` (로그 플러그인만) +- 테스트/프리뷰는 Mock provider 를 init 으로 그대로 주입 — `MockProvider` 변수 따로 둘 필요 없음 +- `MoyaProviderPool` 은 더 이상 RepositoryImpl 에서 직접 호출하지 않는다 (필요 시 풀 자체에서 내부적으로 캐시 처리) +- 레퍼런스: `HomeRepositoryImpl`, `AuthRepositoryImpl`, AsyncMoya `MoyaProvider+Factory.default`, `Extension+MoyaProvider+Auth.authorized` + ### 📏 Swift 코딩 규칙 (`docs/agent/swift-coding-rules.md`) - Swift 스타일 가이드 - 에러 처리 패턴 diff --git a/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift b/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift index 8148595..da5b65c 100644 --- a/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift +++ b/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift @@ -10,15 +10,16 @@ import ProjectDescription public extension TargetDependency.SPM { static let asyncMoya = TargetDependency.external(name: "AsyncMoya", condition: .none) static let logMarco = TargetDependency.external(name: "LogMacro", condition: .none) - + static let composableArchitecture = TargetDependency.external(name: "ComposableArchitecture", condition: .none) static let dependencies = TargetDependency.external(name: "Dependencies", condition: .none) static let identifiedCollections = TargetDependency.external(name: "IdentifiedCollections", condition: .none) static let tcaFlow = TargetDependency.external(name: "TCAFlow", condition: .none) static let concurrencyExtras = TargetDependency.external(name: "ConcurrencyExtras", condition: .none) static let sdwebImage = TargetDependency.external(name: "SDWebImageSwiftUI", condition: .none) + static let kingfisher = TargetDependency.external(name: "Kingfisher", condition: .none) static let weaveDI = TargetDependency.external(name: "WeaveDI", condition: .none) - + static let googleSignIn = TargetDependency.external(name: "GoogleSignIn", condition: .none) static let appAuth: TargetDependency = .external(name: "AppAuth") static let firebaseCrashlytics = TargetDependency.external(name: "FirebaseCrashlytics", condition: .none) @@ -27,5 +28,4 @@ public extension TargetDependency.SPM { static let googleMobileAds = TargetDependency.external(name: "GoogleMobileAds", condition: .none) static let mixpanel = TargetDependency.external(name: "Mixpanel", condition: .none) static let mixpanelSessionReplay = TargetDependency.external(name: "MixpanelSessionReplay", condition: .none) - } diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index 9ffbbd1..36fed6e 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -26,6 +26,8 @@ public extension ModulePath { public static let name: String = "Presentation" case Auth + case MainTab + case Home } } diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index 3e879f6..d47613f 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -35,6 +35,7 @@ public final class AppDIManager: Sendable { // 🏗️ Repository 계층 (Clean Architecture + PFW) .register { AuthRepositoryImpl() as AuthInterface } + .register { HomeRepositoryImpl() as HomeInterface } // .register { ProfileRepositoryImpl() as ProfileInterface } // .register { AppUpdateRepositoryImpl() as AppUpdateInterface } diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index 2907501..1e1cd6e 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -18,6 +18,7 @@ public struct AppReducer: Sendable { public enum State { case splash(SplashFeature.State) case auth(AuthCoordinator.State) + case mainTab(MainTabCoordinator.State) public init() { @@ -29,6 +30,7 @@ public struct AppReducer: Sendable { switch self { case .splash: return "splash" case .auth: return "auth" + case .mainTab: return "mainTab" } } } @@ -53,8 +55,7 @@ public struct AppReducer: Sendable { //MARK: - 앱내에서 사용하는 액션 public enum InnerAction: Equatable { case completeAuthTransition - case completeStaffTransition - case completeMemberTransition + case completeMainTabTransition } //MARK: - 비동기 처리 액션 @@ -73,6 +74,7 @@ public struct AppReducer: Sendable { public enum ScopeAction { case splash(SplashFeature.Action) case auth(AuthCoordinator.Action) + case mainTab(MainTabCoordinator.Action) } @@ -142,6 +144,9 @@ public struct AppReducer: Sendable { .ifCaseLet(\.auth, action: \.scope.auth) { AuthCoordinator() } + .ifCaseLet(\.mainTab, action: \.scope.mainTab) { + MainTabCoordinator() + } } private func handleViewAction( @@ -155,7 +160,7 @@ public struct AppReducer: Sendable { } case .presentRoot: - return startTransition(.completeMemberTransition) + return startTransition(.completeMainTabTransition) case .presentAuth: return startTransition(.completeAuthTransition) @@ -187,13 +192,10 @@ public struct AppReducer: Sendable { state = .auth(.init()) return .none - case .completeStaffTransition: -// state = .staff(.init()) + case .completeMainTabTransition: + state = .mainTab(.init()) return .none - case .completeMemberTransition: -// state = .member(.init()) - return .none } } @@ -205,100 +207,54 @@ public struct AppReducer: Sendable { } // 🎯 PFW 철학: 단순하고 조합 가능한 상태 검증 -// private func isValidAction(_ action: ScopeAction, for state: State) -> Bool { -// switch (action, state) { -// case (.staff, .staff), (.member, .member), (.auth, .auth), (.splash, .splash): -// return true -// default: -// return false -// } -// } -// + private func isValidAction(_ action: ScopeAction, for state: State) -> Bool { + switch (action, state) { + case (.auth, .auth), (.splash, .splash), (.mainTab, .mainTab): + return true + default: + return false + } + } + private func handleScopeAction( state: inout State, action: ScopeAction ) -> Effect { + // 🎯 PFW 철학: 타입 안전한 상태 매칭 + guard isValidAction(action, for: state) else { + return .none + } + + // 🎯 PFW 패턴: 단순한 네비게이션 처리 + return handleScopeNavigation(action: action) + } + + // 🎯 PFW 패턴: 네비게이션 로직 분리 + private func handleScopeNavigation(action: ScopeAction) -> Effect { switch action { case .splash(.view(.onAppear)): + return .none + + case .splash(.delegate(.presentAuth)): return .run { send in try await clock.sleep(for: .seconds(3)) try await send(.view(.presentAuth)) } - + + case .splash(.delegate(.presentMainTab)): + return .run { send in + try await clock.sleep(for: .seconds(3)) + try await send(.view(.presentRoot)) + } + + case .auth(.navigation(.presentMainTab)): + return .send(.view(.presentRoot)) + default: return .none } - - // 🎯 PFW 철학: 타입 안전한 상태 매칭 -// switch (action, state) { -// case (.staff, .staff), (.member, .member), -// (.auth, .auth), (.splash, .splash): -// // ✅ 올바른 상태 매칭 - 네비게이션 처리 진행 -// break -// -// case (.staff, _), (.member, _), (.auth, _), (.splash, _): -// // ✅ 상태 불일치 - PFW 철학: 조용히 무시 -// return .none -// } - - // 🎯 PFW 패턴: 단순한 네비게이션 처리 -// return handleScopeNavigation(action: action) } - // 🎯 PFW 패턴: 네비게이션 로직 분리 -// private func handleScopeNavigation(action: ScopeAction) -> Effect { -// switch action { -// case .splash(.navigation(.presentLogin)): -// return .run { send in -// try await clock.sleep(for: .seconds(0.5)) -// await send(.view(.presentAuth)) -// } -// .cancellable(id: CancelID.transition, cancelInFlight: true) -// -// case .splash(.navigation(.presentStaff)): -// return .send(.view(.presentStaff)) -// -// case .splash(.navigation(.presentMember)): -// return .send(.view(.presentMember)) -// -// case .auth(.navigation(.presentStaff)): -// return .send(.view(.presentStaff)) -// -// case .auth(.navigation(.presentMember)): -// return .send(.view(.presentMember)) -// -// case .staff(.navigation(.presentLogin)): -// return .send(.view(.presentAuth)) -// -// case .staff(.navigation(.presentMember)): -// return .send(.view(.presentMember)) -// -// case .member(.navigation(.presentLogin)): -// return .send(.view(.presentAuth)) -// -// case .member(.navigation(.presentStaff)): -// return .send(.view(.presentStaff)) -// -// default: -// return .none -// } -// } - - // 🎯 PFW 패턴: 간결한 상태 검증 -// private func isStaffState(_ state: State) -> Bool { -// guard case .staff = state else { return false } -// return true -// } -// -// private func isMemberState(_ state: State) -> Bool { -// guard case .member = state else { return false } -// return true -// } -// -// private func isAuthState(_ state: State) -> Bool { -// guard case .auth = state else { return false } -// return true -// } private func isSplashState(_ state: State) -> Bool { guard case .splash = state else { return false } diff --git a/Projects/App/Sources/View/AppView.swift b/Projects/App/Sources/View/AppView.swift index cc1d8de..5bbf38a 100644 --- a/Projects/App/Sources/View/AppView.swift +++ b/Projects/App/Sources/View/AppView.swift @@ -36,7 +36,17 @@ struct AppView: View { insertion: .move(edge: .trailing), removal: .move(edge: .leading) )) - + + } + + case .mainTab: + if let store = store.scope(state: \.mainTab, action: \.scope.mainTab) { + MainTabView(store: store) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + )) + } } } @@ -61,4 +71,3 @@ struct AppView: View { }) ) } - diff --git a/Projects/Data/API/Sources/Base/PieckeDomain.swift b/Projects/Data/API/Sources/Base/PieckeDomain.swift index b2487d4..74a9302 100644 --- a/Projects/Data/API/Sources/Base/PieckeDomain.swift +++ b/Projects/Data/API/Sources/Base/PieckeDomain.swift @@ -12,21 +12,22 @@ import AsyncMoya public enum PieckeDomain { case auth case profile - + case home } extension PieckeDomain: DomainType { public var baseURLString: String { return BaseAPI.base.apiDescription } - + public var url: String { switch self { case .auth: return "api/v1/auth/" case .profile: - return "api/v1/me/" - + return"api/v1/me/" + case .home: + return"api/v1/home" } } } diff --git a/Projects/Data/API/Sources/Home/HomeAPI.swift b/Projects/Data/API/Sources/Home/HomeAPI.swift new file mode 100644 index 0000000..bf3ed3a --- /dev/null +++ b/Projects/Data/API/Sources/Home/HomeAPI.swift @@ -0,0 +1,19 @@ +// +// HomeAPI.swift +// API +// +// Created by Wonji Suh on 5/16/26. +// + +import Foundation + +public enum HomeAPI: String, CaseIterable { + case home + + public var description: String { + switch self { + case .home: + return "" + } + } +} diff --git a/Projects/Data/Model/Sources/Home/DTO/HomeDataDTO.swift b/Projects/Data/Model/Sources/Home/DTO/HomeDataDTO.swift new file mode 100644 index 0000000..25d43f3 --- /dev/null +++ b/Projects/Data/Model/Sources/Home/DTO/HomeDataDTO.swift @@ -0,0 +1,113 @@ +// +// HomeDataDTO.swift +// Model +// +// Created by Wonji Suh on 5/16/26. +// + +import Foundation + +/// `GET /api/v1/home` 의 `data` 필드 페이로드. +public struct HomeDataDTO: Decodable { + public let newNotice: Bool + public let editorPicks: [EditorPickDTO] + public let trendingBattles: [TrendingBattleDTO] + public let bestBattles: [BestBattleDTO] + public let todayQuizzes: [TodayQuizDTO] + public let todayVotes: [TodayVoteDTO] + public let newBattles: [NewBattleDTO] +} + +public struct TagDTO: Decodable, Equatable { + public let tagId: Int + public let name: String + public let type: String +} + +public struct EditorPickDTO: Decodable, Identifiable { + public let battleId: Int + public let thumbnailUrl: String? + public let optionATitle: String + public let optionBTitle: String + public let title: String + public let summary: String + public let tags: [TagDTO] + public let viewCount: Int + + public var id: Int { battleId } +} + +public struct TrendingBattleDTO: Decodable, Identifiable { + public let battleId: Int + public let thumbnailUrl: String? + public let title: String + public let tags: [TagDTO] + public let audioDuration: Int + public let viewCount: Int + + public var id: Int { battleId } +} + +public struct BestBattleDTO: Decodable, Identifiable { + public let battleId: Int + public let philosopherA: String + public let philosopherB: String + public let title: String + public let tags: [TagDTO] + public let audioDuration: Int + public let viewCount: Int + + public var id: Int { battleId } +} + +public struct TodayQuizDTO: Decodable, Identifiable { + public let battleId: Int + public let title: String + public let summary: String + public let participantsCount: Int + public let itemA: String + public let itemADesc: String + public let isCorrectA: Bool + public let itemB: String + public let itemBDesc: String + public let isCorrectB: Bool + + public var id: Int { battleId } +} + +public struct TodayVoteDTO: Decodable, Identifiable { + public let battleId: Int + public let titlePrefix: String + public let titleSuffix: String + public let summary: String + public let participantsCount: Int + public let options: [VoteOptionDTO] + + public var id: Int { battleId } +} + +public struct VoteOptionDTO: Decodable, Equatable { + public let label: String + public let title: String +} + +public struct NewBattleDTO: Decodable, Identifiable { + public let battleId: Int + public let thumbnailUrl: String? + public let title: String + public let summary: String + public let philosopherA: String + public let optionATitle: String + public let philosopherAImageUrl: String? + public let philosopherB: String + public let optionBTitle: String + public let philosopherBImageUrl: String? + public let tags: [TagDTO] + public let audioDuration: Int + public let viewCount: Int + + public var id: Int { battleId } +} + +/// `GET /api/v1/home` 응답 타입 별칭. +public typealias HomeResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Home/Mapper/HomeDataDTO+.swift b/Projects/Data/Model/Sources/Home/Mapper/HomeDataDTO+.swift new file mode 100644 index 0000000..44fd283 --- /dev/null +++ b/Projects/Data/Model/Sources/Home/Mapper/HomeDataDTO+.swift @@ -0,0 +1,132 @@ +// +// HomeDataDTO+.swift +// Model +// +// Created by Wonji Suh on 5/16/26. +// + +import Entity +import Foundation + +public extension TagDTO { + func toDomain() -> BattleTag { + BattleTag(tagId: tagId, name: name, type: TagType(rawValue: type)) + } +} + +public extension EditorPickDTO { + func toDomain(position: Int, total: Int) -> HeroBattle { + HeroBattle( + battleId: battleId, + badge: "EDITOR PICK", + position: position, + total: total, + thumbnailURL: thumbnailUrl.flatMap(URL.init(string:)), + optionA: optionATitle, + optionB: optionBTitle, + title: title, + summary: summary, + tags: tags.map { $0.toDomain() }, + viewCount: viewCount + ) + } +} + +public extension TrendingBattleDTO { + func toDomain() -> HotBattle { + HotBattle( + battleId: battleId, + thumbnailURL: thumbnailUrl.flatMap(URL.init(string:)), + title: title, + tags: tags.map { $0.toDomain() }, + audioDuration: audioDuration, + viewCount: viewCount + ) + } +} + +public extension BestBattleDTO { + func toDomain(rank: Int) -> BestBattle { + BestBattle( + battleId: battleId, + rank: rank, + philosopherA: philosopherA, + philosopherB: philosopherB, + title: title, + tags: tags.map { $0.toDomain() }, + audioDuration: audioDuration, + viewCount: viewCount + ) + } +} + +public extension TodayQuizDTO { + func toDomain() -> QuizQuestion { + QuizQuestion( + battleId: battleId, + title: title, + summary: summary, + participantCount: participantsCount, + itemA: itemA, itemADesc: itemADesc, isCorrectA: isCorrectA, + itemB: itemB, itemBDesc: itemBDesc, isCorrectB: isCorrectB + ) + } +} + +public extension VoteOptionDTO { + func toDomain() -> VoteOption { + VoteOption(label: label, title: title) + } +} + +public extension TodayVoteDTO { + func toDomain() -> VoteQuestion { + VoteQuestion( + battleId: battleId, + titlePrefix: titlePrefix, + titleSuffix: titleSuffix, + summary: summary, + participantCount: participantsCount, + options: options.map { $0.toDomain() } + ) + } +} + +public extension NewBattleDTO { + func toDomain() -> NewBattle { + NewBattle( + battleId: battleId, + thumbnailURL: thumbnailUrl.flatMap(URL.init(string:)), + title: title, + summary: summary, + philosopherA: philosopherA, + optionATitle: optionATitle, + philosopherAImageURL: philosopherAImageUrl.flatMap(URL.init(string:)), + philosopherB: philosopherB, + optionBTitle: optionBTitle, + philosopherBImageURL: philosopherBImageUrl.flatMap(URL.init(string:)), + tags: tags.map { $0.toDomain() }, + audioDuration: audioDuration, + viewCount: viewCount + ) + } +} + +public extension HomeDataDTO { + /// 전체 DTO 를 화면용 도메인 객체 6 묶음으로 변환. + func toDomain() -> HomeBundle { + HomeBundle( + newNotice: newNotice, + heroes: editorPicks.enumerated().map { idx, dto in + dto.toDomain(position: idx + 1, total: editorPicks.count) + }, + hotBattles: trendingBattles.map { $0.toDomain() }, + bestBattles: bestBattles.enumerated().map { idx, dto in + dto.toDomain(rank: idx + 1) + }, + quizzes: todayQuizzes.map { $0.toDomain() }, + votes: todayVotes.map { $0.toDomain() }, + newBattles: newBattles.map { $0.toDomain() } + ) + } +} diff --git a/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift index 3789c2c..53b1fde 100644 --- a/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift @@ -26,11 +26,11 @@ public final class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { private let authProvider: MoyaProvider public init( - provider: MoyaProvider? = nil, - authProvider: MoyaProvider? = nil + provider: MoyaProvider = MoyaProvider.default, + authProvider: MoyaProvider = MoyaProvider.authorized ) { - self.provider = provider ?? MoyaProviderPool.shared.defaultProvider(for: AuthService.self) - self.authProvider = authProvider ?? MoyaProviderPool.shared.authorizedProvider(for: AuthService.self) + self.provider = provider + self.authProvider = authProvider } // MARK: - 로그인 diff --git a/Projects/Data/Repository/Sources/Home/HomeRepositoryImpl.swift b/Projects/Data/Repository/Sources/Home/HomeRepositoryImpl.swift new file mode 100644 index 0000000..e9dc6e1 --- /dev/null +++ b/Projects/Data/Repository/Sources/Home/HomeRepositoryImpl.swift @@ -0,0 +1,40 @@ +// +// HomeRepositoryImpl.swift +// Repository +// +// Created by Wonji Suh on 5/16/26. +// + +import Foundation + +import DomainInterface +import Entity +import Model +import Service + +import LogMacro +import Moya + +@preconcurrency import AsyncMoya + +public final class HomeRepositoryImpl: HomeInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } + + public func fetchHome() async throws -> HomeBundle { + let dto: HomeResponseDTO = try await provider.request(.home) + + guard let data = dto.data else { + let message = dto.error?.message ?? "홈 데이터 응답이 비어 있습니다" + Log.error("[HomeRepositoryImpl] empty home payload: \(message)") + throw AuthError.backendError(message) + } + + return data.toDomain() + } +} diff --git a/Projects/Data/Repository/Sources/OAuth/Kakao/KakaoOAuthRepository.swift b/Projects/Data/Repository/Sources/OAuth/Kakao/KakaoOAuthRepository.swift index 8031fee..cdf45c4 100644 --- a/Projects/Data/Repository/Sources/OAuth/Kakao/KakaoOAuthRepository.swift +++ b/Projects/Data/Repository/Sources/OAuth/Kakao/KakaoOAuthRepository.swift @@ -43,7 +43,8 @@ public final class KakaoOAuthRepository: NSObject, KakaoOAuthInterface { let code = try await OAuthWebPresenter.present( authorizeURL: authorizeURL, redirectHost: redirectHost, - redirectPath: redirectPath + redirectPath: redirectPath, + usesEphemeralSession: true ) Log.debug("kakao authorizationCode", code) @@ -64,7 +65,6 @@ private extension KakaoOAuthRepository { URLQueryItem(name: "response_type", value: "code"), URLQueryItem(name: "client_id", value: clientID), URLQueryItem(name: "redirect_uri", value: serverRedirectUri), - URLQueryItem(name: "prompt", value: "login"), ] guard let url = components?.url else { throw AuthError.invalidCredential("Kakao authorize URL 생성 실패") diff --git a/Projects/Data/Repository/Sources/OAuth/Web/OAuthWebViewController.swift b/Projects/Data/Repository/Sources/OAuth/Web/OAuthWebViewController.swift index 8e4c6e0..4e2b38c 100644 --- a/Projects/Data/Repository/Sources/OAuth/Web/OAuthWebViewController.swift +++ b/Projects/Data/Repository/Sources/OAuth/Web/OAuthWebViewController.swift @@ -34,6 +34,7 @@ final class OAuthWebViewController: UIViewController { private let redirectHost: String private let redirectPath: String private let customUserAgent: String? + private let usesEphemeralSession: Bool private let onComplete: (Result) -> Void private let backgroundTapSubject = PassthroughSubject() private let sheetDragSubject = PassthroughSubject() @@ -44,6 +45,11 @@ final class OAuthWebViewController: UIViewController { private lazy var webView: WKWebView = { let config = WKWebViewConfiguration() + // 카카오처럼 기존 쿠키로 자동 로그인되어 폼이 안 보이고 바로 redirect 되는 흐름을 막아야 할 때만 + // ephemeral 세션을 사용한다. (구글은 자체 SSO 흐름이 있어 persistent 유지) + if usesEphemeralSession { + config.websiteDataStore = .nonPersistent() + } let view = WKWebView(frame: .zero, configuration: config) view.navigationDelegate = self view.customUserAgent = customUserAgent @@ -58,12 +64,14 @@ final class OAuthWebViewController: UIViewController { redirectHost: String, redirectPath: String, customUserAgent: String? = nil, + usesEphemeralSession: Bool = false, onComplete: @escaping (Result) -> Void ) { self.authorizeURL = authorizeURL self.redirectHost = redirectHost self.redirectPath = redirectPath self.customUserAgent = customUserAgent + self.usesEphemeralSession = usesEphemeralSession self.onComplete = onComplete super.init(nibName: nil, bundle: nil) modalPresentationStyle = .overFullScreen @@ -239,6 +247,11 @@ final class OAuthWebViewController: UIViewController { guard !didFinish else { return } didFinish = true let completion = onComplete + + // 콜백 가로채는 순간 webView 가 흰 빈 페이지로 잠깐 비치는 걸 막기 위해 + // dismiss 전에 sheetContainer 의 배경 위에 webView 를 가린다. + webView.isHidden = true + dismiss(animated: animated) { completion(result) } @@ -343,7 +356,8 @@ enum OAuthWebPresenter { authorizeURL: URL, redirectHost: String, redirectPath: String, - customUserAgent: String? = nil + customUserAgent: String? = nil, + usesEphemeralSession: Bool = false ) async throws -> String { try await withCheckedThrowingContinuation { continuation in let controller = OAuthWebViewController( @@ -351,6 +365,7 @@ enum OAuthWebPresenter { redirectHost: redirectHost, redirectPath: redirectPath, customUserAgent: customUserAgent, + usesEphemeralSession: usesEphemeralSession, onComplete: { result in continuation.resume(with: result) } diff --git a/Projects/Data/Service/Project.swift b/Projects/Data/Service/Project.swift index 432729e..2ed4c4b 100644 --- a/Projects/Data/Service/Project.swift +++ b/Projects/Data/Service/Project.swift @@ -13,7 +13,6 @@ let project = Project.makeModule( .Data(implements: .API), .Network(implements: .Foundations), .SPM.asyncMoya, - .Data(implements: .Model) ], sources: ["Sources/**"], hasTests: false diff --git a/Projects/Data/Service/Sources/Auth/AuthService.swift b/Projects/Data/Service/Sources/Auth/AuthService.swift index a5d05bc..ba5d628 100644 --- a/Projects/Data/Service/Sources/Auth/AuthService.swift +++ b/Projects/Data/Service/Sources/Auth/AuthService.swift @@ -62,8 +62,8 @@ extension AuthService: BaseTargetType { switch self { case let .login(_, body): body.toDictionary - case let .refresh(refreshToken): - refreshToken.toDictionary(key: "refreshToken") + case .refresh: + nil case let .withdraw(token): token.toDictionary(key: "token") case .logout: @@ -73,10 +73,14 @@ extension AuthService: BaseTargetType { public var headers: [String: String]? { switch self { + case let .refresh(refreshToken): + var headers = APIHeader.notAccessTokenHeader + headers[APIHeader.refreshToken] = refreshToken + return headers case .withdraw, .logout: - APIHeader.baseHeader + return APIHeader.baseHeader default: - APIHeader.notAccessTokenHeader + return APIHeader.notAccessTokenHeader } } } diff --git a/Projects/Data/Service/Sources/Home/HomeService.swift b/Projects/Data/Service/Sources/Home/HomeService.swift new file mode 100644 index 0000000..c34ac4f --- /dev/null +++ b/Projects/Data/Service/Sources/Home/HomeService.swift @@ -0,0 +1,45 @@ +// +// HomeService.swift +// Service +// +// Created by Wonji Suh on 5/16/26. +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum HomeService { + case home +} + +extension HomeService: BaseTargetType { + public typealias Domain = PieckeDomain + + public var domain: PieckeDomain { .home } + + public var urlPath: String { + switch self { + case .home: + HomeAPI.home.description + } + } + + public var error: [Int: AsyncMoya.NetworkError]? { nil } + + public var method: Moya.Method { + switch self { + case .home: + .get + } + } + + public var parameters: [String: Any]? { nil } + + public var headers: [String: String]? { + APIHeader.baseHeader // 인증 헤더 포함 (액세스 토큰) + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Home/DefaultHomeRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Home/DefaultHomeRepositoryImpl.swift new file mode 100644 index 0000000..d163e0a --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Home/DefaultHomeRepositoryImpl.swift @@ -0,0 +1,18 @@ +// +// DefaultHomeRepositoryImpl.swift +// DomainInterface +// +// Created by Wonji Suh on 5/16/26. +// + +import Entity +import Foundation + +/// Home Repository 기본 구현체 — 미주입 환경에서 mock 번들을 반환한다. +public final class DefaultHomeRepositoryImpl: HomeInterface, @unchecked Sendable { + public init() {} + + public func fetchHome() async throws -> HomeBundle { + .mock + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Home/HomeInterface.swift b/Projects/Domain/DomainInterface/Sources/Home/HomeInterface.swift new file mode 100644 index 0000000..27e1a84 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Home/HomeInterface.swift @@ -0,0 +1,36 @@ +// +// HomeInterface.swift +// DomainInterface +// +// Created by Wonji Suh on 5/16/26. +// + +import Entity +import Foundation +import WeaveDI + +/// 홈 화면 데이터 조회 Repository 인터페이스. +public protocol HomeInterface: Sendable { + func fetchHome() async throws -> HomeBundle +} + +// MARK: - Dependency + +public struct HomeRepositoryDependency: DependencyKey { + public static var liveValue: HomeInterface { + UnifiedDI.resolve(HomeInterface.self) ?? DefaultHomeRepositoryImpl() + } + + public static var testValue: HomeInterface { + UnifiedDI.resolve(HomeInterface.self) ?? DefaultHomeRepositoryImpl() + } + + public static var previewValue: HomeInterface = liveValue +} + +public extension DependencyValues { + var homeRepository: HomeInterface { + get { self[HomeRepositoryDependency.self] } + set { self[HomeRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Home/MockHomeRepository.swift b/Projects/Domain/DomainInterface/Sources/Home/MockHomeRepository.swift new file mode 100644 index 0000000..4b648d7 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Home/MockHomeRepository.swift @@ -0,0 +1,42 @@ +// +// MockHomeRepository.swift +// DomainInterface +// +// Created by Wonji Suh on 5/16/26. +// + +import Entity +import Foundation + +public final class MockHomeRepository: HomeInterface, @unchecked Sendable { + public enum Configuration { + case success(HomeBundle) + case failure(Error) + case empty + } + + private let configuration: Configuration + public private(set) var fetchCallCount = 0 + + public init(configuration: Configuration = .success(.mock)) { + self.configuration = configuration + } + + public func fetchHome() async throws -> HomeBundle { + fetchCallCount += 1 + try await Task.sleep(for: .milliseconds(10)) + + switch configuration { + case let .success(bundle): + return bundle + case let .failure(error): + throw error + case .empty: + return HomeBundle( + newNotice: false, + heroes: [], hotBattles: [], bestBattles: [], + quizzes: [], votes: [], newBattles: [] + ) + } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/BattleTag.swift b/Projects/Domain/Entity/Sources/Home/BattleTag.swift new file mode 100644 index 0000000..005de43 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/BattleTag.swift @@ -0,0 +1,44 @@ +// +// BattleTag.swift +// Entity +// +// Created by Wonji Suh on 5/16/26. +// + +import Foundation + +/// 홈 API 의 `tags` 항목 (id + 이름 + 분류 타입). +public struct BattleTag: Equatable, Identifiable, Hashable { + public let tagId: Int + public let name: String + public let type: TagType + + public var id: Int { tagId } + + public init(tagId: Int, name: String, type: TagType) { + self.tagId = tagId + self.name = name + self.type = type + } +} + +public enum TagType: String, Equatable, Hashable, Decodable { + case philosopher = "PHILOSOPHER" + case category = "CATEGORY" + case era = "ERA" + case unknown + + public init(rawValue: String) { + switch rawValue { + case "PHILOSOPHER": self = .philosopher + case "CATEGORY": self = .category + case "ERA": self = .era + 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/BestBattle.swift b/Projects/Domain/Entity/Sources/Home/BestBattle.swift new file mode 100644 index 0000000..48a3122 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/BestBattle.swift @@ -0,0 +1,80 @@ +// +// BestBattle.swift +// Entity +// +// Created by Wonji Suh on 5/15/26. +// + +import Foundation + +/// "Best 배틀" 랭킹 카드 — API 의 bestBattles. +public struct BestBattle: Equatable, Identifiable { + public let battleId: Int + public let rank: Int + public let philosopherA: String + public let philosopherB: String + public let title: String + public let tags: [BattleTag] + public let audioDuration: Int + public let viewCount: Int + + public var id: Int { battleId } + + public init( + battleId: Int, + rank: Int, + philosopherA: String, + philosopherB: String, + title: String, + tags: [BattleTag] = [], + audioDuration: Int, + viewCount: Int + ) { + self.battleId = battleId + self.rank = rank + self.philosopherA = philosopherA + self.philosopherB = philosopherB + self.title = title + self.tags = tags + self.audioDuration = audioDuration + self.viewCount = viewCount + } + + public var pair: String { "\(philosopherA) VS \(philosopherB)" } + public var durationMinutes: Int { max(1, audioDuration / 60) } +} + +public extension BestBattle { + static let mocks: [BestBattle] = [ + .init( + battleId: 21, rank: 1, + philosopherA: "맹자", philosopherB: "순자", + title: "인간은 본래 선한가, 악한가?", + tags: [ + .init(tagId: 401, name: "#철학", type: .category), + .init(tagId: 402, name: "#인문", type: .category), + ], + audioDuration: 8 * 60, viewCount: 1340 + ), + .init( + battleId: 22, rank: 2, + philosopherA: "칸트", philosopherB: "톨스토이", + title: "죽음을 앞둔 사람에게 진실을 말해야 하는가?", + tags: [ + .init(tagId: 403, name: "#철학", type: .category), + .init(tagId: 404, name: "#인문", type: .category), + ], + audioDuration: 8 * 60, viewCount: 1340 + ), + .init( + battleId: 23, rank: 3, + philosopherA: "튜링", philosopherB: "설", + title: "AI와 사랑에 빠지는 것, 진짜 사랑인가?", + tags: [ + .init(tagId: 405, name: "#철학", type: .category), + .init(tagId: 406, name: "#인문", type: .category), + ], + audioDuration: 8 * 60, viewCount: 1340 + ), + ] +} diff --git a/Projects/Domain/Entity/Sources/Home/HeroBattle.swift b/Projects/Domain/Entity/Sources/Home/HeroBattle.swift new file mode 100644 index 0000000..bcef747 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/HeroBattle.swift @@ -0,0 +1,97 @@ +// +// HeroBattle.swift +// Entity +// +// Created by Wonji Suh on 5/15/26. +// + +import Foundation + +/// 홈 화면 최상단 "EDITOR PICK" 카드 — API 의 editorPicks. +public struct HeroBattle: Equatable, Identifiable { + public let battleId: Int + public let badge: String + public let position: Int + public let total: Int + public let thumbnailURL: URL? + public let optionA: String + public let optionB: String + public let title: String + public let summary: String + public let tags: [BattleTag] + public let viewCount: Int + + public var id: Int { battleId } + + public init( + battleId: Int, + badge: String = "EDITOR PICK", + position: Int, + total: Int, + thumbnailURL: URL? = nil, + optionA: String, + optionB: String, + title: String, + summary: String, + tags: [BattleTag] = [], + viewCount: Int + ) { + self.battleId = battleId + self.badge = badge + self.position = position + self.total = total + self.thumbnailURL = thumbnailURL + self.optionA = optionA + self.optionB = optionB + self.title = title + self.summary = summary + self.tags = tags + self.viewCount = viewCount + } +} + +public extension HeroBattle { + static let mock = HeroBattle( + battleId: 1, + position: 1, + total: 10, + optionA: "예술이다", + optionB: "쓰레기다", + title: "뒤샹의 변기, 예술인가 도발인가", + summary: "뒤샹의 변기 〈샘〉은 \"무엇이 예술인가\"를 묻는 작품이다.", + tags: [ + .init(tagId: 1, name: "#예술", type: .category), + .init(tagId: 2, name: "#현대미술", type: .category), + ], + viewCount: 847 + ) + + static let mocks: [HeroBattle] = (1 ... 10).map { idx in + let titles = [ + "뒤샹의 변기, 예술인가 도발인가", + "AI가 만든 그림도 예술인가", + "사후세계는 존재하는가", + "노키즈존, 차별인가 자유인가", + "안락사를 허용해야 하는가", + "표현의 자유와 혐오 표현", + "인간은 본래 선한가", + "결과가 수단을 정당화하는가", + "기억이 곧 자아인가", + "도덕은 절대적인가 상대적인가", + ] + return HeroBattle( + battleId: idx, + position: idx, + total: 10, + optionA: idx % 2 == 0 ? "그렇다" : "예술이다", + optionB: idx % 2 == 0 ? "아니다" : "쓰레기다", + title: titles[idx - 1], + summary: "지금 가장 뜨거운 토론 — 당신의 입장을 골라보세요.", + tags: [ + .init(tagId: 100 + idx, name: "#철학", type: .category), + .init(tagId: 200 + idx, name: "#오늘의배틀", type: .category), + ], + viewCount: 847 + idx * 53 + ) + } +} diff --git a/Projects/Domain/Entity/Sources/Home/HomeBundle.swift b/Projects/Domain/Entity/Sources/Home/HomeBundle.swift new file mode 100644 index 0000000..eb47860 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/HomeBundle.swift @@ -0,0 +1,61 @@ +// +// HomeBundle.swift +// Entity +// +// Created by Wonji Suh on 5/16/26. +// + +import Foundation + +/// `GET /api/v1/home` 응답을 화면 단위로 묶은 도메인 컨테이너. +public struct HomeBundle: Equatable { + public let newNotice: Bool + public let heroes: [HeroBattle] + public let hotBattles: [HotBattle] + public let bestBattles: [BestBattle] + public let quizzes: [QuizQuestion] + public let votes: [VoteQuestion] + public let newBattles: [NewBattle] + + public init( + newNotice: Bool, + heroes: [HeroBattle], + hotBattles: [HotBattle], + bestBattles: [BestBattle], + quizzes: [QuizQuestion], + votes: [VoteQuestion], + newBattles: [NewBattle] + ) { + self.newNotice = newNotice + self.heroes = heroes + self.hotBattles = hotBattles + self.bestBattles = bestBattles + self.quizzes = quizzes + self.votes = votes + self.newBattles = newBattles + } +} + +public extension HomeBundle { + static let mock = HomeBundle( + newNotice: true, + heroes: HeroBattle.mocks, + hotBattles: HotBattle.mocks, + bestBattles: BestBattle.mocks, + quizzes: [.mock], + votes: [.mock], + newBattles: NewBattle.mocks + ) + + var replacingEmptySectionsWithMocks: HomeBundle { + HomeBundle( + newNotice: newNotice, + heroes: heroes.isEmpty ? HeroBattle.mocks : heroes, + hotBattles: hotBattles.isEmpty ? HotBattle.mocks : hotBattles, + bestBattles: bestBattles.isEmpty ? BestBattle.mocks : bestBattles, + quizzes: quizzes.isEmpty ? [.mock] : quizzes, + votes: votes.isEmpty ? [.mock] : votes, + newBattles: newBattles.isEmpty ? NewBattle.mocks : newBattles + ) + } +} diff --git a/Projects/Domain/Entity/Sources/Home/HotBattle.swift b/Projects/Domain/Entity/Sources/Home/HotBattle.swift new file mode 100644 index 0000000..d75afa5 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/HotBattle.swift @@ -0,0 +1,71 @@ +// +// HotBattle.swift +// Entity +// +// Created by Wonji Suh on 5/15/26. +// + +import Foundation + +/// "지금 뜨는 배틀" 가로 스크롤 카드 — API 의 trendingBattles. +public struct HotBattle: Equatable, Identifiable { + public let battleId: Int + public let thumbnailURL: URL? + public let title: String + public let tags: [BattleTag] + public let audioDuration: Int + public let viewCount: Int + + public var id: Int { battleId } + + public init( + battleId: Int, + thumbnailURL: URL? = nil, + title: String, + tags: [BattleTag] = [], + audioDuration: Int, + viewCount: Int + ) { + self.battleId = battleId + self.thumbnailURL = thumbnailURL + self.title = title + self.tags = tags + self.audioDuration = audioDuration + self.viewCount = viewCount + } + + public var durationMinutes: Int { max(1, audioDuration / 60) } +} + +public extension HotBattle { + static let mocks: [HotBattle] = [ + .init( + battleId: 11, + title: "인간은 본래 선한가, 악한가?", + tags: [.init(tagId: 301, name: "#철학", type: .category)], + audioDuration: 8 * 60, + viewCount: 1340 + ), + .init( + battleId: 12, + title: "안락사 도입, 당신의 입장은?", + tags: [.init(tagId: 302, name: "#역사", type: .category)], + audioDuration: 5 * 60, + viewCount: 1132 + ), + .init( + battleId: 13, + title: "노키즈존, 영업의 자유인가?", + tags: [.init(tagId: 303, name: "#사회", type: .category)], + audioDuration: 5 * 60, + viewCount: 902 + ), + .init( + battleId: 14, + title: "AI는 의식을 가질 수 있는가?", + tags: [.init(tagId: 304, name: "#과학", type: .category)], + audioDuration: 6 * 60, + viewCount: 780 + ), + ] +} diff --git a/Projects/Domain/Entity/Sources/Home/NewBattle.swift b/Projects/Domain/Entity/Sources/Home/NewBattle.swift new file mode 100644 index 0000000..b80cdac --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/NewBattle.swift @@ -0,0 +1,91 @@ +// +// NewBattle.swift +// Entity +// +// Created by Wonji Suh on 5/15/26. +// + +import Foundation + +/// "새로운 배틀" 리스트 아이템 — API 의 newBattles. +public struct NewBattle: Equatable, Identifiable { + public let battleId: Int + public let thumbnailURL: URL? + public let title: String + public let summary: String + public let philosopherA: String + public let optionATitle: String + public let philosopherAImageURL: URL? + public let philosopherB: String + public let optionBTitle: String + public let philosopherBImageURL: URL? + public let tags: [BattleTag] + public let audioDuration: Int + public let viewCount: Int + + public var id: Int { battleId } + + public init( + battleId: Int, + thumbnailURL: URL? = nil, + title: String, + summary: String, + philosopherA: String, + optionATitle: String, + philosopherAImageURL: URL? = nil, + philosopherB: String, + optionBTitle: String, + philosopherBImageURL: URL? = nil, + tags: [BattleTag] = [], + audioDuration: Int, + viewCount: Int + ) { + self.battleId = battleId + self.thumbnailURL = thumbnailURL + self.title = title + self.summary = summary + self.philosopherA = philosopherA + self.optionATitle = optionATitle + self.philosopherAImageURL = philosopherAImageURL + self.philosopherB = philosopherB + self.optionBTitle = optionBTitle + self.philosopherBImageURL = philosopherBImageURL + self.tags = tags + self.audioDuration = audioDuration + self.viewCount = viewCount + } + + public var durationMinutes: Int { max(1, audioDuration / 60) } +} + +public extension NewBattle { + static let mocks: [NewBattle] = [ + .init( + battleId: 51, + title: "인간은 본래 선한가, 악한가?", + summary: "인간 본성의 선악과 문명의 역할에 관한 철학적 대결!", + philosopherA: "순자", optionATitle: "악하다", + philosopherB: "순자", optionBTitle: "악하다", + tags: [.init(tagId: 501, name: "#철학", type: .category)], + audioDuration: 5 * 60, viewCount: 726 + ), + .init( + battleId: 52, + title: "노키즈존: 영업상의 자유인가, 공공장소에서의 차별인가?", + summary: "옆 테이블 아이의 울음소리가 평화로운 휴식시간을 깨뜨린다면?", + philosopherA: "순자", optionATitle: "악하다", + philosopherB: "순자", optionBTitle: "악하다", + tags: [.init(tagId: 502, name: "#사회", type: .category)], + audioDuration: 5 * 60, viewCount: 726 + ), + .init( + battleId: 53, + title: "사후세계는 존재하는가, 인간이 만든 위안인가?", + summary: "죽음은 끝일까요, 아니면 다른 방식의 시작일까요?", + philosopherA: "순자", optionATitle: "악하다", + philosopherB: "순자", optionBTitle: "악하다", + tags: [.init(tagId: 503, name: "#철학", type: .category)], + audioDuration: 5 * 60, viewCount: 726 + ), + ] +} diff --git a/Projects/Domain/Entity/Sources/Home/QuizQuestion.swift b/Projects/Domain/Entity/Sources/Home/QuizQuestion.swift new file mode 100644 index 0000000..650ee0d --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/QuizQuestion.swift @@ -0,0 +1,59 @@ +// +// QuizQuestion.swift +// Entity +// +// Created by Wonji Suh on 5/15/26. +// + +import Foundation + +/// "오늘의 Pické — 퀴즈" 카드 — API 의 todayQuizzes. +public struct QuizQuestion: Equatable, Identifiable { + public let battleId: Int + public let title: String + public let summary: String + public let participantCount: Int + public let itemA: String + public let itemADesc: String + public let isCorrectA: Bool + public let itemB: String + public let itemBDesc: String + public let isCorrectB: Bool + + public var id: Int { battleId } + + public init( + battleId: Int, + title: String, + summary: String, + participantCount: Int, + itemA: String, + itemADesc: String, + isCorrectA: Bool, + itemB: String, + itemBDesc: String, + isCorrectB: Bool + ) { + self.battleId = battleId + self.title = title + self.summary = summary + self.participantCount = participantCount + self.itemA = itemA + self.itemADesc = itemADesc + self.isCorrectA = isCorrectA + self.itemB = itemB + self.itemBDesc = itemBDesc + self.isCorrectB = isCorrectB + } +} + +public extension QuizQuestion { + static let mock = QuizQuestion( + battleId: 31, + title: "AI가 만든 그림도 '예술 작품'으로 인정해야 할까?", + summary: "인간의 창의성 없이 생성된 결과물도 예술로 볼 수 있을까요?\n지금 바로 당신의 입장을 선택하세요", + participantCount: 1340, + itemA: "O 정답", itemADesc: "explanation", isCorrectA: true, + itemB: "X 오답", itemBDesc: "explanation", isCorrectB: false + ) +} diff --git a/Projects/Domain/Entity/Sources/Home/VoteQuestion.swift b/Projects/Domain/Entity/Sources/Home/VoteQuestion.swift new file mode 100644 index 0000000..94520f6 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/VoteQuestion.swift @@ -0,0 +1,68 @@ +// +// VoteQuestion.swift +// Entity +// +// Created by Wonji Suh on 5/15/26. +// + +import Foundation + +/// "오늘의 Pické — 투표" 카드 — API 의 todayVotes. +public struct VoteQuestion: Equatable, Identifiable { + public let battleId: Int + public let titlePrefix: String + public let titleSuffix: String + public let summary: String + public let participantCount: Int + public let options: [VoteOption] + + public var id: Int { battleId } + + public init( + battleId: Int, + titlePrefix: String, + titleSuffix: String, + summary: String, + participantCount: Int, + options: [VoteOption] + ) { + self.battleId = battleId + self.titlePrefix = titlePrefix + self.titleSuffix = titleSuffix + self.summary = summary + self.participantCount = participantCount + self.options = options + } + + // 기존 코드 호환용 + public var prefix: String { titlePrefix } + 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, + titlePrefix: "도덕의 기준은", + titleSuffix: "이다", + summary: "빈칸에 들어갈 가장 적절한 답을 골라주세요", + participantCount: 985, + options: [ + .init(label: "A", title: "결과"), + .init(label: "B", title: "의도"), + .init(label: "C", title: "규칙"), + .init(label: "D", title: "덕"), + ] + ) +} diff --git a/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift b/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift index 8c02f18..e48fcc2 100644 --- a/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift +++ b/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift @@ -12,6 +12,7 @@ public struct APIHeader { public static let contentType = "Content-Type" public static let accessToken = "Authorization" + public static let refreshToken = "X-Refresh-Token" public static let accept = "accept" @Dependency(\.tokenProvider) private static var tokenProvider diff --git a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift index 5178b29..b6e0055 100644 --- a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift +++ b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift @@ -53,7 +53,9 @@ public struct AuthCoordinator { // MARK: - NavigationAction - public enum NavigationAction: Equatable {} + public enum NavigationAction: Equatable { + case presentMainTab + } func handleRoute(state: inout State, action: Action) -> Effect { switch action { @@ -95,9 +97,11 @@ extension AuthCoordinator { // MARK: - 온보딩 완료 → 루트로 (다음 플로우 연결 지점) - case .routeAction(_, action: .onboarding(.delegate(.finished))): - // TODO: 메인 탭으로 전환하는 NavigationAction 발송 - return .none + case .routeAction(id: _, action: .login(.delegate(.presentMainTab))): + return .send(.navigation(.presentMainTab)) + + case .routeAction(_, action: .onboarding(.delegate(.presentMainTab))): + return .send(.navigation(.presentMainTab)) default: return .none @@ -123,7 +127,10 @@ extension AuthCoordinator { state _: inout State, action: NavigationAction ) -> Effect { - switch action {} + switch action { + case .presentMainTab: + return .none + } } private func handleAsyncAction( diff --git a/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift b/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift index d2aedf4..463bf21 100644 --- a/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift +++ b/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift @@ -69,6 +69,7 @@ public struct LoginFeature { public enum DelegateAction: Equatable { /// 로그인이 성공해 토큰을 모두 확보한 시점. 코디네이터에서 다음 화면으로 전환. case presentOnboarding + case presentMainTab } nonisolated enum CancelID: Hashable { @@ -188,7 +189,7 @@ extension LoginFeature { if loginEntity.isNewUser { return .send(.delegate(.presentOnboarding)) } else { - return .send(.delegate(.presentOnboarding)) + return .send(.delegate(.presentMainTab)) } case let .failure(error): @@ -220,6 +221,9 @@ extension LoginFeature { switch action { case .presentOnboarding: return .none + + case .presentMainTab: + return .none } } } diff --git a/Projects/Presentation/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift b/Projects/Presentation/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift index 0079d15..0a6382f 100644 --- a/Projects/Presentation/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift +++ b/Projects/Presentation/Auth/Sources/OnBoarding/Reducer/OnBoardingFeature.swift @@ -106,7 +106,7 @@ public struct OnBoardingFeature { // MARK: - DelegateAction public enum DelegateAction: Equatable { - case finished + case presentMainTab } nonisolated enum CancelID: Hashable {} @@ -142,7 +142,7 @@ extension OnBoardingFeature { switch action { case .primaryButtonTapped: if state.isLastPage { - return .send(.delegate(.finished)) + return .send(.delegate(.presentMainTab)) } state.currentIndex = min(state.currentIndex + 1, OnBoardingFeature.pageCount - 1) return .none @@ -172,7 +172,7 @@ extension OnBoardingFeature { action: DelegateAction ) -> Effect { switch action { - case .finished: + case .presentMainTab: .none } } diff --git a/Projects/Presentation/Home/Project.swift b/Projects/Presentation/Home/Project.swift new file mode 100644 index 0000000..157e580 --- /dev/null +++ b/Projects/Presentation/Home/Project.swift @@ -0,0 +1,20 @@ +import DependencyPackagePlugin +import DependencyPlugin +import Foundation +import ProjectDescription +import ProjectTemplatePlugin + +let project = Project.makeAppModule( + name: "Home", + bundleId: .appBundleID(name: ".Home"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .SPM.logMarco, + .SPM.tcaFlow, + .SPM.kingfisher, + .Domain(implements: .UseCase), + .Shared(implements: .DesignSystem), + ], + sources: ["Sources/**"] +) diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift new file mode 100644 index 0000000..12d2f8a --- /dev/null +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -0,0 +1,87 @@ +// +// HomeCoordinator.swift +// Home +// +// Created by Wonji Suh on 5/15/26. +// + +import Foundation + +import ComposableArchitecture +import TCAFlow + +@FlowCoordinator(screen: "HomeScreen", navigation: true) +public struct HomeCoordinator { + public init() {} + + @ObservableState + public struct State: Equatable { + public var routes: [Route] + + public init() { + routes = [.root(.home(.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 HomeCoordinator { + private func routerAction( + state _: inout State, + action _: IndexedRouterActionOf + ) -> Effect { + .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 + } + } +} + +extension HomeCoordinator { + @Reducer + public enum HomeScreen { + case home(HomeFeature) + } +} + +extension HomeCoordinator.HomeScreen.State: Equatable {} diff --git a/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift new file mode 100644 index 0000000..d95542f --- /dev/null +++ b/Projects/Presentation/Home/Sources/Coordinator/View/HomeCoordinatorView.swift @@ -0,0 +1,30 @@ +// +// HomeCoordinatorView.swift +// Home +// +// Created by Wonji Suh on 5/15/26. +// + +import Foundation + +import SwiftUI + +import ComposableArchitecture +import TCAFlow + +public struct HomeCoordinatorView: 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 .home(homeStore): + HomeView(store: homeStore) + } + } + } +} diff --git a/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift new file mode 100644 index 0000000..4ade8c4 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Main/Reducer/HomeFeature.swift @@ -0,0 +1,163 @@ +// +// HomeFeature.swift +// Home +// +// Created by Wonji Suh on 5/15/26. +// + +import ComposableArchitecture +import DomainInterface +import Entity +import Foundation +import LogMacro + +@Reducer +public struct HomeFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var isLoading: Bool = false + public var hasLoadedHome: Bool = false + public var newNotice: Bool = false + public var heroes: [HeroBattle] = [] + public var heroIndex: Int = 0 + public var hotBattles: [HotBattle] = [] + public var bestBattles: [BestBattle] = [] + public var quizzes: [QuizQuestion] = [] + public var votes: [VoteQuestion] = [] + public var newBattles: [NewBattle] = [] + + public var currentQuiz: QuizQuestion? { quizzes.first } + public var currentVote: VoteQuestion? { votes.first } + + 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 pullToRefresh + case seeMoreTapped(Section) + } + + public enum Section: Equatable { + case hotBattles + case bestBattles + case todayPicke + case newBattles + } + + public enum AsyncAction: Equatable { + case fetchHome + } + + public enum InnerAction: Equatable { + case homeResponse(Result) + } + + public enum DelegateAction: Equatable {} + + nonisolated enum CancelID: Hashable { + case fetchHome + } + + @Dependency(\.homeRepository) private var homeRepository + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + .none + case let .view(viewAction): + handleViewAction(state: &state, action: viewAction) + case let .async(asyncAction): + handleAsyncAction(state: &state, action: asyncAction) + case let .inner(innerAction): + handleInnerAction(state: &state, action: innerAction) + case let .delegate(delegateAction): + handleDelegateAction(state: &state, action: delegateAction) + } + } + } +} + +extension HomeFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + guard !state.hasLoadedHome, !state.isLoading else { return .none } + return .send(.async(.fetchHome)) + + case .pullToRefresh: + guard !state.isLoading else { return .none } + return .send(.async(.fetchHome)) + + case .seeMoreTapped: + return .none + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .fetchHome: + state.isLoading = true + return .run { [repository = homeRepository] send in + let result = await Result { + try await repository.fetchHome() + } + .mapError(AuthError.from) + return await send(.inner(.homeResponse(result))) + } + .cancellable(id: CancelID.fetchHome, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .homeResponse(result): + state.isLoading = false + state.hasLoadedHome = true + switch result { + case let .success(bundle): + let home = bundle.replacingEmptySectionsWithMocks + state.newNotice = home.newNotice + state.heroes = home.heroes + state.heroIndex = 0 + state.hotBattles = home.hotBattles + state.bestBattles = home.bestBattles + state.quizzes = home.quizzes + state.votes = home.votes + state.newBattles = home.newBattles + case let .failure(error): + Log.error("[HomeFeature] fetchHome failed: \(error.localizedDescription)") + } + return .none + } + } + + private func handleDelegateAction( + state _: inout State, + action: DelegateAction + ) -> Effect { + switch action {} + } +} diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift new file mode 100644 index 0000000..efd0e7c --- /dev/null +++ b/Projects/Presentation/Home/Sources/Main/View/Components/BestBattleCardView.swift @@ -0,0 +1,51 @@ +// +// BestBattleCardView.swift +// Home +// +// Created by Wonji Suh on 5/15/26. +// + +import SwiftUI + +import DesignSystem +import Entity + +/// "Best 배틀" 랭킹 카드 (랭크 번호 + 페어 + 카테고리). +struct BestBattleCardView: View { + let battle: BestBattle + + var body: some View { + HStack(alignment: .top, spacing: 16) { + Text("\(battle.rank)") + .pretendardFont(family: .Bold, size: 28) + .foregroundStyle(.primary500) + .frame(width: 28, alignment: .leading) + + VStack(alignment: .leading, spacing: 6) { + Text(battle.pair) + .pretendardFont(family: .SemiBold, size: 11) + .foregroundStyle(.primary500) + Text(battle.title) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.neutral900) + .lineLimit(2) + HStack(spacing: 8) { + ForEach(battle.tags) { tag in + Text(tag.name) + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.neutral300) + } + MetaLabelView(systemImage: "clock", text: "\(battle.durationMinutes)분") + MetaLabelView(systemImage: "eye", text: "\(battle.viewCount.formatted())") + } + } + Spacer(minLength: 0) + } + .padding(.vertical, 16) + .padding(.horizontal, 12) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + ) + } +} diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift new file mode 100644 index 0000000..e6fac5b --- /dev/null +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HeroCarouselView.swift @@ -0,0 +1,150 @@ +// +// HeroCarouselView.swift +// Home +// +// Created by Wonji Suh on 5/15/26. +// + +import SwiftUI + +import DesignSystem +import Entity + +import Kingfisher + +/// 최상단 Editor Pick 캐러셀. 좌우 스와이프 + 3초마다 자동 스크롤, 마지막 뒤엔 처음으로 wrap. +struct HeroCarouselView: View { + let heroes: [HeroBattle] + @Binding var currentIndex: Int + + private static let autoScrollInterval: TimeInterval = 3 + private let timer = Timer.publish(every: autoScrollInterval, on: .main, in: .common).autoconnect() + + var body: some View { + TabView(selection: $currentIndex) { + ForEach(Array(heroes.enumerated()), id: \.element.id) { index, hero in + HeroCardView( + hero: hero, + position: index + 1, + total: heroes.count + ) + .tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(height: 341) // .pen 합: control(53) + thumbnail(167) + subject(121) + .background(Color.neutral800) + .onReceive(timer) { _ in advance() } + } + + private func advance() { + guard !heroes.isEmpty else { return } + withAnimation(.easeInOut(duration: 0.4)) { + currentIndex = (currentIndex + 1) % heroes.count + } + } +} + +/// 캐러셀 내부 단일 hero 카드. +struct HeroCardView: View { + let hero: HeroBattle + let position: Int + let total: Int + + var body: some View { + VStack(spacing: 0) { + controlRow + thumbnail + subject + } + .background(Color.neutral800) + .frame(maxWidth: .infinity) + } + + private var controlRow: some View { + HStack { + Text(hero.badge) + .pretendardFont(family: .SemiBold, size: 11) + .foregroundStyle(.secondary200) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(.primary500, in: RoundedRectangle(cornerRadius: 2)) + + Spacer() + + HStack(spacing: 0) { + Text("\(position)") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.secondary50) + Text("/\(total)") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.secondary50) + .opacity(0.4) + } + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(.neutral500, in: Capsule()) + } + .padding(16) + } + + private var thumbnail: some View { + ZStack { + if let url = hero.thumbnailURL { + KFImage(url) + .resizable() + .scaledToFill() + .frame(height: 167) + .clipped() + Color.black.opacity(0.4) // .pen 의 "#00000066" 오버레이 + } else { + Color.neutral500.opacity(0.4) + } + + HStack(spacing: 24) { + Text(hero.optionA) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.beige100) + ZStack { + Circle() + .stroke(.secondary50.opacity(0.2), lineWidth: 2) + .frame(width: 32, height: 32) + Text("VS") + .pretendardFont(family: .SemiBold, size: 11) + .foregroundStyle(.secondary50) + } + Text(hero.optionB) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.beige100) + } + .opacity(0.85) + } + .frame(height: 167) + .clipped() + } + + private var subject: some View { + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 4) { + Text(hero.title) + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.beige100) + Text(hero.summary) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral200) + .lineLimit(2) + HStack(spacing: 4) { + ForEach(hero.tags) { tag in + Text(tag.name) + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.neutral200) + } + } + .padding(.top, 2) + } + Spacer() + MetaLabelView(systemImage: "eye", text: "\(hero.viewCount)") + } + .padding(20) + } +} diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift new file mode 100644 index 0000000..df5706c --- /dev/null +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HomeHeaderView.swift @@ -0,0 +1,37 @@ +// +// HomeHeaderView.swift +// Home +// +// Created by Wonji Suh on 5/15/26. +// + +import SwiftUI + +import DesignSystem + +/// 홈 화면 최상단 GNB 위 헤더 (PicKé 로고 + 알림 아이콘). +struct HomeHeaderView: View { + let onNotificationTapped: () -> Void + + var body: some View { + HStack { + Image(asset: .appLogo) + .resizable() + .scaledToFit() + .frame(width: 62, height: 39) + + Spacer() + + Button(action: onNotificationTapped) { + Image(asset: .bell) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 8) + .frame(height: 56) + .background(Color.beige50) + } +} diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HomeSectionHeader.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HomeSectionHeader.swift new file mode 100644 index 0000000..b5388dc --- /dev/null +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HomeSectionHeader.swift @@ -0,0 +1,32 @@ +// +// HomeSectionHeader.swift +// Home +// +// Created by Wonji Suh on 5/15/26. +// + +import SwiftUI + +import DesignSystem + +/// "지금 뜨는 배틀 / 더 보기" 같은 섹션 헤더 공통 컴포넌트. +struct HomeSectionHeader: View { + let title: String + let onSeeMoreTapped: () -> Void + + var body: some View { + HStack(spacing: 12) { + Text(title) + .pretendardFont(family: .Bold, size: 18) + .kerning(-0.45) + .foregroundStyle(.neutral900) + Spacer(minLength: 0) + Button(action: onSeeMoreTapped) { + Text("더 보기") + .pretendardFont(family: .Medium, size: 13) + .foregroundStyle(.neutral300) + } + } + .padding(.horizontal, 16) + } +} diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift new file mode 100644 index 0000000..610abcc --- /dev/null +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HotBattleCardView.swift @@ -0,0 +1,60 @@ +// +// HotBattleCardView.swift +// Home +// +// Created by Wonji Suh on 5/15/26. +// + +import SwiftUI + +import DesignSystem +import Entity + +import Kingfisher + +/// "지금 뜨는 배틀" 가로 스크롤 카드 (220 wide). +struct HotBattleCardView: View { + let battle: HotBattle + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + thumbnail + VStack(alignment: .leading, spacing: 6) { + if let tag = battle.tags.first { + Text(tag.name) + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.primary500) + } + Text(battle.title) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.neutral900) + .lineLimit(2) + HStack(spacing: 8) { + MetaLabelView(systemImage: "clock", text: "\(battle.durationMinutes)분") + MetaLabelView(systemImage: "eye", text: "\(battle.viewCount.formatted())") + } + } + } + .padding(12) + .frame(width: 220, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + ) + } + + @ViewBuilder + private var thumbnail: some View { + if let url = battle.thumbnailURL { + KFImage(url) + .resizable() + .scaledToFill() + .frame(width: 196, height: 124) + .clipShape(RoundedRectangle(cornerRadius: 2)) + } else { + RoundedRectangle(cornerRadius: 2) + .fill(.beige500) + .frame(width: 196, height: 124) + } + } +} diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/MetaLabelView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/MetaLabelView.swift new file mode 100644 index 0000000..2e97c7a --- /dev/null +++ b/Projects/Presentation/Home/Sources/Main/View/Components/MetaLabelView.swift @@ -0,0 +1,42 @@ +// +// MetaLabelView.swift +// Home +// +// Created by Wonji Suh on 5/15/26. +// + +import SwiftUI + +import DesignSystem + +/// 시계/눈 같은 메타 정보를 SF Symbol + 12pt 텍스트로 표시. +struct MetaLabelView: View { + let systemImage: String + let text: String + + var body: some View { + HStack(spacing: 2) { + Image(systemName: systemImage) + .resizable().scaledToFit() + .frame(width: 12, height: 12) + .foregroundStyle(.neutral300) + Text(text) + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.neutral300) + } + } +} + +/// 작은 알약형 태그 뱃지 (예: "퀴즈", "투표"). +struct TagBadgeView: View { + let text: String + + var body: some View { + Text(text) + .pretendardFont(family: .SemiBold, size: 11) + .foregroundStyle(.primary500) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(.primary50, in: RoundedRectangle(cornerRadius: 2)) + } +} diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift new file mode 100644 index 0000000..0c9e5eb --- /dev/null +++ b/Projects/Presentation/Home/Sources/Main/View/Components/NewBattleCardView.swift @@ -0,0 +1,118 @@ +// +// NewBattleCardView.swift +// Home +// +// Created by Wonji Suh on 5/15/26. +// + +import SwiftUI + +import DesignSystem +import Entity + +import Kingfisher + +/// "새로운 배틀" 리스트 카드 (제목 + VS 아바타 두 개). +struct NewBattleCardView: View { + let battle: NewBattle + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + headerRow + titleBlock + versusRow + } + .padding(12) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + ) + } + + private var headerRow: some View { + HStack { + if let tag = battle.tags.first { + Text(tag.name) + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.primary500) + } + Spacer() + MetaLabelView(systemImage: "clock", text: "\(battle.durationMinutes)분") + MetaLabelView(systemImage: "eye", text: "\(battle.viewCount.formatted())") + } + } + + private var titleBlock: some View { + VStack(alignment: .leading, spacing: 6) { + Text(battle.title) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.neutral900) + .lineLimit(2) + Text(battle.summary) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral300) + .lineLimit(2) + } + } + + private var versusRow: some View { + HStack(spacing: 8) { + NewBattleAvatarPill( + label: battle.optionATitle, + sub: battle.philosopherA, + imageURL: battle.philosopherAImageURL + ) + Text("VS") + .pretendardFont(family: .SemiBold, size: 11) + .foregroundStyle(.neutral300) + NewBattleAvatarPill( + label: battle.optionBTitle, + sub: battle.philosopherB, + imageURL: battle.philosopherBImageURL + ) + } + } +} + +/// 새로운 배틀 카드 안의 발화자 아바타 (원형 thumbnail + 라벨). +struct NewBattleAvatarPill: View { + let label: String + let sub: String + let imageURL: URL? + + var body: some View { + HStack(spacing: 8) { + avatar + VStack(alignment: .leading, spacing: 0) { + Text(label) + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.neutral900) + Text(sub) + .pretendardFont(family: .Medium, size: 10) + .foregroundStyle(.neutral300) + } + Spacer(minLength: 0) + } + .padding(8) + .frame(maxWidth: .infinity) + .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + ) + } + + @ViewBuilder + private var avatar: some View { + if let url = imageURL { + KFImage(url) + .resizable() + .scaledToFill() + .frame(width: 28, height: 28) + .clipShape(Circle()) + } else { + Circle() + .fill(.beige500) + .frame(width: 28, height: 28) + } + } +} diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift new file mode 100644 index 0000000..4eba39b --- /dev/null +++ b/Projects/Presentation/Home/Sources/Main/View/Components/QuizCardView.swift @@ -0,0 +1,78 @@ +// +// QuizCardView.swift +// Home +// +// Created by Wonji Suh on 5/15/26. +// + +import SwiftUI + +import DesignSystem +import Entity + +/// "오늘의 Pické — 퀴즈" 카드. +struct QuizCardView: View { + let question: QuizQuestion + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + header + titleBlock + options + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + .background(.beige400, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2).stroke(.beige700, lineWidth: 1) + ) + } + + private var header: some View { + HStack { + TagBadgeView(text: "퀴즈") + Spacer() + Text("\(question.participantCount.formatted())명 참여") + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.neutral300) + } + } + + private var titleBlock: some View { + VStack(alignment: .leading, spacing: 6) { + Text(question.title) + .pretendardFont(family: .SemiBold, size: 15) + .foregroundStyle(.neutral900) + .kerning(-0.375) + Text(question.summary) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral200) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + } + + private var options: some View { + HStack(spacing: 8) { + option(label: question.itemA, desc: question.itemADesc) + option(label: question.itemB, desc: question.itemBDesc) + } + } + + private func option(label: String, desc: String) -> some View { + VStack(spacing: 2) { + Text(label) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.neutral900) + Text(desc) + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.neutral300) + } + .frame(maxWidth: .infinity) + .padding(12) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2).stroke(.beige500, lineWidth: 1) + ) + } +} diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift new file mode 100644 index 0000000..8337b55 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Main/View/Components/VoteCardView.swift @@ -0,0 +1,107 @@ +// +// VoteCardView.swift +// Home +// +// Created by Wonji Suh on 5/15/26. +// +// Pencil .pen `wZ4Yt` (Card/Vote) 기준으로 1:1 매핑. +// + +import SwiftUI + +import DesignSystem +import Entity + +/// "오늘의 Pické — 투표" 카드. +struct VoteCardView: View { + let question: VoteQuestion + + private let columns = [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + header + heading + grid + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2).stroke(.beige700, lineWidth: 1) + ) + } + + private var header: some View { + HStack { + Text("투표") + .pretendardFont(family: .SemiBold, size: 14) + .kerning(-0.35) + .foregroundStyle(.primary500) + .frame(width: 35, height: 21) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + + Spacer() + + Text("\(question.participantCount.formatted())명 참여") + .pretendardFont(family: .Medium, size: 11) + .foregroundStyle(.neutral300) + } + } + + private var heading: some View { + VStack(spacing: 6) { + HStack(spacing: 4) { + Text(question.titlePrefix) + .pretendardFont(family: .SemiBold, size: 15) + .kerning(-0.375) + .foregroundStyle(.neutral900) + + RoundedRectangle(cornerRadius: 2) + .fill(.beige200) + .frame(width: 52, height: 24) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(.beige700, lineWidth: 1) + ) + + Text(question.titleSuffix) + .pretendardFont(family: .SemiBold, size: 15) + .kerning(-0.375) + .foregroundStyle(.neutral900) + } + + Text(question.summary) + .pretendardFont(family: .Medium, size: 12) + .foregroundStyle(.neutral200) + } + .frame(maxWidth: .infinity) + } + + private var grid: some View { + LazyVGrid(columns: columns, spacing: 8) { + ForEach(Array(question.options.enumerated()), id: \.offset) { idx, option in + optionButton(index: idx + 1, label: option.title) + } + } + } + + private func optionButton(index: Int, label: String) -> some View { + HStack(spacing: 2) { + Text("\(index).") + .pretendardFont(family: .SemiBold, size: 10) + .foregroundStyle(.secondary900) + Text(label) + .pretendardFont(family: .SemiBold, size: 13) + .foregroundStyle(.neutral900) + } + .frame(maxWidth: .infinity, minHeight: 44) + .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + ) + } +} diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeSkeletonView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeSkeletonView.swift new file mode 100644 index 0000000..82bc2cb --- /dev/null +++ b/Projects/Presentation/Home/Sources/Main/View/HomeSkeletonView.swift @@ -0,0 +1,336 @@ +// +// HomeSkeletonView.swift +// Home +// +// Created by Wonji Suh on 5/16/26. +// + +import SwiftUI + +import DesignSystem + +struct HomeSkeletonView: View { + var body: some View { + VStack(spacing: 32) { + HomeHeroSkeletonView() + HomeHotBattlesSkeletonView() + HomeBestBattlesSkeletonView() + HomeTodayPickeSkeletonView() + HomeNewBattlesSkeletonView() + } + .padding(.bottom, 24) + .allowsHitTesting(false) + } +} + +private struct HomeHeroSkeletonView: View { + var body: some View { + VStack(spacing: 0) { + HStack { + SkeletonBlock(width: 82, height: 18, cornerRadius: 2, color: .primary500.opacity(0.45)) + Spacer() + SkeletonBlock(width: 36, height: 18, cornerRadius: 9, color: .neutral500.opacity(0.5)) + } + .padding(16) + + ZStack { + Rectangle() + .fill(.neutral700.opacity(0.8)) + HStack(spacing: 24) { + SkeletonBlock(width: 58, height: 14, color: .beige100.opacity(0.24)) + SkeletonBlock(width: 32, height: 32, cornerRadius: 16, color: .beige100.opacity(0.18)) + SkeletonBlock(width: 58, height: 14, color: .beige100.opacity(0.24)) + } + } + .frame(height: 167) + + VStack(alignment: .leading, spacing: 8) { + SkeletonBlock(width: 210, height: 18, color: .beige100.opacity(0.22)) + SkeletonBlock(width: 260, height: 12, color: .neutral200.opacity(0.2)) + HStack { + SkeletonBlock(width: 92, height: 12, color: .neutral200.opacity(0.18)) + Spacer() + SkeletonBlock(width: 48, height: 12, color: .neutral200.opacity(0.18)) + } + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 341) + .background(.neutral800) + } +} + +private struct HomeHotBattlesSkeletonView: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SkeletonSectionHeader(width: 132) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(0 ..< 2, id: \.self) { _ in + VStack(alignment: .leading, spacing: 12) { + SkeletonBlock(width: 196, height: 124) + VStack(alignment: .leading, spacing: 8) { + SkeletonBlock(width: 42, height: 20, color: .primary50) + SkeletonBlock(width: 150, height: 16) + SkeletonBlock(width: 104, height: 12) + } + } + .padding(12) + .frame(width: 220, alignment: .leading) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + ) + } + } + .padding(.horizontal, 16) + } + } + } +} + +private struct HomeBestBattlesSkeletonView: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SkeletonSectionHeader(width: 88) + VStack(spacing: 0) { + ForEach(0 ..< 3, id: \.self) { index in + HStack(alignment: .top, spacing: 16) { + SkeletonBlock(width: 18, height: 28, color: index == 0 ? .primary500.opacity(0.35) : .neutral100) + VStack(alignment: .leading, spacing: 10) { + SkeletonBlock(width: 76, height: 18, color: .primary50) + SkeletonBlock(width: 214, height: 16) + HStack(spacing: 8) { + SkeletonBlock(width: 40, height: 12) + SkeletonBlock(width: 44, height: 12) + Spacer() + SkeletonBlock(width: 96, height: 12) + } + } + } + .padding(.vertical, 16) + + if index < 2 { + Divider() + .background(.beige600) + } + } + } + .padding(.horizontal, 16) + } + } +} + +private struct HomeTodayPickeSkeletonView: View { + var body: some View { + VStack(alignment: .leading, spacing: 16) { + SkeletonSectionHeader(width: 104) + VStack(spacing: 16) { + todayQuizCard() + todayVoteCard() + } + .padding(.horizontal, 16) + } + } + + private func todayQuizCard() -> some View { + VStack(alignment: .leading, spacing: 20) { + HStack { + SkeletonBlock(width: 42, height: 20, color: .primary50) + Spacer() + SkeletonBlock(width: 76, height: 12) + } + VStack(spacing: 8) { + SkeletonBlock(width: 220, height: 16) + SkeletonBlock(width: 260, height: 12) + SkeletonBlock(width: 180, height: 12) + } + .frame(maxWidth: .infinity) + + HStack(spacing: 8) { + SkeletonBlock(height: 74, color: .beige50) + SkeletonBlock(height: 74, color: .beige50) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + .background(.beige400, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2).stroke(.beige700, lineWidth: 1) + ) + } + + private func todayVoteCard() -> some View { + VStack(alignment: .leading, spacing: 20) { + HStack { + SkeletonBlock(width: 42, height: 20, color: .primary50) + Spacer() + SkeletonBlock(width: 76, height: 12) + } + VStack(spacing: 8) { + HStack(spacing: 8) { + SkeletonBlock(width: 78, height: 16) + SkeletonBlock(width: 44, height: 24, color: .beige50) + SkeletonBlock(width: 24, height: 16) + } + SkeletonBlock(width: 230, height: 12) + } + .frame(maxWidth: .infinity) + + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2), spacing: 8) { + ForEach(0 ..< 4, id: \.self) { _ in + SkeletonBlock(height: 44, color: .beige50) + } + } + } + .padding(.vertical, 20) + .padding(.horizontal, 16) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2).stroke(.beige700, lineWidth: 1) + ) + } +} + +private struct HomeNewBattlesSkeletonView: View { + var body: some View { + VStack(alignment: .leading, spacing: 16) { + SkeletonSectionHeader(width: 104) + VStack(spacing: 12) { + ForEach(0 ..< 3, id: \.self) { _ in + VStack(alignment: .leading, spacing: 12) { + HStack { + SkeletonBlock(width: 42, height: 20, color: .primary50) + Spacer() + SkeletonBlock(width: 92, height: 12) + } + SkeletonBlock(width: 240, height: 16) + SkeletonBlock(width: 300, height: 12) + HStack(spacing: 8) { + SkeletonBattleOption() + SkeletonBlock(width: 32, height: 32, cornerRadius: 16, color: .secondary100) + SkeletonBattleOption() + } + } + .padding(12) + .background(.beige50, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + ) + } + } + .padding(.horizontal, 16) + } + } +} + +private struct SkeletonBattleOption: View { + var body: some View { + HStack(spacing: 8) { + SkeletonBlock(width: 28, height: 28, cornerRadius: 14, color: .beige500) + VStack(alignment: .leading, spacing: 4) { + SkeletonBlock(width: 46, height: 12) + SkeletonBlock(width: 28, height: 10) + } + Spacer(minLength: 0) + } + .padding(8) + .frame(maxWidth: .infinity) + .background(.beige300, in: RoundedRectangle(cornerRadius: 2)) + .overlay( + RoundedRectangle(cornerRadius: 2).stroke(.beige600, lineWidth: 1) + ) + } +} + +private struct SkeletonSectionHeader: View { + let width: CGFloat + + var body: some View { + HStack { + SkeletonBlock(width: width, height: 22, color: .neutral100) + Spacer() + SkeletonBlock(width: 40, height: 14) + } + .padding(.horizontal, 16) + } +} + +private struct SkeletonBlock: View { + var width: CGFloat? + var height: CGFloat + var cornerRadius: CGFloat + var color: Color + + init( + width: CGFloat? = nil, + height: CGFloat, + cornerRadius: CGFloat = 2, + color: Color = .beige600.opacity(0.55) + ) { + self.width = width + self.height = height + self.cornerRadius = cornerRadius + self.color = color + } + + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(color) + .frame(width: width, height: height) + .frame(maxWidth: width == nil ? .infinity : nil) + .skeletonShimmer(cornerRadius: cornerRadius) + } +} + +private struct SkeletonShimmerModifier: ViewModifier { + @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion + @State private var isShimmering = false + + let cornerRadius: CGFloat + + func body(content: Content) -> some View { + content + .overlay { + if !accessibilityReduceMotion { + shimmer + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } + } + .onAppear { + guard !accessibilityReduceMotion else { return } + isShimmering = true + } + } + + private var shimmer: some View { + GeometryReader { proxy in + LinearGradient( + colors: [ + .clear, + .white.opacity(0.32), + .clear + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: proxy.size.width * 0.55, height: proxy.size.height) + .offset(x: isShimmering ? proxy.size.width : -proxy.size.width) + .blendMode(.screen) + .allowsHitTesting(false) + .animation( + .linear(duration: 1.6) + .delay(0.15) + .repeatForever(autoreverses: false), + value: isShimmering + ) + } + } +} + +private extension View { + func skeletonShimmer(cornerRadius: CGFloat) -> some View { + modifier(SkeletonShimmerModifier(cornerRadius: cornerRadius)) + } +} diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift new file mode 100644 index 0000000..0eb02d5 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Main/View/HomeView.swift @@ -0,0 +1,127 @@ +// +// HomeView.swift +// Home +// +// Created by Wonji Suh on 5/15/26. +// + +import SwiftUI + +import DesignSystem +import Entity + +import ComposableArchitecture + +@ViewAction(for: HomeFeature.self) +public struct HomeView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 0) { + HomeHeaderView { /* TODO: 알림 화면 */ } // sticky — 스크롤 영향 없음 + + ScrollView(showsIndicators: false) { + if shouldShowSkeleton { + HomeSkeletonView() + } else { + VStack(spacing: 32) { + HeroCarouselView( + heroes: store.heroes, + currentIndex: $store.heroIndex + ) + + hotBattlesSection() + bestBattlesSection() + todayPickeSection() + newBattlesSection() + } + .padding(.bottom, 24) + } + } + } + .background(Color.beige50.ignoresSafeArea()) + .onAppear { send(.onAppear) } + .navigationBarHidden(true) + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + } +} + +// MARK: - Sections + +extension HomeView { + private var shouldShowSkeleton: Bool { + store.isLoading && + store.heroes.isEmpty && + store.hotBattles.isEmpty && + store.bestBattles.isEmpty && + store.quizzes.isEmpty && + store.votes.isEmpty && + store.newBattles.isEmpty + } + + private func hotBattlesSection() -> some View { + VStack(alignment: .leading, spacing: 12) { + HomeSectionHeader(title: "지금 뜨는 배틀") { + send(.seeMoreTapped(.hotBattles)) + } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(store.hotBattles) { HotBattleCardView(battle: $0) } + } + .padding(.horizontal, 16) + } + } + } + + private func bestBattlesSection() -> some View { + VStack(alignment: .leading, spacing: 12) { + HomeSectionHeader(title: "Best 배틀") { + send(.seeMoreTapped(.bestBattles)) + } + VStack(spacing: 12) { + ForEach(store.bestBattles) { BestBattleCardView(battle: $0) } + } + .padding(.horizontal, 16) + } + } + + private func todayPickeSection() -> some View { + VStack(alignment: .leading, spacing: 16) { + HomeSectionHeader(title: "오늘의 Pické") { + send(.seeMoreTapped(.todayPicke)) + } + VStack(spacing: 16) { + if let quiz = store.currentQuiz { + QuizCardView(question: quiz) + } + if let vote = store.currentVote { + VoteCardView(question: vote) + } + } + .padding(.horizontal, 16) + } + } + + private func newBattlesSection() -> some View { + VStack(alignment: .leading, spacing: 16) { + HomeSectionHeader(title: "새로운 배틀") { + send(.seeMoreTapped(.newBattles)) + } + VStack(spacing: 12) { + ForEach(store.newBattles) { NewBattleCardView(battle: $0) } + } + .padding(.horizontal, 16) + } + } +} + +#Preview { + HomeView( + store: Store(initialState: HomeFeature.State()) { HomeFeature() } + ) +} diff --git a/Projects/Presentation/Home/Tests/Sources/HomeTests.swift b/Projects/Presentation/Home/Tests/Sources/HomeTests.swift new file mode 100644 index 0000000..af99940 --- /dev/null +++ b/Projects/Presentation/Home/Tests/Sources/HomeTests.swift @@ -0,0 +1,26 @@ +// +// HomeTests.swift +// Presentation.HomeTests +// +// Created by Roy on 2026-05-15. +// + +import Testing +@testable import Home + +struct HomeTests { + + @Test + func homeExample() { + // This is an example of a test case. + #expect(true) + } + + @Test + func homeLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } + +} diff --git a/Projects/Presentation/MainTab/Project.swift b/Projects/Presentation/MainTab/Project.swift new file mode 100644 index 0000000..b008877 --- /dev/null +++ b/Projects/Presentation/MainTab/Project.swift @@ -0,0 +1,20 @@ +import Foundation +import ProjectDescription +import DependencyPlugin +import ProjectTemplatePlugin +import DependencyPackagePlugin + +let project = Project.makeAppModule( + name: "MainTab", + bundleId: .appBundleID(name: ".MainTab"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .SPM.logMarco, + .SPM.tcaFlow, + .Domain(implements: .UseCase), + .Shared(implements: .DesignSystem), + .Presentation(implements: .Home) + ], + sources: ["Sources/**"] +) diff --git a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift new file mode 100644 index 0000000..071ec78 --- /dev/null +++ b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift @@ -0,0 +1,112 @@ +// +// MainTabCoordinator.swift +// MainTab +// +// Created by Wonji Suh on 5/15/26. +// + +import Foundation + +import ComposableArchitecture +import TCAFlow + +import DesignSystem +import Home + +/// 픽케 메인 탭 코디네이터. +/// 모든 탭은 우선 HomeCoordinator 로 채워두고, 각 기능 (Explore / QuickBattle / MyPage) +/// 모듈이 추가될 때 해당 child state 만 교체한다. +@Reducer +public struct MainTabCoordinator { + public init() {} + + public enum Tab: Int, CaseIterable { + case home + case explore + case quickBattle + case myPage + + public var title: String { + switch self { + case .home: "홈" + case .explore: "탐색" + case .quickBattle: "빠른 배틀" + case .myPage: "마이" + } + } + + /// 디자인 시스템 GNB 아이콘 (Pencil 추출 PNG) + public var iconAsset: ImageAsset { + switch self { + case .home: .tabHome + case .explore: .tabExplore + case .quickBattle: .tabQuickBattle + case .myPage: .tabMyPage + } + } + } + + @ObservableState + public struct State: Equatable { + public var selectedTab: Int + public var homeState: HomeCoordinator.State + public var exploreState: HomeCoordinator.State + public var quickBattleState: HomeCoordinator.State + public var myPageState: HomeCoordinator.State + + public init(selectedTab: Int = Tab.home.rawValue) { + self.selectedTab = selectedTab + homeState = .init() + exploreState = .init() + quickBattleState = .init() + myPageState = .init() + } + } + + @CasePathable + public enum Action { + case selectTab(Int) + case tabReselected(Int) + case home(HomeCoordinator.Action) + case explore(HomeCoordinator.Action) + case quickBattle(HomeCoordinator.Action) + case myPage(HomeCoordinator.Action) + } + + public var body: some ReducerOf { + Scope(state: \.homeState, action: \.home) { + HomeCoordinator() + } + Scope(state: \.exploreState, action: \.explore) { + HomeCoordinator() + } + Scope(state: \.quickBattleState, action: \.quickBattle) { + HomeCoordinator() + } + Scope(state: \.myPageState, action: \.myPage) { + HomeCoordinator() + } + + Reduce { state, action in + switch action { + case let .selectTab(tab): + state.selectedTab = tab + return .none + + case let .tabReselected(tab): + // 같은 탭 재탭 → 해당 탭의 navigation stack 을 root 로 되돌림 + switch Tab(rawValue: tab) { + case .home: state.homeState.routes.goBackToRoot() + case .explore: state.exploreState.routes.goBackToRoot() + case .quickBattle: state.quickBattleState.routes.goBackToRoot() + case .myPage: state.myPageState.routes.goBackToRoot() + case .none: break + } + return .none + + case .home, .explore, .quickBattle, .myPage: + return .none + } + } + } +} diff --git a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift new file mode 100644 index 0000000..8fd62c3 --- /dev/null +++ b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift @@ -0,0 +1,140 @@ +// +// MainTabView.swift +// MainTab +// +// Created by Wonji Suh on 5/15/26. +// + +import SwiftUI +import UIKit + +import DesignSystem +import Home +import TCAFlow + +import ComposableArchitecture + +public struct MainTabView: View { + @Bindable private var store: StoreOf + + public init(store: StoreOf) { + Self.configureTabBarAppearance() + self.store = store + } + + public var body: some View { + TCAFlowTabRouter( + selectedTab: $store.selectedTab.sending(\.selectTab), + tabs: MainTabCoordinator.Tab.allCases.map { + TabItem(title: $0.title, icon: $0.iconAsset.rawValue, tag: $0.rawValue) + }, + onReselect: { tab in + store.send(.tabReselected(tab)) + }, + tabItemLabel: { tab in + tabLabel(for: tab) + } + ) { + tabContent(for: $0) + } + } +} + +extension MainTabView { + private static func configureTabBarAppearance() { + let selectedColor = UIColor.neutral900 + let normalColor = UIColor.neutral900.withAlphaComponent(0.4) + let backgroundColor = UIColor.bgDefault + let borderColor = UIColor.borderDefault.withAlphaComponent(0.4) + let font = UIFont.pretendardFontFamily(family: .Medium, size: 12) + + let appearance = UITabBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = backgroundColor + appearance.shadowColor = borderColor + appearance.selectionIndicatorImage = UIImage() + + configureItemAppearance(appearance.stackedLayoutAppearance, selectedColor, normalColor, font) + configureItemAppearance(appearance.inlineLayoutAppearance, selectedColor, normalColor, font) + configureItemAppearance(appearance.compactInlineLayoutAppearance, selectedColor, normalColor, font) + + UITabBar.appearance().standardAppearance = appearance + UITabBar.appearance().scrollEdgeAppearance = appearance + UITabBar.appearance().tintColor = selectedColor + UITabBar.appearance().unselectedItemTintColor = normalColor + } + + private static func configureItemAppearance( + _ itemAppearance: UITabBarItemAppearance, + _ selectedColor: UIColor, + _ normalColor: UIColor, + _ font: UIFont + ) { + itemAppearance.normal.iconColor = normalColor + itemAppearance.normal.titleTextAttributes = [ + .font: font, + .foregroundColor: normalColor + ] + + itemAppearance.selected.iconColor = selectedColor + itemAppearance.selected.titleTextAttributes = [ + .font: font, + .foregroundColor: selectedColor + ] + } + + private func tabLabel(for tab: TabItem) -> some View { + Label { + Text(tab.title) + .pretendardFont(family: .Medium, size: 12) + } icon: { + tabIcon(for: tab) + } + } + + @ViewBuilder + private func tabIcon(for tab: TabItem) -> some View { + if let image = UIImage(assetName: tab.icon)?.withRenderingMode(.alwaysTemplate) { + Image(uiImage: image) + .renderingMode(.template) + } else { + Image(systemName: "questionmark") + } + } + + @ViewBuilder + private func tabContent(for tab: Int) -> some View { + switch MainTabCoordinator.Tab(rawValue: tab) { + case .home: + HomeCoordinatorView( + store: store.scope(state: \.homeState, action: \.home) + ) + + case .explore: + HomeCoordinatorView( + store: store.scope(state: \.exploreState, action: \.explore) + ) + + case .quickBattle: + HomeCoordinatorView( + store: store.scope(state: \.quickBattleState, action: \.quickBattle) + ) + + case .myPage: + HomeCoordinatorView( + store: store.scope(state: \.myPageState, action: \.myPage) + ) + + case .none: + EmptyView() + } + } +} + +#Preview { + MainTabView( + store: Store(initialState: MainTabCoordinator.State()) { + MainTabCoordinator() + } + ) +} diff --git a/Projects/Presentation/MainTab/Tests/Sources/MainTabTests.swift b/Projects/Presentation/MainTab/Tests/Sources/MainTabTests.swift new file mode 100644 index 0000000..ed433d4 --- /dev/null +++ b/Projects/Presentation/MainTab/Tests/Sources/MainTabTests.swift @@ -0,0 +1,26 @@ +// +// MainTabTests.swift +// Presentation.MainTabTests +// +// Created by Roy on 2026-05-15. +// + +import Testing +@testable import MainTab + +struct MainTabTests { + + @Test + func maintabExample() { + // This is an example of a test case. + #expect(true) + } + + @Test + func maintabLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } + +} diff --git a/Projects/Presentation/Presentation/Project.swift b/Projects/Presentation/Presentation/Project.swift index 1468470..2da1f68 100644 --- a/Projects/Presentation/Presentation/Project.swift +++ b/Projects/Presentation/Presentation/Project.swift @@ -11,7 +11,8 @@ let project = Project.makeModule( settings: .settings(), dependencies: [ .Presentation(implements: .Splash), - .Presentation(implements: .Auth) + .Presentation(implements: .Auth), + .Presentation(implements: .MainTab) ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift b/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift index 838e792..13e626a 100644 --- a/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift +++ b/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift @@ -9,3 +9,4 @@ // MARK: - 여기에 한번에 호출 할꺼 추가 @_exported import Splash @_exported import Auth +@_exported import MainTab diff --git a/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift b/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift index 288e82d..0560f3a 100644 --- a/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift +++ b/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift @@ -6,6 +6,8 @@ // import Foundation +import UseCase + import ComposableArchitecture @Reducer @@ -50,10 +52,9 @@ public struct SplashFeature { } - // MARK: - NavigationAction - public enum DelegateAction: Equatable { - + case presentAuth + case presentMainTab } nonisolated enum CancelID: Hashable { @@ -61,6 +62,7 @@ public struct SplashFeature { } @Dependency(\.continuousClock) var clock + @Dependency(\.keychainManager) var keychainManager public var body: some Reducer { BindingReducer() @@ -95,8 +97,14 @@ extension SplashFeature { ) -> Effect { switch action { case .onAppear: + let hasStoredCredential = hasStoredCredential return .run { send in - try await clock.sleep(for: .seconds(0.3)) + try await clock.sleep(for: .seconds(1.2)) + if hasStoredCredential { + await send(.delegate(.presentMainTab)) + } else { + await send(.delegate(.presentAuth)) + } } } @@ -115,17 +123,25 @@ extension SplashFeature { ) -> Effect { } - + private func handleDelegateAction( state: inout State, action: DelegateAction ) -> Effect { switch action { - + case .presentAuth, .presentMainTab: + return .none } } - - + private var hasStoredCredential: Bool { + guard + let accessToken = keychainManager.accessToken(), + let refreshToken = keychainManager.refreshToken() + else { + return false + } + return !accessToken.isEmpty && !refreshToken.isEmpty + } } diff --git a/Projects/Presentation/Splash/Tests/Sources/SplashTests.swift b/Projects/Presentation/Splash/Tests/Sources/SplashTests.swift index 8a1fc8b..c91bb82 100644 --- a/Projects/Presentation/Splash/Tests/Sources/SplashTests.swift +++ b/Projects/Presentation/Splash/Tests/Sources/SplashTests.swift @@ -6,22 +6,43 @@ // import Testing +import ComposableArchitecture +import UseCase + @testable import Splash struct SplashTests { + @Test + func onAppearRoutesToMainTabWhenTokensExist() async { + let keychainManager = InMemoryKeychainManager() + keychainManager.save(accessToken: "access-token", refreshToken: "refresh-token") + let clock = TestClock() - @Test - func splashExample() { - // This is an example of a test case. - #expect(true) + let store = TestStore(initialState: SplashFeature.State()) { + SplashFeature() + } withDependencies: { + $0.continuousClock = clock + $0.keychainManager = keychainManager } - @Test - func splashLogicTest() { - // Add your test logic here. - let result = true - #expect(result == true) + await store.send(.view(.onAppear)) + await clock.advance(by: .seconds(0.5)) + await store.receive(.delegate(.presentMainTab)) + } + + @Test + func onAppearRoutesToAuthWhenTokensDoNotExist() async { + let clock = TestClock() + + let store = TestStore(initialState: SplashFeature.State()) { + SplashFeature() + } withDependencies: { + $0.continuousClock = clock + $0.keychainManager = InMemoryKeychainManager() } + await store.send(.view(.onAppear)) + await clock.advance(by: .seconds(0.5)) + await store.receive(.delegate(.presentAuth)) + } } - diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Contents.json new file mode 100644 index 0000000..1fab2f6 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Union.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Union.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Union.png new file mode 100644 index 0000000..d5b739e Binary files /dev/null and b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabExplore.imageset/Union.png differ diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Contents.json new file mode 100644 index 0000000..1c2ff97 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Icon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Icon.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Icon.svg new file mode 100644 index 0000000..5523ab2 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabHome.imageset/Icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Contents.json new file mode 100644 index 0000000..5cf58fd --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Group 27.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Group 27.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Group 27.svg new file mode 100644 index 0000000..82f9d72 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabMyPage.imageset/Group 27.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Contents.json new file mode 100644 index 0000000..1c2ff97 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Icon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Icon.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Icon.svg new file mode 100644 index 0000000..4cbdacd --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/GNB/tabQuickBattle.imageset/Icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Home/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Home/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Home/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Home/appLogo.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Home/appLogo.imageset/Contents.json new file mode 100644 index 0000000..d1d7df5 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Home/appLogo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "appLogo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Home/appLogo.imageset/appLogo.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Home/appLogo.imageset/appLogo.svg new file mode 100644 index 0000000..0dcc438 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Home/appLogo.imageset/appLogo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Home/bell.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Home/bell.imageset/Contents.json new file mode 100644 index 0000000..83b2005 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Home/bell.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bell.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Home/bell.imageset/bell.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Home/bell.imageset/bell.svg new file mode 100644 index 0000000..dde7ffc --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/Home/bell.imageset/bell.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json index 3b14ade..a1a7abc 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "onboarding1@3x.png", + "filename" : "이미지.svg", "idiom" : "universal" } ], diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/onboarding1@3x.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/onboarding1@3x.png deleted file mode 100644 index 00aedd6..0000000 Binary files a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/onboarding1@3x.png and /dev/null differ diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\235\264\353\257\270\354\247\200.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\235\264\353\257\270\354\247\200.svg" new file mode 100644 index 0000000..32f7c27 --- /dev/null +++ "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding1.imageset/\354\235\264\353\257\270\354\247\200.svg" @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json index 7a5e93f..a1a7abc 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "onboarding2@3x.png", + "filename" : "이미지.svg", "idiom" : "universal" } ], diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/onboarding2@3x.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/onboarding2@3x.png deleted file mode 100644 index 866aa4e..0000000 Binary files a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/onboarding2@3x.png and /dev/null differ diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\235\264\353\257\270\354\247\200.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\235\264\353\257\270\354\247\200.svg" new file mode 100644 index 0000000..baa61af --- /dev/null +++ "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding2.imageset/\354\235\264\353\257\270\354\247\200.svg" @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json index 6376348..a1a7abc 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "onboarding3@3x.png", + "filename" : "이미지.svg", "idiom" : "universal" } ], diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/onboarding3@3x.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/onboarding3@3x.png deleted file mode 100644 index 5698449..0000000 Binary files a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/onboarding3@3x.png and /dev/null differ diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\235\264\353\257\270\354\247\200.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\235\264\353\257\270\354\247\200.svg" new file mode 100644 index 0000000..8c0375e --- /dev/null +++ "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding3.imageset/\354\235\264\353\257\270\354\247\200.svg" @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json index 876f8d2..a1a7abc 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "onboarding4@3x.png", + "filename" : "이미지.svg", "idiom" : "universal" } ], diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/onboarding4@3x.png b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/onboarding4@3x.png deleted file mode 100644 index cbf3ec9..0000000 Binary files a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/onboarding4@3x.png and /dev/null differ diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\235\264\353\257\270\354\247\200.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\235\264\353\257\270\354\247\200.svg" new file mode 100644 index 0000000..c4c3eb9 --- /dev/null +++ "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/OnBoarding/onboarding4.imageset/\354\235\264\353\257\270\354\247\200.svg" @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift b/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift index ef6c72e..dd1dc6f 100644 --- a/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift +++ b/Projects/Shared/DesignSystem/Sources/Extension/Color/UIColor+.swift @@ -8,16 +8,20 @@ import UIKit public extension UIColor { - convenience init(hex: String, alpha: Double? = .zero) { + convenience init(hex: String, alpha: Double = 1.0) { let scanner = Scanner(string: hex) _ = scanner.scanString("#") - + var rgb: UInt64 = 0 scanner.scanHexInt64(&rgb) - + let r = Double((rgb >> 16) & 0xFF) / 255.0 let g = Double((rgb >> 8) & 0xFF) / 255.0 let b = Double((rgb >> 0) & 0xFF) / 255.0 - self.init(red: r, green: g, blue: b, alpha: 100) + self.init(red: r, green: g, blue: b, alpha: alpha) } + + static var neutral900: UIColor { .init(hex: "131212") } + static var bgDefault: UIColor { .init(hex: "FAFAF9") } + static var borderDefault: UIColor { .init(hex: "EFEAE0") } } diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index cf428d9..210ecb2 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift @@ -25,5 +25,17 @@ public enum ImageAsset: String { case onboarding3 case onboarding4 + // MARK: - GNB 탭 아이콘 + + case tabHome + case tabExplore + case tabQuickBattle + case tabMyPage + + + //MARK: - Home 탭 아이콘 + case appLogo + case bell + case none } diff --git a/README.md b/README.md index c9239dd..324d36d 100644 --- a/README.md +++ b/README.md @@ -82,20 +82,23 @@ Picke-iOS/ │ │ │ ├── Presentation/ # 🎨 UI Layer │ │ ├── Auth/ # 로그인 / 코디네이터 / Toast +│ │ ├── Home/ # 홈 피드 / 큐레이팅 / 스켈레톤 +│ │ ├── MainTab/ # 탭 라우팅 / GNB │ │ ├── Splash/ # 스플래시 │ │ └── Presentation/ # 공통 프레젠테이션 유틸 │ │ │ ├── Domain/ # 🔥 Business Logic Layer -│ │ ├── Entity/ # LoginEntity, AuthTokens, SocialType, AuthError ... -│ │ ├── DomainInterface/ # AuthInterface, *OAuth*Interface, KeychainManaging ... +│ │ ├── Entity/ # Auth / Home / OAuth / Error 도메인 엔티티 +│ │ ├── DomainInterface/ # Auth / Home / OAuth Repository + Manager 인터페이스 │ │ └── UseCase/ # AuthUseCaseImpl, UnifiedOAuthUseCase, Provider/{Apple,Google,Kakao} │ │ │ ├── Data/ # 📡 Data Layer -│ │ ├── Model/ # BaseResponseDTO / Login·Token·Logout·Withdraw DTO + Mapper -│ │ ├── API/ # PieckeDomain, AuthAPI, BaseAPI -│ │ ├── Service/ # AuthService (BaseTargetType), OAuthLoginRequest -│ │ └── Repository/ # AuthRepositoryImpl + OAuth Repository (Apple/Google/Kakao) -│ │ └── Auth/ # Interceptor, RefreshToken Session, Pool, MoyaProvider 확장 +│ │ ├── Model/ # BaseResponseDTO / Auth·Home DTO + Entity Mapper +│ │ ├── API/ # PieckeDomain, AuthAPI, HomeAPI, BaseAPI +│ │ ├── Service/ # AuthService / HomeService (BaseTargetType), 요청 바디 +│ │ └── Repository/ # Auth·Home RepositoryImpl + OAuth Repository +│ │ ├── Auth/ # Interceptor, RefreshToken Session, Pool, MoyaProvider 확장 +│ │ └── OAuth/ # Apple / Google / Kakao / Web OAuth 구현 │ │ │ ├── Network/ # 🌐 Network Layer │ │ ├── Networking/ # 네트워크 클라이언트 export @@ -136,6 +139,18 @@ graph TD C -.-> K[API Services] ``` +### 🕸️ TuistSpider 확장 뷰 + +레이어별로 묶어 보거나(Grouped) 모든 모듈을 펼쳐 본(Expanded) 시각화입니다. (TuistSpider 결과) + +
+ +| Grouped | Expanded | +|:---:|:---:| +| | | + +
+ ### 🔄 의존성 방향 원칙 ``` @@ -145,7 +160,7 @@ Domain/UseCase → Domain (Interface / Entity) ↓ Data/Repository → Domain (Interface / Entity) + Data (Model + Service + API) ↓ -Data/Service → Data (API) + Network/Foundations (APIHeader) +Data/Service → Data (API) + Network/Foundations (APIHeader) + Domain/Entity (요청 식별값) ↓ Network/Foundations → Network/ThirdPartys (AsyncMoya, WeaveDI) ``` @@ -154,6 +169,7 @@ Network/Foundations → Network/ThirdPartys (AsyncMoya, WeaveDI) - ✅ **Presentation** 은 Domain UseCase / Entity 만 직접 참조 - ✅ **Domain** 은 외부 계층에 의존하지 않는 순수 비즈니스 로직 - ✅ **Data/Repository** 는 Domain 인터페이스를 구현, DTO ↔ Entity 매핑 담당 +- ✅ **Data/Service** 는 endpoint / header / method / parameter 정의만 담당하고 DTO Model 에 의존하지 않음 - ✅ 모든 데이터 흐름은 **Domain 을 중심**으로 진행 ## 🔐 OAuth 인증 플로우 diff --git a/Tuist/Package.swift b/Tuist/Package.swift index ddd1c9a..dcbaa0a 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -36,7 +36,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/pointfreeco/swift-composable-architecture", exact: "1.25.5"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.6.0"), - .package(url: "https://github.com/Roy-wonji/TCAFlow.git", exact: "1.1.2"), + .package(url: "https://github.com/Roy-wonji/TCAFlow.git", exact: "1.1.3"), .package(url: "https://github.com/Roy-wonji/WeaveDI.git", from: "3.4.1"), .package(url: "https://github.com/google/GoogleSignIn-iOS", from: "9.1.0"), .package(url: "https://github.com/Roy-wonji/AsyncMoya", from: "1.1.8"), diff --git a/docs/graphs/Picke-expanded-Picke.png b/docs/graphs/Picke-expanded-Picke.png new file mode 100644 index 0000000..bb917e7 Binary files /dev/null and b/docs/graphs/Picke-expanded-Picke.png differ diff --git a/docs/graphs/Picke-grouped-Picke.png b/docs/graphs/Picke-grouped-Picke.png new file mode 100644 index 0000000..24643d6 Binary files /dev/null and b/docs/graphs/Picke-grouped-Picke.png differ