diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index 385d62f..8884e88 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -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": { diff --git a/myApp/HaruDanim/IOS/Views/DiaryView.swift b/myApp/HaruDanim/IOS/Views/DiaryView.swift index 3680922..7e166cd 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryView.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryView.swift @@ -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 { + Set(raw.split(separator: ",").compactMap { DiarySection(rawValue: String($0)) }) + } + + static func rawValue(_ sections: some Sequence) -> 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 { 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 { + 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 {