feat(main): 메인 탭 화면을 추가한다
21
SodaLive/Resources/Assets.xcassets/ic_nav_chat.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_nav_chat.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/ic_nav_chat.imageset/ic_nav_chat.png
vendored
Normal file
|
After Width: | Height: | Size: 602 B |
21
SodaLive/Resources/Assets.xcassets/ic_nav_chat_selected.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_nav_chat_selected.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/ic_nav_chat_selected.imageset/ic_nav_chat_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 446 B |
21
SodaLive/Resources/Assets.xcassets/ic_nav_content.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_nav_content.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/ic_nav_content.imageset/ic_nav_content.png
vendored
Normal file
|
After Width: | Height: | Size: 537 B |
21
SodaLive/Resources/Assets.xcassets/ic_nav_content_selected.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_nav_content_selected.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/ic_nav_content_selected.imageset/ic_nav_content_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 378 B |
21
SodaLive/Resources/Assets.xcassets/ic_nav_home.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_nav_home.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/ic_nav_home.imageset/ic_nav_home.png
vendored
Normal file
|
After Width: | Height: | Size: 564 B |
21
SodaLive/Resources/Assets.xcassets/ic_nav_home_selected.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_nav_home_selected.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/ic_nav_home_selected.imageset/ic_nav_home_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 408 B |
21
SodaLive/Resources/Assets.xcassets/ic_nav_my.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_nav_my.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SodaLive/Resources/Assets.xcassets/ic_nav_my.imageset/ic_nav_my.png
vendored
Normal file
|
After Width: | Height: | Size: 723 B |
6
SodaLive/Resources/Assets.xcassets/v2/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ struct ContentView: View {
|
|||||||
if appState.isRestartApp {
|
if appState.isRestartApp {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
} else {
|
} else {
|
||||||
HomeView()
|
MainView()
|
||||||
}
|
}
|
||||||
|
|
||||||
if case .splash = appState.rootStep {
|
if case .splash = appState.rootStep {
|
||||||
|
|||||||
@@ -2983,6 +2983,7 @@ If you block this user, the following features will be restricted.
|
|||||||
|
|
||||||
enum Tab {
|
enum Tab {
|
||||||
static var home: String { pick(ko: "홈", en: "Home", ja: "ホーム") }
|
static var home: String { pick(ko: "홈", en: "Home", ja: "ホーム") }
|
||||||
|
static var content: String { pick(ko: "콘텐츠", en: "Content", ja: "コンテンツ") }
|
||||||
static var live: String { pick(ko: "라이브", en: "Live", ja: "ライブ") }
|
static var live: String { pick(ko: "라이브", en: "Live", ja: "ライブ") }
|
||||||
static var chat: String { pick(ko: "채팅", en: "Chat", ja: "チャット") }
|
static var chat: String { pick(ko: "채팅", en: "Chat", ja: "チャット") }
|
||||||
static var my: String { pick(ko: "마이", en: "My", ja: "マイ") }
|
static var my: String { pick(ko: "마이", en: "My", ja: "マイ") }
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ struct EventPopupDialogView: View {
|
|||||||
.padding(.horizontal, 26.7)
|
.padding(.horizontal, 26.7)
|
||||||
.padding(.bottom, 13.3)
|
.padding(.bottom, 13.3)
|
||||||
}
|
}
|
||||||
.background(Color(hex: "222222"))
|
.background(Color.black)
|
||||||
.cornerRadius(16.7, corners: [.topLeft, .topRight])
|
.cornerRadius(16.7, corners: [.topLeft, .topRight])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
SodaLive/Sources/V2/Main/MainPlaceholderTabView.swift
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
//
|
||||||
|
// MainPlaceholderTabView.swift
|
||||||
|
// SodaLive
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainPlaceholderTabView: View {
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.appFont(size: 20, weight: .bold)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainPlaceholderTabView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
MainPlaceholderTabView(title: "홈")
|
||||||
|
}
|
||||||
|
}
|
||||||
52
SodaLive/Sources/V2/Main/MainTab.swift
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
//
|
||||||
|
// MainTab.swift
|
||||||
|
// SodaLive
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum MainTab: CaseIterable, Hashable {
|
||||||
|
case home
|
||||||
|
case content
|
||||||
|
case chat
|
||||||
|
case my
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .home:
|
||||||
|
return I18n.Main.Tab.home
|
||||||
|
case .content:
|
||||||
|
return I18n.Main.Tab.content
|
||||||
|
case .chat:
|
||||||
|
return I18n.Main.Tab.chat
|
||||||
|
case .my:
|
||||||
|
return I18n.Main.Tab.my
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedIconName: String {
|
||||||
|
switch self {
|
||||||
|
case .home:
|
||||||
|
return "ic_nav_home_selected"
|
||||||
|
case .content:
|
||||||
|
return "ic_nav_content_selected"
|
||||||
|
case .chat:
|
||||||
|
return "ic_nav_chat_selected"
|
||||||
|
case .my:
|
||||||
|
return "ic_tabbar_my_selected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var unselectedIconName: String {
|
||||||
|
switch self {
|
||||||
|
case .home:
|
||||||
|
return "ic_nav_home"
|
||||||
|
case .content:
|
||||||
|
return "ic_nav_content"
|
||||||
|
case .chat:
|
||||||
|
return "ic_nav_chat"
|
||||||
|
case .my:
|
||||||
|
return "ic_nav_my"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
SodaLive/Sources/V2/Main/MainTabBarButton.swift
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//
|
||||||
|
// MainTabBarButton.swift
|
||||||
|
// SodaLive
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainTabBarButton: View {
|
||||||
|
let tab: MainTab
|
||||||
|
let isSelected: Bool
|
||||||
|
let width: CGFloat
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
ZStack(alignment: .center) {
|
||||||
|
Image(isSelected ? tab.selectedIconName : tab.unselectedIconName)
|
||||||
|
}
|
||||||
|
.frame(height: 24)
|
||||||
|
|
||||||
|
Text(tab.title)
|
||||||
|
.appFont(.caption3)
|
||||||
|
.foregroundColor(isSelected ? Color.white : Color.gray600)
|
||||||
|
.frame(height: 12, alignment: .bottom)
|
||||||
|
}
|
||||||
|
.frame(width: width, alignment: .center)
|
||||||
|
.frame(minHeight: 50)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainTabBarButton_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
MainTabBarButton(
|
||||||
|
tab: .home,
|
||||||
|
isSelected: true,
|
||||||
|
width: UIScreen.main.bounds.width / 4,
|
||||||
|
action: {}
|
||||||
|
)
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
SodaLive/Sources/V2/Main/MainTabBarView.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//
|
||||||
|
// MainTabBarView.swift
|
||||||
|
// SodaLive
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainTabBarView: View {
|
||||||
|
let width: CGFloat
|
||||||
|
@Binding var currentTab: MainTab
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
let tabWidth = width / CGFloat(MainTab.allCases.count)
|
||||||
|
|
||||||
|
ForEach(MainTab.allCases, id: \.self) { tab in
|
||||||
|
MainTabBarButton(
|
||||||
|
tab: tab,
|
||||||
|
isSelected: currentTab == tab,
|
||||||
|
width: tabWidth,
|
||||||
|
action: {
|
||||||
|
currentTab = tab
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
.background(Color.black.ignoresSafeArea(edges: .bottom))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainTabBarView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
MainTabBarView(
|
||||||
|
width: UIScreen.main.bounds.width,
|
||||||
|
currentTab: .constant(.home)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
447
SodaLive/Sources/V2/Main/MainView.swift
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
//
|
||||||
|
// MainView.swift
|
||||||
|
// SodaLive
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
import Bootpay
|
||||||
|
import BootpayUI
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
struct MainView: View {
|
||||||
|
@StateObject private var viewModel = MainViewModel()
|
||||||
|
@StateObject private var legacyHomeViewModel = HomeViewModel()
|
||||||
|
@StateObject private var liveViewModel = LiveViewModel()
|
||||||
|
@StateObject private var mypageViewModel = MyPageViewModel()
|
||||||
|
@StateObject private var appState = AppState.shared
|
||||||
|
@StateObject private var contentPlayManager = ContentPlayManager.shared
|
||||||
|
@StateObject private var contentPlayerPlayManager = ContentPlayerPlayManager.shared
|
||||||
|
|
||||||
|
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
|
||||||
|
@AppStorage("auth") private var auth: Bool = UserDefaults.bool(forKey: UserDefaultsKey.auth)
|
||||||
|
|
||||||
|
@State private var isShowPlayer = false
|
||||||
|
@State private var isShowAuthView = false
|
||||||
|
@State private var isShowAuthConfirmView = false
|
||||||
|
@State private var pendingAction: (() -> Void)? = nil
|
||||||
|
@State private var isShowLeaveLiveNavigationDialog = false
|
||||||
|
@State private var pendingExternalNavigationAction: (() -> Void)? = nil
|
||||||
|
@State private var pendingExternalNavigationCancelAction: (() -> Void)? = nil
|
||||||
|
@State private var payload = Payload()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
contentView
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding(.bottom, appState.isShowPlayer ? 72 : 0)
|
||||||
|
|
||||||
|
if contentPlayerPlayManager.isShowingMiniPlayer {
|
||||||
|
contentPlayerMiniPlayerView
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentPlayManager.isShowingMiniPlayer {
|
||||||
|
previewContentMiniPlayerView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
|
MainTabBarView(
|
||||||
|
width: proxy.size.width,
|
||||||
|
currentTab: $viewModel.currentTab
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
configurePayload()
|
||||||
|
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
pushTokenUpdate()
|
||||||
|
legacyHomeViewModel.getMemberInfo()
|
||||||
|
legacyHomeViewModel.getEventPopup()
|
||||||
|
legacyHomeViewModel.addAllPlaybackTracking()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if appState.isShowNotificationSettingsDialog {
|
||||||
|
NotificationSettingsDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
if isShowAuthConfirmView {
|
||||||
|
authConfirmDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
if liveViewModel.isShowPaymentDialog {
|
||||||
|
LivePaymentDialog(
|
||||||
|
title: liveViewModel.paymentDialogTitle,
|
||||||
|
desc: liveViewModel.paymentDialogDesc,
|
||||||
|
desc2: liveViewModel.paymentDialogDesc2,
|
||||||
|
confirmButtonTitle: liveViewModel.paymentDialogConfirmTitle,
|
||||||
|
confirmButtonAction: liveViewModel.paymentDialogConfirmAction,
|
||||||
|
cancelButtonTitle: liveViewModel.paymentDialogCancelTitle,
|
||||||
|
cancelButtonAction: liveViewModel.hidePopup,
|
||||||
|
startDateTime: liveViewModel.liveStartDate,
|
||||||
|
nowDateTime: liveViewModel.nowDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if liveViewModel.isShowPasswordDialog {
|
||||||
|
LiveRoomPasswordDialog(
|
||||||
|
isShowing: $liveViewModel.isShowPasswordDialog,
|
||||||
|
can: liveViewModel.secretOrPasswordDialogCan,
|
||||||
|
confirmAction: liveViewModel.passwordDialogConfirmAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let eventItem = appState.eventPopup {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
EventPopupDialogView(eventPopup: eventItem)
|
||||||
|
}
|
||||||
|
.background(Color.black)
|
||||||
|
.onTapGesture {
|
||||||
|
appState.eventPopup = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isShowPlayer {
|
||||||
|
ContentPlayerView(isShowing: $isShowPlayer, playlist: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
if appState.isShowPlayer {
|
||||||
|
LiveRoomViewV2()
|
||||||
|
}
|
||||||
|
|
||||||
|
if isShowLeaveLiveNavigationDialog {
|
||||||
|
leaveLiveNavigationDialog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $isShowAuthView) {
|
||||||
|
authView
|
||||||
|
}
|
||||||
|
.valueChanged(value: appState.pushRoomId) { handlePushRoomId($0) }
|
||||||
|
.valueChanged(value: appState.pushChannelId) { handlePushChannelId($0) }
|
||||||
|
.valueChanged(value: appState.pushMessageId) { handlePushMessageId($0) }
|
||||||
|
.valueChanged(value: appState.pushAudioContentId) { handlePushAudioContentId($0) }
|
||||||
|
.valueChanged(value: appState.pushSeriesId) { handlePushSeriesId($0) }
|
||||||
|
.valueChanged(value: appState.isShowPlayer) { isShowPlayer in
|
||||||
|
guard !isShowPlayer,
|
||||||
|
let pendingExternalNavigationAction = pendingExternalNavigationAction else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.pendingExternalNavigationAction = nil
|
||||||
|
self.pendingExternalNavigationCancelAction = nil
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
pendingExternalNavigationAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if appState.pushMessageId > 0 {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
appState.setAppStep(step: .message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sodaToast(
|
||||||
|
isPresented: $liveViewModel.isShowPopup,
|
||||||
|
message: liveViewModel.errorMessage,
|
||||||
|
autohideIn: 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var contentView: some View {
|
||||||
|
switch viewModel.currentTab {
|
||||||
|
case .home:
|
||||||
|
MainPlaceholderTabView(title: MainTab.home.title)
|
||||||
|
case .content:
|
||||||
|
MainPlaceholderTabView(title: MainTab.content.title)
|
||||||
|
case .chat:
|
||||||
|
MainPlaceholderTabView(title: MainTab.chat.title)
|
||||||
|
case .my:
|
||||||
|
MyPageView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var contentPlayerMiniPlayerView: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
KFImage(URL(string: contentPlayerPlayManager.coverImageUrl))
|
||||||
|
.cancelOnDisappear(true)
|
||||||
|
.downsampling(size: CGSize(width: 36.7, height: 36.7))
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 36.7, height: 36.7)
|
||||||
|
.cornerRadius(5.3)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2.3) {
|
||||||
|
Text(contentPlayerPlayManager.title)
|
||||||
|
.appFont(size: 13, weight: .medium)
|
||||||
|
.foregroundColor(Color.grayee)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Text(contentPlayerPlayManager.nickname)
|
||||||
|
.appFont(size: 11, weight: .medium)
|
||||||
|
.foregroundColor(Color.grayd2)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10.7)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(contentPlayerPlayManager.isPlaying ? "ic_noti_pause" : "btn_bar_play")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 25, height: 25)
|
||||||
|
.onTapGesture { contentPlayerPlayManager.playOrPause() }
|
||||||
|
|
||||||
|
Image("ic_noti_stop")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 25, height: 25)
|
||||||
|
.padding(.leading, 16)
|
||||||
|
.onTapGesture { contentPlayerPlayManager.resetPlayer() }
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10.7)
|
||||||
|
.padding(.horizontal, 13.3)
|
||||||
|
.background(Color.gray22)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { isShowPlayer = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var previewContentMiniPlayerView: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
KFImage(URL(string: contentPlayManager.coverImage))
|
||||||
|
.cancelOnDisappear(true)
|
||||||
|
.downsampling(size: CGSize(width: 36.7, height: 36.7))
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 36.7, height: 36.7)
|
||||||
|
.cornerRadius(5.3)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2.3) {
|
||||||
|
Text(contentPlayManager.title)
|
||||||
|
.appFont(size: 13, weight: .medium)
|
||||||
|
.foregroundColor(Color.grayee)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Text(contentPlayManager.nickname)
|
||||||
|
.appFont(size: 11, weight: .medium)
|
||||||
|
.foregroundColor(Color.grayd2)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10.7)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(contentPlayManager.isPlaying ? "ic_noti_pause" : "btn_bar_play")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 25, height: 25)
|
||||||
|
.onTapGesture {
|
||||||
|
if contentPlayManager.isPlaying {
|
||||||
|
contentPlayManager.pauseAudio()
|
||||||
|
} else {
|
||||||
|
contentPlayManager.playAudio(contentId: contentPlayManager.contentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Image("ic_noti_stop")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 25, height: 25)
|
||||||
|
.padding(.leading, 16)
|
||||||
|
.onTapGesture { contentPlayManager.stopAudio() }
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10.7)
|
||||||
|
.padding(.horizontal, 13.3)
|
||||||
|
.background(Color.gray22)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
appState.setAppStep(step: .contentDetail(contentId: contentPlayManager.contentId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var authConfirmDialog: some View {
|
||||||
|
SodaDialog(
|
||||||
|
title: I18n.Main.Auth.dialogTitle,
|
||||||
|
desc: I18n.Main.Auth.liveEntryVerificationDescription,
|
||||||
|
confirmButtonTitle: I18n.Main.Auth.goToVerification,
|
||||||
|
confirmButtonAction: {
|
||||||
|
isShowAuthConfirmView = false
|
||||||
|
isShowAuthView = true
|
||||||
|
},
|
||||||
|
cancelButtonTitle: I18n.Common.cancel,
|
||||||
|
cancelButtonAction: {
|
||||||
|
isShowAuthConfirmView = false
|
||||||
|
pendingAction = nil
|
||||||
|
},
|
||||||
|
textAlignment: .center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var leaveLiveNavigationDialog: some View {
|
||||||
|
SodaDialog(
|
||||||
|
title: I18n.Common.alertTitle,
|
||||||
|
desc: I18n.LiveRoom.leaveLiveForNavigationDesc,
|
||||||
|
confirmButtonTitle: I18n.Common.confirm,
|
||||||
|
confirmButtonAction: { confirmExternalNavigation() },
|
||||||
|
cancelButtonTitle: I18n.Common.cancel,
|
||||||
|
cancelButtonAction: { cancelExternalNavigation() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var authView: some View {
|
||||||
|
BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION)
|
||||||
|
.onConfirm { _ in true }
|
||||||
|
.onCancel { _ in isShowAuthView = false }
|
||||||
|
.onError { _ in
|
||||||
|
appState.errorMessage = I18n.Main.Auth.authenticationError
|
||||||
|
appState.isShowErrorPopup = true
|
||||||
|
isShowAuthView = false
|
||||||
|
}
|
||||||
|
.onDone {
|
||||||
|
DEBUG_LOG("onDone: \($0)")
|
||||||
|
mypageViewModel.authVerify($0) {
|
||||||
|
auth = true
|
||||||
|
isShowAuthView = false
|
||||||
|
if let action = pendingAction {
|
||||||
|
pendingAction = nil
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onClose { isShowAuthView = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configurePayload() {
|
||||||
|
payload.applicationId = BOOTPAY_APP_ID
|
||||||
|
payload.price = 0
|
||||||
|
payload.pg = "다날"
|
||||||
|
payload.method = "본인인증"
|
||||||
|
payload.orderName = "본인인증"
|
||||||
|
payload.authenticationId = "\(UserDefaults.string(forKey: .nickname))__\(String(NSTimeIntervalSince1970))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePushRoomId(_ value: Int) {
|
||||||
|
guard value > 0 else { return }
|
||||||
|
let roomId = value
|
||||||
|
let isPushRoomFromDeepLink = appState.isPushRoomFromDeepLink
|
||||||
|
appState.pushRoomId = 0
|
||||||
|
appState.isPushRoomFromDeepLink = false
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
handleExternalNavigationRequest(
|
||||||
|
value: roomId,
|
||||||
|
navigationAction: {
|
||||||
|
if !isPushRoomFromDeepLink { appState.setAppStep(step: .main) }
|
||||||
|
liveViewModel.enterLiveRoom(roomId: roomId)
|
||||||
|
},
|
||||||
|
cancelAction: {
|
||||||
|
appState.pushRoomId = 0
|
||||||
|
appState.isPushRoomFromDeepLink = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePushChannelId(_ value: Int) {
|
||||||
|
guard value > 0 else { return }
|
||||||
|
let channelId = value
|
||||||
|
appState.pushChannelId = 0
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
handleExternalNavigationRequest(
|
||||||
|
value: channelId,
|
||||||
|
navigationAction: {
|
||||||
|
appState.setAppStep(step: .main)
|
||||||
|
appState.setAppStep(step: .creatorDetail(userId: channelId))
|
||||||
|
},
|
||||||
|
cancelAction: { appState.pushChannelId = 0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePushMessageId(_ value: Int) {
|
||||||
|
guard value > 0 else { return }
|
||||||
|
let messageId = value
|
||||||
|
appState.pushMessageId = 0
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
handleExternalNavigationRequest(
|
||||||
|
value: messageId,
|
||||||
|
navigationAction: {
|
||||||
|
appState.setAppStep(step: .main)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
appState.setAppStep(step: .message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelAction: { appState.pushMessageId = 0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePushAudioContentId(_ value: Int) {
|
||||||
|
guard value > 0 else { return }
|
||||||
|
let contentId = value
|
||||||
|
appState.pushAudioContentId = 0
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
handleExternalNavigationRequest(
|
||||||
|
value: contentId,
|
||||||
|
navigationAction: {
|
||||||
|
appState.setAppStep(step: .main)
|
||||||
|
appState.setAppStep(step: .contentDetail(contentId: contentId))
|
||||||
|
},
|
||||||
|
cancelAction: { appState.pushAudioContentId = 0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePushSeriesId(_ value: Int) {
|
||||||
|
guard value > 0 else { return }
|
||||||
|
let seriesId = value
|
||||||
|
appState.pushSeriesId = 0
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
handleExternalNavigationRequest(
|
||||||
|
value: seriesId,
|
||||||
|
navigationAction: {
|
||||||
|
appState.setAppStep(step: .main)
|
||||||
|
appState.setAppStep(step: .seriesDetail(seriesId: seriesId))
|
||||||
|
},
|
||||||
|
cancelAction: { appState.pushSeriesId = 0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleExternalNavigationRequest(
|
||||||
|
value: Int,
|
||||||
|
navigationAction: @escaping () -> Void,
|
||||||
|
cancelAction: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
guard value > 0 else { return }
|
||||||
|
if appState.isShowPlayer {
|
||||||
|
pendingExternalNavigationAction = navigationAction
|
||||||
|
pendingExternalNavigationCancelAction = cancelAction
|
||||||
|
isShowLeaveLiveNavigationDialog = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigationAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmExternalNavigation() {
|
||||||
|
guard pendingExternalNavigationAction != nil else {
|
||||||
|
isShowLeaveLiveNavigationDialog = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isShowLeaveLiveNavigationDialog = false
|
||||||
|
NotificationCenter.default.post(name: .requestLiveRoomQuitForExternalNavigation, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelExternalNavigation() {
|
||||||
|
isShowLeaveLiveNavigationDialog = false
|
||||||
|
pendingExternalNavigationAction = nil
|
||||||
|
pendingExternalNavigationCancelAction?()
|
||||||
|
pendingExternalNavigationCancelAction = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pushTokenUpdate() {
|
||||||
|
let pushToken = UserDefaults.string(forKey: .pushToken)
|
||||||
|
if !pushToken.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
|
legacyHomeViewModel.pushTokenUpdate(pushToken: pushToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
MainView()
|
||||||
|
}
|
||||||
|
}
|
||||||
11
SodaLive/Sources/V2/Main/MainViewModel.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//
|
||||||
|
// MainViewModel.swift
|
||||||
|
// SodaLive
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class MainViewModel: ObservableObject {
|
||||||
|
@Published var currentTab: MainTab = .home
|
||||||
|
}
|
||||||
655
docs/plan-task/20260519_메인페이지신규개발.md
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
# Main Page Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** `홈`, `콘텐츠`, `채팅`, `마이` 4개 하단 탭을 가진 신규 메인 페이지를 `SodaLive/Sources/V2/Main/**` 아래에 구현하고 앱 루트에 연결한다.
|
||||||
|
|
||||||
|
**Architecture:** 신규 `MainView`를 기존 `HomeView`와 독립된 메인 컨테이너로 만든다. 탭 상태, 탭바, 빈 탭 화면을 V2/Main 모듈로 분리하고, 기존 `HomeView`의 미니 플레이어/인증/팝업 흐름은 새 컨테이너에 필요한 범위만 이관한다. `마이` 탭은 기존 `MyPageView`를 그대로 재사용한다.
|
||||||
|
|
||||||
|
**Tech Stack:** SwiftUI, Combine, `AppState` 기반 전역 `NavigationStack`, Xcode project source membership, 기존 `I18n` 문자열 시스템
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기준 문서
|
||||||
|
|
||||||
|
- PRD: `docs/prd/20260519_메인페이지신규개발_PRD.md`
|
||||||
|
- 검증 가이드: `docs/agent-guides/build-test-verification.md`
|
||||||
|
- 코드 스타일: `docs/agent-guides/code-style.md`
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
### 생성
|
||||||
|
|
||||||
|
- `SodaLive/Sources/V2/Main/MainTab.swift`: 4개 탭 정의, 타이틀, 선택/미선택 아이콘 매핑
|
||||||
|
- `SodaLive/Sources/V2/Main/MainViewModel.swift`: 현재 선택 탭 상태 관리
|
||||||
|
- `SodaLive/Sources/V2/Main/MainView.swift`: 신규 메인 루트 컨테이너, 탭 콘텐츠 전환, 미니 플레이어/팝업 이관 지점
|
||||||
|
- `SodaLive/Sources/V2/Main/MainTabBarView.swift`: 하단 탭바 HStack 구성
|
||||||
|
- `SodaLive/Sources/V2/Main/MainTabBarButton.swift`: 탭 버튼 단일 UI, 정렬 규칙 적용
|
||||||
|
- `SodaLive/Sources/V2/Main/MainPlaceholderTabView.swift`: 검정 배경 + 탭명 표시 빈 페이지
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
|
||||||
|
- `SodaLive/Sources/ContentView.swift`: 루트 화면을 `HomeView()`에서 `MainView()`로 변경
|
||||||
|
- `SodaLive/Sources/I18n/I18n.swift`: `I18n.Main.Tab.content` 추가
|
||||||
|
- `SodaLive.xcodeproj/project.pbxproj`: 신규 Swift 파일 6개를 `SodaLive`, `SodaLive-dev` 앱 타깃 Sources에 포함
|
||||||
|
|
||||||
|
### 수정하지 않음
|
||||||
|
|
||||||
|
- `SodaLive/Sources/MyPage/MyPageView.swift`: 기존 마이페이지는 재사용만 한다.
|
||||||
|
- `SodaLive/Sources/Main/Home/HomeView.swift`: 신규 `MainView` 연결이 안정화되기 전에는 삭제하거나 대규모 정리하지 않는다.
|
||||||
|
- `SodaLive/Sources/App/AppState.swift`, `SodaLive/Sources/App/AppStep.swift`: `.main` 의미와 전역 라우팅 구조는 유지한다.
|
||||||
|
|
||||||
|
## 구현 체크리스트
|
||||||
|
|
||||||
|
### Task 1: `MainTab` 모델 작성
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `SodaLive/Sources/V2/Main/MainTab.swift`
|
||||||
|
|
||||||
|
- [x] **Step 1: `MainTab.swift` 생성**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
//
|
||||||
|
// MainTab.swift
|
||||||
|
// SodaLive
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum MainTab: CaseIterable, Hashable {
|
||||||
|
case home
|
||||||
|
case content
|
||||||
|
case chat
|
||||||
|
case my
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .home:
|
||||||
|
return I18n.Main.Tab.home
|
||||||
|
case .content:
|
||||||
|
return I18n.Main.Tab.content
|
||||||
|
case .chat:
|
||||||
|
return I18n.Main.Tab.chat
|
||||||
|
case .my:
|
||||||
|
return I18n.Main.Tab.my
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedIconName: String {
|
||||||
|
switch self {
|
||||||
|
case .home:
|
||||||
|
return "ic_nav_home_selected"
|
||||||
|
case .content:
|
||||||
|
return "ic_nav_content_selected"
|
||||||
|
case .chat:
|
||||||
|
return "ic_nav_chat_selected"
|
||||||
|
case .my:
|
||||||
|
return "ic_tabbar_my_selected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var unselectedIconName: String {
|
||||||
|
switch self {
|
||||||
|
case .home:
|
||||||
|
return "ic_nav_home"
|
||||||
|
case .content:
|
||||||
|
return "ic_nav_content"
|
||||||
|
case .chat:
|
||||||
|
return "ic_nav_chat"
|
||||||
|
case .my:
|
||||||
|
return "ic_nav_my"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: 정적 진단 실행**
|
||||||
|
|
||||||
|
Run: `lsp_diagnostics("SodaLive/Sources/V2/Main/MainTab.swift")`
|
||||||
|
|
||||||
|
Expected: 신규 파일 자체의 Swift 문법 오류가 없어야 한다. SourceKit이 프로젝트 컨텍스트를 완전히 잡지 못해 외부 심볼 오류를 보고하면 이후 빌드로 최종 검증한다.
|
||||||
|
|
||||||
|
### Task 2: 다국어 탭 라벨 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
|
||||||
|
- [x] **Step 1: `I18n.Main.Tab.content` 추가**
|
||||||
|
|
||||||
|
`I18n.Main.Tab`에 아래 항목을 추가한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
static var content: String { pick(ko: "콘텐츠", en: "Content", ja: "コンテンツ") }
|
||||||
|
```
|
||||||
|
|
||||||
|
적용 후 `I18n.Main.Tab`은 아래 형태가 된다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum Tab {
|
||||||
|
static var home: String { pick(ko: "홈", en: "Home", ja: "ホーム") }
|
||||||
|
static var content: String { pick(ko: "콘텐츠", en: "Content", ja: "コンテンツ") }
|
||||||
|
static var live: String { pick(ko: "라이브", en: "Live", ja: "ライブ") }
|
||||||
|
static var chat: String { pick(ko: "채팅", en: "Chat", ja: "チャット") }
|
||||||
|
static var my: String { pick(ko: "마이", en: "My", ja: "マイ") }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: 정적 진단 실행**
|
||||||
|
|
||||||
|
Run: `lsp_diagnostics("SodaLive/Sources/I18n/I18n.swift")`
|
||||||
|
|
||||||
|
Expected: 변경 구간의 중괄호/스코프 오류가 없어야 한다.
|
||||||
|
|
||||||
|
### Task 3: 빈 탭 페이지 작성
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `SodaLive/Sources/V2/Main/MainPlaceholderTabView.swift`
|
||||||
|
|
||||||
|
- [x] **Step 1: 검정 배경 + 탭명 표시 View 생성**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
//
|
||||||
|
// MainPlaceholderTabView.swift
|
||||||
|
// SodaLive
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainPlaceholderTabView: View {
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.appFont(size: 20, weight: .bold)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainPlaceholderTabView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
MainPlaceholderTabView(title: "홈")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: 정적 진단 실행**
|
||||||
|
|
||||||
|
Run: `lsp_diagnostics("SodaLive/Sources/V2/Main/MainPlaceholderTabView.swift")`
|
||||||
|
|
||||||
|
Expected: 문법 오류가 없어야 한다.
|
||||||
|
|
||||||
|
### Task 4: 탭 버튼 UI 작성
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `SodaLive/Sources/V2/Main/MainTabBarButton.swift`
|
||||||
|
|
||||||
|
- [x] **Step 1: 정렬 기준을 반영한 버튼 View 생성**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
//
|
||||||
|
// MainTabBarButton.swift
|
||||||
|
// SodaLive
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainTabBarButton: View {
|
||||||
|
let tab: MainTab
|
||||||
|
let isSelected: Bool
|
||||||
|
let width: CGFloat
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
ZStack(alignment: .center) {
|
||||||
|
Image(isSelected ? tab.selectedIconName : tab.unselectedIconName)
|
||||||
|
}
|
||||||
|
.frame(height: 24)
|
||||||
|
|
||||||
|
Text(tab.title)
|
||||||
|
.appFont(size: 10, weight: isSelected ? .bold : .medium)
|
||||||
|
.foregroundColor(isSelected ? Color.button : Color.graybb)
|
||||||
|
.frame(height: 12, alignment: .bottom)
|
||||||
|
}
|
||||||
|
.frame(width: width, minHeight: 50, alignment: .center)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainTabBarButton_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
MainTabBarButton(
|
||||||
|
tab: .home,
|
||||||
|
isSelected: true,
|
||||||
|
width: UIScreen.main.bounds.width / 4,
|
||||||
|
action: {}
|
||||||
|
)
|
||||||
|
.background(Color.gray11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: 정적 진단 실행**
|
||||||
|
|
||||||
|
Run: `lsp_diagnostics("SodaLive/Sources/V2/Main/MainTabBarButton.swift")`
|
||||||
|
|
||||||
|
Expected: 문법 오류가 없어야 한다.
|
||||||
|
|
||||||
|
### Task 5: 하단 탭바 작성
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `SodaLive/Sources/V2/Main/MainTabBarView.swift`
|
||||||
|
|
||||||
|
- [x] **Step 1: 4분할 탭바 View 생성**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
//
|
||||||
|
// MainTabBarView.swift
|
||||||
|
// SodaLive
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainTabBarView: View {
|
||||||
|
let width: CGFloat
|
||||||
|
@Binding var currentTab: MainTab
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
let tabWidth = width / CGFloat(MainTab.allCases.count)
|
||||||
|
|
||||||
|
ForEach(MainTab.allCases, id: \.self) { tab in
|
||||||
|
MainTabBarButton(
|
||||||
|
tab: tab,
|
||||||
|
isSelected: currentTab == tab,
|
||||||
|
width: tabWidth,
|
||||||
|
action: {
|
||||||
|
currentTab = tab
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
.background(Color.gray11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainTabBarView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
MainTabBarView(
|
||||||
|
width: UIScreen.main.bounds.width,
|
||||||
|
currentTab: .constant(.home)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: 정적 진단 실행**
|
||||||
|
|
||||||
|
Run: `lsp_diagnostics("SodaLive/Sources/V2/Main/MainTabBarView.swift")`
|
||||||
|
|
||||||
|
Expected: 문법 오류가 없어야 한다.
|
||||||
|
|
||||||
|
### Task 6: 메인 ViewModel 작성
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `SodaLive/Sources/V2/Main/MainViewModel.swift`
|
||||||
|
|
||||||
|
- [x] **Step 1: 탭 상태 ViewModel 생성**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
//
|
||||||
|
// MainViewModel.swift
|
||||||
|
// SodaLive
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class MainViewModel: ObservableObject {
|
||||||
|
@Published var currentTab: MainTab = .home
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: 정적 진단 실행**
|
||||||
|
|
||||||
|
Run: `lsp_diagnostics("SodaLive/Sources/V2/Main/MainViewModel.swift")`
|
||||||
|
|
||||||
|
Expected: 문법 오류가 없어야 한다.
|
||||||
|
|
||||||
|
### Task 7: 신규 `MainView` 1차 작성
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `SodaLive/Sources/V2/Main/MainView.swift`
|
||||||
|
|
||||||
|
- [x] **Step 1: 탭 전환 컨테이너 생성**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
//
|
||||||
|
// MainView.swift
|
||||||
|
// SodaLive
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainView: View {
|
||||||
|
@StateObject private var viewModel = MainViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
contentView
|
||||||
|
|
||||||
|
MainTabBarView(
|
||||||
|
width: proxy.size.width,
|
||||||
|
currentTab: $viewModel.currentTab
|
||||||
|
)
|
||||||
|
|
||||||
|
if proxy.safeAreaInsets.bottom > 0 {
|
||||||
|
Rectangle()
|
||||||
|
.foregroundColor(Color.gray11)
|
||||||
|
.frame(width: proxy.size.width, height: 15.3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var contentView: some View {
|
||||||
|
switch viewModel.currentTab {
|
||||||
|
case .home:
|
||||||
|
MainPlaceholderTabView(title: MainTab.home.title)
|
||||||
|
case .content:
|
||||||
|
MainPlaceholderTabView(title: MainTab.content.title)
|
||||||
|
case .chat:
|
||||||
|
MainPlaceholderTabView(title: MainTab.chat.title)
|
||||||
|
case .my:
|
||||||
|
MyPageView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
MainView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: 정적 진단 실행**
|
||||||
|
|
||||||
|
Run: `lsp_diagnostics("SodaLive/Sources/V2/Main/MainView.swift")`
|
||||||
|
|
||||||
|
Expected: 문법 오류가 없어야 한다.
|
||||||
|
|
||||||
|
### Task 8: 기존 메인 주변 기능 이관
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `SodaLive/Sources/V2/Main/MainView.swift`
|
||||||
|
- Reference: `SodaLive/Sources/Main/Home/HomeView.swift`
|
||||||
|
|
||||||
|
- [x] **Step 1: 미니 플레이어 상태와 매니저 이관**
|
||||||
|
|
||||||
|
`MainView`에 기존 `HomeView`의 플레이어 관련 상태를 추가한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@StateObject private var appState = AppState.shared
|
||||||
|
@StateObject private var contentPlayManager = ContentPlayManager.shared
|
||||||
|
@StateObject private var contentPlayerPlayManager = ContentPlayerPlayManager.shared
|
||||||
|
|
||||||
|
@State private var isShowPlayer = false
|
||||||
|
```
|
||||||
|
|
||||||
|
`VStack`에서 콘텐츠와 탭바 사이에 기존 `HomeView`의 두 미니 플레이어 블록을 동일한 조건으로 배치한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
if contentPlayerPlayManager.isShowingMiniPlayer {
|
||||||
|
contentPlayerMiniPlayerView
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentPlayManager.isShowingMiniPlayer {
|
||||||
|
previewContentMiniPlayerView
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`contentPlayerMiniPlayerView`는 `HomeView.swift`의 `contentPlayerPlayManager` 기반 블록과 동일한 동작을 유지한다. `previewContentMiniPlayerView`는 `contentPlayManager` 기반 블록과 동일하게 `contentDetail` 이동, 재생/일시정지, 정지를 처리한다.
|
||||||
|
|
||||||
|
- [x] **Step 2: 본인인증 관련 상태와 Bootpay payload 이관**
|
||||||
|
|
||||||
|
기존 `HomeView`의 인증 관련 상태를 `MainView`에 추가한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
|
||||||
|
@AppStorage("auth") private var auth: Bool = UserDefaults.bool(forKey: UserDefaultsKey.auth)
|
||||||
|
|
||||||
|
@State private var isShowAuthView = false
|
||||||
|
@State private var isShowAuthConfirmView = false
|
||||||
|
@State private var pendingAction: (() -> Void)? = nil
|
||||||
|
@State private var payload = Payload()
|
||||||
|
```
|
||||||
|
|
||||||
|
`Bootpay`, `BootpayUI` import가 필요한 경우 `MainView.swift` 상단에 추가한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import Bootpay
|
||||||
|
import BootpayUI
|
||||||
|
```
|
||||||
|
|
||||||
|
`onAppear`에서 기존 payload 초기화 값을 유지한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
payload.applicationId = BOOTPAY_APP_ID
|
||||||
|
payload.price = 0
|
||||||
|
payload.pg = "다날"
|
||||||
|
payload.method = "본인인증"
|
||||||
|
payload.orderName = "본인인증"
|
||||||
|
payload.authenticationId = "\(UserDefaults.string(forKey: .nickname))__\(String(NSTimeIntervalSince1970))"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: 전역 팝업/다이얼로그 이관**
|
||||||
|
|
||||||
|
기존 `HomeView`에서 메인 루트 차원에 표시되던 아래 UI를 `MainView`의 최상위 `ZStack`으로 옮긴다.
|
||||||
|
|
||||||
|
- `NotificationSettingsDialog()`
|
||||||
|
- 본인인증 안내 `SodaDialog`
|
||||||
|
- `LivePaymentDialog`
|
||||||
|
- `LiveRoomPasswordDialog`
|
||||||
|
- `appState.eventPopup` 기반 이벤트 팝업
|
||||||
|
|
||||||
|
`LivePaymentDialog`, `LiveRoomPasswordDialog` 유지에 필요한 `LiveViewModel`은 기존처럼 `MainView`가 소유한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@StateObject private var liveViewModel = LiveViewModel()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: 사용자 정보 갱신/이벤트 팝업 조회 이관 범위 결정대로 반영**
|
||||||
|
|
||||||
|
기존 `HomeView`의 `HomeViewModel` 의존을 신규 `MainViewModel`로 무리하게 옮기지 않는다. 기존 메인 진입 시 필요한 부수효과가 있으면 `HomeViewModel`을 임시로 `MainView`에서 소유해 기존 메서드를 호출한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@StateObject private var legacyHomeViewModel = HomeViewModel()
|
||||||
|
```
|
||||||
|
|
||||||
|
`onAppear`에서 기존 호출을 유지한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
pushTokenUpdate()
|
||||||
|
legacyHomeViewModel.getMemberInfo()
|
||||||
|
legacyHomeViewModel.getEventPopup()
|
||||||
|
legacyHomeViewModel.addAllPlaybackTracking()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`pushTokenUpdate()`는 기존 `HomeView`의 메서드를 동일하게 옮겨 `Messaging.messaging().token`을 조회하고 `legacyHomeViewModel.pushTokenUpdate(pushToken:)`를 호출한다.
|
||||||
|
|
||||||
|
- [x] **Step 5: 정적 진단 실행**
|
||||||
|
|
||||||
|
Run: `lsp_diagnostics("SodaLive/Sources/V2/Main/MainView.swift")`
|
||||||
|
|
||||||
|
Expected: 문법 오류가 없어야 한다. 외부 SDK 심볼 인식 한계가 있으면 빌드로 최종 검증한다.
|
||||||
|
|
||||||
|
### Task 9: 앱 루트 연결
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `SodaLive/Sources/ContentView.swift`
|
||||||
|
|
||||||
|
- [x] **Step 1: 루트 화면 교체**
|
||||||
|
|
||||||
|
`ContentView`의 루트 렌더링을 아래처럼 변경한다.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
if appState.isRestartApp {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
MainView()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: 정적 진단 실행**
|
||||||
|
|
||||||
|
Run: `lsp_diagnostics("SodaLive/Sources/ContentView.swift")`
|
||||||
|
|
||||||
|
Expected: `MainView` 참조 오류가 없어야 한다. Xcode project membership이 아직 반영되지 않아 인식 실패하면 Task 10 이후 다시 확인한다.
|
||||||
|
|
||||||
|
### Task 10: Xcode project membership 반영
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `SodaLive.xcodeproj/project.pbxproj`
|
||||||
|
|
||||||
|
- [x] **Step 1: 신규 Swift 파일을 프로젝트에 포함**
|
||||||
|
|
||||||
|
아래 6개 파일을 Xcode project의 `SodaLive` 및 `SodaLive-dev` Sources build phase에 포함한다.
|
||||||
|
|
||||||
|
- `MainTab.swift`
|
||||||
|
- `MainViewModel.swift`
|
||||||
|
- `MainView.swift`
|
||||||
|
- `MainTabBarView.swift`
|
||||||
|
- `MainTabBarButton.swift`
|
||||||
|
- `MainPlaceholderTabView.swift`
|
||||||
|
|
||||||
|
기존 `project.pbxproj`는 동일 파일이 두 앱 타깃에 각각 `PBXBuildFile`로 등록되는 패턴을 사용한다. 새 파일도 같은 패턴으로 추가한다.
|
||||||
|
|
||||||
|
- [x] **Step 2: 프로젝트 파일 참조 확인**
|
||||||
|
|
||||||
|
Run: `rg -n "MainTab.swift|MainViewModel.swift|MainView.swift|MainTabBarView.swift|MainTabBarButton.swift|MainPlaceholderTabView.swift" "SodaLive.xcodeproj/project.pbxproj"`
|
||||||
|
|
||||||
|
Expected: 각 파일명이 `PBXFileReference`, `PBXBuildFile`, `PBXSourcesBuildPhase` 구간에 나타난다.
|
||||||
|
|
||||||
|
### Task 11: 기능 검증
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify: `SodaLive/Sources/V2/Main/*.swift`
|
||||||
|
- Verify: `SodaLive/Sources/ContentView.swift`
|
||||||
|
- Verify: `SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
- Verify: `SodaLive.xcodeproj/project.pbxproj`
|
||||||
|
|
||||||
|
- [x] **Step 1: 변경 파일 정적 진단 실행**
|
||||||
|
|
||||||
|
Run each:
|
||||||
|
|
||||||
|
```text
|
||||||
|
lsp_diagnostics("SodaLive/Sources/V2/Main/MainTab.swift")
|
||||||
|
lsp_diagnostics("SodaLive/Sources/V2/Main/MainViewModel.swift")
|
||||||
|
lsp_diagnostics("SodaLive/Sources/V2/Main/MainView.swift")
|
||||||
|
lsp_diagnostics("SodaLive/Sources/V2/Main/MainTabBarView.swift")
|
||||||
|
lsp_diagnostics("SodaLive/Sources/V2/Main/MainTabBarButton.swift")
|
||||||
|
lsp_diagnostics("SodaLive/Sources/V2/Main/MainPlaceholderTabView.swift")
|
||||||
|
lsp_diagnostics("SodaLive/Sources/ContentView.swift")
|
||||||
|
lsp_diagnostics("SodaLive/Sources/I18n/I18n.swift")
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 변경으로 인한 Swift 문법/타입 오류가 없어야 한다. SourceKit이 외부 모듈을 못 찾는 기존 한계는 빌드 결과로 판정한다.
|
||||||
|
|
||||||
|
- [x] **Step 2: 앱 빌드 실행**
|
||||||
|
|
||||||
|
Run: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
|
||||||
|
|
||||||
|
Expected: `** BUILD SUCCEEDED **`
|
||||||
|
|
||||||
|
- [x] **Step 3: dev 앱 빌드 실행**
|
||||||
|
|
||||||
|
Run: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
|
||||||
|
|
||||||
|
Expected: `** BUILD SUCCEEDED **`
|
||||||
|
|
||||||
|
- [x] **Step 4: 테스트 액션 상태 확인**
|
||||||
|
|
||||||
|
Run: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
|
||||||
|
|
||||||
|
Expected: 현재 저장소 상태상 테스트 번들 타깃이 없으면 `Scheme SodaLive is not currently configured for the test action.`가 나올 수 있다. 이 경우 빌드 성공을 컴파일 검증 기준으로 삼고, 테스트 액션 부재를 검증 기록에 남긴다.
|
||||||
|
|
||||||
|
### Task 12: 수동 확인 항목
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify: 실행 앱 화면
|
||||||
|
|
||||||
|
- [x] **Step 1: 메인 탭 표시 확인**
|
||||||
|
|
||||||
|
확인 기준:
|
||||||
|
|
||||||
|
- 하단 탭 순서가 `홈`, `콘텐츠`, `채팅`, `마이`다.
|
||||||
|
- 각 탭은 화면 폭의 1/4을 사용한다.
|
||||||
|
- 아이콘과 타이틀은 가로 가운데 정렬이다.
|
||||||
|
- 타이틀 하단 라인이 탭 간 흔들리지 않는다.
|
||||||
|
|
||||||
|
- [x] **Step 2: 탭별 화면 확인**
|
||||||
|
|
||||||
|
확인 기준:
|
||||||
|
|
||||||
|
- `홈` 탭: 검정 배경, `홈` 텍스트 표시
|
||||||
|
- `콘텐츠` 탭: 검정 배경, `콘텐츠` 텍스트 표시
|
||||||
|
- `채팅` 탭: 검정 배경, `채팅` 텍스트 표시
|
||||||
|
- `마이` 탭: 기존 `MyPageView` 표시
|
||||||
|
|
||||||
|
- [x] **Step 3: 아이콘 매핑 확인**
|
||||||
|
|
||||||
|
확인 기준:
|
||||||
|
|
||||||
|
- `홈`: `ic_nav_home_selected` / `ic_nav_home`
|
||||||
|
- `콘텐츠`: `ic_nav_content_selected` / `ic_nav_content`
|
||||||
|
- `채팅`: `ic_nav_chat_selected` / `ic_nav_chat`
|
||||||
|
- `마이`: `ic_tabbar_my_selected` / `ic_nav_my`
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
- 계획 문서 작성 단계에서는 코드 구현을 수행하지 않았다.
|
||||||
|
- 계획 작성 전 확인한 근거 파일: `ContentView.swift`, `HomeView.swift`, `HomeViewModel.swift`, `BottomTabView.swift`, `TabButton.swift`, `MyPageView.swift`, `I18n.swift`, `SodaLive.xcodeproj/project.pbxproj`.
|
||||||
|
- 구현 완료 후 검증 기록은 이 섹션 아래에 실행 명령과 결과를 누적한다.
|
||||||
|
- 무엇/왜/어떻게: 신규 `V2/Main` Swift 파일 6개, `I18n.Main.Tab.content`, `ContentView` 루트 연결, Xcode project membership을 계획 문서 기준으로 구현했다.
|
||||||
|
- 실행 명령: `lsp_diagnostics` (`SodaLive/Sources/V2/Main`, `ContentView.swift`, `I18n.swift`)
|
||||||
|
- 결과: SourceKit 단독 컨텍스트 한계로 기존 프로젝트 심볼/외부 모듈 인식 오류가 보고되었고, 실제 컴파일 유효성은 빌드로 검증했다.
|
||||||
|
- 실행 명령: `rg -n "MainTab.swift|MainViewModel.swift|MainView.swift|MainTabBarView.swift|MainTabBarButton.swift|MainPlaceholderTabView.swift|41A00013" "SodaLive.xcodeproj/project.pbxproj"`
|
||||||
|
- 결과: 신규 파일 6개가 `PBXFileReference`, `PBXBuildFile`, `V2/Main` 그룹, 두 앱 타깃 `PBXSourcesBuildPhase`에 포함되어 있음을 확인했다.
|
||||||
|
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -list`
|
||||||
|
- 결과: workspace/project 파싱 성공, `SodaLive`, `SodaLive-dev` 스킴 확인.
|
||||||
|
- 실행 명령: `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" -configuration Debug -destination "platform=iOS Simulator,name=iPhone 17,OS=26.0" build`
|
||||||
|
- 결과: `** BUILD SUCCEEDED **`.
|
||||||
|
- 실행 명령: `xcrun simctl boot "iPhone 17" && xcrun simctl install "iPhone 17" ".../Debug-iphonesimulator/SodaLive-dev.app" && xcrun simctl launch "iPhone 17" "kr.co.vividnext.sodalive.debug2"`
|
||||||
|
- 결과: 앱 설치 및 실행 성공, launch pid `75253` 확인.
|
||||||
|
- 실행 명령: `xcrun simctl io "iPhone 17" screenshot "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sodalive-main.png"`
|
||||||
|
- 결과: 시뮬레이터 실행 화면 스크린샷 저장 완료.
|
||||||
|
- 무엇/왜/어떻게: 구현 후 코드 리뷰를 요청해 요구사항 누락과 품질 이슈를 점검했다.
|
||||||
|
- 실행 명령/도구: `task(category="deep")` 코드 리뷰 및 follow-up 검토
|
||||||
|
- 결과: 현재 범위는 `홈/콘텐츠/채팅/마이` 중 `홈/콘텐츠/채팅`이 placeholder이므로 Bootpay 인증 트리거가 없는 것은 blocking issue가 아니며, 향후 실제 라이브/성인 콘텐츠 진입 UI 연결 시 `isShowAuthConfirmView`/`pendingAction` 경로를 연결해야 한다는 non-blocking future integration note로 정리했다.
|
||||||
|
|
||||||
|
## 커밋 정책
|
||||||
|
|
||||||
|
- 이 계획 실행 중 커밋은 사용자가 명시적으로 요청한 경우에만 수행한다.
|
||||||
|
- 커밋 요청이 있으면 먼저 `commit-policy` 스킬을 로드하고, 커밋 직후 `work/scripts/check-commit-message-rules.sh`를 실행한다.
|
||||||
147
docs/prd/20260519_메인페이지신규개발_PRD.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# PRD: 메인 페이지 신규 개발
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
앱 진입 후 표시되는 메인 페이지를 하단 내비게이션 4개 탭 구조로 정리한다. 탭은 `홈`, `콘텐츠`, `채팅`, `마이` 순서로 제공하며, `마이`는 기존 `MyPageView`를 재사용하고 나머지 탭은 신규 빈 페이지로 시작한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Background
|
||||||
|
|
||||||
|
현재 앱의 전역 진입 흐름은 `SodaLiveApp` → `ContentView` → `HomeView` 구조다. `ContentView`는 `NavigationStack(path: $appState.navigationPath)`로 전역 화면 전환을 관리하고, `.main` 상태에서는 별도 화면을 push하지 않고 항상 `HomeView()`를 루트 화면으로 노출한다.
|
||||||
|
|
||||||
|
현재 메인 탭 관련 구현은 아래 파일에 분산되어 있다.
|
||||||
|
|
||||||
|
- `SodaLive/Sources/Main/Home/HomeView.swift`: 현재 메인 루트 컨테이너와 미니 플레이어, 하단 탭 포함
|
||||||
|
- `SodaLive/Sources/Main/Home/HomeViewModel.swift`: 현재 탭 상태 `CurrentTab = home, chat, live, mypage`
|
||||||
|
- `SodaLive/Sources/Main/Home/BottomTabView.swift`: 하단 탭 4개 UI
|
||||||
|
- `SodaLive/Sources/Main/Home/TabButton.swift`: 탭 버튼 공용 UI
|
||||||
|
- `SodaLive/Sources/MyPage/MyPageView.swift`: 신규 `마이` 탭에서 재사용할 기존 마이페이지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
|
||||||
|
- 메인 페이지가 하단 내비게이션 4개 탭을 제공한다.
|
||||||
|
- 탭 순서는 `홈`, `콘텐츠`, `채팅`, `마이`로 고정한다.
|
||||||
|
- 탭 선택 시 각 탭에 대응하는 페이지가 표시된다.
|
||||||
|
- `마이` 탭은 기존 `SodaLive/Sources/MyPage/MyPageView.swift`를 그대로 표시한다.
|
||||||
|
- `홈`, `콘텐츠`, `채팅` 탭은 신규 빈 페이지를 만들어 표시한다.
|
||||||
|
- 하단 내비게이션 아이콘은 요청된 `ic_nav_*` 계열을 사용한다.
|
||||||
|
- 현재 없는 `ic_nav_my_selected`는 임시로 `ic_tabbar_my_selected`를 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
|
||||||
|
- 신규 `홈`, `콘텐츠`, `채팅` 탭의 실제 콘텐츠 구현은 하지 않는다.
|
||||||
|
- 기존 마이페이지 내부 UI/비즈니스 로직은 변경하지 않는다.
|
||||||
|
- 전역 라우팅 구조(`AppState`, `AppStep`, `ContentView`)의 대규모 개편은 하지 않는다.
|
||||||
|
- 미니 플레이어, 팝업, 본인인증, 딥링크 등 기존 메인 주변 기능의 동작 변경은 하지 않는다.
|
||||||
|
- 누락된 실제 이미지 리소스 `ic_nav_my_selected`를 새로 제작하거나 추가하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Core Requirements
|
||||||
|
|
||||||
|
### 5.1 하단 내비게이션 탭
|
||||||
|
|
||||||
|
하단 내비게이션은 4개 탭으로 구성한다.
|
||||||
|
|
||||||
|
| 순서 | 탭 | 표시 페이지 | 구현 상태 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | 홈 | 신규 빈 페이지 | 신규 생성 |
|
||||||
|
| 2 | 콘텐츠 | 신규 빈 페이지 | 신규 생성 |
|
||||||
|
| 3 | 채팅 | 신규 빈 페이지 | 신규 생성 |
|
||||||
|
| 4 | 마이 | `MyPageView` | 기존 재사용 |
|
||||||
|
|
||||||
|
### 5.2 탭 선택 상태
|
||||||
|
|
||||||
|
탭을 선택하면 선택된 탭의 페이지를 메인 콘텐츠 영역에 표시한다. 선택된 탭과 선택되지 않은 탭의 아이콘은 아래 매핑을 따른다.
|
||||||
|
|
||||||
|
| 탭 | 선택 아이콘 | 미선택 아이콘 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 홈 | `ic_nav_home_selected` | `ic_nav_home` |
|
||||||
|
| 콘텐츠 | `ic_nav_content_selected` | `ic_nav_content` |
|
||||||
|
| 채팅 | `ic_nav_chat_selected` | `ic_nav_chat` |
|
||||||
|
| 마이 | `ic_tabbar_my_selected` 임시 사용 | `ic_nav_my` |
|
||||||
|
|
||||||
|
`ic_nav_my_selected` 리소스가 추가되면 `마이` 탭 선택 아이콘은 `ic_tabbar_my_selected`에서 `ic_nav_my_selected`로 교체한다.
|
||||||
|
|
||||||
|
### 5.3 탭 버튼 정렬
|
||||||
|
|
||||||
|
탭 버튼의 아이콘과 타이틀 정렬 우선순위는 아래 기준을 따른다.
|
||||||
|
|
||||||
|
- 가로 정렬 1순위: 탭 버튼 내부 콘텐츠는 가운데 정렬한다.
|
||||||
|
- 세로 정렬 1순위: 아이콘과 타이틀 묶음은 가운데 정렬한다.
|
||||||
|
- 세로 정렬 2순위: 탭별 타이틀의 하단 라인을 맞춘다.
|
||||||
|
|
||||||
|
구현 시 탭 버튼별 콘텐츠 높이가 달라도 타이틀 기준선이 흔들리지 않도록 아이콘 영역 높이와 타이틀 영역 높이를 명확히 분리한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Recommended Approach
|
||||||
|
|
||||||
|
### 접근안 A: 기존 `HomeView`/`BottomTabView` 구조를 최소 변경으로 확장
|
||||||
|
|
||||||
|
현재 앱 구조와 가장 잘 맞는다. `HomeView`가 이미 메인 루트 컨테이너 역할을 하고 있고, `BottomTabView`가 4개 탭을 `width / 4`로 고정 렌더링하고 있으므로 신규 요구사항을 반영하기 쉽다.
|
||||||
|
|
||||||
|
장점은 변경 범위가 작고 기존 미니 플레이어, 전역 팝업, `AppState.startTab` 연동을 유지하기 쉽다는 점이다. 단점은 현재 `HomeView`가 이미 많은 책임을 갖고 있어, 구현 시 탭 콘텐츠 분리 경계를 문서대로 지키지 않으면 파일이 더 커질 수 있다.
|
||||||
|
|
||||||
|
### 접근안 B: 신규 `MainView` 컨테이너를 만들고 `ContentView`에서 교체
|
||||||
|
|
||||||
|
신규 메인 페이지의 의미가 명확해진다. 기존 `HomeView`를 직접 확장하지 않고 `MainView`, `MainTab`, `MainTabBarView` 등으로 책임을 분리할 수 있다.
|
||||||
|
|
||||||
|
장점은 구조가 명확하고 새 요구사항을 독립적으로 구현하기 좋다는 점이다. 단점은 기존 `HomeView`에 포함된 미니 플레이어, 팝업, 인증, 이벤트 팝업 흐름을 이관해야 하므로 문서 단계의 요구사항보다 구현 영향이 커질 수 있다.
|
||||||
|
|
||||||
|
### 접근안 C: SwiftUI 기본 `TabView(selection:)` 기반으로 전환
|
||||||
|
|
||||||
|
SwiftUI 표준 탭 내비게이션 방식이다. 탭별 독립 상태 관리와 접근성 측면에서 유리하다.
|
||||||
|
|
||||||
|
단, 현재 프로젝트는 커스텀 하단 바와 전역 `NavigationStack`/`AppStep` 구조를 이미 사용하고 있으므로 기본 `TabView` 전환은 기존 UI/안전영역/미니 플레이어 연동 방식과 충돌할 수 있다.
|
||||||
|
|
||||||
|
### 권장
|
||||||
|
|
||||||
|
접근안 B로 결정한다. 새 요구사항을 기존 `HomeView`에 덧붙이기보다 신규 `MainView` 컨테이너에서 독립적으로 구현한 뒤, 기존 `HomeView`에 있던 미니 플레이어, 인증, 팝업 등 메인 주변 기능을 필요한 범위만 이관하는 방향이 낫다고 판단한다. 구현 계획 단계에서는 신규 파일을 `SodaLive/Sources/V2/Main/**` 아래에 작성하고, `ContentView`의 루트 렌더링 지점에서 신규 메인 컨테이너를 사용하도록 연결한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Technical Constraints
|
||||||
|
|
||||||
|
- `AppStep.main`은 기존처럼 메인 복귀 의미로 유지한다.
|
||||||
|
- `ContentView`의 전역 `NavigationStack` 구조와 `AppStepLayerView` 매핑은 변경하지 않는다.
|
||||||
|
- 현재 `HomeViewModel.CurrentTab`에는 `content`가 없고 `live`가 있으므로, 구현 시 탭 enum을 `home, content, chat, mypage`로 변경하거나 신규 메인 탭 enum을 도입해야 한다.
|
||||||
|
- `I18n.Main.Tab`에는 현재 `home`, `live`, `chat`, `my`만 있으므로 `content` 라벨을 추가해야 한다.
|
||||||
|
- `ic_nav_my_selected`는 현재 에셋에 없으므로 구현 시 `ic_tabbar_my_selected`를 임시 사용한다.
|
||||||
|
- 신규로 생성되는 메인 페이지 관련 파일은 모두 `SodaLive/Sources/V2/Main/**` 아래에 둔다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Success Criteria
|
||||||
|
|
||||||
|
- 문서 기준 구현 후 앱 메인 화면에 하단 탭 4개가 표시된다.
|
||||||
|
- 탭 순서가 `홈`, `콘텐츠`, `채팅`, `마이`와 일치한다.
|
||||||
|
- 각 탭 선택 시 대응 페이지가 표시된다.
|
||||||
|
- `마이` 탭 선택 시 기존 `MyPageView`가 표시된다.
|
||||||
|
- `홈`, `콘텐츠`, `채팅` 탭 선택 시 빈 페이지가 표시된다.
|
||||||
|
- 탭 아이콘 선택/미선택 매핑이 요구사항과 일치한다.
|
||||||
|
- `마이` 선택 아이콘은 임시로 `ic_tabbar_my_selected`를 사용한다.
|
||||||
|
- 탭 버튼 내부 콘텐츠는 가로/세로 가운데 정렬을 우선하고, 타이틀 하단 라인이 맞는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Decisions
|
||||||
|
|
||||||
|
- 신규 빈 페이지는 검정 배경을 사용하고 탭명만 표시한다.
|
||||||
|
- 신규로 생성되는 파일은 모두 `SodaLive/Sources/V2/Main/**` 아래에 둔다.
|
||||||
|
- 메인 컨테이너는 접근안 B에 따라 신규 구현 후 기존 미니 플레이어, 인증, 팝업 등 필요한 주변 기능을 이관한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Verification Notes
|
||||||
|
|
||||||
|
- 문서 작성 전 `SodaLive/Sources/MyPage/MyPageView.swift`를 확인해 `마이` 탭 재사용 대상을 검증했다.
|
||||||
|
- `SodaLive/Sources/Main/Home/HomeView.swift`, `HomeViewModel.swift`, `BottomTabView.swift`, `TabButton.swift`를 확인해 현재 메인/탭 구조를 검증했다.
|
||||||
|
- `SodaLive/Sources/App/AppState.swift`, `AppStep.swift`, `ContentView.swift`를 확인해 `.main` 라우팅과 전역 내비게이션 구조를 검증했다.
|
||||||
|
- `SodaLive/Resources/Assets.xcassets` 검색으로 `ic_nav_home(_selected)`, `ic_nav_content(_selected)`, `ic_nav_chat(_selected)`, `ic_nav_my`, `ic_tabbar_my_selected` 존재와 `ic_nav_my_selected` 부재를 확인했다.
|
||||||
|
- `SodaLive/Sources/I18n/I18n.swift`를 확인해 `I18n.Main.Tab.content`가 아직 없음을 확인했다.
|
||||||