mycode/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift
songyc macbook f70e6a1c06 fix(goal,watch,diary): judge goals as of their end date + watch l10n + template render polish
전체 재분석에서 확정한 4건 + 양식 개선 2건:

1) 목표 자동 판정 기준 시점 (핵심 수정)
   기존에는 판정이 "실행되는 시점의 현재 주기" 기준이라, 종료일
   며칠 뒤에 앱을 열면 목표 기간 밖의 데이터로 달성/미달성이
   갈렸다. 이제 자동 판정(evaluateIfEnded)은 **종료일이 속한
   논리적 하루의 마지막 순간**(judgmentReference) 기준 — 늦게
   실행해도, 종료일을 과거로 고쳐도 종료일까지의 기록으로 판정.
   (하루 시작 시간 설정으로 기준이 미래가 되는 경우 now로 상한)
   수동 종료는 종료일 없는 목표 전용이므로 기존대로 "지금" 기준.
   목표 편집 시 종료일 변경 처리: 진행 중 목표는 저장 즉시 판정,
   완료된 목표의 종료일을 바꾸면 '진행 중'으로 재개 후 재판정
   (미래/없음 → 진행 중 복귀, 다른 과거 날짜 → 그 시점 기준 재판정).
   검증: -endGoalYesterday로 어제 종료 → 종료일 기준 '달성' 판정 확인.

2) 워치 컴플리케이션 지역화 2건
   - 다짐 달성률 rectangular의 기간 라벨("하루/주간/월간")이 String
     파라미터라 미추출 → en/ja에서 한국어 노출 → String(localized:)
   - 빈 상태 문구("목표 없음"/"다짐 없음") 동일 문제 → 추출 + 워치
     앱/워치 위젯 카탈로그에 en/ja 번역 추가 (타깃별 stringsdata로
     sync — 카탈로그 오염 없음, 전 카탈로그 missing 0)

3) 배포 체크리스트: CloudKit 프로덕션 스키마 배포 항목을 CLAUDE.md
   §1에 추가 (DiaryTemplate 등 새 레코드 타입은 출시 전 대시보드
   Deploy Schema to Production 필수)

4) 일기 양식 렌더 개선
   - 첫 렌더 비동기화: 저장된 썸네일을 플레이스홀더로 즉시 깔고
     본 렌더(2×)를 백그라운드로 — 복잡한 벡터 PDF도 페이지 넘김이
     걸리지 않음
   - 비활성 페이지 고해상도 강등: 화면 밖 페이지는 래스터를 2×로
     상한(allowsHighResolution) — TabView가 이웃 페이지를 유지해도
     양식 여러 장의 메모리 사용을 절약, 활성 복귀 시 원래 배율 복원
   검증: 양식 페이지 2× 프로그램 줌에서 점 노트 선명(회귀 없음),
   시드 필기 페이지 획 유지 확인.

빌드: Debug·Store·워치 스킴 모두 성공. 도움말(목표 판정 문구
갱신 — 종료일 기준·재판정 규칙 안내)과 CLAUDE.md §4.3 갱신,
문구 수정 stale 1건 삭제 및 ko/en/ja 번역 완료.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-14 19:44:02 +09:00

