mycode/myApp/HaruDanim/IOS/ContentView.swift
songyc macbook 8b1bc44f67 fix+feat(1.5-p7): 소킹 실측 2건 수정 + 추가 2건 — 빌드 5
- fix: 더보기 탭 일기 달력 날짜 탭 무반응 — morePath [AppTab]→NavigationPath(Date 푸시 가능), 회귀 인자 -morePushDiaryToday(착지 실측)
- fix: 수면 블록이 수면 단계별로 칸칸이 갈라짐 — 10분 이하 깸 병합(sleepDisplayMergeGap, 표시 전용·값 무영향)
- feat: 기록 탭 타임테이블(하루·주간)에 수면·운동 블록 — 단일 공급 지점·숨김 토글 공유, 내보내기 injectingHealthBlocks(forDayKeys:)+trim 확장
- feat: 타임테이블 표시 색 설정(설정→건강 데이터 — 기본 인디고·주황, 전 타임테이블·내보내기 공통), -healthColorPreview
- QA: 주간 6일 블록 렌더·커스텀 색 픽셀 검증(#FF2D55→(255,221,228))·색 설정 화면·Date 푸시 착지, l10n 0/0, Debug/Store 그린

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
2026-08-22 23:24:13 +09:00

459 lines
19 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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.20.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 ["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<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
}
@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)
}