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:
parent
9825be505f
commit
a400a71896
@ -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` |
|
||||
|
||||
---
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user