diff --git a/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift b/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift index da8d1ea..b62c487 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift @@ -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() diff --git a/myApp/HaruDanim/IOS/Views/DiaryView.swift b/myApp/HaruDanim/IOS/Views/DiaryView.swift index 168f7a9..5d5694e 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryView.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryView.swift @@ -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) } } } diff --git a/myApp/HaruDanim/Shared/DiaryModels.swift b/myApp/HaruDanim/Shared/DiaryModels.swift index 535a933..878087d 100644 --- a/myApp/HaruDanim/Shared/DiaryModels.swift +++ b/myApp/HaruDanim/Shared/DiaryModels.swift @@ -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 } } }