mycode/myApp/HaruDanim/Widgets/QuestRingWidget.swift
songyc macbook 42b95c5d7f 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
2026-07-13 23:49:45 +09:00

237 lines
8.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// QuestRingWidget.swift
// Haru_DanimWidgets
//
// (CLAUDE.md §8.2)
// ( ) + (Interactive).
// , " 0%" .
// - : 1 ()
// - : 2 ()
// - : 4 (2×2)
//
import SwiftUI
import WidgetKit
import AppIntents
// MARK: -
struct QuestRingConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "다짐 진행률 위젯"
static let description = IntentDescription("원형 진행률로 표시할 다짐들과 기간, 테마를 선택하세요. 선택하지 않은 칸은 비워 둬요.")
@Parameter(title: "다짐")
var quests: [QuestEntity]?
@Parameter(title: "진행률 기간", default: .day)
var span: SpanOption
@Parameter(title: "테마", default: .matchApp)
var theme: WidgetThemeOption
}
// MARK: -
struct QuestRingEntry: TimelineEntry {
let date: Date
let locked: Bool
let theme: WidgetThemeOption
let spanLabel: String
/// (nil = )
let cells: [QuestCellSnapshot?]
///
var isLive: Bool { cells.contains { $0?.isRunning == true } }
}
struct QuestRingProvider: AppIntentTimelineProvider {
/// ( 1 / 2 / 4)
static func capacity(_ family: WidgetFamily) -> Int {
switch family {
case .systemMedium: return 2
case .systemLarge: return 4
default: return 1
}
}
@MainActor
static func makeEntry(_ configuration: QuestRingConfigIntent, family: WidgetFamily,
now: Date = .now) -> QuestRingEntry {
IntentStore.refresh()
guard WidgetStore.isUnlocked else {
return QuestRingEntry(date: now, locked: true, theme: configuration.theme, spanLabel: "", cells: [])
}
let capacity = Self.capacity(family)
let quests = WidgetStore.selectedQuests(configuration.quests, defaultCount: capacity)
let span = configuration.span.statSpan
return QuestRingEntry(
date: now,
locked: false,
theme: configuration.theme,
spanLabel: configuration.span.label,
cells: WidgetStore.slots(quests.map { QuestCellSnapshot.make(quest: $0, span: span, now: now) },
capacity: capacity)
)
}
func placeholder(in context: Context) -> QuestRingEntry {
QuestRingEntry(date: .now, locked: false, theme: .matchApp, spanLabel: "오늘",
cells: Array(QuestCellSnapshot.samples.prefix(Self.capacity(context.family))))
}
@MainActor
func snapshot(for configuration: QuestRingConfigIntent, in context: Context) async -> QuestRingEntry {
Self.makeEntry(configuration, family: context.family)
}
@MainActor
func timeline(for configuration: QuestRingConfigIntent, in context: Context) async -> Timeline<QuestRingEntry> {
let first = Self.makeEntry(configuration, family: context.family)
return WidgetRefresh.timeline(first: first, live: first.isLive) { date in
Self.makeEntry(configuration, family: context.family, now: date)
}
}
}
// MARK: -
struct QuestRingActionCellView: View {
let cell: QuestCellSnapshot
let spanLabel: String
var ringSize: CGFloat = 52
/// ()
var fullBleed = false
var body: some View {
// ( )
if let target = cell.runTarget {
Button(intent: RunActionIntent(actionID: target.id.uuidString)) {
content
}
.buttonStyle(.plain)
} else {
content
}
}
private var content: some View {
VStack(spacing: 4) {
ZStack {
QuestRingView(snapshot: cell, lineWidth: 5, iconSize: ringSize * 0.34)
.frame(width: ringSize, height: ringSize)
if cell.isRunning {
// ( )
Circle()
.fill(AppTheme.yellow)
.frame(width: 9, height: 9)
.offset(x: ringSize * 0.38, y: -ringSize * 0.38)
}
}
Text(cell.targetName)
.font(.caption2.weight(.semibold))
.lineLimit(1)
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)
.background(WidgetCellBackground(fullBleed: fullBleed))
}
private var percentText: String {
if cell.isAtMost {
return cell.isAchieved
? String(localized: "\(spanLabel) 한도 지킴")
: String(localized: "\(spanLabel) 한도 초과")
}
return "\(spanLabel) \(Format.percent(cell.displayRatio))"
}
}
struct QuestRingWidgetView: View {
@Environment(\.widgetFamily) private var envFamily
/// DEBUG
var previewFamily: WidgetFamily? = nil
private var family: WidgetFamily { previewFamily ?? envFamily }
let entry: QuestRingEntry
var body: some View {
Group {
if entry.locked {
WidgetLockedView()
.padding(12)
} else if !entry.cells.contains(where: { $0 != nil }) {
WidgetEmptyView(symbolName: "checkmark.circle", message: "목표 탭에서 다짐을 만들면\n진행률이 표시돼요")
} else {
switch family {
case .systemMedium:
HStack(spacing: 8) {
ForEach(0..<2, id: \.self) { index in
slotCell(index, ringSize: 52)
}
}
.padding(10)
case .systemLarge:
VStack(spacing: 8) {
ForEach(0..<2, id: \.self) { row in
HStack(spacing: 8) {
ForEach(0..<2, id: \.self) { col in
slotCell(row * 2 + col, ringSize: 60)
}
}
}
}
.padding(10)
default:
// : ()
if let cell = entry.cells.first ?? nil {
QuestRingActionCellView(cell: cell, spanLabel: entry.spanLabel,
ringSize: 58, fullBleed: true)
} else {
WidgetBlankCellView(fullBleed: true)
}
}
}
}
.widgetAppTheme(entry.theme)
}
@ViewBuilder
private func slotCell(_ index: Int, ringSize: CGFloat) -> some View {
if index < entry.cells.count, let cell = entry.cells[index] {
QuestRingActionCellView(cell: cell, spanLabel: entry.spanLabel, ringSize: ringSize)
} else {
WidgetBlankCellView()
}
}
}
// MARK: -
struct QuestRingWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "HaruQuestRingWidget",
intent: QuestRingConfigIntent.self,
provider: QuestRingProvider()
) { entry in
QuestRingWidgetView(entry: entry)
}
.configurationDisplayName("다짐 진행률")
.description("다짐의 진행률을 원형 링으로 보고, 눌러서 바로 실행해요. (프리미엄)")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
.contentMarginsDisabled()
}
}