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:
parent
59d6105d21
commit
64c9f2d619
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user