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:
parent
379ea6ebf4
commit
24c3004ead
@ -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 |
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -915,6 +915,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"0분": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "0m"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "0分"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"1.0": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -3282,6 +3282,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"0분": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "0m"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "0分"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.0"
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user