mycode/myApp/HaruDanim/IOS/Views/DiaryTemplates.swift
songyc macbook 3067877d37 fix(diary): make full template row tappable in page chooser sheet
페이지 추가 시트의 '내 양식' 목록에서 한 쪽짜리 양식(사진·1쪽 PDF) 행이
왼쪽(썸네일·이름)만 눌리고 오른쪽 절반은 반응하지 않던 문제 수정.
DiaryTemplateRowLabel이 내용물 폭만 차지해 plain 버튼의 contentShape도
그 좁은 라벨에만 적용됐던 것 — 라벨을 frame(maxWidth: .infinity)로 행
전체 폭으로 넓힌 뒤 contentShape(Rectangle())를 적용해 행 어디를 눌러도
동작하게 했다. 여러 쪽 PDF 행(NavigationLink)은 원래 전체 폭이 눌려
변경 없음.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-15 01:48:09 +09:00

618 lines
26 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.

//
// DiaryTemplates.swift
// Haru_Danim
//
// () PDF/ (CLAUDE.md §6.7).
//
// :
// - DiaryTemplateRenderer: PDF (768×1024) aspect-fit .
// PDF " " .
// (3×) .
// - DiaryTemplateBackground: .
// DiaryZoomScrollView (assignCanvasScale) .
// - DiaryTemplateManagerView: (· ·).
// - DiaryPageChooserSheet: [ / / ] .
// PDF 2 .
//
// (· ).
//
import SwiftUI
import SwiftData
import PhotosUI
import UniformTypeIdentifiers
// MARK: -
enum DiaryTemplateRenderer {
/// PDF ( CloudKit )
static let maxPDFBytes = 20 * 1024 * 1024
/// ( )
static let maxPages = 24
/// ( px) ( ×3)
static let maxImageSide: CGFloat = 3072
/// PDF ( nil)
static func pdfPageCount(_ data: Data) -> Int? {
guard let provider = CGDataProvider(data: data as CFData),
let document = CGPDFDocument(provider) else { return nil }
return document.numberOfPages
}
/// PDF (768×1024) aspect-fit .
/// PDF(A4 ) (·) .
/// scale = ( × , )
static func renderPDFPage(data: Data, pageIndex: Int, scale: CGFloat) -> UIImage? {
guard scale > 0,
let provider = CGDataProvider(data: data as CFData),
let document = CGPDFDocument(provider),
let page = document.page(at: pageIndex + 1) else { return nil }
let pageSize = DiaryPageMetrics.size
let box = page.getBoxRect(.cropBox)
guard box.width > 0, box.height > 0 else { return nil }
let fitScale = min(pageSize.width / box.width, pageSize.height / box.height)
let drawSize = CGSize(width: box.width * fitScale, height: box.height * fitScale)
let origin = CGPoint(x: (pageSize.width - drawSize.width) / 2,
y: (pageSize.height - drawSize.height) / 2)
let format = UIGraphicsImageRendererFormat()
format.scale = scale
format.opaque = false
return UIGraphicsImageRenderer(size: pageSize, format: format).image { ctx in
let cg = ctx.cgContext
// PDF '' ( )
cg.setFillColor(UIColor.white.cgColor)
cg.fill(CGRect(origin: origin, size: drawSize))
cg.saveGState()
cg.translateBy(x: origin.x, y: origin.y + drawSize.height)
cg.scaleBy(x: fitScale, y: -fitScale)
cg.translateBy(x: -box.minX, y: -box.minY)
cg.interpolationQuality = .high
cg.drawPDFPage(page)
cg.restoreGState()
}
}
/// ( )
static func image(for template: DiaryTemplate, pageIndex: Int, scale: CGFloat) -> UIImage? {
switch template.kind {
case .pdf:
return renderPDFPage(data: template.data, pageIndex: pageIndex, scale: scale)
case .image:
return UIImage(data: template.data)
}
}
/// ( = )
static func normalizedImageData(_ image: UIImage) -> Data? {
let pixelWidth = image.size.width * image.scale
let pixelHeight = image.size.height * image.scale
let longest = max(pixelWidth, pixelHeight)
guard longest > 0 else { return nil }
let ratio = longest > maxImageSide ? maxImageSide / longest : 1
let target = CGSize(width: pixelWidth * ratio, height: pixelHeight * ratio)
let format = UIGraphicsImageRendererFormat()
format.scale = 1
let resized = UIGraphicsImageRenderer(size: target, format: format).image { _ in
image.draw(in: CGRect(origin: .zero, size: target))
}
return resized.jpegData(compressionQuality: 0.85)
}
/// ( px).
/// () ,
/// 릿 (JPEG KB ).
static let thumbnailSide: CGFloat = 512
/// ( )
static func thumbnailData(kind: DiaryTemplateKind, data: Data) -> Data? {
switch kind {
case .pdf:
return renderPDFPage(data: data, pageIndex: 0, scale: thumbnailSide / DiaryPageMetrics.size.height)?
.jpegData(compressionQuality: 0.8)
case .image:
guard let image = UIImage(data: data) else { return nil }
let longest = max(image.size.width, image.size.height)
guard longest > 0 else { return nil }
let ratio = min(thumbnailSide / longest, 1)
let target = CGSize(width: image.size.width * ratio, height: image.size.height * ratio)
let format = UIGraphicsImageRendererFormat()
format.scale = 1
return UIGraphicsImageRenderer(size: target, format: format).image { _ in
image.draw(in: CGRect(origin: .zero, size: target))
}.jpegData(compressionQuality: 0.8)
}
}
}
// MARK: - ( )
/// . PDF
/// ( ).
///
/// PDF .
/// .
final class DiaryTemplateImageView: UIImageView {
/// · (SwiftUI )
var renderKey = ""
private var pdfData: Data?
private var pdfPageIndex = 0
private var renderedScale: CGFloat = 0
private var renderTask: Task<Void, Never>?
/// DiaryZoomScrollView ( × , 3)
var targetContentsScale: CGFloat = 0 {
didSet { renderIfNeeded() }
}
/// () TabView
/// , 2× .
var allowsHighResolution = true {
didSet {
guard oldValue != allowsHighResolution else { return }
renderIfNeeded()
}
}
/// PDF + ()
func setPDF(data: Data, pageIndex: Int, placeholder: UIImage?) {
pdfData = data
pdfPageIndex = pageIndex
renderedScale = 0
image = placeholder
if targetContentsScale <= 0 { targetContentsScale = 2 }
renderIfNeeded()
}
/// ( )
func setImage(data: Data) {
pdfData = nil
renderedScale = 0
image = UIImage(data: data)
}
/// 2× ( )
private var effectiveScale: CGFloat {
allowsHighResolution ? targetContentsScale : min(targetContentsScale, 2)
}
private func renderIfNeeded() {
guard pdfData != nil, effectiveScale > 0,
abs(effectiveScale - renderedScale) > 0.01 else { return }
scheduleRender()
}
private func scheduleRender() {
guard let data = pdfData else { return }
let scale = effectiveScale
let index = pdfPageIndex
renderedScale = scale
renderTask?.cancel()
renderTask = Task { [weak self] in
// CoreGraphics PDF ·
let image = await Task.detached(priority: .userInitiated) {
DiaryTemplateRenderer.renderPDFPage(data: data, pageIndex: index, scale: scale)
}.value
guard !Task.isCancelled else { return }
guard let image else {
// ( ) (/)
self?.renderedScale = 0
return
}
self?.image = image
}
}
}
/// ZStack
struct DiaryTemplateBackground: UIViewRepresentable {
let template: DiaryTemplate
let pageIndex: Int
/// ( )
var isActive = true
func makeUIView(context: Context) -> DiaryTemplateImageView {
let view = DiaryTemplateImageView()
view.contentMode = .scaleAspectFit
configure(view)
return view
}
func updateUIView(_ view: DiaryTemplateImageView, context: Context) {
configure(view)
}
private func configure(_ view: DiaryTemplateImageView) {
view.allowsHighResolution = isActive
let key = "\(template.uuid.uuidString)-\(pageIndex)"
guard view.renderKey != key else { return }
view.renderKey = key
switch template.kind {
case .pdf:
view.setPDF(
data: template.data,
pageIndex: pageIndex,
placeholder: template.thumbnailData.flatMap(UIImage.init(data:))
)
case .image:
view.setImage(data: template.data)
}
}
}
// MARK: -
///
enum DiaryPageStyle {
case blank
case lined
case template(DiaryTemplate, pageIndex: Int)
}
/// : / / ( 2 )
struct DiaryPageChooserSheet: View {
let templates: [DiaryTemplate]
let onPick: (DiaryPageStyle) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
basicRow(symbol: "square", title: "빈 캔버스") { pick(.blank) }
basicRow(symbol: "text.justify", title: "줄 노트") { pick(.lined) }
}
Section("내 양식") {
ForEach(templates, id: \.uuid) { template in
if template.pageCount > 1 {
// PDF: 2
NavigationLink {
DiaryTemplatePagePicker(template: template) { index in
pick(.template(template, pageIndex: index))
}
} label: {
DiaryTemplateRowLabel(template: template)
}
} else {
Button {
pick(.template(template, pageIndex: 0))
} label: {
DiaryTemplateRowLabel(template: template)
//
//
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("페이지 추가")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("닫기") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
}
private func pick(_ style: DiaryPageStyle) {
onPick(style)
dismiss()
}
private func basicRow(symbol: String, title: LocalizedStringKey,
action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 12) {
Image(systemName: symbol)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(AppTheme.green)
.frame(width: 30)
Text(title)
.foregroundStyle(.primary)
Spacer()
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
/// ( + + ·)
struct DiaryTemplateRowLabel: View {
let template: DiaryTemplate
var body: some View {
HStack(spacing: 12) {
Group {
if let data = template.thumbnailData, let image = UIImage(data: data) {
Image(uiImage: image)
.resizable()
.scaledToFit()
} else {
Image(systemName: "doc.text.image")
.foregroundStyle(.tertiary)
}
}
.frame(width: 44, height: 56)
.background(Color.white, in: RoundedRectangle(cornerRadius: 6, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 6, style: .continuous).strokeBorder(.quaternary))
VStack(alignment: .leading, spacing: 2) {
Text(template.name)
.font(.subheadline.weight(.medium))
.foregroundStyle(.primary)
.lineLimit(1)
HStack(spacing: 4) {
if template.kind == .pdf {
Text(verbatim: "PDF")
} else {
Text("이미지")
}
if template.pageCount > 1 {
Text("\(template.pageCount)")
}
}
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
/// PDF ( )
private struct DiaryTemplatePagePicker: View {
let template: DiaryTemplate
let onPick: (Int) -> Void
@State private var thumbnails: [Int: UIImage] = [:]
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 140, maximum: 190), spacing: 12)],
spacing: 14) {
ForEach(0..<template.pageCount, id: \.self) { index in
Button {
onPick(index)
} label: {
VStack(spacing: 6) {
Group {
if let image = thumbnails[index] {
Image(uiImage: image)
.resizable()
.scaledToFit()
} else {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(.quaternary.opacity(0.4))
.overlay(ProgressView())
.aspectRatio(3 / 4, contentMode: .fit)
}
}
.frame(maxWidth: .infinity)
.background(Color.white, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).strokeBorder(.quaternary))
Text("\(index + 1)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
.task {
guard thumbnails[index] == nil else { return }
let data = template.data
let image = await Task.detached(priority: .userInitiated) {
DiaryTemplateRenderer.renderPDFPage(
data: data, pageIndex: index,
scale: 260 / DiaryPageMetrics.size.height
)
}.value
if let image { thumbnails[index] = image }
}
}
}
.padding()
}
.background(AppTheme.background)
.navigationTitle(template.name)
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - ( )
struct DiaryTemplateManagerView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var context
@Query(sort: \DiaryTemplate.createdAt) private var templates: [DiaryTemplate]
@Query private var allPages: [DiaryPage]
@State private var showingPDFImporter = false
@State private var photoSelection: PhotosPickerItem?
@State private var noticeMessage: String?
@State private var renamingTemplate: DiaryTemplate?
@State private var renameText = ""
@State private var deletingTemplate: DiaryTemplate?
var body: some View {
NavigationStack {
List {
Section {
Button {
showingPDFImporter = true
} label: {
Label("PDF 가져오기", systemImage: "doc.badge.plus")
.foregroundStyle(AppTheme.green)
}
PhotosPicker(selection: $photoSelection, matching: .images) {
Label("사진에서 가져오기", systemImage: "photo.badge.plus")
.foregroundStyle(AppTheme.green)
}
} footer: {
Text("가져온 양식은 노트 페이지를 추가할 때 배경으로 고를 수 있어요. 여러 쪽 PDF는 원하는 쪽을 골라 쓸 수 있어요. (쪽은 최대 \(DiaryTemplateRenderer.maxPages)쪽, PDF 파일은 20MB까지)")
}
Section("내 양식") {
if templates.isEmpty {
Text("가져온 양식이 없어요")
.foregroundStyle(.secondary)
}
ForEach(templates, id: \.uuid) { template in
row(template)
}
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("양식 관리")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("닫기") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
.fileImporter(isPresented: $showingPDFImporter, allowedContentTypes: [.pdf]) { result in
importPDF(result)
}
.onChange(of: photoSelection) {
guard let photoSelection else { return }
Task {
if let data = try? await photoSelection.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
importPhoto(image)
}
self.photoSelection = nil
}
}
.alert(
"양식 가져오기",
isPresented: Binding(get: { noticeMessage != nil }, set: { if !$0 { noticeMessage = nil } })
) {
Button("확인") {}
} message: {
Text(noticeMessage ?? "")
}
.alert(
"이름 변경",
isPresented: Binding(get: { renamingTemplate != nil }, set: { if !$0 { renamingTemplate = nil } }),
presenting: renamingTemplate
) { template in
TextField("양식 이름", text: $renameText)
Button("저장") {
let trimmed = renameText.trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty {
template.name = trimmed
try? context.save()
}
renamingTemplate = nil
}
Button("취소", role: .cancel) { renamingTemplate = nil }
}
.alert(
"양식 삭제",
isPresented: Binding(get: { deletingTemplate != nil }, set: { if !$0 { deletingTemplate = nil } }),
presenting: deletingTemplate
) { template in
Button("삭제", role: .destructive) {
context.delete(template)
try? context.save()
deletingTemplate = nil
}
Button("취소", role: .cancel) { deletingTemplate = nil }
} message: { template in
let usage = usageCount(template)
if usage > 0 {
Text("\(template.name) 양식을 삭제할까요? 이 양식을 쓰는 노트 페이지 \(usage)개는 빈 캔버스로 표시돼요. (필기와 요소는 그대로 남아요)")
} else {
Text("\(template.name) 양식을 삭제할까요?")
}
}
}
private func row(_ template: DiaryTemplate) -> some View {
HStack {
DiaryTemplateRowLabel(template: template)
Spacer()
let usage = usageCounts[template.uuid.uuidString] ?? 0
if usage > 0 {
Text("페이지 \(usage)개 사용 중")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.swipeActions {
Button("삭제", role: .destructive) {
deletingTemplate = template
}
Button("이름 변경") {
renameText = template.name
renamingTemplate = template
}
.tint(AppTheme.green)
}
}
///
private var usageCounts: [String: Int] {
var counts: [String: Int] = [:]
for page in allPages where !page.templateID.isEmpty {
counts[page.templateID, default: 0] += 1
}
return counts
}
/// ( )
private func usageCount(_ template: DiaryTemplate) -> Int {
usageCounts[template.uuid.uuidString] ?? 0
}
// MARK:
private func importPDF(_ result: Result<URL, Error>) {
guard case .success(let url) = result else { return }
let accessing = url.startAccessingSecurityScopedResource()
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
guard let data = try? Data(contentsOf: url) else {
noticeMessage = String(localized: "파일을 읽지 못했어요. 다시 시도해 주세요.")
return
}
guard data.count <= DiaryTemplateRenderer.maxPDFBytes else {
noticeMessage = String(localized: "PDF가 너무 커요. 20MB 이하 파일만 가져올 수 있어요.")
return
}
guard let count = DiaryTemplateRenderer.pdfPageCount(data), count > 0 else {
noticeMessage = String(localized: "PDF를 열 수 없어요. 파일이 손상됐을 수 있어요.")
return
}
let template = DiaryTemplate(name: url.deletingPathExtension().lastPathComponent, kind: .pdf)
template.data = data
template.pageCount = min(count, DiaryTemplateRenderer.maxPages)
template.thumbnailData = DiaryTemplateRenderer.thumbnailData(kind: .pdf, data: data)
context.insert(template)
try? context.save()
if count > DiaryTemplateRenderer.maxPages {
noticeMessage = String(localized: "쪽이 많아 처음 \(DiaryTemplateRenderer.maxPages)쪽까지만 사용해요.")
}
}
private func importPhoto(_ image: UIImage) {
guard let data = DiaryTemplateRenderer.normalizedImageData(image) else {
noticeMessage = String(localized: "파일을 읽지 못했어요. 다시 시도해 주세요.")
return
}
let template = DiaryTemplate(name: String(localized: "사진 양식"), kind: .image)
template.data = data
template.pageCount = 1
template.thumbnailData = DiaryTemplateRenderer.thumbnailData(kind: .image, data: data)
context.insert(template)
try? context.save()
}
}