mycode/myApp/HaruDanim/IOS/ContentView.swift
songyc macbook ac40e14cfe feat(ux): UX 감사 반영 배치 — 시스템 테마·온보딩·일기 잠금·기간 비교 외
2026-07-23 사용성 감사(A그룹 5건 + 지시 추가분)를 한 번에 반영.

[테마] '시스템 설정 따름' 추가 + 기본값 light→system. "system"은 스킴을
  강제하지 않아(preferredColorScheme nil) 앱·위젯('앱 일치' 옵션,
  WidgetThemeOption.scheme: ColorScheme?)·DEBUG 위젯 미리보기까지
  시스템 라이트/다크를 그대로 따른다
[온보딩] 첫 실행 3장 소개(OnboardingView — 행동→목표·다짐→정리).
  데이터가 하나도 없고 본 적 없을 때만 1회(건너뛰기 포함 재표시 없음,
  onboarding.done), 시드·마케팅 촬영 플로우는 자동 건너뜀.
  설정 → 지원 '앱 소개 다시 보기'로 재열람
[일기 잠금] 설정 → 일기(iPad 전용 표시) 토글 — DiaryLock이
  .deviceOwnerAuthentication으로 Face ID/Touch ID/Optic ID+암호 폴백을
  기기별 분기 없이 처리. 일기 탭 진입 게이트(DiaryLockGateView),
  백그라운드 재잠금, 토글 변경 시 인증 요구, 잠글 수단 없는 기기는
  통과(영구 잠김 방지). NSFaceIDUsageDescription 추가(Info.plist+
  InfoPlist.xcstrings ko/en/ja)
[통계] 이전 기간 비교 카드(맨 위) — 총 시간·횟수를 어제/지난주/지난달과
  비교(▲▼%, 이전 기록 없으면 상태 문구). 이전 기간은 관계 기반
  Aggregator로 직접 집계, 필터 반영. 화면 전용(내보내기 미포함 의도)
[스플래시] 1.2→0.7초 단축 (하루에도 여러 번 여는 앱)
[워치] 행동 실행 탭 시 WKInterfaceDevice.play(.click) 햅틱 — 화면을
  안 보고 탭해도 접수 확인
[컴플리케이션] rectangular 빈 상태에 해결 안내("목표 편집에서
  '애플워치에서 보기'를 켜면 나타나요") — 옵트인 발견성 보완
[문구] 행동 편집기 꼬리표 색 규칙(여러 개면 가장 먼저 만든 꼬리표),
  새로고침 버튼 accessibilityHint

- 도움말 3주제 추가(새로고침 버튼·일기 잠금·이전 기간과 비교)
- 신규 문구 31키 en/ja 완역 + 워치 카탈로그 2키, missing/stale 0
- CLAUDE.md §6·§6.6·§6.7·§6.8·§10·§11·§14 갱신, DEBUG 인자
  -showOnboarding·-diaryLockScreen 추가
- 검증: Debug/워치/Store 빌드, 온보딩·설정·비교 카드·잠금 게이트·
  컴플리케이션 스크린샷

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 02:02:38 +09:00

397 lines
15 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
var body: some View {
ZStack {
#if DEBUG
// : -widgetPreview YES( ) / lock( )
// -premiumPreview YES
// -helpPreview YES
if let mode = UserDefaults.standard.string(forKey: "widgetPreview") {
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 {
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 }
}
// (Designed for iPad)
// .
.transformEnvironment(\.dynamicTypeSize) { size in
if DeviceLayout.isMac, size < .xLarge { size = .xLarge }
}
.task {
#if DEBUG
DebugSeed.seedIfRequested(context: context)
DebugSeed.seedEmptyGoalIfRequested(context: context)
DebugSeed.seedBulkIfRequested(context: context)
DebugSeed.autoStartIfRequested(context: context)
DebugSeed.pinGoalsIfRequested(context: context)
DebugSeed.watchGoalsIfRequested(context: context)
DebugSeed.endGoalYesterdayIfRequested(context: context)
DebugSeed.dumpStreaksIfRequested(context: context)
DebugSeed.runProgressSelfTestIfRequested()
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
}
}
}
}
// 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) .
/// (Designed for iPad)
/// , .
static var sidebarCases: [AppTab] {
DeviceLayout.isMac ? allCases.filter { $0 != .diary } : 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)
}