feat(widget): show span accumulated value in quest ring widget

③ 다짐 진행률 위젯에서 '이하 유지' 다짐은 시작부터 링이 가득 차 있고
문구도 '오늘 한도 지킴'뿐이라 지금 몇 회/몇 시간 했는지 알 수 없던 문제.
퍼센트(또는 한도 지킴/초과) 문구와 연속 달성 문구 사이에 위젯 옵션의
진행률 기간 기준 실제 누적값 한 줄을 추가한다 ("1시간 20분" / "3회").
'이상 달성' 다짐에도 동일하게 표시. 주간·월간 진행률 캡과 무관한
원본 누적값이며, 다른 위젯(②④)과 나머지 레이아웃은 그대로.

- QuestCellSnapshot.valueLabel 추가 (spanRange 원본 값 포맷)
- QuestProgress.spanRange(_:now:) 헬퍼 추출
- 위젯 미리보기에 '이하 유지(소형)' 행 신설 (앵커 CM)
- l10n: '0분' ko/en/ja (IOS·Widgets 카탈로그)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-14 02:37:41 +09:00
parent 379ea6ebf4
commit 24c3004ead
8 changed files with 142 additions and 71 deletions

View File

@ -240,7 +240,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}"
|---|---|
| ① 행동 실행 `HaruActionRunWidget` | `Button(intent: RunActionIntent)` — 시간형 토글/횟수형 +1. 누적값 표시 기간(오늘/주/월/숨김) 선택. 소형 1(풀블리드)/중형 4(2×2 행 레이아웃)/대형 4 또는 8. 측정 중 노란 테두리+타이머(`Text(style:.timer)`) |
| ② 목표 진행률 `HaruGoalBarsWidget` | 하루/주간/월간 가로 진행바 3줄. 중형: 목표2 또는 목표1+다짐. 대형: 목표4 / 목표+다짐×2 / 목표1+모든 다짐. 다짐 목록은 높이에 맞춰 적응 채움(+N) |
| ③ 다짐 진행률 `HaruQuestRingWidget` | 원형 링(행동 대상이면 눌러 실행). 기간 선택. 소형1/중형2/대형4. atMost는 "한도 지킴/초과" |
| ③ 다짐 진행률 `HaruQuestRingWidget` | 원형 링(행동 대상이면 눌러 실행). 기간 선택. 소형1/중형2/대형4. atMost는 "한도 지킴/초과". 퍼센트(한도) 문구 아래에 선택 기간의 **실제 누적값**("1시간 20분"/"3회") 한 줄 — atMost는 퍼센트가 없어 이 줄이 유일한 현재 수치(진행률 캡과 무관한 원본 값, 미리보기 앵커 CM) |
| ④ 다짐 현황 `HaruGoalQuestGridWidget` | 표시 전용 링 그리드 + 목표 헤더. 소형 2×2 / 중형 4×2 / 대형: 다짐16 / 목표4 / 목표2 |
| ⑤ 행동 통계 `HaruStatsChartWidget` | 꺾은선(행동 색). 기간: 일주일/한 달(일별, 소형 제외→주별 대체)/한 달(주별). 선 수 제한 소2/중4/대6 |
| 잠금화면 `HaruLockGoalWidget` | 목표 1개 달성률 — 원형 게이지/숫자만/다짐 점(달성=채움). circular/rectangular/inline |

View File

