From 58f5352626d656035b449f6fa97b4d7ab8d473d9 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 15:23:41 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20Hifi=20=EB=AA=A8=EB=93=88=20Coord?= =?UTF-8?q?inator/Feature=20=EA=B8=B0=EB=B3=B8=20=EA=B3=A8=EA=B2=A9=20+=20?= =?UTF-8?q?=ED=83=90=EC=83=89=20=ED=83=AD=20=EC=97=B0=EA=B2=B0=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Presentation/Hifi/Project.swift | 8 +- Projects/Presentation/Hifi/Sources/Base.swift | 22 ----- .../Coordinator/Reducer/HifiCoordinator.swift | 93 +++++++++++++++++++ .../View/HifiCoordinatorView.swift | 28 ++++++ .../Hifi/Sources/Reducer/HifiFeature.swift | 43 +++++++++ .../Hifi/Sources/View/HifiView.swift | 33 +++++++ .../Sources/Reducer/MainTabCoordinator.swift | 7 +- .../MainTab/Sources/View/MainTabView.swift | 3 +- 8 files changed, 208 insertions(+), 29 deletions(-) delete mode 100644 Projects/Presentation/Hifi/Sources/Base.swift create mode 100644 Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift create mode 100644 Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift create mode 100644 Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift create mode 100644 Projects/Presentation/Hifi/Sources/View/HifiView.swift diff --git a/Projects/Presentation/Hifi/Project.swift b/Projects/Presentation/Hifi/Project.swift index b07893cc..b8c5a7d4 100644 --- a/Projects/Presentation/Hifi/Project.swift +++ b/Projects/Presentation/Hifi/Project.swift @@ -1,8 +1,8 @@ +import DependencyPackagePlugin +import DependencyPlugin import Foundation import ProjectDescription -import DependencyPlugin import ProjectTemplatePlugin -import DependencyPackagePlugin let project = Project.makeAppModule( name: "Hifi", @@ -11,8 +11,10 @@ let project = Project.makeAppModule( settings: .settings(), dependencies: [ .Presentation(implements: .Chat), + .Shared(implements: .DesignSystem), .SPM.composableArchitecture, .SPM.tcaFlow, + .SPM.kingfisher, ], sources: ["Sources/**"] -) \ No newline at end of file +) diff --git a/Projects/Presentation/Hifi/Sources/Base.swift b/Projects/Presentation/Hifi/Sources/Base.swift deleted file mode 100644 index b1d530b1..00000000 --- a/Projects/Presentation/Hifi/Sources/Base.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// base.swift -// DDDAttendance. -// -// Created by Roy on 2026-06-04 -// Copyright © 2026 DDD , Ltd., All rights reserved. -// - -import SwiftUI - -struct BaseView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("Hello, world!") - } - .padding() - } -} - diff --git a/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift b/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift new file mode 100644 index 00000000..7cc20715 --- /dev/null +++ b/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift @@ -0,0 +1,93 @@ +// +// HifiCoordinator.swift +// Hifi +// +// Hi-Fi 탭 코디네이터. 루트는 HifiFeature, 후속 화면이 필요하면 HifiScreen 에 case 추가. +// + +import Foundation + +import ComposableArchitecture +import TCAFlow + +@FlowCoordinator(screen: "HifiScreen", navigation: true) +public struct HifiCoordinator { + public init() {} + + @ObservableState + public struct State: Equatable { + public var routes: [Route] + + public init() { + routes = [.root(.hifi(.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 HifiCoordinator { + 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 + } + } +} + +// swiftformat:disable extensionAccessControl +extension HifiCoordinator { + @Reducer + public enum HifiScreen { + case hifi(HifiFeature) + } +} + +// swiftformat:enable extensionAccessControl + +extension HifiCoordinator.HifiScreen.State: Equatable {} diff --git a/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift b/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift new file mode 100644 index 00000000..2989aebf --- /dev/null +++ b/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift @@ -0,0 +1,28 @@ +// +// HifiCoordinatorView.swift +// Hifi +// + +import Foundation + +import SwiftUI + +import ComposableArchitecture +import TCAFlow + +public struct HifiCoordinatorView: 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 .hifi(hifiStore): + HifiView(store: hifiStore) + } + } + } +} diff --git a/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift b/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift new file mode 100644 index 00000000..625bbdb0 --- /dev/null +++ b/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift @@ -0,0 +1,43 @@ +// +// HifiFeature.swift +// Hifi +// +// Hi-Fi 탭 루트 기능. 기본 골격만 구성 (추후 화면 구현 시 State/Action 확장). +// + +import Foundation + +import ComposableArchitecture + +@Reducer +public struct HifiFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public init() {} + } + + public enum Action: ViewAction { + case view(View) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case onAppear + } + + public enum DelegateAction: Equatable {} + + public var body: some Reducer { + Reduce { _, action in + switch action { + case .view(.onAppear): + return .none + case .delegate: + return .none + } + } + } +} diff --git a/Projects/Presentation/Hifi/Sources/View/HifiView.swift b/Projects/Presentation/Hifi/Sources/View/HifiView.swift new file mode 100644 index 00000000..c230834e --- /dev/null +++ b/Projects/Presentation/Hifi/Sources/View/HifiView.swift @@ -0,0 +1,33 @@ +// +// HifiView.swift +// Hifi +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem + +@ViewAction(for: HifiFeature.self) +public struct HifiView: View { + public let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(spacing: 12) { + Image(systemName: "waveform") + .font(.system(size: 40, weight: .regular)) + .foregroundStyle(.primary500) + Text("Hi-Fi") + .pretendardFont(family: .SemiBold, size: 18) + .foregroundStyle(.neutral800) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.beige200.ignoresSafeArea()) + .navigationBarHidden(true) + .onAppear { send(.onAppear) } + } +} diff --git a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift index d76465f0..97a3ee5e 100644 --- a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift +++ b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift @@ -11,6 +11,7 @@ import ComposableArchitecture import TCAFlow import DesignSystem +import Hifi import Home /// 픽케 메인 탭 코디네이터. @@ -50,7 +51,7 @@ public struct MainTabCoordinator { public struct State: Equatable { public var selectedTab: Int public var homeState: HomeCoordinator.State - public var exploreState: HomeCoordinator.State + public var exploreState: HifiCoordinator.State public var quickBattleState: HomeCoordinator.State public var myPageState: HomeCoordinator.State @@ -68,7 +69,7 @@ public struct MainTabCoordinator { case selectTab(Int) case tabReselected(Int) case home(HomeCoordinator.Action) - case explore(HomeCoordinator.Action) + case explore(HifiCoordinator.Action) case quickBattle(HomeCoordinator.Action) case myPage(HomeCoordinator.Action) } @@ -78,7 +79,7 @@ public struct MainTabCoordinator { HomeCoordinator() } Scope(state: \.exploreState, action: \.explore) { - HomeCoordinator() + HifiCoordinator() } Scope(state: \.quickBattleState, action: \.quickBattle) { HomeCoordinator() diff --git a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift index a846aab1..6cfa1168 100644 --- a/Projects/Presentation/MainTab/Sources/View/MainTabView.swift +++ b/Projects/Presentation/MainTab/Sources/View/MainTabView.swift @@ -9,6 +9,7 @@ import SwiftUI import UIKit import DesignSystem +import Hifi import Home import TCAFlow @@ -115,7 +116,7 @@ extension MainTabView { ) case .explore: - HomeCoordinatorView( + HifiCoordinatorView( store: store.scope(state: \.exploreState, action: \.explore) ) From 0e0f883b5ddbfaa8f80505d36f2825ca8e8e85b3 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 23:46:57 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20API(/search/battles)=20=EC=A0=84=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20+=20=ED=83=90=EC=83=89=20Entity(ExploreItem/Categor?= =?UTF-8?q?y/Sort)=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/Di/DiRegister.swift | 1 + .../Data/API/Sources/Base/PieckeDomain.swift | 17 +- .../Data/API/Sources/Search/SearchAPI.swift | 17 + .../Search/DTO/SearchBattleDataDTO.swift | 30 ++ .../Search/Mapper/SearchBattleDataDTO+.swift | 37 ++ .../Sources/Search/SearchRepositoryImpl.swift | 45 +++ .../Sources/Search/SearchService.swift | 53 +++ .../Search/DefaultSearchRepositoryImpl.swift | 20 ++ .../Sources/Search/SearchInterface.swift | 36 ++ .../Sources/Home/Explore/ExploreItem.swift | 99 +++++ .../Sources/Search/SearchUseCase.swift | 44 +++ .../View/Components/HifiHeaderView.swift | 0 .../Sources/Main/View/HomeSkeletonView.swift | 339 ------------------ .../Web/Sources/Reducer/WebReducer.swift | 39 ++ .../logo/noDataLogo.imageset/Contents.json | 12 + .../logo/noDataLogo.imageset/pickeLogo2.svg | 4 + 16 files changed, 447 insertions(+), 346 deletions(-) create mode 100644 Projects/Data/API/Sources/Search/SearchAPI.swift create mode 100644 Projects/Data/Model/Sources/Search/DTO/SearchBattleDataDTO.swift create mode 100644 Projects/Data/Model/Sources/Search/Mapper/SearchBattleDataDTO+.swift create mode 100644 Projects/Data/Repository/Sources/Search/SearchRepositoryImpl.swift create mode 100644 Projects/Data/Service/Sources/Search/SearchService.swift create mode 100644 Projects/Domain/DomainInterface/Sources/Search/DefaultSearchRepositoryImpl.swift create mode 100644 Projects/Domain/DomainInterface/Sources/Search/SearchInterface.swift create mode 100644 Projects/Domain/Entity/Sources/Home/Explore/ExploreItem.swift create mode 100644 Projects/Domain/UseCase/Sources/Search/SearchUseCase.swift create mode 100644 Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift delete mode 100644 Projects/Presentation/Home/Sources/Main/View/HomeSkeletonView.swift create mode 100644 Projects/Presentation/Web/Sources/Reducer/WebReducer.swift create mode 100644 Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/Contents.json create mode 100644 Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/pickeLogo2.svg diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index 6678ba1f..92f4600e 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -39,6 +39,7 @@ public final class AppDIManager: Sendable { .register { BattleRepositoryImpl() as BattleInterface } .register { CommentRepositoryImpl() as CommentInterface } .register { PerspectiveRepositoryImpl() as PerspectiveInterface } + .register { SearchRepositoryImpl() as SearchInterface } .register { AudioPlayerRepositoryImpl() as AudioPlayerInterface } // .register { ProfileRepositoryImpl() as ProfileInterface } // .register { AppUpdateRepositoryImpl() as AppUpdateInterface } diff --git a/Projects/Data/API/Sources/Base/PieckeDomain.swift b/Projects/Data/API/Sources/Base/PieckeDomain.swift index 31261ddf..5c177847 100644 --- a/Projects/Data/API/Sources/Base/PieckeDomain.swift +++ b/Projects/Data/API/Sources/Base/PieckeDomain.swift @@ -17,6 +17,7 @@ public enum PieckeDomain { case battle case comment case perspective + case search } extension PieckeDomain: DomainType { @@ -27,19 +28,21 @@ extension PieckeDomain: DomainType { public var url: String { switch self { case .auth: - "api/v1/auth/" + return "api/v1/auth/" case .profile: - "api/v1/me/" + return "api/v1/me/" case .home: - "api/v1/home" + return "api/v1/home" case .poll: - "api/v1/poll" + return "api/v1/poll" case .battle: - "api/v1/battles/" + return "api/v1/battles/" case .comment: - "api/v1/comments/" + return "api/v1/comments/" case .perspective: - "api/v1/perspectives/" + return "api/v1/perspectives/" + case .search: + return "api/v1/search/" } } } diff --git a/Projects/Data/API/Sources/Search/SearchAPI.swift b/Projects/Data/API/Sources/Search/SearchAPI.swift new file mode 100644 index 00000000..1888fc8b --- /dev/null +++ b/Projects/Data/API/Sources/Search/SearchAPI.swift @@ -0,0 +1,17 @@ +// +// SearchAPI.swift +// API +// + +import Foundation + +public enum SearchAPI { + case battles + + public var description: String { + switch self { + case .battles: + return "battles" + } + } +} diff --git a/Projects/Data/Model/Sources/Search/DTO/SearchBattleDataDTO.swift b/Projects/Data/Model/Sources/Search/DTO/SearchBattleDataDTO.swift new file mode 100644 index 00000000..c2675162 --- /dev/null +++ b/Projects/Data/Model/Sources/Search/DTO/SearchBattleDataDTO.swift @@ -0,0 +1,30 @@ +// +// SearchBattleDataDTO.swift +// Model +// + +import Foundation + +public struct SearchBattlePageDataDTO: Decodable { + public let items: [SearchBattleDTO] + public let nextOffset: Int? + public let hasNext: Bool +} + +public struct SearchBattleDTO: Decodable { + public let battleId: Int + public let thumbnailUrl: String? + public let title: String? + public let summary: String? + public let tags: [SearchBattleTagDTO]? + public let audioDuration: Int? + public let viewCount: Int? +} + +public struct SearchBattleTagDTO: Decodable { + public let tagId: Int + public let name: String? + public let type: String? +} + +public typealias SearchBattlePageResponseDTO = BaseResponseDTO diff --git a/Projects/Data/Model/Sources/Search/Mapper/SearchBattleDataDTO+.swift b/Projects/Data/Model/Sources/Search/Mapper/SearchBattleDataDTO+.swift new file mode 100644 index 00000000..4348e5cf --- /dev/null +++ b/Projects/Data/Model/Sources/Search/Mapper/SearchBattleDataDTO+.swift @@ -0,0 +1,37 @@ +// +// SearchBattleDataDTO+.swift +// Model +// + +import Entity +import Foundation + +public extension SearchBattlePageDataDTO { + func toDomain() -> ExploreItemPage { + ExploreItemPage( + items: items.map { $0.toDomain() }, + nextOffset: nextOffset, + hasNext: hasNext + ) + } +} + +public extension SearchBattleDTO { + func toDomain() -> ExploreItem { + ExploreItem( + id: battleId, + category: tags?.first?.name ?? "", + title: title ?? "", + summary: summary ?? "", + minutes: Self.toMinutes(audioDuration ?? 0), + viewCount: viewCount ?? 0, + imageURL: thumbnailUrl + ) + } + + /// 초 단위 오디오 길이를 분으로 환산 (최소 1분). + private static func toMinutes(_ seconds: Int) -> Int { + guard seconds > 0 else { return 0 } + return max(1, Int((Double(seconds) / 60).rounded())) + } +} diff --git a/Projects/Data/Repository/Sources/Search/SearchRepositoryImpl.swift b/Projects/Data/Repository/Sources/Search/SearchRepositoryImpl.swift new file mode 100644 index 00000000..fe3e1f91 --- /dev/null +++ b/Projects/Data/Repository/Sources/Search/SearchRepositoryImpl.swift @@ -0,0 +1,45 @@ +// +// SearchRepositoryImpl.swift +// Repository +// + +import Foundation + +import DomainInterface +import Entity +import Model +import Service + +import LogMacro +import Moya + +@preconcurrency import AsyncMoya + +public final class SearchRepositoryImpl: SearchInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } + + public func searchBattles( + category: String?, + sort: String?, + offset: Int?, + size: Int? + ) async throws -> ExploreItemPage { + let dto: SearchBattlePageResponseDTO = try await provider.request( + .battles(category: category, sort: sort, offset: offset, size: size) + ) + + guard let data = dto.data else { + let message = dto.error?.message ?? "배틀 검색 응답이 비어 있습니다" + Log.error("[SearchRepositoryImpl] empty searchBattles payload: \(message)") + throw BattleError.backendError(message) + } + + return data.toDomain() + } +} diff --git a/Projects/Data/Service/Sources/Search/SearchService.swift b/Projects/Data/Service/Sources/Search/SearchService.swift new file mode 100644 index 00000000..728d9561 --- /dev/null +++ b/Projects/Data/Service/Sources/Search/SearchService.swift @@ -0,0 +1,53 @@ +// +// SearchService.swift +// Service +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum SearchService { + case battles(category: String?, sort: String?, offset: Int?, size: Int?) +} + +extension SearchService: BaseTargetType { + public typealias Domain = PieckeDomain + + public var domain: PieckeDomain { .search } + + public var urlPath: String { + switch self { + case .battles: + return SearchAPI.battles.description + } + } + + public var error: [Int: AsyncMoya.NetworkError]? { nil } + + public var method: Moya.Method { + switch self { + case .battles: + return .get + } + } + + public var parameters: [String: Any]? { + switch self { + case let .battles(category, sort, offset, size): + var query: [String: Any] = [:] + if let category, !category.isEmpty { query["category"] = category } + if let sort, !sort.isEmpty { query["sort"] = sort } + if let offset { query["offset"] = offset } + if let size { query["size"] = size } + return query.isEmpty ? nil : query + } + } + + public var headers: [String: String]? { + return APIHeader.baseHeader + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Search/DefaultSearchRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Search/DefaultSearchRepositoryImpl.swift new file mode 100644 index 00000000..32b0ff66 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Search/DefaultSearchRepositoryImpl.swift @@ -0,0 +1,20 @@ +// +// DefaultSearchRepositoryImpl.swift +// DomainInterface +// + +import Entity +import Foundation + +public struct DefaultSearchRepositoryImpl: SearchInterface { + public init() {} + + public func searchBattles( + category _: String?, + sort _: String?, + offset _: Int?, + size _: Int? + ) async throws -> ExploreItemPage { + ExploreItemPage(items: [], nextOffset: nil, hasNext: false) + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Search/SearchInterface.swift b/Projects/Domain/DomainInterface/Sources/Search/SearchInterface.swift new file mode 100644 index 00000000..66b17452 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Search/SearchInterface.swift @@ -0,0 +1,36 @@ +// +// SearchInterface.swift +// DomainInterface +// + +import Entity +import Foundation +import WeaveDI + +public protocol SearchInterface: Sendable { + func searchBattles( + category: String?, + sort: String?, + offset: Int?, + size: Int? + ) async throws -> ExploreItemPage +} + +public struct SearchRepositoryDependency: DependencyKey { + public static var liveValue: SearchInterface { + UnifiedDI.resolve(SearchInterface.self) ?? DefaultSearchRepositoryImpl() + } + + public static var testValue: SearchInterface { + UnifiedDI.resolve(SearchInterface.self) ?? DefaultSearchRepositoryImpl() + } + + public static var previewValue: SearchInterface = liveValue +} + +public extension DependencyValues { + var searchRepository: SearchInterface { + get { self[SearchRepositoryDependency.self] } + set { self[SearchRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/Entity/Sources/Home/Explore/ExploreItem.swift b/Projects/Domain/Entity/Sources/Home/Explore/ExploreItem.swift new file mode 100644 index 00000000..0199d0a8 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Home/Explore/ExploreItem.swift @@ -0,0 +1,99 @@ +// +// ExploreItem.swift +// Entity +// +// 탐색(Hi-Fi) 화면 리스트 아이템 도메인 모델. .pen `탐색 hifi 이미지` 기준. +// + +import Foundation + +public struct ExploreItemPage: Equatable { + public let items: [ExploreItem] + public let nextOffset: Int? + public let hasNext: Bool + + public init( + items: [ExploreItem], + nextOffset: Int?, + hasNext: Bool + ) { + self.items = items + self.nextOffset = nextOffset + self.hasNext = hasNext + } +} + +public struct ExploreItem: Equatable, Identifiable, Hashable { + public let id: Int + public let category: String + public let title: String + public let summary: String + public let minutes: Int + public let viewCount: Int + public let imageURL: String? + + public init( + id: Int, + category: String, + title: String, + summary: String, + minutes: Int, + viewCount: Int, + imageURL: String? = nil + ) { + self.id = id + self.category = category + self.title = title + self.summary = summary + self.minutes = minutes + self.viewCount = viewCount + self.imageURL = imageURL + } +} + +public enum ExploreCategory: String, Equatable, Hashable, CaseIterable, Identifiable { + case all + case philosophy + case literature + case art + case science + case society + case history + + public var id: String { rawValue } + + public var title: String { + switch self { + case .all: "전체" + case .philosophy: "철학" + case .literature: "문학" + case .art: "예술" + case .science: "과학" + case .society: "사회" + case .history: "역사" + } + } + + /// 검색 API `category` 쿼리 값 (대문자 영문 enum). 전체는 nil(필터 없음). + public var queryValue: String? { + switch self { + case .all: nil + default: rawValue.uppercased() + } + } +} + +public enum ExploreSort: String, Equatable, Hashable, CaseIterable { + case popular + case latest + + public var title: String { + switch self { + case .popular: "인기순" + case .latest: "최신순" + } + } + + /// 검색 API `sort` 쿼리 값 (대문자 영문 enum: POPULAR / LATEST). + public var queryValue: String { rawValue.uppercased() } +} diff --git a/Projects/Domain/UseCase/Sources/Search/SearchUseCase.swift b/Projects/Domain/UseCase/Sources/Search/SearchUseCase.swift new file mode 100644 index 00000000..8a578e9b --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Search/SearchUseCase.swift @@ -0,0 +1,44 @@ +// +// SearchUseCase.swift +// UseCase +// + +import Foundation + +import DomainInterface +import Entity + +import ComposableArchitecture + +public struct SearchUseCaseImpl: SearchInterface { + @Dependency(\.searchRepository) private var searchRepository + + public init() {} + + public func searchBattles( + category: String?, + sort: String?, + offset: Int?, + size: Int? + ) async throws -> ExploreItemPage { + return try await searchRepository.searchBattles( + category: category, + sort: sort, + offset: offset, + size: size + ) + } +} + +extension SearchUseCaseImpl: DependencyKey { + public static var liveValue = SearchUseCaseImpl() + public static var testValue = SearchUseCaseImpl() + public static var previewValue = SearchUseCaseImpl() +} + +public extension DependencyValues { + var searchUseCase: SearchUseCaseImpl { + get { self[SearchUseCaseImpl.self] } + set { self[SearchUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift b/Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift new file mode 100644 index 00000000..e69de29b diff --git a/Projects/Presentation/Home/Sources/Main/View/HomeSkeletonView.swift b/Projects/Presentation/Home/Sources/Main/View/HomeSkeletonView.swift deleted file mode 100644 index ce7b7093..00000000 --- a/Projects/Presentation/Home/Sources/Main/View/HomeSkeletonView.swift +++ /dev/null @@ -1,339 +0,0 @@ -// -// 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) - } - } - - @ViewBuilder - 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) - ) - } - - @ViewBuilder - 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 - } - } - - @ViewBuilder - 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/Web/Sources/Reducer/WebReducer.swift b/Projects/Presentation/Web/Sources/Reducer/WebReducer.swift new file mode 100644 index 00000000..729c5e3a --- /dev/null +++ b/Projects/Presentation/Web/Sources/Reducer/WebReducer.swift @@ -0,0 +1,39 @@ +// +// WebReducer.swift +// Profile +// +// Created by Wonji Suh on 1/4/26. +// + +import Foundation +import ComposableArchitecture + + +@Reducer +public struct WebReducer { + public init() {} + + @ObservableState + public struct State: Equatable { + var url: String = "" + + public init(url: String) { + self.url = url + } + } + + public enum Action { + case backToRoot + + } + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .backToRoot: + return .none + } + } + } +} + diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/Contents.json new file mode 100644 index 00000000..cadf2b73 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "pickeLogo2.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/pickeLogo2.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/pickeLogo2.svg new file mode 100644 index 00000000..6e305f32 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/noDataLogo.imageset/pickeLogo2.svg @@ -0,0 +1,4 @@ + + + + From f592d672369027b67fb8a2f1363c052f743538c2 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 23:47:06 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20Hifi(=ED=83=90=EC=83=89)=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=E2=80=94=20=EA=B2=80=EC=83=89=20UI/?= =?UTF-8?q?=EB=AC=B4=ED=95=9C=EC=8A=A4=ED=81=AC=EB=A1=A4/=EC=8A=A4?= =?UTF-8?q?=EC=99=80=EC=9D=B4=ED=94=84/=EC=8A=A4=EC=BC=88=EB=A0=88?= =?UTF-8?q?=ED=86=A4/=EB=B9=88=EC=83=81=ED=83=9C=20+=20=ED=83=90=EC=83=89?= =?UTF-8?q?=20=ED=83=AD=20=EC=97=B0=EA=B2=B0=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TargetDependency+Module/Modules.swift | 1 + Projects/Presentation/Hifi/Project.swift | 3 +- .../Coordinator/Reducer/HifiCoordinator.swift | 22 +- .../View/HifiCoordinatorView.swift | 3 + .../Hifi/Sources/Reducer/HifiFeature.swift | 137 +++++++++- .../View/Components/ExploreSkeletonView.swift | 53 ++++ .../View/Components/HifiHeaderView.swift | 43 +++ .../Hifi/Sources/View/HifiView.swift | 246 +++++++++++++++++- Projects/Presentation/MainTab/Project.swift | 3 +- .../Sources/Image/ImageAsset.swift | 1 + 10 files changed, 494 insertions(+), 18 deletions(-) create mode 100644 Projects/Presentation/Hifi/Sources/View/Components/ExploreSkeletonView.swift diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift index bd108ac0..21903a25 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/TargetDependency+Module/Modules.swift @@ -30,6 +30,7 @@ public extension ModulePath { public static let name: String = "Presentation" case Hifi + case Web } } diff --git a/Projects/Presentation/Hifi/Project.swift b/Projects/Presentation/Hifi/Project.swift index b8c5a7d4..50aba6c9 100644 --- a/Projects/Presentation/Hifi/Project.swift +++ b/Projects/Presentation/Hifi/Project.swift @@ -11,7 +11,8 @@ let project = Project.makeAppModule( settings: .settings(), dependencies: [ .Presentation(implements: .Chat), - .Shared(implements: .DesignSystem), + .Shared(implements: .Shared), + .Domain(implements: .UseCase), .SPM.composableArchitecture, .SPM.tcaFlow, .SPM.kingfisher, diff --git a/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift b/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift index 7cc20715..7598c76d 100644 --- a/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift +++ b/Projects/Presentation/Hifi/Sources/Coordinator/Reducer/HifiCoordinator.swift @@ -7,6 +7,7 @@ import Foundation +import Chat import ComposableArchitecture import TCAFlow @@ -59,10 +60,24 @@ public struct HifiCoordinator { extension HifiCoordinator { private func routerAction( - state _: inout State, - action _: IndexedRouterActionOf + state: inout State, + action: IndexedRouterActionOf ) -> Effect { - .none + switch action { + case let .routeAction(_, action: .hifi(.delegate(.openBattle(battleId)))): + state.routes.push(.chat(.init(battleId: battleId))) + return .none + + case .routeAction(_, action: .chat(.delegate(.dismiss))): + return .send(.view(.backAction)) + + case .routeAction(_, action: .chat(.delegate(.popToRoot))): + // 큐레이팅 X — 탐색 루트로 복귀 + return .send(.view(.backToRootAction)) + + default: + return .none + } } private func handleViewAction( @@ -85,6 +100,7 @@ extension HifiCoordinator { @Reducer public enum HifiScreen { case hifi(HifiFeature) + case chat(ChatCoordinator) } } diff --git a/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift b/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift index 2989aebf..4f37b53a 100644 --- a/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift +++ b/Projects/Presentation/Hifi/Sources/Coordinator/View/HifiCoordinatorView.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI +import Chat import ComposableArchitecture import TCAFlow @@ -22,6 +23,8 @@ public struct HifiCoordinatorView: View { switch screen.case { case let .hifi(hifiStore): HifiView(store: hifiStore) + case let .chat(chatStore): + ChatCoordinatorView(store: chatStore) } } } diff --git a/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift b/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift index 625bbdb0..11eac7c9 100644 --- a/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift +++ b/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift @@ -2,12 +2,16 @@ // HifiFeature.swift // Hifi // -// Hi-Fi 탭 루트 기능. 기본 골격만 구성 (추후 화면 구현 시 State/Action 확장). +// 탐색(Hi-Fi) 탭 루트 기능. .pen `탐색 hifi 이미지` 기준. +// GET /api/v1/search/battles (카테고리·정렬 검색) // import Foundation import ComposableArchitecture +import Entity +import LogMacro +import UseCase @Reducer public struct HifiFeature { @@ -15,29 +19,152 @@ public struct HifiFeature { @ObservableState public struct State: Equatable { + public var categories: [ExploreCategory] = ExploreCategory.allCases + public var selectedCategory: ExploreCategory = .all + public var selectedSort: ExploreSort = .popular + public var items: [ExploreItem] = [] + public var isLoading: Bool = false + public var nextOffset: Int? + public var hasNext: Bool = false + public init() {} } public enum Action: ViewAction { case view(View) + case async(AsyncAction) + case inner(InnerAction) case delegate(DelegateAction) } @CasePathable public enum View { case onAppear + case categoryTapped(ExploreCategory) + case swipedCategory(forward: Bool) + case sortTapped(ExploreSort) + case itemTapped(id: Int) + case reachedBottom + } + + public enum AsyncAction: Equatable { + case search(reset: Bool) + } + + public enum InnerAction: Equatable { + case searchResponse(Result, reset: Bool) + } + + public enum DelegateAction: Equatable { + case openBattle(battleId: Int) + } + + nonisolated enum CancelID: Hashable { + case search } - public enum DelegateAction: Equatable {} + @Dependency(\.searchUseCase) private var searchUseCase public var body: some Reducer { - Reduce { _, action in + Reduce { state, action in switch action { - case .view(.onAppear): - return .none + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + case .delegate: return .none } } } } + +extension HifiFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case .onAppear: + return .send(.async(.search(reset: true))) + + case let .categoryTapped(category): + state.selectedCategory = category + return .send(.async(.search(reset: true))) + + case let .swipedCategory(forward): + // 좌우 스와이프 → 인접 카테고리로 전환 (범위 벗어나면 무시) + guard let next = adjacentCategory(in: state, forward: forward) else { return .none } + state.selectedCategory = next + return .send(.async(.search(reset: true))) + + case let .sortTapped(sort): + state.selectedSort = sort + return .send(.async(.search(reset: true))) + + case let .itemTapped(id): + return .send(.delegate(.openBattle(battleId: id))) + + case .reachedBottom: + // 무한 스크롤: 다음 페이지가 있고 로딩 중이 아니면 추가 로드. + guard state.hasNext, !state.isLoading else { return .none } + return .send(.async(.search(reset: false))) + } + } + + /// 현재 선택된 카테고리 기준 인접 카테고리(스와이프 방향). 끝에서는 순환(역사→전체, 전체→역사). + private func adjacentCategory(in state: State, forward: Bool) -> ExploreCategory? { + let all = state.categories + guard !all.isEmpty, let idx = all.firstIndex(of: state.selectedCategory) else { return nil } + let count = all.count + let nextIdx = forward ? (idx + 1) % count : (idx - 1 + count) % count + return all[nextIdx] + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case let .search(reset): + state.isLoading = true + if reset { state.items = [] } + let category = state.selectedCategory.queryValue + let sort = state.selectedSort.queryValue + let offset = reset ? 0 : (state.nextOffset ?? 0) + return .run { [useCase = searchUseCase] send in + let result = await Result { + try await useCase.searchBattles(category: category, sort: sort, offset: offset, size: 20) + } + .mapError(BattleError.from) + return await send(.inner(.searchResponse(result, reset: reset))) + } + .cancellable(id: CancelID.search, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case let .searchResponse(result, reset): + state.isLoading = false + switch result { + case let .success(page): + state.items = reset ? page.items : state.items + page.items + state.nextOffset = page.nextOffset + state.hasNext = page.hasNext + case let .failure(error): + Log.error("[HifiFeature] searchBattles failed: \(error.localizedDescription)") + if reset { state.items = [] } + } + return .none + } + } +} diff --git a/Projects/Presentation/Hifi/Sources/View/Components/ExploreSkeletonView.swift b/Projects/Presentation/Hifi/Sources/View/Components/ExploreSkeletonView.swift new file mode 100644 index 00000000..2b6e57fd --- /dev/null +++ b/Projects/Presentation/Hifi/Sources/View/Components/ExploreSkeletonView.swift @@ -0,0 +1,53 @@ +// +// ExploreSkeletonView.swift +// Hifi +// +// 탐색(Hi-Fi) 리스트 로딩 placeholder. 초기 로드 / 카테고리·정렬 전환 시 노출. +// + +import SwiftUI + +import DesignSystem + +struct ExploreSkeletonView: View { + var count: Int = 6 + + var body: some View { + LazyVStack(spacing: 0) { + ForEach(0 ..< count, id: \.self) { _ in + row() + } + } + } + + @ViewBuilder + private func row() -> some View { + HStack(alignment: .center, spacing: 8) { + SkeletonView(cornerRadius: 2) + .frame(width: 76, height: 76) + + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + SkeletonView(cornerRadius: 2).frame(width: 44, height: 18) + SkeletonView(cornerRadius: 4).frame(maxWidth: .infinity).frame(height: 14) + } + SkeletonView(cornerRadius: 4).frame(maxWidth: .infinity).frame(height: 12) + SkeletonView(cornerRadius: 4).frame(width: 180, height: 12) + } + + HStack(spacing: 6) { + Spacer() + SkeletonView(cornerRadius: 4).frame(width: 36, height: 12) + SkeletonView(cornerRadius: 4).frame(width: 44, height: 12) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.beige50) + .overlay(alignment: .bottom) { + Rectangle().fill(.beige600).frame(height: 1) + } + } +} diff --git a/Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift b/Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift index e69de29b..44549c27 100644 --- a/Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift +++ b/Projects/Presentation/Hifi/Sources/View/Components/HifiHeaderView.swift @@ -0,0 +1,43 @@ +// +// HifiHeaderView.swift +// Hifi +// +// Created by Wonji Suh on 6/4/26. +// + +import SwiftUI + +import DesignSystem + +/// 홈 화면 최상단 GNB 위 헤더 (PicKé 로고 + 알림 아이콘). +struct HifiHeaderView: 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) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1) + } + } +} + diff --git a/Projects/Presentation/Hifi/Sources/View/HifiView.swift b/Projects/Presentation/Hifi/Sources/View/HifiView.swift index c230834e..2c68fbbf 100644 --- a/Projects/Presentation/Hifi/Sources/View/HifiView.swift +++ b/Projects/Presentation/Hifi/Sources/View/HifiView.swift @@ -2,11 +2,16 @@ // HifiView.swift // Hifi // +// .pen `탐색 hifi 이미지` 기준 탐색 화면 UI. +// import SwiftUI import ComposableArchitecture import DesignSystem +import Entity +import Kingfisher +import Utill @ViewAction(for: HifiFeature.self) public struct HifiView: View { @@ -17,17 +22,242 @@ public struct HifiView: View { } public var body: some View { - VStack(spacing: 12) { - Image(systemName: "waveform") - .font(.system(size: 40, weight: .regular)) - .foregroundStyle(.primary500) - Text("Hi-Fi") - .pretendardFont(family: .SemiBold, size: 18) - .foregroundStyle(.neutral800) + VStack(spacing: 0) { + HifiHeaderView {} + + categoryTabs() + + sortRow() + + Group { + if store.isLoading, store.items.isEmpty { + ScrollView { + ExploreSkeletonView() + } + } else if store.items.isEmpty { + emptyState() + } else { + exploreList() + } + } + .scrollIndicators(.hidden) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + .simultaneousGesture(categorySwipe) } - .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.beige200.ignoresSafeArea()) .navigationBarHidden(true) + .toolbar(.hidden, for: .navigationBar) .onAppear { send(.onAppear) } } } + +// MARK: - Empty + +private extension HifiView { + @ViewBuilder + func emptyState() -> some View { + VStack(spacing: 8) { + Image(asset: .noDataLogo) + .resizable() + .scaledToFit() + .frame(width: 135, height: 90) + + Text("새로운 콘텐츠가 없습니다") + .pretendardCustomFont(textStyle: .bodyMedium) + .foregroundStyle(.beige800) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - List + +private extension HifiView { + @ViewBuilder + func exploreList() -> some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(store.items) { item in + exploreRow(item) + .onAppear { + // 무한 스크롤: 마지막 아이템 노출 시 다음 페이지 로드 + if item.id == store.items.last?.id { + send(.reachedBottom) + } + } + } + } + } + } + + /// 좌우 스와이프로 카테고리 전환 (빈 상태/스켈레톤 포함 콘텐츠 영역 전체에 적용). + /// 인접 카테고리 계산은 Feature 가 담당. + var categorySwipe: some Gesture { + DragGesture(minimumDistance: 24) + .onEnded { value in + guard abs(value.translation.width) > abs(value.translation.height), + abs(value.translation.width) > 50 + else { return } + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + send(.swipedCategory(forward: value.translation.width < 0)) + } + } + } +} + +// MARK: - Category Tabs + +private extension HifiView { + @ViewBuilder + func categoryTabs() -> some View { + ScrollView(.horizontal) { + HStack(spacing: 2) { + ForEach(store.categories, id: \.self) { category in + categoryTab(category) + } + } + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + .background(.beige200) + .overlay(alignment: .bottom) { + Rectangle() + .fill(.beige600) + .frame(height: 1.5) + } + } + + @ViewBuilder + func categoryTab(_ category: ExploreCategory) -> some View { + let isSelected = store.selectedCategory == category + Button { send(.categoryTapped(category)) } label: { + Text(category.title) + .pretendardFont(family: isSelected ? .Medium : .Regular, size: 14) + .foregroundStyle(isSelected ? Color.primary500 : Color.neutral300) + .frame(width: 50) + .padding(.vertical, 8) + .overlay(alignment: .bottom) { + if isSelected { + Rectangle() + .fill(.primary500) + .frame(height: 4) + } + } + } + .buttonStyle(.plain) + } +} + +// MARK: - Sort + +private extension HifiView { + @ViewBuilder + func sortRow() -> some View { + HStack(spacing: 12) { + ForEach(ExploreSort.allCases, id: \.self) { sort in + let isSelected = store.selectedSort == sort + Button { send(.sortTapped(sort)) } label: { + Text(sort.title) + .pretendardFont(family: isSelected ? .SemiBold : .Medium, size: 12) + .foregroundStyle(isSelected ? Color.neutral800 : Color.neutral300) + } + .buttonStyle(.plain) + } + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.beige200) + } +} + +// MARK: - List Row + +private extension HifiView { + @ViewBuilder + func exploreRow(_ item: ExploreItem) -> some View { + Button { send(.itemTapped(id: item.id)) } label: { + HStack(alignment: .center, spacing: 8) { + thumbnail(item.imageURL) + + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 6) { + Text("#\(item.category)") + .pretendardFont(family: .SemiBold, size: 12) + .foregroundStyle(.primary500) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.beige600, in: RoundedRectangle(cornerRadius: 2)) + + Text(item.title) + .pretendardFont(family: .SemiBold, size: 14) + .foregroundStyle(.neutral500) + .kerning(-0.35) + .lineSpacing(14 * 0.28) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + + Text(item.summary) + .pretendardFont(family: .Regular, size: 13) + .foregroundStyle(.neutral400) + .lineSpacing(13 * 0.4) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 2) + } + + footer(item) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.beige50) + .overlay(alignment: .bottom) { + Rectangle().fill(.beige600).frame(height: 1) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + func thumbnail(_ url: String?) -> some View { + Group { + if let url, let imageURL = URL(string: url) { + KFImage(imageURL) + .placeholder { Color.beige600 } + .resizable() + .scaledToFill() + } else { + Color.beige600 + } + } + .frame(width: 76) + .frame(maxHeight: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 2)) + } + + @ViewBuilder + func footer(_ item: ExploreItem) -> some View { + HStack(spacing: 6) { + Spacer() + HStack(spacing: 2) { + Image(systemName: "clock") + .font(.system(size: 11, weight: .regular)) + Text("\(item.minutes)분") + .pretendardFont(family: .Medium, size: 12) + } + .foregroundStyle(.neutral300) + + HStack(spacing: 2) { + Image(systemName: "eye") + .font(.system(size: 11, weight: .regular)) + Text(item.viewCount.decimalFormatted) + .pretendardFont(family: .Medium, size: 12) + } + .foregroundStyle(.neutral300) + } + } +} diff --git a/Projects/Presentation/MainTab/Project.swift b/Projects/Presentation/MainTab/Project.swift index b0088779..5efc2acf 100644 --- a/Projects/Presentation/MainTab/Project.swift +++ b/Projects/Presentation/MainTab/Project.swift @@ -14,7 +14,8 @@ let project = Project.makeAppModule( .SPM.tcaFlow, .Domain(implements: .UseCase), .Shared(implements: .DesignSystem), - .Presentation(implements: .Home) + .Presentation(implements: .Home), + .Presentation(implements: .Hifi) ], sources: ["Sources/**"] ) diff --git a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift index c19d69a4..715e900f 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/ImageAsset.swift @@ -14,6 +14,7 @@ public enum ImageAsset: String { case loginLogo case google case kakao + case noDataLogo case errorXmark case checkBlue From 4d131c19beb39552a177aca8df3cf37d2b3cb32b Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 23:47:19 +0900 Subject: [PATCH 04/12] =?UTF-8?q?refactor:=20=EC=88=AB=EC=9E=90=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=20Utill=20=EB=AA=A8=EB=93=88=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94(Int.decimalFormatted)=20+=20Chat/Ho?= =?UTF-8?q?me=20=EC=9D=98=EC=A1=B4=EC=84=B1=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Presentation/Chat/Project.swift | 2 +- .../Chat/Sources/Comment/View/CommentView.swift | 9 ++------- .../CommentReply/View/CommentReplyView.swift | 13 ++----------- Projects/Presentation/Home/Project.swift | 3 +-- .../Sources/Extension/Int+DecimalFormat.swift | 17 +++++++++++++++++ 5 files changed, 23 insertions(+), 21 deletions(-) create mode 100644 Projects/Shared/Utill/Sources/Extension/Int+DecimalFormat.swift diff --git a/Projects/Presentation/Chat/Project.swift b/Projects/Presentation/Chat/Project.swift index 8a845669..f5b4a3d3 100644 --- a/Projects/Presentation/Chat/Project.swift +++ b/Projects/Presentation/Chat/Project.swift @@ -11,7 +11,7 @@ let project = Project.makeAppModule( settings: .settings(), dependencies: [ .Domain(implements: .UseCase), - .Shared(implements: .DesignSystem), + .Shared(implements: .Shared), .SPM.composableArchitecture, .SPM.tcaFlow, .SPM.kingfisher, diff --git a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift index c499766c..622034cd 100644 --- a/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift +++ b/Projects/Presentation/Chat/Sources/Comment/View/CommentView.swift @@ -10,6 +10,7 @@ import SwiftUI import ComposableArchitecture import DesignSystem import Entity +import Utill @ViewAction(for: CommentFeature.self) public struct CommentView: View { @@ -500,7 +501,7 @@ private extension CommentView { Button { send(.commentRow(id: comment.id, action: .like)) } label: { actionLabel( systemName: comment.isLiked ? "heart.fill" : "heart", - text: formattedCount(comment.likeCount) + text: comment.likeCount.decimalFormatted ) } .buttonStyle(.plain) @@ -596,12 +597,6 @@ private extension CommentView { func percentText(_ percentage: Double) -> String { "\(String(format: "%.1f", percentage * 100))%" } - - func formattedCount(_ count: Int) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return formatter.string(from: NSNumber(value: count)) ?? "\(count)" - } } #Preview { diff --git a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift index fee3c001..7a486353 100644 --- a/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift +++ b/Projects/Presentation/Chat/Sources/CommentReply/View/CommentReplyView.swift @@ -8,6 +8,7 @@ import SwiftUI import ComposableArchitecture import DesignSystem import Entity +import Utill @ViewAction(for: CommentReplyFeature.self) public struct CommentReplyView: View { @@ -258,7 +259,7 @@ private extension CommentReplyView { Button(action: likeAction) { actionLabel( systemName: isLiked ? "heart.fill" : "heart", - text: formattedCount(likeCount) + text: likeCount.decimalFormatted ) } .buttonStyle(.plain) @@ -402,13 +403,3 @@ private extension CommentReplyView { } } } - -// MARK: - Format - -private extension CommentReplyView { - func formattedCount(_ count: Int) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return formatter.string(from: NSNumber(value: count)) ?? "\(count)" - } -} diff --git a/Projects/Presentation/Home/Project.swift b/Projects/Presentation/Home/Project.swift index 74900543..4375ac25 100644 --- a/Projects/Presentation/Home/Project.swift +++ b/Projects/Presentation/Home/Project.swift @@ -10,11 +10,10 @@ let project = Project.makeAppModule( product: .staticFramework, settings: .settings(), dependencies: [ - .SPM.logMarco, .SPM.tcaFlow, .SPM.kingfisher, .Domain(implements: .UseCase), - .Shared(implements: .DesignSystem), + .Shared(implements: .Shared), .Presentation(implements: .Chat) ], sources: ["Sources/**"] diff --git a/Projects/Shared/Utill/Sources/Extension/Int+DecimalFormat.swift b/Projects/Shared/Utill/Sources/Extension/Int+DecimalFormat.swift new file mode 100644 index 00000000..e3546704 --- /dev/null +++ b/Projects/Shared/Utill/Sources/Extension/Int+DecimalFormat.swift @@ -0,0 +1,17 @@ +// +// Int+DecimalFormat.swift +// Utill +// +// 숫자 표시용 공통 포맷 (좋아요/조회수 등). Chat / Home / Auth / Hifi 등에서 공용 사용. +// + +import Foundation + +public extension Int { + /// 천 단위 구분 콤마가 적용된 문자열. (예: 1340 → "1,340") + var decimalFormatted: String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: self)) ?? "\(self)" + } +} From 168eb6207dc145508f11b56c7db6cd87a86bf6ed Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 23:47:29 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20Apple/Google=20=EC=8B=A0=EA=B7=9C?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=95=BD=EA=B4=80=20=EB=8F=99=EC=9D=98=20?= =?UTF-8?q?=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8(pickeModal)=20+=20?= =?UTF-8?q?=EC=95=BD=EA=B4=80=20WebView=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Entity/Sources/Auth/TermsDocument.swift | 32 ++++ Projects/Presentation/Auth/Project.swift | 5 +- .../Coordinator/Reducer/AuthCoordinator.swift | 14 ++ .../View/AuthCoordinatorView.swift | 6 + .../Sources/Main/Reducer/LoginFeature.swift | 53 ++++++- .../Main/Reducer/TermsAgreementFeature.swift | 76 +++++++++ .../View/Components/TermsAgreementView.swift | 141 +++++++++++++++++ .../Auth/Sources/Main/View/LoginView.swift | 3 + Projects/Presentation/Web/Project.swift | 17 ++ .../Sources/View/WebRepresentableView.swift | 148 ++++++++++++++++++ .../Web/Sources/View/WebView.swift | 44 ++++++ .../Web/Tests/Sources/WebTests.swift | 27 ++++ .../Sources/Color/ShapeStyle+.swift | 1 + .../Sources/UI/Modal/PickeModal.swift | 30 ++++ 14 files changed, 590 insertions(+), 7 deletions(-) create mode 100644 Projects/Domain/Entity/Sources/Auth/TermsDocument.swift create mode 100644 Projects/Presentation/Auth/Sources/Main/Reducer/TermsAgreementFeature.swift create mode 100644 Projects/Presentation/Auth/Sources/Main/View/Components/TermsAgreementView.swift create mode 100644 Projects/Presentation/Web/Project.swift create mode 100644 Projects/Presentation/Web/Sources/View/WebRepresentableView.swift create mode 100644 Projects/Presentation/Web/Sources/View/WebView.swift create mode 100644 Projects/Presentation/Web/Tests/Sources/WebTests.swift create mode 100644 Projects/Shared/DesignSystem/Sources/UI/Modal/PickeModal.swift diff --git a/Projects/Domain/Entity/Sources/Auth/TermsDocument.swift b/Projects/Domain/Entity/Sources/Auth/TermsDocument.swift new file mode 100644 index 00000000..d4337228 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Auth/TermsDocument.swift @@ -0,0 +1,32 @@ +// +// TermsDocument.swift +// Entity +// +// 약관 동의 항목. .pen `애플 구글_약관 동의` 기준 (필수 2종). +// + +import Foundation + +public enum TermsDocument: String, CaseIterable, Identifiable, Hashable { + case service + case privacy + + public var id: String { rawValue } + + public var title: String { + switch self { + case .service: "(필수) 서비스 이용약관" + case .privacy: "(필수) 개인정보처리방침" + } + } + + public var urlString: String { + switch self { + case .service: "https://www.notion.so/3566effee51c8184bdc2e8595bfead27?source=copy_link" + case .privacy: "https://www.notion.so/3566effee51c81898de5f91d52ab7391?source=copy_link" + } + } + + /// 동의가 필수인 항목들. + public static var requiredCases: [TermsDocument] { allCases } +} diff --git a/Projects/Presentation/Auth/Project.swift b/Projects/Presentation/Auth/Project.swift index 497cafe7..cdfbba74 100644 --- a/Projects/Presentation/Auth/Project.swift +++ b/Projects/Presentation/Auth/Project.swift @@ -1,8 +1,8 @@ +import DependencyPackagePlugin +import DependencyPlugin import Foundation import ProjectDescription -import DependencyPlugin import ProjectTemplatePlugin -import DependencyPackagePlugin let project = Project.makeAppModule( name: "Auth", @@ -14,6 +14,7 @@ let project = Project.makeAppModule( .SPM.tcaFlow, .Domain(implements: .UseCase), .Shared(implements: .Shared), + .Presentation(implements: .Web) ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift index de816b75..8b8c20f4 100644 --- a/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift +++ b/Projects/Presentation/Auth/Sources/Coordinator/Reducer/AuthCoordinator.swift @@ -11,6 +11,7 @@ import ComposableArchitecture import TCAFlow import Entity +import Web @FlowCoordinator(screen: "AuthScreen", navigation: true) public struct AuthCoordinator { @@ -106,6 +107,15 @@ extension AuthCoordinator { case .routeAction(_, action: .onboarding(.delegate(.presentMainTab))): return .send(.navigation(.presentMainTab)) + // MARK: - 약관 상세 보기 → WebView 화면 푸시 + + case let .routeAction(_, action: .login(.delegate(.presentTermsWeb(urlString)))): + state.routes.push(.web(.init(url: urlString))) + return .none + + case .routeAction(_, action: .web(.backToRoot)): + return .send(.view(.backAction)) + default: return .none } @@ -151,12 +161,16 @@ extension AuthCoordinator { } } +// swiftformat:disable extensionAccessControl extension AuthCoordinator { @Reducer public enum AuthScreen { case login(LoginFeature) case onboarding(OnBoardingFeature) + case web(WebReducer) } } +// swiftformat:enable extensionAccessControl + extension AuthCoordinator.AuthScreen.State: Equatable {} diff --git a/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift b/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift index fa0b9195..aec0440f 100644 --- a/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift +++ b/Projects/Presentation/Auth/Sources/Coordinator/View/AuthCoordinatorView.swift @@ -12,6 +12,8 @@ import SwiftUI import ComposableArchitecture import TCAFlow +import Web + public struct AuthCoordinatorView: View { @Bindable private var store: StoreOf @@ -31,6 +33,10 @@ public struct AuthCoordinatorView: View { case let .onboarding(onboardingStore): OnBoardingView(store: onboardingStore) .navigationBarBackButtonHidden() + + case let .web(webStore): + WebView(store: webStore) + .navigationBarBackButtonHidden() } } } diff --git a/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift b/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift index 463bf21b..472f0a21 100644 --- a/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift +++ b/Projects/Presentation/Auth/Sources/Main/Reducer/LoginFeature.swift @@ -28,6 +28,9 @@ public struct LoginFeature { @Shared var userSession: UserSession var loginEntity: LoginEntity? + /// Apple/Google 신규 가입자 약관 동의 바텀시트. nil 이면 미표시. + @Presents var termsAgreement: TermsAgreementFeature.State? + public init( userSession: UserSession = .empty ) { @@ -41,6 +44,7 @@ public struct LoginFeature { case async(AsyncAction) case inner(InnerAction) case delegate(DelegateAction) + case termsAgreement(PresentationAction) } // MARK: - ViewAction @@ -70,6 +74,8 @@ public struct LoginFeature { /// 로그인이 성공해 토큰을 모두 확보한 시점. 코디네이터에서 다음 화면으로 전환. case presentOnboarding case presentMainTab + /// 약관 상세 WebView 화면으로 이동 (코디네이터에서 처리). + case presentTermsWeb(urlString: String) } nonisolated enum CancelID: Hashable { @@ -100,8 +106,14 @@ public struct LoginFeature { case let .delegate(delegateAction): handleDelegateAction(state: &state, action: delegateAction) + + case let .termsAgreement(presentationAction): + handleTermsAgreement(state: &state, action: presentationAction) } } + .ifLet(\.$termsAgreement, action: \.termsAgreement) { + TermsAgreementFeature() + } } } @@ -112,7 +124,7 @@ extension LoginFeature { ) -> Effect { switch action { case let .signInWithSocial(social): - .send(.async(.login(socialType: social))) + return .send(.async(.login(socialType: social))) } } @@ -185,12 +197,16 @@ extension LoginFeature { switch result { case let .success(loginEntity): state.loginEntity = loginEntity - - if loginEntity.isNewUser { - return .send(.delegate(.presentOnboarding)) - } else { + + guard loginEntity.isNewUser else { return .send(.delegate(.presentMainTab)) } + // 신규 가입자 중 Apple/Google 만 약관 동의 바텀시트 노출, 그 외(카카오)는 바로 온보딩. + if state.currentSocialType == .apple || state.currentSocialType == .google { + state.termsAgreement = TermsAgreementFeature.State() + return .none + } + return .send(.delegate(.presentOnboarding)) case let .failure(error): #logNetwork("로그인 실패", error.localizedDescription) @@ -224,6 +240,33 @@ extension LoginFeature { case .presentMainTab: return .none + + case .presentTermsWeb: + // 코디네이터가 라우팅 처리. 리듀서에서는 부수효과 없음. + return .none + } + } + + private func handleTermsAgreement( + state: inout State, + action: PresentationAction + ) -> Effect { + switch action { + case .presented(.delegate(.confirmed)): + // 약관 동의 완료 → 바텀시트 닫고 온보딩 진행. + state.termsAgreement = nil + return .send(.delegate(.presentOnboarding)) + + case .presented(.delegate(.dismissed)), .dismiss: + state.termsAgreement = nil + return .none + + case let .presented(.delegate(.openDocument(document))): + // 약관 상세 보기 → 코디네이터가 WebView 화면으로 라우팅. + return .send(.delegate(.presentTermsWeb(urlString: document.urlString))) + + case .presented: + return .none } } } diff --git a/Projects/Presentation/Auth/Sources/Main/Reducer/TermsAgreementFeature.swift b/Projects/Presentation/Auth/Sources/Main/Reducer/TermsAgreementFeature.swift new file mode 100644 index 00000000..49cd19e1 --- /dev/null +++ b/Projects/Presentation/Auth/Sources/Main/Reducer/TermsAgreementFeature.swift @@ -0,0 +1,76 @@ +// +// TermsAgreementFeature.swift +// Auth +// +// Apple/Google 신규 가입자 약관 동의 바텀시트 리듀서. +// LoginFeature 에서 @Presents 로 보유하고 .termsAgreementSheet 커스텀 오버레이로 표시. +// + +import Foundation + +import ComposableArchitecture +import Entity + +@Reducer +public struct TermsAgreementFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var agreed: Set = [] + + /// 필수 약관 모두 동의 시 true. + public var canAgree: Bool { + TermsDocument.requiredCases.allSatisfy { agreed.contains($0) } + } + + public init() {} + } + + public enum Action: ViewAction { + case view(View) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case toggled(TermsDocument) + case detailTapped(TermsDocument) + case agreeTapped + case dismissTapped + } + + public enum DelegateAction: Equatable { + case confirmed + case dismissed + /// 약관 상세 보기 — 부모가 WebView 로 표시. + case openDocument(TermsDocument) + } + + public var body: some Reducer { + Reduce { state, action in + switch action { + case let .view(.toggled(document)): + if state.agreed.contains(document) { + state.agreed.remove(document) + } else { + state.agreed.insert(document) + } + return .none + + case let .view(.detailTapped(document)): + return .send(.delegate(.openDocument(document))) + + case .view(.agreeTapped): + guard state.canAgree else { return .none } + return .send(.delegate(.confirmed)) + + case .view(.dismissTapped): + return .send(.delegate(.dismissed)) + + case .delegate: + return .none + } + } + } +} diff --git a/Projects/Presentation/Auth/Sources/Main/View/Components/TermsAgreementView.swift b/Projects/Presentation/Auth/Sources/Main/View/Components/TermsAgreementView.swift new file mode 100644 index 00000000..fa05fb0c --- /dev/null +++ b/Projects/Presentation/Auth/Sources/Main/View/Components/TermsAgreementView.swift @@ -0,0 +1,141 @@ +// +// TermsAgreementView.swift +// Auth +// +// .pen `애플 구글_약관 동의` — Apple/Google 신규 가입자 약관 동의 커스텀 바텀시트. +// Picke `.customAlert` / Attendance `.attendanceModal` 과 동일한 오버레이 방식. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem +import Entity + +struct TermsAgreementView: View { + @Bindable var store: StoreOf + + var body: some View { + ZStack(alignment: .bottom) { + Color.black.opacity(0.4) + .ignoresSafeArea() + .onTapGesture { store.send(.view(.dismissTapped)) } + + sheet() + } + } + + @ViewBuilder + private func sheet() -> some View { + VStack(spacing: 20) { + grabber() + logo() + header() + VStack(spacing: 4) { + ForEach(TermsDocument.allCases) { document in + agreementRow(document) + } + } + agreeButton() + } + .padding(.top, 16) + .padding(.horizontal, 16) + .padding(.bottom, 40) + .frame(maxWidth: .infinity) + .background( + UnevenRoundedRectangle(topLeadingRadius: 26, topTrailingRadius: 26) + .fill(Color.beige50) + .ignoresSafeArea(edges: .bottom) + ) + } + + @ViewBuilder + private func grabber() -> some View { + RoundedRectangle(cornerRadius: 2) + .fill(.beige600) + .frame(width: 40, height: 4) + } + + @ViewBuilder + private func logo() -> some View { + Image(asset: .appLogo) + .resizable() + .scaledToFit() + .frame(width: 50, height: 32) + } + + @ViewBuilder + private func header() -> some View { + VStack(spacing: 6) { + Text("픽케 약관 동의서") + .pretendardFont(family: .SemiBold, size: 24) + .foregroundStyle(.neutral900) + Text("편리한 서비스 이용을 위해 약관에 동의해 주세요") + .pretendardFont(family: .Regular, size: 14) + .foregroundStyle(.neutral400) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private func agreementRow(_ document: TermsDocument) -> some View { + let isChecked = store.agreed.contains(document) + HStack(spacing: 12) { + Button { store.send(.view(.toggled(document))) } label: { + HStack(spacing: 12) { + checkbox(isChecked: isChecked) + Text(document.title) + .pretendardFont(family: .Regular, size: 14) + .foregroundStyle(.neutral900) + } + } + .buttonStyle(.plain) + + Spacer() + + Button { store.send(.view(.detailTapped(document))) } label: { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(.neutral900) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .padding(16) + .background(.beige300, in: RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6).stroke(.beige600, lineWidth: 1) + } + } + + @ViewBuilder + private func checkbox(isChecked: Bool) -> some View { + ZStack { + Circle() + .fill(isChecked ? Color.primary500 : Color.primary50) + .overlay { Circle().stroke(isChecked ? Color.primary500 : Color.primary100, lineWidth: 1) } + if isChecked { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.beige50) + } + } + .frame(width: 24, height: 24) + } + + @ViewBuilder + private func agreeButton() -> some View { + Button { store.send(.view(.agreeTapped)) } label: { + Text("동의") + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.beige50) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(.primary500, in: RoundedRectangle(cornerRadius: 2)) + } + .buttonStyle(.plain) + .disabled(!store.canAgree) + .opacity(store.canAgree ? 1 : 0.5) + } +} diff --git a/Projects/Presentation/Auth/Sources/Main/View/LoginView.swift b/Projects/Presentation/Auth/Sources/Main/View/LoginView.swift index b18d343e..edcf58ed 100644 --- a/Projects/Presentation/Auth/Sources/Main/View/LoginView.swift +++ b/Projects/Presentation/Auth/Sources/Main/View/LoginView.swift @@ -34,6 +34,9 @@ public struct LoginView: View { } .toastOverlay() } + .pickeModal($store.scope(state: \.termsAgreement, action: \.termsAgreement)) { termsStore in + TermsAgreementView(store: termsStore) + } } } diff --git a/Projects/Presentation/Web/Project.swift b/Projects/Presentation/Web/Project.swift new file mode 100644 index 00000000..73182b1b --- /dev/null +++ b/Projects/Presentation/Web/Project.swift @@ -0,0 +1,17 @@ +import Foundation +import ProjectDescription +import DependencyPlugin +import ProjectTemplatePlugin +import DependencyPackagePlugin + +let project = Project.makeAppModule( + name: "Web", + bundleId: .appBundleID(name: ".Web"), + product: .staticFramework, + settings: .settings(), + dependencies: [ + .SPM.composableArchitecture, + .Shared(implements: .Shared), + ], + sources: ["Sources/**"] +) \ No newline at end of file diff --git a/Projects/Presentation/Web/Sources/View/WebRepresentableView.swift b/Projects/Presentation/Web/Sources/View/WebRepresentableView.swift new file mode 100644 index 00000000..11627fcb --- /dev/null +++ b/Projects/Presentation/Web/Sources/View/WebRepresentableView.swift @@ -0,0 +1,148 @@ +// +// WebRepresentableView.swift +// Profile +// +// Created by Wonji Suh on 1/4/26. +// + +import SwiftUI +import WebKit + +import DesignSystem + +public struct WebRepresentableView: UIViewRepresentable { + // MARK: - URL to load + + private var urlToLoad: String + + public init(urlToLoad: String) { + self.urlToLoad = urlToLoad + } + + public func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + public func makeUIView(context: Context) -> UIView { + // 컨테이너 + let containerView = UIView() + containerView.backgroundColor = UIColor(red: 26 / 255.0, green: 26 / 255.0, blue: 26 / 255.0, alpha: 1.0) + + // WKWebView + let configuration = WKWebViewConfiguration() + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.scrollView.showsVerticalScrollIndicator = false + webView.scrollView.minimumZoomScale = 1.0 + webView.scrollView.maximumZoomScale = 1.0 + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator + webView.allowsLinkPreview = true + webView.backgroundColor = UIColor(red: 26 / 255.0, green: 26 / 255.0, blue: 26 / 255.0, alpha: 1.0) + webView.translatesAutoresizingMaskIntoConstraints = false + + // ProgressView(로딩 인디케이터) 표시 + let loadingContainer = createLoadingIndicator() + loadingContainer.translatesAutoresizingMaskIntoConstraints = false + + containerView.addSubview(webView) + containerView.addSubview(loadingContainer) + + NSLayoutConstraint.activate([ + // WebView는 전체 + webView.topAnchor.constraint(equalTo: containerView.topAnchor), + webView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + webView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + + // 로딩 인디케이터는 중앙 + loadingContainer.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + loadingContainer.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + ]) + + // 코디네이터가 참조 보관 + context.coordinator.webView = webView + context.coordinator.loadingIndicator = loadingContainer + + // 로드 직전에 로딩 컨테이너 표시 + loadingContainer.alpha = 1 + + // 로드 + _Concurrency.Task { + await loadURLInWebView(urlToLoad: urlToLoad, webView: webView) + } + + return containerView + } + + func loadURLInWebView(urlToLoad: String, webView: WKWebView) async { + guard let url = URL(string: urlToLoad) else { + return + } + let request = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy) + + await MainActor.run { + webView.configuration.upgradeKnownHostsToHTTPS = true + webView.configuration.preferences.minimumFontSize = 16 + webView.load(request) + } + } + + public func updateUIView(_: UIView, context _: Context) { + // 필요 시 업데이트 + } + + // MARK: - Loading Indicator + + private func createLoadingIndicator() -> UIView { + let indicator = UIActivityIndicatorView(style: .large) + indicator.color = .white + indicator.hidesWhenStopped = false + indicator.startAnimating() + return indicator + } + + // MARK: - Coordinator + + public class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { + var parent: WebRepresentableView + weak var webView: WKWebView? + weak var loadingIndicator: UIView? + + init(_ parent: WebRepresentableView) { + self.parent = parent + } + + // MARK: - WKNavigationDelegate + + public func webView(_: WKWebView, didStartProvisionalNavigation _: WKNavigation!) { + // 로딩 시작 → AnimatedImage 표시 (MainActor 최적화) + Task { @MainActor [weak self] in + guard let self, let loadingIndicator else { return } + loadingIndicator.alpha = 1 + } + } + + public func webView(_: WKWebView, didFinish _: WKNavigation!) { + // 로딩 완료 → AnimatedImage 숨김(페이드아웃) + hideLoadingIndicator() + } + + public func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { + hideLoadingIndicator() + } + + public func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { + hideLoadingIndicator() + } + + private func hideLoadingIndicator() { + Task { @MainActor [weak self] in + guard let self, let loadingIndicator else { return } + + UIView.animate(withDuration: 0.3, animations: { + loadingIndicator.alpha = 0 + }) + } + } + } +} diff --git a/Projects/Presentation/Web/Sources/View/WebView.swift b/Projects/Presentation/Web/Sources/View/WebView.swift new file mode 100644 index 00000000..19b9996a --- /dev/null +++ b/Projects/Presentation/Web/Sources/View/WebView.swift @@ -0,0 +1,44 @@ +// +// WebView.swift +// Profile +// +// Created by Wonji Suh on 1/4/26. +// + +import ComposableArchitecture +import DesignSystem +import SwiftUI + +public struct WebView: View { + @Bindable var store: StoreOf + + public init( + store: StoreOf, + ) { + self.store = store + } + + public var body: some View { + ZStack { + Color.basicBlack + .edgesIgnoringSafeArea(.all) + + VStack { + Spacer() + .frame(height: 12) + + PickeNavigationBar(onBack: { store.send(.backToRoot) }) { + Color.clear.frame(width: 24, height: 24) + } + .foregroundStyle(.beige50) + + Spacer() + .frame(height: 20) + + WebRepresentableView(urlToLoad: store.url) + .edgesIgnoringSafeArea(.bottom) + } + .navigationBarBackButtonHidden(true) + } + } +} diff --git a/Projects/Presentation/Web/Tests/Sources/WebTests.swift b/Projects/Presentation/Web/Tests/Sources/WebTests.swift new file mode 100644 index 00000000..5c330c51 --- /dev/null +++ b/Projects/Presentation/Web/Tests/Sources/WebTests.swift @@ -0,0 +1,27 @@ +// +// WebTests.swift +// Presentation.WebTests +// +// Created by Roy on 2026-06-04. +// + +import Testing +@testable import Web + +struct WebTests { + + @Test + func webExample() { + // This is an example of a test case. + #expect(true) + } + + @Test + func webLogicTest() { + // Add your test logic here. + let result = true + #expect(result == true) + } + +} + diff --git a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift index 07a7ccea..f3bbcb4d 100644 --- a/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift +++ b/Projects/Shared/DesignSystem/Sources/Color/ShapeStyle+.swift @@ -71,6 +71,7 @@ public extension ShapeStyle where Self == Color { // MARK: - Primitive / Status + static var basicBlack: Color { .init(hex: "1A1A1A") } static var errorAlpha: Color { .init(hex: "C92D33", alpha: 0.4) } static var errorDefault: Color { .init(hex: "C92D33") } static var errorStrong: Color { .init(hex: "D7110C") } diff --git a/Projects/Shared/DesignSystem/Sources/UI/Modal/PickeModal.swift b/Projects/Shared/DesignSystem/Sources/UI/Modal/PickeModal.swift new file mode 100644 index 00000000..9794562c --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/UI/Modal/PickeModal.swift @@ -0,0 +1,30 @@ +// +// PickeModal.swift +// DesignSystem +// +// TCA @Presents 스토어 기반 커스텀 바텀시트 오버레이. +// 예: .pickeModal($store.scope(state: \.termsAgreement, action: \.termsAgreement)) { TermsAgreementSheetView(store: $0) } +// + +import SwiftUI + +import ComposableArchitecture + +public extension View { + /// 하단에서 슬라이드업 되는 커스텀 모달(바텀시트) 오버레이. + /// - Parameters: + /// - store: `@Presents` 스코프 바인딩 (nil 이면 미표시). + /// - content: 표시할 모달 뷰 (스코프된 스토어를 받음). + func pickeModal( + _ store: Binding?>, + @ViewBuilder content: @escaping (Store) -> some View + ) -> some View { + overlay { + if let modalStore = store.wrappedValue { + content(modalStore) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.3), value: store.wrappedValue != nil) + } +} From 3170fe6406aea33af5da825c32dc954c7ae72f6b Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 23:47:37 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=ED=81=90=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=8C=85=20X=20=E2=86=92=20=EB=B6=80=EB=AA=A8=20=EB=A3=A8?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=B5=EA=B7=80(popToRoot)=20+=20WebView=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20ProgressView=20=EC=A0=84=ED=99=98=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift | 2 +- .../Sources/Coordinator/Reducer/ChatCoordinator.swift | 8 +++++--- .../Sources/Coordinator/Reducer/HomeCoordinator.swift | 4 ++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift b/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift index 06e56afb..508ced4e 100644 --- a/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/AudioPlayer/AudioPlayerRepositoryImpl.swift @@ -82,7 +82,7 @@ final class AudioPlayerService { func load(url: URL) async -> Bool { let item = AVPlayerItem(url: url) player.replaceCurrentItem(with: item) - player.seek(to: .zero) + await player.seek(to: .zero) // 자산이 실제 재생 가능한지 확인 → 실패 시 오류 배너 노출 트리거. do { return try await item.asset.load(.isPlayable) diff --git a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift index 7dcf667a..a66c49cd 100644 --- a/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift +++ b/Projects/Presentation/Chat/Sources/Coordinator/Reducer/ChatCoordinator.swift @@ -47,6 +47,8 @@ public struct ChatCoordinator { public enum DelegateAction: Equatable { case dismiss + /// 큐레이팅 X — 채팅 디투어 전체를 빠져나가 부모 스택 최상위(root)로 복귀. + case popToRoot } func handleRoute( @@ -125,8 +127,8 @@ extension ChatCoordinator { return .send(.view(.backAction)) case .routeAction(_, action: .curation(.delegate(.close))): - // X : 채팅 플로우 전체를 빠져나가 앱 루트(홈)로 이동 - return .send(.delegate(.dismiss)) + // X : 채팅 디투어 전체를 빠져나가 부모 스택 최상위(root)로 복귀 + return .send(.delegate(.popToRoot)) case let .routeAction(_, action: .curation(.delegate(.openBattle(battleId)))): state.routes.push(.preVote(.init(battleId: battleId))) @@ -156,7 +158,7 @@ extension ChatCoordinator { action: DelegateAction ) -> Effect { switch action { - case .dismiss: + case .dismiss, .popToRoot: .none } } diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index 5a1148e1..94aa6f13 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -71,6 +71,10 @@ extension HomeCoordinator { case .routeAction(_, action: .chat(.delegate(.dismiss))): return .send(.view(.backAction)) + case .routeAction(_, action: .chat(.delegate(.popToRoot))): + // 큐레이팅 X — 홈 루트로 복귀 + return .send(.view(.backToRootAction)) + default: return .none } From 6d104a3aa609eea742037c190841944a27a89365 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 23:47:58 +0900 Subject: [PATCH 07/12] =?UTF-8?q?chore:=20HomeSkeletonView=20Components=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20+=20Splash/Presentation/App=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=A0=95=EB=A6=AC=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/ContentView.swift | 4 + Projects/App/Sources/Reducer/AppReducer.swift | 2 +- .../View/Components/HomeSkeletonView.swift | 339 ++++++++++++++++++ .../Presentation/Presentation/Project.swift | 3 +- .../Exported/PresentationExported.swift | 1 + Projects/Presentation/Splash/Project.swift | 2 +- .../Sources/Reducer/SplashFeature.swift | 10 +- 7 files changed, 353 insertions(+), 8 deletions(-) create mode 100644 Projects/Presentation/Home/Sources/Main/View/Components/HomeSkeletonView.swift diff --git a/Projects/App/Sources/ContentView.swift b/Projects/App/Sources/ContentView.swift index 97a8c483..4cf9a402 100644 --- a/Projects/App/Sources/ContentView.swift +++ b/Projects/App/Sources/ContentView.swift @@ -1,5 +1,7 @@ import SwiftUI import Presentation +import Auth +import Web public struct ContentView: View { public init() {} @@ -20,3 +22,5 @@ public struct ContentView: View { AuthCoordinator() })) } + + diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index a8fc4724..c6572b46 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -236,7 +236,7 @@ public struct AppReducer: Sendable { private func handleScopeNavigation(action: ScopeAction) -> Effect { switch action { case .splash(.view(.onAppear)): - return .none + return .send(.view(.presentAuth)) case .splash(.delegate(.presentAuth)): return .run { send in diff --git a/Projects/Presentation/Home/Sources/Main/View/Components/HomeSkeletonView.swift b/Projects/Presentation/Home/Sources/Main/View/Components/HomeSkeletonView.swift new file mode 100644 index 00000000..ce7b7093 --- /dev/null +++ b/Projects/Presentation/Home/Sources/Main/View/Components/HomeSkeletonView.swift @@ -0,0 +1,339 @@ +// +// 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) + } + } + + @ViewBuilder + 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) + ) + } + + @ViewBuilder + 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 + } + } + + @ViewBuilder + 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/Presentation/Project.swift b/Projects/Presentation/Presentation/Project.swift index 2da1f68d..d50394e9 100644 --- a/Projects/Presentation/Presentation/Project.swift +++ b/Projects/Presentation/Presentation/Project.swift @@ -12,7 +12,8 @@ let project = Project.makeModule( dependencies: [ .Presentation(implements: .Splash), .Presentation(implements: .Auth), - .Presentation(implements: .MainTab) + .Presentation(implements: .MainTab), + .Presentation(implements: .Web) ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift b/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift index 13e626a0..0bf0979d 100644 --- a/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift +++ b/Projects/Presentation/Presentation/Sources/Exported/PresentationExported.swift @@ -10,3 +10,4 @@ @_exported import Splash @_exported import Auth @_exported import MainTab +@_exported import Web diff --git a/Projects/Presentation/Splash/Project.swift b/Projects/Presentation/Splash/Project.swift index 22aa652b..8067ad63 100644 --- a/Projects/Presentation/Splash/Project.swift +++ b/Projects/Presentation/Splash/Project.swift @@ -12,7 +12,7 @@ let project = Project.makeAppModule( dependencies: [ .SPM.composableArchitecture, .Domain(implements: .UseCase), - .Shared(implements: .DesignSystem) + .Shared(implements: .Shared), ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift b/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift index 0560f3ac..6ac0463d 100644 --- a/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift +++ b/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift @@ -100,11 +100,11 @@ extension SplashFeature { let hasStoredCredential = hasStoredCredential return .run { send in try await clock.sleep(for: .seconds(1.2)) - if hasStoredCredential { - await send(.delegate(.presentMainTab)) - } else { - await send(.delegate(.presentAuth)) - } +// if hasStoredCredential { +// await send(.delegate(.presentMainTab)) +// } else { +// await send(.delegate(.presentAuth)) +// } } } From d97aba0f1c4a9e1c918d2f0bab68c266b6f9c8af Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 4 Jun 2026 23:51:27 +0900 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20Fastfile=20platform=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=20=EB=8B=AB=EB=8A=94=20end=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=E2=80=94=20=EB=AC=B8=EB=B2=95=20=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastlane/Fastfile | 44 +------------------------------------------- 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index bd2448be..9e7a0a04 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -355,49 +355,7 @@ platform :ios do UI.user_error!("⚠️ version 옵션을 지정해주세요: fastlane submit_for_review version:'1.0.0'") end end - end -# 🤖 TDD 자동화 CI 레인 -desc "TDD 테스트 및 자동 배포" -lane :tdd_ci do |options| - domain = options[:domain] || "All" - - UI.header "🧪 #{domain} 도메인 TDD CI 시작" - - # 1. 클린 빌드 - clear_derived_data - - # 2. 테스트 실행 - run_tests( - workspace: "Picke.xcworkspace", - devices: ["iPhone 15"], - scheme: "Picke" - ) - - # 3. 커버리지 체크 - slather( - cobertura_xml: true, - jenkins: true, - scheme: "Picke", - workspace: "Picke.xcworkspace" - ) - - # 4. 성공 시 알림 - slack( - message: "✅ #{domain} 도메인 TDD 테스트 성공!", - success: true, - channel: "#ios-dev" - ) if ENV["SLACK_URL"] - - UI.success "🎉 #{domain} 도메인 TDD CI 완료!" -end -# 🚨 TDD 테스트 실패 시 처리 -error do |lane, exception| - slack( - message: "❌ TDD 테스트 실패: #{exception.message}", - success: false, - channel: "#ios-dev" - ) if ENV["SLACK_URL"] -end + From 109f72b929810564d3c6bfc5cb9ae3bae4a3b81c Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 5 Jun 2026 00:05:53 +0900 Subject: [PATCH 09/12] =?UTF-8?q?refactor:=20HifiFeature=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=95=A1=EC=85=98=20search=20=E2=86=92=20searchReq?= =?UTF-8?q?uested=20(TCA=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D)=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Hifi/Sources/Reducer/HifiFeature.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift b/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift index 11eac7c9..a6e9100a 100644 --- a/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift +++ b/Projects/Presentation/Hifi/Sources/Reducer/HifiFeature.swift @@ -48,7 +48,7 @@ public struct HifiFeature { } public enum AsyncAction: Equatable { - case search(reset: Bool) + case searchRequested(reset: Bool) } public enum InnerAction: Equatable { @@ -91,21 +91,21 @@ extension HifiFeature { ) -> Effect { switch action { case .onAppear: - return .send(.async(.search(reset: true))) + return .send(.async(.searchRequested(reset: true))) case let .categoryTapped(category): state.selectedCategory = category - return .send(.async(.search(reset: true))) + return .send(.async(.searchRequested(reset: true))) case let .swipedCategory(forward): // 좌우 스와이프 → 인접 카테고리로 전환 (범위 벗어나면 무시) guard let next = adjacentCategory(in: state, forward: forward) else { return .none } state.selectedCategory = next - return .send(.async(.search(reset: true))) + return .send(.async(.searchRequested(reset: true))) case let .sortTapped(sort): state.selectedSort = sort - return .send(.async(.search(reset: true))) + return .send(.async(.searchRequested(reset: true))) case let .itemTapped(id): return .send(.delegate(.openBattle(battleId: id))) @@ -113,7 +113,7 @@ extension HifiFeature { case .reachedBottom: // 무한 스크롤: 다음 페이지가 있고 로딩 중이 아니면 추가 로드. guard state.hasNext, !state.isLoading else { return .none } - return .send(.async(.search(reset: false))) + return .send(.async(.searchRequested(reset: false))) } } @@ -131,7 +131,7 @@ extension HifiFeature { action: AsyncAction ) -> Effect { switch action { - case let .search(reset): + case let .searchRequested(reset): state.isLoading = true if reset { state.items = [] } let category = state.selectedCategory.queryValue From e94bf55bdb069dcd4d4f223044ab631c27ad0270 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 5 Jun 2026 00:18:01 +0900 Subject: [PATCH 10/12] Keep fastlane on a supported bundled Ruby runtime Fastlane should run through the project Gemfile on Ruby 3.3.9 so local automation avoids the Ruby 3.2 support warning and stays reproducible. Rejected: Continue using globally installed fastlane | it bypasses Gemfile locking and preserves the setup warning. --- .ruby-version | 1 + Gemfile | 2 ++ Gemfile.lock | 3 +++ fastlane/Fastfile | 2 -- 4 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 .ruby-version diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..3b47f2e4 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.9 diff --git a/Gemfile b/Gemfile index 7a118b49..1bc925f1 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,5 @@ source "https://rubygems.org" +ruby "3.3.9" + gem "fastlane" diff --git a/Gemfile.lock b/Gemfile.lock index d76974dd..4b84c268 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -234,5 +234,8 @@ PLATFORMS DEPENDENCIES fastlane +RUBY VERSION + ruby 3.3.9p170 + BUNDLED WITH 2.7.2 diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 9e7a0a04..6a6884ec 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,5 +1,4 @@ # Fastlane 기본 설정 -update_fastlane default_platform(:ios) ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "600" @@ -358,4 +357,3 @@ platform :ios do end - From 17140bcf7c362fc06db4295b4c24d32e38f7e7b3 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 5 Jun 2026 00:24:21 +0900 Subject: [PATCH 11/12] Reduce repeated fastlane archive work QA and release archives should reuse local package state and incremental Swift compilation while exposing build progress and timing data. Rejected: Skip archive or use a debug configuration | those outputs cannot be uploaded as the requested App Store build. --- fastlane/Fastfile | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 6a6884ec..3949fc99 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -100,16 +100,21 @@ platform :ios do scheme: "Picke-Stage", configuration: "Release", export_method: "app-store", - output_directory: File.expand_path("../#{OUTPUT_DIR}"), + output_directory: File.expand_path(OUTPUT_DIR), output_name: IPA_FILENAME, clean: false, - silent: true, + silent: false, + build_timing_summary: true, + disable_package_automatic_updates: true, + skip_package_dependencies_resolution: true, + skip_package_repository_fetches: true, + use_system_scm: true, xcargs: "CODE_SIGN_IDENTITY='Apple Distribution' CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM=#{TEAM_ID} " + "COMPILER_INDEX_STORE_ENABLE=NO ENABLE_BITCODE=NO " + - "SWIFT_COMPILATION_MODE=wholemodule -parallelizeTargets " + + "SWIFT_COMPILATION_MODE=incremental -parallelizeTargets " + "ENABLE_PARALLEL_SIGNING=YES DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES " + "FRAMEWORK_SEARCH_PATHS='$(inherited)' LIBRARY_SEARCH_PATHS='$(inherited)' " + - "-allowProvisioningUpdates -quiet", + "-allowProvisioningUpdates", export_options: { signingStyle: "manual", uploadBitcode: false, @@ -257,16 +262,21 @@ platform :ios do scheme: "Picke-Prod", configuration: "Release", export_method: "app-store", - output_directory: File.expand_path("../#{OUTPUT_DIR}"), + output_directory: File.expand_path(OUTPUT_DIR), output_name: IPA_FILENAME, clean: false, - silent: true, + silent: false, + build_timing_summary: true, + disable_package_automatic_updates: true, + skip_package_dependencies_resolution: true, + skip_package_repository_fetches: true, + use_system_scm: true, xcargs: "CODE_SIGN_IDENTITY='Apple Distribution: Wonji Suh (N94CS4N6VR)' CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM=#{TEAM_ID} " + "COMPILER_INDEX_STORE_ENABLE=NO ENABLE_BITCODE=NO " + - "SWIFT_COMPILATION_MODE=wholemodule -parallelizeTargets " + + "SWIFT_COMPILATION_MODE=incremental -parallelizeTargets " + "ENABLE_PARALLEL_SIGNING=YES DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING=YES " + "FRAMEWORK_SEARCH_PATHS='$(inherited)' LIBRARY_SEARCH_PATHS='$(inherited)' " + - "-allowProvisioningUpdates -quiet", + "-allowProvisioningUpdates", export_options: { signingStyle: "manual", uploadBitcode: false, @@ -355,5 +365,3 @@ platform :ios do end end end - - From 82392077c437979bcb5e6d0a695cfd6dd172ff9f Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 5 Jun 2026 00:28:58 +0900 Subject: [PATCH 12/12] Restore credential-aware splash routing and capture fastlane usage Splash completion should decide between the main tab and authentication from stored credentials, while generated fastlane lane documentation remains available in the repository. Rejected: Keep the unconditional auth presentation on splash appearance | it bypasses the credential-aware delegate routing. --- Projects/App/Sources/Reducer/AppReducer.swift | 2 +- .../Sources/Reducer/SplashFeature.swift | 10 ++-- fastlane/README.md | 48 +++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 fastlane/README.md diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index c6572b46..a8fc4724 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -236,7 +236,7 @@ public struct AppReducer: Sendable { private func handleScopeNavigation(action: ScopeAction) -> Effect { switch action { case .splash(.view(.onAppear)): - return .send(.view(.presentAuth)) + return .none case .splash(.delegate(.presentAuth)): return .run { send in diff --git a/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift b/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift index 6ac0463d..0560f3ac 100644 --- a/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift +++ b/Projects/Presentation/Splash/Sources/Reducer/SplashFeature.swift @@ -100,11 +100,11 @@ extension SplashFeature { let hasStoredCredential = hasStoredCredential return .run { send in try await clock.sleep(for: .seconds(1.2)) -// if hasStoredCredential { -// await send(.delegate(.presentMainTab)) -// } else { -// await send(.delegate(.presentAuth)) -// } + if hasStoredCredential { + await send(.delegate(.presentMainTab)) + } else { + await send(.delegate(.presentAuth)) + } } } diff --git a/fastlane/README.md b/fastlane/README.md new file mode 100644 index 00000000..6a379018 --- /dev/null +++ b/fastlane/README.md @@ -0,0 +1,48 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## iOS + +### ios QA + +```sh +[bundle exec] fastlane ios QA +``` + +Upload to TestFlight (Debug) + +### ios release + +```sh +[bundle exec] fastlane ios release +``` + +Submit to App Store + +### ios submit_for_review + +```sh +[bundle exec] fastlane ios submit_for_review +``` + +Submit already uploaded version for review + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).