feat(quest): consecutive-achievement display on quests, plus wording fixes
다짐 단위 '연속 달성' 표기 추가 (QuestProgress.streak → QuestStreakInfo): - 하루 단위 다짐: 적용일만 세는 연속 계산 — 매일이면 연속 일수, '매주 수요일'이면 수요일들만 이어서 센다 (4주 연속 = 연속 4일) - 주간/월간 다짐: 주기 전체 달성 여부로 연속 N주/N달 - 특정 기간 다짐: 반복이 없어 표시하지 않음 - 진행 중인 오늘/이번 주기는 '이상 달성'이면 미달이어도 끊기지 않고 건너뛰며, 채웠으면 포함. '이하 유지'는 한도를 넘는 순간 끊김 - 성능: 기록을 논리적 하루 키로 1회 버킷팅해 O(기록수+일수), 소급 상한 400일 표기(기존 디자인·데이터는 그대로 두고 추가만, 0이면 숨김): - 목표 탭 다짐 행: 이름 옆 불꽃 + "연속 N일" 배지 (노랑) - 위젯 ③ 다짐 진행률: 퍼센트 아래 한 줄 - 위젯 ④ 다짐 현황: 이름 아래 작은 글씨 - 위젯 ② 목표+다짐 구성: 퍼센트 아래 6.5pt — 연속 표기가 있을 때만 행 높이 14→19 (적응 채움이 자동 반영) - 문구는 '스트릭' 대신 "연속 N일/주/달" (en은 streak, ja는 N日連続) 문구 정정: '몇째 주 요일' → '몇 번째 요일' — 구현(weekdayOrdinal)은 달력의 주차가 아니라 "그 달의 n번째 ◯요일"이므로 편집 화면 픽커와 주기 요약 문구를 실제 동작에 맞게 수정 (동작 변경 없음). en/ja 번역 포함. 검증: 시드 데이터로 목표 탭 배지 손계산 대조(독서 연속 3일 — 3일 전 기록 없음 / 영상 시청 어제 한도 초과로 연속 1일 등 전부 일치), 위젯 ②③④ 미리보기 스크린샷으로 레이아웃 넘침 없음 확인, missing: [], Debug·Store 스킴 빌드 성공. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
parent
076dad26d8
commit
42b95c5d7f
@ -166,7 +166,8 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}"
|
||||
- 목표 행: 상태 뱃지(진행 중/달성/미달성/확인 필요) + 기간 진행률 바(종료일 있을 때). "확인 필요"는 탭해서 달성/미달성 직접 선택
|
||||
- 목표 편집: 내용·아이콘·색·시작일(과거 가능)·종료일(선택)·**달성 판정 기준 슬라이더(10~100%, 5% 단위, 기본 100%)**
|
||||
- 목표 상세: 다짐 목록(탭=수정, 스와이프 삭제, 순서 모드), 다짐 추가(무료 한도 3개), 종료일 없으면 수동 종료(기준<100%면 안내 문구에 기준 표기), 삭제
|
||||
- 다짐 편집 3단계: ①대상(행동 또는 꼬리표+측정 기준) ②주기(하루[매일/요일/날짜/몇째주 요일]·주·월·특정 기간) ③목표량(시간 휠 또는 횟수 스테퍼)+방향(이상 달성/이하 유지)
|
||||
- 다짐 편집 3단계: ①대상(행동 또는 꼬리표+측정 기준) ②주기(하루[매일/요일/날짜/n번째 요일]·주·월·특정 기간 — ordinalWeekday 구현은 weekdayOrdinal 즉 "그 달의 n번째 ◯요일"이며 UI 문구도 그렇게 표기) ③목표량(시간 휠 또는 횟수 스테퍼)+방향(이상 달성/이하 유지)
|
||||
- **연속 달성**(`QuestProgress.streak` → `QuestStreakInfo`): 하루 단위=적용일만 연속 계산("연속 N일" — 매주 수요일 다짐을 4주 연속이면 연속 4일), 주간/월간="연속 N주/N달", 특정 기간=nil. 진행 중인 오늘/이번 주기는 atLeast면 미달이어도 끊기지 않고(건너뜀) 채웠으면 포함, atMost는 넘는 순간 끊김. 소급 상한 400일(성능). 기록을 하루 키로 1회 버킷팅해 O(기록+일수). 표기: 목표 탭 다짐 행 + 위젯 ②다짐 줄·③링 셀·④현황 그리드 (불꽃+노랑, 0이면 숨김). '스트릭'이라는 단어는 한국어 UI에 쓰지 않는다
|
||||
- 목록 진입 시 `evaluateIfEnded` 일괄 실행
|
||||
|
||||
### 6.5 기록 탭 (`HistoryView`, `RecordEditors`)
|
||||
|
||||
@ -602,6 +602,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld번째": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "#%lld"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld番目"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld분": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -689,6 +705,7 @@
|
||||
}
|
||||
},
|
||||
"%lld째 주": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
@ -3431,7 +3448,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"매달 %lld번째 %@요일": {
|
||||
"localizations": {
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "매달 %1$lld번째 %2$@요일"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "#%lld %@ of every month"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "毎月第%lld%@曜日"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"매달 %lld째 주 %@요일": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
@ -3549,7 +3589,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"몇 번째": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Occurrence"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "何番目"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"몇 번째 요일": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nth weekday"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "何番目の曜日"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"몇째 주": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
@ -3566,6 +3639,7 @@
|
||||
}
|
||||
},
|
||||
"몇째 주 요일": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
@ -5944,6 +6018,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"연속 %lld달": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld-month streak"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lldか月連続"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"연속 %lld일": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld-day streak"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld日連続"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"연속 %lld주": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld-week streak"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld週連続"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"영상 시청": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
|
||||
@ -313,6 +313,12 @@ struct GoalRow: View {
|
||||
struct QuestRow: View {
|
||||
let quest: Quest
|
||||
|
||||
/// 연속 달성 (1 이상일 때만 표기 — 끊기면 배지가 사라진다)
|
||||
private var streakLabel: String? {
|
||||
guard let streak = QuestProgress(quest: quest).streak(), streak.count > 0 else { return nil }
|
||||
return streak.label
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
@ -323,6 +329,17 @@ struct QuestRow: View {
|
||||
.background(quest.targetColor, in: RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
Text(quest.targetName)
|
||||
.font(.subheadline.weight(.medium))
|
||||
if let streakLabel {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
Text(streakLabel)
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(quest.scheduleLabel) · \(quest.targetValueLabel) \(quest.direction.label)")
|
||||
.font(.caption2)
|
||||
|
||||
@ -142,9 +142,10 @@ struct QuestEditorView: View {
|
||||
case .monthDays:
|
||||
monthDayPicker
|
||||
case .ordinalWeekday:
|
||||
Picker("몇째 주", selection: $ordinalWeek) {
|
||||
// "n번째 ◯요일" (weekdayOrdinal) — 달력의 'n째 주'와 다를 수 있어 문구를 맞춘다
|
||||
Picker("몇 번째", selection: $ordinalWeek) {
|
||||
ForEach(1...5, id: \.self) { n in
|
||||
Text("\(n)째 주").tag(n)
|
||||
Text("\(n)번째").tag(n)
|
||||
}
|
||||
}
|
||||
Picker("요일", selection: $ordinalWeekday) {
|
||||
|
||||
@ -332,7 +332,8 @@ enum QuestScheduleMode: String, CaseIterable, Identifiable {
|
||||
case .everyDay: return String(localized: "매일")
|
||||
case .weekdays: return String(localized: "요일 지정")
|
||||
case .monthDays: return String(localized: "날짜 지정")
|
||||
case .ordinalWeekday: return String(localized: "몇째 주 요일")
|
||||
// 구현은 weekdayOrdinal(그 달의 n번째 ◯요일) — '셋째 주'와 다를 수 있어 문구를 정확히 맞춘다
|
||||
case .ordinalWeekday: return String(localized: "몇 번째 요일")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -462,7 +463,8 @@ extension Quest {
|
||||
let names = monthDays.sorted().map { String(localized: "\($0)일") }
|
||||
return String(localized: "매달 \(names.joined(separator: ", "))")
|
||||
case .ordinalWeekday:
|
||||
return String(localized: "매달 \(ordinalWeek)째 주 \(Format.weekdayShort(ordinalWeekday))요일")
|
||||
// weekdayOrdinal 기준이므로 "n째 주"가 아니라 "n번째 ◯요일" (예: 매달 3번째 화요일)
|
||||
return String(localized: "매달 \(ordinalWeek)번째 \(Format.weekdayShort(ordinalWeekday))요일")
|
||||
}
|
||||
case .weekly:
|
||||
return String(localized: "매주")
|
||||
|
||||
@ -221,6 +221,140 @@ struct QuestProgress {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 연속 달성
|
||||
|
||||
/// 다짐의 연속 달성 정보. 문구는 "연속 3일 / 연속 3주 / 연속 2달" 형태만 사용한다 (스펙).
|
||||
struct QuestStreakInfo {
|
||||
enum Unit {
|
||||
case day, week, month
|
||||
}
|
||||
|
||||
let count: Int
|
||||
let unit: Unit
|
||||
|
||||
var label: String {
|
||||
switch unit {
|
||||
case .day: return String(localized: "연속 \(count)일")
|
||||
case .week: return String(localized: "연속 \(count)주")
|
||||
case .month: return String(localized: "연속 \(count)달")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension QuestProgress {
|
||||
/// 연속 계산이 과거로 거슬러 올라가는 상한(일). 성능 보호용 —
|
||||
/// 이보다 긴 연속은 상한에서 잘린 값으로 표기된다 (하루 단위 400일 / 주 57주 / 달 13달 수준).
|
||||
private static let streakScanDays = 400
|
||||
|
||||
/// 현재 연속 달성 횟수. 표시 규칙:
|
||||
/// - 하루 단위: 적용일(매일/요일/날짜/몇 번째 요일)만 세어 "연속 N일".
|
||||
/// 예: 매주 수요일 다짐을 4주 연속 수행 → 연속 4일.
|
||||
/// - 주간/월간: 주기 전체 달성 여부로 "연속 N주 / N달".
|
||||
/// - 특정 기간: 반복이 없으므로 nil (표시 안 함).
|
||||
/// 진행 중인 오늘/이번 주기는 '이상 달성'이면 아직 못 채워도 끊기지 않고(건너뜀),
|
||||
/// 이미 채웠으면 포함한다. '이하 유지'는 한도를 넘는 순간 끊김, 한도 안이면 포함.
|
||||
func streak(now: Date = .now) -> QuestStreakInfo? {
|
||||
guard quest.targetValue > 0 else { return nil }
|
||||
switch quest.period {
|
||||
case .custom: return nil
|
||||
case .daily: return dayStreak(now: now)
|
||||
case .weekly: return periodStreak(unit: .week, now: now)
|
||||
case .monthly: return periodStreak(unit: .month, now: now)
|
||||
}
|
||||
}
|
||||
|
||||
/// 스캔 구간의 기록을 논리적 하루 키로 한 번에 버킷팅한다.
|
||||
/// 하루마다 Aggregator로 전체 기록을 다시 훑는 대신(O(일수×기록수)) 기록을 한 번만 지나가
|
||||
/// 연속 계산 전체가 O(기록수 + 일수)가 되게 하는 것이 핵심.
|
||||
private func perDayValues(since lowerBound: Date, now: Date) -> [Date: Double] {
|
||||
var values: [Date: Double] = [:]
|
||||
for action in quest.targetActions {
|
||||
switch quest.measure {
|
||||
case .time:
|
||||
for session in action.sessions {
|
||||
let end = min(session.endAt ?? now, now)
|
||||
let start = max(session.startAt, lowerBound)
|
||||
guard start < end else { continue }
|
||||
for segment in math.splitByDay(start: start, end: end) {
|
||||
values[segment.dayKey, default: 0]
|
||||
+= segment.range.upperBound.timeIntervalSince(segment.range.lowerBound)
|
||||
}
|
||||
}
|
||||
case .count:
|
||||
for entry in action.countEntries
|
||||
where entry.timestamp >= lowerBound && entry.timestamp <= now {
|
||||
values[math.dayKey(for: entry.timestamp), default: 0] += Double(entry.amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
private func isAchievedValue(_ value: Double) -> Bool {
|
||||
switch quest.direction {
|
||||
case .atLeast: return value >= quest.targetValue
|
||||
case .atMost: return value <= quest.targetValue
|
||||
}
|
||||
}
|
||||
|
||||
/// 하루 단위: 오늘(적용일)부터 거꾸로 적용일만 세며 연속 달성 계산
|
||||
private func dayStreak(now: Date) -> QuestStreakInfo {
|
||||
let cal = math.calendar
|
||||
let todayKey = math.dayKey(for: now)
|
||||
let lowerKey = cal.date(byAdding: .day, value: -Self.streakScanDays, to: todayKey)!
|
||||
let values = perDayValues(since: math.dayRange(forKey: lowerKey).lowerBound, now: now)
|
||||
|
||||
var count = 0
|
||||
var key = todayKey
|
||||
while key >= lowerKey {
|
||||
if isActiveDay(key) {
|
||||
if isAchievedValue(values[key] ?? 0) {
|
||||
count += 1
|
||||
} else if key == todayKey && quest.direction == .atLeast {
|
||||
// 오늘은 아직 하루가 끝나지 않았으므로 미달이어도 끊김이 아님 — 어제까지로 센다
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
key = cal.date(byAdding: .day, value: -1, to: key)!
|
||||
}
|
||||
return QuestStreakInfo(count: count, unit: .day)
|
||||
}
|
||||
|
||||
/// 주간/월간: 현재 주기부터 거꾸로 주기 전체 달성 여부로 연속 계산
|
||||
private func periodStreak(unit: QuestStreakInfo.Unit, now: Date) -> QuestStreakInfo {
|
||||
let currentRange = unit == .week ? math.weekRange(containing: now) : math.monthRange(containing: now)
|
||||
let lowerBound = math.calendar.date(
|
||||
byAdding: .day, value: -Self.streakScanDays, to: currentRange.lowerBound
|
||||
)!
|
||||
let values = perDayValues(since: lowerBound, now: now)
|
||||
|
||||
func value(in range: Range<Date>) -> Double {
|
||||
math.dayKeys(in: range).reduce(0) { $0 + (values[$1] ?? 0) }
|
||||
}
|
||||
|
||||
var count = 0
|
||||
var range = currentRange
|
||||
var isCurrentPeriod = true
|
||||
while range.lowerBound >= lowerBound {
|
||||
if isAchievedValue(value(in: range)) {
|
||||
count += 1
|
||||
} else if isCurrentPeriod && quest.direction == .atLeast {
|
||||
// 진행 중인 이번 주기의 미달은 끊김이 아님 — 지난 주기부터 센다
|
||||
} else {
|
||||
break
|
||||
}
|
||||
isCurrentPeriod = false
|
||||
// 이전 주기: 현재 주기 시작 직전 시각이 속한 주/달
|
||||
let justBefore = range.lowerBound.addingTimeInterval(-1)
|
||||
range = unit == .week
|
||||
? math.weekRange(containing: justBefore)
|
||||
: math.monthRange(containing: justBefore)
|
||||
}
|
||||
return QuestStreakInfo(count: count, unit: unit)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 목표 평가
|
||||
|
||||
extension Goal {
|
||||
|
||||
@ -156,7 +156,8 @@ struct GoalBarsCellView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 다짐 1개의 하루 진행률 한 줄 (아이콘 + 이름 + 진행바 + %)
|
||||
/// 다짐 1개의 하루 진행률 한 줄 (아이콘 + 이름 + 진행바 + %).
|
||||
/// 연속 달성이 있으면 % 아래에 아주 작게 덧붙인다 (기존 열 폭·구성은 유지).
|
||||
struct QuestBarRow: View {
|
||||
let quest: QuestCellSnapshot
|
||||
|
||||
@ -173,10 +174,24 @@ struct QuestBarRow: View {
|
||||
ProgressView(value: min(max(quest.ratio, 0), 1))
|
||||
.progressViewStyle(.linear)
|
||||
.tint(quest.isAtMost && !quest.isAchieved ? Color.red : quest.color)
|
||||
Text(Format.percent(quest.displayRatio))
|
||||
.font(.system(size: 9, weight: .semibold).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
if let streak = quest.streakLabel {
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
Text(Format.percent(quest.displayRatio))
|
||||
.font(.system(size: 9, weight: .semibold).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(streak)
|
||||
.font(.system(size: 6.5, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(width: 34, alignment: .trailing)
|
||||
} else {
|
||||
Text(Format.percent(quest.displayRatio))
|
||||
.font(.system(size: 9, weight: .semibold).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 34, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -186,7 +201,11 @@ struct QuestBarRow: View {
|
||||
struct QuestBarsColumn: View {
|
||||
let quests: [QuestCellSnapshot]
|
||||
|
||||
private let rowHeight: CGFloat = 14
|
||||
/// 연속 달성 줄이 하나라도 있으면 행 높이를 살짝 키운다 (없으면 기존 14 그대로 —
|
||||
/// 표시 개수 계산(fitCount)이 자동으로 따라오므로 넘치지 않는다)
|
||||
private var rowHeight: CGFloat {
|
||||
quests.contains { $0.streakLabel != nil } ? 19 : 14
|
||||
}
|
||||
private let rowSpacing: CGFloat = 6
|
||||
private let headerHeight: CGFloat = 17
|
||||
|
||||
@ -456,7 +475,7 @@ struct QuestStatusProvider: AppIntentTimelineProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// 링 + 이름만 (진행률 % 텍스트 없음)
|
||||
/// 링 + 이름만 (진행률 % 텍스트 없음). 연속 달성이 있으면 이름 아래 아주 작게 덧붙인다.
|
||||
struct QuestRingCellView: View {
|
||||
let quest: QuestCellSnapshot
|
||||
var ringSize: CGFloat = 34
|
||||
@ -465,10 +484,18 @@ struct QuestRingCellView: View {
|
||||
VStack(spacing: 5) {
|
||||
QuestRingView(snapshot: quest, lineWidth: 4, iconSize: ringSize * 0.36)
|
||||
.frame(width: ringSize, height: ringSize)
|
||||
Text(quest.targetName)
|
||||
.font(.system(size: 8, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(spacing: 1) {
|
||||
Text(quest.targetName)
|
||||
.font(.system(size: 8, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.secondary)
|
||||
if let streak = quest.streakLabel {
|
||||
Text(streak)
|
||||
.font(.system(size: 7, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,6 +133,16 @@ struct QuestRingActionCellView: View {
|
||||
Text(percentText)
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
if let streak = cell.streakLabel {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.system(size: 7, weight: .bold))
|
||||
Text(streak)
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(fullBleed ? 10 : 6)
|
||||
|
||||
@ -93,12 +93,16 @@ struct QuestCellSnapshot: Identifiable {
|
||||
/// 실행 버튼 대상 (단일 행동 대상 다짐만)
|
||||
let runTarget: ActionEntity?
|
||||
let isRunning: Bool
|
||||
/// 연속 달성 문구 ("연속 3일" 등). 1 미만이거나 특정 기간 다짐이면 nil (표시 생략)
|
||||
let streakLabel: String?
|
||||
|
||||
var color: Color { Color(hex: colorHex) }
|
||||
|
||||
@MainActor
|
||||
static func make(quest: Quest, span: StatSpan, now: Date = .now) -> QuestCellSnapshot {
|
||||
let result = QuestProgress(quest: quest).spanProgress(span, now: now)
|
||||
let progress = QuestProgress(quest: quest)
|
||||
let result = progress.spanProgress(span, now: now)
|
||||
let streak = progress.streak(now: now)
|
||||
let action = quest.targetAction
|
||||
return QuestCellSnapshot(
|
||||
id: quest.uuid,
|
||||
@ -111,7 +115,8 @@ struct QuestCellSnapshot: Identifiable {
|
||||
isAtMost: quest.direction == .atMost,
|
||||
isAchieved: result.isAchieved,
|
||||
runTarget: action.map(ActionEntity.init),
|
||||
isRunning: action?.isRunning ?? false
|
||||
isRunning: action?.isRunning ?? false,
|
||||
streakLabel: (streak?.count ?? 0) > 0 ? streak?.label : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -174,16 +179,20 @@ extension QuestCellSnapshot {
|
||||
static let samples: [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),
|
||||
isAtMost: false, isAchieved: false, runTarget: nil, isRunning: true,
|
||||
streakLabel: QuestStreakInfo(count: 5, unit: .day).label),
|
||||
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),
|
||||
isAtMost: false, isAchieved: false, runTarget: nil, isRunning: false,
|
||||
streakLabel: nil),
|
||||
QuestCellSnapshot(id: UUID(), targetName: String(localized: "물 마시기"), symbolName: "drop.fill",
|
||||
colorHex: "#4A7A9D", ratio: 1, displayRatio: 1.1,
|
||||
isAtMost: false, isAchieved: true, runTarget: nil, isRunning: false),
|
||||
isAtMost: false, isAchieved: true, runTarget: nil, isRunning: false,
|
||||
streakLabel: QuestStreakInfo(count: 12, unit: .day).label),
|
||||
QuestCellSnapshot(id: UUID(), targetName: String(localized: "영상 시청"), symbolName: "play.rectangle.fill",
|
||||
colorHex: "#7A5C9D", ratio: 1, displayRatio: 1,
|
||||
isAtMost: true, isAchieved: true, runTarget: nil, isRunning: false),
|
||||
isAtMost: true, isAchieved: true, runTarget: nil, isRunning: false,
|
||||
streakLabel: nil),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user