@ -1,90 +1,89 @@
{
"sourceLanguage": "ko",
"strings": {
"CFBundleDisplayName": {
"comment": "홈 화면에 표시되는 앱 이름",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Haru Danim"
"sourceLanguage" : "ko",
"strings" : {
"CFBundleDisplayName" : {
"comment" : "홈 화면에 표시되는 앱 이름",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Haru Danim"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Haru Danim"
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "Haru Danim"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "하루 다님"
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "하루 다님"
}
}
}
},
"CFBundleName": {
"comment": "Bundle name",
"extractionState": "extracted_with_value",
"localizations": {
"ko": {
"stringUnit": {
"state": "new",
"value": "Haru_Danim"
"CFBundleName" : {
"comment" : "Bundle name",
"extractionState" : "extracted_with_value",
"localizations" : {
"ko" : {
"stringUnit" : {
"state" : "new",
"value" : "Haru_Danim"
}
}
},
"shouldTranslate": false
"shouldTranslate" : false
},
"NSCalendarsFullAccessUsageDescription": {
"comment": "Privacy - Calendars Full Access Usage Description",
"extractionState": "extracted_with_value",
"localizations": {
"ko": {
"stringUnit": {
"state": "translated",
"value": "일기 타임테이블에 캘린더 일정을 함께 표시하기 위해 필요해요."
"NSCalendarsFullAccessUsageDescription" : {
"comment" : "Privacy - Calendars Full Access Usage Description",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Needed to show your calendar events alongside the diary timetable."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Needed to show your calendar events alongside the diary timetable."
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "日記のタイムテーブルにカレンダーの予定を一緒に表示するために必要です。"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "日記のタイムテーブルにカレンダーの予定を一緒に表示するために必要です。"
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "일기 타임테이블에 캘린더 일정을 함께 표시하기 위해 필요해요."
}
}
}
},
"NSPhotoLibraryAddUsageDescription": {
"comment": "이미지 내보내기 → 사진에 저장 권한 문구",
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Needed to save exported report images to your photo library."
"NSPhotoLibraryAddUsageDescription" : {
"comment" : "이미지 내보내기 → 사진에 저장 권한 문구",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Needed to save exported report images to your photo library."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "書き出したレポート画像を写真アルバムに保存するために必要です。"
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "書き出したレポート画像を写真アルバムに保存するために必要です。"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "내보낸 리포트 이미지를 사진 앨범에 저장하기 위해 필요해요."
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "내보낸 리포트 이미지를 사진 앨범에 저장하기 위해 필요해요."
}
}
}
}
},
"version": "1.0"
"version" : "1.0"
}

View File

@ -915,6 +915,22 @@
}
}
},
"0분": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "0m"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "0分"
}
}
}
},
"1.0": {
"localizations": {
"en": {

View File

@ -30,6 +30,8 @@ struct WidgetPreviewScreen: View {
@State private var ringSmall: QuestRingEntry?
@State private var ringMedium: QuestRingEntry?
@State private var ringLarge: QuestRingEntry?
/// ' ' ( + )
@State private var ringAtMostSmall: QuestRingEntry?
//
@State private var statusSmall: QuestStatusEntry?
@State private var statusMedium: QuestStatusEntry?
@ -117,6 +119,11 @@ struct WidgetPreviewScreen: View {
ringSmall = QuestRingProvider.makeEntry(ringConfig, family: .systemSmall)
ringMedium = QuestRingProvider.makeEntry(ringConfig, family: .systemMedium)
ringLarge = QuestRingProvider.makeEntry(ringConfig, family: .systemLarge)
if let atMost = IntentStore.quests().first(where: { $0.direction == .atMost }) {
var atMostConfig = QuestRingConfigIntent()
atMostConfig.quests = [QuestEntity(atMost)]
ringAtMostSmall = QuestRingProvider.makeEntry(atMostConfig, family: .systemSmall)
}
// : 3
let statusConfig = QuestStatusConfigIntent()
@ -214,6 +221,12 @@ struct WidgetPreviewScreen: View {
}
.id("C")
}
if let atMostSmall = ringAtMostSmall {
row("③ 다짐 진행률 · 이하 유지(소형)") {
widgetBox(.systemSmall) { QuestRingWidgetView(previewFamily: .systemSmall, entry: atMostSmall) }
}
.id("CM")
}
if let large = ringLarge {
row("③ 다짐 진행률 · 대형(2×2)") {
widgetBox(.systemLarge) { QuestRingWidgetView(previewFamily: .systemLarge, entry: large) }

View File

@ -146,14 +146,18 @@ struct QuestProgress {
// MARK: // (CLAUDE.md §6.4 )
/// span ( · )
func spanRange(_ span: StatSpan, now: Date = .now) -> Range<Date> {
switch span {
case .day: return math.dayRange(containing: now)
case .week: return math.weekRange(containing: now)
case .month: return math.monthRange(containing: now)
}
}
/// span , span
func spanProgress(_ span: StatSpan, now: Date = .now) -> QuestProgressResult {
let range: Range<Date>
switch span {
case .day: range = math.dayRange(containing: now)
case .week: range = math.weekRange(containing: now)
case .month: range = math.monthRange(containing: now)
}
let range = spanRange(span, now: now)
return QuestProgressResult(
value: spanValue(for: span, range: range, now: now),
target: scaledTarget(for: span, range: range, now: now),

View File

@ -3282,6 +3282,22 @@
}
}
}
},
"0분": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "0m"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "0分"
}
}
}
}
},
"version": "1.0"

