diff --git a/Projects/App/Sources/View/AppRootView.swift b/Projects/App/Sources/View/AppRootView.swift index 51f72cbc..97dc2024 100644 --- a/Projects/App/Sources/View/AppRootView.swift +++ b/Projects/App/Sources/View/AppRootView.swift @@ -49,7 +49,7 @@ private extension AppRootView { case .launch: LaunchScreenView() case .login: - LoginContentView( + LoginCoordinatorView( onLoginCompleted: { transition(to: .main) }, onSignupCompleted: { nickname in self.nickname = nickname diff --git a/Projects/App/Sources/View/LivithMainTabView.swift b/Projects/App/Sources/View/LivithMainTabView.swift index 0c8daeca..83958567 100644 --- a/Projects/App/Sources/View/LivithMainTabView.swift +++ b/Projects/App/Sources/View/LivithMainTabView.swift @@ -26,24 +26,22 @@ struct LivithMainTabView: View { // MARK: - Property @State private var selectedTab: Tab = .home - @State private var isTabBarHidden: Bool = false @State private var deepLinkConcertID: Int? @State private var deepLinkInitialTab: SegmentedTabBarType.DetailTab = .artistDetail @State private var deepLinkInitialSection: ConcertInfoSection? @State private var deepLinkShowInterest: Bool = false - + // MARK: - LifeCycle - + init() { configureTabBarAppearance() } - + // MARK: - Body - + var body: some View { TabView(selection: $selectedTab) { - HomeContentView( - isTabBarHidden: $isTabBarHidden, + HomeCoordinatorView( deepLinkConcertID: $deepLinkConcertID, deepLinkInitialTab: $deepLinkInitialTab, deepLinkInitialSection: $deepLinkInitialSection, @@ -53,17 +51,14 @@ struct LivithMainTabView: View { .tabItem { makeTabItem(.home) } - .toolbar(isTabBarHidden ? .hidden : .visible, for: .tabBar) - - SearchContentView(isTabBarHidden: $isTabBarHidden) + + SearchCoordinatorView() .tag(Tab.search) .tabItem { makeTabItem(.search) } - .toolbar(isTabBarHidden ? .hidden : .visible, for: .tabBar) - - UserContentView( - isTabBarHidden: $isTabBarHidden, + + UserCoordinatorView( onNavigateToHome: { selectedTab = .home } @@ -72,7 +67,6 @@ struct LivithMainTabView: View { .tabItem { makeTabItem(.my) } - .toolbar(isTabBarHidden ? .hidden : .visible, for: .tabBar) } .preferredColorScheme(.dark) .onChange(of: selectedTab) { _, newTab in @@ -109,24 +103,24 @@ private extension LivithMainTabView { let appearance = UITabBarAppearance() appearance.configureWithOpaqueBackground() appearance.backgroundColor = UIColor(Color.livithColor(.black100)) - + // 상단 구분선 설정 appearance.shadowColor = UIColor(Color.livithColor(.black50)) - + appearance.stackedLayoutAppearance.normal.iconColor = UIColor(Color.livithColor(.black50)) appearance.stackedLayoutAppearance.normal.titleTextAttributes = [ .foregroundColor: UIColor(Color.livithColor(.black50)) ] - + appearance.stackedLayoutAppearance.selected.iconColor = UIColor(Color.livithColor(.yellow60)) appearance.stackedLayoutAppearance.selected.titleTextAttributes = [ .foregroundColor: UIColor(Color.livithColor(.yellow60)) ] - + UITabBar.appearance().standardAppearance = appearance UITabBar.appearance().scrollEdgeAppearance = appearance } - + @ViewBuilder func makeTabItem(_ tab: Tab) -> some View { (selectedTab == tab ? tab.selectedIcon : tab.defaultIcon) @@ -147,7 +141,7 @@ extension LivithMainTabView.Tab { case .my: return "마이" } } - + var defaultIcon: Image { switch self { case .home: return Image.livithIcon(.homeDisabled) @@ -155,7 +149,7 @@ extension LivithMainTabView.Tab { case .my: return Image.livithIcon(.myDisabled) } } - + var selectedIcon: Image { switch self { case .home: return Image.livithIcon(.homeEnabled) diff --git a/Projects/ConcertFeature/Sources/Coordinator/ConcertCoordinator.swift b/Projects/ConcertFeature/Sources/Coordinator/ConcertCoordinator.swift deleted file mode 100644 index 2cd88752..00000000 --- a/Projects/ConcertFeature/Sources/Coordinator/ConcertCoordinator.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// ConcertCoordinator.swift -// ConcertFeature -// -// Created by Youjin Lee on 12/31/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI -import UIKit - -import LivithDesignSystem -import Coordinator -import SetlistFeature -import SongFeature - -public final class ConcertCoordinator: Coordinator { - public typealias R = ConcertRoute - - public let navigationController: UINavigationController - - private let onDismiss: () -> Void - - var onTicketSiteReturn: (() -> Void)? - - // MARK: - Initializer - - public init( - navigationController: UINavigationController, - onDismiss: @escaping () -> Void = {} - ) { - self.navigationController = navigationController - self.onDismiss = onDismiss - } - - // MARK: - Coordinator - - public func start() {} - - public func start( - concertID: Int, - initialTab: SegmentedTabBarType.DetailTab = .artistDetail, - initialSection: ConcertInfoSection? = nil - ) { - push(to: .detail(concertID: concertID, initialTab: initialTab, initialSection: initialSection)) - } - - public func buildViewController(for route: ConcertRoute) -> UIViewController { - switch route { - case .detail(let concertID, let initialTab, let initialSection): - let view = ConcertView( - concertID: concertID, - initialTab: initialTab, - initialSection: initialSection, - onDismiss: { [weak self] in - self?.pop() - self?.onDismiss() - } - ) - .environment(\.concertCoordinator, self) - - return UIHostingController(rootView: view) - - case .safari(let url): - let safariView = SafariView(url: url) { [weak self] in - self?.dismiss() - }.ignoresSafeArea() - - return UIHostingController(rootView: safariView) - - case .ticketSafari(let url): - let safariView = SafariView(url: url) { [weak self] in - self?.dismiss() - self?.onTicketSiteReturn?() - }.ignoresSafeArea() - - return UIHostingController(rootView: safariView) - - case .merchandiseDetail(let merchandiseList, let ticketingOfficeURL): - let view = MerchandiseDetailView( - merchandiseList: merchandiseList, - ticketingOfficeURL: ticketingOfficeURL, - onDismiss: { [weak self] in self?.pop() } - ) - .environment(\.concertCoordinator, self) - - return UIHostingController(rootView: view) - - case .setlistDetail(let concertID, let setlistID): - let view = SetlistDetailView( - concertID: concertID, - setlistID: setlistID, - onPlaySong: { [weak self] song in - self?.push(to: .songLyrics(songID: song.id, setlistID: setlistID, songTitle: song.title)) - }, - onReportTapped: { [weak self] in - self?.present(to: .safari(ConcertConstant.reportFormURL)) - } - ) - - return UIHostingController(rootView: view) - - case .songLyrics(let songID, let setlistID, let songTitle): - let view = SongLyricsView( - songID: songID, - setlistID: setlistID, - songTitle: songTitle, - onReportTapped: { [weak self] in - self?.present(to: .safari(ConcertConstant.reportFormURL)) - } - ) - - let hostingController = UIHostingController(rootView: view) - hostingController.hidesBottomBarWhenPushed = true - return hostingController - } - } -} diff --git a/Projects/ConcertFeature/Sources/Coordinator/ConcertCoordinatorView.swift b/Projects/ConcertFeature/Sources/Coordinator/ConcertCoordinatorView.swift new file mode 100644 index 00000000..2fffda30 --- /dev/null +++ b/Projects/ConcertFeature/Sources/Coordinator/ConcertCoordinatorView.swift @@ -0,0 +1,71 @@ +// +// ConcertCoordinatorView.swift +// ConcertFeature +// +// Created by on 6/16/26. +// Copyright © 2026 Livith. All rights reserved. +// + +import SwiftUI + +import Domain +import LivithDesignSystem +import SetlistFeature +import SongFeature + +public struct ConcertCoordinatorView: View { + + private let concertID: Int + private let initialTab: SegmentedTabBarType.DetailTab + private let initialSection: ConcertInfoSection? + private let onTicketSiteReturn: () -> Void + + public init( + concertID: Int, + initialTab: SegmentedTabBarType.DetailTab = .artistDetail, + initialSection: ConcertInfoSection? = nil, + onTicketSiteReturn: @escaping () -> Void = {} + ) { + self.concertID = concertID + self.initialTab = initialTab + self.initialSection = initialSection + self.onTicketSiteReturn = onTicketSiteReturn + } + + public var body: some View { + ConcertView( + concertID: concertID, + initialTab: initialTab, + initialSection: initialSection, + onTicketSiteReturn: onTicketSiteReturn + ) + .navigationDestination(for: ConcertRoute.self) { route in + destinationView(for: route) + } + } + + @ViewBuilder + private func destinationView(for route: ConcertRoute) -> some View { + switch route { + case .setlistDetail(let concertID, let setlistID): + SetlistDetailContainerView( + concertID: concertID, + setlistID: setlistID + ) + case .songLyrics(let songID, let setlistID, let songTitle): + SongLyricsView( + songID: songID, + setlistID: setlistID, + songTitle: songTitle + ) + case .merchandiseDetail(let merchandiseList, let ticketingOfficeURL): + MerchandiseDetailView( + merchandiseList: merchandiseList, + ticketingOfficeURL: ticketingOfficeURL, + onTicketSiteReturn: onTicketSiteReturn + ) + case .detail: + EmptyView() + } + } +} diff --git a/Projects/ConcertFeature/Sources/Coordinator/ConcertRoute.swift b/Projects/ConcertFeature/Sources/Coordinator/ConcertRoute.swift index bd6d4398..af722015 100644 --- a/Projects/ConcertFeature/Sources/Coordinator/ConcertRoute.swift +++ b/Projects/ConcertFeature/Sources/Coordinator/ConcertRoute.swift @@ -10,9 +10,8 @@ import Foundation import Domain import LivithDesignSystem -import Coordinator -public enum ConcertRoute: Route { +public enum ConcertRoute: Hashable { case detail( concertID: Int, initialTab: SegmentedTabBarType.DetailTab = .artistDetail, @@ -20,7 +19,5 @@ public enum ConcertRoute: Route { ) case setlistDetail(concertID: Int, setlistID: Int) case songLyrics(songID: Int, setlistID: Int, songTitle: String) - case safari(URL) - case ticketSafari(URL) case merchandiseDetail([ConcertMerchandise], ticketingOfficeURL: URL?) } diff --git a/Projects/ConcertFeature/Sources/Coordinator/EnvironmentValues+ConcertCoordinator.swift b/Projects/ConcertFeature/Sources/Coordinator/EnvironmentValues+ConcertCoordinator.swift deleted file mode 100644 index 0287eecf..00000000 --- a/Projects/ConcertFeature/Sources/Coordinator/EnvironmentValues+ConcertCoordinator.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// EnvironmentValues+ConcertCoordinator.swift -// ConcertFeature -// -// Created by Youjin Lee on 12/31/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI - -private struct ConcertCoordinatorKey: EnvironmentKey { - static let defaultValue: ConcertCoordinator? = nil -} - -extension EnvironmentValues { - var concertCoordinator: ConcertCoordinator? { - get { self[ConcertCoordinatorKey.self] } - set { self[ConcertCoordinatorKey.self] = newValue } - } -} diff --git a/Projects/ConcertFeature/Sources/Coordinator/SetlistDetailContainerView.swift b/Projects/ConcertFeature/Sources/Coordinator/SetlistDetailContainerView.swift new file mode 100644 index 00000000..c41b5fe7 --- /dev/null +++ b/Projects/ConcertFeature/Sources/Coordinator/SetlistDetailContainerView.swift @@ -0,0 +1,36 @@ +// +// SetlistDetailContainerView.swift +// ConcertFeature +// +// Created by on 6/16/26. +// Copyright © 2026 Livith. All rights reserved. +// + +import SwiftUI + +import Domain +import SetlistFeature +import SongFeature + +struct SetlistDetailContainerView: View { + + let concertID: Int + let setlistID: Int + + @State private var pendingSong: SetlistSong? + + var body: some View { + SetlistDetailView( + concertID: concertID, + setlistID: setlistID, + onPlaySong: { song in pendingSong = song } + ) + .navigationDestination(item: $pendingSong) { song in + SongLyricsView( + songID: song.id, + setlistID: setlistID, + songTitle: song.title + ) + } + } +} diff --git a/Projects/ConcertFeature/Sources/View/ConcertView.swift b/Projects/ConcertFeature/Sources/View/ConcertView.swift index 15e26ef1..64b5d854 100644 --- a/Projects/ConcertFeature/Sources/View/ConcertView.swift +++ b/Projects/ConcertFeature/Sources/View/ConcertView.swift @@ -20,11 +20,11 @@ public struct ConcertView: View { private let concertID: Int private let initialTab: ConcertTab private let initialSection: ConcertInfoSection? - private let onDismiss: () -> Void + private let onTicketSiteReturn: () -> Void - @Environment(\.concertCoordinator) private var coordinator + @Environment(\.dismiss) private var dismiss - @ObservedObject private var store: ConcertStore + @StateObject private var store: ConcertStore = ConcertStore() @StateObject private var communityStore: CommunityStore = CommunityStore() @State private var isExceedingLineLimit: Bool = false @State private var isExceedingCharacterLimit: Bool = false @@ -32,17 +32,15 @@ public struct ConcertView: View { // MARK: - Initializer public init( - store: ConcertStore = ConcertStore(), concertID: Int, initialTab: ConcertTab = .artistDetail, initialSection: ConcertInfoSection? = nil, - onDismiss: @escaping () -> Void + onTicketSiteReturn: @escaping () -> Void = {} ) { - self.store = store self.concertID = concertID self.initialTab = initialTab self.initialSection = initialSection - self.onDismiss = onDismiss + self.onTicketSiteReturn = onTicketSiteReturn } // MARK: - Body @@ -54,7 +52,7 @@ public struct ConcertView: View { public var body: some View { VStack(spacing: 0) { LivithNavigationView( - type: .back(title: navigationTitle, onBack: onDismiss) + type: .back(title: navigationTitle, onBack: { dismiss() }) ) if showEmptyView { @@ -116,12 +114,6 @@ public struct ConcertView: View { store.send(.tabSelected(initialTab)) store.send(.sectionSelected(initialSection)) communityStore.send(.onAppear(concertID: concertID)) - coordinator?.onTicketSiteReturn = { [weak store] in - store?.send(.onTicketSiteReturn) - } - } - .onDisappear { - coordinator?.onTicketSiteReturn = nil } } } @@ -312,7 +304,8 @@ private extension ConcertView { concertInfoList: store.state.concertInfoList, merchandiseList: store.state.merchandiseList, initialSection: store.state.initialSection, - onSectionScrolled: { store.send(.sectionSelected(nil)) } + onSectionScrolled: { store.send(.sectionSelected(nil)) }, + onTicketSiteReturn: { store.send(.onTicketSiteReturn) } ) .frame(maxWidth: UIScreen.main.bounds.width) .background(.livithColor(.black100)) @@ -488,8 +481,6 @@ private extension ConcertView { #Preview { ConcertView( - store: ConcertStore(), - concertID: 1, - onDismiss: {} + concertID: 1 ) } diff --git a/Projects/ConcertFeature/Sources/View/MerchandiseDetailView.swift b/Projects/ConcertFeature/Sources/View/MerchandiseDetailView.swift index 3b62b23e..1ad9b9bf 100644 --- a/Projects/ConcertFeature/Sources/View/MerchandiseDetailView.swift +++ b/Projects/ConcertFeature/Sources/View/MerchandiseDetailView.swift @@ -15,11 +15,13 @@ struct MerchandiseDetailView: View { // MARK: - Property - @Environment(\.concertCoordinator) private var coordinator + @Environment(\.dismiss) private var dismiss let merchandiseList: [ConcertMerchandise] let ticketingOfficeURL: URL? - let onDismiss: () -> Void + let onTicketSiteReturn: () -> Void + + @State private var isTicketSheetPresented: Bool = false private let columns = Array( repeating: GridItem(.flexible(), spacing: 8, alignment: .top), @@ -31,7 +33,7 @@ struct MerchandiseDetailView: View { var body: some View { VStack(spacing: 0) { LivithNavigationView( - type: .back(title: "MD 상세", onBack: onDismiss) + type: .back(title: "MD 상세", onBack: { dismiss() }) ) ScrollView { @@ -44,8 +46,9 @@ struct MerchandiseDetailView: View { isFlexible: true ) .onTapGesture { - guard let url = ticketingOfficeURL else { return } - coordinator?.present(to: .ticketSafari(url)) + if ticketingOfficeURL != nil { + isTicketSheetPresented = true + } } } } @@ -55,6 +58,14 @@ struct MerchandiseDetailView: View { } } .background(Color.livithColor(.black100).ignoresSafeArea()) + .sheet( + isPresented: $isTicketSheetPresented, + onDismiss: { onTicketSiteReturn() } + ) { + if let ticketingOfficeURL { + SafariView(url: ticketingOfficeURL) + } + } } } @@ -70,6 +81,6 @@ struct MerchandiseDetailView: View { ConcertMerchandise(id: 5, name: "제품이름", price: "가격", imageURL: nil) ], ticketingOfficeURL: nil, - onDismiss: {} + onTicketSiteReturn: {} ) } diff --git a/Projects/ConcertFeature/Sources/View/Subview/ConcertInfoCarousel.swift b/Projects/ConcertFeature/Sources/View/Subview/ConcertInfoCarousel.swift index 404fd615..64806d4f 100644 --- a/Projects/ConcertFeature/Sources/View/Subview/ConcertInfoCarousel.swift +++ b/Projects/ConcertFeature/Sources/View/Subview/ConcertInfoCarousel.swift @@ -15,13 +15,13 @@ struct ConcertInfoCarousel: View { // MARK: - Property - @Environment(\.concertCoordinator) private var coordinator - let concertInfoList: [ConcertInfo] let ticketingOfficeURL: URL? + let onTicketSiteReturn: () -> Void @State private var currentIndex: Int = 0 @State private var dragOffset: CGFloat = 0 + @State private var isTicketSheetPresented: Bool = false // MARK: - Body @@ -39,8 +39,8 @@ struct ConcertInfoCarousel: View { .clipped() .contentShape(Rectangle()) .onTapGesture { - if let url = ticketingOfficeURL { - coordinator?.present(to: .ticketSafari(url)) + if ticketingOfficeURL != nil { + isTicketSheetPresented = true } } .simultaneousGesture( @@ -50,6 +50,14 @@ struct ConcertInfoCarousel: View { } .onEnded(handleDragEnded) ) + .sheet( + isPresented: $isTicketSheetPresented, + onDismiss: { onTicketSiteReturn() } + ) { + if let ticketingOfficeURL { + SafariView(url: ticketingOfficeURL) + } + } } } @@ -128,7 +136,8 @@ private extension ConcertInfoCarousel { description: "공연 당일 현장에서 MD를 구매하실 수 있습니다." ) ], - ticketingOfficeURL: URL(string: "https://tickets.interpark.com") + ticketingOfficeURL: URL(string: "https://tickets.interpark.com"), + onTicketSiteReturn: {} ) .background(Color.livithColor(.black100)) } diff --git a/Projects/ConcertFeature/Sources/View/TabContent/ArtistDetailTabView.swift b/Projects/ConcertFeature/Sources/View/TabContent/ArtistDetailTabView.swift index 6ae279c1..2e56b321 100644 --- a/Projects/ConcertFeature/Sources/View/TabContent/ArtistDetailTabView.swift +++ b/Projects/ConcertFeature/Sources/View/TabContent/ArtistDetailTabView.swift @@ -16,12 +16,14 @@ struct ArtistDetailTabView: View { // MARK: - Property - @Environment(\.concertCoordinator) private var coordinator - let artist: Artist? let introduction: String let fanCultures: [ConcertCulture] + @State private var isReportSheetPresented: Bool = false + @State private var isInstagramSheetPresented: Bool = false + @State private var isTwitterSheetPresented: Bool = false + // MARK: - Body private var hasNoContent: Bool { @@ -48,6 +50,19 @@ struct ArtistDetailTabView: View { } .padding(.top, 30) .padding(.bottom, 40) + .sheet(isPresented: $isReportSheetPresented) { + SafariView(url: ConcertConstant.reportFormURL) + } + .sheet(isPresented: $isInstagramSheetPresented) { + if let url = artist?.instagramURL { + SafariView(url: url) + } + } + .sheet(isPresented: $isTwitterSheetPresented) { + if let url = artist?.twitterURL { + SafariView(url: url) + } + } } } } @@ -72,7 +87,7 @@ private extension ArtistDetailTabView { secondLine: "함께 알아볼까요?" ) { AmplitudeService.shared.trackEvent(tag: .click(.reportArtistInfo)) - coordinator?.present(to: .safari(ConcertConstant.reportFormURL)) + isReportSheetPresented = true } .padding(.bottom, 20) .zIndex(1) @@ -135,9 +150,9 @@ private extension ArtistDetailTabView { Spacer() - if let instagramURL = artist.instagramURL { + if artist.instagramURL != nil { Button { - coordinator?.present(to: .safari(instagramURL)) + isInstagramSheetPresented = true } label: { Image.livithImage(.instagram) .resizable() @@ -145,9 +160,9 @@ private extension ArtistDetailTabView { } } - if let twitterURL = artist.twitterURL { + if artist.twitterURL != nil { Button { - coordinator?.present(to: .safari(twitterURL)) + isTwitterSheetPresented = true } label: { Image.livithImage(.twitter) .resizable() @@ -202,7 +217,7 @@ private extension ArtistDetailTabView { secondLine: "꿀팁을 알아봐요" ) { AmplitudeService.shared.trackEvent(tag: .click(.reportFanTips)) - coordinator?.present(to: .safari(ConcertConstant.reportFormURL)) + isReportSheetPresented = true } .padding(.horizontal, 16) diff --git a/Projects/ConcertFeature/Sources/View/TabContent/ConcertInfoTabView.swift b/Projects/ConcertFeature/Sources/View/TabContent/ConcertInfoTabView.swift index b47b33e5..ba431aed 100644 --- a/Projects/ConcertFeature/Sources/View/TabContent/ConcertInfoTabView.swift +++ b/Projects/ConcertFeature/Sources/View/TabContent/ConcertInfoTabView.swift @@ -16,8 +16,6 @@ struct ConcertInfoTabView: View { // MARK: - Property - @Environment(\.concertCoordinator) private var coordinator - let ticketingOffice: String? let ticketingOfficeURL: URL? let scheduleList: [ConcertSchedule] @@ -25,6 +23,10 @@ struct ConcertInfoTabView: View { let merchandiseList: [ConcertMerchandise] let initialSection: ConcertInfoSection? let onSectionScrolled: () -> Void + let onTicketSiteReturn: () -> Void + + @State private var isReportSheetPresented: Bool = false + @State private var isTicketSheetPresented: Bool = false // MARK: - Body @@ -57,6 +59,17 @@ struct ConcertInfoTabView: View { } } } + .sheet(isPresented: $isReportSheetPresented) { + SafariView(url: ConcertConstant.reportFormURL) + } + .sheet( + isPresented: $isTicketSheetPresented, + onDismiss: { onTicketSiteReturn() } + ) { + if let ticketingOfficeURL { + SafariView(url: ticketingOfficeURL) + } + } } } } @@ -73,7 +86,7 @@ private extension ConcertInfoTabView { secondLine: "잊지 말고 확인해요" ) { AmplitudeService.shared.trackEvent(tag: .click(.reportSchedule)) - coordinator?.present(to: .safari(ConcertConstant.reportFormURL)) + isReportSheetPresented = true } VStack(spacing: 34) { @@ -91,9 +104,9 @@ private extension ConcertInfoTabView { private extension ConcertInfoTabView { @ViewBuilder var ticketWebsiteCard: some View { - if let ticketingOfficeURL { + if ticketingOfficeURL != nil { Button { - coordinator?.present(to: .ticketSafari(ticketingOfficeURL)) + isTicketSheetPresented = true } label: { HStack(alignment: .top, spacing: 16) { Image.livithIcon(.earth) @@ -136,12 +149,13 @@ private extension ConcertInfoTabView { secondLine: "빠르게 확인해요" ) { AmplitudeService.shared.trackEvent(tag: .click(.reportConcertInfo)) - coordinator?.present(to: .safari(ConcertConstant.reportFormURL)) + isReportSheetPresented = true } ConcertInfoCarousel( concertInfoList: concertInfoList, - ticketingOfficeURL: ticketingOfficeURL + ticketingOfficeURL: ticketingOfficeURL, + onTicketSiteReturn: onTicketSiteReturn ) } } @@ -161,9 +175,7 @@ private extension ConcertInfoTabView { firstLine: "의 MD 정보를", secondLine: "한 눈에 확인해요" ) { - Button { - coordinator?.push(to: .merchandiseDetail(merchandiseList, ticketingOfficeURL: ticketingOfficeURL)) - } label: { + NavigationLink(value: ConcertRoute.merchandiseDetail(merchandiseList, ticketingOfficeURL: ticketingOfficeURL)) { Image.livithIcon(.rightLineDefault) .resizable() .frame(width: 24, height: 24) @@ -236,7 +248,8 @@ private extension ConcertInfoTabView { ConcertMerchandise(id: 3, name: "제품이름", price: "가격", imageURL: nil) ], initialSection: nil, - onSectionScrolled: {} + onSectionScrolled: {}, + onTicketSiteReturn: {} ) } .background(Color.livithColor(.black100)) diff --git a/Projects/ConcertFeature/Sources/View/TabContent/SetlistTabView.swift b/Projects/ConcertFeature/Sources/View/TabContent/SetlistTabView.swift index b3a0934c..25ad97e1 100644 --- a/Projects/ConcertFeature/Sources/View/TabContent/SetlistTabView.swift +++ b/Projects/ConcertFeature/Sources/View/TabContent/SetlistTabView.swift @@ -17,11 +17,11 @@ struct SetlistTabView: View { // MARK: - Property - @Environment(\.concertCoordinator) private var coordinator - let concertID: Int let setlistList: [Setlist] + @State private var isReportSheetPresented: Bool = false + private let columns = Array( repeating: GridItem(.flexible(), spacing: 10, alignment: .top), count: 3 @@ -58,7 +58,7 @@ private extension SetlistTabView { secondLine: "확인해 보세요" ) { AmplitudeService.shared.trackEvent(tag: .click(.reportSetlistSection)) - coordinator?.present(to: .safari(ConcertConstant.reportFormURL)) + isReportSheetPresented = true } LazyVGrid(columns: columns, spacing: 16) { @@ -70,13 +70,13 @@ private extension SetlistTabView { .padding(.horizontal, 16) .padding(.top, 30) .padding(.bottom, 40) + .sheet(isPresented: $isReportSheetPresented) { + SafariView(url: ConcertConstant.reportFormURL) + } } func setlistCard(for setlist: Setlist) -> some View { - Button { - AmplitudeService.shared.trackEvent(tag: .click(.setlistCell)) - coordinator?.push(to: .setlistDetail(concertID: concertID, setlistID: setlist.id)) - } label: { + NavigationLink(value: ConcertRoute.setlistDetail(concertID: concertID, setlistID: setlist.id)) { LivithCard( imageURL: setlist.imageURL, title: setlist.title, @@ -87,6 +87,9 @@ private extension SetlistTabView { ) } .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded { + AmplitudeService.shared.trackEvent(tag: .click(.setlistCell)) + }) } func formatDate(_ setlist: Setlist) -> String { diff --git a/Projects/Core/Coordinator/Sources/Router.swift b/Projects/Core/Coordinator/Sources/Router.swift new file mode 100644 index 00000000..002d8d6c --- /dev/null +++ b/Projects/Core/Coordinator/Sources/Router.swift @@ -0,0 +1,41 @@ +// +// Router.swift +// Coordinator +// +// Created by on 6/15/26. +// Copyright © 2026 Livith. All rights reserved. +// + +import SwiftUI + +/// SwiftUI 네이티브 NavigationStack을 위한 라우터 기반 클래스. +/// +/// - Note: `@MainActor`에서 동작하며 `NavigationPath`를 통해 +/// 선언적 화면 전환을 관리합니다. +/// - Important: `path`는 `private(set)`으로 보호되며, +/// 화면 전환은 반드시 `push`, `pop`, `popToRoot` 메서드를 통해야 합니다. +/// +/// 제네릭 파라미터 +/// - `R`: 화면 전환을 정의하는 `Hashable` Route 타입 +@MainActor +open class Router: ObservableObject { + @Published public var path = NavigationPath() + + public init() {} + + /// 지정한 `route`를 네비게이션 스택에 추가합니다. + /// - Parameter route: 이동할 대상 `Route` + open func push(_ route: R) { + path.append(route) + } + + /// 현재 화면을 하나 뒤로 되돌립니다. + open func pop() { + path.removeLast() + } + + /// 루트 화면까지 되돌립니다. + open func popToRoot() { + path.removeLast(path.count) + } +} diff --git a/Projects/HomeFeature/Sources/Coordinator/EnvironmentValues+HomeCoordinator.swift b/Projects/HomeFeature/Sources/Coordinator/EnvironmentValues+HomeCoordinator.swift deleted file mode 100644 index b229bac8..00000000 --- a/Projects/HomeFeature/Sources/Coordinator/EnvironmentValues+HomeCoordinator.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// EnvironmentValues+HomeCoordinator.swift -// HomeFeature -// -// Created by 김진웅 on 12/27/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI - -private struct HomeCoordinatorKey: EnvironmentKey { - static let defaultValue: HomeCoordinator? = nil -} - -extension EnvironmentValues { - var homeCoordinator: HomeCoordinator? { - get { self[HomeCoordinatorKey.self] } - set { self[HomeCoordinatorKey.self] = newValue } - } -} diff --git a/Projects/HomeFeature/Sources/Coordinator/HomeContentView.swift b/Projects/HomeFeature/Sources/Coordinator/HomeContentView.swift deleted file mode 100644 index 9eaa3dc7..00000000 --- a/Projects/HomeFeature/Sources/Coordinator/HomeContentView.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// HomeContentView.swift -// HomeFeature -// -// Created by 김진웅 on 12/27/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI -import UIKit - -import ConcertFeature -import LivithDesignSystem - -public struct HomeContentView: View { - @State private var coordinator: HomeCoordinator - @Binding private var isTabBarHidden: Bool - @Binding private var deepLinkConcertID: Int? - @Binding private var deepLinkInitialTab: SegmentedTabBarType.DetailTab - @Binding private var deepLinkInitialSection: ConcertInfoSection? - @Binding private var deepLinkShowInterest: Bool - - public init( - isTabBarHidden: Binding, - deepLinkConcertID: Binding = .constant(nil), - deepLinkInitialTab: Binding = .constant(.artistDetail), - deepLinkInitialSection: Binding = .constant(nil), - deepLinkShowInterest: Binding = .constant(false) - ) { - self._coordinator = State(initialValue: HomeCoordinator()) - self._isTabBarHidden = isTabBarHidden - self._deepLinkConcertID = deepLinkConcertID - self._deepLinkInitialTab = deepLinkInitialTab - self._deepLinkInitialSection = deepLinkInitialSection - self._deepLinkShowInterest = deepLinkShowInterest - } - - public var body: some View { - HomeNavigationHost(coordinator: coordinator, isTabBarHidden: $isTabBarHidden) - .ignoresSafeArea() - .onChange(of: deepLinkConcertID) { _, newValue in - if let concertID = newValue { - coordinator.popToRoot() - coordinator.showConcertDetail( - concertID: concertID, - initialTab: deepLinkInitialTab, - initialSection: deepLinkInitialSection - ) - deepLinkConcertID = nil - deepLinkInitialTab = .artistDetail - deepLinkInitialSection = nil - } - } - .onChange(of: deepLinkShowInterest) { _, newValue in - if newValue { - coordinator.popToRoot() - coordinator.push(to: .interestConcertSetting(mode: .update)) - deepLinkShowInterest = false - } - } - } -} - -// MARK: - NavigationHost - -private extension HomeContentView { - struct HomeNavigationHost: UIViewControllerRepresentable { - let coordinator: HomeCoordinator - @Binding var isTabBarHidden: Bool - - func makeCoordinator() -> NavDelegate { - NavDelegate(isTabBarHidden: $isTabBarHidden) - } - - final class NavDelegate: NSObject, UINavigationControllerDelegate { - @Binding var isTabBarHidden: Bool - - init(isTabBarHidden: Binding) { - self._isTabBarHidden = isTabBarHidden - } - - func navigationController( - _ navigationController: UINavigationController, - willShow viewController: UIViewController, - animated: Bool - ) { - let stackCount = navigationController.viewControllers.count - Task { @MainActor in - isTabBarHidden = stackCount > 1 - } - } - } - - func makeUIViewController(context: Context) -> UINavigationController { - let nav = coordinator.navigationController - nav.delegate = context.coordinator - if nav.viewControllers.isEmpty { - coordinator.start() - } - return nav - } - - func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} - } -} diff --git a/Projects/HomeFeature/Sources/Coordinator/HomeCoordinator.swift b/Projects/HomeFeature/Sources/Coordinator/HomeCoordinator.swift deleted file mode 100644 index cef3471f..00000000 --- a/Projects/HomeFeature/Sources/Coordinator/HomeCoordinator.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// HomeCoordinator.swift -// HomeFeature -// -// Created by 김진웅 on 12/27/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI - -import ConcertFeature -import Coordinator -import LivithDesignSystem -import UserFeature - -final class HomeCoordinator: Coordinator { - typealias R = HomeRoute - - let navigationController: UINavigationController - - private var concertCoordinator: ConcertCoordinator? - - init() { - self.navigationController = UINavigationController() - - self.navigationController.setNavigationBarHidden(true, animated: false) - } - - func start() { - push(to: .home, animated: false) - } - - func buildViewController(for route: R) -> UIViewController { - switch route { - case .home: - return UIHostingController(rootView: HomeView().environment(\.homeCoordinator, self)) - - case .interestConcertSetting(let mode): - let vc = UIHostingController( - rootView: InterestConcertSettingView(mode: mode).environment(\.homeCoordinator, self) - ) - vc.hidesBottomBarWhenPushed = true - return vc - - case .interestConcertList: - let vc = UIHostingController( - rootView: InterestConcertListView().environment(\.homeCoordinator, self) - ) - vc.hidesBottomBarWhenPushed = true - return vc - - case .notice: - return UIHostingController( - rootView: NoticeView( - onBack: { [weak self] in self?.pop() }, - onSettingTap: { [weak self] in self?.push(to: .noticeSetting) }, - onInterestTap: { [weak self] in self?.push(to: .interestConcertSetting(mode: .update)) }, - onConcertTap: { [weak self] concertID, initialTab, initialSection in - self?.showConcertDetail(concertID: concertID, initialTab: initialTab, initialSection: initialSection) - } - ) - .environment(\.homeCoordinator, self) - ) - - case .noticeSetting: - return UIHostingController( - rootView: NoticeSettingView( - onBack: { [weak self] in self?.pop() } - ) - .environment(\.homeCoordinator, self) - ) - - case .recommendedConcertList(let concertList): - let vc = UIHostingController( - rootView: RecommendedConcertGridView(concertList: concertList) - .environment(\.homeCoordinator, self) - ) - vc.hidesBottomBarWhenPushed = true - return vc - - case .preferredGenreUpdate: - let vc = UIHostingController( - rootView: GenreUpdateView().environment(\.homeCoordinator, self) - ) - vc.hidesBottomBarWhenPushed = true - return vc - - case .preferredArtistUpdate(let genreList): - let vc = UIHostingController( - rootView: ArtistUpdateView(selectedGenreList: genreList).environment(\.homeCoordinator, self) - ) - vc.hidesBottomBarWhenPushed = true - return vc - } - } - - func showConcertDetail( - concertID: Int, - initialTab: SegmentedTabBarType.DetailTab = .artistDetail, - initialSection: ConcertInfoSection? = nil - ) { - let coordinator = ConcertCoordinator( - navigationController: navigationController, - onDismiss: { [weak self] in - self?.concertCoordinator = nil - } - ) - self.concertCoordinator = coordinator - coordinator.start(concertID: concertID, initialTab: initialTab, initialSection: initialSection) - } - - func showSongDetail(songID: Int, setlistID: Int, songTitle: String) { - let coordinator = ConcertCoordinator( - navigationController: navigationController, - onDismiss: { [weak self] in - self?.concertCoordinator = nil - } - ) - self.concertCoordinator = coordinator - coordinator.push(to: .songLyrics(songID: songID, setlistID: setlistID, songTitle: songTitle)) - } - - func showSetlistDetail(concertID: Int, setlistID: Int) { - let coordinator = ConcertCoordinator( - navigationController: navigationController, - onDismiss: { [weak self] in - self?.concertCoordinator = nil - } - ) - self.concertCoordinator = coordinator - coordinator.push(to: .setlistDetail(concertID: concertID, setlistID: setlistID)) - } -} diff --git a/Projects/HomeFeature/Sources/Coordinator/HomeCoordinatorView.swift b/Projects/HomeFeature/Sources/Coordinator/HomeCoordinatorView.swift new file mode 100644 index 00000000..84f99012 --- /dev/null +++ b/Projects/HomeFeature/Sources/Coordinator/HomeCoordinatorView.swift @@ -0,0 +1,120 @@ +// +// HomeCoordinatorView.swift +// HomeFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import SwiftUI + +import ConcertFeature +import Coordinator +import LivithDesignSystem +import UserFeature + +// MARK: - HomeRouter + +typealias HomeRouter = Router + +// MARK: - HomeCoordinatorView + +public struct HomeCoordinatorView: View { + + // MARK: - Property + + @StateObject private var router: HomeRouter + + @Binding private var deepLinkConcertID: Int? + @Binding private var deepLinkInitialTab: SegmentedTabBarType.DetailTab + @Binding private var deepLinkInitialSection: ConcertInfoSection? + @Binding private var deepLinkShowInterest: Bool + + // MARK: - Initializer + + public init( + deepLinkConcertID: Binding = .constant(nil), + deepLinkInitialTab: Binding = .constant(.artistDetail), + deepLinkInitialSection: Binding = .constant(nil), + deepLinkShowInterest: Binding = .constant(false) + ) { + _router = StateObject(wrappedValue: HomeRouter()) + self._deepLinkConcertID = deepLinkConcertID + self._deepLinkInitialTab = deepLinkInitialTab + self._deepLinkInitialSection = deepLinkInitialSection + self._deepLinkShowInterest = deepLinkShowInterest + } + + // MARK: - Body + + public var body: some View { + NavigationStack(path: $router.path) { + HomeView() + .navigationDestination(for: HomeRoute.self) { route in + destinationView(for: route) + .toolbar(.hidden, for: .tabBar, .navigationBar) + } + } + .environmentObject(router) + .ignoresSafeArea() + .onChange(of: deepLinkConcertID) { newValue in + if let concertID = newValue { + router.popToRoot() + router.push(.concertDetail( + concertID: concertID, + initialTab: deepLinkInitialTab, + initialSection: deepLinkInitialSection + )) + deepLinkConcertID = nil + deepLinkInitialTab = .artistDetail + deepLinkInitialSection = nil + } + } + .onChange(of: deepLinkShowInterest) { newValue in + if newValue { + router.popToRoot() + router.push(.interestConcertSetting(mode: .update)) + deepLinkShowInterest = false + } + } + } + + @ViewBuilder + private func destinationView(for route: HomeRoute) -> some View { + switch route { + case .home: + HomeView() + case .interestConcertSetting(let mode): + InterestConcertSettingView(mode: mode) + case .interestConcertList: + InterestConcertListView() + case .notice: + NoticeView( + onBack: { router.pop() }, + onSettingTap: { router.push(.noticeSetting) }, + onInterestTap: { router.push(.interestConcertSetting(mode: .update)) }, + onConcertTap: { concertID, initialTab, initialSection in + router.push(.concertDetail( + concertID: concertID, + initialTab: initialTab, + initialSection: initialSection + )) + } + ) + case .noticeSetting: + NoticeSettingView(onBack: { router.pop() }) + case .recommendedConcertList(let concertList): + RecommendedConcertGridView(concertList: concertList) + case .preferredGenreUpdate: + GenreUpdateView() + case .preferredArtistUpdate(let selectedGenreList): + ArtistUpdateView(selectedGenreList: selectedGenreList) + case .concertDetail(let concertID, let initialTab, let initialSection): + ConcertCoordinatorView( + concertID: concertID, + initialTab: initialTab, + initialSection: initialSection + ) + } + } +} diff --git a/Projects/HomeFeature/Sources/Coordinator/HomeRoute.swift b/Projects/HomeFeature/Sources/Coordinator/HomeRoute.swift index 0bd86527..530c6fe8 100644 --- a/Projects/HomeFeature/Sources/Coordinator/HomeRoute.swift +++ b/Projects/HomeFeature/Sources/Coordinator/HomeRoute.swift @@ -8,10 +8,11 @@ import Foundation -import Coordinator +import ConcertFeature import Domain +import LivithDesignSystem -enum HomeRoute: Route { +enum HomeRoute: Hashable { case home case interestConcertSetting(mode: InterestConcertSettingMode) case interestConcertList @@ -20,4 +21,9 @@ enum HomeRoute: Route { case recommendedConcertList(concertList: [Concert]) case preferredGenreUpdate case preferredArtistUpdate(selectedGenreList: [PreferredGenre]) + case concertDetail( + concertID: Int, + initialTab: SegmentedTabBarType.DetailTab, + initialSection: ConcertInfoSection? + ) } diff --git a/Projects/HomeFeature/Sources/Home/View/HomeView.swift b/Projects/HomeFeature/Sources/Home/View/HomeView.swift index 3e710b1f..37948627 100644 --- a/Projects/HomeFeature/Sources/Home/View/HomeView.swift +++ b/Projects/HomeFeature/Sources/Home/View/HomeView.swift @@ -16,7 +16,7 @@ struct HomeView: View { // MARK: - Properties - @Environment(\.homeCoordinator) private var coordinator + @EnvironmentObject private var homeRouter: HomeRouter @StateObject private var store: HomeStore = .init() @State private var showErrorToast = false @@ -88,7 +88,7 @@ private extension HomeView { var navigationView: some View { LivithNavigationView(type: .logo( hasNewNotice: store.state.hasNewNotice, - onNoticeTap: { coordinator?.push(to: .notice) } + onNoticeTap: { homeRouter.push(.notice) } )) } @@ -133,7 +133,7 @@ private extension HomeView { isExpanded: $isPreferenceBannerExpanded, onTapBanner: { AmplitudeService.shared.trackEvent(tag: .click(.setPreferenceBannerMain)) - coordinator?.push(to: .preferredGenreUpdate) + homeRouter.push(.preferredGenreUpdate) }, backgroundColor: preferenceBannerBackgroundColor ) @@ -148,11 +148,15 @@ private extension HomeView { HomeInterestConcertSectionView( interestConcertList: store.state.interestConcertList, selectedSort: store.state.interestConcertSort, - onChangeTap: { coordinator?.push(to: .interestConcertSetting(mode: .update)) }, - onTitleTap: { coordinator?.push(to: .interestConcertList) }, + onChangeTap: { homeRouter.push(.interestConcertSetting(mode: .update)) }, + onTitleTap: { homeRouter.push(.interestConcertList) }, onSortSelected: { store.send(.interestConcertSortSelected($0)) }, onConcertTap: { interestConcert in - coordinator?.showConcertDetail(concertID: interestConcert.concert.id) + homeRouter.push(.concertDetail( + concertID: interestConcert.concert.id, + initialTab: .artistDetail, + initialSection: nil + )) } ) } else { @@ -160,7 +164,7 @@ private extension HomeView { nickname: store.state.user?.nickname ?? "라이빗", onSettingTap: { AmplitudeService.shared.trackEvent(tag: .click(.interestConcertMain)) - coordinator?.push(to: .interestConcertSetting(mode: .initialSetup)) + homeRouter.push(.interestConcertSetting(mode: .initialSetup)) } ) } @@ -174,14 +178,22 @@ private extension HomeView { shouldShowRecommendedConcertSection: !store.state.shouldShowPreferenceBanner, onRecommendedConcertTap: { concert in AmplitudeService.shared.trackEvent(tag: .click(.recommendedConcertCell)) - coordinator?.showConcertDetail(concertID: concert.id) + homeRouter.push(.concertDetail( + concertID: concert.id, + initialTab: .artistDetail, + initialSection: nil + )) }, onRecommendedSeeAllTap: { - coordinator?.push(to: .recommendedConcertList(concertList: store.state.recommendedConcertList)) + homeRouter.push(.recommendedConcertList(concertList: store.state.recommendedConcertList)) }, onConcertTap: { concert in AmplitudeService.shared.trackEvent(tag: .click(.concertCellMain)) - coordinator?.showConcertDetail(concertID: concert.id) + homeRouter.push(.concertDetail( + concertID: concert.id, + initialTab: .artistDetail, + initialSection: nil + )) } ) } diff --git a/Projects/HomeFeature/Sources/Home/View/Subview/ConcertContentSection/RecommendedConcertGridView.swift b/Projects/HomeFeature/Sources/Home/View/Subview/ConcertContentSection/RecommendedConcertGridView.swift index 338b850c..bc9fa45a 100644 --- a/Projects/HomeFeature/Sources/Home/View/Subview/ConcertContentSection/RecommendedConcertGridView.swift +++ b/Projects/HomeFeature/Sources/Home/View/Subview/ConcertContentSection/RecommendedConcertGridView.swift @@ -18,21 +18,21 @@ struct RecommendedConcertGridView: View { // MARK: - Properties - @Environment(\.homeCoordinator) private var coordinator - + @EnvironmentObject private var homeRouter: HomeRouter + let concertList: [Concert] // MARK: - Body - + var body: some View { VStack(spacing: .zero) { LivithNavigationView( type: .back( title: "취향이 담긴 콘서트", - onBack: { coordinator?.pop() } + onBack: { homeRouter.pop() } ) ) - + gridView .padding(16) } @@ -65,7 +65,11 @@ private extension RecommendedConcertGridView { badge: .status(text: ConcertDisplayHelper.statusBadge(for: concert), remainDays: nil), onTap: { AmplitudeService.shared.trackEvent(tag: .click(.recommendedConcertCell)) - coordinator?.showConcertDetail(concertID: concert.id) + homeRouter.push(.concertDetail( + concertID: concert.id, + initialTab: .artistDetail, + initialSection: nil + )) } ) .transition(.opacity.combined(with: .scale(scale: 0.95))) diff --git a/Projects/HomeFeature/Sources/Interest/View/InterestConcertListView.swift b/Projects/HomeFeature/Sources/Interest/View/InterestConcertListView.swift index 0700760d..e99f0bf8 100644 --- a/Projects/HomeFeature/Sources/Interest/View/InterestConcertListView.swift +++ b/Projects/HomeFeature/Sources/Interest/View/InterestConcertListView.swift @@ -16,7 +16,7 @@ struct InterestConcertListView: View { // MARK: - Properties - @Environment(\.homeCoordinator) private var coordinator + @EnvironmentObject private var homeRouter: HomeRouter @StateObject private var store: InterestConcertListStore = .init() @State private var showSortOption: Bool = false @@ -50,7 +50,7 @@ private extension InterestConcertListView { var headerView: some View { HStack(spacing: 4) { Button { - coordinator?.pop() + homeRouter.pop() } label: { Image.livithIcon(.backLineDefault) .resizable() @@ -73,7 +73,7 @@ private extension InterestConcertListView { var changeButton: some View { Button { - coordinator?.push(to: .interestConcertSetting(mode: .update)) + homeRouter.push(.interestConcertSetting(mode: .update)) } label: { Text("변경하기") .notosans(.body4Medium) @@ -202,7 +202,13 @@ private extension InterestConcertListView { subtitle: ConcertDisplayHelper.dateRange(for: concert), secondaryText: concert.artist, badge: .status(text: ConcertDisplayHelper.statusBadge(for: concert), remainDays: nil), - onTap: { coordinator?.showConcertDetail(concertID: concert.id) } + onTap: { + homeRouter.push(.concertDetail( + concertID: concert.id, + initialTab: .artistDetail, + initialSection: nil + )) + } ) .transition(.opacity.combined(with: .scale(scale: 0.95))) .onAppear { diff --git a/Projects/HomeFeature/Sources/Interest/View/InterestConcertSettingView.swift b/Projects/HomeFeature/Sources/Interest/View/InterestConcertSettingView.swift index 6d8d0e14..2d4c73ac 100644 --- a/Projects/HomeFeature/Sources/Interest/View/InterestConcertSettingView.swift +++ b/Projects/HomeFeature/Sources/Interest/View/InterestConcertSettingView.swift @@ -15,7 +15,7 @@ struct InterestConcertSettingView: View { // MARK: - Properties - @Environment(\.homeCoordinator) private var coordinator + @EnvironmentObject private var homeRouter: HomeRouter @StateObject private var store: InterestConcertSettingStore @State private var showErrorToast: Bool = false @State private var showSuccessToast: Bool = false @@ -72,7 +72,7 @@ struct InterestConcertSettingView: View { cancelTitle: "잘못 눌렀어요", type: .confirm(onConfirm: { isDiscardChangesModalPresented = false - coordinator?.pop() + homeRouter.pop() }), onCancel: { isDiscardChangesModalPresented = false @@ -89,7 +89,7 @@ struct InterestConcertSettingView: View { showSuccessToast = true Task { @MainActor in await Task.yield() - coordinator?.popToRoot() + homeRouter.popToRoot() } } } @@ -212,12 +212,12 @@ private extension InterestConcertSettingView { func handleBackButtonTap() { guard store.state.mode != .initialSetup else { - coordinator?.pop() + homeRouter.pop() return } guard store.state.hasUnsavedChanges else { - coordinator?.pop() + homeRouter.pop() return } diff --git a/Projects/HomeFeature/Sources/PreferenceUpdate/View/ArtistUpdateView.swift b/Projects/HomeFeature/Sources/PreferenceUpdate/View/ArtistUpdateView.swift index 47ac8854..ea6c75a2 100644 --- a/Projects/HomeFeature/Sources/PreferenceUpdate/View/ArtistUpdateView.swift +++ b/Projects/HomeFeature/Sources/PreferenceUpdate/View/ArtistUpdateView.swift @@ -14,24 +14,24 @@ import LivithDesignSystem import Coordinator struct ArtistUpdateView: View { - @Environment(\.homeCoordinator) private var coordinator - + @EnvironmentObject private var homeRouter: HomeRouter + @StateObject private var store: PreferenceUpdateStore - + @State private var isUpdateFailureModalPresented: Bool = false @State private var isDiscardChangesModalPresented: Bool = false @State private var isSuccessToastPresented: Bool = false - + init(selectedGenreList: [PreferredGenre]) { self._store = StateObject(wrappedValue: PreferenceUpdateStore(selectedGenreList)) } - + var body: some View { ArtistEditView(config: .artistHome(), isSubmitting: store.state.isLoading) { isModified in if isModified { isDiscardChangesModalPresented = true } else { - self.coordinator?.pop() + self.homeRouter.pop() } } onSkip: { store.send(.onSkip) @@ -42,7 +42,7 @@ struct ArtistUpdateView: View { switch result { case .success: isSuccessToastPresented = true - coordinator?.popToRoot() + homeRouter.popToRoot() case .failure: isUpdateFailureModalPresented = true case .idle: @@ -60,7 +60,7 @@ struct ArtistUpdateView: View { confirmTitle: "홈으로 돌아가기", onConfirm: { isUpdateFailureModalPresented = false - coordinator?.popToRoot() + homeRouter.popToRoot() } ) } @@ -71,7 +71,7 @@ struct ArtistUpdateView: View { cancelTitle: "잘못 눌렀어요", type: .confirm(onConfirm: { isDiscardChangesModalPresented = false - coordinator?.pop() + homeRouter.pop() }), onCancel: { isDiscardChangesModalPresented = false diff --git a/Projects/HomeFeature/Sources/PreferenceUpdate/View/GenreUpdateView.swift b/Projects/HomeFeature/Sources/PreferenceUpdate/View/GenreUpdateView.swift index 6da6b3c7..74593625 100644 --- a/Projects/HomeFeature/Sources/PreferenceUpdate/View/GenreUpdateView.swift +++ b/Projects/HomeFeature/Sources/PreferenceUpdate/View/GenreUpdateView.swift @@ -14,7 +14,7 @@ import LivithDesignSystem import Coordinator struct GenreUpdateView: View { - @Environment(\.homeCoordinator) private var coordinator + @EnvironmentObject private var homeRouter: HomeRouter @State private var isDiscardChangesModalPresented: Bool = false var body: some View { @@ -22,10 +22,10 @@ struct GenreUpdateView: View { if isModified { isDiscardChangesModalPresented = true } else { - coordinator?.pop() + homeRouter.pop() } } onSubmit: { genreList in - coordinator?.push(to: .preferredArtistUpdate(selectedGenreList: genreList)) + homeRouter.push(.preferredArtistUpdate(selectedGenreList: genreList)) } .crossDissolve(isPresented: $isDiscardChangesModalPresented, dismissOnTapOutside: false) { LivithDangerModal( @@ -34,7 +34,7 @@ struct GenreUpdateView: View { cancelTitle: "잘못 눌렀어요", type: .confirm(onConfirm: { isDiscardChangesModalPresented = false - coordinator?.pop() + homeRouter.pop() }), onCancel: { isDiscardChangesModalPresented = false diff --git a/Projects/LoginFeature/Sources/Coordinator/EnvironmentValues+LoginCoordinator.swift b/Projects/LoginFeature/Sources/Coordinator/EnvironmentValues+LoginCoordinator.swift deleted file mode 100644 index 70b12ef4..00000000 --- a/Projects/LoginFeature/Sources/Coordinator/EnvironmentValues+LoginCoordinator.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// EnvironmentValues+LoginCoordinator.swift -// LoginFeature -// -// Created by 김진웅 on 12/27/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI - -private struct LoginCoordinatorKey: EnvironmentKey { - static let defaultValue: LoginCoordinator? = nil -} - -extension EnvironmentValues { - var loginCoordinator: LoginCoordinator? { - get { self[LoginCoordinatorKey.self] } - set { self[LoginCoordinatorKey.self] = newValue } - } -} diff --git a/Projects/LoginFeature/Sources/Coordinator/LoginContentView.swift b/Projects/LoginFeature/Sources/Coordinator/LoginContentView.swift deleted file mode 100644 index 36d5c463..00000000 --- a/Projects/LoginFeature/Sources/Coordinator/LoginContentView.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// LoginContentView.swift -// LoginFeature -// -// Created by 김진웅 on 12/27/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI -import UIKit - -public struct LoginContentView: View { - @State private var coordinator: LoginCoordinator - - public init( - onLoginCompleted: @escaping () -> Void, - onSignupCompleted: @escaping (String) -> Void - ) { - _coordinator = State( - initialValue: LoginCoordinator( - onLoginCompleted: onLoginCompleted, - onSignupCompleted: onSignupCompleted - ) - ) - } - - public var body: some View { - LoginNavigationHost(coordinator: coordinator) - .ignoresSafeArea() - } -} - -// MARK: - NavigationHost - -private extension LoginContentView { - struct LoginNavigationHost: UIViewControllerRepresentable { - let coordinator: LoginCoordinator - - func makeUIViewController(context: Context) -> UINavigationController { - let nav = coordinator.navigationController - if nav.viewControllers.isEmpty { - coordinator.start() - } - return nav - } - - func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} - } -} diff --git a/Projects/LoginFeature/Sources/Coordinator/LoginCoordinator.swift b/Projects/LoginFeature/Sources/Coordinator/LoginCoordinator.swift deleted file mode 100644 index 759363e3..00000000 --- a/Projects/LoginFeature/Sources/Coordinator/LoginCoordinator.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// LoginCoordinator.swift -// LoginFeature -// -// Created by 김진웅 on 12/27/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI -import UIKit - -import LivithDesignSystem -import Coordinator -import Domain - -final class LoginCoordinator: Coordinator { - typealias R = LoginRoute - - let navigationController: UINavigationController - - private let onLoginCompleted: (() -> Void) - private let onSignupCompleted: ((String) -> Void) - - init( - onLoginCompleted: @escaping () -> Void = { }, - onSignupCompleted: @escaping (String) -> Void = { _ in } - ) { - self.navigationController = UINavigationController() - self.onLoginCompleted = onLoginCompleted - self.onSignupCompleted = onSignupCompleted - - self.navigationController.setNavigationBarHidden(true, animated: false) - } - - func start() { - push(to: .login, animated: false) - } - - func buildViewController(for route: LoginRoute) -> UIViewController { - switch route { - case .login: - return UIHostingController(rootView: LoginView().environment(\.loginCoordinator, self)) - - case .terms(let tempUser): - return UIHostingController( - rootView: TermsView(tempUser: tempUser) - .environment(\.loginCoordinator, self) - ) - - case .nickname(let builder): - return UIHostingController( - rootView: NicknameSettingView(builder: builder) - .environment(\.loginCoordinator, self) - ) - - case .preferredGenre(let builder): - return UIHostingController( - rootView: PreferredGenreSettingView(builder: builder) - .environment(\.loginCoordinator, self) - ) - - case .preferredArtist(let builder): - return UIHostingController( - rootView: PreferredArtistSettingView(builder: builder) - .environment(\.loginCoordinator, self) - ) - } - } - - func completeLogin() { - onLoginCompleted() - } - - func completeSignup(with nickname: String) { - onSignupCompleted(nickname) - } -} diff --git a/Projects/LoginFeature/Sources/Coordinator/LoginCoordinatorView.swift b/Projects/LoginFeature/Sources/Coordinator/LoginCoordinatorView.swift new file mode 100644 index 00000000..954b81fc --- /dev/null +++ b/Projects/LoginFeature/Sources/Coordinator/LoginCoordinatorView.swift @@ -0,0 +1,53 @@ +// +// LoginCoordinatorView.swift +// LoginFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import SwiftUI + +public struct LoginCoordinatorView: View { + @StateObject private var router: LoginRouter + + public init( + onLoginCompleted: @escaping () -> Void, + onSignupCompleted: @escaping (String) -> Void + ) { + _router = StateObject( + wrappedValue: LoginRouter( + onLoginCompleted: onLoginCompleted, + onSignupCompleted: onSignupCompleted + ) + ) + } + + public var body: some View { + NavigationStack(path: $router.path) { + LoginView() + .navigationDestination(for: LoginRoute.self) { route in + destinationView(for: route) + .toolbar(.hidden, for: .navigationBar) + } + } + .environmentObject(router) + .ignoresSafeArea() + } + + @ViewBuilder + private func destinationView(for route: LoginRoute) -> some View { + switch route { + case .login: + LoginView() + case .terms(let tempUser): + TermsView(tempUser: tempUser) + case .nickname(let builder): + NicknameSettingView(builder: builder) + case .preferredGenre(let builder): + PreferredGenreSettingView(builder: builder) + case .preferredArtist(let builder): + PreferredArtistSettingView(builder: builder) + } + } +} diff --git a/Projects/LoginFeature/Sources/Coordinator/LoginRoute.swift b/Projects/LoginFeature/Sources/Coordinator/LoginRoute.swift index a9bae14c..bf6e59e1 100644 --- a/Projects/LoginFeature/Sources/Coordinator/LoginRoute.swift +++ b/Projects/LoginFeature/Sources/Coordinator/LoginRoute.swift @@ -8,11 +8,10 @@ import Foundation -import LivithDesignSystem -import Coordinator import Domain +import LivithDesignSystem -enum LoginRoute: Route { +enum LoginRoute: Hashable { case login case terms(TempUser) case nickname(SignupBuilder) diff --git a/Projects/LoginFeature/Sources/Coordinator/LoginRouter.swift b/Projects/LoginFeature/Sources/Coordinator/LoginRouter.swift new file mode 100644 index 00000000..3c818543 --- /dev/null +++ b/Projects/LoginFeature/Sources/Coordinator/LoginRouter.swift @@ -0,0 +1,31 @@ +// +// LoginRouter.swift +// LoginFeature +// +// Created by on 6/15/26. +// Copyright © 2026 Livith. All rights reserved. +// + +import Coordinator + +final class LoginRouter: Router { + private let onLoginCompleted: () -> Void + private let onSignupCompleted: (String) -> Void + + init( + onLoginCompleted: @escaping () -> Void = {}, + onSignupCompleted: @escaping (String) -> Void = { _ in } + ) { + self.onLoginCompleted = onLoginCompleted + self.onSignupCompleted = onSignupCompleted + super.init() + } + + func completeLogin() { + onLoginCompleted() + } + + func completeSignup(with nickname: String) { + onSignupCompleted(nickname) + } +} diff --git a/Projects/LoginFeature/Sources/Login/View/LoginView.swift b/Projects/LoginFeature/Sources/Login/View/LoginView.swift index 5bee5479..4e0fbec3 100644 --- a/Projects/LoginFeature/Sources/Login/View/LoginView.swift +++ b/Projects/LoginFeature/Sources/Login/View/LoginView.swift @@ -27,7 +27,7 @@ struct LoginView: View { } @StateObject private var store = LoginStore() - @Environment(\.loginCoordinator) private var coordinator + @EnvironmentObject private var router: LoginRouter var body: some View { contentView @@ -133,9 +133,9 @@ private extension LoginView { func handleLoginSuccess(_ status: LoginStatus) { switch status { case .existingUser: - coordinator?.completeLogin() + router.completeLogin() case .newUser(let tempUser): - coordinator?.push(to: .terms(tempUser)) + router.push(.terms(tempUser)) } } } diff --git a/Projects/LoginFeature/Sources/Onboarding/View/NicknameSettingView.swift b/Projects/LoginFeature/Sources/Onboarding/View/NicknameSettingView.swift index f16c7ace..3f84225d 100644 --- a/Projects/LoginFeature/Sources/Onboarding/View/NicknameSettingView.swift +++ b/Projects/LoginFeature/Sources/Onboarding/View/NicknameSettingView.swift @@ -13,7 +13,7 @@ import LivithDesignSystem import NicknameEditFeature struct NicknameSettingView: View { - @Environment(\.loginCoordinator) private var coordinator + @EnvironmentObject private var router: LoginRouter @State private var isSignupFailureModalPresented: Bool = false @State private var signupFailureMessage: String = "" @@ -26,9 +26,9 @@ struct NicknameSettingView: View { var body: some View { NicknameEditView(config: .signup) { - coordinator?.pop() + router.pop() } onSubmitSuccess: { nickname in - coordinator?.push(to: .preferredGenre(builder.withNickname(nickname))) + router.push(.preferredGenre(builder.withNickname(nickname))) } .crossDissolve(isPresented: $isSignupFailureModalPresented, dismissOnTapOutside: false) { LivithModal( @@ -38,7 +38,7 @@ struct NicknameSettingView: View { isSignupFailureModalPresented = false Task { @MainActor in try? await Task.sleep(for: .seconds(0.25)) - coordinator?.popToRoot() + router.popToRoot() } } ) diff --git a/Projects/LoginFeature/Sources/Onboarding/View/PreferredArtistSettingView.swift b/Projects/LoginFeature/Sources/Onboarding/View/PreferredArtistSettingView.swift index 29379467..c3db9e44 100644 --- a/Projects/LoginFeature/Sources/Onboarding/View/PreferredArtistSettingView.swift +++ b/Projects/LoginFeature/Sources/Onboarding/View/PreferredArtistSettingView.swift @@ -14,7 +14,7 @@ import LivithDesignSystem import PreferenceFeature struct PreferredArtistSettingView: View { - @Environment(\.loginCoordinator) private var coordinator + @EnvironmentObject private var router: LoginRouter @StateObject private var store: SignupStore @State private var isSignupFailureModalPresented: Bool = false @@ -35,7 +35,7 @@ struct PreferredArtistSettingView: View { if isModified { isDiscardChangesModalPresented = true } else { - coordinator?.pop() + router.pop() } } onSkip: { AmplitudeService.shared.trackEvent(tag: .click(.skipArtistPreference)) @@ -53,7 +53,7 @@ struct PreferredArtistSettingView: View { confirmTitle: Literals.signupFailureModalConfirmTitle, onConfirm: { isSignupFailureModalPresented = false - coordinator?.popToRoot() + router.popToRoot() } ) } @@ -64,7 +64,7 @@ struct PreferredArtistSettingView: View { cancelTitle: Literals.discardChangesCancelTitle, type: .confirm(onConfirm: { isDiscardChangesModalPresented = false - coordinator?.pop() + router.pop() }), onCancel: { isDiscardChangesModalPresented = false @@ -82,7 +82,7 @@ private extension PreferredArtistSettingView { case .idle: break case .success: - coordinator?.completeSignup(with: builder.nickname) + router.completeSignup(with: builder.nickname) case .failure: isSignupFailureModalPresented = true } diff --git a/Projects/LoginFeature/Sources/Onboarding/View/PreferredGenreSettingView.swift b/Projects/LoginFeature/Sources/Onboarding/View/PreferredGenreSettingView.swift index 2234c782..2fafe835 100644 --- a/Projects/LoginFeature/Sources/Onboarding/View/PreferredGenreSettingView.swift +++ b/Projects/LoginFeature/Sources/Onboarding/View/PreferredGenreSettingView.swift @@ -14,7 +14,7 @@ import PreferenceFeature import LivithDesignSystem struct PreferredGenreSettingView: View { - @Environment(\.loginCoordinator) private var coordinator + @EnvironmentObject private var router: LoginRouter @State private var isDiscardChangesModalPresented: Bool = false @@ -29,12 +29,12 @@ struct PreferredGenreSettingView: View { if isModified { isDiscardChangesModalPresented = true } else { - coordinator?.pop() + router.pop() } } onSubmit: { selectedGenreList in AmplitudeService.shared.trackEvent(tag: .confirm(.genrePreference)) let updated = builder.withPreferredGenreList(selectedGenreList) - coordinator?.push(to: .preferredArtist(updated)) + router.push(.preferredArtist(updated)) } .crossDissolve(isPresented: $isDiscardChangesModalPresented, dismissOnTapOutside: false) { LivithDangerModal( @@ -43,7 +43,7 @@ struct PreferredGenreSettingView: View { cancelTitle: "잘못 눌렀어요", type: .confirm(onConfirm: { isDiscardChangesModalPresented = false - coordinator?.pop() + router.pop() }), onCancel: { isDiscardChangesModalPresented = false diff --git a/Projects/LoginFeature/Sources/Onboarding/View/TermsView.swift b/Projects/LoginFeature/Sources/Onboarding/View/TermsView.swift index 37a9ed90..0e3652f6 100644 --- a/Projects/LoginFeature/Sources/Onboarding/View/TermsView.swift +++ b/Projects/LoginFeature/Sources/Onboarding/View/TermsView.swift @@ -13,7 +13,7 @@ import LivithDesignSystem struct TermsView: View { @StateObject private var store = TermsStore() - @Environment(\.loginCoordinator) private var coordinator + @EnvironmentObject private var router: LoginRouter @Environment(\.openURL) private var openURL @State private var showSafari = false @State private var safariURL: URL? @@ -65,7 +65,7 @@ struct TermsView: View { private extension TermsView { var navigationBar: some View { LivithNavigationView( - type: .back(title: Literals.navigationTitle, onBack: { coordinator?.pop() }) + type: .back(title: Literals.navigationTitle, onBack: { router.pop() }) ) } @@ -146,7 +146,7 @@ private extension TermsView { var nextButton: some View { LivithButton(Literals.nextButtonText, variant: .primary) { - coordinator?.push(to: .nickname(.start(tempUser: tempUser, isMarketingAgreed: isMarketingAgreed))) + router.push(.nickname(.start(tempUser: tempUser, isMarketingAgreed: isMarketingAgreed))) } .disabled(!canProceed) } diff --git a/Projects/SearchFeature/Sources/Coordinator/EnvironmentValues+SearchCoordinator.swift b/Projects/SearchFeature/Sources/Coordinator/EnvironmentValues+SearchCoordinator.swift deleted file mode 100644 index ceb653f0..00000000 --- a/Projects/SearchFeature/Sources/Coordinator/EnvironmentValues+SearchCoordinator.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// EnvironmentValues+SearchCoordinator.swift -// SearchFeature -// -// Created by 김진웅 on 12/27/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI - -private struct SearchCoordinatorKey: EnvironmentKey { - static let defaultValue: SearchCoordinator? = nil -} - -extension EnvironmentValues { - var searchCoordinator: SearchCoordinator? { - get { self[SearchCoordinatorKey.self] } - set { self[SearchCoordinatorKey.self] = newValue } - } -} diff --git a/Projects/SearchFeature/Sources/Coordinator/SearchContentView.swift b/Projects/SearchFeature/Sources/Coordinator/SearchContentView.swift deleted file mode 100644 index 20a23f92..00000000 --- a/Projects/SearchFeature/Sources/Coordinator/SearchContentView.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// SearchContentView.swift -// SearchFeature -// -// Created by 김진웅 on 12/27/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI - -import LivithDesignSystem - -public struct SearchContentView: View { - @State private var coordinator: SearchCoordinator = SearchCoordinator() - @Binding private var isTabBarHidden: Bool - - public init(isTabBarHidden: Binding) { - self._isTabBarHidden = isTabBarHidden - } - - public var body: some View { - SearchNavigationHost(coordinator: coordinator, isTabBarHidden: $isTabBarHidden) - .ignoresSafeArea() - } -} - -// MARK: - NavigationHost - -private extension SearchContentView { - struct SearchNavigationHost: UIViewControllerRepresentable { - let coordinator: SearchCoordinator - @Binding var isTabBarHidden: Bool - - func makeCoordinator() -> NavDelegate { - NavDelegate(isTabBarHidden: $isTabBarHidden) - } - - final class NavDelegate: NSObject, UINavigationControllerDelegate { - @Binding var isTabBarHidden: Bool - - init(isTabBarHidden: Binding) { - self._isTabBarHidden = isTabBarHidden - } - - func navigationController( - _ navigationController: UINavigationController, - willShow viewController: UIViewController, - animated: Bool - ) { - let stackCount = navigationController.viewControllers.count - Task { @MainActor in - self.isTabBarHidden = stackCount > 1 - } - } - } - - func makeUIViewController(context: Context) -> UINavigationController { - let nav = coordinator.navigationController - nav.delegate = context.coordinator - if nav.viewControllers.isEmpty { - coordinator.start() - } - return nav - } - - func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} - } -} diff --git a/Projects/SearchFeature/Sources/Coordinator/SearchCoordinator.swift b/Projects/SearchFeature/Sources/Coordinator/SearchCoordinator.swift deleted file mode 100644 index 23160fd8..00000000 --- a/Projects/SearchFeature/Sources/Coordinator/SearchCoordinator.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// SearchCoordinator.swift -// SearchFeature -// -// Created by 김진웅 on 12/27/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI -import UIKit - -import ConcertFeature - -import LivithDesignSystem -import Coordinator - -final class SearchCoordinator: Coordinator { - typealias R = SearchRoute - - let navigationController: UINavigationController - - private var concertCoordinator: ConcertCoordinator? - - init() { - self.navigationController = UINavigationController() - - self.navigationController.setNavigationBarHidden(true, animated: false) - } - - func start() { - push(to: .explore, animated: false) - } - - func buildViewController(for route: SearchRoute) -> UIViewController { - switch route { - case .explore: - return UIHostingController(rootView: ExploreView().environment(\.searchCoordinator, self)) - case .search: - return UIHostingController(rootView: SearchView(store: .init()).environment(\.searchCoordinator, self)) - } - } - - func showConcertDetail(concertID: Int) { - let coordinator = ConcertCoordinator( - navigationController: navigationController, - onDismiss: { [weak self] in - self?.concertCoordinator = nil - } - ) - self.concertCoordinator = coordinator - coordinator.start(concertID: concertID) - } -} diff --git a/Projects/SearchFeature/Sources/Coordinator/SearchCoordinatorView.swift b/Projects/SearchFeature/Sources/Coordinator/SearchCoordinatorView.swift new file mode 100644 index 00000000..1d811c8e --- /dev/null +++ b/Projects/SearchFeature/Sources/Coordinator/SearchCoordinatorView.swift @@ -0,0 +1,61 @@ +// +// SearchCoordinatorView.swift +// SearchFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import SwiftUI + +import ConcertFeature +import Coordinator + +// MARK: - SearchRouter + +typealias SearchRouter = Router + +// MARK: - SearchCoordinatorView + +public struct SearchCoordinatorView: View { + + // MARK: - Property + + @StateObject private var router: SearchRouter + + // MARK: - Initializer + + public init() { + _router = StateObject(wrappedValue: SearchRouter()) + } + + // MARK: - Body + + public var body: some View { + NavigationStack(path: $router.path) { + ExploreView() + .navigationDestination(for: SearchRoute.self) { route in + destinationView(for: route) + .toolbar(.hidden, for: .tabBar, .navigationBar) + } + } + .environmentObject(router) + .ignoresSafeArea() + } + + @ViewBuilder + private func destinationView(for route: SearchRoute) -> some View { + switch route { + case .explore: + ExploreView() + case .search: + SearchView(store: .init()) + case .concertDetail(let concertID): + ConcertCoordinatorView( + concertID: concertID, + initialTab: .artistDetail, + initialSection: nil + ) + } + } +} diff --git a/Projects/SearchFeature/Sources/Coordinator/SearchRoute.swift b/Projects/SearchFeature/Sources/Coordinator/SearchRoute.swift index c7579e37..4fa6373e 100644 --- a/Projects/SearchFeature/Sources/Coordinator/SearchRoute.swift +++ b/Projects/SearchFeature/Sources/Coordinator/SearchRoute.swift @@ -8,10 +8,8 @@ import Foundation -import LivithDesignSystem -import Coordinator - -enum SearchRoute: Route { +enum SearchRoute: Hashable { case explore case search + case concertDetail(concertID: Int) } diff --git a/Projects/SearchFeature/Sources/Explore/View/ExploreView.swift b/Projects/SearchFeature/Sources/Explore/View/ExploreView.swift index ae7608b7..e0378f27 100644 --- a/Projects/SearchFeature/Sources/Explore/View/ExploreView.swift +++ b/Projects/SearchFeature/Sources/Explore/View/ExploreView.swift @@ -17,7 +17,7 @@ struct ExploreView: View { // MARK: - Property - @Environment(\.searchCoordinator) private var coordinator + @EnvironmentObject private var searchRouter: SearchRouter @Environment(\.openURL) private var openURL @StateObject private var store: ExploreStore = ExploreStore() @@ -227,7 +227,7 @@ private extension ExploreView { badge: .status(text: ConcertDisplayHelper.statusBadge(for: concert), remainDays: nil), onTap: { AmplitudeService.shared.trackEvent(tag: .click(.searchCell)) - coordinator?.showConcertDetail(concertID: concert.id) + searchRouter.push(.concertDetail(concertID: concert.id)) } ) .onAppear { @@ -264,7 +264,7 @@ private extension ExploreView { func handleSearchTap() { AmplitudeService.shared.trackEvent(tag: .click(.searchBar)) - coordinator?.push(to: .search) + searchRouter.push(.search) } func handleBannerTap(_ banner: Banner) { diff --git a/Projects/SearchFeature/Sources/Search/View/SearchView.swift b/Projects/SearchFeature/Sources/Search/View/SearchView.swift index 8a5885a1..0ae1c1c4 100644 --- a/Projects/SearchFeature/Sources/Search/View/SearchView.swift +++ b/Projects/SearchFeature/Sources/Search/View/SearchView.swift @@ -19,7 +19,7 @@ struct SearchView: View { // MARK: - Property - @Environment(\.searchCoordinator) private var coordinator + @EnvironmentObject private var searchRouter: SearchRouter @ObservedObject private var store: SearchStore @State private var showError: Bool = false @@ -111,7 +111,7 @@ private extension SearchView { HStack(alignment: .center) { Button { hideKeyboard() - coordinator?.pop() + searchRouter.pop() } label: { Image.livithIcon(.backLineDefault) .resizable() @@ -243,7 +243,7 @@ private extension SearchView { onTap: { AmplitudeService.shared.trackEvent(tag: .click(.searchCell)) hideKeyboard() - coordinator?.showConcertDetail(concertID: concert.id) + searchRouter.push(.concertDetail(concertID: concert.id)) } ) .transition(.opacity.combined(with: .scale(scale: 0.95))) diff --git a/Projects/SetlistFeature/Sources/View/SetlistDetailView.swift b/Projects/SetlistFeature/Sources/View/SetlistDetailView.swift index bde30616..a2b833e2 100644 --- a/Projects/SetlistFeature/Sources/View/SetlistDetailView.swift +++ b/Projects/SetlistFeature/Sources/View/SetlistDetailView.swift @@ -16,26 +16,27 @@ public struct SetlistDetailView: View { // MARK: - Property + private static let reportFormURL = URL(string: "https://forms.gle/aMj5C4LhDcMzueWz5")! + @StateObject private var store = SetlistStore() @Environment(\.dismiss) private var dismiss private let concertID: Int private let setlistID: Int private let onPlaySong: ((SetlistSong) -> Void)? - private let onReportTapped: (() -> Void)? + + @State private var isReportSheetPresented: Bool = false // MARK: - Initializer public init( concertID: Int, setlistID: Int, - onPlaySong: ((SetlistSong) -> Void)? = nil, - onReportTapped: (() -> Void)? = nil + onPlaySong: ((SetlistSong) -> Void)? = nil ) { self.concertID = concertID self.setlistID = setlistID self.onPlaySong = onPlaySong - self.onReportTapped = onReportTapped } private var showErrorToast: Binding { @@ -72,6 +73,9 @@ public struct SetlistDetailView: View { type: .failure, message: store.state.fetchError ?? "" ) + .sheet(isPresented: $isReportSheetPresented) { + SafariView(url: Self.reportFormURL) + } .onAppear { store.send(.onAppear(concertID: concertID, setlistID: setlistID)) } @@ -102,7 +106,7 @@ private extension SetlistDetailView { firstLine: setlist.type.displayText, onReportTapped: { AmplitudeService.shared.trackEvent(tag: .click(.reportSetlist)) - onReportTapped?() + isReportSheetPresented = true } ) .padding(.horizontal, 16) diff --git a/Projects/SongFeature/Sources/View/SongLyricsView.swift b/Projects/SongFeature/Sources/View/SongLyricsView.swift index 52fefef3..76b341ee 100644 --- a/Projects/SongFeature/Sources/View/SongLyricsView.swift +++ b/Projects/SongFeature/Sources/View/SongLyricsView.swift @@ -28,30 +28,32 @@ public struct SongLyricsView: View { // MARK: - Property + private static let reportFormURL = URL(string: "https://forms.gle/aMj5C4LhDcMzueWz5")! + @StateObject private var store = SongLyricsStore() @Environment(\.dismiss) private var dismiss @State private var selectedDetent: PresentationDetent = .medium @State private var warningMessage: String? @State private var errorMessage: String? @State private var warningDismissTask: Task? + @State private var isReportSheetPresented: Bool = false + @State private var isLyricsSheetPresented: Bool = false + @State private var isPendingReportPresentation: Bool = false private let songID: Int private let setlistID: Int? private let songTitle: String - private let onReportTapped: () -> Void // MARK: - Initializer public init( songID: Int, setlistID: Int? = nil, - songTitle: String, - onReportTapped: @escaping () -> Void = {} + songTitle: String ) { self.songID = songID self.setlistID = setlistID self.songTitle = songTitle - self.onReportTapped = onReportTapped } private var showErrorToast: Binding { @@ -86,7 +88,7 @@ public struct SongLyricsView: View { .background(Color.livithColor(.black100)) .navigationBarBackButtonHidden() .toolbar(.hidden, for: .navigationBar) - .sheet(isPresented: .constant(store.state.hasLyrics)) { + .sheet(isPresented: $isLyricsSheetPresented) { LyricsContentView(store: store) .presentationDetents([.height(150), .medium, .large], selection: $selectedDetent) .presentationDragIndicator(.visible) @@ -102,6 +104,9 @@ public struct SongLyricsView: View { type: .failure, message: errorMessage ?? "" ) + .sheet(isPresented: $isReportSheetPresented) { + SafariView(url: Self.reportFormURL) + } .onAppear { store.send(.onAppear(songID: songID, setlistID: setlistID, songTitle: songTitle)) } @@ -125,6 +130,20 @@ public struct SongLyricsView: View { } } } + .onChange(of: store.state.hasLyrics) { newValue in + isLyricsSheetPresented = newValue + } + .onChange(of: isLyricsSheetPresented) { newValue in + if !newValue && isPendingReportPresentation { + isPendingReportPresentation = false + isReportSheetPresented = true + } + } + .onChange(of: isReportSheetPresented) { newValue in + if !newValue && store.state.hasLyrics { + isLyricsSheetPresented = true + } + } .onDisappear { warningDismissTask?.cancel() } @@ -137,6 +156,7 @@ private extension SongLyricsView { var navigationBar: some View { HStack(spacing: 4) { Button { + isLyricsSheetPresented = false dismiss() } label: { Image.livithIcon(.backLineDefault) @@ -155,7 +175,12 @@ private extension SongLyricsView { Button { AmplitudeService.shared.trackEvent(tag: .click(.reportSong)) - onReportTapped() + if isLyricsSheetPresented { + isLyricsSheetPresented = false + isPendingReportPresentation = true + } else { + isReportSheetPresented = true + } } label: { Text("정보 제보") .notosans(.caption1Semibold) diff --git a/Projects/UserFeature/Sources/Coordinator/EnvironmentValues+UserCoordinator.swift b/Projects/UserFeature/Sources/Coordinator/EnvironmentValues+UserCoordinator.swift deleted file mode 100644 index db1d6800..00000000 --- a/Projects/UserFeature/Sources/Coordinator/EnvironmentValues+UserCoordinator.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// EnvironmentValues+UserCoordinator.swift -// UserFeature -// -// Created by 김진웅 on 2/7/26. -// Copyright © 2026 Livith. All rights reserved. -// - -import SwiftUI - -private struct UserCoordinatorKey: EnvironmentKey { - static let defaultValue: UserCoordinator? = nil -} - -extension EnvironmentValues { - var userCoordinator: UserCoordinator? { - get { self[UserCoordinatorKey.self] } - set { self[UserCoordinatorKey.self] = newValue } - } -} diff --git a/Projects/UserFeature/Sources/Coordinator/UserContentView.swift b/Projects/UserFeature/Sources/Coordinator/UserContentView.swift deleted file mode 100644 index 3aeb4036..00000000 --- a/Projects/UserFeature/Sources/Coordinator/UserContentView.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// UserContentView.swift -// UserFeature -// -// Created by Youjin Lee on 1/28/26. -// Copyright © 2026 Livith. All rights reserved. -// - -import SwiftUI -import UIKit - -import LivithDesignSystem - -public struct UserContentView: View { - @State private var coordinator: UserCoordinator - @Binding private var isTabBarHidden: Bool - private let onNavigateToHome: (() -> Void)? - - public init( - isTabBarHidden: Binding, - onNavigateToHome: (() -> Void)? = nil - ) { - self._coordinator = State(initialValue: UserCoordinator(isTabBarHidden: isTabBarHidden, onNavigateToHome: onNavigateToHome)) - self._isTabBarHidden = isTabBarHidden - self.onNavigateToHome = onNavigateToHome - } - - public var body: some View { - UserNavigationHost(coordinator: coordinator, isTabBarHidden: $isTabBarHidden) - .ignoresSafeArea() - } -} - -// MARK: - NavigationHost - -private extension UserContentView { - struct UserNavigationHost: UIViewControllerRepresentable { - let coordinator: UserCoordinator - @Binding var isTabBarHidden: Bool - - func makeCoordinator() -> NavDelegate { - NavDelegate(isTabBarHidden: $isTabBarHidden) - } - - final class NavDelegate: NSObject, UINavigationControllerDelegate { - @Binding var isTabBarHidden: Bool - - init(isTabBarHidden: Binding) { - self._isTabBarHidden = isTabBarHidden - } - - func navigationController( - _ navigationController: UINavigationController, - willShow viewController: UIViewController, - animated: Bool - ) { - let stackCount = navigationController.viewControllers.count - Task { @MainActor in - isTabBarHidden = stackCount > 1 - } - } - } - - func makeUIViewController(context: Context) -> UINavigationController { - let nav = coordinator.navigationController - nav.delegate = context.coordinator - if nav.viewControllers.isEmpty { - coordinator.start() - } - return nav - } - - func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} - } -} diff --git a/Projects/UserFeature/Sources/Coordinator/UserCoordinator.swift b/Projects/UserFeature/Sources/Coordinator/UserCoordinator.swift deleted file mode 100644 index 5e6aa439..00000000 --- a/Projects/UserFeature/Sources/Coordinator/UserCoordinator.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// UserCoordinator.swift -// UserFeature -// -// Created by Youjin Lee on 1/28/26. -// Copyright © 2026 Livith. All rights reserved. -// - -import SwiftUI - -import Coordinator - -final class UserCoordinator: Coordinator { - typealias R = UserRoute - - let navigationController: UINavigationController - - private let isTabBarHidden: Binding - - var onGenreUpdateSuccess: (() -> Void)? - var onArtistUpdateSuccess: (() -> Void)? - var onNavigateToHome: (() -> Void)? - - init( - isTabBarHidden: Binding, - onNavigateToHome: (() -> Void)? = nil - ) { - self.navigationController = UINavigationController() - self.isTabBarHidden = isTabBarHidden - self.onNavigateToHome = onNavigateToHome - - self.navigationController.setNavigationBarHidden(true, animated: false) - } - - func start() { - push(to: .user, animated: false) - } - - func buildViewController(for route: R) -> UIViewController { - switch route { - case .user: - return UIHostingController( - rootView: UserView( - isTabBarHidden: isTabBarHidden - ) - .environment(\.userCoordinator, self) - ) - - case .setting: - return UIHostingController( - rootView: SettingView() - .environment(\.userCoordinator, self) - ) - - case .noticeSetting: - return UIHostingController( - rootView: NoticeSettingView( - onBack: { [weak self] in self?.pop() } - ) - ) - - case .nicknameUpdate: - return UIHostingController( - rootView: NicknameUpdateView() - .environment(\.userCoordinator, self) - ) - - case .deleteUser: - return UIHostingController( - rootView: DeleteUserView( - store: DeleteUserStore() - ) - .environment(\.userCoordinator, self) - ) - - case .genreUpdate(let genreList): - let vc = UIHostingController( - rootView: UserGenreUpdateView( - selectedGenreList: genreList - ) - .environment(\.userCoordinator, self) - ) - vc.hidesBottomBarWhenPushed = true - return vc - - case .artistUpdate(let artistList): - let vc = UIHostingController( - rootView: UserArtistUpdateView( - selectedArtistList: artistList - ) - .environment(\.userCoordinator, self) - ) - vc.hidesBottomBarWhenPushed = true - return vc - } - } -} diff --git a/Projects/UserFeature/Sources/Coordinator/UserCoordinatorView.swift b/Projects/UserFeature/Sources/Coordinator/UserCoordinatorView.swift new file mode 100644 index 00000000..50cab1ea --- /dev/null +++ b/Projects/UserFeature/Sources/Coordinator/UserCoordinatorView.swift @@ -0,0 +1,58 @@ +// +// UserCoordinatorView.swift +// UserFeature +// +// Created by Youjin Lee on 1/28/26. +// Copyright © 2026 Livith. All rights reserved. +// + +import SwiftUI + +import LivithDesignSystem + +public struct UserCoordinatorView: View { + @StateObject private var router: UserRouter + + public init( + onNavigateToHome: (() -> Void)? = nil + ) { + _router = StateObject( + wrappedValue: UserRouter( + onNavigateToHome: onNavigateToHome ?? {} + ) + ) + } + + public var body: some View { + NavigationStack(path: $router.path) { + UserView() + .navigationDestination(for: UserRoute.self) { route in + destinationView(for: route) + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + } + } + .environmentObject(router) + .ignoresSafeArea() + } + + @ViewBuilder + private func destinationView(for route: UserRoute) -> some View { + switch route { + case .user: + UserView() + case .setting: + SettingView() + case .noticeSetting: + NoticeSettingView(onBack: { router.pop() }) + case .nicknameUpdate: + NicknameUpdateView() + case .deleteUser: + DeleteUserView(store: DeleteUserStore()) + case .genreUpdate(let genreList): + UserGenreUpdateView(selectedGenreList: genreList) + case .artistUpdate(let artistList): + UserArtistUpdateView(selectedArtistList: artistList) + } + } +} diff --git a/Projects/UserFeature/Sources/Coordinator/UserRoute.swift b/Projects/UserFeature/Sources/Coordinator/UserRoute.swift index 72cc1cbd..eeb4178c 100644 --- a/Projects/UserFeature/Sources/Coordinator/UserRoute.swift +++ b/Projects/UserFeature/Sources/Coordinator/UserRoute.swift @@ -8,10 +8,9 @@ import Foundation -import Coordinator import Domain -enum UserRoute: Route { +enum UserRoute: Hashable { case user case setting case noticeSetting diff --git a/Projects/UserFeature/Sources/Coordinator/UserRouter.swift b/Projects/UserFeature/Sources/Coordinator/UserRouter.swift new file mode 100644 index 00000000..1a4dae4a --- /dev/null +++ b/Projects/UserFeature/Sources/Coordinator/UserRouter.swift @@ -0,0 +1,38 @@ +// +// UserRouter.swift +// UserFeature +// +// Created by on 6/15/26. +// Copyright © 2026 Livith. All rights reserved. +// + +import Coordinator + +final class UserRouter: Router { + private let onNavigateToHome: () -> Void + // TODO: 향후 Store 상태 변경으로 이전 예정 + var onGenreUpdateSuccess: (() -> Void)? + // TODO: 향후 Store 상태 변경으로 이전 예정 + var onArtistUpdateSuccess: (() -> Void)? + + init( + onNavigateToHome: @escaping () -> Void = {} + ) { + self.onNavigateToHome = onNavigateToHome + super.init() + } + + func navigateToHome() { + onNavigateToHome() + } + + // TODO: 향후 Store 상태 변경으로 이전 예정 + func genreUpdateSuccess() { + onGenreUpdateSuccess?() + } + + // TODO: 향후 Store 상태 변경으로 이전 예정 + func artistUpdateSuccess() { + onArtistUpdateSuccess?() + } +} diff --git a/Projects/UserFeature/Sources/View/DeleteUserView.swift b/Projects/UserFeature/Sources/View/DeleteUserView.swift index 7428b23f..c725918a 100644 --- a/Projects/UserFeature/Sources/View/DeleteUserView.swift +++ b/Projects/UserFeature/Sources/View/DeleteUserView.swift @@ -23,7 +23,7 @@ struct DeleteUserView: View { @ObservedObject private var store: DeleteUserStore - @Environment(\.userCoordinator) private var coordinator + @EnvironmentObject private var router: UserRouter // MARK: - LifeCycle @@ -116,7 +116,7 @@ struct DeleteUserView: View { isConfirmed: $isConfirmed, onCancel: { showConfirmSheet = false - coordinator?.pop() + router.pop() }, onConfirm: { showConfirmSheet = false @@ -132,7 +132,7 @@ struct DeleteUserView: View { private extension DeleteUserView { var navigationBar: some View { LivithNavigationView( - type: .backOnly(onBack: { coordinator?.pop() }) + type: .backOnly(onBack: { router.pop() }) ) } diff --git a/Projects/UserFeature/Sources/View/NicknameUpdateView.swift b/Projects/UserFeature/Sources/View/NicknameUpdateView.swift index 289694e5..e637331a 100644 --- a/Projects/UserFeature/Sources/View/NicknameUpdateView.swift +++ b/Projects/UserFeature/Sources/View/NicknameUpdateView.swift @@ -18,14 +18,14 @@ struct NicknameUpdateView: View { @State private var showFailureToast: Bool = false @State private var toastMessage: String = "" - @Environment(\.userCoordinator) private var coordinator + @EnvironmentObject private var router: UserRouter var body: some View { NicknameEditView( config: .update, - onDismiss: { coordinator?.pop() }, + onDismiss: { router.pop() }, onSubmitSuccess: { _ in - coordinator?.pop() + router.pop() }, onSubmitFailure: { _ in toastMessage = "닉네임 변경에 실패했어요" diff --git a/Projects/UserFeature/Sources/View/SettingView.swift b/Projects/UserFeature/Sources/View/SettingView.swift index 5bde9667..57bd5fe5 100644 --- a/Projects/UserFeature/Sources/View/SettingView.swift +++ b/Projects/UserFeature/Sources/View/SettingView.swift @@ -20,7 +20,7 @@ struct SettingView: View { @StateObject private var logoutStore = LogoutStore() - @Environment(\.userCoordinator) private var coordinator + @EnvironmentObject private var router: UserRouter // MARK: - Initializer @@ -33,7 +33,7 @@ struct SettingView: View { navigationBar VStack(spacing: 12) { - LivithListItem(Literals.noticeSetting, type: .navigation, action: { coordinator?.push(to: .noticeSetting) }) + LivithListItem(Literals.noticeSetting, type: .navigation, action: { router.push(.noticeSetting) }) LivithListItem(Literals.updateNote, type: .navigation, action: showUpdateNote) @@ -43,7 +43,7 @@ struct SettingView: View { LivithListItem(Literals.logout, type: .action, action: showLogoutModal) - LivithListItem(Literals.deleteAccount, type: .action, action: { coordinator?.push(to: .deleteUser) }) + LivithListItem(Literals.deleteAccount, type: .action, action: { router.push(.deleteUser) }) } .padding(.top, 20) @@ -84,7 +84,7 @@ struct SettingView: View { private extension SettingView { var navigationBar: some View { - LivithNavigationView(type: .back(title: Literals.title, onBack: { coordinator?.pop() })) + LivithNavigationView(type: .back(title: Literals.title, onBack: { router.pop() })) } } diff --git a/Projects/UserFeature/Sources/View/UserArtistUpdateView.swift b/Projects/UserFeature/Sources/View/UserArtistUpdateView.swift index b64fc4ef..73585c41 100644 --- a/Projects/UserFeature/Sources/View/UserArtistUpdateView.swift +++ b/Projects/UserFeature/Sources/View/UserArtistUpdateView.swift @@ -21,7 +21,7 @@ struct UserArtistUpdateView: View { private let selectedArtistList: [PreferredArtist] - @Environment(\.userCoordinator) private var coordinator + @EnvironmentObject private var router: UserRouter init( selectedArtistList: [PreferredArtist] @@ -39,7 +39,7 @@ struct UserArtistUpdateView: View { if isModified { isDiscardChangesModalPresented = true } else { - coordinator?.pop() + router.pop() } } onSubmit: { artistList in AmplitudeService.shared.trackEvent(tag: .confirm(.changeArtistPreference)) @@ -53,7 +53,7 @@ struct UserArtistUpdateView: View { type: .confirm(onConfirm: { AmplitudeService.shared.trackEvent(tag: .confirm(.backPreference)) isDiscardChangesModalPresented = false - coordinator?.pop() + router.pop() }) ) { AmplitudeService.shared.trackEvent(tag: .click(.cancelPreference)) @@ -68,8 +68,9 @@ struct UserArtistUpdateView: View { .onChange(of: store.state.result) { _, result in switch result { case .success: - coordinator?.onArtistUpdateSuccess?() - coordinator?.pop() + // TODO: 향후 Store 상태 변경으로 이전 예정 + router.artistUpdateSuccess() + router.pop() case .failure: isFailureToastPresented = true case .idle: diff --git a/Projects/UserFeature/Sources/View/UserGenreUpdateView.swift b/Projects/UserFeature/Sources/View/UserGenreUpdateView.swift index 7c5673af..bb81a8f2 100644 --- a/Projects/UserFeature/Sources/View/UserGenreUpdateView.swift +++ b/Projects/UserFeature/Sources/View/UserGenreUpdateView.swift @@ -21,7 +21,7 @@ struct UserGenreUpdateView: View { private let selectedGenreList: [PreferredGenre] - @Environment(\.userCoordinator) private var coordinator + @EnvironmentObject private var router: UserRouter init( selectedGenreList: [PreferredGenre] @@ -39,7 +39,7 @@ struct UserGenreUpdateView: View { if isModified { isDiscardChangesModalPresented = true } else { - coordinator?.pop() + router.pop() } } onSubmit: { genreList in AmplitudeService.shared.trackEvent(tag: .confirm(.changeGenrePreference)) @@ -53,7 +53,7 @@ struct UserGenreUpdateView: View { type: .confirm(onConfirm: { AmplitudeService.shared.trackEvent(tag: .confirm(.backPreference)) isDiscardChangesModalPresented = false - coordinator?.pop() + router.pop() }) ) { AmplitudeService.shared.trackEvent(tag: .click(.cancelPreference)) @@ -63,8 +63,9 @@ struct UserGenreUpdateView: View { .onChange(of: store.state.result) { _, result in switch result { case .success: - coordinator?.onGenreUpdateSuccess?() - coordinator?.pop() + // TODO: 향후 Store 상태 변경으로 이전 예정 + router.genreUpdateSuccess() + router.pop() case .failure: isFailureToastPresented = true case .idle: diff --git a/Projects/UserFeature/Sources/View/UserView.swift b/Projects/UserFeature/Sources/View/UserView.swift index 14be5e5d..2532bfa8 100644 --- a/Projects/UserFeature/Sources/View/UserView.swift +++ b/Projects/UserFeature/Sources/View/UserView.swift @@ -36,17 +36,9 @@ struct UserView: View { @State private var showGenreUpdateSuccessSnackBar: Bool = false @State private var showArtistUpdateSuccessSnackBar: Bool = false - @Binding private var isTabBarHidden: Bool - @StateObject private var store = UserStore() - @Environment(\.userCoordinator) private var coordinator - - // MARK: - LifeCycle - - init(isTabBarHidden: Binding) { - self._isTabBarHidden = isTabBarHidden - } + @EnvironmentObject private var router: UserRouter // MARK: - Body @@ -102,7 +94,7 @@ struct UserView: View { private extension UserView { var settingButton: some View { - Button(action: { coordinator?.push(to: .setting) }) { + Button(action: { router.push(.setting) }) { Image.livithIcon(.settingFill) .resizable() .frame(width: 36, height: 36) @@ -131,7 +123,7 @@ private extension UserView { } var editButton: some View { - Button(action: { coordinator?.push(to: .nicknameUpdate) }) { + Button(action: { router.push(.nicknameUpdate) }) { Text(Literals.editNickname) .notosans(.body4Medium) .foregroundStyle(Color.livithColor(.black5)) @@ -182,8 +174,9 @@ private extension UserView { } else { AmplitudeService.shared.trackEvent(tag: .click(.setGenrePreference)) } - coordinator?.push(to: .genreUpdate(selectedGenreList: store.state.genres)) - coordinator?.onGenreUpdateSuccess = { + router.push(.genreUpdate(selectedGenreList: store.state.genres)) + // TODO: 향후 Store 상태 변경으로 이전 예정 + router.onGenreUpdateSuccess = { showGenreUpdateSuccessSnackBar = true } } @@ -213,10 +206,11 @@ private extension UserView { if store.state.hasArtistData { AmplitudeService.shared.trackEvent(tag: .click(.changeArtistPreference)) } else { - AmplitudeService.shared.trackEvent(tag: .click(.setArtistPreference)) + AmplitudeService.shared.trackEvent(tag: .click(.setGenrePreference)) } - coordinator?.push(to: .artistUpdate(selectedArtistList: store.state.artists)) - coordinator?.onArtistUpdateSuccess = { + router.push(.artistUpdate(selectedArtistList: store.state.artists)) + // TODO: 향후 Store 상태 변경으로 이전 예정 + router.onArtistUpdateSuccess = { showArtistUpdateSuccessSnackBar = true } } @@ -249,7 +243,7 @@ private extension UserView { position: .top, onActionTapped: { showGenreUpdateSuccessSnackBar = false - coordinator?.onNavigateToHome?() + router.navigateToHome() }, onDismiss: { showGenreUpdateSuccessSnackBar = false @@ -268,7 +262,7 @@ private extension UserView { position: .top, onActionTapped: { showArtistUpdateSuccessSnackBar = false - coordinator?.onNavigateToHome?() + router.navigateToHome() }, onDismiss: { showArtistUpdateSuccessSnackBar = false @@ -349,7 +343,5 @@ private extension UserView { // MARK: - Preview #Preview { - UserView( - isTabBarHidden: .constant(false) - ) + UserView() } diff --git a/docs/archives/LIVD-425-concert-setlist-song-navigationstack-migration.md b/docs/archives/LIVD-425-concert-setlist-song-navigationstack-migration.md new file mode 100644 index 00000000..782919a8 --- /dev/null +++ b/docs/archives/LIVD-425-concert-setlist-song-navigationstack-migration.md @@ -0,0 +1,216 @@ +# LIVD-425 Concert/Setlist/Song NavigationStack 마이그레이션 + +## 배경 +- LIVD-425에서 LoginFeature와 UserFeature를 SwiftUI `Router` + `NavigationStack` 패턴으로 전환 완료. +- ConcertFeature는 Home/Search의 자식 `ConcertCoordinator`로 동작하며, 6개의 view가 `concertCoordinator` environment에 의존하고 있다. +- SetlistFeature/SongFeature는 자체 Coordinator가 없으며 `ConcertCoordinator`의 route로 push된다. +- ADR-001에 따라 cross-feature 자식(Concert)은 view-only `CoordinatorView`로 전환한다. Router 없이 선언형 `NavigationLink(value:)` + view 내부 `.sheet`로 navigation/modal을 처리한다. + +## 목표 +- `ConcertRoute`는 push 케이스만 유지 (`.safari`, `.ticketSafari` 제거). +- `ConcertCoordinatorView` 신설 (NavigationStack/Router 미보유, view-only). +- ConcertFeature 6개 view에서 `concertCoordinator` 의존 제거. +- `SetlistDetailView`/`SongLyricsView`는 callback 인터페이스를 유지하되, `onPlaySong`/`onReportTapped`를 wrapper view로 navigation 연결. +- `ConcertView`의 `coordinator.onTicketSiteReturn` 메커니즘을 자식 view의 `onTicketSiteReturn: () -> Void` 클로저로 전달. +- `ConcertCoordinator`, `EnvironmentValues+ConcertCoordinator` 삭제. + +## 작업 항목 + +### 1. `ConcertRoute` 재작성 +- [x] `Projects/ConcertFeature/Sources/Coordinator/ConcertRoute.swift` 수정 + - 기존 6 case에서 `.safari(URL)`, `.ticketSafari(URL)` 제거 + - 4 case 유지: `.detail(concertID:initialTab:initialSection:)`, `.setlistDetail(concertID:setlistID:)`, `.songLyrics(songID:setlistID:songTitle:)`, `.merchandiseDetail([ConcertMerchandise]:ticketingOfficeURL:)` + - `Route` 프로토콜 채택 제거, `Hashable` 직접 채택 (LoginRoute/UserRoute와 동일) + - `import Coordinator` 제거 + +### 2. `ConcertCoordinatorView` 신설 +- [x] `Projects/ConcertFeature/Sources/Coordinator/ConcertCoordinatorView.swift` 신규 생성 + - `public struct ConcertCoordinatorView: View` + - `@State`로 `concertID: Int`, `initialTab: SegmentedTabBarType.DetailTab`, `initialSection: ConcertInfoSection?` 보유 + - body는 `ConcertView(concertID:, initialTab:, initialSection:)`를 root로 렌더링하고 `.navigationDestination(for: ConcertRoute.self)` 등록 + - 4 case 매핑: + - `.detail` → root (이 route는 실제로 push되지 않음, root가 항상 detail이므로 navigationDestination에 .detail case 불필요) + - `.setlistDetail` → `SetlistDetailContainer` + - `.songLyrics` → `SongLyricsView` + - `.merchandiseDetail` → `MerchandiseDetailView` + - **NavigationStack 미포함**, **Router 미보유** (view-only) + +### 3. `SetlistDetailContainerView` wrapper 신설 +- [x] `Projects/ConcertFeature/Sources/Coordinator/SetlistDetailContainerView.swift` 신규 생성 + - `struct SetlistDetailContainerView: View` + - `@State private var pendingSong: SetlistSong?` 보유 + - body: `SetlistDetailView(concertID:, setlistID:, onPlaySong: { song in pendingSong = song })` + `.navigationDestination(item: $pendingSong)` → `SongLyricsView(songID:, setlistID:, songTitle:)` + - `SetlistSong`이 `Hashable`인지 확인 후 `.navigationDestination(item:)` 사용 (iOS 17+) + - `onReportTapped`는 `SetlistDetailView` 내부 `.sheet`로 처리되므로 wrapper는 신경쓰지 않음 + - **이름 변경:** 초기 `SetlistDetailContainer`로 작성 후 `View` 접미사 컨벤션(`CoordinatorView`, `LoginCoordinatorView`, `UserCoordinatorView` 등)에 맞춰 `SetlistDetailContainerView`로 리네임 + +### 4. `ConcertView` 리팩터 +- [x] `Projects/ConcertFeature/Sources/View/ConcertView.swift` 수정 + - `@Environment(\.concertCoordinator)` 제거 + - `init`에서 `onDismiss: @escaping () -> Void` 제거 (시스템 back 사용, navigationDestination이 back을 자동 처리) + - 단, `LivithNavigationView(type: .back(title:, onBack:))`에서 custom back이 필요한 경우를 위해 `onBack: nil` 또는 시스템 처리 검토 — `LivithNavigationView`의 back 타입이 onBack 클로저를 받으므로 빈 클로저 `{}`로 두거나, custom back이 없으면 시스템 back으로 변경 + - `onAppear`의 `coordinator?.onTicketSiteReturn = { ... }` 제거 + - `onDisappear`의 `coordinator?.onTicketSiteReturn = nil` 제거 + - 대신 ticket return 배너를 트리거하기 위해 `@State private var showTicketReturnBanner`로 자체 관리하고, 자식 view(`ConcertInfoTabView`, `ConcertInfoCarousel`, `MerchandiseDetailView`)에 `onTicketSiteReturn: { showTicketReturnBanner = true }` 클로저 전달 + - `tabContentView`의 `ConcertInfoTabView(...)` 생성 시 `onTicketSiteReturn` 클로저 추가 + - `ConcertInfoCarousel` 사용처 (`ConcertInfoTabView` 내부)에도 동일 클로저 전달 (ConcertInfoTabView가 받은 클로저를 그대로 전달) + - `MerchandiseDetailView` 사용처 (`ConcertCoordinatorView`의 navigationDestination)에도 동일 클로저 전달 + - `store.state.showTicketReturnBanner` 직접 참조 부분 (line 113, 232-247) 확인 후 `@State` 변수로 변경 + +### 5. `SetlistTabView` 리팩터 +- [x] `Projects/ConcertFeature/Sources/View/TabContent/SetlistTabView.swift` 수정 + - `@Environment(\.concertCoordinator)` 제거 + - `@State private var isReportSheetPresented: Bool = false` (방안 b - bool flag) + - `coordinator?.present(to: .safari(...))` → `isReportSheetPresented = true` (SectionHeaderView의 onReport 액션) + - `coordinator?.push(to: .setlistDetail(...))` → `NavigationLink(value: ConcertRoute.setlistDetail(...))` (setlistCard) + - `.sheet(isPresented: $isReportSheetPresented) { SafariView(url: ConcertConstant.reportFormURL) }` + +### 6. `ConcertInfoTabView` 리팩터 +- [x] `Projects/ConcertFeature/Sources/View/TabContent/ConcertInfoTabView.swift` 수정 + - `@Environment(\.concertCoordinator)` 제거 + - `@State private var isReportSheetPresented: Bool` + `@State private var isTicketSheetPresented: Bool` + - `coordinator?.present(to: .safari(...))` 2곳 → `isReportSheetPresented = true` + - `coordinator?.present(to: .ticketSafari(...))` → `isTicketSheetPresented = true` + - `coordinator?.push(to: .merchandiseDetail(...))` → `NavigationLink(value: ConcertRoute.merchandiseDetail(...))` + - `.sheet(isPresented:)` 2개 + ticket sheet에는 `onDismiss: { onTicketSiteReturn() }` + - `onTicketSiteReturn: () -> Void` 클로저를 init에서 받음 + - `ConcertInfoCarousel`에 `onTicketSiteReturn` 전달 + +### 7. `ArtistDetailTabView` 리팩터 +- [x] `Projects/ConcertFeature/Sources/View/TabContent/ArtistDetailTabView.swift` 수정 + - `@Environment(\.concertCoordinator)` 제거 + - `@State` bool 3개: `isReportSheetPresented`, `isInstagramSheetPresented`, `isTwitterSheetPresented` + - `coordinator?.present(to: .safari(...))` 4곳 → 각 bool true + - `.sheet(isPresented:)` 3개 + +### 8. `ConcertInfoCarousel` 리팩터 +- [x] `Projects/ConcertFeature/Sources/View/Subview/ConcertInfoCarousel.swift` 수정 + - `@Environment(\.concertCoordinator)` 제거 + - `@State private var isTicketSheetPresented: Bool` + - `coordinator?.present(to: .ticketSafari(...))` (탭) → `isTicketSheetPresented = true` + - `.sheet(isPresented:onDismiss:)` ticket + - `onTicketSiteReturn: () -> Void` 클로저를 init에서 받음 + +### 9. `MerchandiseDetailView` 리팩터 +- [x] `Projects/ConcertFeature/Sources/View/MerchandiseDetailView.swift` 수정 + - `@Environment(\.concertCoordinator)` 제거 + - `init`에서 `onDismiss: @escaping () -> Void` 제거 (시스템 back), `onTicketSiteReturn: @escaping () -> Void` 추가 + - `@Environment(\.dismiss)`로 back 처리 + - `@State private var isTicketSheetPresented: Bool` + - `coordinator?.present(to: .ticketSafari(...))` → `isTicketSheetPresented = true` + - `.sheet(isPresented:onDismiss:)` ticket + - Preview 업데이트 + +### 10. `SetlistDetailView` 리팩터 +- [x] `Projects/SetlistFeature/Sources/View/SetlistDetailView.swift` 수정 + - `onReportTapped: (() -> Void)?` → `reportURL: URL?` 제거 + - `static let reportFormURL` (타입 프로퍼티) + 자체 `.sheet(isPresented:)` + - `init`에서 `reportURL` 파라미터 제거 + - **결정 변경:** plan 작성 시점의 방안 A(`onReportTapped` 유지, nil이면 자체 처리)에서 사용자 피드백으로 `static let reportFormURL` 타입 프로퍼티 방식으로 변경. URL 자체가 고정이므로 인자로 받을 필요 없음. + +### 11. `SongLyricsView` 리팩터 +- [x] `Projects/SongFeature/Sources/View/SongLyricsView.swift` 수정 + - `onReportTapped: @escaping () -> Void` → `reportURL: URL?` 제거 + - `static let reportFormURL` (타입 프로퍼티) + 자체 `.sheet(isPresented:)` + - **결정 변경:** 10번과 동일 + +### 12. `ConcertConstant` 공유 검토 +- [x] 방안 3 폐기, 타입 프로퍼티 방식으로 변경 + - `SetlistDetailView`, `SongLyricsView`의 `static let reportFormURL`로 자체 보유 + - `ConcertConstant.reportFormURL`은 ConcertFeature 내부에서만 사용 (TabContent view들의 .sheet) + - 모듈 간 의존성 없음 + +### 13. 불필요 파일 삭제 +- [x] `Projects/ConcertFeature/Sources/Coordinator/ConcertCoordinator.swift` 삭제 +- [x] `Projects/ConcertFeature/Sources/Coordinator/EnvironmentValues+ConcertCoordinator.swift` 삭제 + +### 14. Home/Search 임시 처리 (Plan 2/3에서 정식 처리) +- [x] `Projects/HomeFeature/Sources/Coordinator/HomeCoordinator.swift` 임시 수정 + - `ConcertCoordinator` 참조 제거 + - `showConcertDetail(concertID:initialTab:initialSection:)` → `ConcertCoordinatorView`를 `UIHostingController`로 push + - 미사용 메서드 (`showSongDetail`, `showSetlistDetail`) 제거 +- [x] `Projects/SearchFeature/Sources/Coordinator/SearchCoordinator.swift` 임시 수정 + - 동일 처리 + +### 15. 빌드 검증 +- [x] `tuist generate --no-open` 정상 완료 +- [x] HomeFeature, SearchFeature, ConcertFeature, SetlistFeature, SongFeature, Livith-iOS 스킴 빌드 성공 확인 + - **빌드 환경:** xcodebuildmcp `XcodeBuildMCP_build_sim` 사용 + - **시뮬레이터 실행은 사용하지 않음** (빌드만 수행) + +## 영향 범위 + +| 모듈 | 파일 | 변경 유형 | +|------|------|-----------| +| ConcertFeature | `Sources/Coordinator/ConcertRoute.swift` | 수정 (case 제거, Hashable) | +| ConcertFeature | `Sources/Coordinator/ConcertCoordinatorView.swift` | 신규 | +| ConcertFeature | `Sources/Coordinator/SetlistDetailContainer.swift` | 신규 | +| ConcertFeature | `Sources/Coordinator/ConcertCoordinator.swift` | 삭제 | +| ConcertFeature | `Sources/Coordinator/EnvironmentValues+ConcertCoordinator.swift` | 삭제 | +| ConcertFeature | `Sources/View/ConcertView.swift` | 수정 (coordinator 제거, onTicketSiteReturn 자체 관리) | +| ConcertFeature | `Sources/View/TabContent/SetlistTabView.swift` | 수정 (NavigationLink, .sheet) | +| ConcertFeature | `Sources/View/TabContent/ConcertInfoTabView.swift` | 수정 (NavigationLink, .sheet, onTicketSiteReturn) | +| ConcertFeature | `Sources/View/TabContent/ArtistDetailTabView.swift` | 수정 (.sheet) | +| ConcertFeature | `Sources/View/Subview/ConcertInfoCarousel.swift` | 수정 (.sheet, onTicketSiteReturn) | +| ConcertFeature | `Sources/View/MerchandiseDetailView.swift` | 수정 (.sheet, onTicketSiteReturn) | +| SetlistFeature | `Sources/View/SetlistDetailView.swift` | 수정 (onReportTapped 자체 처리, reportURL 파라미터) | +| SongFeature | `Sources/View/SongLyricsView.swift` | 수정 (onReportTapped 자체 처리, reportURL 파라미터) | +| HomeFeature | `Sources/Coordinator/HomeCoordinator.swift` | 임시 수정 (ConcertCoordinator → ConcertCoordinatorView) | +| SearchFeature | `Sources/Coordinator/SearchCoordinator.swift` | 임시 수정 (ConcertCoordinator → ConcertCoordinatorView) | + +**영향 없는 모듈:** LoginFeature, UserFeature, App (Plan 2/3에서 업데이트), Domain, Data + +## 기술 결정 + +| 결정 사항 | 선택지 | 결정 | 근거 | +|-----------|--------|------|------| +| ConcertRouter 유무 | 생성 / 미생성 | 미생성 | ADR-001, 단일 NavigationStack 정책, view-only CoordinatorView | +| modal 처리 | Router.present / view .sheet | view .sheet | Router 없음, view-only 정책. 일관성 | +| cross-feature 자식 navigation | NavigationLink 선언 / imperative push | NavigationLink 선언 | Router 없으므로 선언형 강제 | +| Setlist/Song 콜백 유지 | callback / @EnvironmentObject router | callback 유지 | 자체 router 없음, wrapper로 bridge. 모듈 분리 | +| SetlistDetailContainer 위치 | SetlistFeature / ConcertFeature | ConcertFeature | navigation 연결이 ConcertFeature의 책임 | +| ConcertConstant 공유 | public 노출 / Core 이동 / 파라미터 전달 | 파라미터 전달 (reportURL) | 모듈 의존 최소화 | +| HomeRoute/SearchRoute에 .concertDetail 추가 | Plan 1에서 / Plan 2/3에서 | Plan 1에서 임시 처리 | 빌드 통과를 위해 임시 어댑터. Plan 2/3에서 정식 처리 | +| ticket return banner 트리거 | coordinator callback / @State 자체 관리 | @State 자체 관리 + 자식에 클로저 전달 | Router 없음. ConcertView가 banner 상태 관리, 자식이 알림 | +| onTicketSiteReturn 클로저 위치 | Environment / init 파라미터 | init 파라미터 | 모듈 간 결합 최소화 | + +## 주의 사항 +- `ConcertRoute`의 모든 associated value는 이미 `Hashable` (확인 완료: `Int`, `SegmentedTabBarType.DetailTab`, `ConcertInfoSection?`, `[ConcertMerchandise]`, `URL?`). +- `SetlistSong`이 `Hashable`인지 확인 필요 (`.navigationDestination(item:)` 사용 조건). 미충족 시 래퍼 struct 도입. +- `LivithNavigationView`의 `.back(title:, onBack:)` 타입이 `onBack` 클로저를 받음. `ConcertView`/`MerchandiseDetailView`에서 system back이 아닌 custom back을 원하면 클로저를 제공해야 함. 현재 custom back 로직이 없는지 확인. +- `SectionHeaderView`의 `onReport` 액션 시그니처 확인 필요 (Task-5의 .sheet 처리 패턴 결정에 영향). +- `URL`은 `Identifiable`이 아니므로 `.sheet(item:)` 사용 시 별도 wrapper struct 필요 (`IdentifiableURL` 등) 또는 `.sheet(isPresented:)` + `@State URL?` 사용. +- `@EnvironmentObject`와 달리 `@Environment`는 default value가 가능해서 ConcertFeature view가 `concertCoordinator` 없이도 컴파일 가능했음. Plan 1 이후에는 모든 사용처가 사라지므로 문제 없음. +- Steps 4-12는 ConcertFeature view들의 `concertCoordinator` 사용을 모두 제거. Step 14에서 Home/Search의 `ConcertCoordinator` 사용도 임시로 변경. Step 15에서 일괄 빌드 검증. +- Plan 1 종료 시점에서 Home/Search의 임시 어댑터로 빌드는 통과하지만, 코드 품질은 Plan 2/3에서 정식 처리. +- Plan 1 종료 후 다음 Plan 2를 시작하기 전까지, 코드 변경이 반영된 상태로 작업이 일시 정지될 수 있음 (사용자 승인 대기). + +## 검증 방법 + +### 빌드 환경 +- 빌드 검증 도구: **xcodebuildmcp** (MCP 서버) +- 시뮬레이터 실행은 사용하지 않음 (빌드만 수행, 시뮬레이터 부팅/실행 안 함) +- 사용 도구: + - `XcodeBuildMCP_discover_projs` — workspace 인식 + - `XcodeBuildMCP_session_set_defaults` — scheme/simulator 지정 + - `XcodeBuildMCP_build_sim` — 빌드 (시뮬레이터 target으로 컴파일만, 실행 안 함) +- 사용하지 않는 도구: `XcodeBuildMCP_build_run_sim` (시뮬레이터 실행) + +### 빌드 검증 절차 +1. `tuist generate --no-open` 정상 완료 +2. `XcodeBuildMCP_discover_projs` workspace 인식 확인 +3. `XcodeBuildMCP_session_set_defaults`로 scheme 지정 (Plan 1은 LoginFeature, UserFeature, HomeFeature, SearchFeature, ConcertFeature, SetlistFeature, SongFeature, Livith-iOS 모두 빌드) +4. `XcodeBuildMCP_build_sim` 빌드 성공 확인 — 임시 어댑터로 Home/Search가 컴파일 가능해야 함 + +### 런타임 검증 (수동 테스트, 사용자 디바이스/시뮬레이터 환경) +- 빌드 검증은 xcodebuildmcp로만 수행. 시뮬레이터는 사용자 환경에서 별도 실행. +- 검증 시나리오: + 1. Home → Concert 진입 → setlist → song navigation 동작 + 2. Concert에서 safari/report form/ticket safari .sheet 표시 동작 + 3. Ticket safari dismiss 후 banner 표시 동작 + 4. back 동작: Concert → Home 정상 pop + 5. Search → Concert 진입 동일 시나리오 + 6. Login/User 마이그레이션과 무관한지 확인 (Login/User 회귀 테스트) + +## 빌드 상태 +Plan 1 종료 시점에서 빌드는 임시 어댑터로 통과 가능. 단, Home/Search의 navigation은 UIKit Coordinator 패턴이 일부 남아있어 최종 사용자 동작은 Plan 2/3 완료 후 정상. diff --git a/docs/archives/LIVD-425-home-navigationstack-migration.md b/docs/archives/LIVD-425-home-navigationstack-migration.md new file mode 100644 index 00000000..0f5e9e4e --- /dev/null +++ b/docs/archives/LIVD-425-home-navigationstack-migration.md @@ -0,0 +1,174 @@ +# LIVD-425 HomeFeature NavigationStack 마이그레이션 + +## 배경 +- LIVD-425 Concert/Setlist/Song 마이그레이션(Plan 1)을 완료했다. `ConcertCoordinatorView`(view-only)가 신설되어 Home/Search가 `ConcertRoute`로 진입할 수 있는 기반이 마련됐다. +- HomeFeature는 여전히 UIKit 기반 Coordinator 패턴(`HomeCoordinator: Coordinator` + `UIViewControllerRepresentable`로 `UINavigationController` 임베드)을 사용 중이다. +- LoginFeature/UserFeature와 동일한 Router + NavigationStack 패턴으로 통일하여 일관성을 확보한다. +- HomeRoute에 `.concertDetail` case를 추가하여 Plan 1에서 신설한 `ConcertCoordinatorView`를 navigation destination으로 연결한다. + +## 목표 +- `HomeRoute`에 `.concertDetail(concertID:initialTab:initialSection:)` case 추가, `Route` → `Hashable` 직접 채택 +- `HomeRouter`는 typealias로 표현 (별도 클래스 신설 없음, `Router` 직접 사용) +- `HomeCoordinatorView` 신설 — `NavigationStack(path: $router.path)` + `navigationDestination(for: HomeRoute.self)` +- HomeFeature의 view들이 `@EnvironmentObject var homeRouter: HomeRouter`로 router에 접근 (Login/User 패턴) +- 예외: `NoticeSettingView`는 `UserFeature` 모듈에 있고 `UserRoute.noticeSetting`에서도 재사용되므로 closure 인터페이스 유지 +- Tab bar 숨김: destination view에 `.toolbar(.hidden, for: .tabBar)` 적용, `isTabBarHidden` binding 제거 +- Deep link: `LivithMainTabView`의 binding을 `HomeCoordinatorView`가 받아서 `.onChange`에서 `homeRouter.popToRoot()` + `homeRouter.push(.concertDetail(...))` 처리 +- `HomeCoordinator`, `HomeContentView`, `EnvironmentValues+HomeCoordinator` 삭제 +- `LivithMainTabView`에서 `HomeContentView`를 `HomeCoordinatorView`로 교체 + +## 작업 항목 + +### 1. `HomeRoute` 확장 +- [x] `Projects/HomeFeature/Sources/Coordinator/HomeRoute.swift` 수정 + - `.concertDetail(concertID: Int, initialTab: SegmentedTabBarType.DetailTab, initialSection: ConcertInfoSection?)` case 추가 + - `Route` 프로토콜 채택 제거, `Hashable` 직접 채택 (LoginRoute/UserRoute/ConcertRoute와 동일) + - `import Coordinator` 제거 + - `import LivithDesignSystem` 추가 (SegmentedTabBarType 사용) + - **모듈 의존성:** `ConcertFeature`는 이미 HomeFeature/Project.swift에 dependencies로 포함되어 있어 별도 추가 불필요 + +### 2. `HomeRouter` typealias 선언 +- [x] `Projects/HomeFeature/Sources/Coordinator/HomeCoordinatorView.swift` 상단에 typealias 선언 + - `typealias HomeRouter = Router` (별도 파일 미생성, HomeCoordinatorView 파일 내 top-level) + - **결정 변경:** 사용자 피드백으로 nested `HomeCoordinatorView.HomeRouter` → top-level `HomeRouter`로 변경. views에서 `HomeRouter` 직접 사용 가능 + +### 3. `HomeCoordinatorView` 신설 +- [x] `Projects/HomeFeature/Sources/Coordinator/HomeCoordinatorView.swift` 신규 생성 + - `public struct HomeCoordinatorView: View` + - `@StateObject private var router: HomeRouter` + - body: `NavigationStack(path: $router.path) { HomeView().navigationDestination(for: HomeRoute.self) { route in destinationView(for: route).toolbar(.hidden, for: .tabBar, .navigationBar) } }` + - 9개 case 매핑 완료 + - `.environmentObject(router)` 주입 + - `.ignoresSafeArea()` + - **Tab bar + navigation bar 숨김:** `.toolbar(.hidden, for: .tabBar, .navigationBar)` — 시스템 NavigationStack의 nav bar는 숨기고 `LivithNavigationView`만 표시 + +### 4. Deep link 처리 +- [x] `HomeCoordinatorView.init`에 deep link binding 파라미터 추가 + - `@Binding deepLinkConcertID: Int?` + - `@Binding deepLinkInitialTab: SegmentedTabBarType.DetailTab` + - `@Binding deepLinkInitialSection: ConcertInfoSection?` + - `@Binding deepLinkShowInterest: Bool` + - `.onChange(of: deepLinkConcertID)` + `.onChange(of: deepLinkShowInterest)`로 router 호출 + - **구현 참고:** iOS 17 `onChange(of:)`는 1-arg (deprecated) 또는 0/2-arg closure 지원. 빌드 호환을 위해 1-arg deprecated form 사용 (warning만 발생, 동작 동일). 향후 2-arg form으로 전환 가능 + +### 5. 하위 View에서 Router로 전환 +- [x] `Projects/HomeFeature/Sources/Home/View/HomeView.swift` 수정 + - `@EnvironmentObject private var homeRouter: HomeRouter` + - 모든 `coordinator?.push(to:)` → `homeRouter.push()`로 변경 + - `coordinator?.showConcertDetail(concertID:)` → `homeRouter.push(.concertDetail(...))` (initialTab: .artistDetail, initialSection: nil) + +- [x] `Projects/HomeFeature/Sources/Notice/View/NoticeView.swift` 변경 없음 + - closure 인터페이스 유지, HomeCoordinatorView에서 router 메서드로 wire + +- [x] `Projects/HomeFeature/Sources/Interest/View/InterestConcertListView.swift` 수정 +- [x] `Projects/HomeFeature/Sources/Interest/View/InterestConcertSettingView.swift` 수정 +- [x] `Projects/HomeFeature/Sources/PreferenceUpdate/View/GenreUpdateView.swift` 수정 +- [x] `Projects/HomeFeature/Sources/PreferenceUpdate/View/ArtistUpdateView.swift` 수정 +- [x] `Projects/HomeFeature/Sources/Home/View/Subview/ConcertContentSection/RecommendedConcertGridView.swift` 수정 + +### 6. Tab bar + navigation bar 숨김 처리 +- [x] `HomeCoordinatorView`의 `navigationDestination(for: HomeRoute.self)` closure에서 모든 destination view에 `.toolbar(.hidden, for: .tabBar, .navigationBar)` modifier 적용 + +### 7. `LivithMainTabView` 업데이트 +- [x] `Projects/App/Sources/View/LivithMainTabView.swift` 수정 + - `isTabBarHidden` state는 Search tab이 여전히 UIKit 기반이므로 **유지** (Search는 Plan 3에서 제거 예정) + - Home tab: `HomeContentView(isTabBarHidden: ...)` → `HomeCoordinatorView(deepLinkConcertID: ...)`로 교체 + - Home tab의 `.toolbar(isTabBarHidden ? .hidden : .visible, for: .tabBar)` 제거 (HomeCoordinatorView가 자체 관리) + - Search tab은 UIKit Coordinator가 아직 사용 중이므로 `isTabBarHidden` binding 유지 + +### 8. 불필요 파일 삭제 +- [x] `Projects/HomeFeature/Sources/Coordinator/HomeCoordinator.swift` 삭제 +- [x] `Projects/HomeFeature/Sources/Coordinator/HomeContentView.swift` 삭제 +- [x] `Projects/HomeFeature/Sources/Coordinator/EnvironmentValues+HomeCoordinator.swift` 삭제 + +### 9. 모듈 의존성 +- [x] `Projects/HomeFeature/Project.swift` — ConcertFeature dependencies 이미 포함 (변경 불필요) + +### 10. 빌드 검증 +- [x] `tuist generate --no-open` 정상 완료 +- [x] HomeFeature, ConcertFeature, UserFeature, Livith-iOS 스킴 빌드 성공 + - **빌드 환경:** xcodebuildmcp `XcodeBuildMCP_build_sim` 사용 + - **시뮬레이터 실행은 사용하지 않음** (빌드만 수행) + - HomeFeature: 빌드 성공 (deprecation warning 2건 — onChange(of:perform:) deprecated in iOS 17.0, 1-arg form 사용 중) + - ConcertFeature: 빌드 성공 + - UserFeature: 빌드 성공 + - Livith-iOS: 빌드 성공 + - Search는 Plan 3 전이므로 UIKit Coordinator 상태로 빌드 통과 (임시 어댑터) + +## 영향 범위 + +| 모듈 | 파일 | 변경 유형 | +|------|------|-----------| +| HomeFeature | `Sources/Coordinator/HomeRoute.swift` | 수정 (case 추가, Hashable) | +| HomeFeature | `Sources/Coordinator/HomeCoordinatorView.swift` | 신규 (typealias HomeRouter 포함) | +| HomeFeature | `Sources/Coordinator/HomeCoordinator.swift` | 삭제 | +| HomeFeature | `Sources/Coordinator/HomeContentView.swift` | 삭제 | +| HomeFeature | `Sources/Coordinator/EnvironmentValues+HomeCoordinator.swift` | 삭제 | +| HomeFeature | `Sources/Home/View/HomeView.swift` | 수정 (coordinator → router) | +| HomeFeature | `Sources/Interest/View/InterestConcertListView.swift` | 수정 (coordinator → router) | +| HomeFeature | `Sources/Interest/View/InterestConcertSettingView.swift` | 수정 (coordinator → router) | +| HomeFeature | `Sources/PreferenceUpdate/View/GenreUpdateView.swift` | 수정 (coordinator → router) | +| HomeFeature | `Sources/PreferenceUpdate/View/ArtistUpdateView.swift` | 수정 (coordinator → router) | +| HomeFeature | `Sources/Home/View/Subview/ConcertContentSection/RecommendedConcertGridView.swift` | 수정 (coordinator → router) | +| HomeFeature | `Sources/Notice/View/NoticeView.swift` | 변경 없음 (closure 인터페이스 유지, HomeCoordinatorView에서 wire) | +| HomeFeature | `Project.swift` | 수정 (dependencies에 ConcertFeature 추가) | +| App | `Sources/View/LivithMainTabView.swift` | 수정 (HomeContentView → HomeCoordinatorView, isTabBarHidden 제거) | + +**영향 없는 모듈:** LoginFeature, UserFeature, ConcertFeature, SetlistFeature, SongFeature, SearchFeature (Plan 3 대상), Domain, Data + +## 기술 결정 + +| 결정 사항 | 선택지 | 결정 | 근거 | +|-----------|--------|------|------| +| HomeView Router 인터페이스 | @EnvironmentObject / closure | @EnvironmentObject | Login/User 패턴과 일관. closure는 view를 router 타입에 결합 | +| NoticeSettingView 인터페이스 | closure / @EnvironmentObject | closure 유지 | UserFeature와 공유. HomeRoute/ UserRoute 두 곳에서 사용. router 타입 결합 회피 | +| Tab bar 숨김 | isTabBarHidden binding / .toolbar modifier | .toolbar modifier | UserCoordinatorView 패턴과 일관. SwiftUI preference가 pop 시 자동 복원 | +| Deep link 처리 위치 | LivithMainTabView / HomeCoordinatorView | HomeCoordinatorView | HomeFeature 내부 관심사. binding만 받아서 router로 위임 | +| HomeRoute.concertDetail case | 새로 추가 / 기존 route 활용 | 새로 추가 (concertID, initialTab, initialSection) | Plan 1의 ConcertCoordinatorView init 시그니처와 일치. deep link 시 initialTab/initialSection 필요 | +| HomeCoordinator 임시 어댑터 처리 | 제거 / 유지 | 제거 | Plan 2에서 정식 Router로 전환. Plan 1 임시 어댑터 코드 모두 제거 | +| HomeRouter 표현 방식 | 별도 class / typealias | **typealias `HomeRouter = Router`** | 추가 콜백/메서드 없음. LoginRouter/UserRouter와 달리 subclass 불필요. HomeCoordinatorView 파일 상단에 MARK와 함께 선언. 필요 시 향후 확장 가능 (별도 파일로 분리) | + +## 주의 사항 +- `HomeRoute`의 모든 associated value가 `Hashable`인지 확인 필요: `InterestConcertSettingMode`, `[Concert]`, `[PreferredGenre]` (확인 완료), `Int`, `SegmentedTabBarType.DetailTab`, `ConcertInfoSection?` (모두 Hashable 가정) +- `LivithMainTabView`의 `isTabBarHidden` state와 Home tab의 `.toolbar(.hidden, for: .tabBar)`는 Plan 2에서 제거. Search tab의 동일 코드는 Plan 3까지 유지 (Search의 NavigationStack 마이그레이션이 끝나기 전이므로) +- `HomeRoute`가 `SegmentedTabBarType.DetailTab`, `ConcertInfoSection`을 사용하므로 `import ConcertFeature`가 필요. `HomeFeature/Project.swift`의 dependencies에 `ConcertFeature` 추가 +- `HomeFeature`는 이미 `ConcertFeature`를 import해서 `Coordinator.swift`를 통해 접근 중이지만, 이제 `ConcertRoute`/`ConcertCoordinatorView`도 import하므로 명시적 dependencies 필요 +- `HomeContentView`의 `HomeNavigationHost: UIViewControllerRepresentable` 구조는 `UINavigationControllerDelegate`를 통해 tab bar hidden을 관리했는데, Plan 2에서는 SwiftUI NavigationStack이 자동 처리하므로 불필요 +- Deep link의 `popToRoot()` 후 `push(.concertDetail(...))` 순서는 Plan 1과 동일 (Home의 root → Concert). User의 push가 `popToRoot` 후 즉시 push되므로 stack이 `[home, concertDetail]`이 됨 +- `recommendedConcertList`의 `concertList`는 array of `Concert`. `Concert`가 `Hashable`인지 확인 (Domain 모델에서 Hashable 채택 가정) +- `preferredArtistUpdate`의 `selectedGenreList`는 `[PreferredGenre]`. `PreferredGenre`가 `Hashable`인지 확인 (UserRoute plan에서 이미 확인 완료) +- `NoticeSettingView`의 `onBack` closure는 `HomeCoordinatorView.destinationView(for:)`에서 `{ router.pop() }`로 wire. `UserFeature`의 `UserCoordinatorView`는 이미 `{ router.pop() }`로 wire 중 +- Steps 5는 다수 view 리팩터. Step 7에서 `LivithMainTabView` 업데이트. Step 10에서 일괄 빌드 검증 +- Plan 2 종료 후 다음 Plan 3 시작 전까지 사용자 승인 대기 + +## 검증 방법 + +### 빌드 환경 +- 빌드 검증 도구: **xcodebuildmcp** (MCP 서버) +- 시뮬레이터 실행은 사용하지 않음 (빌드만 수행, 시뮬레이터 부팅/실행 안 함) +- 사용 도구: + - `XcodeBuildMCP_discover_projs` — workspace 인식 + - `XcodeBuildMCP_session_set_defaults` — scheme/simulator 지정 + - `XcodeBuildMCP_build_sim` — 빌드 (시뮬레이터 target으로 컴파일만, 실행 안 함) +- 사용하지 않는 도구: `XcodeBuildMCP_build_run_sim` (시뮬레이터 실행) + +### 빌드 검증 절차 +1. `tuist generate --no-open` 정상 완료 +2. `XcodeBuildMCP_discover_projs` workspace 인식 확인 +3. `XcodeBuildMCP_session_set_defaults`로 scheme 지정 (Plan 2는 HomeFeature, ConcertFeature, UserFeature, Livith-iOS 빌드) +4. `XcodeBuildMCP_build_sim` 빌드 성공 확인 — HomeFeature 스킴과 의존 모듈 컴파일 가능 + +### 런타임 검증 (수동 테스트, 사용자 디바이스/시뮬레이터 환경) +- 빌드 검증은 xcodebuildmcp로만 수행. 시뮬레이터는 사용자 환경에서 별도 실행. +- 검증 시나리오: + 1. Home → 알림 → 알림 설정 → 뒤로 가기 (pop) + 2. Home → 관심 콘서트 설정 → 변경 → 저장 → popToRoot + 3. Home → 콘서트 카드 탭 → Concert 진입 → setlist → song → 뒤로 가기 체인 + 4. Home → 추천 콘서트 → 그리드 → 콘서트 탭 → Concert 진입 + 5. 탭 전환: Home ↔ Search ↔ User 시 navigation history 유지 + 6. Tab bar: Home root에서 보이고, push된 화면에서 숨김, pop 시 다시 보임 + 7. Deep link: 알림 탭 → Concert 진입, 알림 아이콘 → interest concert 설정 진입 + 8. Login/User 마이그레이션 회귀 테스트 + +## 빌드 상태 +Plan 2 종료 시점에서 Home은 정식 Router로 마이그레이션 완료. Search는 Plan 1과 동일한 임시 어댑터 상태 유지. 빌드는 전체 통과. Plan 3에서 Search 마이그레이션 후 일괄 커밋. diff --git a/docs/archives/LIVD-425-login-navigationstack-migration.md b/docs/archives/LIVD-425-login-navigationstack-migration.md new file mode 100644 index 00000000..52e74365 --- /dev/null +++ b/docs/archives/LIVD-425-login-navigationstack-migration.md @@ -0,0 +1,111 @@ +# LIVD-425 LoginFeature NavigationStack 마이그레이션 + +## 배경 +- 현재 프로젝트는 UIKit 기반 Coordinator 패턴(UINavigationController + UIHostingController 래핑)으로 화면 전환을 관리한다. +- SwiftUI 네이티브 NavigationStack/NavigationPath 기반으로 점진적 전환한다. +- 첫 번째 대상은 외부 Feature 의존이 없고 라우트 수가 적은 LoginFeature다. + +## 목표 +- LoginFeature의 화면 전환을 SwiftUI NavigationStack + Router 패턴으로 교체한다. +- Coordinator class, UIViewControllerRepresentable, EnvironmentKey Coordinator 의존성을 제거한다. +- 외부 인터페이스(onLoginCompleted, onSignupCompleted)는 변경하지 않는다. +- 이후 Feature 마이그레이션을 위한 참조 패턴을 확립한다. + +## 작업 항목 + +### 1. Core/Coordinator 모듈에 Router 기반 클래스 추가 +- [x] `Projects/Core/Coordinator/Sources/Router.swift` 신규 생성 + - `@MainActor open class Router: ObservableObject` + - `@Published public private(set) var path = NavigationPath()` + - 기본 메서드: `push(_ route: R)`, `pop()`, `popToRoot()` +- 기존 `Coordinator` 프로토콜과 `Route` 프로토콜은 수정하지 않고 유지 (다른 Feature 마이그레이션까지 공존) + +### 2. LoginFeature에 LoginRouter 구현 +- [x] `Projects/LoginFeature/Sources/Coordinator/LoginRouter.swift` 신규 생성 + - `final class LoginRouter: Router` + - `onLoginCompleted: () -> Void`, `onSignupCompleted: (String) -> Void`를 private 프로퍼티로 보유 + - `completeLogin()`, `completeSignup(with:)` 메서드 노출 + +### 3. LoginContentView를 NavigationStack 기반으로 재작성 +- [x] `Projects/LoginFeature/Sources/Coordinator/LoginContentView.swift` 전체 재작성 + - `@StateObject private var router: LoginRouter` 선언 + - `NavigationStack(path: $router.path)` 내에 `LoginView()` 배치 + - `.navigationDestination(for: LoginRoute.self)`로 5개 Route → View 매핑 + - `.environmentObject(router)` 주입 + - `UIViewControllerRepresentable`(LoginNavigationHost) 제거 + +### 4. 하위 View에서 Coordinator 접근부를 Router로 교체 +- [x] `LoginView.swift`: `@Environment(\.loginCoordinator)` → `@EnvironmentObject var router: LoginRouter` + - `coordinator?.push(to: .terms(user))` → `router.push(.terms(user))` + - `coordinator?.completeLogin()` → `router.completeLogin()` +- [x] `TermsView.swift`: 동일 변환 +- [x] `NicknameSettingView.swift`: 동일 변환 +- [x] `PreferredGenreSettingView.swift`: 동일 변환 +- [x] `PreferredArtistSettingView.swift`: 동일 변환 + - `coordinator?.completeSignup(with: nickname)` → `router.completeSignup(with: nickname)` + +### 5. 불필요 파일 삭제 +- [x] `LoginCoordinator.swift` 삭제 +- [x] `EnvironmentValues+LoginCoordinator.swift` 삭제 + +### 6. LoginFeature 모듈 의존성 정리 +- [x] `Projects/LoginFeature/Project.swift`에서 Coordinator 모듈 의존성 확인 및 유지/제거 판단 + - LoginFeature → Coordinator 의존성은 `Router`를 사용하므로 유지 + +### 7. 프로젝트 빌드 검증 +- [x] `tuist generate --no-open` 정상 완료 +- [x] `XcodeBuildMCP_discover_projs` workspace 인식 확인 +- [x] `XcodeBuildMCP_build_sim` 빌드 성공 확인 (LoginFeature 스킴 + Livith-iOS 스킴) + +### 8. Login 화면 전환 수동 테스트 +- [x] 회원가입 flow 전체: Login → Terms → Nickname → PreferredGenre → PreferredArtist → 회원가입 완료 +- [x] pop 동작: Terms에서 뒤로 가기, 중간 단계에서 pop +- [x] popToRoot: 콘텐츠 내에서 한 번에 루트로 복귀 (필요 시) +- [x] 로그인 성공: 로그인 버튼 → 메인 화면으로 전환 + +## 영향 범위 + +| 모듈 | 파일 | 변경 유형 | +|------|------|-----------| +| Core/Coordinator | `Sources/Router.swift` | 신규 | +| LoginFeature | `Sources/Coordinator/LoginRouter.swift` | 신규 | +| LoginFeature | `Sources/Coordinator/LoginCoordinatorView.swift` | 재작성 | +| LoginFeature | `Sources/Coordinator/LoginCoordinator.swift` | 삭제 | +| LoginFeature | `Sources/Coordinator/EnvironmentValues+LoginCoordinator.swift` | 삭제 | +| LoginFeature | `Sources/Login/View/LoginView.swift` | 수정 (coordinator 참조부) | +| LoginFeature | `Sources/Onboarding/View/TermsView.swift` | 수정 (coordinator 참조부) | +| LoginFeature | `Sources/Onboarding/View/NicknameSettingView.swift` | 수정 (coordinator 참조부) | +| LoginFeature | `Sources/Onboarding/View/PreferredGenreSettingView.swift` | 수정 (coordinator 참조부) | +| LoginFeature | `Sources/Onboarding/View/PreferredArtistSettingView.swift` | 수정 (coordinator 참조부) | +| App | `Sources/View/AppRootView.swift` | 변경 없음 (인터페이스 유지) | + +**영향 없는 모듈:** HomeFeature, SearchFeature, ConcertFeature, UserFeature, Domain, Data + +## 기술 결정 + +| 결정 사항 | 선택지 | 결정 | 근거 | +|-----------|--------|------|------| +| Core Router 타입 | Protocol + associatedtype / Generic class | Generic class | `private(set) path` 요구사항 충족, View에서 구체 타입 사용 가능 | +| 외부 콜백 위치 | Router / EnvironmentKey 분리 | Router | LoginFeature 5 depth라 FlowAction 분리는 과추상화. 간단히 Router에 보유 | +| Route 소유 | Core / 각 Feature | 각 Feature | Core가 모든 Feature 타입을 알게 됨. Feature 응집도 우선 | +| LoginRoute 변경 | 유지 / 단순화 | 유지 | 이미 Hashable, associated value도 모두 Hashable | +| sub-feature (Concert) | ViewFactory / Protocol / 별도 Router | ViewFactory (이후 HomeFeature 마이그레이션 시 적용) | 의존성 최소, 패턴 일관성 | + +## 주의 사항 +- `LoginRoute`의 모든 associated value(`TempUser`, `SignupBuilder`, `PreferredGenre` 등)가 `Hashable`인지 사전 확인 — 확인 완료 +- `AppRootView`에서 `LoginContentView(onLoginCompleted:onSignupCompleted:)` 시그니처 불변 — 변경 없음 +- `NavigationStack`은 iOS 16+ 요구사항 — 프로젝트 배포 타겟 iOS 17이므로 충족 +- Coordinator 모듈 의존성은 `Router` 참조를 위해 유지 +- Store/ViewModel(`LoginStore`, `TermsStore`, `SignupStore`)은 변경 없음 — MVI 패턴 유지 +- Steps 3-4는 중간 컴파일 실패가 예상됨 (하위 View가 아직 이전 `@Environment` 참조). Step 7에서 일괄 빌드 검증 +- `@EnvironmentObject`는 `.environmentObject()` 미주입 시 runtime fatalError 발생. LoginContentView에서 주입하므로 정상 경로에서는 문제 없으나, 추후 해당 View를 다른 컨텍스트에서 재사용 시 주의 + +## 검증 방법 +1. `tuist generate --no-open` 정상 완료 +2. `XcodeBuildMCP_build_sim` 빌드 성공 확인 (LoginFeature 스킴) +3. `XcodeBuildMCP_build_run_sim` 시뮬레이터에서 앱 실행 +4. 수동 검증 + - 회원가입 flow: Login → Terms → Nickname → PreferredGenre → PreferredArtist → 완료까지 정상 이동 + - 뒤로 가기: 각 단계에서 pop 정상 동작 + - 로그인: Apple/Kakao 로그인 후 메인 진입 정상 +5. 기존 Store/ViewModel 동작 이상 없음 확인 diff --git a/docs/archives/LIVD-425-search-navigationstack-migration.md b/docs/archives/LIVD-425-search-navigationstack-migration.md new file mode 100644 index 00000000..6f55f983 --- /dev/null +++ b/docs/archives/LIVD-425-search-navigationstack-migration.md @@ -0,0 +1,137 @@ +# LIVD-425 SearchFeature NavigationStack 마이그레이션 + +## 배경 +- LIVD-425 Plan 1(Concert/Setlist/Song), Plan 2(Home) 마이그레이션을 완료했다. Login/User + Home + Concert/Setlist/Song은 모두 SwiftUI Router + NavigationStack 패턴을 사용한다. +- SearchFeature는 UIKit 기반 Coordinator 패턴(`SearchCoordinator: Coordinator` + `UIViewControllerRepresentable`로 `UINavigationController` 임베드)을 사용 중인 마지막 Feature다. +- `SearchRoute`에 `.concertDetail` case를 추가하여 Plan 1의 `ConcertCoordinatorView`로 진입할 수 있게 한다. +- Plan 2에서 도입한 `HomeCoordinatorView` 패턴과 동일하게 적용하여 일관성을 완성한다. + +## 목표 +- `SearchRoute`에 `.concertDetail(concertID:)` case 추가, `Hashable` 직접 채택 +- `SearchRouter`는 typealias로 표현 (`Router`) +- `SearchCoordinatorView` 신설 — `NavigationStack(path: $router.path)` + `navigationDestination(for: SearchRoute.self)` +- SearchFeature의 view들이 `@EnvironmentObject var searchRouter: SearchRouter`로 router에 접근 +- Tab bar + navigation bar 숨김: destination view에 `.toolbar(.hidden, for: .tabBar, .navigationBar)` 적용 +- `SearchCoordinator`, `SearchContentView`, `EnvironmentValues+SearchCoordinator` 삭제 +- `LivithMainTabView` 최종 업데이트 — `SearchContentView`를 `SearchCoordinatorView`로 교체, `isTabBarHidden` state 완전 제거 + +## 작업 항목 + +### 1. `SearchRoute` 확장 +- [x] `Projects/SearchFeature/Sources/Coordinator/SearchRoute.swift` 수정 + - `.concertDetail(concertID: Int)` case 추가 + - `Route` 프로토콜 채택 제거, `Hashable` 직접 채택 + - `import Coordinator` 제거 + - `import ConcertFeature` 불필요 (Int만 사용) + +### 2. `SearchRouter` typealias 선언 +- [x] `Projects/SearchFeature/Sources/Coordinator/SearchCoordinatorView.swift` 상단에 typealias 선언 + - `typealias SearchRouter = Router` + - Home과 동일 패턴, top-level 선언 + +### 3. `SearchCoordinatorView` 신설 +- [x] `Projects/SearchFeature/Sources/Coordinator/SearchCoordinatorView.swift` 신규 생성 + - `public struct SearchCoordinatorView: View` + - `@StateObject private var router: SearchRouter` + - body: `NavigationStack(path: $router.path) { ExploreView().navigationDestination(for: SearchRoute.self) { route in destinationView(for: route).toolbar(.hidden, for: .tabBar, .navigationBar) } }` + - 3개 case 매핑 완료 + - `.environmentObject(router)` 주입 + - `.ignoresSafeArea()` + +### 4. 하위 View에서 Router로 전환 +- [x] `Projects/SearchFeature/Sources/Explore/View/ExploreView.swift` 수정 + - `@EnvironmentObject private var searchRouter: SearchRouter` + - `coordinator?.push(to: .search)` → `searchRouter.push(.search)` + - `coordinator?.showConcertDetail(concertID:)` → `searchRouter.push(.concertDetail(concertID:))` + +- [x] `Projects/SearchFeature/Sources/Search/View/SearchView.swift` 수정 + - `@EnvironmentObject private var searchRouter: SearchRouter` + - `coordinator?.pop()` → `searchRouter.pop()` (back 액션) + - `coordinator?.showConcertDetail(concertID:)` → `searchRouter.push(.concertDetail(concertID:))` + +### 5. `LivithMainTabView` 최종 업데이트 +- [x] `Projects/App/Sources/View/LivithMainTabView.swift` 수정 + - `isTabBarHidden` state **완전 제거** (Home + Search 모두 자체 관리) + - Search tab: `SearchContentView(isTabBarHidden: ...)` → `SearchCoordinatorView()`로 교체 + - Search tab의 `.toolbar(isTabBarHidden ? .hidden : .visible, for: .tabBar)` 제거 + - deep link notification 처리는 Home/탭 전환 로직만 유지 + +### 6. 불필요 파일 삭제 +- [x] `Projects/SearchFeature/Sources/Coordinator/SearchCoordinator.swift` 삭제 +- [x] `Projects/SearchFeature/Sources/Coordinator/SearchContentView.swift` 삭제 +- [x] `Projects/SearchFeature/Sources/Coordinator/EnvironmentValues+SearchCoordinator.swift` 삭제 + +### 7. 빌드 검증 +- [x] `tuist generate --no-open` 정상 완료 +- [x] SearchFeature, HomeFeature, ConcertFeature, UserFeature, Livith-iOS 스킴 빌드 성공 + - **빌드 환경:** xcodebuildmcp `XcodeBuildMCP_build_sim` 사용 + - **시뮬레이터 실행은 사용하지 않음** (빌드만 수행) + - SearchFeature: 빌드 성공 (경고 0, 에러 0) + - HomeFeature: 빌드 성공 + - ConcertFeature: 빌드 성공 + - UserFeature: 빌드 성공 + - Livith-iOS: 빌드 성공 + +## 영향 범위 + +| 모듈 | 파일 | 변경 유형 | +|------|------|-----------| +| SearchFeature | `Sources/Coordinator/SearchRoute.swift` | 수정 (case 추가, Hashable) | +| SearchFeature | `Sources/Coordinator/SearchCoordinatorView.swift` | 신규 (typealias SearchRouter 포함) | +| SearchFeature | `Sources/Coordinator/SearchCoordinator.swift` | 삭제 | +| SearchFeature | `Sources/Coordinator/SearchContentView.swift` | 삭제 | +| SearchFeature | `Sources/Coordinator/EnvironmentValues+SearchCoordinator.swift` | 삭제 | +| SearchFeature | `Sources/Explore/View/ExploreView.swift` | 수정 (coordinator → router) | +| SearchFeature | `Sources/Search/View/SearchView.swift` | 수정 (coordinator → router) | +| App | `Sources/View/LivithMainTabView.swift` | 수정 (SearchContentView → SearchCoordinatorView, isTabBarHidden 완전 제거) | + +**영향 없는 모듈:** LoginFeature, UserFeature, ConcertFeature, SetlistFeature, SongFeature, HomeFeature, Domain, Data + +## 기술 결정 + +| 결정 사항 | 선택지 | 결정 | 근거 | +|-----------|--------|------|------| +| SearchView Router 인터페이스 | @EnvironmentObject / closure | @EnvironmentObject | Login/User/Home 패턴과 일관. closure는 view를 router 타입에 결합 | +| SearchCoordinator 임시 어댑터 처리 | 제거 / 유지 | 제거 | Plan 3에서 정식 Router로 전환. Plan 1 임시 어댑터 코드 모두 제거 | +| SearchRouter 표현 방식 | 별도 class / typealias | **typealias `SearchRouter = Router`** | 추가 콜백/메서드 없음. Home과 동일 패턴. top-level typealias로 views에서 직접 사용 | +| Tab bar + navigation bar 숨김 | isTabBarHidden binding / .toolbar modifier | .toolbar(.hidden, for: .tabBar, .navigationBar) | HomeCoordinatorView 패턴과 일관. SearchContentView의 UIViewControllerRepresentable wrapper 제거 | +| LivithMainTabView의 isTabBarHidden | 유지 / 제거 | **완전 제거** | Plan 3에서 Home + Search 모두 자체 관리하므로 binding 불필요. App에서 tab bar hidden 관리 책임 제거 | + +## 주의 사항 +- `SearchRoute`의 모든 associated value가 `Hashable`인지 확인 (`Int` — Hashable, 확인 완료) +- `SearchContentView`의 `SearchNavigationHost: UIViewControllerRepresentable` 구조와 `UINavigationControllerDelegate` 기반 tab bar hidden 관리가 SwiftUI NavigationStack으로 전환되며 자동 처리됨 +- `SearchView`는 `init(store: SearchStore)`로 `SearchStore`를 외부에서 받음. `SearchCoordinatorView.destinationView(for: .search)`에서 `SearchView(store: .init())`로 새 store 생성 +- `ExploreView`의 `@Environment(\.openURL) private var openURL`은 Coordinator와 무관하므로 유지 +- `SearchCoordinator`의 미사용 메서드 (`showConcertDetail`)는 Plan 1에서 이미 사용되지 않음. Plan 3에서 파일 자체 삭제 +- Steps 4는 2개 view 리팩터 (ExploreView, SearchView). Step 5에서 `LivithMainTabView` 최종 업데이트. Step 7에서 일괄 빌드 검증 +- Plan 3 종료 후 모든 plan 완료 → 단일 commit/PR로 최종 정리 (또는 plan별 commit 분리) + +## 검증 방법 + +### 빌드 환경 +- 빌드 검증 도구: **xcodebuildmcp** (MCP 서버) +- 시뮬레이터 실행은 사용하지 않음 (빌드만 수행, 시뮬레이터 부팅/실행 안 함) +- 사용 도구: + - `XcodeBuildMCP_discover_projs` — workspace 인식 + - `XcodeBuildMCP_session_set_defaults` — scheme/simulator 지정 + - `XcodeBuildMCP_build_sim` — 빌드 (시뮬레이터 target으로 컴파일만, 실행 안 함) +- 사용하지 않는 도구: `XcodeBuildMCP_build_run_sim` (시뮬레이터 실행) + +### 빌드 검증 절차 +1. `tuist generate --no-open` 정상 완료 +2. `XcodeBuildMCP_discover_projs` workspace 인식 확인 +3. `XcodeBuildMCP_session_set_defaults`로 scheme 지정 (Plan 3는 SearchFeature, HomeFeature, ConcertFeature, UserFeature, Livith-iOS 빌드) +4. `XcodeBuildMCP_build_sim` 빌드 성공 확인 — 5개 스킴 모두 컴파일 가능 + +### 런타임 검증 (수동 테스트, 사용자 디바이스/시뮬레이터 환경) +- 빌드 검증은 xcodebuildmcp로만 수행. 시뮬레이터는 사용자 환경에서 별도 실행. +- 검증 시나리오: + 1. Search (탐색) → 콘서트 카드 탭 → Concert 진입 → back 시 Search로 복귀 + 2. Search (탐색) → 검색 탭 → 검색 결과 → 콘서트 탭 → Concert 진입 + 3. Search → Concert 진입 시 tab bar 숨김, back 시 복원 + 4. 탭 전환: Home ↔ Search ↔ User 시 navigation history 독립 유지 + 5. Deep link 알림 → Home → Concert 진입, Search history에 영향 없음 + 6. Home/Search/Concert 마이그레이션 회귀 테스트 + +## 빌드 상태 +Plan 3 종료 시점에서 모든 Feature가 SwiftUI Router + NavigationStack 패턴으로 통일됨. 단일 NavigationStack 정책 완성. 빌드는 전체 통과. Plan 1, 2, 3 완료 후 단일 PR로 일괄 커밋 가능. diff --git a/docs/archives/LIVD-425-user-navigationstack-migration.md b/docs/archives/LIVD-425-user-navigationstack-migration.md new file mode 100644 index 00000000..91a22789 --- /dev/null +++ b/docs/archives/LIVD-425-user-navigationstack-migration.md @@ -0,0 +1,131 @@ +# LIVD-425 UserFeature NavigationStack 마이그레이션 + +## 배경 +- LoginFeature(LIVD-425)에서 SwiftUI 네이티브 NavigationStack + Router 패턴으로의 마이그레이션을 완료했다. +- UserFeature는 현재 UIKit 기반 Coordinator 패턴(UINavigationController + UIHostingController)으로 화면 전환을 관리한다. +- LoginFeature에서 확립된 패턴을 UserFeature에 적용하여 일관성을 맞춘다. +- UserFeature는 외부 Feature 의존이 거의 없고(onNavigateToHome 클로저 하나), 라우트 수가 7개로 적절해 두 번째 마이그레이션 대상으로 적합하다. + +## 목표 +- UserFeature의 화면 전환을 SwiftUI NavigationStack + Router 패턴으로 교체한다. +- `UserCoordinator` class, `UIViewControllerRepresentable`, `EnvironmentValues+UserCoordinator`를 제거한다. +- 외부 인터페이스(UserContentView의 `isTabBarHidden`, `onNavigateToHome`)는 변경하지 않는다. +- LoginFeature 패턴과 동일한 구조로 통일한다. + +## 작업 항목 + +### 1. UserRoute를 Hashable로 변경 +- [x] `Projects/UserFeature/Sources/Coordinator/UserRoute.swift` 수정 + - `enum UserRoute: Route` → `enum UserRoute: Hashable` + - `import Coordinator` 제거 (Hashable은 Swift 표준) + - associated value 타입(`PreferredGenre`, `PreferredArtist`)은 이미 Hashable이므로 추가 작업 불필요 + +### 2. UserRouter 신규 생성 +- [x] `Projects/UserFeature/Sources/Coordinator/UserRouter.swift` 신규 생성 + - `final class UserRouter: Router` + - `onNavigateToHome: () -> Void` private 프로퍼티로 보유, `navigateToHome()` 메서드 노출 + - `onGenreUpdateSuccess: () -> Void` private 프로퍼티로 보유, `genreUpdateSuccess()` 메서드 노출 + - `onArtistUpdateSuccess: () -> Void` private 프로퍼티로 보유, `artistUpdateSuccess()` 메서드 노출 + - LoginFeature의 LoginRouter와 동일한 패턴 + - TODO: 스낵바 콜백은 향후 Store 상태 변경으로 이전 예정 + +### 3. UserContentView를 NavigationStack 기반으로 재작성 +- [x] `Projects/UserFeature/Sources/Coordinator/UserContentView.swift` 재작성 + - `@StateObject private var router: UserRouter` 선언 + - `NavigationStack(path: $router.path)` 내에 `UserView()` 배치 + - `.navigationDestination(for: UserRoute.self)`로 7개 Route → View 매핑 + - `.environmentObject(router)` 주입 + - push되는 모든 destination View에 `.toolbar(.hidden, for: .tabBar)` 추가 + - `UIViewControllerRepresentable`(UserNavigationHost) 제거 + - NoticeSettingView는 `NoticeSettingView(onBack: { router.pop() })`로 생성 + - UserContentView에서 `.environmentObject(router)` 주입 + +### 4. 하위 View에서 Coordinator 접근부를 Router로 교체 +- [x] `Projects/UserFeature/Sources/View/UserView.swift` + - `@Environment(\.userCoordinator) private var coordinator` → `@EnvironmentObject private var router: UserRouter` + - `coordinator?.push(to: .xxx)` → `router.push(.xxx)` + - `coordinator?.pop()` → `router.pop()` + - `coordinator?.onGenreUpdateSuccess = { ... }` → `router.onGenreUpdateSuccess = { ... }` (TODO: 향후 Store 상태 변경으로 이전) + +- [x] `Projects/UserFeature/Sources/View/SettingView.swift` + - 동일 변환 + +- [x] `Projects/UserFeature/Sources/View/NicknameUpdateView.swift` + - 동일 변환 + +- [x] `Projects/UserFeature/Sources/View/DeleteUserView.swift` + - 동일 변환 + +- [x] `Projects/UserFeature/Sources/View/UserGenreUpdateView.swift` + - 동일 변환 + - 성공 후 `router.genreUpdateSuccess()` / `router.pop()` (TODO: 향후 Store 상태 변경으로 이전) + +- [x] `Projects/UserFeature/Sources/View/UserArtistUpdateView.swift` + - 동일 변환 + +- [x] `Projects/UserFeature/Sources/View/NoticeSettingView.swift` + - `onBack` 클로저 유지 (HomeFeature와 공유) + - `navigationDestination`에서 `NoticeSettingView(onBack: { router.pop() })` 생성 + +### 5. 불필요 파일 삭제 +- [x] `Projects/UserFeature/Sources/Coordinator/UserCoordinator.swift` 삭제 +- [x] `Projects/UserFeature/Sources/Coordinator/EnvironmentValues+UserCoordinator.swift` 삭제 + +### 6. 프로젝트 빌드 검증 +- [x] `tuist generate --no-open` 정상 완료 +- [x] Xcode 빌드 성공 확인 (UserFeature 스킴 + Livith-iOS 스킴) + +### 7. User 화면 전환 수동 테스트 +- [x] 기존 동작 회귀 테스트: Store/MVI 변경 없으므로 기존 동작 그대로 유지 +- [x] 마이 메인 → 설정 → 각 메뉴 이동/뒤로가기 +- [x] 닉네임 수정 완료 후 pop 및 메인 반영 +- [x] 선호 장르 변경 완료 후 pop +- [x] 선호 아티스트 변경 완료 후 pop +- [x] 변경사항 있는 상태에서 뒤로가기 → DangerModal → pop +- [x] 회원탈퇴 플로우 +- [x] 로그아웃 → 재로그인 +- [x] 탭바 전환 시 탭바 상태 정상 복원 확인 + +## 영향 범위 + +| 모듈 | 파일 | 변경 유형 | +|------|------|-----------| +| UserFeature | `Sources/Coordinator/UserRouter.swift` | 신규 | +| UserFeature | `Sources/Coordinator/UserRoute.swift` | 수정 (Route → Hashable) | +| UserFeature | `Sources/Coordinator/UserContentView.swift` | 재작성 | +| UserFeature | `Sources/Coordinator/UserCoordinator.swift` | 삭제 | +| UserFeature | `Sources/Coordinator/EnvironmentValues+UserCoordinator.swift` | 삭제 | +| UserFeature | `Sources/View/UserView.swift` | 수정 (coordinator 참조부) | +| UserFeature | `Sources/View/SettingView.swift` | 수정 (coordinator 참조부) | +| UserFeature | `Sources/View/NicknameUpdateView.swift` | 수정 (coordinator 참조부) | +| UserFeature | `Sources/View/DeleteUserView.swift` | 수정 (coordinator 참조부) | +| UserFeature | `Sources/View/UserGenreUpdateView.swift` | 수정 (coordinator 참조부) | +| UserFeature | `Sources/View/UserArtistUpdateView.swift` | 수정 (coordinator 참조부) | +| UserFeature | `Sources/View/NoticeSettingView.swift` | 수정 (coordinator 참조부) | +| App | `Sources/View/LivithMainTabView.swift` | 변경 없음 (인터페이스 유지) | + +**영향 없는 모듈:** HomeFeature, SearchFeature, ConcertFeature, LoginFeature, Domain, Data + +## 기술 결정 + +| 결정 사항 | 선택지 | 결정 | 근거 | +|-----------|--------|------|------| +| 탭바 숨김 방식 | path.count 관찰 / 각 destination View에 .toolbar | 각 destination View에 .toolbar(.hidden, for: .tabBar) | LoginFeature가 navigationBar를 destination에서 가린 것과 동일한 방식. SwiftUI preference 시스템이 pop 시 자동 복원 | +| 동적 콜백 처리 | Router에 콜백 유지 / Store 상태 변경으로 전환 | Router에 콜백 유지 (TODO: 향후 Store로 이전) | 네비게이션 마이그레이션이 우선. 스낵바 리팩토링은 별도 작업으로 분리 | +| NoticeSettingView | onBack 클로저 유지 / Router로 교체 | onBack 클로저 유지 (HomeFeature에서 공유 사용) | `public` 인터페이스. HomeFeature도 동일하게 사용 중. Router 주입 불필요 | +| Route 소유 | Core / 각 Feature | 각 Feature | LoginFeature와 동일한 결정. Feature 응집도 우선 | +| Router 외부 콜백 | Router에 클로저 / EnvironmentKey 분리 | Router에 클로저 | LoginFeature와 동일. 필요 이상의 추상화 방지 | + +## 주의 사항 +- `UserRoute`의 모든 associated value(`PreferredGenre`, `PreferredArtist`)가 `Hashable`인지 확인 — Domain 모델에서 Hashable 준수 확인 완료 (Conformances: Hashable) +- `AppRootView`에서 `UserContentView(isTabBarHidden:onNavigateToHome:)` 시그니처 불변 — 변경 없음 +- `NavigationStack`은 iOS 16+ 요구사항 — 프로젝트 배포 타겟 iOS 17이므로 충족 +- `@EnvironmentObject`는 `.environmentObject()` 미주입 시 runtime fatalError 발생. UserContentView에서 주입하므로 정상 경로에서는 문제 없으나, 추후 해당 View를 다른 컨텍스트에서 재사용 시 주의 +- Steps 3-4는 중간 컴파일 실패가 예상됨 (하위 View가 아직 이전 `@Environment` 참조). Step 7에서 일괄 빌드 검증 +- `SettingView`의 로그아웃과 `DeleteUserView`의 회원탈퇴는 `NotificationCenter.post(name: "reloginRequired")`를 사용 — Router와 무관하므로 변경 없음 +- NoticeSettingView는 `onBack` 클로저를 유지 (HomeFeature와 공유). `navigationDestination`에서 `NoticeSettingView(onBack: { router.pop() })`로 주입 +- UserRoute의 모든 associated value(`PreferredGenre`, `PreferredArtist`)는 Domain 모델에서 이미 `Hashable` 채택 확인됨 + +## 검증 방법 +1. `tuist generate --no-open` 정상 완료 +2. Xcode 빌드 성공 확인 (UserFeature 스킴) diff --git a/docs/archives/adr-001-navigationstack-migration.md b/docs/archives/adr-001-navigationstack-migration.md new file mode 100644 index 00000000..d93fbad9 --- /dev/null +++ b/docs/archives/adr-001-navigationstack-migration.md @@ -0,0 +1,79 @@ +# ADR-001: SwiftUI NavigationStack 기반 네비게이션 마이그레이션 + +## 상태 +Accepted (2026-06-16) + +## 컨텍스트 +- 프로젝트는 UIKit 기반 Coordinator 패턴(`UINavigationController` + `UIHostingController` 래핑)으로 화면 전환을 관리해 왔다. +- LIVD-425에서 LoginFeature와 UserFeature를 SwiftUI `Router` + `NavigationStack` + `NavigationPath` 패턴으로 마이그레이션 완료. +- HomeFeature, SearchFeature, ConcertFeature는 아직 UIKit Coordinator 패턴을 사용 중이며, 다음 두 가지 한계가 있다. + 1. `UIViewControllerRepresentable`로 SwiftUI 안에 `UINavigationController`를 임베드하므로 SwiftUI 네이티브 navigation API(`NavigationLink`, `.toolbar`, `.sheet` 등)와 자연스럽게 연동되지 않는다. + 2. Login/User와 패턴이 달라 코드 가독성과 신규 개발자 온보딩 비용이 분기되어 발생한다. +- `ConcertFeature`는 Home/Search의 자식 Coordinator로 동작해 `UINavigationController`를 공유하는데, SwiftUI `NavigationStack`은 중첩이 시각적으로 부자연스럽다 (네비게이션 바 중복). + +## 결정 +LoginFeature/UserFeature와 동일한 SwiftUI 네이티브 패턴으로 나머지 Feature를 마이그레이션한다. 단, `NavigationStack` 중첩을 회피하기 위해 cross-feature 진입은 부모 `NavigationStack`에 push하고 자식 Feature는 view-only 형태로 노출한다. + +### 1. Feature별 navigation 구조 +- **Home / Search / User**: 각 Feature는 자체 `*Router: Router<*Route>` + `*CoordinatorView` 보유. `*CoordinatorView`는 `NavigationStack(path: $router.path)` + `.navigationDestination(for: *Route.self)`. 각 탭당 1개의 `NavigationStack` (탭별 navigation history 독립). +- **Concert (cross-feature 자식)**: `ConcertRoute`와 `ConcertCoordinatorView`만 보유. `NavigationStack`/`Router` 없음 (view-only). 부모(Home/Search) `NavigationStack`의 `navigationDestination(for: HomeRoute.concertDetail)`가 `ConcertCoordinatorView`를 렌더링하고, `ConcertCoordinatorView`는 `navigationDestination(for: ConcertRoute.self)`을 등록해 부모 스택에 push한다. + +### 2. View ↔ Router 인터페이스 +- Home/Search/User의 view는 `@EnvironmentObject var *Router: *Router`로 router에 접근, `*Router.push(.xxx)`로 navigation 트리거 (Login/User 패턴과 동일). +- 예외: 다른 모듈과 공유되는 view(예: `UserFeature`의 `NoticeSettingView`는 `HomeFeature`의 `HomeRoute.noticeSetting`과 `UserRoute.noticeSetting`에서 모두 사용)는 closure 인터페이스를 유지해 router 타입에 결합되지 않도록 한다. +- `ConcertFeature`의 view는 router가 없으므로 `NavigationLink(value:)` 선언형 navigation과 view 내부 `.sheet`로 modal을 처리한다. +- `SetlistDetailView`/`SongLyricsView`는 자체 router가 없으므로 closure 인터페이스를 유지하고, `ConcertCoordinatorView`의 destination switch가 wrapper view(`SetlistDetailContainer`)로 navigation을 연결한다. + +### 3. Modal 처리 +- Router가 있는 Feature: `present(to:)` 호출 제거, view 내부 `.sheet(item:)` 또는 `.sheet(isPresented:)`로 처리. +- Router가 없는 Feature(Concert): 모든 modal을 view 내부 `.sheet`로 처리. `ConcertRoute`에서 `.safari`/`.ticketSafari` case 제거. + +### 4. Tab bar 숨김 +- 기존 `isTabBarHidden` binding + `UINavigationControllerDelegate` 패턴 제거. +- 각 Feature의 `*CoordinatorView`의 `.navigationDestination(for: *Route.self)` closure 안에서 모든 destination view에 `.toolbar(.hidden, for: .tabBar)` modifier를 적용한다. SwiftUI preference 시스템이 pop 시 자동 복원. + +### 5. Deep link +- `LivithMainTabView`가 deep link state(`deepLinkConcertID` 등)를 보유하고 `*CoordinatorView`로 binding 전달. +- `*CoordinatorView`는 `.onChange`에서 `*Router.popToRoot()` + `*Router.push(.concertDetail(...))`로 처리. +- 외부 인터페이스(`LivithMainTabView`)는 변경하지 않는다. + +### 6. 진행 순서 +- **Plan 1 (Concert + Setlist + Song)**: Concert를 view-only CoordinatorView로 전환, Setlist/Song의 callback은 wrapper view로 bridge. Home/Search는 임시로 컴파일 실패 상태가 되지만 커밋하지 않음. +- **Plan 2 (Home)**: HomeFeature를 `HomeRouter` + `HomeCoordinatorView`로 전환. `HomeRoute.concertDetail`이 `ConcertCoordinatorView`를 호출. `LivithMainTabView` 업데이트. +- **Plan 3 (Search)**: SearchFeature를 `SearchRouter` + `SearchCoordinatorView`로 전환. `SearchRoute.concertDetail`이 `ConcertCoordinatorView`를 호출. `LivithMainTabView` 최종 업데이트. +- 모든 plan 완료 후 단일 PR로 머지, PR 내부는 plan별로 commit 분리. + +## 검토한 대안 + +### 대안 A: 모든 Feature에 자체 NavigationStack (중첩 허용) +- 각 Feature가 자체 `NavigationStack` 보유, cross-feature는 자식 `NavigationStack`이 됨. +- 장점: Feature 간 결합도 최소, 각 Feature가 독립적. +- 단점: `NavigationStack` 중첩으로 인한 UX 문제 (네비게이션 바 중복, back 버튼 이중 노출). 사용자 거부감. + +### 대안 B: 앱 전역 단일 NavigationStack +- `AppRootView` 또는 `LivithMainTabView` 레벨에서 1개의 `NavigationStack` 보유, 모든 view가 그 안에. +- 장점: 단순한 구조. +- 단점: 탭별로 navigation history를 독립적으로 유지하기 어려움 (탭 전환 시 path 손실). Modal/Sheet와 Push의 routing이 복잡해짐. + +### 대안 C (선택): 탭 단위 NavigationStack + cross-feature view-only +- 탭별로 `NavigationStack` 1개, cross-feature 진입은 부모 스택에 push, 자식은 view-only. +- 장점: 탭별 history 독립, 단일 스택 정책으로 back 동작 자연스러움, cross-feature 자식의 복잡도 최소화 (Router 불필요). +- 단점: cross-feature 자식의 modal은 자체 `.sheet` 처리 필요. Plan 1 종료 시점에서 Home/Search가 컴파일 실패하는 중간 상태 발생 (커밋하지 않음으로 우회). + +## 결과 + +### 긍정적 +- LoginFeature/UserFeature와 일관된 SwiftUI 네이티브 패턴 확립. +- 단일 `NavigationStack` 정책으로 back 동작이 자연스럽고 일관됨 (Home → Concert → Setlist → Song의 back 체인이 한 스택에서 처리됨). +- 모듈별 응집도 유지: cross-feature 진입은 부모 `NavigationStack`을 사용하므로 Feature 간 직접 의존이 발생하지 않음. +- `UIViewControllerRepresentable` 제거로 SwiftUI 네이티브 API(`NavigationLink`, `.toolbar`, `.sheet`)와 자연스러운 연동. + +### 부정적 +- Plan 1 종료 시점에서 Home/Search가 컴파일 실패. Plan 2/3에서 복구할 때까지 build 가능한 상태가 아님. 워크플로우로 우회 (Plan 단위로 진행, 최종 build 가능한 시점에서 단일 PR). +- cross-feature 자식(Concert)의 view는 router가 없으므로 modal을 자체 `.sheet`로 처리. 동일 패턴이 view마다 중복될 수 있음 (필요 시 helper modifier로 추출). +- `SetlistDetailView`/`SongLyricsView`에 wrapper view(`SetlistDetailContainer`)가 추가되어 view 트리 depth가 1단계 깊어짐. + +### Follow-up +- `docs/rules/architecture.md`의 "Coordinator 패턴" 섹션을 Router + CoordinatorView 패턴으로 갱신. +- 동일 패턴이 view마다 중복될 경우 `.sheet` 헬퍼 modifier로 추출하는 리팩토링. +- `NoticeSettingView`처럼 다중 모듈 공유 view가 늘어나면 별도 Shared 모듈 분리 검토. diff --git a/docs/rules/architecture.md b/docs/rules/architecture.md index 90927986..eb4d9352 100644 --- a/docs/rules/architecture.md +++ b/docs/rules/architecture.md @@ -43,12 +43,15 @@ - Data 레이어에 ErrorMapper를 두어 네트워크/저장소 에러를 도메인 에러로 변환한다. - Repository 메서드는 Typed Throws를 사용한다 (예: `throws(UserError)`). -### Coordinator 패턴 -- 화면 전환 로직은 Coordinator에서 관리한다. -- 각 Feature 모듈은 독립적인 Coordinator와 Route enum을 가진다. -- SwiftUI View는 `UIHostingController`로 래핑하여 Coordinator에 연결한다. -- View는 `@Environment`를 통해 Coordinator를 참조한다. -- 다른 Feature로의 전환이 필요하면 Child Coordinator를 생성한다. +### 네비게이션 패턴 (Router + NavigationStack) +- 화면 전환은 SwiftUI 네이티브 `NavigationStack` + `Router`을 사용한다. +- 각 Feature 모듈은 독립적인 `Router` (또는 typealias)와 `Route` enum을 가진다. `Route`는 `Hashable`을 채택하며, `Router`는 `Router: ObservableObject`를 상속받는다. +- Feature의 진입점은 `*CoordinatorView`로, `NavigationStack(path: $router.path)`를 생성하고 `navigationDestination(for:)`에서 Route → View를 매핑한다. +- `NavigationStack`은 **탭 단위로 1개**만 유지하며, 중첩을 금지한다 (자식 Feature는 view-only CoordinatorView로 동작). +- View는 `@EnvironmentObject var *Router: *Router`로 Router에 접근하여 `push()`, `pop()`, `popToRoot()`를 호출한다. +- 자식 Feature(예: Concert)로 진입 시 부모 Feature의 Route case에 추가하고, `navigationDestination`에서 자식의 `*CoordinatorView`(Router 없음)를 렌더링하여 단일 NavigationStack을 유지한다. +- 모달(Sheet, fullScreenCover)은 각 View 내부에서 `.sheet(isPresented:)`, `.fullScreenCover(isPresented:)`로 자체 처리한다. +- 다른 Feature와 공유되는 View(예: `NoticeSettingView`)는 closure 인터페이스를 유지하여 Router 타입에 결합되지 않도록 한다. ### 의존성 주입 - `@Injected` Property Wrapper를 사용하여 의존성을 주입한다. @@ -71,8 +74,8 @@ - View에서 State를 직접 변경하지 않는다 (Intent를 통해서만 변경한다). - Store 외부에서 `state` 프로퍼티에 직접 값을 할당하지 않는다. - DTO를 Domain 레이어나 Presentation 레이어에 노출하지 않는다. -- Coordinator 없이 View에서 직접 화면 전환 로직을 작성하지 않는다. -- Feature 모듈 간 직접 의존을 만들지 않는다 (Coordinator 또는 Shared를 통한다). +- Router 없이 View에서 직접 `NavigationStack`을 생성하지 않는다. +- Feature 모듈 간 직접 의존을 만들지 않는다 (Router + CoordinatorView, NotificationCenter, 또는 Shared를 통한다). ## Exception - DesignSystem 모듈은 레이어 구조와 무관하게 Presentation 레이어에서 자유롭게 사용한다. @@ -86,4 +89,5 @@ - Store가 send → State 변경 흐름을 따르는가 - DTO가 Domain/Presentation에 노출되지 않았는가 - 새 Data 모듈에 전담 Assembler가 있는가 -- 화면 전환이 Coordinator를 통해 이루어지는가 +- 화면 전환이 Router + NavigationStack을 통해 이루어지는가 +- 자식 Feature 진입 시 단일 NavigationStack 정책을 지키는가 (Router 없이 view-only CoordinatorView 사용)