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:
parent
209042575e
commit
6b0b6055eb
@ -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": {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user