- 첫 페이지를 '하루 정리' 화면으로 개편: 기록 유무와 무관한 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
521 lines
19 KiB
Swift
521 lines
19 KiB
Swift
//
|
||
// 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())
|
||
}
|
||
}
|
||
}
|