- WhatsNewView(버전당 1회·신규 설치 시드·온보딩 우선·全기기 업데이트 안내·-showWhatsNew) - HelpView: 전 주제 ko 자연화(오타 '남긴 하지만'·번역투·'완료 보관함'→'완료된 목표' 정정, 1.1 맥 일기 반영), 신규 주제 3개(행동 컴플리케이션·건강 보기·건강 다짐), 건강 그룹은 지원 기기에서만 노출 - l10n: 4개 카탈로그 sync·en/ja 신규 208건+본문 재번역·용어 통일(Action/アクション· Quest/クエスト·Home/ホーム)·stale 43 청소 → 전 카탈로그 missing/stale 0, InfoPlist에 NSHealthShareUsageDescription 3언어 - 검증: Debug/Store/워치 빌드, 자가 검증 61·20·27·10 ALL PASS, ja 도움말 실렌더 확인 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
428 lines
17 KiB
Swift
428 lines
17 KiB
Swift
//
|
|
// 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 {
|
|
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<Action>())) ?? 0) == 0 {
|
|
showOnboarding = true
|
|
}
|
|
// 1.5: 업데이트 신기능 소개 팝업 — 신규 설치는 현재 버전을 시드해 건너뛰고
|
|
// (첫 안내는 온보딩 담당), 온보딩이 뜨는 실행에서는 표시하지 않는다 (plan §11)
|
|
WhatsNewView.markSeededIfFreshInstall(context: context)
|
|
#if DEBUG
|
|
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?
|
|
|
|
/// 더보기 탭 내부 내비게이션 경로. 더보기에 숨은 탭으로 바로 진입할 때 라우터가 채워 넣는다.
|
|
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) {
|
|
if usesSidebar || allTabsDirect {
|
|
tabSelection = tab.rawValue
|
|
return
|
|
}
|
|
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, diary, settings
|
|
|
|
static let moreTabValue = "more"
|
|
|
|
/// iPhone에 노출되는 탭 (일기는 iPad 전용 — 탭바/더보기/라디얼 메뉴에서 제외)
|
|
static var phoneCases: [AppTab] {
|
|
allCases.filter { $0 != .diary }
|
|
}
|
|
|
|
/// 사이드바(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<AppTab> = []
|
|
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.filter { $0 != .diary }
|
|
}
|
|
|
|
@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
|
|
// 검증용: -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)
|
|
}
|