실사용 보고: 도형이 고정 비율 손잡이 하나뿐이라 모양을 다듬을 수 없고, 텍스트는 시트에 입력하는 방식이 일반 노트 앱과 동떨어져 있었다. - 종류별 모양 손잡이 (드래그는 페이지 좌표계로 받아 실시간 회전에도 안정): · 사각형: 꼭짓점 4개 — 끈 모서리만 움직이고 반대편 고정 · 원: 상하좌우 4개 — 늘리고 눌러 타원 만들기 · 선·화살표: 양 끝점 — 반대 끝 고정한 채 길이+방향 동시 조절 · 사진·텍스트: 우하단 1개 (사진은 원본 비율 유지) - 정사각형·정원 스냅: |가로-세로| ≤ 10pt면 딱 맞추고 손잡이·테두리 노란색 강조 + 햅틱. 핀치는 모양 비율 그대로 균등 스케일 - 텍스트 상자 제자리 입력: 선택된 상자를 탭하면 그 자리에서 키보드 입력·줄바꿈 (생성 직후에도 바로 입력). '글 수정' 시트는 '스타일' 시트로 재편 — 테두리 켬/끔 + 독립 테두리 색(borderColorHex, 빈 값 = 글자 색) + 굵기 - borderColorHex 필드 추가 (기본값 — CloudKit·구버전 안전) - 검증 인자 -diaryTextEdit 추가, 도움말 갱신 + en/ja 번역 - 검증: Debug/Store 빌드, 아이패드 심 손잡이 배치·회전 정합·제자리 편집 렌더 확인, 카탈로그 missing·stale 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
238 lines
8.6 KiB
Swift
238 lines
8.6 KiB
Swift
//
|
||
// DiaryModels.swift
|
||
// Haru_Danim
|
||
//
|
||
// 일기(다이어리) 데이터 모델 — iPad 전용 프리미엄 기능.
|
||
// 하루(논리적 하루 키)당 DiaryEntry 하나:
|
||
// - 첫 페이지(요약): 오늘 기분(이모지 또는 사진) + 할 일 체크리스트 (+ 그날의 기록/통계는 즉석 계산)
|
||
// - 이후 페이지: PencilKit 자유 필기 캔버스(줄 노트 배경 선택) + 사진/도형 배치
|
||
// CloudKit 호환 규칙(모든 프로퍼티 기본값, to-many 관계 optional storage + computed 접근자)을 따른다.
|
||
//
|
||
|
||
import Foundation
|
||
import SwiftData
|
||
|
||
@Model
|
||
final class DiaryEntry {
|
||
var uuid: UUID = UUID()
|
||
/// 논리적 하루 키 (DayMath.dayKey — 해당 달력일의 자정)
|
||
var dayKey: Date = Date()
|
||
/// 오늘 기분 이모지 (사진과 동시 사용 가능, 빈 문자열 = 미설정)
|
||
var moodEmoji: String = ""
|
||
/// 오늘 기분 사진 (사진 앨범에서 선택, nil = 미설정)
|
||
@Attribute(.externalStorage)
|
||
var moodImageData: Data? = nil
|
||
/// 이 날짜의 타임테이블에 함께 표시할 캘린더 일정 ID들 (EKEvent.eventIdentifier).
|
||
/// 날짜마다 따로 선택한다 — 오늘은 포함하고 내일은 안 하는 식.
|
||
var calendarEventIDs: [String] = []
|
||
/// 이 날짜의 타임테이블에서 숨길 행동 uuid 문자열들 (비어 있으면 전체 표시).
|
||
/// 캘린더 일정과 마찬가지로 날짜마다 따로 저장하고, 내보내기에도 동일하게 반영.
|
||
var hiddenActionIDs: [String] = []
|
||
/// 이 날짜의 '이 날의 목표' 카드에서 숨길 목표 uuid 문자열들 (비어 있으면 전체 표시).
|
||
/// 기본값 있는 새 필드 — CloudKit 호환 규칙(§3)에 따라 스키마 추가만으로 안전.
|
||
var hiddenGoalIDs: [String] = []
|
||
var createdAt: Date = Date()
|
||
var updatedAt: Date = Date()
|
||
|
||
@Relationship(deleteRule: .cascade, inverse: \DiaryTodo.entry)
|
||
var todosStorage: [DiaryTodo]? = []
|
||
|
||
@Relationship(deleteRule: .cascade, inverse: \DiaryPage.entry)
|
||
var pagesStorage: [DiaryPage]? = []
|
||
|
||
init(dayKey: Date) {
|
||
self.dayKey = dayKey
|
||
}
|
||
|
||
var todos: [DiaryTodo] {
|
||
get { (todosStorage ?? []).sorted { $0.sortOrder < $1.sortOrder } }
|
||
set { todosStorage = newValue }
|
||
}
|
||
|
||
var pages: [DiaryPage] {
|
||
get { (pagesStorage ?? []).sorted { $0.index < $1.index } }
|
||
set { pagesStorage = newValue }
|
||
}
|
||
|
||
/// 달력 마커 표시 기준: 무언가 하나라도 기록했으면 "작성함"
|
||
var hasContent: Bool {
|
||
!moodEmoji.isEmpty || moodImageData != nil
|
||
|| !(todosStorage ?? []).isEmpty
|
||
|| (pagesStorage ?? []).contains { $0.hasContent }
|
||
}
|
||
}
|
||
|
||
/// 요약 페이지의 할 일 한 줄 (펜슬 탭으로 체크)
|
||
@Model
|
||
final class DiaryTodo {
|
||
var uuid: UUID = UUID()
|
||
var text: String = ""
|
||
var isDone: Bool = false
|
||
var sortOrder: Int = 0
|
||
var entry: DiaryEntry?
|
||
|
||
init(text: String = "", sortOrder: Int = 0) {
|
||
self.text = text
|
||
self.sortOrder = sortOrder
|
||
}
|
||
}
|
||
|
||
/// 자유 필기 노트 페이지
|
||
@Model
|
||
final class DiaryPage {
|
||
var uuid: UUID = UUID()
|
||
/// 페이지 순서 (0 = 요약 다음 첫 노트)
|
||
var index: Int = 0
|
||
/// true = 줄 노트 배경, false = 빈 캔버스
|
||
var lined: Bool = false
|
||
/// 줄 노트 줄 간격 (pt, 768×1024 논리 페이지 기준)
|
||
var lineSpacing: Double = 44
|
||
/// 배경 양식 (DiaryTemplate.uuid 문자열, 빈 값 = 없음).
|
||
/// uuid 문자열 참조라 동기화 순서와 무관하게 안전 — 양식이 없으면(삭제/미도착) 빈 캔버스로 폴백.
|
||
var templateID: String = ""
|
||
/// 여러 쪽 PDF 양식에서 이 페이지가 쓰는 쪽 (0부터)
|
||
var templatePageIndex: Int = 0
|
||
/// PKDrawing.dataRepresentation()
|
||
@Attribute(.externalStorage)
|
||
var drawingData: Data = Data()
|
||
|
||
var entry: DiaryEntry?
|
||
|
||
@Relationship(deleteRule: .cascade, inverse: \DiaryPageItem.page)
|
||
var itemsStorage: [DiaryPageItem]? = []
|
||
|
||
init(index: Int) {
|
||
self.index = index
|
||
}
|
||
|
||
var items: [DiaryPageItem] {
|
||
get { (itemsStorage ?? []).sorted { $0.createdAt < $1.createdAt } }
|
||
set { itemsStorage = newValue }
|
||
}
|
||
|
||
var hasContent: Bool {
|
||
// 양식을 깐 페이지는 아직 필기가 없어도 의도적으로 만든 것 → 작성함으로 취급 (내보내기 포함)
|
||
!drawingData.isEmpty || !(itemsStorage ?? []).isEmpty || !templateID.isEmpty
|
||
}
|
||
}
|
||
|
||
// MARK: - 양식 (노트 페이지 배경 속지)
|
||
|
||
/// 외부에서 가져온 노트 페이지 배경 양식.
|
||
/// PDF는 여러 쪽이어도 **원본을 통째로 1개만** 저장하고(pageCount 기억), 페이지를 추가할 때
|
||
/// 쪽을 고른다 — 쪽마다 복제하지 않아 저장·동기화가 가볍다. 이미지는 리샘플해 1쪽으로 저장.
|
||
@Model
|
||
final class DiaryTemplate {
|
||
var uuid: UUID = UUID()
|
||
var name: String = ""
|
||
/// DiaryTemplateKind rawValue
|
||
var kindRaw: String = DiaryTemplateKind.pdf.rawValue
|
||
/// 원본 데이터 (PDF 전체 또는 리샘플된 이미지)
|
||
@Attribute(.externalStorage)
|
||
var data: Data = Data()
|
||
/// 라이브러리 표시용 첫 쪽 썸네일 (나머지 쪽 썸네일은 선택 화면에서 즉석 생성)
|
||
@Attribute(.externalStorage)
|
||
var thumbnailData: Data? = nil
|
||
/// 사용 가능한 쪽 수 (이미지 = 1, PDF = 가져오기 상한까지)
|
||
var pageCount: Int = 1
|
||
var createdAt: Date = Date()
|
||
|
||
init(name: String, kind: DiaryTemplateKind) {
|
||
self.name = name
|
||
self.kindRaw = kind.rawValue
|
||
}
|
||
|
||
var kind: DiaryTemplateKind {
|
||
get { DiaryTemplateKind(rawValue: kindRaw) ?? .pdf }
|
||
set { kindRaw = newValue.rawValue }
|
||
}
|
||
}
|
||
|
||
nonisolated enum DiaryTemplateKind: String, CaseIterable {
|
||
case pdf
|
||
case image
|
||
}
|
||
|
||
/// 노트 페이지 위에 배치하는 요소 (사진, 도형 또는 텍스트 상자)
|
||
@Model
|
||
final class DiaryPageItem {
|
||
var uuid: UUID = UUID()
|
||
/// DiaryItemKind rawValue
|
||
var kindRaw: String = DiaryItemKind.photo.rawValue
|
||
/// 사진일 때만 사용
|
||
@Attribute(.externalStorage)
|
||
var imageData: Data? = nil
|
||
/// 텍스트 상자일 때만 사용 (키보드로 입력하는 글)
|
||
var text: String = ""
|
||
/// 도형·텍스트 색 (hex)
|
||
var colorHex: String = "#2F6B4F"
|
||
/// 페이지 폭 대비 중심 좌표 비율 (0...1) — 페이지 크기가 달라져도 같은 자리에 놓인다
|
||
var centerX: Double = 0.5
|
||
var centerY: Double = 0.3
|
||
/// 페이지 폭 대비 너비 비율
|
||
var widthRatio: Double = 0.35
|
||
/// 페이지 "폭" 대비 높이 비율 (widthRatio와 같은 기준 — 자유 비율 크기 조절용).
|
||
/// 0 = 미지정 → 종류별 기본 비율(defaultAspect)로 그린다 (레거시 데이터·새 요소 초기값).
|
||
/// 사진은 항상 원본 비율을 따르므로 이 값을 쓰지 않는다.
|
||
var heightRatio: Double = 0
|
||
var rotationDegrees: Double = 0
|
||
/// 도형 선 굵기·텍스트 테두리 굵기(pt). 0 = 종류별 기본값
|
||
var lineWidth: Double = 0
|
||
/// 사각형·원 채움 여부 (테두리 대신 면으로 그림)
|
||
var filled: Bool = false
|
||
/// 사각형 모서리 둥글기(pt). 음수 = 기본 10
|
||
var cornerRadius: Double = -1
|
||
/// 텍스트 글자 크기(pt). 0 = 기본 24
|
||
var fontSize: Double = 0
|
||
/// 텍스트 굵게
|
||
var textBold: Bool = false
|
||
/// 텍스트 정렬 — "" = 왼쪽, "center", "trailing"
|
||
var textAlignRaw: String = ""
|
||
/// 텍스트 상자 테두리 표시
|
||
var textBorder: Bool = false
|
||
/// 텍스트 상자 테두리 색 (hex). 빈 값 = 글자 색을 따름
|
||
var borderColorHex: String = ""
|
||
var createdAt: Date = Date()
|
||
|
||
var page: DiaryPage?
|
||
|
||
init(kind: DiaryItemKind) {
|
||
self.kindRaw = kind.rawValue
|
||
}
|
||
|
||
var kind: DiaryItemKind {
|
||
get { DiaryItemKind(rawValue: kindRaw) ?? .photo }
|
||
set { kindRaw = newValue.rawValue }
|
||
}
|
||
}
|
||
|
||
nonisolated enum DiaryItemKind: String, CaseIterable {
|
||
case photo
|
||
case rectangle
|
||
case ellipse
|
||
case arrow
|
||
case line
|
||
case text
|
||
|
||
/// 사진 대비 도형의 기본 세로 비율 (widthRatio 기준)
|
||
var defaultAspect: Double {
|
||
switch self {
|
||
case .photo: return 1
|
||
case .rectangle: return 0.7
|
||
case .ellipse: return 0.7
|
||
case .arrow: return 0.35
|
||
case .line: return 0.1
|
||
case .text: return 0.4
|
||
}
|
||
}
|
||
|
||
/// lineWidth 미지정(0)일 때의 기본 선 굵기 — 기존 하드코딩 값과 동일 (텍스트는 테두리 굵기)
|
||
var defaultLineWidth: Double {
|
||
switch self {
|
||
case .arrow: return 6
|
||
case .text: return 2
|
||
default: return 5
|
||
}
|
||
}
|
||
}
|