feat(diary): redesign summary page as daily review and add note pinch-zoom, line spacing
- 첫 페이지를 '하루 정리' 화면으로 개편: 기록 유무와 무관한 24시간 고정 타임테이블(trimHours 옵션), 그 날짜에 진행 중이던 목표들의 다짐별 진행 현황 카드(하루 다짐은 그날 실적, 주간/월간/기간 다짐은 그 날짜까지 누적, '이하 유지'는 한도 게이지로 구분), 기록 상세·통계 막대 재배치 - 노트 페이지: UIScrollView 기반 핀치줌(두 손가락, fit~4배, 배치 모드에선 잠금) - 줄 노트 줄 간격 설정(좁게/보통/넓게/아주 넓게, 페이지별 저장·내보내기 반영) - 새 문자열 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
1f77264852
commit
ecb7b43d31
@ -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": {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<Content: View>: 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<Content>?
|
||||
|
||||
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 {
|
||||
|
||||
@ -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<Date>?
|
||||
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..<min(dayRange.upperBound, week.upperBound)
|
||||
context = String(localized: "주간 목표 · 이 날까지 누적")
|
||||
case .monthly:
|
||||
let month = math.monthRange(containing: dayRange.lowerBound)
|
||||
measured = month.lowerBound..<min(dayRange.upperBound, month.upperBound)
|
||||
context = String(localized: "월간 목표 · 이 날까지 누적")
|
||||
case .custom:
|
||||
if let full = progress.currentPeriodRange(now: referenceNow), full.overlaps(dayRange) {
|
||||
measured = full.lowerBound..<min(dayRange.upperBound, full.upperBound)
|
||||
context = String(localized: "기간 목표 · 이 날까지 누적")
|
||||
} else {
|
||||
measured = nil
|
||||
context = String(localized: "설정된 기간 밖이에요")
|
||||
}
|
||||
}
|
||||
|
||||
guard let measured else {
|
||||
return QuestStat(contextLabel: context, value: nil, achieved: false)
|
||||
}
|
||||
let value = progress.value(in: measured, now: referenceNow)
|
||||
let achieved: Bool
|
||||
switch quest.direction {
|
||||
case .atLeast: achieved = value >= 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))회")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -203,7 +203,8 @@ enum ExportBuilder {
|
||||
entries: [CountEntry],
|
||||
orderedActions: [Action],
|
||||
excludedActionIDs: Set<PersistentIdentifier>,
|
||||
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<PersistentIdentifier>, math: DayMath, now: Date
|
||||
allowed: Set<PersistentIdentifier>, 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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user