View File

@ -133,6 +133,11 @@ struct QuestRingActionCellView: View {
Text(percentText)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
// span ' '
Text(cell.valueLabel)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
if let streak = cell.streakLabel {
HStack(spacing: 2) {
Image(systemName: "flame.fill")

View File

@ -95,6 +95,11 @@ struct QuestCellSnapshot: Identifiable {
let isRunning: Bool
/// (" 3" ). 1 nil ( )
let streakLabel: String?
/// span ("1 20" / "3")
/// ( ' ') . ' '
/// . (· )
/// .
let valueLabel: String
var color: Color { Color(hex: colorHex) }
@ -104,6 +109,14 @@ struct QuestCellSnapshot: Identifiable {
let result = progress.spanProgress(span, now: now)
let streak = progress.streak(now: now)
let action = quest.targetAction
let rawValue = progress.value(in: progress.spanRange(span, now: now), now: now)
let valueLabel: String
switch quest.measure {
case .time:
valueLabel = rawValue < 60 ? String(localized: "0분") : Format.durationShort(rawValue)
case .count:
valueLabel = String(localized: "\(Int(rawValue))")
}
return QuestCellSnapshot(
id: quest.uuid,
targetName: quest.targetName,
@ -116,7 +129,8 @@ struct QuestCellSnapshot: Identifiable {
isAchieved: result.isAchieved,
runTarget: action.map(ActionEntity.init),
isRunning: action?.isRunning ?? false,
streakLabel: (streak?.count ?? 0) > 0 ? streak?.label : nil
streakLabel: (streak?.count ?? 0) > 0 ? streak?.label : nil,
valueLabel: valueLabel
)
}
}
@ -180,19 +194,23 @@ extension QuestCellSnapshot {
QuestCellSnapshot(id: UUID(), targetName: String(localized: "독서"), symbolName: "book.fill",
colorHex: "#2F6B4F", ratio: 0.75, displayRatio: 0.75,
isAtMost: false, isAchieved: false, runTarget: nil, isRunning: true,
streakLabel: QuestStreakInfo(count: 5, unit: .day).label),
streakLabel: QuestStreakInfo(count: 5, unit: .day).label,
valueLabel: Format.durationShort(45 * 60)),
QuestCellSnapshot(id: UUID(), targetName: String(localized: "달리기"), symbolName: "figure.run",
colorHex: "#9D5B4A", ratio: 0.4, displayRatio: 0.4,
isAtMost: false, isAchieved: false, runTarget: nil, isRunning: false,
streakLabel: nil),
streakLabel: nil,
valueLabel: Format.durationShort(12 * 60)),
QuestCellSnapshot(id: UUID(), targetName: String(localized: "물 마시기"), symbolName: "drop.fill",
colorHex: "#4A7A9D", ratio: 1, displayRatio: 1.1,
isAtMost: false, isAchieved: true, runTarget: nil, isRunning: false,
streakLabel: QuestStreakInfo(count: 12, unit: .day).label),
streakLabel: QuestStreakInfo(count: 12, unit: .day).label,
valueLabel: String(localized: "\(9)")),
QuestCellSnapshot(id: UUID(), targetName: String(localized: "영상 시청"), symbolName: "play.rectangle.fill",
colorHex: "#7A5C9D", ratio: 1, displayRatio: 1,
isAtMost: true, isAchieved: true, runTarget: nil, isRunning: false,
streakLabel: nil),
streakLabel: nil,
valueLabel: Format.durationShort(40 * 60)),
]
}