feat(diary): customizable first-page sections — show/hide and reorder

- 일기 상세 메뉴에 '첫 화면 구성' 시트 추가: 7개 섹션(오늘 기분/요약
  지표/이 날의 목표/오늘 할 일/타임테이블/기록 상세/통계 막대)을
  드래그로 순서 변경, 스위치로 표시/숨김 (모든 날짜 공통 적용)
- DiarySummaryPage가 설정 순서대로 렌더링, 숨긴 섹션 제외. 와이드
  레이아웃은 타임테이블 표시 중일 때만 2컬럼(타임테이블 왼쪽 전담)
- 기존 '오늘 기분 표시' 토글은 새 숨김 목록으로 1회 이관
- 검증 인자: -diaryShowConfig YES, -diary.sectionOrder/-diary.hiddenSections
- 새 문자열 en/ja 번역 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-13 05:57:11 +09:00
parent 209042575e
commit 6b0b6055eb
2 changed files with 298 additions and 31 deletions

View File

@ -1004,6 +1004,7 @@
}
},
"‘오늘 기분’ 영역 표시": {
"extractionState": "stale",
"localizations": {
"en": {
"stringUnit": {
@ -1468,6 +1469,22 @@
}
}
},
"기록 상세": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Record details"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "記録の詳細"
}
}
}
},
"기록 시 메모 창 표시": {
"localizations": {
"en": {
@ -2771,6 +2788,22 @@
}
}
},
"드래그해서 순서를 바꾸고, 스위치로 표시 여부를 정할 수 있어요. 모든 날짜의 일기 첫 화면에 함께 적용돼요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Drag to reorder and use the switches to show or hide sections. Applies to the first page of every diary date."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ドラッグで並べ替え、スイッチで表示/非表示を設定できます。すべての日付の日記の最初のページに適用されます。"
}
}
}
},
"등록된 꼬리표가 없어요": {
"localizations": {
"en": {
@ -5202,6 +5235,22 @@
}
}
},
"요약 지표": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Summary stats"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "サマリー指標"
}
}
}
},
"요일": {
"localizations": {
"en": {
@ -6660,6 +6709,22 @@
}
}
},
"첫 화면 구성": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "First Page Layout"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "最初のページの構成"
}
}
}
},
"총 측정 시간": {
"localizations": {
"en": {
@ -7029,6 +7094,22 @@
}
}
},
"통계 막대": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Stat bars"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "統計バー"
}
}
}
},
"통계 보기": {
"localizations": {
"en": {
@ -7221,6 +7302,22 @@
}
}
},
"표시할 항목이 없어요. 오른쪽 위 메뉴의 '첫 화면 구성'에서 켜 주세요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Nothing to show. Turn sections on in 'First Page Layout' from the top-right menu."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "表示する項目がありません。右上メニューの「最初のページの構成」からオンにしてください。"
}
}
}
},
"표시할 행동 없음": {
"localizations": {
"en": {

View File

@ -305,9 +305,15 @@ struct DiaryDetailView: View {
let dayKey: Date
@Environment(\.modelContext) private var context
@AppStorage("diary.showMood", store: AppGroup.defaults) private var showMood = true
@State private var entry: DiaryEntry?
@State private var showingSectionConfig: Bool = {
#if DEBUG
// : -diaryShowConfig YES
if UserDefaults.standard.bool(forKey: "diaryShowConfig") { return true }
#endif
return false
}()
@State private var pageIndex: Int = {
#if DEBUG
// : -diaryPage N (0=)
@ -333,7 +339,11 @@ struct DiaryDetailView: View {
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Toggle("‘오늘 기분’ 영역 표시", isOn: $showMood)
Button {
showingSectionConfig = true
} label: {
Label("첫 화면 구성", systemImage: "slider.horizontal.3")
}
Button {
addPage()
} label: {
@ -351,12 +361,15 @@ struct DiaryDetailView: View {
}
}
}
.sheet(isPresented: $showingSectionConfig) {
DiarySectionConfigSheet()
}
.onAppear(perform: loadEntry)
}
private func pager(_ entry: DiaryEntry) -> some View {
TabView(selection: $pageIndex) {
DiarySummaryPage(entry: entry, showMood: showMood)
DiarySummaryPage(entry: entry)
.tag(0)
ForEach(Array(entry.pages.enumerated()), id: \.element.uuid) { index, page in
DiaryNotePageView(page: page, isActive: pageIndex == index + 1)
@ -433,15 +446,146 @@ struct DiaryDetailView: View {
}
}
// MARK: - ( + )
/// . rawValue .
enum DiarySection: String, CaseIterable, Identifiable {
case mood, hero, goals, todos, timetable, records, bars
var id: String { rawValue }
var label: LocalizedStringKey {
switch self {
case .mood: return "오늘 기분"
case .hero: return "요약 지표"
case .goals: return "이 날의 목표"
case .todos: return "오늘 할 일"
case .timetable: return "타임테이블"
case .records: return "기록 상세"
case .bars: return "통계 막대"
}
}
var symbol: String {
switch self {
case .mood: return "face.smiling"
case .hero: return "number.square"
case .goals: return "flag.checkered"
case .todos: return "checklist"
case .timetable: return "calendar.day.timeline.left"
case .records: return "list.bullet.rectangle"
case .bars: return "chart.bar.fill"
}
}
}
/// · UserDefaults ( ).
/// ( ) .
enum DiarySectionConfig {
static let orderKey = "diary.sectionOrder"
static let hiddenKey = "diary.hiddenSections"
static func order(from raw: String) -> [DiarySection] {
var result = raw.split(separator: ",").compactMap { DiarySection(rawValue: String($0)) }
for section in DiarySection.allCases where !result.contains(section) {
result.append(section)
}
return result
}
static func hidden(from raw: String) -> Set<DiarySection> {
Set(raw.split(separator: ",").compactMap { DiarySection(rawValue: String($0)) })
}
static func rawValue(_ sections: some Sequence<DiarySection>) -> String {
sections.map(\.rawValue).joined(separator: ",")
}
/// ' ' (diary.showMood) 1
static func migrateIfNeeded() {
let defaults = UserDefaults.standard
guard !defaults.bool(forKey: "diary.sectionsMigrated") else { return }
defaults.set(true, forKey: "diary.sectionsMigrated")
if let old = AppGroup.defaults.object(forKey: "diary.showMood") as? Bool, old == false {
var hiddenSet = hidden(from: defaults.string(forKey: hiddenKey) ?? "")
hiddenSet.insert(.mood)
defaults.set(rawValue(hiddenSet), forKey: hiddenKey)
}
}
}
/// : , /
struct DiarySectionConfigSheet: View {
@Environment(\.dismiss) private var dismiss
@AppStorage(DiarySectionConfig.orderKey) private var orderRaw = ""
@AppStorage(DiarySectionConfig.hiddenKey) private var hiddenRaw = ""
private var sections: [DiarySection] { DiarySectionConfig.order(from: orderRaw) }
private var hiddenSections: Set<DiarySection> { DiarySectionConfig.hidden(from: hiddenRaw) }
var body: some View {
NavigationStack {
List {
Section {
ForEach(sections) { section in
HStack(spacing: 12) {
Image(systemName: section.symbol)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AppTheme.green)
.frame(width: 26)
Toggle(isOn: visibilityBinding(section)) {
Text(section.label)
.font(.subheadline)
}
.tint(AppTheme.green)
}
}
.onMove(perform: move)
} footer: {
Text("드래그해서 순서를 바꾸고, 스위치로 표시 여부를 정할 수 있어요. 모든 날짜의 일기 첫 화면에 함께 적용돼요.")
}
}
.environment(\.editMode, .constant(.active))
.navigationTitle("첫 화면 구성")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("닫기") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
}
private func visibilityBinding(_ section: DiarySection) -> Binding<Bool> {
Binding(
get: { !hiddenSections.contains(section) },
set: { visible in
var hiddenSet = hiddenSections
if visible { hiddenSet.remove(section) } else { hiddenSet.insert(section) }
hiddenRaw = DiarySectionConfig.rawValue(
DiarySection.allCases.filter(hiddenSet.contains) //
)
}
)
}
private func move(from source: IndexSet, to destination: Int) {
var reordered = sections
reordered.move(fromOffsets: source, toOffset: destination)
orderRaw = DiarySectionConfig.rawValue(reordered)
}
}
// MARK: - ( )
/// " " .
/// - 24 (trimHours: false)
/// - .
/// / " " .
/// - · DiarySectionConfig( ) .
private struct DiarySummaryPage: View {
@Bindable var entry: DiaryEntry
let showMood: Bool
@Environment(\.modelContext) private var context
@Query private var sessions: [TimeSession]
@ -449,9 +593,17 @@ private struct DiarySummaryPage: View {
@Query(sort: \Action.createdAt) private var allActionsQuery: [Action]
@Query(sort: \Goal.sortOrder) private var allGoals: [Goal]
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
@AppStorage(DiarySectionConfig.orderKey) private var sectionOrderRaw = ""
@AppStorage(DiarySectionConfig.hiddenKey) private var hiddenSectionsRaw = ""
private var math: DayMath { DayMath() }
/// ( , )
private var visibleSections: [DiarySection] {
let hidden = DiarySectionConfig.hidden(from: hiddenSectionsRaw)
return DiarySectionConfig.order(from: sectionOrderRaw).filter { !hidden.contains($0) }
}
private var orderedActions: [Action] {
LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw)
}
@ -507,7 +659,10 @@ private struct DiarySummaryPage: View {
var body: some View {
GeometryReader { geo in
let wide = geo.size.width >= 720
let sections = visibleSections
// + 2 ( ,
// )
let wide = geo.size.width >= 720 && sections.contains(.timetable)
let timetableWidth: CGFloat = 300
let rightWidth = geo.size.width - 40 - timetableWidth - 16
let fullWidth = geo.size.width - 40
@ -515,46 +670,61 @@ private struct DiarySummaryPage: View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
header
if showMood {
DiaryMoodCard(entry: entry)
}
ExportHeroRow(stats: daySnapshot.hero)
if wide {
if sections.isEmpty {
Text("표시할 항목이 없어요. 오른쪽 위 메뉴의 '첫 화면 구성'에서 켜 주세요.")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
} else if wide {
HStack(alignment: .top, spacing: 16) {
// : 24 ( )
VStack(spacing: 14) {
timetableSections(innerWidth: timetableWidth - 28)
}
.frame(width: timetableWidth)
// : ·
VStack(spacing: 14) {
DiaryGoalsCard(
goals: activeGoals,
dayKey: entry.dayKey,
referenceNow: referenceNow,
math: math
)
DiaryTodoCard(entry: entry)
recordSections(innerWidth: rightWidth - 28)
barSections(innerWidth: rightWidth - 28)
ForEach(sections.filter { $0 != .timetable }) { section in
sectionView(section, innerWidth: rightWidth - 28)
}
}
}
} else {
DiaryGoalsCard(
goals: activeGoals,
dayKey: entry.dayKey,
referenceNow: referenceNow,
math: math
)
DiaryTodoCard(entry: entry)
timetableSections(innerWidth: fullWidth - 28)
recordSections(innerWidth: fullWidth - 28)
barSections(innerWidth: fullWidth - 28)
ForEach(sections) { section in
sectionView(section, innerWidth: fullWidth - 28)
}
}
}
.padding(20)
}
}
.onAppear(perform: DiarySectionConfig.migrateIfNeeded)
}
///
@ViewBuilder
private func sectionView(_ section: DiarySection, innerWidth: CGFloat) -> some View {
switch section {
case .mood:
DiaryMoodCard(entry: entry)
case .hero:
ExportHeroRow(stats: daySnapshot.hero)
case .goals:
DiaryGoalsCard(
goals: activeGoals,
dayKey: entry.dayKey,
referenceNow: referenceNow,
math: math
)
case .todos:
DiaryTodoCard(entry: entry)
case .timetable:
timetableSections(innerWidth: innerWidth)
case .records:
recordSections(innerWidth: innerWidth)
case .bars:
barSections(innerWidth: innerWidth)
}
}
private var header: some View {