722 lines
28 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// DiaryNotePage.swift
// Haru_Danim
//
// + / .
//
// : 768×1024pt scaleEffect /.
// (PKDrawing ) ( ) ·
// , ( 768pt ) .
//
// : ( ), ' '
// / · ( ).
//
import SwiftUI
import SwiftData
import PencilKit
import PhotosUI
/// ( )
enum DiaryPageMetrics {
static let size = CGSize(width: 768, height: 1024)
static let lineSpacing: CGFloat = 44
static let lineTopInset: CGFloat = 72
}
struct DiaryNotePageView: View {
@Bindable var page: DiaryPage
/// ( )
let isActive: Bool
@Environment(\.modelContext) private var context
@Environment(\.diaryReadOnly) private var readOnly
@Query private var allTemplates: [DiaryTemplate]
@State private var arranging = false
@State private var selectedItemID: UUID?
@State private var photoSelection: PhotosPickerItem?
///
@State private var editingTextItem: DiaryPageItem?
/// ( nil )
private var template: DiaryTemplate? {
guard !page.templateID.isEmpty else { return nil }
return allTemplates.first { $0.uuid.uuidString == page.templateID }
}
var body: some View {
VStack(spacing: 10) {
if !readOnly {
pageToolbar
}
// ( 4), .
// / · .
// .
DiaryZoomContainer(pageSize: DiaryPageMetrics.size, zoomEnabled: !arranging) {
pageContent
.frame(width: DiaryPageMetrics.size.width, height: DiaryPageMetrics.size.height)
}
}
.padding(.horizontal, 24)
.padding(.bottom, 34)
.onChange(of: photoSelection) {
guard let photoSelection else { return }
Task {
if let data = try? await photoSelection.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
addPhoto(image)
}
self.photoSelection = nil
}
}
.sheet(item: $editingTextItem) { item in
DiaryTextEditSheet(item: item, onCommit: save)
}
}
// MARK:
private var pageToolbar: some View {
HStack(spacing: 12) {
// ( )
if template == nil {
Toggle(isOn: $page.lined.animation()) {
Label("줄 노트", systemImage: "text.justify")
}
.toggleStyle(.button)
.onChange(of: page.lined) { save() }
}
if page.lined, template == nil {
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")
}
Menu {
shapeButton(.rectangle, label: String(localized: "사각형"), symbol: "rectangle")
shapeButton(.ellipse, label: String(localized: ""), symbol: "circle")
shapeButton(.arrow, label: String(localized: "화살표"), symbol: "arrow.right")
shapeButton(.line, label: String(localized: ""), symbol: "minus")
} label: {
Label("도형", systemImage: "square.on.circle")
}
Button {
addText()
} label: {
Label("텍스트", systemImage: "textformat")
}
Spacer()
if arranging, let selectedItemID,
let item = page.items.first(where: { $0.uuid == selectedItemID }) {
if item.kind == .text {
Button {
editingTextItem = item
} label: {
Label("글 수정", systemImage: "square.and.pencil")
}
}
Button(role: .destructive) {
deleteItem(item)
} label: {
Label("삭제", systemImage: "trash")
}
}
Toggle(isOn: $arranging.animation()) {
Label("배치 모드", systemImage: "hand.draw")
}
.toggleStyle(.button)
.onChange(of: arranging) {
if !arranging { selectedItemID = nil }
}
}
.font(.subheadline)
.tint(AppTheme.green)
.padding(.horizontal, 4)
.frame(maxWidth: DiaryPageMetrics.size.width)
}
private func shapeButton(_ kind: DiaryItemKind, label: String, symbol: String) -> some View {
Button {
addShape(kind)
} label: {
Label(label, systemImage: symbol)
}
}
// MARK: (768×1024 )
private var pageContent: some View {
ZStack {
//
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(AppTheme.surface)
.shadow(color: .black.opacity(0.10), radius: 14, y: 6)
if page.lined, template == nil {
DiaryLinedPaper(spacing: page.lineSpacing)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
// (PDF/) ·
if let template {
DiaryTemplateBackground(template: template, pageIndex: page.templatePageIndex,
isActive: isActive)
.allowsHitTesting(false)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
// (/)
ForEach(page.items, id: \.uuid) { item in
DiaryItemView(
item: item,
pageSize: DiaryPageMetrics.size,
arranging: arranging,
selectedID: $selectedItemID,
onCommit: save
)
}
// . ,
// ( ) .
DiaryPencilCanvas(
drawingData: page.drawingData,
isActive: isActive && !arranging && !readOnly
) { data in
page.drawingData = data
save()
}
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.allowsHitTesting(!arranging && !readOnly)
if arranging {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(AppTheme.yellow, style: StrokeStyle(lineWidth: 2, dash: [7, 5]))
}
}
.contentShape(Rectangle())
.onTapGesture {
if arranging { selectedItemID = nil }
}
}
// MARK: /
private func addPhoto(_ image: UIImage) {
let item = DiaryPageItem(kind: .photo)
item.imageData = image.diaryCompressed()
item.page = page
context.insert(item)
arranging = true
selectedItemID = item.uuid
save()
}
private func addShape(_ kind: DiaryItemKind) {
let item = DiaryPageItem(kind: kind)
item.colorHex = AppTheme.tagPresets.randomElement() ?? "#2F6B4F"
item.page = page
context.insert(item)
arranging = true
selectedItemID = item.uuid
save()
}
///
private func addText() {
let item = DiaryPageItem(kind: .text)
item.colorHex = "#3A3A3A"
item.widthRatio = 0.45
item.page = page
context.insert(item)
arranging = true
selectedItemID = item.uuid
save()
editingTextItem = item
}
private func deleteItem(_ item: DiaryPageItem) {
context.delete(item)
selectedItemID = nil
save()
}
private func save() {
page.entry?.updatedAt = .now
try? context.save()
}
}
// 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 += 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.allowedTouchTypes = [
NSNumber(value: UITouch.TouchType.direct.rawValue)
]
scroll.panGestureRecognizer.minimumNumberOfTouches = 1
scroll.pinchGestureRecognizer?.allowedTouchTypes = [
NSNumber(value: UITouch.TouchType.direct.rawValue)
]
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
#if DEBUG
// : -diaryZoomScale 2 2.5 fit N
// ( · )
let zoomFactor = UserDefaults.standard.double(forKey: "diaryZoomScale")
if zoomFactor > 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak scroll] in
guard let scroll else { return }
scroll.setZoomScale(scroll.minimumZoomScale * zoomFactor, animated: false)
scroll.delegate?.scrollViewDidEndZooming?(
scroll, with: scroll.zoomedView, atScale: scroll.zoomScale
)
}
}
#endif
return scroll
}
func updateUIView(_ scroll: DiaryZoomScrollView, context: Context) {
context.coordinator.hosting?.rootView = content()
scroll.pinchGestureRecognizer?.isEnabled = zoomEnabled
// ( )
scroll.panGestureRecognizer.minimumNumberOfTouches = zoomEnabled ? 1 : 2
}
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()
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
// ( )
(scrollView as? DiaryZoomScrollView)?.updateCanvasSharpness()
}
}
}
/// (· ) fit
final class DiaryZoomScrollView: UIScrollView {
var pageSize: CGSize = DiaryPageMetrics.size
weak var zoomedView: UIView?
private var didSetInitialZoom = false
private var lastCanvasScale: CGFloat = 0
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()
updateCanvasSharpness()
}
///
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)
}
/// .
/// 768×1024pt + ,
/// 1:1 (contentsScale = × ) ( )
/// ( ) . ( × 3).
/// · SharpPencilCanvasView ( ).
func updateCanvasSharpness() {
guard let zoomedView else { return }
let display = max(traitCollection.displayScale, 1)
let target = display * min(max(zoomScale, 0.7), 3)
guard abs(target - lastCanvasScale) > 0.01 else { return }
lastCanvasScale = target
Self.assignCanvasScale(target, in: zoomedView)
}
/// ·
/// (PDF )
private static func assignCanvasScale(_ scale: CGFloat, in view: UIView) {
if let canvas = view as? SharpPencilCanvasView {
canvas.targetContentsScale = scale
} else if let templateView = view as? DiaryTemplateImageView {
templateView.targetContentsScale = scale
}
for subview in view.subviews {
assignCanvasScale(scale, in: subview)
}
}
}
// MARK: - (/)
struct DiaryItemView: View {
@Bindable var item: DiaryPageItem
let pageSize: CGSize
let arranging: Bool
@Binding var selectedID: UUID?
var onCommit: () -> Void = {}
@State private var dragTranslation: CGSize = .zero
@State private var pinchScale: CGFloat = 1
private var isSelected: Bool { selectedID == item.uuid }
private var width: CGFloat {
pageSize.width * item.widthRatio * pinchScale
}
private var height: CGFloat {
if item.kind == .photo,
let data = item.imageData, let image = UIImage(data: data), image.size.width > 0 {
return width * image.size.height / image.size.width
}
return width * item.kind.defaultAspect
}
var body: some View {
content
.frame(width: width, height: height)
.overlay {
if arranging && isSelected {
RoundedRectangle(cornerRadius: 6)
.strokeBorder(AppTheme.green, lineWidth: 2)
}
}
.rotationEffect(.degrees(item.rotationDegrees))
.position(
x: pageSize.width * item.centerX + dragTranslation.width,
y: pageSize.height * item.centerY + dragTranslation.height
)
.allowsHitTesting(arranging)
.onTapGesture { selectedID = item.uuid }
.gesture(arranging ? moveAndResize : nil)
}
@ViewBuilder
private var content: some View {
switch item.kind {
case .photo:
if let data = item.imageData, let image = UIImage(data: data) {
Image(uiImage: image)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.shadow(color: .black.opacity(0.12), radius: 5, y: 2)
} else {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.2))
}
case .rectangle:
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color(hex: item.colorHex), lineWidth: 5)
case .ellipse:
Ellipse()
.strokeBorder(Color(hex: item.colorHex), lineWidth: 5)
case .arrow:
DiaryArrowShape()
.stroke(Color(hex: item.colorHex),
style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round))
case .line:
DiaryLineShape()
.stroke(Color(hex: item.colorHex),
style: StrokeStyle(lineWidth: 5, lineCap: .round))
case .text:
Text(item.text.isEmpty ? String(localized: "텍스트") : item.text)
.font(.system(size: 24, weight: .medium))
.foregroundStyle(Color(hex: item.colorHex).opacity(item.text.isEmpty ? 0.4 : 1))
.minimumScaleFactor(0.3)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
/// () + () . .
private var moveAndResize: some Gesture {
let drag = DragGesture()
.onChanged { value in
selectedID = item.uuid
dragTranslation = value.translation
}
.onEnded { value in
item.centerX = min(max(item.centerX + value.translation.width / pageSize.width, 0.02), 0.98)
item.centerY = min(max(item.centerY + value.translation.height / pageSize.height, 0.02), 0.98)
dragTranslation = .zero
onCommit()
}
let pinch = MagnificationGesture()
.onChanged { value in
selectedID = item.uuid
pinchScale = value
}
.onEnded { value in
item.widthRatio = min(max(item.widthRatio * value, 0.08), 0.95)
pinchScale = 1
onCommit()
}
return drag.simultaneously(with: pinch)
}
}
// MARK: -
private struct DiaryTextEditSheet: View {
@Bindable var item: DiaryPageItem
var onCommit: () -> Void
@Environment(\.dismiss) private var dismiss
@FocusState private var focused: Bool
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 12) {
TextField("내용을 입력하세요", text: $item.text, axis: .vertical)
.lineLimit(3...8)
.padding(10)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.focused($focused)
ColorPicker("글자 색", selection: Binding(
get: { Color(hex: item.colorHex) },
set: { item.colorHex = $0.hexString }
), supportsOpacity: false)
Spacer()
}
.padding()
.background(AppTheme.background)
.navigationTitle("텍스트 상자")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("완료") {
onCommit()
dismiss()
}
}
}
}
.presentationDetents([.height(300)])
.onAppear { focused = true }
}
}
struct DiaryArrowShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let midY = rect.midY
path.move(to: CGPoint(x: rect.minX, y: midY))
path.addLine(to: CGPoint(x: rect.maxX, y: midY))
let head = min(rect.width * 0.22, rect.height * 0.9)
path.move(to: CGPoint(x: rect.maxX - head, y: midY - head * 0.6))
path.addLine(to: CGPoint(x: rect.maxX, y: midY))
path.addLine(to: CGPoint(x: rect.maxX - head, y: midY + head * 0.6))
return path
}
}
struct DiaryLineShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.midY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
return path
}
}
// MARK: - PencilKit
/// PKCanvasView.
/// PencilKit
/// contentsScale .
final class SharpPencilCanvasView: PKCanvasView {
/// ( × )
var targetContentsScale: CGFloat = 0 {
didSet {
if abs(targetContentsScale - oldValue) > 0.01 { applySharpness() }
}
}
override func layoutSubviews() {
super.layoutSubviews()
applySharpness()
}
func applySharpness() {
guard targetContentsScale > 0 else { return }
Self.apply(targetContentsScale, view: self)
}
/// contentScaleFactor '' contentsScale
/// setNeedsDisplay() , PencilKit contents
///
/// (
/// ). PencilKit
/// .
private static func apply(_ scale: CGFloat, view: UIView) {
if abs(view.contentScaleFactor - scale) > 0.01 {
view.contentScaleFactor = scale
}
for subview in view.subviews {
apply(scale, view: subview)
}
}
}
struct DiaryPencilCanvas: UIViewRepresentable {
let drawingData: Data
/// ( ) &
let isActive: Bool
let onChange: (Data) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onChange: onChange)
}
func makeUIView(context: Context) -> SharpPencilCanvasView {
let canvas = SharpPencilCanvasView()
canvas.backgroundColor = .clear
canvas.isOpaque = false
#if targetEnvironment(simulator)
// ()
canvas.drawingPolicy = .anyInput
#else
// : /
canvas.drawingPolicy = .pencilOnly
#endif
canvas.delegate = context.coordinator
if let drawing = try? PKDrawing(data: drawingData), !drawingData.isEmpty {
canvas.drawing = drawing
}
context.coordinator.canvas = canvas
return canvas
}
func updateUIView(_ canvas: SharpPencilCanvasView, context: Context) {
context.coordinator.setActive(isActive)
}
@MainActor
final class Coordinator: NSObject, PKCanvasViewDelegate {
let onChange: (Data) -> Void
weak var canvas: SharpPencilCanvasView?
private let toolPicker = PKToolPicker()
private var observing = false
init(onChange: @escaping (Data) -> Void) {
self.onChange = onChange
}
func setActive(_ active: Bool) {
guard let canvas else { return }
if active {
if !observing {
toolPicker.addObserver(canvas)
observing = true
}
toolPicker.setVisible(true, forFirstResponder: canvas)
//
DispatchQueue.main.async {
if canvas.window != nil {
canvas.becomeFirstResponder()
}
}
} else if canvas.isFirstResponder {
canvas.resignFirstResponder()
}
}
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
onChange(canvasView.drawing.dataRepresentation())
//
(canvasView as? SharpPencilCanvasView)?.applySharpness()
}
}
}