mycode/myApp/HaruDanim/IOS/ContentView.swift
songyc macbook 379ea6ebf4 fix(quest): bound streaks by the goal's start date
'이하 유지' 다짐은 기록 없는 날도 달성으로 집계되므로 연속 계산이
목표가 생기기 전 과거(스캔 상한 400일)까지 거슬러 올라가 '연속 401일'로
표시되던 버그 수정. 연속은 행동이 아니라 '목표 안 다짐'의 달성이므로
하한 = 목표 시작일:
- 하루 단위: 시작일이 속한 논리적 하루부터만 센다
- 주간/월간: 시작일이 걸친 부분 주기까지 포함, 그 이전 주기에서 중단

검증: 시드에 재현 목표(스크린 타임 관리, 3일 전 시작 + 초과 없는 이하
유지 다짐) 추가 → 기대값 연속 4일 확인. -streakDump 런치 인자 신설
(전 다짐의 연속 계산 결과를 Documents/streak-dump.txt로 덤프).

부수: 도움말에 '연속 달성 표시' 항목 추가(ko/en/ja). 지난 기능들의
문자열이 위젯·워치·InfoPlist 카탈로그에 미번역으로 남아 있던 것을
채움(제어 센터·연속 표기·즐겨찾기·캘린더 권한 문구) — l10n 루틴이
IOS 카탈로그만 확인하던 함정을 CLAUDE.md §2.3에 명시.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-14 01:42:13 +09:00

363 lines
13 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 = "light"
@State private var showSplash = true
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)
.preferredColorScheme(theme == "dark" ? .dark : .light)
.task {
#if DEBUG
DebugSeed.seedIfRequested(context: context)
DebugSeed.autoStartIfRequested(context: context)
DebugSeed.pinGoalsIfRequested(context: context)
DebugSeed.endGoalYesterdayIfRequested(context: context)
DebugSeed.dumpStreaksIfRequested(context: context)
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
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: - (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 }
}
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)
}