feat(diary): text boxes, page reorder, read-only browsing after expiry

- 텍스트 상자 요소: 노트 페이지 툴바에 '텍스트' 추가 — 키보드로 쓰는
  글 상자를 배치 요소로 올린다 (배치 모드에서 이동·크기 조절, 선택 시
  '글 수정'으로 내용·색 편집, 내보내기에도 그대로 렌더).
  DiaryPageItem에 text 필드 추가(kind .text, CloudKit 기본값 규칙 준수)
- 페이지 순서 변경: ⋯ 메뉴 → '페이지 순서 변경' 시트에서 노트 페이지를
  드래그로 재배열 (요약 페이지는 항상 첫 장)
- 프리미엄 만료 후 열람 허용: 이미 쓴 일기가 있으면 잠금 화면 대신
  달력을 열람 전용으로 연다 — 안내 배너 + 일기 있는 날짜만 활성,
  진입만으로 엔트리를 만들지 않고, 기분/할일/필기/필터/캘린더 등
  모든 편집 UI 비활성 (environment diaryReadOnly로 전파).
  일기를 쓴 적 없는 미구매 사용자는 기존 잠금 안내 유지

검증(iPad): 시드된 노트 페이지에서 텍스트 상자 렌더·툴바 확인,
-premium NO에서 배너/날짜 비활성/편집 UI 숨김 스크린샷 확인

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-14 00:13:11 +09:00
parent 59d6105d21
commit 64c9f2d619
3 changed files with 341 additions and 85 deletions

View File

@ -30,14 +30,19 @@ struct DiaryNotePageView: View {
let isActive: Bool
@Environment(\.modelContext) private var context
@Environment(\.diaryReadOnly) private var readOnly
@State private var arranging = false
@State private var selectedItemID: UUID?
@State private var photoSelection: PhotosPickerItem?
///
@State private var editingTextItem: DiaryPageItem?
var body: some View {
VStack(spacing: 10) {
pageToolbar
if !readOnly {
pageToolbar
}
// ( 4), .
// / · .
// .
@ -58,6 +63,9 @@ struct DiaryNotePageView: View {
self.photoSelection = nil
}
}
.sheet(item: $editingTextItem) { item in
DiaryTextEditSheet(item: item, onCommit: save)
}
}
// MARK:
@ -99,10 +107,23 @@ struct DiaryNotePageView: View {
Label("도형", systemImage: "square.on.circle")
}
Button {
addText()
} label: {
Label("텍스트", systemImage: "textformat")
}
Spacer()
if arranging, let selectedItemID,
let item = page.items.first(where: { $0.uuid == selectedItemID }) {
if item.kind == .text {
Button {
editingTextItem = item
} label: {
Label("글 수정", systemImage: "square.and.pencil")
}
}
Button(role: .destructive) {
deleteItem(item)
} label: {
@ -156,16 +177,17 @@ struct DiaryNotePageView: View {
)
}
// . .
// . ,
// ( ) .
DiaryPencilCanvas(
drawingData: page.drawingData,
isActive: isActive && !arranging
isActive: isActive && !arranging && !readOnly
) { data in
page.drawingData = data
save()
}
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.allowsHitTesting(!arranging)
.allowsHitTesting(!arranging && !readOnly)
if arranging {
RoundedRectangle(cornerRadius: 18, style: .continuous)
@ -200,6 +222,19 @@ struct DiaryNotePageView: View {
save()
}
///
private func addText() {
let item = DiaryPageItem(kind: .text)
item.colorHex = "#3A3A3A"
item.widthRatio = 0.45
item.page = page
context.insert(item)
arranging = true
selectedItemID = item.uuid
save()
editingTextItem = item
}
private func deleteItem(_ item: DiaryPageItem) {
context.delete(item)
selectedItemID = nil
@ -441,6 +476,12 @@ struct DiaryItemView: View {
DiaryLineShape()
.stroke(Color(hex: item.colorHex),
style: StrokeStyle(lineWidth: 5, lineCap: .round))
case .text:
Text(item.text.isEmpty ? String(localized: "텍스트") : item.text)
.font(.system(size: 24, weight: .medium))
.foregroundStyle(Color(hex: item.colorHex).opacity(item.text.isEmpty ? 0.4 : 1))
.minimumScaleFactor(0.3)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
@ -471,6 +512,47 @@ struct DiaryItemView: View {
}
}
// MARK: -
private struct DiaryTextEditSheet: View {
@Bindable var item: DiaryPageItem
var onCommit: () -> Void
@Environment(\.dismiss) private var dismiss
@FocusState private var focused: Bool
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 12) {
TextField("내용을 입력하세요", text: $item.text, axis: .vertical)
.lineLimit(3...8)
.padding(10)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.focused($focused)
ColorPicker("글자 색", selection: Binding(
get: { Color(hex: item.colorHex) },
set: { item.colorHex = $0.hexString }
), supportsOpacity: false)
Spacer()
}
.padding()
.background(AppTheme.background)
.navigationTitle("텍스트 상자")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("완료") {
onCommit()
dismiss()
}
}
}
}
.presentationDetents([.height(300)])
.onAppear { focused = true }
}
}
struct DiaryArrowShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()

