# 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`를 실행한다.