feat(ui): unify time wheels + diary calendar mood-photo backlayer

- 다짐 '마감 시각'·설정 '하루 시작 시간'의 컴팩트 시각 픽커도 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
This commit is contained in:
songyc macbook 2026-07-18 00:03:04 +09:00
parent 9825be505f
commit a400a71896
5 changed files with 104 additions and 15 deletions

View File

@ -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 <day|week|month>` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor <session|count>`(최신 기록 수정 시트) |
| 설정/기타 | `-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 <N>` `-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 <N>` `-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 <stale\|quest>` `-autoRunFirstAction` |
---

View File

@ -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<DiaryTemplate>())) ?? 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<NSString, UIImage>()
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())
}
}

View File

@ -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
)
}
}

View File

@ -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)

View File

@ -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: {