View File

@ -16,6 +16,21 @@ import SwiftUI
import SwiftData
import PhotosUI
// MARK: - ( )
/// true
/// ( · UI ).
private struct DiaryReadOnlyKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var diaryReadOnly: Bool {
get { self[DiaryReadOnlyKey.self] }
set { self[DiaryReadOnlyKey.self] = newValue }
}
}
// MARK: - ()
struct DiaryRootView: View {
@ -26,12 +41,18 @@ struct DiaryRootView: View {
/// ( 1 )
@State private var monthAnchor: Date = DayMath().dayKey(for: .now)
@State private var showingExport = false
@State private var showingPremiumSheet = false
private var math: DayMath { DayMath() }
///
private var isReadOnly: Bool {
!premium.isPremium && diaryEntries.contains { $0.hasContent }
}
var body: some View {
Group {
if !premium.isPremium {
if !premium.isPremium && !isReadOnly {
DiaryLockedView()
} else {
#if DEBUG
@ -46,6 +67,7 @@ struct DiaryRootView: View {
#endif
}
}
.environment(\.diaryReadOnly, isReadOnly)
.background(AppTheme.background)
.navigationTitle("일기")
.navigationBarTitleDisplayMode(.inline)
@ -62,11 +84,15 @@ struct DiaryRootView: View {
private var calendar: some View {
ScrollView {
VStack(spacing: 14) {
if isReadOnly {
readOnlyBanner
}
monthHeader
DiaryCalendarGrid(
monthAnchor: monthAnchor,
entriesByDay: entriesByDay,
math: math
math: math,
readOnly: isReadOnly
)
monthSummary
}
@ -89,6 +115,9 @@ struct DiaryRootView: View {
.sheet(isPresented: $showingExport) {
DiaryExportSheet()
}
.sheet(isPresented: $showingPremiumSheet) {
PremiumSheetView()
}
.onAppear {
// (loadEntry),
// (CloudKit )
@ -126,6 +155,27 @@ struct DiaryRootView: View {
return result
}
/// ,
private var readOnlyBanner: some View {
HStack(spacing: 10) {
Image(systemName: "book.closed")
.foregroundStyle(AppTheme.yellow)
Text("프리미엄 기간이 끝나 열람만 가능해요. 이미 쓴 일기는 계속 볼 수 있어요.")
.font(.footnote)
.foregroundStyle(.secondary)
Spacer()
Button("프리미엄") {
showingPremiumSheet = true
}
.font(.footnote.weight(.semibold))
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.tint(AppTheme.green)
}
.padding(12)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
// MARK:
private var monthHeader: some View {
@ -203,6 +253,12 @@ struct DiaryRootView: View {
arrow.centerX = 0.62; arrow.centerY = 0.4; arrow.widthRatio = 0.3
arrow.page = page
context.insert(arrow)
let textBox = DiaryPageItem(kind: .text)
textBox.text = "오늘은 여기까지!\n내일은 더 일찍 시작하자."
textBox.colorHex = "#3A3A3A"
textBox.centerX = 0.42; textBox.centerY = 0.62; textBox.widthRatio = 0.5
textBox.page = page
context.insert(textBox)
try? context.save()
}
#endif
@ -248,6 +304,8 @@ private struct DiaryCalendarGrid: View {
let monthAnchor: Date
let entriesByDay: [Date: DiaryEntry]
let math: DayMath
/// ( )
var readOnly = false
private var calendar: Calendar { math.calendar }
@ -279,10 +337,15 @@ private struct DiaryCalendarGrid: View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7), spacing: 4) {
ForEach(Array(cells.enumerated()), id: \.offset) { _, key in
if let key {
NavigationLink(value: key) {
if readOnly && entriesByDay[key] == nil {
dayCell(key)
.opacity(0.45)
} else {
NavigationLink(value: key) {
dayCell(key)
}
.buttonStyle(.plain)
}
.buttonStyle(.plain)
} else {
Color.clear
.frame(height: 74)
@ -339,6 +402,7 @@ struct DiaryDetailView: View {
let dayKey: Date
@Environment(\.modelContext) private var context
@Environment(\.diaryReadOnly) private var readOnly
@State private var entry: DiaryEntry?
@State private var showingSectionConfig: Bool = {
@ -357,11 +421,18 @@ struct DiaryDetailView: View {
#endif
return 0
}()
@State private var showingPageOrder = false
var body: some View {
Group {
if let entry {
pager(entry)
} else if readOnly {
ContentUnavailableView(
"이 날짜의 일기가 없어요",
systemImage: "book.closed",
description: Text("프리미엄에서 새 일기를 쓸 수 있어요.")
)
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
@ -372,27 +443,7 @@ struct DiaryDetailView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
showingSectionConfig = true
} label: {
Label("첫 화면 구성", systemImage: "slider.horizontal.3")
}
Button {
addPage()
} label: {
Label("페이지 추가", systemImage: "plus.square.on.square")
}
if let entry, pageIndex >= 1, pageIndex <= entry.pages.count {
Button(role: .destructive) {
deleteCurrentPage()
} label: {
Label("이 페이지 삭제", systemImage: "trash")
}
}
} label: {
Image(systemName: "ellipsis.circle")
}
if !readOnly { detailMenu }
}
}
.sheet(isPresented: $showingSectionConfig) {
@ -401,6 +452,37 @@ struct DiaryDetailView: View {
.onAppear(perform: loadEntry)
}
private var detailMenu: some View {
Menu {
Button {
showingSectionConfig = true
} label: {
Label("첫 화면 구성", systemImage: "slider.horizontal.3")
}
Button {
addPage()
} label: {
Label("페이지 추가", systemImage: "plus.square.on.square")
}
if let entry, entry.pages.count > 1 {
Button {
showingPageOrder = true
} label: {
Label("페이지 순서 변경", systemImage: "arrow.up.arrow.down")
}
}
if let entry, pageIndex >= 1, pageIndex <= entry.pages.count {
Button(role: .destructive) {
deleteCurrentPage()
} label: {
Label("이 페이지 삭제", systemImage: "trash")
}
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
private func pager(_ entry: DiaryEntry) -> some View {
TabView(selection: $pageIndex) {
DiarySummaryPage(entry: entry)
@ -409,11 +491,16 @@ struct DiaryDetailView: View {
DiaryNotePageView(page: page, isActive: pageIndex == index + 1)
.tag(index + 1)
}
addPagePlaceholder
.tag(entry.pages.count + 1)
if !readOnly {
addPagePlaceholder
.tag(entry.pages.count + 1)
}
}
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))
.sheet(isPresented: $showingPageOrder) {
DiaryPageOrderSheet(entry: entry)
}
}
/// : ( )
@ -449,6 +536,8 @@ struct DiaryDetailView: View {
if let found = try? context.fetch(descriptor).first {
entry = found
} else {
// ( ) ' '
guard !readOnly else { return }
let created = DiaryEntry(dayKey: key)
context.insert(created)
try? context.save()
@ -480,6 +569,71 @@ struct DiaryDetailView: View {
}
}
// MARK: -
/// ( )
private struct DiaryPageOrderSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var context
let entry: DiaryEntry
var body: some View {
NavigationStack {
List {
Section {
ForEach(Array(entry.pages.enumerated()), id: \.element.uuid) { index, page in
HStack(spacing: 12) {
Image(systemName: page.lined ? "text.justify" : "square")
.foregroundStyle(AppTheme.green)
.frame(width: 24)
VStack(alignment: .leading, spacing: 1) {
Text("페이지 \(index + 1)")
.font(.subheadline.weight(.medium))
Text(summary(page))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.onMove(perform: move)
} footer: {
Text("손잡이를 끌어 노트 페이지 순서를 바꿔요. 하루 정리(요약)는 항상 첫 장이에요.")
}
}
.environment(\.editMode, .constant(.active))
.navigationTitle("페이지 순서")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("완료") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
}
private func summary(_ page: DiaryPage) -> String {
var parts: [String] = [page.lined ? String(localized: "줄 노트") : String(localized: "빈 캔버스")]
if !page.drawingData.isEmpty {
parts.append(String(localized: "필기 있음"))
}
if !page.items.isEmpty {
parts.append(String(localized: "요소 \(page.items.count)"))
}
return parts.joined(separator: " · ")
}
private func move(from source: IndexSet, to destination: Int) {
var pages = entry.pages
pages.move(fromOffsets: source, toOffset: destination)
for (index, page) in pages.enumerated() {
page.index = index
}
entry.updatedAt = .now
try? context.save()
}
}
// MARK: - ( + )
/// . rawValue .
@ -622,6 +776,7 @@ private struct DiarySummaryPage: View {
@Bindable var entry: DiaryEntry
@Environment(\.modelContext) private var context
@Environment(\.diaryReadOnly) private var readOnly
@Query private var sessions: [TimeSession]
@Query private var countEntries: [CountEntry]
@Query(sort: \Action.createdAt) private var allActionsQuery: [Action]
@ -884,8 +1039,9 @@ private struct DiarySummaryPage: View {
if case .timetable = section {
ZStack(alignment: .topTrailing) {
ExportSectionView(section: section, innerWidth: innerWidth)
// (·)
HStack(spacing: 6) {
if !dayRecordedActions.isEmpty || hiddenCount > 0 {
if !readOnly, !dayRecordedActions.isEmpty || hiddenCount > 0 {
Button {
filterExcluded = timetableExcludedIDs
showingActionFilter = true
@ -909,24 +1065,26 @@ private struct DiarySummaryPage: View {
.buttonStyle(.plain)
.accessibilityLabel(Text("타임테이블 필터"))
}
Button {
showingCalendarPicker = true
} label: {
HStack(spacing: 4) {
Image(systemName: "calendar.badge.plus")
.font(.system(size: 12, weight: .semibold))
if !calendarBlocks.isEmpty {
Text(verbatim: "\(calendarBlocks.count)")
.font(.system(size: 11, weight: .bold).monospacedDigit())
if !readOnly {
Button {
showingCalendarPicker = true
} label: {
HStack(spacing: 4) {
Image(systemName: "calendar.badge.plus")
.font(.system(size: 12, weight: .semibold))
if !calendarBlocks.isEmpty {
Text(verbatim: "\(calendarBlocks.count)")
.font(.system(size: 11, weight: .bold).monospacedDigit())
}
}
.foregroundStyle(AppTheme.green)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(AppTheme.green.opacity(0.12), in: Capsule())
}
.foregroundStyle(AppTheme.green)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(AppTheme.green.opacity(0.12), in: Capsule())
.buttonStyle(.plain)
.accessibilityLabel(Text("캘린더 일정"))
}
.buttonStyle(.plain)
.accessibilityLabel(Text("캘린더 일정"))
}
.padding(10)
}
@ -1170,6 +1328,7 @@ private struct DiaryGoalsCard: View {
private struct DiaryMoodCard: View {
@Bindable var entry: DiaryEntry
@Environment(\.modelContext) private var context
@Environment(\.diaryReadOnly) private var readOnly
@State private var showingEmojiPicker = false
@State private var photoSelection: PhotosPickerItem?
@ -1193,33 +1352,35 @@ private struct DiaryMoodCard: View {
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 8)
VStack(alignment: .leading, spacing: 8) {
Button {
showingEmojiPicker = true
} label: {
Label("이모지 선택", systemImage: "face.smiling")
}
.popover(isPresented: $showingEmojiPicker) {
emojiPicker
}
PhotosPicker(selection: $photoSelection, matching: .images) {
Label("사진 선택", systemImage: "photo.badge.plus")
}
if !entry.moodEmoji.isEmpty || entry.moodImageData != nil {
if !readOnly {
VStack(alignment: .leading, spacing: 8) {
Button {
entry.moodEmoji = ""
entry.moodImageData = nil
touch()
showingEmojiPicker = true
} label: {
Label("지우기", systemImage: "xmark.circle")
Label("이모지 선택", systemImage: "face.smiling")
}
.popover(isPresented: $showingEmojiPicker) {
emojiPicker
}
PhotosPicker(selection: $photoSelection, matching: .images) {
Label("사진 선택", systemImage: "photo.badge.plus")
}
if !entry.moodEmoji.isEmpty || entry.moodImageData != nil {
Button {
entry.moodEmoji = ""
entry.moodImageData = nil
touch()
} label: {
Label("지우기", systemImage: "xmark.circle")
}
.foregroundStyle(.secondary)
}
.foregroundStyle(.secondary)
}
.font(.footnote)
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.tint(AppTheme.green)
}
.font(.footnote)
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.tint(AppTheme.green)
}
.frame(maxWidth: .infinity, minHeight: tileSize, alignment: .leading)
}
@ -1280,6 +1441,7 @@ private struct DiaryMoodCard: View {
.contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
.buttonStyle(.plain)
.disabled(readOnly)
}
private var emojiPicker: some View {
@ -1327,6 +1489,7 @@ extension UIImage {
private struct DiaryTodoCard: View {
@Bindable var entry: DiaryEntry
@Environment(\.modelContext) private var context
@Environment(\.diaryReadOnly) private var readOnly
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@ -1334,14 +1497,16 @@ private struct DiaryTodoCard: View {
Text("오늘 할 일")
.font(.subheadline.weight(.semibold))
Spacer()
Button {
addTodo()
} label: {
Image(systemName: "plus.circle.fill")
.font(.title3)
.foregroundStyle(AppTheme.green)
if !readOnly {
Button {
addTodo()
} label: {
Image(systemName: "plus.circle.fill")
.font(.title3)
.foregroundStyle(AppTheme.green)
}
.buttonStyle(.plain)
}
.buttonStyle(.plain)
}
if entry.todos.isEmpty {
Text("펜슬이나 키보드로 할 일을 적고,\n체크 표시로 완료해 보세요")
@ -1379,6 +1544,7 @@ private struct DiaryTodoRow: View {
let onDelete: () -> Void
@Environment(\.modelContext) private var context
@Environment(\.diaryReadOnly) private var readOnly
var body: some View {
HStack(spacing: 10) {
@ -1391,21 +1557,25 @@ private struct DiaryTodoRow: View {
.foregroundStyle(todo.isDone ? AppTheme.green : .secondary)
}
.buttonStyle(.plain)
.disabled(readOnly)
TextField("할 일", text: $todo.text)
.font(.subheadline)
.strikethrough(todo.isDone, color: .secondary)
.foregroundStyle(todo.isDone ? .secondary : .primary)
.onSubmit { try? context.save() }
.disabled(readOnly)
Button(action: onDelete) {
Image(systemName: "xmark")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
.padding(4)
.contentShape(Rectangle())
if !readOnly {
Button(action: onDelete) {
Image(systemName: "xmark")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
.padding(4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.buttonStyle(.plain)
}
}
}

View File

@ -107,7 +107,7 @@ final class DiaryPage {
}
}
/// ( )
/// (, )
@Model
final class DiaryPageItem {
var uuid: UUID = UUID()
@ -116,7 +116,9 @@ final class DiaryPageItem {
///
@Attribute(.externalStorage)
var imageData: Data? = nil
/// (hex)
/// ( )
var text: String = ""
/// · (hex)
var colorHex: String = "#2F6B4F"
/// (0...1)
var centerX: Double = 0.5
@ -144,6 +146,7 @@ nonisolated enum DiaryItemKind: String, CaseIterable {
case ellipse
case arrow
case line
case text
/// (widthRatio )
var defaultAspect: Double {
@ -153,6 +156,7 @@ nonisolated enum DiaryItemKind: String, CaseIterable {
case .ellipse: return 0.7
case .arrow: return 0.35
case .line: return 0.1
case .text: return 0.4
}
}
}