From a400a71896538b69fd33d249f0ce87ba466f2b3d Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Sat, 18 Jul 2026 00:03:04 +0900 Subject: [PATCH] feat(ui): unify time wheels + diary calendar mood-photo backlayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 다짐 '마감 시각'·설정 '하루 시작 시간'의 컴팩트 시각 픽커도 RecordTimeWheel(시·분 전용)로 교체 — 기록 편집기와 같은 팝오버 커밋 유실 기전 제거, 앱의 시각 다이얼 전부 상시 노출 휠로 통일 - 일기 달력: 기분 사진이 있는 날짜는 셀 배경(백레이어)에 사진 썸네일 표시 — 이모지/연필 마커·오늘 표시는 그대로 위에 유지, 사진 없으면 기존과 동일. ImageIO ≤220px 축소 + NSCache(DiaryMoodThumbs)로 한 달치 렌더 부담 최소화, 검은 스크림 0.16+흰 날짜·마커 그림자로 가독성 확보 - 사진만 있는 엔트리는 hasContent가 moodImageData를 포함해 빈 엔트리 정리에서 보존됨(기존 로직 확인) - 검증 인자 -diarySeedPhoto 신설(오늘=이모지+사진·어제=사진만) — iPad 시뮬 달력 스크린샷으로 백레이어·마커 확인, 마감 시각·하루 시작 시간 휠 스크린샷 확인 - l10n 무변화(기존 키 재사용, 카탈로그 클린). CLAUDE.md §6.5·§6.7·§14 갱신. Debug/Store 두 스킴 빌드 성공 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp --- myApp/HaruDanim/CLAUDE.md | 6 +- myApp/HaruDanim/IOS/Views/DiaryView.swift | 90 +++++++++++++++++-- .../HaruDanim/IOS/Views/QuestEditorView.swift | 6 +- myApp/HaruDanim/IOS/Views/RecordEditors.swift | 8 +- myApp/HaruDanim/IOS/Views/SettingsView.swift | 9 +- 5 files changed, 104 insertions(+), 15 deletions(-) diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index 9303bc8..5dbd6a9 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -199,7 +199,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f ### 6.5 기록 탭 (`HistoryView`, `RecordEditors`) - **범위 조회**: 기록은 전량 로드하지 않고 선택 날짜가 속한 **주 범위**만 @Query로 가져온다(`HistoryRecordsView` — SwiftData 동적 쿼리 패턴: 날짜가 바뀌면 새 조건으로 재생성). 하루 경계 걸침·진행 중 세션은 "시작 < 범위끝 AND (종료 없음 OR 종료 > 범위시작)" 겹침 조건으로 포함 - 날짜 헤더(화살표 이동, 날짜 탭=달력 팝오버, "오늘" 버튼) + 목록/타임테이블 세그먼트 -- 목록: 시간 기록(시작~종료·총 시간·메모)·횟수 기록(시각·+n·메모), 탭하면 수정 시트. **수정·추가 시트의 시각 입력은 상시 노출 휠 다이얼**(`RecordEditors.swift` RecordTimeWheel) — 컴팩트 팝오버는 다이얼 감속 중 닫히면 정착값이 유실되는 실기기 보고가 있어 폐기. `in:` 범위 제약도 하한 근처 선택을 소리 없이 되감아 금지(검증은 종료<시작 경고 문구+저장 비활성). 편집 상태는 분 단위 절사(flooredToMinute) — 실측정 기록의 초 찌꺼기(14:32:47)가 남으면 다이얼로 맞춘 1시간이 59분으로 표시되는 어긋남 방지. 마감 시각(다짐)·하루 시작 시간(설정)의 hourAndMinute 컴팩트 픽커는 편집 빈도가 낮아 유지 — 같은 증상 보고 시 RecordTimeWheel 패턴 적용 +- 목록: 시간 기록(시작~종료·총 시간·메모)·횟수 기록(시각·+n·메모), 탭하면 수정 시트. **수정·추가 시트의 시각 입력은 상시 노출 휠 다이얼**(`RecordEditors.swift` RecordTimeWheel) — 컴팩트 팝오버는 다이얼 감속 중 닫히면 정착값이 유실되는 실기기 보고가 있어 폐기. `in:` 범위 제약도 하한 근처 선택을 소리 없이 되감아 금지(검증은 종료<시작 경고 문구+저장 비활성). 편집 상태는 분 단위 절사(flooredToMinute) — 실측정 기록의 초 찌꺼기(14:32:47)가 남으면 다이얼로 맞춘 1시간이 59분으로 표시되는 어긋남 방지. 다짐 '마감 시각'·설정 '하루 시작 시간'도 같은 RecordTimeWheel(components: .hourAndMinute)로 통일 — 앱의 시각 입력 다이얼은 전부 상시 노출 휠 - 타임테이블: 24시간 축(하루 시작 시간 기준), 하루/일주일 전환(주간 보기에서 화살표는 7일씩 점프). 시간=색 블록, 횟수=색 점. 블록/점 탭=수정 - 필터: `RecordFilterSheet`(공용) — 꼬리표 아래 행동이 그룹된 트리, **"제외한 행동 ID" 집합**으로 관리해 기본이 전체 선택. 활성 시 초록 칩 표시 - 내보내기: 현재 날짜/모드/필터 그대로 `ExportBuilder.history` 스냅숏 → 이미지 시트 @@ -210,7 +210,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f - 평균 분모는 "이미 시작된" 날/주만 (미래로 희석 방지). 필터·내보내기는 기록 탭과 동일 패턴 ### 6.7 일기 탭 (iPad 전용, 프리미엄 — `DiaryView`, `DiaryNotePage`, `DiaryCalendarEvents`, `DiaryExportSheet`) -- 루트: 월 달력(작성일에 기분 이모지/연필 마커), 날짜 탭 → 일기 상세. 툴바에서 기간/복수 날짜 내보내기(PDF=페이지별 1장, 이미지=세로 스티치 PNG, 2배율) +- 루트: 월 달력(작성일에 기분 이모지/연필 마커, **기분 사진이 있으면 셀 배경에 썸네일 백레이어** — ImageIO ≤220px 축소를 NSCache에 캐시(`DiaryMoodThumbs`), 검은 스크림 0.16+흰 날짜·마커 그림자로 가독성 유지, 사진 없으면 기존 그대로), 날짜 탭 → 일기 상세. 툴바에서 기간/복수 날짜 내보내기(PDF=페이지별 1장, 이미지=세로 스티치 PNG, 2배율) - 상세는 진입만 해도 엔트리를 생성(loadEntry)하므로, 달력 onAppear가 **완전히 빈 엔트리만 정리**(기분·할일·페이지 없음 + calendarEventIDs·hiddenActionIDs 비어있음 + 페이지 0개 — 캘린더/필터 선택이나 빈 노트 페이지가 있으면 보존) - 상세: 좌우 페이지 넘김. 1페이지=하루 정리 요약, 이후=펜슬 노트, 마지막=페이지 추가 자리 - **요약 페이지 섹션** (표시·순서는 "첫 화면 구성" 시트, 전 날짜 공통, standard defaults): 오늘 기분(대형 정사각 타일: 이모지/사진) · 요약 지표 · 이 날의 목표(그날 기준 다짐 현황, 주간/월간은 그날까지 누적) · 오늘 할 일 · 타임테이블 · 기록 상세 · 통계 막대. 화면 720pt 이상이면 타임테이블 좌측 고정 2컬럼 @@ -334,7 +334,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f | 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan ` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor `(최신 기록 수정 시트) | | 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-premiumPreview` `-helpPreview` `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사) `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) | | 버블 | `-radialExpanded` `-radialDoubleTapTest`(2초 뒤 FAB 더블탭 재현 — `-settings.radialDoubleTapTab <탭>`과 함께) | -| 일기 | `-diarySeed`(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage ` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` | +| 일기 | `-diarySeed`(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage ` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diarySeedPhoto`(오늘=이모지+사진·어제=사진만 기분 사진 시드 — 달력 백레이어 검증) `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` | | 워치 | `-complicationPreview` `-complicationScroll` `-complicationScrollTo ` `-autoRunFirstAction` | --- diff --git a/myApp/HaruDanim/IOS/Views/DiaryView.swift b/myApp/HaruDanim/IOS/Views/DiaryView.swift index 70a3bfe..6f4bd9b 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryView.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryView.swift @@ -15,6 +15,7 @@ import SwiftUI import SwiftData import PhotosUI +import ImageIO #if DEBUG import PencilKit // 시드 필기 획 생성용 (검증 전용) #endif @@ -85,6 +86,10 @@ struct DiaryRootView: View { if UserDefaults.standard.bool(forKey: "diarySeedTemplate") { seedSampleTemplate() } + // 검증용: -diarySeedPhoto YES → 오늘(이모지+사진)·어제(사진만) 기분 사진 시드 + if UserDefaults.standard.bool(forKey: "diarySeedPhoto") { + seedMoodPhotos() + } #endif } } @@ -314,6 +319,39 @@ struct DiaryRootView: View { } /// 검증용 샘플 양식: 1쪽 = 격자, 2쪽 = 점 노트 (코드로 그린 2쪽짜리 PDF) + /// 달력 사진 백레이어 검증용 — 오늘(이모지 유지)·어제(이모지 없음, 연필 마커)에 + /// 그라데이션 샘플 사진을 붙인다. 실제 첨부와 같은 경로(diaryCompressed)로 저장. + private func seedMoodPhotos() { + let hues: [UIColor] = [ + UIColor(red: 0.98, green: 0.72, blue: 0.40, alpha: 1), // 오늘: 노을 + UIColor(red: 0.35, green: 0.62, blue: 0.85, alpha: 1), // 어제: 바다 + ] + for (offset, base) in hues.enumerated() { + guard let day = math.calendar.date(byAdding: .day, value: -offset, to: math.dayKey(for: .now)) else { continue } + let entry: DiaryEntry + if let existing = diaryEntries.first(where: { $0.dayKey == day }) { + entry = existing + } else { + entry = DiaryEntry(dayKey: day) + context.insert(entry) + } + guard entry.moodImageData == nil else { continue } + if offset == 1 { entry.moodEmoji = "" } + let size = CGSize(width: 800, height: 600) + let image = UIGraphicsImageRenderer(size: size).image { ctx in + let cg = ctx.cgContext + let colors = [base.cgColor, base.withAlphaComponent(0.35).cgColor] as CFArray + if let gradient = CGGradient(colorsSpace: nil, colors: colors, locations: [0, 1]) { + cg.drawLinearGradient(gradient, start: .zero, end: CGPoint(x: 0, y: size.height), options: []) + } + cg.setFillColor(UIColor.white.withAlphaComponent(0.85).cgColor) + cg.fillEllipse(in: CGRect(x: size.width * 0.62, y: size.height * 0.18, width: 130, height: 130)) + } + entry.moodImageData = image.diaryCompressed() + } + try? context.save() + } + private func seedSampleTemplate() { let count = (try? context.fetchCount(FetchDescriptor())) ?? 0 guard count == 0 else { return } @@ -405,6 +443,31 @@ struct DiaryLockedView: View { // MARK: - 달력 그리드 +/// 달력 셀용 기분 사진 썸네일 캐시 — 원본(≤900px JPEG)을 셀마다 디코드하면 +/// 한 달치 렌더가 무거워지므로 ImageIO로 작게(≤220px) 뽑아 NSCache에 둔다. +/// 키에 데이터 길이를 포함해 사진을 바꾸면 자연히 새로 만든다. +@MainActor +private enum DiaryMoodThumbs { + private static let cache = NSCache() + + static func thumbnail(for entry: DiaryEntry) -> UIImage? { + guard let data = entry.moodImageData else { return nil } + let key = "\(entry.dayKey.timeIntervalSinceReferenceDate)-\(data.count)" as NSString + if let hit = cache.object(forKey: key) { return hit } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: 220, + kCGImageSourceCreateThumbnailWithTransform: true, + ] + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) + else { return nil } + let image = UIImage(cgImage: cg) + cache.setObject(image, forKey: key) + return image + } +} + private struct DiaryCalendarGrid: View { let monthAnchor: Date let entriesByDay: [Date: DiaryEntry] @@ -466,22 +529,26 @@ private struct DiaryCalendarGrid: View { private func dayCell(_ key: Date) -> some View { let isToday = key == math.dayKey(for: .now) let entry = entriesByDay[key] + // 기분 사진이 있으면 셀 배경(백레이어)으로 깔아 달력에서 바로 보이게 한다 + let photo = entry.flatMap { DiaryMoodThumbs.thumbnail(for: $0) } return VStack(spacing: 3) { Text("\(calendar.component(.day, from: key))") .font(.callout.weight(isToday ? .bold : .regular)) .monospacedDigit() - .foregroundStyle(isToday ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) + .foregroundStyle(isToday || photo != nil ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) + .shadow(color: photo != nil ? .black.opacity(0.55) : .clear, radius: 2) .frame(width: 32, height: 32) .background( Circle().fill(isToday ? AppTheme.green : Color.clear) ) - // 작성 마커: 기분 이모지가 있으면 이모지, 아니면 연필 점 + // 작성 마커: 기분 이모지가 있으면 이모지, 아니면 연필 점 (사진 위에서도 유지) Group { if let entry { if entry.moodEmoji.isEmpty { Image(systemName: "applepencil.tip") .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(AppTheme.green) + .foregroundStyle(photo != nil ? AnyShapeStyle(.white) : AnyShapeStyle(AppTheme.green)) + .shadow(color: photo != nil ? .black.opacity(0.55) : .clear, radius: 2) } else { Text(entry.moodEmoji) .font(.system(size: 15)) @@ -494,10 +561,19 @@ private struct DiaryCalendarGrid: View { } .frame(maxWidth: .infinity) .frame(height: 74) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(entry != nil ? AppTheme.green.opacity(0.07) : Color.clear) - ) + .background { + if let photo { + // Color.clear가 셀 크기를 잡고, 사진은 overlay로 채운 뒤 클리핑 — + // scaledToFill 오버플로가 이웃 셀을 침범하지 않게 하는 표준 패턴 + Color.clear + .overlay(Image(uiImage: photo).resizable().scaledToFill()) + .overlay(Color.black.opacity(0.16)) // 날짜·마커 가독성 스크림 + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } else { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(entry != nil ? AppTheme.green.opacity(0.07) : Color.clear) + } + } .contentShape(Rectangle()) } } diff --git a/myApp/HaruDanim/IOS/Views/QuestEditorView.swift b/myApp/HaruDanim/IOS/Views/QuestEditorView.swift index aabf047..9e166d2 100644 --- a/myApp/HaruDanim/IOS/Views/QuestEditorView.swift +++ b/myApp/HaruDanim/IOS/Views/QuestEditorView.swift @@ -163,7 +163,11 @@ struct QuestEditorView: View { // 마감 시각: 그날의 집계 창을 [하루 시작, 마감]으로 줄인다 (하루 단위 전용) Toggle("마감 시각까지만 집계", isOn: $hasDeadline.animation()) if hasDeadline { - DatePicker("마감 시각", selection: $deadlineTime, displayedComponents: .hourAndMinute) + RecordTimeWheel( + label: String(localized: "마감 시각"), + selection: $deadlineTime, + components: .hourAndMinute + ) } } diff --git a/myApp/HaruDanim/IOS/Views/RecordEditors.swift b/myApp/HaruDanim/IOS/Views/RecordEditors.swift index 80ad19e..fb44f28 100644 --- a/myApp/HaruDanim/IOS/Views/RecordEditors.swift +++ b/myApp/HaruDanim/IOS/Views/RecordEditors.swift @@ -16,17 +16,19 @@ private func flooredToMinute(_ date: Date) -> Date { return Calendar.current.date(from: comps) ?? date } -/// 기록 편집용 시각 다이얼 — 팝오버 없이 상시 노출되는 휠. +/// 시각 입력용 다이얼 — 팝오버 없이 상시 노출되는 휠. /// 컴팩트 스타일은 팝오버 다이얼이 감속 중에 닫히면 정착값이 바인딩에 /// 반영되지 않는 경우가 있어(사용자 실기기 보고), 다이얼 자체가 곧 표시가 /// 되도록 휠을 인라인으로 둔다. 범위 제약(in:)은 하한 근처 선택을 소리 없이 /// 되감아 버리므로 쓰지 않고, 검증은 호출부의 경고·저장 비활성으로 한다. -private struct RecordTimeWheel: View { +/// 기록 편집 외에 다짐 '마감 시각'·설정 '하루 시작 시간'(시·분 전용)도 공용. +struct RecordTimeWheel: View { let label: String @Binding var selection: Date + var components: DatePickerComponents = [.date, .hourAndMinute] var body: some View { - DatePicker(label, selection: $selection) + DatePicker(label, selection: $selection, displayedComponents: components) .datePickerStyle(.wheel) .labelsHidden() .frame(maxWidth: .infinity) diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index e80ad20..d56312a 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -146,7 +146,14 @@ struct SettingsView: View { Text("\(Format.weekdayShort(weekday))요일").tag(weekday) } } - DatePicker("하루 시작 시간", selection: dayStartDate, displayedComponents: .hourAndMinute) + VStack(alignment: .leading, spacing: 0) { + Text("하루 시작 시간") + RecordTimeWheel( + label: String(localized: "하루 시작 시간"), + selection: dayStartDate, + components: .hourAndMinute + ) + } } header: { Text("날짜 기준") } footer: {