mycode/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift
songyc macbook ecb7b43d31 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
2026-07-13 03:46:58 +09:00

521 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

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
@State private var arranging = false
@State private var selectedItemID: UUID?
@State private var photoSelection: PhotosPickerItem?
var body: some View {
VStack(spacing: 10) {
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
}
}
}
// MARK:
private var pageToolbar: some View {
HStack(spacing: 12) {
Toggle(isOn: $page.lined.animation()) {
Label("줄 노트", systemImage: "text.justify")
}
.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")
}
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")
}
Spacer()
if arranging, let selectedItemID,
let item = page.items.first(where: { $0.uuid == selectedItemID }) {
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 {
DiaryLinedPaper(spacing: page.lineSpacing)
.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
) { data in
page.drawingData = data
save()
}
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.allowsHitTesting(!arranging)
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 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.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 {
@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))
}
}
/// () + () . .
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)
}
}
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
struct DiaryPencilCanvas: UIViewRepresentable {
let drawingData: Data
/// ( ) &
let isActive: Bool
let onChange: (Data) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onChange: onChange)
}
func makeUIView(context: Context) -> PKCanvasView {
let canvas = PKCanvasView()
canvas.backgroundColor = .clear
canvas.isOpaque = false
// · ( )
canvas.drawingPolicy = .anyInput
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: PKCanvasView, context: Context) {
context.coordinator.setActive(isActive)
}
@MainActor
final class Coordinator: NSObject, PKCanvasViewDelegate {
let onChange: (Data) -> Void
weak var canvas: PKCanvasView?
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())
}
}
}