From 1a5df53edb00bf68a1bdd7c1590b17d624674dea Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 19 May 2026 15:54:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(main):=20=EB=A9=94=EC=9D=B8=20=ED=83=AD=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ic_nav_chat.imageset/Contents.json | 21 + .../ic_nav_chat.imageset/ic_nav_chat.png | Bin 0 -> 602 bytes .../Contents.json | 21 + .../ic_nav_chat_selected.png | Bin 0 -> 446 bytes .../ic_nav_content.imageset/Contents.json | 21 + .../ic_nav_content.png | Bin 0 -> 537 bytes .../Contents.json | 21 + .../ic_nav_content_selected.png | Bin 0 -> 378 bytes .../ic_nav_home.imageset/Contents.json | 21 + .../ic_nav_home.imageset/ic_nav_home.png | Bin 0 -> 564 bytes .../Contents.json | 21 + .../ic_nav_home_selected.png | Bin 0 -> 408 bytes .../ic_nav_my.imageset/Contents.json | 21 + .../ic_nav_my.imageset/ic_nav_my.png | Bin 0 -> 723 bytes .../Assets.xcassets/v2/Contents.json | 6 + SodaLive/Sources/ContentView.swift | 2 +- SodaLive/Sources/I18n/I18n.swift | 1 + .../Sources/Main/EventPopupDialogView.swift | 2 +- .../V2/Main/MainPlaceholderTabView.swift | 27 + SodaLive/Sources/V2/Main/MainTab.swift | 52 ++ .../Sources/V2/Main/MainTabBarButton.swift | 45 ++ SodaLive/Sources/V2/Main/MainTabBarView.swift | 40 ++ SodaLive/Sources/V2/Main/MainView.swift | 447 ++++++++++++ SodaLive/Sources/V2/Main/MainViewModel.swift | 11 + docs/plan-task/20260519_메인페이지신규개발.md | 655 ++++++++++++++++++ docs/prd/20260519_메인페이지신규개발_PRD.md | 147 ++++ 26 files changed, 1580 insertions(+), 2 deletions(-) create mode 100644 SodaLive/Resources/Assets.xcassets/ic_nav_chat.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_nav_chat.imageset/ic_nav_chat.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_nav_chat_selected.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_nav_chat_selected.imageset/ic_nav_chat_selected.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_nav_content.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_nav_content.imageset/ic_nav_content.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_nav_content_selected.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_nav_content_selected.imageset/ic_nav_content_selected.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_nav_home.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_nav_home.imageset/ic_nav_home.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_nav_home_selected.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_nav_home_selected.imageset/ic_nav_home_selected.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_nav_my.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_nav_my.imageset/ic_nav_my.png create mode 100644 SodaLive/Resources/Assets.xcassets/v2/Contents.json create mode 100644 SodaLive/Sources/V2/Main/MainPlaceholderTabView.swift create mode 100644 SodaLive/Sources/V2/Main/MainTab.swift create mode 100644 SodaLive/Sources/V2/Main/MainTabBarButton.swift create mode 100644 SodaLive/Sources/V2/Main/MainTabBarView.swift create mode 100644 SodaLive/Sources/V2/Main/MainView.swift create mode 100644 SodaLive/Sources/V2/Main/MainViewModel.swift create mode 100644 docs/plan-task/20260519_메인페이지신규개발.md create mode 100644 docs/prd/20260519_메인페이지신규개발_PRD.md diff --git a/SodaLive/Resources/Assets.xcassets/ic_nav_chat.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_nav_chat.imageset/Contents.json new file mode 100644 index 0000000..0221024 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_nav_chat.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_nav_chat.imageset/ic_nav_chat.png b/SodaLive/Resources/Assets.xcassets/ic_nav_chat.imageset/ic_nav_chat.png new file mode 100644 index 0000000000000000000000000000000000000000..4e2afd3430b75c2872b3e16464264d75700434f5 GIT binary patch literal 602 zcmV-g0;T%U&Fs#Mi5|ED?b?P~t!5gA z@wVITa$y)|HBHMx9q795Z@1e?r_%`%5mc+y91Qm{E%G;v`)pVckkkVn);=x~oh7K( z>!0x06`s1Q)oSFr?o!=jSynckPM0ukV3-(AyWMVHC9rLq4>%YD9I)sH;2Ir7);LFE zM2wdqT!h?2$Tr=G`F!pb3I%XzPClPs;QLFi>5;wc#Sq3FiV1PX! ziLp2#0LIL&K2Th6^)mrF!1RPbd7uU*1PpUw2CY_WB6ZO>m|?t90*DUq4hPGsK1#sG zJ&FPDNvTwdjays?XcZ%M{n(56?!i7qn5E5T^9}jJEgCuxau)5DY&et2ykm{8#e}SM zyInWxfTn~vI51um oDf}$9a~k^V^?Dp_;}68f51Q@K0rxjhD*ylh07*qoM6N<$f_2pRcK`qY literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/ic_nav_chat_selected.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_nav_chat_selected.imageset/Contents.json new file mode 100644 index 0000000..7257b4a --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_nav_chat_selected.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_nav_chat_selected.imageset/ic_nav_chat_selected.png b/SodaLive/Resources/Assets.xcassets/ic_nav_chat_selected.imageset/ic_nav_chat_selected.png new file mode 100644 index 0000000000000000000000000000000000000000..6a9f13fc1b892200985e9ee68accf2ce2bd552d7 GIT binary patch literal 446 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL zyA#8@b22Z19F}xPUq=Rpjs4tz5?O(Kz7p4nlHmNblJdl&R0g-q^xVXG8>b}$by<44 zIEGX(z75Ttc*sED?!7q;7CS6-IoI2%l~^of zFJ#?)-LOi6)y&v-Vq*f!^{ASPbg3}S!+}kiCk|)_w(`h0eD=vMJJ7UzLSu|xrq@#S zErBbPxn-rqGCXFBEvZ$Ru<`7o%TIh8uF3muoW$|r-lTHzv!DInSD!IW*V`&)wP>sI zKZ9wX+mgCn>%G3-2uRb?op{cQFE4Sv1yu(3`L9_yHFrY zE(QPCw(|G;y^;IAM$WxBgZw7{!t4ye3bVDYr}#V^4z0|Ac%k5AGWk9# zB%j4iMB8s9R(IhN_^aTNMK{lCgxU@W84$rjQ67wq^5n#wE*hh1)oL|Ilmzd@ZnulX b4cCc(7xKS)OC55`00000NkvXXu0mjfK0W1j literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/ic_nav_content_selected.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_nav_content_selected.imageset/Contents.json new file mode 100644 index 0000000..2dee7b4 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_nav_content_selected.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_nav_content_selected.imageset/ic_nav_content_selected.png b/SodaLive/Resources/Assets.xcassets/ic_nav_content_selected.imageset/ic_nav_content_selected.png new file mode 100644 index 0000000000000000000000000000000000000000..d911ac0be7d52b73187501379941df759f958d00 GIT binary patch literal 378 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL zyA#8@b22Z19F}xPUq=Rpjs4tz5?O(Kz7p4nlHmNblJdl&R0g-q^xVXG8>b}$Wxjg4 zIEGX(z74r3*kr(yJ9k0e1l|%Bvj*M-=G~3TC)jp*-fa*}U@mByHSeL`^woR6HOoJ} z`&9XD^;vJO(5&P2hpO`pW---?n(X|eP#{svSkE^r=F`>sfR#Q0?2KmZHc59P>yBk8 z@8M&PxEHLhR$Afg*HE;eN$*hUmh>4D8}vdIc1W~%pP#$QR^fP9$^GJcjxYQqxKC&I zEWcK@h*|G-;})}tX|5*}LlXplayOq3asS~ym0RlKi6Zah1`hFz8r^8=Cml;x2v5&3 z(vx&KXcc;3!sFQmzup?#ZvJy{x4-Dd+5a+B3TE4isu;A&n(TYB@%G2)3g(0wAFs;= SXRiVSfWgz%&t;ucLK6TOtd8mc literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/ic_nav_home.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_nav_home.imageset/Contents.json new file mode 100644 index 0000000..9a9296a --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_nav_home.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_nav_home.imageset/ic_nav_home.png b/SodaLive/Resources/Assets.xcassets/ic_nav_home.imageset/ic_nav_home.png new file mode 100644 index 0000000000000000000000000000000000000000..2bc02eab242623978b6f4e2be1b73dceb9df4c74 GIT binary patch literal 564 zcmV-40?Yl0P)3R+2H8U7AP48-8x^>{ zqyrBw?m!4SY&M%um;n*Z8@q>zCOn-^)A`0ZBe5JD4hIgCOi!?lVKU(8^!y|ed)*1* z2rLmr29gXuAE9SNXp_Fv9)>Uz6n$t01iLgkO9)Q}D2K=r0@>X|HqJf4bwP*v#6*9w z-=T>E2_mEEe|qBi7gZ`18I5fv$Yf=|wkf+J4t@aR*3a)h!IJR+0000b}$bqRaA zIEGX(z72WH+vLFWR`UO8wq1>V2RQN;NWWlvz_O{)g3+QeXMuEsu!8V|>=h?jpFaNa zbzoBiSWE6Vw3f4?~SCs zg?a}LuAP<=9B4C5!uVvt0YS+oD@hkS|0LNgX3tXvD^)GRe!GRmK8Rz=HDWn^qr%Xl zcAn5Hj+q+*wx7+2*mL0M4Awe>s>aH>!c}q~9<1D3r=kQW3GR+lzDza$BgplOhGCK4=?Zgp&mG&cPE?i-GFxo*!N4h zr?iQR8qF(NtmS_4*^{HGw>+JWZgDj^$Rhvf-N*e6ZgTe~DWM4fQr)3Z literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/ic_nav_my.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_nav_my.imageset/Contents.json new file mode 100644 index 0000000..72a0357 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_nav_my.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_nav_my.imageset/ic_nav_my.png b/SodaLive/Resources/Assets.xcassets/ic_nav_my.imageset/ic_nav_my.png new file mode 100644 index 0000000000000000000000000000000000000000..30eefebdfd055b1d9e2666940a7a2fd83e183239 GIT binary patch literal 723 zcmV;^0xbQBP)jB5S7GML2;5JutE?Ktcbu%1d<~_7k~Mmv8Q>uAXW2^y}{FX<62HC%eobj$^l0 ztNp^#Kb=mu=R>yr0{(YA9{)}zlk`gf;2&-` zrLv+UY@hb~{U1=K+w^)p4UyG(l|B z7!f^#v+Z`<)T8eA`w_%XW(Dx|Xf#@?Z!BlETID%+x7#)JbIt{tZ9h;la>Mx?Vh-m% zoj@)`cP6oI5gb6V?$a?+4Pms@%Oa6SQm)2$JZ^GL-C!^Xm7Sb<0=Jn_4giq_vRvp9 z0PqeXnNFu`rQ;g<4LHzk*$m83+WHbe(%+bu3f-%;sb%OhoVn3;QYxPJa{!=Q9LTIZ zB?KQ1hj&WR?RFVqfXiN8u}@*|sm%|ogiv!{Xn;OnLnd;`jBu%UQW5DHynwv|kO<}y+m-lMi2j%K7OE%?N+bXL$sxD zX2)#!;Pio~#s+n{3?u?NZi5>zaPTr&F@JsiSt z#mkgdY!=NUHHR38*fO(6-KKEZNw?5>VtqgW2^cv`Ryyo-5Jn~2g*iS*#{VMw^tjd{ z^JMs)FOIKVdx#!c_E-NGr2qy{U>Uzmhz&dMt1bO~=nYhHeQu;eHKzao002ovPDHLk FV1oTvJ3RmZ literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/v2/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 13b9e31..8c61eb7 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -22,7 +22,7 @@ struct ContentView: View { if appState.isRestartApp { EmptyView() } else { - HomeView() + MainView() } if case .splash = appState.rootStep { diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index beeea80..6b8ffe1 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -2983,6 +2983,7 @@ If you block this user, the following features will be restricted. 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: "マイ") } diff --git a/SodaLive/Sources/Main/EventPopupDialogView.swift b/SodaLive/Sources/Main/EventPopupDialogView.swift index 85b11fe..04f4acb 100644 --- a/SodaLive/Sources/Main/EventPopupDialogView.swift +++ b/SodaLive/Sources/Main/EventPopupDialogView.swift @@ -56,7 +56,7 @@ struct EventPopupDialogView: View { .padding(.horizontal, 26.7) .padding(.bottom, 13.3) } - .background(Color(hex: "222222")) + .background(Color.black) .cornerRadius(16.7, corners: [.topLeft, .topRight]) } } diff --git a/SodaLive/Sources/V2/Main/MainPlaceholderTabView.swift b/SodaLive/Sources/V2/Main/MainPlaceholderTabView.swift new file mode 100644 index 0000000..efe6010 --- /dev/null +++ b/SodaLive/Sources/V2/Main/MainPlaceholderTabView.swift @@ -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: "홈") + } +} diff --git a/SodaLive/Sources/V2/Main/MainTab.swift b/SodaLive/Sources/V2/Main/MainTab.swift new file mode 100644 index 0000000..7388d9a --- /dev/null +++ b/SodaLive/Sources/V2/Main/MainTab.swift @@ -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" + } + } +} diff --git a/SodaLive/Sources/V2/Main/MainTabBarButton.swift b/SodaLive/Sources/V2/Main/MainTabBarButton.swift new file mode 100644 index 0000000..149f0a9 --- /dev/null +++ b/SodaLive/Sources/V2/Main/MainTabBarButton.swift @@ -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) + } +} diff --git a/SodaLive/Sources/V2/Main/MainTabBarView.swift b/SodaLive/Sources/V2/Main/MainTabBarView.swift new file mode 100644 index 0000000..3d7161a --- /dev/null +++ b/SodaLive/Sources/V2/Main/MainTabBarView.swift @@ -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) + ) + } +} diff --git a/SodaLive/Sources/V2/Main/MainView.swift b/SodaLive/Sources/V2/Main/MainView.swift new file mode 100644 index 0000000..be03891 --- /dev/null +++ b/SodaLive/Sources/V2/Main/MainView.swift @@ -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() + } +} diff --git a/SodaLive/Sources/V2/Main/MainViewModel.swift b/SodaLive/Sources/V2/Main/MainViewModel.swift new file mode 100644 index 0000000..052a847 --- /dev/null +++ b/SodaLive/Sources/V2/Main/MainViewModel.swift @@ -0,0 +1,11 @@ +// +// MainViewModel.swift +// SodaLive +// + +import Combine +import Foundation + +final class MainViewModel: ObservableObject { + @Published var currentTab: MainTab = .home +} diff --git a/docs/plan-task/20260519_메인페이지신규개발.md b/docs/plan-task/20260519_메인페이지신규개발.md new file mode 100644 index 0000000..06d9c7a --- /dev/null +++ b/docs/plan-task/20260519_메인페이지신규개발.md @@ -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`를 실행한다. diff --git a/docs/prd/20260519_메인페이지신규개발_PRD.md b/docs/prd/20260519_메인페이지신규개발_PRD.md new file mode 100644 index 0000000..4571aca --- /dev/null +++ b/docs/prd/20260519_메인페이지신규개발_PRD.md @@ -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`가 아직 없음을 확인했다.