From da515a8f71b046cf68c09138bb22e35519a0162e Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Sat, 18 Jul 2026 00:49:36 +0900 Subject: [PATCH] fix(ui): quest-less goals show status text instead of 0% + Swift 6 warning cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 다짐 없는 목표의 진행률 0% 표기(실패로 오독)를 상태 문구로 교체 — 사용자 승인 방식 ③ · 위젯 ② 목표 진행바: 0% 바 3줄 대신 "다짐이 없어요" (④와 동일 문구·기존 처리와 통일) · 일기 '이 날의 목표' 헤더: "다짐 없음" + 게이지 생략 (기존 캡션은 유지) · 모음 탭 카드(게이지 블록 미표시)·위젯 ④·시리(오류 응답)는 이미 처리돼 있어 무변경 · 잠금화면·워치 컴플리케이션은 공간 제약으로 수치 유지 — '수행일 아님'과 같은 의도된 단순화(§4.3 문서화) - Swift 6 대비 경고 정리(사용자 승인): DiaryTemplateRenderer.renderPDFPage·DiaryPageMetrics를 nonisolated로 선언(순수 CoreGraphics — 백그라운드 렌더 호출부와 격리 일치), DiarySectionConfig 마이그레이션 onAppear 클로저 교정 — iOS 앱 타깃 경고 0 - 검증 인자 -seedEmptyGoal 신설(다짐 없는 목표 시드, 기본 시드·마케팅 스크린샷과 분리) - l10n 신규 2키 ko/en/ja(다짐 없음·시드 목표 이름), 카탈로그 클린. CLAUDE.md §4.3·§14 갱신 - 검증: 위젯 ② 프리뷰·일기 카드 시뮬 스크린샷, Debug/Store 빌드 성공 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp --- myApp/HaruDanim/CLAUDE.md | 4 +-- myApp/HaruDanim/IOS/ContentView.swift | 1 + myApp/HaruDanim/IOS/Core/DebugSeed.swift | 13 ++++++++ myApp/HaruDanim/IOS/Localizable.xcstrings | 32 +++++++++++++++++++ myApp/HaruDanim/IOS/Views/DiaryNotePage.swift | 5 +-- .../HaruDanim/IOS/Views/DiaryTemplates.swift | 4 ++- myApp/HaruDanim/IOS/Views/DiaryView.swift | 21 ++++++++---- myApp/HaruDanim/Widgets/GoalWidgets.swift | 16 ++++++++-- 8 files changed, 82 insertions(+), 14 deletions(-) diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index 08751cc..479d03f 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -128,7 +128,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f - **마감 시각(deadlineMinutes, 하루 단위 전용)**: 그날의 집계 창을 [하루 시작, 마감)으로 줄인다(`dayMeasurementRange` — 단일 구현, 주기 범위·span 하루 루프·연속 버킷팅·위젯 라벨·일기 카드가 공유). 마감 뒤 기록은 원본은 남되 그 다짐엔 미집계(시간형은 겹침 클리핑으로 부분 인정). 하루 시작보다 이른 시각=다음 달력일 새벽, 시작과 같은 시각=온전한 하루(0길이 창 없음, 검증 불필요). **의도된 트레이드오프**: '마감 지남' 전용 표시 상태 없음(게이지 값이 마감 시점에 연속이라 위젯 타임라인 마감 경계도 불필요), 연속 달성은 값 클리핑만 반영(오늘 마감을 놓쳐도 유예 규칙대로 끊김은 내일부터), ③ 위젯 누적 라벨은 창 안 값(`spanRawValue`). 주간/월간/기간 다짐 마감은 미지원(교차 span 의미가 정의 안 됨 — 확장 시 §4.2 전체 재설계 필요) ### 4.3 목표 진행률·판정 (Goal extension) -- `combinedSpanRatio(span)`: 소속 다짐 ratio 평균. 하루 span은 오늘이 수행일인 다짐만 모수(하나도 없으면 100% 취급). 모음 탭 카드·위젯·워치·시리가 공유 +- `combinedSpanRatio(span)`: 소속 다짐 ratio 평균. 하루 span은 오늘이 수행일인 다짐만 모수(하나도 없으면 100% 취급). 모음 탭 카드·위젯·워치·시리가 공유. **다짐이 하나도 없는 목표는 수치(0%) 대신 '다짐 없음' 상태로 표기** — 위젯 ②('다짐이 없어요' 문구, ④는 원래 처리돼 있었음)·일기 '이 날의 목표' 헤더('다짐 없음'+게이지 생략)·시리(기존 '아직 다짐이 없어요' 오류 응답). 모음 탭 카드는 다짐 없으면 게이지 블록 자체가 안 그려져 해당 없음. 잠금화면·워치 컴플리케이션은 공간 제약으로 수치(0%) 유지 — 수행일 아님과 동일한 의도된 단순화 - **달성 판정**: 종료일 경과 자동(`evaluateIfEnded` — 앱 실행/포그라운드 복귀와 목표 탭 진입 시 실행. 위젯 확장에서는 판정하지 않음) 또는 수동 종료(`manualFinish`). `questAchievementRatio`(달성 다짐=1, 미달성=현재 주기 ratio 클램프, 수행일 아님=1) × 100 ≥ `achieveThresholdPercent` − 0.0001 이면 달성. **기준 100 = 레거시 allSatisfy와 동일** - **판정 기준 시점**: 자동 판정은 **종료일이 속한 논리적 하루의 마지막 순간**(`judgmentReference` — 달력일을 키로 직접 찾아 '하루 시작 시간'이 오후여도 안전) — 앱을 종료일 며칠 뒤에 열어도, 종료일을 과거 날짜로 수정해도 종료일까지의 기록으로 판정된다(실행 시점보다 늦으면 now로 상한). 수동 종료(종료일 없는 목표 전용)는 "지금" 기준. 목표 편집에서 종료일을 바꾸면: 진행 중 목표는 저장 즉시 판정, **완료된 목표는 다시 '진행 중'으로 재개 후 재판정**(미래/없음이면 진행 중 유지, 다른 과거 날짜면 그 시점 기준 재판정) - **주기 중간 종료 판정**(`QuestProgress.judgment(asOf:)`): 판정용 진행률은 current()와 달리 ①값을 기준 시점까지의 누적으로 제한(종료일 이후 같은 주기의 기록 배제)하고 ②주간/월간/특정 기간 다짐의 목표량·한도를 **주기 내 경과일 비율로 축소** — 수요일에 끝나는 목표의 주간 다짐이 남은 4일치 미달로 깎이지 않고, atMost 한도도 같은 비율이라 공정. 하루 단위 다짐은 목표량 축소 없음(그날 목표는 온전히 채워야 달성). 표시 연산(combinedSpanRatio·spanProgress)에는 영향 없음 @@ -324,7 +324,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f ## 14. DEBUG 런치 인자 (검증용, 모두 DEBUG 빌드 전용) -공통: `-seedDemo YES`(데모 데이터: 태그3·행동7·기록 7일치·목표8·자정 걸친 세션·마감 시각 다짐 1개[아침 루틴 08:00, 기대: 하루 100%·연속 1일 — 시드 기록이 고정 시각이라 오전 7:30 이전에 실행하면 오늘 기록이 미래 시각으로 연속 계산에서 빠져 연속 0일이 정상]. **시드 이름·메모는 String(localized:) — 실행 로케일을 따르므로(마케팅 스크린샷용) 이름으로 찾는 인자(-autoStart·-openStatsFor·-excludeActions·-endGoalYesterday)는 그 로케일의 이름을 넘길 것**, ko 기본 사용은 기존과 동일), `-seedBulk YES`(성능 검증용 과거 500일 약 1만 건 — 벌크 행동 4개), `-premium YES/NO`(**인자가 있는 콜드 스타트 동안만 유효** — 다음 실행에 인자를 빼면 실권한으로 복귀하므로 프리미엄 화면 검증 시 매 런치에 함께 줄 것. 지속 강제는 설정의 DEBUG 테스트 토글), `-startTab `, `-navStyle radial`, `-themeAutoToggle YES` +공통: `-seedDemo YES`(데모 데이터: 태그3·행동7·기록 7일치·목표8·자정 걸친 세션·마감 시각 다짐 1개[아침 루틴 08:00, 기대: 하루 100%·연속 1일 — 시드 기록이 고정 시각이라 오전 7:30 이전에 실행하면 오늘 기록이 미래 시각으로 연속 계산에서 빠져 연속 0일이 정상]. **시드 이름·메모는 String(localized:) — 실행 로케일을 따르므로(마케팅 스크린샷용) 이름으로 찾는 인자(-autoStart·-openStatsFor·-excludeActions·-endGoalYesterday)는 그 로케일의 이름을 넘길 것**, ko 기본 사용은 기존과 동일), `-seedBulk YES`(성능 검증용 과거 500일 약 1만 건 — 벌크 행동 4개), `-seedEmptyGoal YES`(다짐 없는 진행 중 목표 1개 추가 — '다짐 없음' 표기 검증용, 기본 시드와 분리), `-premium YES/NO`(**인자가 있는 콜드 스타트 동안만 유효** — 다음 실행에 인자를 빼면 실권한으로 복귀하므로 프리미엄 화면 검증 시 매 런치에 함께 줄 것. 지속 강제는 설정의 DEBUG 테스트 토글), `-startTab `, `-navStyle radial`, `-themeAutoToggle YES` | 영역 | 인자 | |---|---| diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index 3d50fd1..82f5dea 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -44,6 +44,7 @@ struct ContentView: View { .task { #if DEBUG DebugSeed.seedIfRequested(context: context) + DebugSeed.seedEmptyGoalIfRequested(context: context) DebugSeed.seedBulkIfRequested(context: context) DebugSeed.autoStartIfRequested(context: context) DebugSeed.pinGoalsIfRequested(context: context) diff --git a/myApp/HaruDanim/IOS/Core/DebugSeed.swift b/myApp/HaruDanim/IOS/Core/DebugSeed.swift index 109aa02..b7cf9ac 100644 --- a/myApp/HaruDanim/IOS/Core/DebugSeed.swift +++ b/myApp/HaruDanim/IOS/Core/DebugSeed.swift @@ -467,6 +467,19 @@ enum DebugSeed { ) } + /// `-seedEmptyGoal YES`: 다짐 없는 진행 중 목표 1개 추가 — + /// 위젯 ②·일기 카드의 '다짐 없음' 상태 표기 검증용 (기본 시드·마케팅 스크린샷과 분리) + static func seedEmptyGoalIfRequested(context: ModelContext) { + guard UserDefaults.standard.bool(forKey: "seedEmptyGoal") else { return } + let title = String(localized: "새해 다짐 정리하기") + let existing = (try? context.fetch(FetchDescriptor()))?.contains { $0.title == title } ?? false + guard !existing else { return } + let goal = Goal(title: title, symbolName: "sparkles", colorHex: "#D9A621", + startDate: .now, endDate: nil) + context.insert(goal) + try? context.save() + } + static func seedIfRequested(context: ModelContext) { guard UserDefaults.standard.bool(forKey: "seedDemo") else { return } let existing = (try? context.fetchCount(FetchDescriptor())) ?? 0 diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index ffb2f22..3a27514 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -3157,6 +3157,22 @@ } } }, + "다짐 없음": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No quests" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クエストなし" + } + } + } + }, "다짐 점 표시": { "localizations": { "en": { @@ -5827,6 +5843,22 @@ } } }, + "새해 다짐 정리하기": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Organize New Year resolutions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新年の抱負を整理する" + } + } + } + }, "색": { "localizations": { "en": { diff --git a/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift b/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift index 4411e31..e18e072 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift @@ -17,8 +17,9 @@ import SwiftData import PencilKit import PhotosUI -/// 페이지 논리 크기 (내보내기 렌더와 공유) -enum DiaryPageMetrics { +/// 페이지 논리 크기 (내보내기 렌더와 공유). 순수 상수라 격리 불필요 — +/// 백그라운드 PDF 렌더(DiaryTemplateRenderer)에서도 참조한다. +nonisolated enum DiaryPageMetrics { static let size = CGSize(width: 768, height: 1024) static let lineSpacing: CGFloat = 44 static let lineTopInset: CGFloat = 72 diff --git a/myApp/HaruDanim/IOS/Views/DiaryTemplates.swift b/myApp/HaruDanim/IOS/Views/DiaryTemplates.swift index 17e3388..b4cdc5c 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryTemplates.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryTemplates.swift @@ -42,7 +42,9 @@ enum DiaryTemplateRenderer { /// PDF 한 쪽을 페이지 논리 크기(768×1024)에 aspect-fit으로 래스터화. /// 비율이 다른 PDF(A4 등)는 여백을 두고 가운데 맞춤 — 페이지 좌표계(필기·내보내기)는 불변. /// scale = 포인트당 픽셀 배율 (기기 스케일 × 줌, 상한은 호출부가 관리) - static func renderPDFPage(data: Data, pageIndex: Int, scale: CGFloat) -> UIImage? { + /// CoreGraphics만 사용하므로 어느 스레드에서든 안전 — 백그라운드 렌더 호출부(Task.detached)와 + /// 격리 선언을 일치시킨다 (Swift 6 언어 모드 대비). + nonisolated static func renderPDFPage(data: Data, pageIndex: Int, scale: CGFloat) -> UIImage? { guard scale > 0, let provider = CGDataProvider(data: data as CFData), let document = CGPDFDocument(provider), diff --git a/myApp/HaruDanim/IOS/Views/DiaryView.swift b/myApp/HaruDanim/IOS/Views/DiaryView.swift index 6f4bd9b..996e631 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryView.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryView.swift @@ -1196,7 +1196,7 @@ private struct DiarySummaryPage: View { .padding(20) } } - .onAppear(perform: DiarySectionConfig.migrateIfNeeded) + .onAppear { DiarySectionConfig.migrateIfNeeded() } .sheet(isPresented: $showingCalendarPicker) { DiaryCalendarPickerSheet(entry: entry) } @@ -1483,12 +1483,21 @@ private struct DiaryGoalsCard: View { .font(.footnote.weight(.semibold)) .lineLimit(1) Spacer(minLength: 6) - Text("\(Int((dayRatio * 100).rounded()))%") - .font(.footnote.weight(.bold).monospacedDigit()) - .foregroundStyle(goal.color) + if goal.sortedQuests.isEmpty { + // 다짐 없는 목표의 0%는 "실패 중"으로 오독됨 — 상태 문구로 표기 + Text("다짐 없음") + .font(.caption.weight(.medium)) + .foregroundStyle(.tertiary) + } else { + Text("\(Int((dayRatio * 100).rounded()))%") + .font(.footnote.weight(.bold).monospacedDigit()) + .foregroundStyle(goal.color) + } + } + // 목표 단위 하루 진행률 (다짐 진행률 평균) — 다짐 없으면 게이지도 생략 + if !goal.sortedQuests.isEmpty { + gaugeBar(ratio: dayRatio, color: goal.color) } - // 목표 단위 하루 진행률 (다짐 진행률 평균) - gaugeBar(ratio: dayRatio, color: goal.color) if goal.sortedQuests.isEmpty { Text("다짐이 아직 없는 목표예요") .font(.caption2) diff --git a/myApp/HaruDanim/Widgets/GoalWidgets.swift b/myApp/HaruDanim/Widgets/GoalWidgets.swift index f318c53..c173d27 100644 --- a/myApp/HaruDanim/Widgets/GoalWidgets.swift +++ b/myApp/HaruDanim/Widgets/GoalWidgets.swift @@ -146,9 +146,19 @@ struct GoalBarsCellView: View { .font(.caption.weight(.semibold)) .lineLimit(1) } - SpanBarRow(label: String(localized: "하루"), ratio: goal.dayRatio, color: goal.color) - SpanBarRow(label: String(localized: "주간"), ratio: goal.weekRatio, color: goal.color) - SpanBarRow(label: String(localized: "월간"), ratio: goal.monthRatio, color: goal.color) + if goal.quests.isEmpty { + // 다짐 없는 목표의 0% 진행바는 "실패 중"으로 오독됨 — 상태 문구로 표기 (④와 동일) + Spacer() + Text("다짐이 없어요") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + Spacer() + } else { + SpanBarRow(label: String(localized: "하루"), ratio: goal.dayRatio, color: goal.color) + SpanBarRow(label: String(localized: "주간"), ratio: goal.weekRatio, color: goal.color) + SpanBarRow(label: String(localized: "월간"), ratio: goal.monthRatio, color: goal.color) + } } .padding(fullBleed ? 14 : 10) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)