- 달력(작성일 마커: 기분 이모지/연필 점) → 날짜별 일기, 이번 달 작성 수 표시 - 첫 페이지: 날짜·오늘 기분(이모지/사진, 끄기 옵션)·할 일 체크리스트(스크리블 입력)· 타임테이블·통계(내보내기 스냅숏 재사용으로 화면·내보내기 일치) - 노트 페이지: PencilKit 캔버스(도구 팔레트) + 줄 노트 배경 + 사진/도형(사각형·원·화살표·선) 배치 모드(드래그·핀치), 페이지 무한 추가 — 논리 크기 768×1024 고정으로 WYSIWYG 내보내기 - 내보내기: 구간/복수 날짜(MultiDatePicker) → PDF 1개 또는 날짜별 세로 스티치 PNG - SwiftData 모델 4종(CloudKit 호환 패턴) 스키마 추가, PremiumFeature.diary, iPhone 탭 컨텍스트(탭바·더보기·라디얼)에서 일기 제외, en/ja 번역 39건 - 검증 인자: -diarySeed/-diaryOpenToday/-diaryPage N/-diaryShowExport/-diaryExportRun pdf|image Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
408 lines
14 KiB
Swift
408 lines
14 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
|
||
GeometryReader { geo in
|
||
let scale = min(geo.size.width / DiaryPageMetrics.size.width,
|
||
geo.size.height / DiaryPageMetrics.size.height)
|
||
pageContent
|
||
.frame(width: DiaryPageMetrics.size.width, height: DiaryPageMetrics.size.height)
|
||
.scaleEffect(scale)
|
||
.frame(width: geo.size.width, height: geo.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() }
|
||
|
||
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()
|
||
.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 {
|
||
var body: some View {
|
||
Canvas { canvasContext, size in
|
||
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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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())
|
||
}
|
||
}
|
||
}
|