// // ContentView.swift // Haru_Danim // // 스플래시 + 탭 구조 (CLAUDE.md §5, 탭바 구성은 설정에서 변경 가능) // import SwiftUI import SwiftData struct ContentView: View { @Environment(\.modelContext) private var context @AppStorage(SettingsKeys.theme) private var theme: String = "light" @State private var showSplash = true var body: some View { ZStack { #if DEBUG // 검증용: -widgetPreview YES(홈 위젯) / lock(잠금화면 위젯) → 위젯 미리보기 화면 if let mode = UserDefaults.standard.string(forKey: "widgetPreview") { WidgetPreviewScreen(showLock: mode == "lock") } else { MainTabView() } #else MainTabView() #endif if showSplash { SplashView() .transition(.opacity) .zIndex(1) } } .tint(AppTheme.green) .preferredColorScheme(theme == "dark" ? .dark : .light) .task { #if DEBUG DebugSeed.seedIfRequested(context: context) DebugSeed.autoStartIfRequested(context: context) DebugSeed.pinGoalsIfRequested(context: context) #endif try? await Task.sleep(for: .seconds(1.2)) withAnimation(.easeOut(duration: 0.4)) { showSplash = false } } } } // MARK: - 스플래시 (앱 로고 + 앱 이름 + 브랜드 멘트, CLAUDE.md §5.1) struct SplashView: View { @State private var appeared = false var body: some View { ZStack { // 로고 배경(웜 화이트)과 이어지도록 라이트/다크 공통 배경 사용 AppTheme.background.ignoresSafeArea() VStack(spacing: 18) { Image("AppLogo") .resizable() .scaledToFit() .frame(width: 150, height: 150) .clipShape(RoundedRectangle(cornerRadius: 34, style: .continuous)) .shadow(color: .black.opacity(0.12), radius: 14, y: 6) .scaleEffect(appeared ? 1 : 0.85) .opacity(appeared ? 1 : 0) Text("하루다님", comment: "앱 이름") .font(.largeTitle.bold()) .foregroundStyle(AppTheme.green) .opacity(appeared ? 1 : 0) Text("당신의 하루에 다녀가다", comment: "스플래시 브랜드 멘트") .font(.subheadline) .foregroundStyle(.secondary) .opacity(appeared ? 1 : 0) } } .onAppear { withAnimation(.spring(duration: 0.5)) { appeared = true } } } } // MARK: - 탭 간 이동 라우터 /// 탭 전환과 탭 간 전달값 관리 (예: 모음 탭 '기록 확인' → 기록 탭 행동 필터) @Observable final class AppRouter { var tabSelection: String = { #if DEBUG if let raw = UserDefaults.standard.string(forKey: "startTab") { return raw } #endif return AppTab.main.rawValue }() /// 기록 탭이 열릴 때 적용할 행동 필터 (기록 탭에서 소비 후 nil로 되돌림) var pendingHistoryActionID: PersistentIdentifier? /// 통계 탭이 열릴 때 적용할 행동 필터 (통계 탭에서 소비 후 nil로 되돌림) var pendingStatsActionID: PersistentIdentifier? /// 더보기 탭 내부 내비게이션 경로. 더보기에 숨은 탭으로 바로 진입할 때 라우터가 채워 넣는다. var morePath: [AppTab] = [] /// 기록 탭으로 이동하며 행동 필터를 예약. 기록 탭이 노출 탭에 없으면 더보기 안의 기록 화면까지 바로 진입. func openHistory(filtering actionID: PersistentIdentifier, visibleTabsRaw: String) { pendingHistoryActionID = actionID open(.history, visibleTabsRaw: visibleTabsRaw) } /// 통계 탭으로 이동하며 행동 필터를 예약. 통계 탭이 노출 탭에 없으면 더보기 안의 통계 화면까지 바로 진입. func openStats(filtering actionID: PersistentIdentifier, visibleTabsRaw: String) { pendingStatsActionID = actionID open(.stats, visibleTabsRaw: visibleTabsRaw) } private func open(_ tab: AppTab, visibleTabsRaw: String) { let visible = AppTab.visibleTabs(from: visibleTabsRaw) if visible.contains(tab) { tabSelection = tab.rawValue } else { morePath = [tab] tabSelection = AppTab.moreTabValue } } } // MARK: - 탭 정의 enum AppTab: String, CaseIterable, Identifiable { case main, action, tag, goal, history, stats, settings static let moreTabValue = "more" var id: String { rawValue } var label: String { switch self { case .main: return "모음" case .action: return "행동" case .tag: return "꼬리표" case .goal: return "목표" case .history: return "기록" case .stats: return "통계" case .settings: return "설정" } } var symbol: String { switch self { case .main: return "square.grid.2x2.fill" case .action: return "figure.walk" case .tag: return "tag.fill" case .goal: return "flag.checkered" case .history: return "calendar" case .stats: return "chart.xyaxis.line" case .settings: return "gearshape.fill" } } /// 저장 문자열("main,action,history")을 스펙 순서를 유지한 탭 배열로 변환 static func visibleTabs(from raw: String) -> [AppTab] { let chosen = Set(raw.split(separator: ",").map(String.init)) let tabs = AppTab.allCases.filter { chosen.contains($0.rawValue) } return tabs.isEmpty ? [.main] : Array(tabs.prefix(TabBarConfig.maxVisible)) } @ViewBuilder var rootView: some View { switch self { case .main: MainView() case .action: ActionListView() case .tag: TagListView() case .goal: GoalListView() case .history: HistoryView() case .stats: StatsTabView() case .settings: SettingsView() } } } // MARK: - 탭 구조 (노출 탭 1~3개 + 더보기, 시스템 탭바 = 리퀴드 글라스) struct MainTabView: View { @AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs @State private var router = AppRouter() private var visibleTabs: [AppTab] { AppTab.visibleTabs(from: visibleTabsRaw) } private var moreTabs: [AppTab] { AppTab.allCases.filter { !visibleTabs.contains($0) } } var body: some View { TabView(selection: $router.tabSelection) { ForEach(visibleTabs) { tab in Tab(tab.label, systemImage: tab.symbol, value: tab.rawValue) { NavigationStack { tab.rootView } } } Tab("더보기", systemImage: "ellipsis", value: AppTab.moreTabValue) { MoreTabView(tabs: moreTabs) } } .environment(router) .onChange(of: visibleTabsRaw) { // 노출 탭에서 빠진 탭이 선택돼 있으면 더보기로 이동 let valid = visibleTabs.map(\.rawValue) + [AppTab.moreTabValue] if !valid.contains(router.tabSelection) { router.tabSelection = AppTab.moreTabValue } } .onAppear { #if DEBUG // 검증용: -startTab이 노출 탭에 없으면 더보기 안의 그 화면까지 바로 진입 let valid = visibleTabs.map(\.rawValue) + [AppTab.moreTabValue] if !valid.contains(router.tabSelection) { if let hidden = AppTab(rawValue: router.tabSelection) { router.morePath = [hidden] } router.tabSelection = AppTab.moreTabValue } #endif } } } // MARK: - 더보기 탭 struct MoreTabView: View { @Environment(AppRouter.self) private var router let tabs: [AppTab] var body: some View { @Bindable var router = router NavigationStack(path: $router.morePath) { List(tabs) { tab in NavigationLink(value: tab) { Label { Text(tab.label) } icon: { Image(systemName: tab.symbol) .foregroundStyle(AppTheme.green) } } } .scrollContentBackground(.hidden) .background(AppTheme.background) .navigationTitle("더보기") .navigationDestination(for: AppTab.self) { tab in tab.rootView } } } } #Preview { ContentView() .modelContainer(for: [ Tag.self, Action.self, TimeSession.self, CountEntry.self, Goal.self, Quest.self, ], inMemory: true) }