mycode/myApp/HaruDanim/IOS/Views/DiaryTemplates.swift
songyc macbook da515a8f71 fix(ui): quest-less goals show status text instead of 0% + Swift 6 warning cleanup
- 다짐 없는 목표의 진행률 0% 표기(실패로 오독)를 상태 문구로 교체 — 사용자 승인 방식 ③
  · 위젯 ② 목표 진행바: 0% 바 3줄 대신 "다짐이 없어요" (④와 동일 문구·기존 처리와 통일)
  · 일기 '이 날의 목표' 헤더: "다짐 없음" + 게이지 생략 (기존 캡션은 유지)
  · 모음 탭 카드(게이지 블록 미표시)·위젯 ④·시리(오류 응답)는 이미 처리돼 있어 무변경
  · 잠금화면·워치 컴플리케이션은 공간 제약으로 수치 유지 — '수행일 아님'과 같은 의도된 단순화(§4.3 문서화)
- Swift 6 대비 경고 정리(사용자 승인): DiaryTemplateRenderer.renderPDFPage·DiaryPageMetrics를
  nonisolated로 선언(순수 CoreGraphics — 백그라운드 렌더 호출부와 격리 일치), DiarySectionConfig
  마이그레이션 onAppear 클로저 교정 — iOS 앱 타깃 경고 0
- 검증 인자 -seedEmptyGoal 신설(다짐 없는 목표 시드, 기본 시드·마케팅 스크린샷과 분리)
- l10n 신규 2키 ko/en/ja(다짐 없음·시드 목표 이름), 카탈로그 클린. CLAUDE.md §4.3·§14 갱신
- 검증: 위젯 ② 프리뷰·일기 카드 시뮬 스크린샷, Debug/Store 빌드 성공

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
2026-07-18 00:49:36 +09:00

620 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 = ( × , )
/// CoreGraphics (Task.detached)
/// (Swift 6 ).
nonisolated 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()
}
}