diff --git a/SodaLive/Sources/App/AppState.swift b/SodaLive/Sources/App/AppState.swift index ab08d2a..1b1979f 100644 --- a/SodaLive/Sources/App/AppState.swift +++ b/SodaLive/Sources/App/AppState.swift @@ -7,13 +7,23 @@ import Foundation +struct AppRoute: Hashable { + let id = UUID() +} + class AppState: ObservableObject { static let shared = AppState() - private var appStepBackStack = [AppStep]() + private var routeStepMap: [AppRoute: AppStep] = [:] @Published var alreadyUpdatedMarketingInfo = false @Published private(set) var appStep: AppStep = .splash + @Published private(set) var rootStep: AppStep = .splash + @Published var navigationPath: [AppRoute] = [] { + didSet { + syncStepWithNavigationPath() + } + } @Published var isShowPlayer = false { didSet { @@ -53,28 +63,52 @@ class AppState: ObservableObject { @Published var isShowErrorPopup = false @Published var errorMessage = "" - func setAppStep(step: AppStep) { - switch step { - case .splash, .main: - appStepBackStack.removeAll() - - default: - appStepBackStack.append(appStep) - } + private func syncStepWithNavigationPath() { + let validRoutes = Set(navigationPath) + routeStepMap = routeStepMap.filter { validRoutes.contains($0.key) } + if let route = navigationPath.last, + let step = routeStepMap[route] { + appStep = step + } else { + appStep = rootStep + } + } + + func appStep(for route: AppRoute) -> AppStep? { + routeStepMap[route] + } + + func setAppStep(step: AppStep) { DispatchQueue.main.async { - self.appStep = step + switch step { + case .splash, .main: + self.rootStep = step + self.routeStepMap.removeAll() + self.navigationPath.removeAll() + self.appStep = step + + default: + let route = AppRoute() + self.routeStepMap[route] = step + self.navigationPath.append(route) + self.appStep = step + } } } func back() { - if let step = appStepBackStack.popLast() { - self.appStep = step - } else { - self.appStep = .main + DispatchQueue.main.async { + if self.navigationPath.isEmpty { + self.rootStep = .main + self.appStep = .main + return + } + + _ = self.navigationPath.popLast() } } - + // 언어 적용 직후 앱을 소프트 재시작(스플래시 -> 메인)하여 전역 UI를 새로고침 func softRestart() { isRestartApp = true diff --git a/SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift b/SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift index 3b75788..a5c05c6 100644 --- a/SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift +++ b/SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift @@ -14,105 +14,100 @@ struct NewCharacterListView: View { private let gridSpacing: CGFloat = 12 var body: some View { - NavigationStack { - BaseView(isLoading: $viewModel.isLoading) { - VStack(spacing: 8) { - // Toolbar - DetailNavigationBar(title: String(localized: "신규 캐릭터 전체보기")) + Group { BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 8) { + // Toolbar + DetailNavigationBar(title: String(localized: "신규 캐릭터 전체보기")) + + VStack(alignment: .leading, spacing: 12) { + // 전체 n개 + HStack(spacing: 0) { + Text("전체") + .appFont(size: 12, weight: .regular) + .foregroundColor(Color(hex: "e2e2e2")) + Text(" \(viewModel.totalCount)") + .appFont(size: 12, weight: .regular) + .foregroundColor(Color(hex: "ff5c49")) + Text("개") + .appFont(size: 12, weight: .regular) + .foregroundColor(Color(hex: "e2e2e2")) + Spacer() + } + .padding(.horizontal, 24) - VStack(alignment: .leading, spacing: 12) { - // 전체 n개 - HStack(spacing: 0) { - Text("전체") - .appFont(size: 12, weight: .regular) - .foregroundColor(Color(hex: "e2e2e2")) - Text(" \(viewModel.totalCount)") - .appFont(size: 12, weight: .regular) - .foregroundColor(Color(hex: "ff5c49")) - Text("개") - .appFont(size: 12, weight: .regular) - .foregroundColor(Color(hex: "e2e2e2")) - Spacer() - } - .padding(.horizontal, 24) + // Grid 3열 + GeometryReader { geo in + let width = (geo.size.width - (horizontalPadding * 2) - gridSpacing) / 2 - // Grid 3열 - GeometryReader { geo in - let width = (geo.size.width - (horizontalPadding * 2) - gridSpacing) / 2 - - ScrollView(.vertical, showsIndicators: false) { - LazyVGrid( - columns: Array( - repeating: GridItem( - .flexible(), - spacing: gridSpacing, - alignment: .topLeading - ), - count: 2 + ScrollView(.vertical, showsIndicators: false) { + LazyVGrid( + columns: Array( + repeating: GridItem( + .flexible(), + spacing: gridSpacing, + alignment: .topLeading ), - alignment: .leading, - spacing: gridSpacing - ) { - ForEach(viewModel.items.indices, id: \.self) { idx in - let item = viewModel.items[idx] - - NavigationLink(value: item.characterId) { - CharacterItemView( - character: item, - size: width, - rank: 0, - isShowRank: false - ) - .onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) } - } - } + count: 2 + ), + alignment: .leading, + spacing: gridSpacing + ) { + ForEach(viewModel.items.indices, id: \.self) { idx in + let item = viewModel.items[idx] + + CharacterItemView( + character: item, + size: width, + rank: 0, + isShowRank: false + ) + .onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) } + .onTapGesture { AppState.shared.setAppStep(step: .characterDetail(characterId: item.characterId)) } } - .padding(.horizontal, horizontalPadding) - - if viewModel.isLoadingMore { - HStack { - Spacer() - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .padding(.vertical, 16) - Spacer() - } + } + .padding(.horizontal, horizontalPadding) + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .padding(.vertical, 16) + Spacer() } } } - .frame(minHeight: 0, maxHeight: .infinity) - } - .padding(.vertical, 12) - .onAppear { - // 최초 1회만 로드하여 상세 진입 후 복귀 시 스크롤 위치가 유지되도록 함 - if viewModel.items.isEmpty { - viewModel.fetch() - } } + .frame(minHeight: 0, maxHeight: .infinity) } - .background(Color.black) - } - .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { - GeometryReader { geo in - HStack { - Spacer() - Text(viewModel.errorMessage) - .padding(.vertical, 13.3) - .frame(width: geo.size.width - 66.7, alignment: .center) - .appFont(size: 12, weight: .medium) - .background(Color.button) - .foregroundColor(Color.white) - .multilineTextAlignment(.center) - .cornerRadius(20) - .padding(.top, 66.7) - Spacer() + .padding(.vertical, 12) + .onAppear { + // 최초 1회만 로드하여 상세 진입 후 복귀 시 스크롤 위치가 유지되도록 함 + if viewModel.items.isEmpty { + viewModel.fetch() } } } - .navigationDestination(for: Int.self) { characterId in - CharacterDetailView(characterId: characterId) + .background(Color.black) + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .frame(width: geo.size.width - 66.7, alignment: .center) + .appFont(size: 12, weight: .medium) + .background(Color.button) + .foregroundColor(Color.white) + .multilineTextAlignment(.center) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } } } + } } } diff --git a/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift index 5b42788..aaf6c4b 100644 --- a/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift +++ b/SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift @@ -15,91 +15,87 @@ struct OriginalWorkDetailView: View { let originalId: Int var body: some View { - NavigationStack { - BaseView(isLoading: $viewModel.isLoading) { - ZStack(alignment: .top) { - if let imageUrl = viewModel.response?.imageUrl { - KFImage(URL(string: imageUrl)) - .cancelOnDisappear(true) + Group { BaseView(isLoading: $viewModel.isLoading) { + ZStack(alignment: .top) { + if let imageUrl = viewModel.response?.imageUrl { + KFImage(URL(string: imageUrl)) + .cancelOnDisappear(true) + .resizable() + .scaledToFill() + .frame(width: screenSize().width, height: (168 * 288 / 306) + 56) + .clipped() + .blur(radius: 25) + } + + Color.black.opacity(0.5).ignoresSafeArea() + + VStack(spacing: 0) { + HStack(spacing: 0) { + Image("ic_back") .resizable() - .scaledToFill() - .frame(width: screenSize().width, height: (168 * 288 / 306) + 56) - .clipped() - .blur(radius: 25) - } - - Color.black.opacity(0.5).ignoresSafeArea() - - VStack(spacing: 0) { - HStack(spacing: 0) { - Image("ic_back") - .resizable() - .frame(width: 24, height: 24) - .onTapGesture { - AppState.shared.back() - } - - Spacer() - } - .padding(.horizontal, 24) - .frame(height: 56) + .frame(width: 24, height: 24) + .onTapGesture { + AppState.shared.back() + } - if let response = viewModel.response { - ScrollView(.vertical, showsIndicators: false) { - VStack(spacing: 0) { - OriginalWorkDetailHeaderView(item: response) - .padding(.horizontal, 24) - .padding(.bottom, 24) - - HStack(spacing: 0) { - SeriesDetailTabView( - title: I18n.Tab.character, - width: screenSize().width / 2, - isSelected: viewModel.currentTab == .character - ) { - if viewModel.currentTab != .character { - viewModel.currentTab = .character - } - } - - SeriesDetailTabView( - title: I18n.Tab.workInfo, - width: screenSize().width / 2, - isSelected: viewModel.currentTab == .info - ) { - if viewModel.currentTab != .info { - viewModel.currentTab = .info - } + Spacer() + } + .padding(.horizontal, 24) + .frame(height: 56) + + if let response = viewModel.response { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + OriginalWorkDetailHeaderView(item: response) + .padding(.horizontal, 24) + .padding(.bottom, 24) + + HStack(spacing: 0) { + SeriesDetailTabView( + title: I18n.Tab.character, + width: screenSize().width / 2, + isSelected: viewModel.currentTab == .character + ) { + if viewModel.currentTab != .character { + viewModel.currentTab = .character } } - .background(Color.black) - Rectangle() - .foregroundColor(Color.gray90.opacity(0.5)) - .frame(height: 1) - .frame(maxWidth: .infinity) - - switch(viewModel.currentTab) { - case .info: - OriginalWorkInfoView(response: response) - default: - OriginalWorkCharacterView(characters: viewModel.characters) + SeriesDetailTabView( + title: I18n.Tab.workInfo, + width: screenSize().width / 2, + isSelected: viewModel.currentTab == .info + ) { + if viewModel.currentTab != .info { + viewModel.currentTab = .info + } } } + .background(Color.black) + + Rectangle() + .foregroundColor(Color.gray90.opacity(0.5)) + .frame(height: 1) + .frame(maxWidth: .infinity) + + switch(viewModel.currentTab) { + case .info: + OriginalWorkInfoView(response: response) + default: + OriginalWorkCharacterView(characters: viewModel.characters) + } } } } } } - .onAppear { - if viewModel.response == nil { - viewModel.originalId = originalId - } - } - .navigationDestination(for: Int.self) { characterId in - CharacterDetailView(characterId: characterId) + } + .onAppear { + if viewModel.response == nil { + viewModel.originalId = originalId } } + } } } @@ -129,14 +125,13 @@ struct OriginalWorkCharacterView: View { ForEach(characters.indices, id: \.self) { idx in let item = characters[idx] - NavigationLink(value: item.characterId) { - CharacterItemView( - character: item, - size: width, - rank: 0, - isShowRank: false - ) - } + CharacterItemView( + character: item, + size: width, + rank: 0, + isShowRank: false + ) + .onTapGesture { AppState.shared.setAppStep(step: .characterDetail(characterId: item.characterId)) } } } .padding(.horizontal, horizontalPadding) diff --git a/SodaLive/Sources/Content/All/ByTheme/ContentAllByThemeView.swift b/SodaLive/Sources/Content/All/ByTheme/ContentAllByThemeView.swift index 98e2db7..4eca240 100644 --- a/SodaLive/Sources/Content/All/ByTheme/ContentAllByThemeView.swift +++ b/SodaLive/Sources/Content/All/ByTheme/ContentAllByThemeView.swift @@ -9,11 +9,12 @@ import SwiftUI struct ContentAllByThemeView: View { @StateObject var viewModel = ContentAllByThemeViewModel() + @State private var isInitialized = false let themeId: Int var body: some View { - NavigationView { + Group { BaseView(isLoading: $viewModel.isLoading) { VStack(alignment: .leading, spacing: 0) { DetailNavigationBar(title: viewModel.theme) @@ -111,8 +112,11 @@ struct ContentAllByThemeView: View { } } .onAppear { - viewModel.themeId = themeId - viewModel.getContentList() + if !isInitialized || viewModel.themeId != themeId { + viewModel.themeId = themeId + viewModel.getContentList() + isInitialized = true + } } } } diff --git a/SodaLive/Sources/Content/All/ContentAllView.swift b/SodaLive/Sources/Content/All/ContentAllView.swift index 0db6864..af31be3 100644 --- a/SodaLive/Sources/Content/All/ContentAllView.swift +++ b/SodaLive/Sources/Content/All/ContentAllView.swift @@ -10,12 +10,13 @@ import SwiftUI struct ContentAllView: View { @StateObject var viewModel = ContentAllViewModel() + @State private var isInitialized = false var isFree: Bool = false var isPointAvailableOnly: Bool = false var body: some View { - NavigationView { + Group { BaseView(isLoading: $viewModel.isLoading) { VStack(spacing: 0) { DetailNavigationBar(title: isFree ? String(localized: "무료 콘텐츠 전체") : isPointAvailableOnly ? String(localized: "포인트 대여 전체") : String(localized: "콘텐츠 전체")) @@ -78,63 +79,63 @@ struct ContentAllView: View { ForEach(viewModel.contentList.indices, id: \.self) { idx in let item = viewModel.contentList[idx] - NavigationLink { - ContentDetailView(contentId: item.contentId) - } label: { - VStack(alignment: .leading, spacing: 0) { - ZStack(alignment: .top) { - DownsampledKFImage( - url: URL(string: item.coverImageUrl), - size: CGSize(width: itemSize, height: itemSize) - ) - .cornerRadius(16) + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .top) { + DownsampledKFImage( + url: URL(string: item.coverImageUrl), + size: CGSize(width: itemSize, height: itemSize) + ) + .cornerRadius(16) + + HStack(alignment: .top, spacing: 0) { + Spacer() - HStack(alignment: .top, spacing: 0) { - Spacer() - - if item.isPointAvailable { - Image("ic_point") - .padding(.top, 6) - .padding(.trailing, 6) - } + if item.isPointAvailable { + Image("ic_point") + .padding(.top, 6) + .padding(.trailing, 6) } } - - Text(item.title) - .appFont(size: 18, weight: .regular) - .foregroundColor(.white) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(1) - .padding(.horizontal, 6) - .padding(.top, 8) - - - Text(item.creatorNickname) - .appFont(size: 14, weight: .regular) - .foregroundColor(Color(hex: "78909C")) - .lineLimit(1) - .padding(.horizontal, 6) - .padding(.top, 4) } - .frame(width: itemSize) - .contentShape(Rectangle()) - .onAppear { - if idx == viewModel.contentList.count - 1 { - viewModel.fetchData() - } + + Text(item.title) + .appFont(size: 18, weight: .regular) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(1) + .padding(.horizontal, 6) + .padding(.top, 8) + + + Text(item.creatorNickname) + .appFont(size: 14, weight: .regular) + .foregroundColor(Color(hex: "78909C")) + .lineLimit(1) + .padding(.horizontal, 6) + .padding(.top, 4) + } + .frame(width: itemSize) + .contentShape(Rectangle()) + .onAppear { + if idx == viewModel.contentList.count - 1 { + viewModel.fetchData() } } + .onTapGesture { AppState.shared.setAppStep(step: .contentDetail(contentId: item.contentId)) } } } .padding(horizontalPadding) } } .onAppear { - viewModel.isFree = isFree - viewModel.isPointAvailableOnly = isPointAvailableOnly - viewModel.getThemeList() - viewModel.fetchData() + if !isInitialized || viewModel.isFree != isFree || viewModel.isPointAvailableOnly != isPointAvailableOnly { + viewModel.isFree = isFree + viewModel.isPointAvailableOnly = isPointAvailableOnly + viewModel.getThemeList() + viewModel.fetchData() + isInitialized = true + } } } } diff --git a/SodaLive/Sources/Content/All/ContentNewAllItemView.swift b/SodaLive/Sources/Content/All/ContentNewAllItemView.swift index 30fe495..aa4d4bd 100644 --- a/SodaLive/Sources/Content/All/ContentNewAllItemView.swift +++ b/SodaLive/Sources/Content/All/ContentNewAllItemView.swift @@ -14,95 +14,92 @@ struct ContentNewAllItemView: View { let item: GetAudioContentMainItem var body: some View { - NavigationLink { - ContentDetailView(contentId: item.contentId) - } label: { - VStack(alignment: .leading, spacing: 8) { - ZStack(alignment: .bottom) { - KFImage(URL(string: item.coverImageUrl)) - .cancelOnDisappear(true) - .downsampling( - size: CGSize( - width: width, - height: width - ) + VStack(alignment: .leading, spacing: 8) { + ZStack(alignment: .bottom) { + KFImage(URL(string: item.coverImageUrl)) + .cancelOnDisappear(true) + .downsampling( + size: CGSize( + width: width, + height: width ) - .resizable() - .scaledToFill() - .frame(width: width, height: width, alignment: .top) - .cornerRadius(2.7) + ) + .resizable() + .scaledToFill() + .frame(width: width, height: width, alignment: .top) + .cornerRadius(2.7) + + VStack(spacing: 0) { + Spacer() - VStack(spacing: 0) { - Spacer() - - HStack(spacing: 0) { - HStack(spacing: 2) { - if item.price > 0 { - Image("ic_card_can_gray") - - Text("\(item.price)") - .appFont(size: 8.5, weight: .medium) - .foregroundColor(Color.white) - } else { - Text("무료") - .appFont(size: 8.5, weight: .medium) - .foregroundColor(Color.white) - } - } - .padding(3) - .background(Color(hex: "333333").opacity(0.7)) - .cornerRadius(10) - .padding(.leading, 2.7) - .padding(.bottom, 2.7) - - Spacer() - - HStack(spacing: 2) { - Text(item.duration) + HStack(spacing: 0) { + HStack(spacing: 2) { + if item.price > 0 { + Image("ic_card_can_gray") + + Text("\(item.price)") + .appFont(size: 8.5, weight: .medium) + .foregroundColor(Color.white) + } else { + Text("무료") .appFont(size: 8.5, weight: .medium) .foregroundColor(Color.white) } - .padding(3) - .background(Color(hex: "333333").opacity(0.7)) - .cornerRadius(10) - .padding(.trailing, 2.7) - .padding(.bottom, 2.7) } + .padding(3) + .background(Color(hex: "333333").opacity(0.7)) + .cornerRadius(10) + .padding(.leading, 2.7) + .padding(.bottom, 2.7) + + Spacer() + + HStack(spacing: 2) { + Text(item.duration) + .appFont(size: 8.5, weight: .medium) + .foregroundColor(Color.white) + } + .padding(3) + .background(Color(hex: "333333").opacity(0.7)) + .cornerRadius(10) + .padding(.trailing, 2.7) + .padding(.bottom, 2.7) } } - .frame(width: width, height: width) - - Text(item.title) - .appFont(size: 13.3, weight: .medium) - .foregroundColor(Color(hex: "d2d2d2")) - .frame(width: width, alignment: .leading) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(2) - - HStack(spacing: 5.3) { - KFImage(URL(string: item.creatorProfileImageUrl)) - .cancelOnDisappear(true) - .downsampling( - size: CGSize( - width: 21.3, - height: 21.3 - ) - ) - .resizable() - .scaledToFill() - .frame(width: 21.3, height: 21.3) - .clipShape(Circle()) - .onTapGesture { AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId)) } - - Text(item.creatorNickname) - .appFont(size: 12, weight: .medium) - .foregroundColor(Color(hex: "777777")) - .lineLimit(1) - } - .padding(.bottom, 10) } - .frame(width: width) + .frame(width: width, height: width) + + Text(item.title) + .appFont(size: 13.3, weight: .medium) + .foregroundColor(Color(hex: "d2d2d2")) + .frame(width: width, alignment: .leading) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(2) + + HStack(spacing: 5.3) { + KFImage(URL(string: item.creatorProfileImageUrl)) + .cancelOnDisappear(true) + .downsampling( + size: CGSize( + width: 21.3, + height: 21.3 + ) + ) + .resizable() + .scaledToFill() + .frame(width: 21.3, height: 21.3) + .clipShape(Circle()) + .onTapGesture { AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId)) } + + Text(item.creatorNickname) + .appFont(size: 12, weight: .medium) + .foregroundColor(Color(hex: "777777")) + .lineLimit(1) + } + .padding(.bottom, 10) } + .frame(width: width) + .onTapGesture { AppState.shared.setAppStep(step: .contentDetail(contentId: item.contentId)) } } } diff --git a/SodaLive/Sources/Content/All/ContentNewAllView.swift b/SodaLive/Sources/Content/All/ContentNewAllView.swift index bdceacf..149cc3a 100644 --- a/SodaLive/Sources/Content/All/ContentNewAllView.swift +++ b/SodaLive/Sources/Content/All/ContentNewAllView.swift @@ -10,11 +10,12 @@ import SwiftUI struct ContentNewAllView: View { @StateObject var viewModel = ContentNewAllViewModel() + @State private var isInitialized = false let isFree: Bool var body: some View { - NavigationView { + Group { BaseView(isLoading: $viewModel.isLoading) { VStack(alignment: .leading, spacing: 13.3) { DetailNavigationBar(title: isFree ? "최신 무료 콘텐츠" : "최신 콘텐츠") @@ -82,9 +83,12 @@ struct ContentNewAllView: View { } } .onAppear { - viewModel.isFree = isFree - viewModel.getThemeList() - viewModel.getNewContentList() + if !isInitialized || viewModel.isFree != isFree { + viewModel.isFree = isFree + viewModel.getThemeList() + viewModel.getNewContentList() + isInitialized = true + } } } .navigationBarHidden(true) diff --git a/SodaLive/Sources/Content/All/ContentRankingAllView.swift b/SodaLive/Sources/Content/All/ContentRankingAllView.swift index f1ccda8..813f2d4 100644 --- a/SodaLive/Sources/Content/All/ContentRankingAllView.swift +++ b/SodaLive/Sources/Content/All/ContentRankingAllView.swift @@ -11,9 +11,10 @@ import Kingfisher struct ContentRankingAllView: View { @StateObject var viewModel = ContentRankingAllViewModel() + @State private var isInitialized = false var body: some View { - NavigationView { + Group { BaseView(isLoading: $viewModel.isLoading) { VStack(spacing: 0) { DetailNavigationBar(title: "인기 콘텐츠") @@ -44,97 +45,94 @@ struct ContentRankingAllView: View { LazyVStack(spacing: 20) { ForEach(0.. 0 { - HStack(spacing: 8) { - Image("ic_can") - .resizable() - .frame(width: 17, height: 17) - - Text("\(item.price)") - .appFont(size: 12, weight: .medium) - .foregroundColor(Color(hex: "909090")) - } - } else { - Text("무료") - .appFont(size: 12, weight: .medium) - .foregroundColor(Color(hex: "ffffff")) - .padding(.horizontal, 5.3) - .padding(.vertical, 2.7) - .background(Color(hex: "cf5c37")) + ) + .resizable() + .scaledToFill() + .frame(width: 66.7, height: 66.7, alignment: .top) + .clipped() + .cornerRadius(5.3) + + Text("\(index + 1)") + .appFont(size: 16.7, weight: .bold) + .foregroundColor(Color(hex: "3bb9f1")) + .padding(.horizontal, 12) + + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Text(item.themeStr) + .appFont(size: 8, weight: .medium) + .foregroundColor(Color(hex: "3bac6a")) + .padding(2.6) + .background(Color(hex: "28312b")) .cornerRadius(2.6) + + Text(item.duration) + .appFont(size: 8, weight: .medium) + .foregroundColor(Color(hex: "777777")) + .padding(2.6) + .background(Color(hex: "222222")) + .cornerRadius(2.6) + + if item.isPointAvailable { + Text("포인트") + .appFont(size: 8, weight: .medium) + .foregroundColor(.white) + .padding(2.6) + .background(Color(hex: "7849bc")) + .cornerRadius(2.6) + } } + + Text(item.creatorNickname) + .appFont(size: 10.7, weight: .medium) + .foregroundColor(Color(hex: "777777")) + .padding(.vertical, 8) + + Text(item.title) + .appFont(size: 12, weight: .medium) + .foregroundColor(Color(hex: "d2d2d2")) + .lineLimit(2) + .padding(.top, 2.7) } - .frame(height: 66.7) - .contentShape(Rectangle()) - .onAppear { - if index == viewModel.contentRankingItemList.count - 1 { - viewModel.getContentRanking() + + Spacer() + + if item.price > 0 { + HStack(spacing: 8) { + Image("ic_can") + .resizable() + .frame(width: 17, height: 17) + + Text("\(item.price)") + .appFont(size: 12, weight: .medium) + .foregroundColor(Color(hex: "909090")) } + } else { + Text("무료") + .appFont(size: 12, weight: .medium) + .foregroundColor(Color(hex: "ffffff")) + .padding(.horizontal, 5.3) + .padding(.vertical, 2.7) + .background(Color(hex: "cf5c37")) + .cornerRadius(2.6) } } + .frame(height: 66.7) + .contentShape(Rectangle()) + .onAppear { + if index == viewModel.contentRankingItemList.count - 1 { + viewModel.getContentRanking() + } + } + .onTapGesture { AppState.shared.setAppStep(step: .contentDetail(contentId: item.contentId)) } } } } @@ -165,8 +163,11 @@ struct ContentRankingAllView: View { } } .onAppear { - viewModel.getContentRankingSortType() - viewModel.getContentRanking() + if !isInitialized { + viewModel.getContentRankingSortType() + viewModel.getContentRanking() + isInitialized = true + } } } } diff --git a/SodaLive/Sources/Content/Box/ContentBoxView.swift b/SodaLive/Sources/Content/Box/ContentBoxView.swift index 6118d1a..076600b 100644 --- a/SodaLive/Sources/Content/Box/ContentBoxView.swift +++ b/SodaLive/Sources/Content/Box/ContentBoxView.swift @@ -21,7 +21,7 @@ struct ContentBoxView: View { var body: some View { ZStack { - NavigationView { + Group { VStack(spacing: 13.3) { DetailNavigationBar(title: I18n.ContentBox.title) diff --git a/SodaLive/Sources/Content/ContentListView.swift b/SodaLive/Sources/Content/ContentListView.swift index bb98813..58eb289 100644 --- a/SodaLive/Sources/Content/ContentListView.swift +++ b/SodaLive/Sources/Content/ContentListView.swift @@ -11,9 +11,10 @@ struct ContentListView: View { let userId: Int @StateObject var viewModel = ContentListViewModel() + @State private var isInitialized = false var body: some View { - NavigationView { + Group { BaseView(isLoading: $viewModel.isLoading) { VStack(spacing: 0) { HStack(spacing: 0) { @@ -128,17 +129,14 @@ struct ContentListView: View { ForEach(0.. CharacterDetailView`) +- [x] 모달 기반 전환 현황 확인 (`sheet` 13회/7개 파일, `fullScreenCover` 4회/4개 파일) +- [x] 딥링크/푸시 진입 흐름 확인 (`AppDelegate` -> `AppState.push*` -> `SplashView`/`HomeView`) + +## 분산 내비게이션 패턴 메모 +- [x] 채팅 영역 2개 화면에서만 자체 `NavigationStack`을 보유하고 있어 앱 전역 스택과 분리되어 동작 +- [x] `NavigationView`를 가진 부모 화면 + 하위 컴포넌트 `NavigationLink` 조합(중첩 구조)이 콘텐츠/시리즈/검색 화면에 광범위하게 분포 +- [x] 댓글/공유/이미지뷰어/본인인증은 `sheet`/`fullScreenCover`로 분리되어 있어 스택 푸시와 별도 정책 필요 + +## 라우팅 핫스팟(우선 전환 대상) +- [x] `setAppStep` 상위 호출 파일 확인: `MyPageView.swift`(14), `LiveView.swift`(10), `SettingsView.swift`(8), `LiveReservation/SectionLiveReservationView.swift`(6), `SplashView.swift`(6) +- [x] `back` 상위 호출 파일 확인: `LiveDetailView.swift`(5), `ProfileUpdateViewModel.swift`(3), `CreatorCommunityModifyView.swift`(3), `CanPgPaymentView.swift`(3), `CanPgPaymentViewModel.swift`(3) +- [x] 루트 화면 재진입 영향 구간 확인: `SplashView`/`HomeView`에서 `setAppStep(.main)` 후 상세 스텝 연속 진입 패턴 존재 + +## 외부 레퍼런스 반영 메모 +- [x] Apple 공식 `NavigationStack`/`NavigationPath` 문서 기준으로 value-based 라우팅(`path` + `navigationDestination`) 채택 필요 확인 +- [x] `NavigationLink(isActive:)`/`selection` 기반 API deprecate 이슈 확인, 값 기반 링크(`NavigationLink(value:)`)로 정리 필요 +- [x] 상태 복원 요구 시 `NavigationPath.CodableRepresentation` 기반 직렬화/복원 전략 적용 가능성 확인 +- [x] 탭/플로우별 path 분리 패턴(오픈소스 코디네이터 예시) 확인 +- [x] SwiftUI 목적지 재사용으로 인한 `onAppear` 동작 함정 사례 확인(필요 시 `.task(id:)`/id 기반 제어) + +### 참고 링크 +- https://developer.apple.com/documentation/swiftui/navigationstack +- https://developer.apple.com/documentation/swiftui/understanding-the-navigation-stack +- https://developer.apple.com/documentation/swiftui/navigationpath +- https://developer.apple.com/documentation/swiftui/navigationlink/init%28isactive%3Adestination%3Alabel%3A%29 +- https://github.com/chocoford/ExcalidrawZ/blob/acb84e0f49ab943bb417ac5a7c036247ef55707b/ExcalidrawZ/Share/ShareView.swift#L56-L191 +- https://github.com/wunax/strimr/blob/8ce61ac3fb540187f03e2c94d082dc3b86720165/Shared/Features/MainCoordinator.swift#L4-L147 +- https://github.com/TortugaPower/BookPlayer/blob/a0a8f00a3a80e5aca706da6e8d28fa2231203bd7/BookPlayer/Profile/Passkey/PasskeyRegistrationView.swift#L31-L137 +- https://github.com/twostraws/Inferno/blob/725c30a8b29957ba0fdef18aef1289e5c0092298/Sandbox/Inferno/ContentView.swift#L72-L91 + +## 구현 체크리스트 +- [x] `AppRoute`(가칭) 설계: `NavigationStack`에 적합한 `Hashable` 라우트 모델 정의 +- [x] `AppStep` -> `AppRoute` 매핑표 작성: 78개 스텝을 루트 전환/푸시 전환으로 분리 +- [x] 클로저/비-Hashable 연관값 대응 설계: 콜백 전달이 필요한 스텝(`refresh`, `onSuccess` 등)의 안전한 브리지 전략 수립 +- [x] 전역 내비게이션 코디네이터(가칭) 설계: `path`, `push`, `pop`, `reset` API 정의 +- [x] `ContentView` 루트 구조 개편: 단일 `NavigationStack(path:)` + `navigationDestination` 등록 구조로 전환 +- [x] 스플래시/로그인/메인 루트 상태 전이 재정의: 기존 `.splash`, `.main`, `.login` 동작 동등성 보장 +- [x] 딥링크/푸시 라우팅 재배선: `pushRoomId/pushChannelId/pushAudioContentId/pushSeriesId/pushMessageId`를 path 전환으로 일원화 +- [x] 기존 `AppState.shared.setAppStep` 호출부 점진 전환(모듈 우선순위 적용) +- [x] 기존 `AppState.shared.back` 호출부를 stack pop 동작으로 전환 (`DetailNavigationBar` 포함) +- [x] 중첩 내비게이션 정리: `NavigationView` 18개 제거 및 `NavigationStack` 2개(채팅 화면) 중첩 해소 +- [x] `NavigationLink` 로컬 푸시와 전역 라우트의 역할 분리 규칙 확정 +- [x] 데이터 재로딩 방지 점검: 복귀 시 재요청이 발생하는 목록/상세 화면의 `onAppear`/ViewModel 생명주기 정리 +- [x] 단계별 마이그레이션 플래그 또는 호환 레이어(`setAppStep` 브리지) 적용 여부 결정 +- [x] 모듈별 전환 순서 확정 (권장: Root -> Home/Content -> MyPage/Settings -> Live -> Message/Chat) +- [x] 완료 검증 시나리오 문서화 (뒤로가기, 탭 전환, 푸시 진입, 로그인 전환, 결제/충전 플로우) + +## 스크롤 유지/재로딩 후속 보정 +- [x] `ContentCurationView` 복귀 시 무조건 재조회 방지(`isInitialized` + `curationId` 변경 감지) +- [x] `ContentMainAlarmAllView` 최초 진입 1회 조회 가드 적용 +- [x] `ContentMainAsmrAllView`/`ContentMainReplayAllView` 테마 재설정 기반 중복 조회 방지 +- [x] `ContentMainIntroduceCreatorAllView` 최초 진입 1회 조회 가드 적용 +- [x] `SeriesListAllView` 초기화 가드 및 필터 변경 시에만 목록 리셋/재조회 +- [x] `SeriesMainHomeView`/`SeriesMainByGenreView` 복귀 재조회 방지 +- [x] Series 화면의 남은 로컬 `NavigationLink`를 `AppState.setAppStep` 기반으로 전환해 루트 path 일관성 확보 +- [x] 잔여 `NavigationLink` 2건(댓글 답글 로컬 플로우)은 의도적으로 유지 +- [x] `UserProfileView` 복귀 시 무조건 `getCreatorProfile` 재호출 방지(최초 진입/미로딩 상태에만 조회) +- [x] 전역 step 화면에 기본 `Navigation` 뒤로가기 버튼 비노출 적용(`ContentView`의 `AppStepLayerView` 호출부 공통 처리) +- [x] 언어 변경 soft restart 시 스플래시 상단 오프셋 보정(`SplashView`에서 `toolbar(.hidden, for: .navigationBar)` 적용) +- [x] 댓글 리스트 시트에서 답글 보기/쓰기 동작 복구(`ContentDetailView`/`CreatorCommunityAllView` 시트 컨텐츠를 `NavigationStack`으로 감싸고 nav bar 숨김 적용) + +## Navigation 컨테이너 정리 대상 파일 +- [x] `SodaLive/Sources/MyPage/OrderList/OrderListAllView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/Comment/CreatorCommunityCommentListView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Search/SearchView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Content/ContentListView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Content/Box/ContentBoxView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Content/All/ContentAllView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Content/Main/V2/Alarm/All/ContentMainAlarmAllView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Content/All/ContentRankingAllView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Content/Curation/ContentCurationView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Content/Main/V2/Replay/All/ContentMainReplayAllView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Content/All/ByTheme/ContentAllByThemeView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Content/All/ContentNewAllView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Content/Main/V2/ContentMainViewV2.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Content/Main/V2/Free/All/ContentMainIntroduceCreatorAllView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Content/Series/Main/SeriesMainView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Content/Main/V2/Asmr/All/ContentMainAsmrAllView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Content/Series/SeriesListAllView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Content/Detail/Comment/AudioContentCommentListView.swift` (`NavigationView` 제거) +- [x] `SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift` (중첩 `NavigationStack` 제거) +- [x] `SodaLive/Sources/Chat/Original/Detail/OriginalWorkDetailView.swift` (중첩 `NavigationStack` 제거) + +## 화면군 우선순위(초안) +- [x] P0: Root/App (`ContentView`, `AppState`, `AppStep`, `SplashView`, `HomeView` 푸시 진입 처리) +- [x] P1: Content/Series/Search (`NavigationView` 다수 분포 구간) +- [x] P1: MyPage/Settings (계정/설정/캔 결제 흐름) +- [x] P2: Live/Audition (콜백 기반 스텝 다수 구간) +- [x] P2: Message/Chat (일부 `NavigationStack` 선구현 화면 정합화) + +## 리스크 및 대응 계획 +- [x] 리스크: `AppStep` 연관값에 클로저가 많아 `NavigationStack`의 `Hashable` 경로 모델과 충돌 가능 +- [x] 대응: 라우트에는 식별자/파라미터만 담고, 콜백은 코디네이터의 임시 액션 저장소(토큰 기반)로 분리 +- [x] 리스크: 푸시/딥링크 타이밍(현재 `DispatchQueue.main.asyncAfter`) 의존 로직 회귀 가능 +- [x] 대응: 루트 준비 완료 시점 이벤트를 기준으로 경로 적용 순서를 표준화 +- [x] 리스크: 기존 `NavigationView` 내부 `NavigationLink` 동작과 전역 스택 충돌 가능 +- [x] 대응: "로컬 화면 내부 계층"과 "앱 전역 화면 전환" 규칙을 문서화하고 중복 push 금지 가드 추가 + +## 검증 계획(구현 단계에서 수행) +- [x] `lsp_diagnostics`로 수정 파일 오류 0 확인 +- [x] `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` +- [x] `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` +- [x] `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` +- [x] `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` + +## 검증 기록 +- 무엇/왜/어떻게: 내비게이션 단일화 계획 수립을 위해 코드베이스 전수 탐색으로 현재 라우팅 구조와 분산 내비게이션 사용처를 먼저 계량했다. +- 실행 명령: `grep` 패턴 검색 (`NavigationView`, `NavigationStack`, `NavigationLink`, `navigationDestination`, `setAppStep`, `back`) +- 결과: `NavigationView` 18개 파일, `NavigationStack` 2개 파일, `AppState.shared.setAppStep` 162회, `AppState.shared.back` 54회 확인. +- 실행 명령: `ast_grep_search` (`NavigationView { $$$ }`, `NavigationStack { $$$ }`, `func setAppStep($$$) { $$$ }`) +- 결과: 중첩 `NavigationStack` 화면 2개와 전역 라우팅 함수 정의 위치(`AppState.swift`)를 교차 검증. +- 실행 명령: `lsp_symbols` (`ContentView.swift`, `AppState.swift`, `AppStep.swift`) +- 결과: 루트 분기 지점(`ContentView.body`), 전역 라우팅 API(`setAppStep`, `back`), 라우트 열거형(`AppStep`) 심볼 확인. +- 실행 명령: `rg -n "NavigationView|NavigationStack|setAppStep\(|AppState\.shared\.back\(|navigationDestination|NavigationLink" "SodaLive/Sources"` +- 결과: 로컬 환경에 `rg` 미설치(`command not found`)로 확인되어 `grep`/`ast-grep` 기반으로 대체 탐색 수행. +- 실행 명령: background `librarian` 조사 (`bg_0803bb96`) +- 결과: Apple 공식 문서 기준(`NavigationStack`, `NavigationPath`, deprecated `NavigationLink(isActive:)`)과 오픈소스 코디네이터 패턴(탭별 `NavigationPath`, 딥링크 path 매핑, 상태 복원) 근거 확보. +- 실행 명령: background `explore` 조사 (`bg_7711b310`) +- 결과: `NavigationView` 18개, `NavigationStack` 2개, `navigationDestination` 2개, 모달 전환(`sheet`/`fullScreenCover`) 분산 사용처를 파일 단위로 확정. +- 무엇/왜/어떻게: `AppState`에 `AppRoute` 기반 `navigationPath`를 도입하고 `setAppStep/back`를 path push/pop/reset 브리지로 전환했다. `ContentView`는 단일 루트 `NavigationStack(path:)` + `navigationDestination(for: AppRoute)` 구조로 교체하고, 기존 화면 매핑은 `AppStepLayerView`로 유지해 UI를 고정했다. +- 실행 명령: `ast_grep_replace` (`NavigationView { $$$ } -> Group { $$$ }`, `NavigationStack { $$$ } -> Group { $$$ }`) +- 결과: 대상 20개 파일(18 `NavigationView`, 2 `NavigationStack`)의 로컬 컨테이너 제거 완료. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` +- 결과: 1차 `AppStep` `Equatable` 비교 오류 수정 후 재실행에서 `** BUILD SUCCEEDED **`. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` +- 결과: `** BUILD SUCCEEDED **`. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` +- 결과: `Scheme SodaLive is not currently configured for the test action.` +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` +- 결과: `Scheme SodaLive-dev is not currently configured for the test action.` +- 무엇/왜/어떻게: 언어 변경 적용 후 `AppState.softRestart()` 경로에서 스플래시가 `NavigationStack` 컨텍스트 안에 렌더링되며 네비게이션 바 안전영역만큼 아래로 밀리는 현상을 확인했다. 스플래시 화면에서 네비게이션 바를 숨기도록 수정해 오프셋을 제거했다. +- 실행 명령: background 분석 `explore`/`librarian` (`bg_ff67655b`, `bg_750d2d2e`, `bg_cb65fc63`) +- 결과: 공통 원인으로 `ContentView`의 단일 `NavigationStack` 내 스플래시 렌더링 시 nav bar inset 개입이 확인되었고, Apple 문서 기준 `toolbar(_:for:)`로 navigation bar를 숨기는 방식이 유효함을 확인. +- 실행 명령: `lsp_diagnostics` (`SodaLive/Sources/Splash/SplashView.swift`) +- 결과: SourceKit 인덱싱 컨텍스트에서 `FirebaseRemoteConfig` 모듈 미해석 오탐(`No such module 'FirebaseRemoteConfig'`) 발생, 빌드로 실제 유효성 확인. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` +- 결과: 두 스킴 모두 `** BUILD SUCCEEDED **`. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` +- 결과: 두 스킴 모두 `is not currently configured for the test action`으로 테스트 액션 미구성 상태. +- 무엇/왜/어떻게: 모든 페이지에서 시스템 기본 뒤로가기 버튼을 감추기 위해, 단일 루트 라우팅 지점인 `ContentView`의 `AppStepLayerView` 렌더링 2곳(루트 overlay / `navigationDestination`)에 공통으로 `.navigationBarBackButtonHidden(true)`를 적용했다. +- 실행 명령: `lsp_diagnostics` (`SodaLive/Sources/ContentView.swift`) +- 결과: SourceKit 인덱싱 컨텍스트 부재로 외부 타입 다수 미해석 오탐이 발생했고, 실제 유효성은 빌드로 확인. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` +- 결과: `** BUILD SUCCEEDED **` (중간 1회는 동시 빌드로 `build.db` lock 실패 후 단독 재실행 성공). +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` +- 결과: `** BUILD SUCCEEDED **`. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` +- 결과: 두 스킴 모두 `is not currently configured for the test action`으로 테스트 액션 미구성 상태 재확인. +- 실행 명령: `lsp_diagnostics` (수정 파일 일괄 점검) +- 결과: `ContentView.swift`/`AppState.swift` 등에서 SourceKit 워크스페이스 인덱싱 한계로 외부 타입 미해석 오탐이 발생했다. 실제 컴파일은 두 스킴 빌드 성공으로 검증. +- 무엇/왜/어떻게: 뒤로가기 시 스크롤 위치 초기화와 불필요 재로딩을 줄이기 위해, 목록/탭 화면의 `.onAppear` 무조건 조회를 초기 1회 가드로 정리하고 Series 계열 잔여 로컬 push를 `AppState` 경로 기반으로 통일했다. +- 실행 명령: `grep -n "NavigationLink\\s*\\(" "SodaLive/Sources/**/*.swift"`(동등 패턴 검색) +- 결과: `NavigationLink` 잔여는 댓글 답글 로컬 플로우 2건(`CreatorCommunityCommentItemView.swift`, `AudioContentCommentItemView.swift`)만 확인. +- 무엇/왜/어떻게: NavigationStack 마이그레이션 후 댓글 리스트(`AudioContentCommentListView`, `CreatorCommunityCommentListView`)가 `.sheet`로만 표시되면서 내부 `NavigationLink`가 네비게이션 컨텍스트 없이 렌더링되어 답글 보기/쓰기 진입이 무반응이 되었다. 두 시트 호출부(`ContentDetailView`, `CreatorCommunityAllView`)를 `NavigationStack`으로 감싸 reply push가 동작하도록 복구했다. +- 실행 명령: background 분석 `explore` (`bg_5ad57f11`, `bg_a7c4e178`) +- 결과: 공통 원인으로 “시트 내부 NavigationStack 부재 + item view의 NavigationLink 의존”이 확인됨. +- 실행 명령: `lsp_diagnostics` (`SodaLive/Sources/Content/Detail/ContentDetailView.swift`, `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift`) +- 결과: SourceKit 인덱싱 컨텍스트에서 외부 모듈/타입 미해석 오탐이 발생했고, 실제 유효성은 빌드로 확인. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` +- 결과: 두 스킴 모두 `** BUILD SUCCEEDED **`. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` +- 결과: 두 스킴 모두 `is not currently configured for the test action`으로 테스트 액션 미구성 상태. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` +- 결과: `** BUILD SUCCEEDED **`. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` +- 결과: `** BUILD SUCCEEDED **`. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` +- 결과: `Scheme SodaLive is not currently configured for the test action.` +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` +- 결과: `Scheme SodaLive-dev is not currently configured for the test action.` +- 실행 명령: `lsp_diagnostics` (이번 수정 8개 파일) +- 결과: SourceKit 인덱싱 컨텍스트 부재로 다수 오탐(`Cannot find ... in scope`)이 재현되었고, 문법/타입 안정성은 두 스킴 빌드 성공으로 최종 검증. +- 무엇/왜/어떻게: `UserProfileView`에서 콘텐츠 상세로 이동 후 뒤로 복귀 시 `.onAppear`가 매번 `getCreatorProfile`를 호출해 `creatorProfile = nil` 재초기화가 발생했고, 이로 인해 스크롤이 최상단으로 리셋되던 문제를 조회 조건 가드로 수정했다. +- 실행 명령: `lsp_diagnostics` (`SodaLive/Sources/Explorer/Profile/UserProfileView.swift`) +- 결과: SourceKit 인덱싱 컨텍스트에서 `Kingfisher` 모듈 미해석 오탐(`No such module 'Kingfisher'`)이 발생했으며, 실제 빌드로 유효성 확인. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` +- 결과: `** BUILD SUCCEEDED **`. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` +- 결과: `** BUILD SUCCEEDED **`. +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` +- 결과: `Scheme SodaLive is not currently configured for the test action.` +- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` +- 결과: `Scheme SodaLive-dev is not currently configured for the test action.`