// // ContentView.swift // Haru_Danim // // 스플래시 + 탭 구조 (CLAUDE.md §5, 탭바 구성은 설정에서 변경 가능) // import SwiftUI import SwiftData struct ContentView: View { @Environment(\.modelContext) private var context @AppStorage(SettingsKeys.theme, store: AppGroup.defaults) private var theme: String = "system" @State private var showSplash = true @State private var showOnboarding = false @State private var showWhatsNew = false var body: some View { ZStack { #if DEBUG // 검증용: -widgetPreview YES(홈 위젯) / lock(잠금화면 위젯) / marketing(스토어 촬영용) → 위젯 미리보기 화면 // -premiumPreview YES → 프리미엄 결제 화면 직접 표시 // -helpPreview YES → 도움말 화면 직접 표시 if let mode = UserDefaults.standard.string(forKey: "widgetPreview") { if mode == "marketing" { MarketingWidgetScreen() } else { WidgetPreviewScreen(showLock: mode == "lock") } } else if UserDefaults.standard.bool(forKey: "premiumPreview") { NavigationStack { PremiumView() } } else if UserDefaults.standard.bool(forKey: "helpPreview") { NavigationStack { HelpView() } } else if UserDefaults.standard.bool(forKey: "dataExportPreview") { NavigationStack { DataExportView() } } else if UserDefaults.standard.bool(forKey: "dataImportPreview") { NavigationStack { DataImportView() } } else if UserDefaults.standard.bool(forKey: "dataResetPreview") { NavigationStack { DataResetView() } } else if UserDefaults.standard.bool(forKey: "sleepWindowPreview") { // 검증용: -sleepWindowPreview YES → 수면 표시 구간 설정 화면 직접 표시 (1.5 추가분) NavigationStack { SleepWindowSettingsView() } } else if UserDefaults.standard.bool(forKey: "healthColorPreview") { // 검증용: -healthColorPreview YES → 타임테이블 표시 색 설정 화면 직접 표시 (1.5(4)) NavigationStack { HealthColorSettingsView() } } else { RootNavigationView() } #else RootNavigationView() #endif if showSplash { SplashView() .transition(.opacity) .zIndex(1) } } .tint(AppTheme.green) // "system"(기본)은 강제하지 않음 — 시스템 라이트/다크를 그대로 따른다 .preferredColorScheme(theme == "dark" ? .dark : theme == "light" ? .light : nil) .fullScreenCover(isPresented: $showOnboarding) { OnboardingView { showOnboarding = false } } // 1.5: 업데이트 신기능 소개 팝업 (버전당 1회 — 닫는 즉시 기록, plan §11) .sheet(isPresented: $showWhatsNew, onDismiss: WhatsNewView.markShown) { WhatsNewView() } // 맥(Designed for iPad)은 아이패드 포인트가 축소 매핑돼 글자가 작게 보임 — // 전역 글자 크기를 한 단계 키운다. 사용자가 시스템에서 더 크게 쓰면 그 값을 존중 .transformEnvironment(\.dynamicTypeSize) { size in if DeviceLayout.isMac, size < .xLarge { size = .xLarge } } .task { // 건강 데이터 루틴 (1.5): 기기 가용성 기록 + 최근 값 갱신 + 하루 1회 백필 HealthDataStore.shared.bootstrap() #if DEBUG DebugSeed.seedIfRequested(context: context) DebugSeed.seedHealthQuestIfRequested(context: context) DebugSeed.seedEmptyGoalIfRequested(context: context) DebugSeed.seedPeriodFulfilledIfRequested(context: context) DebugSeed.seedBulkIfRequested(context: context) DebugSeed.autoStartIfRequested(context: context) DebugSeed.pinGoalsIfRequested(context: context) DebugSeed.watchGoalsIfRequested(context: context) DebugSeed.watchActionsIfRequested(context: context) DebugSeed.endGoalYesterdayIfRequested(context: context) DebugSeed.dumpStreaksIfRequested(context: context) DebugSeed.dumpHealthQuestsIfRequested(context: context) DebugSeed.dumpMissingSymbolsIfRequested() DebugSeed.runProgressSelfTestIfRequested() CSVImport.selfTestIfRequested() DataReset.selfTestIfRequested() await DebugSeed.runIntentSmokeTestIfRequested() // 검증용: -themeAutoToggle YES → 3초 후 설정 화면과 같은 경로(App Group defaults)로 // 테마를 반전시켜 '재시작 없이 즉시 적용'되는지 확인 if UserDefaults.standard.bool(forKey: "themeAutoToggle") { Task { try? await Task.sleep(for: .seconds(3)) AppGroup.defaults.set(theme == "dark" ? "light" : "dark", forKey: SettingsKeys.theme) } } #endif // 스플래시는 짧게 — 추적 앱은 하루에도 여러 번 여는 앱이라 대기를 최소화 (1.2→0.7초) try? await Task.sleep(for: .seconds(0.7)) withAnimation(.easeOut(duration: 0.4)) { showSplash = false } // 첫 실행 온보딩: 데이터가 하나도 없고 본 적 없을 때만 1회 자동 표시. // 위 시드(DEBUG)가 행동을 먼저 만들므로 검증·마케팅 촬영 플로우는 자동으로 건너뛴다. #if DEBUG if UserDefaults.standard.bool(forKey: "showOnboarding") { showOnboarding = true return } #endif if !UserDefaults.standard.bool(forKey: OnboardingView.doneKey), ((try? context.fetchCount(FetchDescriptor())) ?? 0) == 0 { showOnboarding = true } // 1.5: 업데이트 신기능 소개 팝업 — 신규 설치는 현재 버전을 시드해 건너뛰고 // (첫 안내는 온보딩 담당), 온보딩이 뜨는 실행에서는 표시하지 않는다 (plan §11) WhatsNewView.markSeededIfFreshInstall(context: context) #if DEBUG // 시드 실행(검증·마케팅 촬영 플로우)에서는 팝업이 화면을 덮지 않게 본 것으로 처리 if ["seedDemo", "seedBulk", "seedHealthQuest"].contains(where: { UserDefaults.standard.bool(forKey: $0) }) { WhatsNewView.markShown() } if UserDefaults.standard.bool(forKey: "showWhatsNew") { showWhatsNew = true } #endif if !showOnboarding, WhatsNewView.shouldShow { showWhatsNew = true } } } } // 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: - 내비게이션 껍데기 분기 (iPhone=탭바, iPad·Mac=사이드바) /// 기기 종류에 따라 내비게이션 구조만 바꾸고 각 화면(rootView)은 그대로 공유한다. /// iPhone은 설정(NavStyle)에 따라 기본 하단 탭바 / 플로팅 반원 메뉴 중 하나를 사용하고, /// iPad·Mac은 항상 사이드바를 사용한다 (반원 메뉴는 iPhone 전용). struct RootNavigationView: View { @AppStorage(SettingsKeys.navStyle) private var navStyleRaw = NavStyle.tabBar.rawValue private let refresh = AppRefresh.shared /// DEBUG 검증용: `-navStyle radial` 런치 인자로 설정과 무관하게 강제 전환 private var effectiveNavStyle: NavStyle { #if DEBUG if let forced = UserDefaults.standard.string(forKey: "navStyle"), let style = NavStyle(rawValue: forced) { return style } #endif return NavStyle(rawValue: navStyleRaw) ?? .tabBar } var body: some View { Group { if DeviceLayout.isPad { SidebarRootView() } else if effectiveNavStyle == .radial { RadialTabView() } else { MainTabView() } } // 새로고침 토큰이 바뀌면 화면 전체를 다시 만들어 모든 @Query가 재조회된다 .id(refresh.token) } } // MARK: - 탭 간 이동 라우터 /// 탭 전환과 탭 간 전달값 관리 (예: 모음 탭 '기록 확인' → 기록 탭 행동 필터) @Observable final class AppRouter { /// 사이드바(NavigationSplitView) 모드 여부. 사이드바에는 모든 탭이 항상 보이므로 /// '더보기' 우회 없이 어떤 탭이든 바로 선택할 수 있다. var usesSidebar = false /// 라디얼(플로팅 반원) 모드 여부. 반원 메뉴에도 모든 탭이 항상 노출되므로 /// 사이드바와 마찬가지로 '더보기' 우회 없이 어떤 탭이든 바로 선택한다. var allTabsDirect = false 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? /// 더보기 탭 내부 내비게이션 경로. 더보기에 숨은 탭으로 바로 진입할 때 라우터가 채워 넣는다. /// ⚠️ 반드시 NavigationPath(이종 허용) — [AppTab] 고정 타입이면 더보기 안 화면이 다른 타입을 /// 푸시하지 못한다(1.5(4) 실측: 일기 달력의 날짜(Date) 탭이 조용히 무시돼 상세로 못 들어감) var morePath = NavigationPath() /// 기록 탭으로 이동하며 행동 필터를 예약. 기록 탭이 노출 탭에 없으면 더보기 안의 기록 화면까지 바로 진입. 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) { if usesSidebar || allTabsDirect { tabSelection = tab.rawValue return } let visible = AppTab.visibleTabs(from: visibleTabsRaw) if visible.contains(tab) { tabSelection = tab.rawValue } else { morePath = NavigationPath([tab]) tabSelection = AppTab.moreTabValue } } } // MARK: - 탭 정의 enum AppTab: String, CaseIterable, Identifiable { case main, action, tag, goal, history, stats, diary, settings static let moreTabValue = "more" /// iPhone에 노출되는 탭. 일기는 1.0~1.5(3)에서 iPad 전용이었으나 1.5 추가분에서 /// 아이폰에도 개방 — 노트 필기는 맥과 같은 '그리기' 토글로 손가락 입력을 받는다 (§6.7). /// 기본 노출 탭에는 들어가지 않으므로 기존 사용자에게는 더보기·버블 끝에 조용히 추가된다 static var phoneCases: [AppTab] { allCases } /// 사이드바(iPad·Mac)에 노출되는 탭. /// 일기는 1.0에서 맥(Designed for iPad)에 숨겼으나 1.1부터 개방 — 맥은 텍스트 입력이 /// 메인이고 필기는 노트 도구 줄의 '그리기' 토글로 마우스 입력을 받는다 (§6.7). static var sidebarCases: [AppTab] { allCases } var id: String { rawValue } var label: String { switch self { case .main: return String(localized: "모음") case .action: return String(localized: "행동") case .tag: return String(localized: "꼬리표") case .goal: return String(localized: "목표") case .history: return String(localized: "기록") case .stats: return String(localized: "통계") case .diary: return String(localized: "일기") case .settings: return String(localized: "설정") } } 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 .diary: return "book.closed.fill" 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.phoneCases.filter { chosen.contains($0.rawValue) } return tabs.isEmpty ? [.main] : Array(tabs.prefix(TabBarConfig.maxVisible)) } /// 저장된 반원 메뉴 순서를 항상 모든 탭을 포함한 배열로 변환. /// 방어 로직: 저장 문자열에 없거나(예: 나중에 추가된 탭) 잘못된 값은 기본(스펙) 순서로 맨 끝에 붙는다. static func radialOrder(from raw: String) -> [AppTab] { var result: [AppTab] = [] var seen: Set = [] for part in raw.split(separator: ",") { if let tab = AppTab(rawValue: String(part)), seen.insert(tab).inserted { result.append(tab) } } for tab in AppTab.phoneCases where !seen.contains(tab) { result.append(tab) } return result } @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 .diary: DiaryRootView() 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.phoneCases.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 // 검증용: -morePushDiaryToday YES → 더보기 스택에 [일기, 오늘 날짜]를 프로그램 푸시. // 일기 달력의 날짜 탭과 같은 '이종 타입(Date) 푸시' 경로 실측 — morePath가 [AppTab] // 고정 타입이던 시절엔 이 푸시가 조용히 무시됐다(1.5(4) 수정의 회귀 감시 인자) if UserDefaults.standard.bool(forKey: "morePushDiaryToday") { var path = NavigationPath([AppTab.diary]) path.append(DayMath().dayKey(for: .now)) router.morePath = path router.tabSelection = AppTab.moreTabValue return } // 검증용: -startTab이 노출 탭에 없으면 더보기 안의 그 화면까지 바로 진입 let valid = visibleTabs.map(\.rawValue) + [AppTab.moreTabValue] if !valid.contains(router.tabSelection) { if let hidden = AppTab(rawValue: router.tabSelection) { router.morePath = NavigationPath([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 moreRow(tab) } .scrollContentBackground(.hidden) .background(AppTheme.background) .navigationTitle("더보기") .navigationDestination(for: AppTab.self) { tab in tab.rootView } } } @ViewBuilder private func moreRow(_ tab: AppTab) -> some View { NavigationLink(value: tab) { Label { Text(tab.label) } icon: { Image(systemName: tab.symbol) .foregroundStyle(AppTheme.green) } } } } #Preview { ContentView() .modelContainer(for: [ Tag.self, Action.self, TimeSession.self, CountEntry.self, Goal.self, Quest.self, ], inMemory: true) }