From ecb7b43d31cd32d2701ee483f4adc99175488ed0 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Mon, 13 Jul 2026 03:46:58 +0900 Subject: [PATCH] feat(diary): redesign summary page as daily review and add note pinch-zoom, line spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 첫 페이지를 '하루 정리' 화면으로 개편: 기록 유무와 무관한 24시간 고정 타임테이블(trimHours 옵션), 그 날짜에 진행 중이던 목표들의 다짐별 진행 현황 카드(하루 다짐은 그날 실적, 주간/월간/기간 다짐은 그 날짜까지 누적, '이하 유지'는 한도 게이지로 구분), 기록 상세·통계 막대 재배치 - 노트 페이지: UIScrollView 기반 핀치줌(두 손가락, fit~4배, 배치 모드에선 잠금) - 줄 노트 줄 간격 설정(좁게/보통/넓게/아주 넓게, 페이지별 저장·내보내기 반영) - 새 문자열 en/ja 번역 추가 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- myApp/HaruDanim/IOS/Localizable.xcstrings | 319 +++++++++++++++++- .../IOS/Views/DiaryExportSheet.swift | 2 +- myApp/HaruDanim/IOS/Views/DiaryNotePage.swift | 127 ++++++- myApp/HaruDanim/IOS/Views/DiaryView.swift | 296 ++++++++++++++-- .../HaruDanim/IOS/Views/ExportImageView.swift | 20 +- myApp/HaruDanim/Shared/DiaryModels.swift | 2 + myApp/HaruDanim/Widgets/InfoPlist.xcstrings | 50 +-- 7 files changed, 745 insertions(+), 71 deletions(-) diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index 47c9640..385d62f 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -127,6 +127,50 @@ } } }, + "%@ / %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / %2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / %2$@" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "%1$@ / %2$@" + } + } + } + }, + "%@ / 한도 %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / limit %2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / 上限%2$@" + } + }, + "ko": { + "stringUnit": { + "state": "new", + "value": "%1$@ / 한도 %2$@" + } + } + } + }, "%@ ~ %@": { "localizations": { "en": { @@ -477,24 +521,28 @@ } } }, + "%lld개 진행 중": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld in progress" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld件進行中" + } + } + } + }, "%lld건": { "localizations": { "en": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "%lld entry" - } - }, - "other": { - "stringUnit": { - "state": "translated", - "value": "%lld entries" - } - } - } + "stringUnit": { + "state": "translated", + "value": "%lld" } }, "ja": { @@ -1308,6 +1356,22 @@ } } }, + "기간 목표 · 이 날까지 누적": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Period goal · total up to this day" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "期間目標・この日までの累計" + } + } + } + }, "기간 진행률 %@": { "localizations": { "en": { @@ -1970,6 +2034,22 @@ } } }, + "넓게": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Wide" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "広い" + } + } + } + }, "누적 기록": { "localizations": { "en": { @@ -2322,6 +2402,22 @@ } } }, + "다짐이 아직 없는 목표예요": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This goal has no quests yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "まだクエストのない目標です" + } + } + } + }, "다짐이 없는 목표예요. 달성했나요?": { "localizations": { "en": { @@ -3808,6 +3904,22 @@ } } }, + "보통": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "標準" + } + } + } + }, "부터": { "localizations": { "en": { @@ -4192,6 +4304,22 @@ } } }, + "설정된 기간 밖이에요": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Outside the set period" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定期間の範囲外です" + } + } + } + }, "설정한 시간보다 짧게 측정하고 종료한 기록은 저장하지 않아요. 버튼을 실수로 눌렀을 때 유용해요.": { "localizations": { "en": { @@ -4673,6 +4801,22 @@ } } }, + "아주 넓게": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Extra wide" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "とても広い" + } + } + } + }, "아직 등록된 행동이 없어요": { "localizations": { "en": { @@ -4690,6 +4834,7 @@ } }, "아직 오늘의 기록이 없어요": { + "extractionState": "stale", "localizations": { "en": { "stringUnit": { @@ -5186,6 +5331,22 @@ } } }, + "월간 목표 · 이 날까지 누적": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Monthly goal · total up to this day" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "月間目標・この日までの累計" + } + } + } + }, "월간 시간 합계": { "localizations": { "en": { @@ -5266,6 +5427,70 @@ } } }, + "이 날은 수행일이 아니에요": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not scheduled for this day" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この日は実行日ではありません" + } + } + } + }, + "이 날의 기록이 없어요": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No records on this day" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この日の記録はありません" + } + } + } + }, + "이 날의 목표": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This Day's Goals" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この日の目標" + } + } + } + }, + "이 날짜에 진행 중인 목표가 없어요": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No goals in progress on this date" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この日付に進行中の目標はありません" + } + } + } + }, "이 목표를 이루기 위한 다짐(행동/꼬리표 + 주기 + 목표량)을 추가하세요.": { "localizations": { "en": { @@ -5907,6 +6132,22 @@ } } }, + "좁게": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Narrow" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "狭い" + } + } + } + }, "종료": { "localizations": { "en": { @@ -6083,6 +6324,22 @@ } } }, + "주간 목표 · 이 날까지 누적": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Weekly goal · total up to this day" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "週間目標・この日までの累計" + } + } + } + }, "주간 시간 합계": { "localizations": { "en": { @@ -6195,6 +6452,22 @@ } } }, + "줄 간격": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Line spacing" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "行間" + } + } + } + }, "줄 노트": { "localizations": { "en": { @@ -7173,6 +7446,22 @@ } } }, + "하루 목표": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Daily goal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1日の目標" + } + } + } + }, "하루 시작 시간": { "localizations": { "en": { diff --git a/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift b/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift index dd3ebc8..2d27580 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift @@ -389,7 +389,7 @@ struct DiaryPrintNotePageView: View { ZStack { Rectangle().fill(AppTheme.surface) if page.lined { - DiaryLinedPaper() + DiaryLinedPaper(spacing: page.lineSpacing) } ForEach(page.items, id: \.uuid) { item in DiaryItemView( diff --git a/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift b/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift index 7dc4c51..9f815b4 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift @@ -38,13 +38,12 @@ struct DiaryNotePageView: View { var body: some View { VStack(spacing: 10) { pageToolbar - GeometryReader { geo in - let scale = min(geo.size.width / DiaryPageMetrics.size.width, - geo.size.height / DiaryPageMetrics.size.height) + // 두 손가락 핀치로 화면에 꽉 차게 확대(최대 4배)하고, 확대 중엔 두 손가락으로 이동. + // 한 손가락/펜슬은 그대로 필기·배치에 쓰인다. 배치 모드에서는 요소 핀치와 + // 충돌하지 않도록 페이지 줌을 잠근다. + DiaryZoomContainer(pageSize: DiaryPageMetrics.size, zoomEnabled: !arranging) { pageContent .frame(width: DiaryPageMetrics.size.width, height: DiaryPageMetrics.size.height) - .scaleEffect(scale) - .frame(width: geo.size.width, height: geo.size.height) } } .padding(.horizontal, 24) @@ -71,6 +70,22 @@ struct DiaryNotePageView: View { .toggleStyle(.button) .onChange(of: page.lined) { save() } + if page.lined { + Menu { + Picker("줄 간격", selection: Binding( + get: { page.lineSpacing }, + set: { page.lineSpacing = $0; save() } + )) { + Text("좁게").tag(34.0) + Text("보통").tag(44.0) + Text("넓게").tag(56.0) + Text("아주 넓게").tag(70.0) + } + } label: { + Label("줄 간격", systemImage: "arrow.up.and.down.text.horizontal") + } + } + PhotosPicker(selection: $photoSelection, matching: .images) { Label("사진", systemImage: "photo.badge.plus") } @@ -126,7 +141,7 @@ struct DiaryNotePageView: View { .fill(AppTheme.surface) .shadow(color: .black.opacity(0.10), radius: 14, y: 6) if page.lined { - DiaryLinedPaper() + DiaryLinedPaper(spacing: page.lineSpacing) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) } @@ -200,20 +215,118 @@ struct DiaryNotePageView: View { // MARK: - 줄 노트 배경 struct DiaryLinedPaper: View { + /// 줄 간격 (페이지별 설정, 기본 44pt) + var spacing: Double = DiaryPageMetrics.lineSpacing + var body: some View { Canvas { canvasContext, size in + let step = max(CGFloat(spacing), 20) var y = DiaryPageMetrics.lineTopInset while y < size.height - 24 { var path = Path() path.move(to: CGPoint(x: 32, y: y)) path.addLine(to: CGPoint(x: size.width - 32, y: y)) canvasContext.stroke(path, with: .color(.primary.opacity(0.10)), lineWidth: 1) - y += DiaryPageMetrics.lineSpacing + y += step } } } } +// MARK: - 핀치줌 컨테이너 (UIScrollView 기반) + +/// 노트 페이지를 감싸는 확대/축소 스크롤 뷰. +/// - 처음엔 페이지가 화면에 딱 맞게(fit) 표시되고, 두 손가락 핀치로 최대 4배까지 확대 +/// - 확대 상태에서 두 손가락 드래그로 이동 (한 손가락·펜슬은 안쪽 필기 캔버스가 그대로 받는다) +/// - 배치 모드에서는 zoomEnabled=false로 페이지 줌을 잠가 요소 핀치와 충돌하지 않게 한다 +private struct DiaryZoomContainer: UIViewRepresentable { + let pageSize: CGSize + let zoomEnabled: Bool + @ViewBuilder let content: () -> Content + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> DiaryZoomScrollView { + let scroll = DiaryZoomScrollView() + scroll.pageSize = pageSize + scroll.delegate = context.coordinator + scroll.showsVerticalScrollIndicator = false + scroll.showsHorizontalScrollIndicator = false + scroll.contentInsetAdjustmentBehavior = .never + scroll.scrollsToTop = false + scroll.bounces = false + scroll.bouncesZoom = true + scroll.backgroundColor = .clear + // 한 손가락은 필기, 두 손가락은 이동 + scroll.panGestureRecognizer.minimumNumberOfTouches = 2 + + let hosting = UIHostingController(rootView: content()) + hosting.view.backgroundColor = .clear + hosting.safeAreaRegions = [] + hosting.view.frame = CGRect(origin: .zero, size: pageSize) + scroll.addSubview(hosting.view) + scroll.contentSize = pageSize + scroll.zoomedView = hosting.view + context.coordinator.hosting = hosting + return scroll + } + + func updateUIView(_ scroll: DiaryZoomScrollView, context: Context) { + context.coordinator.hosting?.rootView = content() + scroll.pinchGestureRecognizer?.isEnabled = zoomEnabled + } + + final class Coordinator: NSObject, UIScrollViewDelegate { + var hosting: UIHostingController? + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + (scrollView as? DiaryZoomScrollView)?.zoomedView + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + (scrollView as? DiaryZoomScrollView)?.centerZoomedView() + } + } +} + +/// 레이아웃(회전·창 크기 변경)에 맞춰 fit 배율을 다시 계산하고 콘텐츠를 가운데 정렬하는 스크롤 뷰 +final class DiaryZoomScrollView: UIScrollView { + var pageSize: CGSize = DiaryPageMetrics.size + weak var zoomedView: UIView? + private var didSetInitialZoom = false + + override func layoutSubviews() { + super.layoutSubviews() + guard bounds.width > 0, bounds.height > 0 else { return } + let fit = min(bounds.width / pageSize.width, bounds.height / pageSize.height) + let wasAtMinimum = zoomScale <= minimumZoomScale + 0.0001 + if abs(minimumZoomScale - fit) > 0.0001 { + minimumZoomScale = fit + maximumZoomScale = max(fit * 4, 1) + // 처음이거나 fit 상태로 보고 있었다면 새 크기에 맞춰 다시 fit + if !didSetInitialZoom || wasAtMinimum || zoomScale < fit { + zoomScale = fit + didSetInitialZoom = true + } + } else if !didSetInitialZoom { + zoomScale = fit + didSetInitialZoom = true + } + centerZoomedView() + } + + /// 콘텐츠가 화면보다 작을 때 가운데 정렬 + func centerZoomedView() { + guard let zoomedView else { return } + let dx = max((bounds.width - contentSize.width) / 2, 0) + let dy = max((bounds.height - contentSize.height) / 2, 0) + zoomedView.center = CGPoint(x: contentSize.width / 2 + dx, + y: contentSize.height / 2 + dy) + } +} + // MARK: - 배치 요소 (사진/도형) struct DiaryItemView: View { diff --git a/myApp/HaruDanim/IOS/Views/DiaryView.swift b/myApp/HaruDanim/IOS/Views/DiaryView.swift index b0dcc8c..3680922 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryView.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryView.swift @@ -433,8 +433,12 @@ struct DiaryDetailView: View { } } -// MARK: - 첫 페이지 (하루 요약) +// MARK: - 첫 페이지 (하루 정리) +/// "오늘 하루를 정리한 모습"이 목표인 첫 페이지. +/// - 타임테이블은 기록 유무와 무관하게 항상 24시간 전체를 보여준다 (trimHours: false) +/// - 그 날짜에 진행 중이던 목표들을 찾아 다짐별 진행 현황을 보여준다. +/// 주간/월간 다짐은 "그 날짜까지의 누적"으로 계산해 과거 일기에서도 그날 기준 수치가 된다. private struct DiarySummaryPage: View { @Bindable var entry: DiaryEntry let showMood: Bool @@ -443,6 +447,7 @@ private struct DiarySummaryPage: View { @Query private var sessions: [TimeSession] @Query private var countEntries: [CountEntry] @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 = "" private var math: DayMath { DayMath() } @@ -451,7 +456,26 @@ private struct DiarySummaryPage: View { LocalPrefs.orderedActions(allActionsQuery, raw: actionOrderRaw) } - /// 그날의 기록 스냅숏 (이미지 내보내기와 동일한 데이터 — 타임테이블 + 기록 목록) + /// 진행률 계산 기준 시각: 오늘 일기면 지금, 과거/미래 일기면 그 날짜의 끝 + private var referenceNow: Date { + let range = math.dayRange(forKey: entry.dayKey) + return range.contains(.now) ? .now : range.upperBound.addingTimeInterval(-1) + } + + /// 이 날짜에 진행 중이던 목표 (시작일 도래 & 종료일 이전) + private var activeGoals: [Goal] { + let day = math.dayRange(forKey: entry.dayKey).lowerBound + let cal = math.calendar + return allGoals.filter { goal in + guard cal.startOfDay(for: goal.startDate) <= day else { return false } + if let end = goal.endDate { + return cal.startOfDay(for: end) >= day + } + return true + } + } + + /// 그날의 기록 스냅숏 (이미지 내보내기와 동일한 데이터 — 24시간 타임테이블 + 기록 목록) private var daySnapshot: ExportSnapshot { ExportBuilder.history( dayKey: entry.dayKey, @@ -460,7 +484,8 @@ private struct DiarySummaryPage: View { entries: countEntries, orderedActions: orderedActions, excludedActionIDs: [], - math: math + math: math, + trimHours: false ) } @@ -483,10 +508,9 @@ private struct DiarySummaryPage: View { var body: some View { GeometryReader { geo in let wide = geo.size.width >= 720 - let sideWidth: CGFloat = 340 - let leftWidth = wide - ? geo.size.width - sideWidth - 16 - 40 - : geo.size.width - 40 + let timetableWidth: CGFloat = 300 + let rightWidth = geo.size.width - 40 - timetableWidth - 16 + let fullWidth = geo.size.width - 40 ScrollView { VStack(alignment: .leading, spacing: 14) { @@ -497,19 +521,35 @@ private struct DiarySummaryPage: View { ExportHeroRow(stats: daySnapshot.hero) if wide { HStack(alignment: .top, spacing: 16) { + // 왼쪽: 24시간 타임테이블 (세로로 길어 한 컬럼을 전담) VStack(spacing: 14) { - leftSections(innerWidth: leftWidth - 28) + timetableSections(innerWidth: timetableWidth - 28) } + .frame(width: timetableWidth) + // 오른쪽: 목표·다짐 현황 → 할 일 → 기록 상세 → 통계 막대 VStack(spacing: 14) { + DiaryGoalsCard( + goals: activeGoals, + dayKey: entry.dayKey, + referenceNow: referenceNow, + math: math + ) DiaryTodoCard(entry: entry) - rightSections(innerWidth: sideWidth - 28) + recordSections(innerWidth: rightWidth - 28) + barSections(innerWidth: rightWidth - 28) } - .frame(width: sideWidth) } } else { + DiaryGoalsCard( + goals: activeGoals, + dayKey: entry.dayKey, + referenceNow: referenceNow, + math: math + ) DiaryTodoCard(entry: entry) - leftSections(innerWidth: leftWidth - 28) - rightSections(innerWidth: leftWidth - 28) + timetableSections(innerWidth: fullWidth - 28) + recordSections(innerWidth: fullWidth - 28) + barSections(innerWidth: fullWidth - 28) } } .padding(20) @@ -533,34 +573,242 @@ private struct DiarySummaryPage: View { } } - /// 왼쪽(넓은) 컬럼: 타임테이블 + 통계 막대 @ViewBuilder - private func leftSections(innerWidth: CGFloat) -> some View { + private func timetableSections(innerWidth: CGFloat) -> some View { ForEach(Array(daySnapshot.sections.enumerated()), id: \.offset) { _, section in if case .timetable = section { ExportSectionView(section: section, innerWidth: innerWidth) } } - ForEach(Array(statBars.enumerated()), id: \.offset) { _, section in - ExportSectionView(section: section, innerWidth: innerWidth) + } + + @ViewBuilder + private func recordSections(innerWidth: CGFloat) -> some View { + let recordSections = daySnapshot.sections.filter { + if case .records = $0 { return true } + return false } - if daySnapshot.sections.isEmpty && statBars.isEmpty { - Text("아직 오늘의 기록이 없어요") + if recordSections.isEmpty { + Text("이 날의 기록이 없어요") .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) - .padding(.vertical, 40) + .padding(.vertical, 30) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } else { + ForEach(Array(recordSections.enumerated()), id: \.offset) { _, section in + ExportSectionView(section: section, innerWidth: innerWidth) + } } } - /// 오른쪽(좁은) 컬럼: 기록 목록 @ViewBuilder - private func rightSections(innerWidth: CGFloat) -> some View { - ForEach(Array(daySnapshot.sections.enumerated()), id: \.offset) { _, section in - if case .records = section { - ExportSectionView(section: section, innerWidth: innerWidth) + private func barSections(innerWidth: CGFloat) -> some View { + ForEach(Array(statBars.enumerated()), id: \.offset) { _, section in + ExportSectionView(section: section, innerWidth: innerWidth) + } + } +} + +// MARK: - 이 날의 목표·다짐 현황 카드 + +/// 그 날짜에 진행 중이던 목표들의 다짐별 진행 현황. +/// - 하루 다짐: 그 날짜가 수행일이면 그날의 실적/목표, 아니면 "수행일 아님"으로 표시 +/// - 주간/월간/기간 다짐: 주기 시작부터 "그 날짜 끝"까지의 누적을 목표량과 비교 +/// - '이하 유지' 다짐은 한도 대비 사용량 게이지(노랑, 초과 시 빨강)로 달성형과 구분 +private struct DiaryGoalsCard: View { + let goals: [Goal] + let dayKey: Date + let referenceNow: Date + let math: DayMath + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("이 날의 목표") + .font(.subheadline.weight(.semibold)) + Spacer() + Text(String(localized: "\(goals.count)개 진행 중")) + .font(.caption2) + .foregroundStyle(.secondary) } + if goals.isEmpty { + Text("이 날짜에 진행 중인 목표가 없어요") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } else { + ForEach(Array(goals.enumerated()), id: \.element.uuid) { index, goal in + if index > 0 { + Divider() + } + goalBlock(goal) + } + } + } + .padding(14) + .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + // MARK: 목표 한 덩어리 (헤더 + 다짐 줄들) + + @ViewBuilder + private func goalBlock(_ goal: Goal) -> some View { + let dayRatio = goal.combinedSpanRatio(.day, math: math, now: referenceNow) + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Image(systemName: goal.symbolName) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(goal.color, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + Text(goal.title) + .font(.footnote.weight(.semibold)) + .lineLimit(1) + Spacer(minLength: 6) + Text("\(Int((dayRatio * 100).rounded()))%") + .font(.footnote.weight(.bold).monospacedDigit()) + .foregroundStyle(goal.color) + } + // 목표 단위 하루 진행률 (다짐 진행률 평균) + gaugeBar(ratio: dayRatio, color: goal.color) + if goal.sortedQuests.isEmpty { + Text("다짐이 아직 없는 목표예요") + .font(.caption2) + .foregroundStyle(.tertiary) + } else { + ForEach(goal.sortedQuests, id: \.uuid) { quest in + questRow(quest) + } + } + } + } + + private func gaugeBar(ratio: Double, color: Color, height: CGFloat = 6) -> some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(color.opacity(0.15)) + Capsule() + .fill(color) + .frame(width: max(geo.size.width * min(max(ratio, 0), 1), ratio > 0 ? height : 0)) + } + } + .frame(height: height) + } + + // MARK: 다짐 한 줄 + + private struct QuestStat { + var contextLabel: String + var value: Double? + var achieved: Bool + } + + @ViewBuilder + private func questRow(_ quest: Quest) -> some View { + let stat = questStat(quest) + let target = quest.targetValue + let limit = quest.direction == .atMost + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: quest.targetSymbol) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 20, height: 20) + .background(quest.targetColor, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(quest.targetName) + .font(.caption.weight(.medium)) + .lineLimit(1) + Text(stat.contextLabel) + .font(.caption2) + .foregroundStyle(.tertiary) + Spacer(minLength: 4) + if let value = stat.value { + Group { + if limit { + Text("\(valueLabel(value, quest: quest)) / 한도 \(quest.targetValueLabel)") + } else { + Text("\(valueLabel(value, quest: quest)) / \(quest.targetValueLabel)") + } + } + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + if stat.achieved { + Image(systemName: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(AppTheme.green) + } else if limit { + Image(systemName: "exclamationmark.circle.fill") + .font(.caption) + .foregroundStyle(.red) + } + } + } + if let value = stat.value, target > 0 { + let ratio = value / target + gaugeBar( + ratio: ratio, + color: limit ? (ratio > 1 ? .red : AppTheme.yellow) : quest.targetColor, + height: 4 + ) + } + } + } + .opacity(stat.value == nil ? 0.55 : 1) + } + + /// 다짐의 "그 날짜 기준" 실적. value == nil이면 이 날짜엔 해당 없음(수행일 아님/기간 밖). + private func questStat(_ quest: Quest) -> QuestStat { + let progress = QuestProgress(quest: quest, math: math) + let dayRange = math.dayRange(forKey: dayKey) + + let measured: Range? + let context: String + switch quest.period { + case .daily: + if progress.isActiveDay(dayKey) { + measured = dayRange + context = String(localized: "하루 목표") + } else { + measured = nil + context = String(localized: "이 날은 수행일이 아니에요") + } + case .weekly: + let week = math.weekRange(containing: dayRange.lowerBound) + measured = week.lowerBound..= quest.targetValue + case .atMost: achieved = value <= quest.targetValue + } + return QuestStat(contextLabel: context, value: value, achieved: achieved) + } + + private func valueLabel(_ value: Double, quest: Quest) -> String { + switch quest.measure { + case .time: return Format.durationShort(value) + case .count: return String(localized: "\(Int(value))회") } } } diff --git a/myApp/HaruDanim/IOS/Views/ExportImageView.swift b/myApp/HaruDanim/IOS/Views/ExportImageView.swift index d5dcdc9..ea99937 100644 --- a/myApp/HaruDanim/IOS/Views/ExportImageView.swift +++ b/myApp/HaruDanim/IOS/Views/ExportImageView.swift @@ -203,7 +203,8 @@ enum ExportBuilder { entries: [CountEntry], orderedActions: [Action], excludedActionIDs: Set, - math: DayMath + math: DayMath, + trimHours: Bool = true ) -> ExportSnapshot { let included = orderedActions.filter { !excludedActionIDs.contains($0.persistentModelID) } let allowed = Set(included.map(\.persistentModelID)) @@ -220,7 +221,8 @@ enum ExportBuilder { var sections: [ExportSectionData] = [] if let (table, subtitle) = timetable(dayKeys: keys, emphasizedKey: dayKey, showDayLabels: true, sessions: sessions, entries: entries, - allowed: allowed, math: math, now: now) { + allowed: allowed, math: math, now: now, + trimHours: trimHours) { sections.append(.timetable(title: String(localized: "주간 타임테이블"), subtitle: subtitle, table)) } if let bars = actionTotalBars(segments: segments, counts: counts, type: .time) { @@ -251,7 +253,8 @@ enum ExportBuilder { var sections: [ExportSectionData] = [] if let (table, subtitle) = timetable(dayKeys: [dayKey], emphasizedKey: nil, showDayLabels: false, sessions: sessions, entries: entries, - allowed: allowed, math: math, now: now) { + allowed: allowed, math: math, now: now, + trimHours: trimHours) { sections.append(.timetable(title: String(localized: "타임테이블"), subtitle: subtitle, table)) } if !segments.isEmpty { @@ -352,7 +355,8 @@ enum ExportBuilder { private static func timetable( dayKeys: [Date], emphasizedKey: Date?, showDayLabels: Bool, sessions: [TimeSession], entries: [CountEntry], - allowed: Set, math: DayMath, now: Date + allowed: Set, math: DayMath, now: Date, + trimHours: Bool = true ) -> (ExportTimetableData, subtitle: String?)? { guard let firstKey = dayKeys.first else { return nil } var days: [ExportTimetableData.Day] = [] @@ -394,6 +398,13 @@ enum ExportBuilder { )) } + let startHour = math.calendar.component(.hour, from: math.dayRange(forKey: firstKey).lowerBound) + + // 일기 요약처럼 하루 전체를 고정으로 보여줄 때: 기록이 없어도 24시간 그리드를 그대로 반환 + if !trimHours { + return (ExportTimetableData(startHour: startHour, hourLo: 0, hourHi: 24, days: days), nil) + } + guard minFrac.isFinite else { return nil } // 기록이 없는 구간을 잘라 여백을 줄인다 (최소 6시간은 유지) @@ -403,7 +414,6 @@ enum ExportBuilder { hourHi = min(24, hourLo + 6) hourLo = max(0, hourHi - 6) } - let startHour = math.calendar.component(.hour, from: math.dayRange(forKey: firstKey).lowerBound) let subtitle: String? = (hourLo > 0 || hourHi < 24) ? String(localized: "기록이 있는 \(String(format: "%02d", (startHour + hourLo) % 24)):00 ~ \(String(format: "%02d", (startHour + hourHi) % 24)):00 구간만 표시") : nil diff --git a/myApp/HaruDanim/Shared/DiaryModels.swift b/myApp/HaruDanim/Shared/DiaryModels.swift index 8993be8..ed680af 100644 --- a/myApp/HaruDanim/Shared/DiaryModels.swift +++ b/myApp/HaruDanim/Shared/DiaryModels.swift @@ -76,6 +76,8 @@ final class DiaryPage { var index: Int = 0 /// true = 줄 노트 배경, false = 빈 캔버스 var lined: Bool = false + /// 줄 노트 줄 간격 (pt, 768×1024 논리 페이지 기준) + var lineSpacing: Double = 44 /// PKDrawing.dataRepresentation() @Attribute(.externalStorage) var drawingData: Data = Data() diff --git a/myApp/HaruDanim/Widgets/InfoPlist.xcstrings b/myApp/HaruDanim/Widgets/InfoPlist.xcstrings index 2459034..652784e 100644 --- a/myApp/HaruDanim/Widgets/InfoPlist.xcstrings +++ b/myApp/HaruDanim/Widgets/InfoPlist.xcstrings @@ -1,30 +1,42 @@ { - "sourceLanguage": "ko", - "strings": { - "CFBundleDisplayName": { - "comment": "위젯 익스텐션 표시 이름", - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Haru Danim" + "sourceLanguage" : "ko", + "strings" : { + "CFBundleDisplayName" : { + "comment" : "위젯 익스텐션 표시 이름", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haru Danim" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Haru Danim" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haru Danim" } }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "하루 다님" + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "하루 다님" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "new", + "value" : "Haru_DanimWidgets" } } } } }, - "version": "1.0" + "version" : "1.0" } \ No newline at end of file