체감 개선 2건 (사용자 리포트 기반): 1) 홈 위젯 버튼의 '안 눌렸나?' 문제 — ①행동 실행·③다짐 진행률 위젯의 변하는 값(누적·퍼센트·링)에 invalidatableContent() 적용. 탭 즉시 해당 값이 '갱신 중' 표시로 바뀌어, 새 타임라인 도착 (1~3초, WidgetKit 구조상 단축 불가) 전에도 탭 접수가 보인다 → 중복 탭으로 횟수가 여러 번 올라가는 문제 방지. 2) 워치 컴플리케이션 유령 타이머 — 종료 푸시가 유실되면 '측정 중' 타이머가 무한히 흐르던 문제. 스냅숏 generatedAt 기준 2시간 (runningTrustInterval)을 넘긴 '측정 중'은 '새로고침 필요' 표시로 강등. 워치 앱을 열면 즉시 복구되고 진짜 장시간 측정도 다음 스냅숏에 타이머가 돌아온다 (데이터 무영향). 검증: 위젯 미리보기 레이아웃 무영향 확인, 워치 미리보기에 강등 샘플 행 추가(-complicationScrollTo stale)로 원형/사각형 렌더링 확인. 새 문자열 4개 워치 앱·워치 위젯 카탈로그 ko/en/ja 번역. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
247 lines
9.3 KiB
Swift
247 lines
9.3 KiB
Swift
//
|
||
// 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)
|
||
}
|
||
}
|
||
// 버튼을 누르는 즉시 링·수치가 '갱신 중' 표시로 바뀌어
|
||
// 새 타임라인 도착 전에도 탭이 접수됐음을 보여 준다 (중복 탭 방지)
|
||
.invalidatableContent()
|
||
Text(cell.targetName)
|
||
.font(.caption2.weight(.semibold))
|
||
.lineLimit(1)
|
||
Text(percentText)
|
||
.font(.caption2.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
.invalidatableContent()
|
||
// span 누적값 — '이하 유지'는 퍼센트가 없어 이 줄이 유일한 현재 수치
|
||
Text(cell.valueLabel)
|
||
.font(.system(size: 9, weight: .medium).monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
.invalidatableContent()
|
||
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()
|
||
}
|
||
}
|