- placeholder()의 생 한국어 리터럴 6곳을 String(localized:)로: ③다짐 링·④다짐 현황·잠금화면 위젯의 spanLabel "오늘"(기존 키 재사용), 워치 목표/다짐 달성률 "목표"/"하루"(기존 키 재사용), 워치 현재 현황 샘플 "독서"(워치위젯 카탈로그 신규 1키 — en Reading/ja 読書, iOS 위젯 카탈로그와 동일 값) - placeholder는 갤러리/로딩 스켈레톤 전용 경로라 타임라인 예산·버튼 반응성·갱신 경로 무영향 — ①⑤ 위젯 placeholder가 이미 쓰던 패턴과 동일 - 검증: Debug·Store·워치 3스킴 빌드 성공, 전 카탈로그 missing/stale 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
345 lines
14 KiB
Swift
345 lines
14 KiB
Swift
//
|
||
// RateComplications.swift
|
||
// Haru_DanimWatchWidgetsExtension
|
||
//
|
||
// 목표/다짐 달성률 컴플리케이션 (CLAUDE.md §9.2-2, §9.2-3)
|
||
// - 목표: 선택한 목표의 하루/주간/월간 달성률
|
||
// - 다짐: 선택한 다짐의 달성률. rectangular는 세 기간을 모두 표기.
|
||
// '이하 유지(넘기면 안 되는)' 다짐은 한도 대비 상태로 다르게 표현
|
||
//
|
||
|
||
import AppIntents
|
||
import SwiftUI
|
||
import WidgetKit
|
||
|
||
// MARK: - 목표 달성률
|
||
|
||
struct GoalRateConfigIntent: WidgetConfigurationIntent {
|
||
static let title: LocalizedStringResource = "목표 달성률"
|
||
static let description = IntentDescription("표시할 목표와 기간을 선택하세요.")
|
||
|
||
@Parameter(title: "목표")
|
||
var goal: WatchGoalEntity?
|
||
|
||
@Parameter(title: "기간", default: .day)
|
||
var span: WatchSpanOption
|
||
}
|
||
|
||
struct GoalRateEntry: TimelineEntry {
|
||
let date: Date
|
||
let locked: Bool
|
||
let title: String?
|
||
let symbolName: String
|
||
let spanLabel: String
|
||
let ratio: Double
|
||
}
|
||
|
||
struct GoalRateProvider: AppIntentTimelineProvider {
|
||
static func makeEntry(_ configuration: GoalRateConfigIntent) -> GoalRateEntry {
|
||
guard let snapshot = ComplicationStore.snapshot() else {
|
||
return GoalRateEntry(date: .now, locked: false, title: nil, symbolName: "flag.checkered",
|
||
spanLabel: configuration.span.label, ratio: 0)
|
||
}
|
||
guard snapshot.isPremium else {
|
||
return GoalRateEntry(date: .now, locked: true, title: nil, symbolName: "flag.checkered",
|
||
spanLabel: "", ratio: 0)
|
||
}
|
||
let goal = snapshot.goals.first { $0.id == configuration.goal?.id } ?? snapshot.goals.first
|
||
return GoalRateEntry(
|
||
date: .now,
|
||
locked: false,
|
||
title: goal?.title,
|
||
symbolName: goal?.symbolName ?? "flag.checkered",
|
||
spanLabel: configuration.span.label,
|
||
ratio: goal?.ratio(forSpanRaw: configuration.span.rawValue) ?? 0
|
||
)
|
||
}
|
||
|
||
func placeholder(in context: Context) -> GoalRateEntry {
|
||
GoalRateEntry(date: .now, locked: false, title: String(localized: "목표"), symbolName: "flag.checkered",
|
||
spanLabel: String(localized: "하루"), ratio: 0.6)
|
||
}
|
||
|
||
func snapshot(for configuration: GoalRateConfigIntent, in context: Context) async -> GoalRateEntry {
|
||
Self.makeEntry(configuration)
|
||
}
|
||
|
||
func timeline(for configuration: GoalRateConfigIntent, in context: Context) async -> Timeline<GoalRateEntry> {
|
||
Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
|
||
}
|
||
|
||
/// 워치 페이스 갤러리에 노출할 추천 구성 (목표 × 하루/주간/월간)
|
||
/// 주의: 추천 description에 문자열 보간(포맷 텍스트)을 쓰면 WidgetKit assertion으로
|
||
/// 익스텐션이 즉사해 컴플리케이션이 페이스 편집 목록에 아예 나타나지 않는다.
|
||
/// 반드시 Text(verbatim:)처럼 포맷 없는 텍스트를 넘길 것.
|
||
func recommendations() -> [AppIntentRecommendation<GoalRateConfigIntent>] {
|
||
let cached = (ComplicationStore.snapshot()?.goals ?? []).prefix(2)
|
||
.map { WatchGoalEntity(id: $0.id, title: $0.title) }
|
||
// 스냅숏이 아직 없으면 자리 표시용 엔티티로 추천 (표시 시점엔 첫 목표로 대체됨)
|
||
let goals = cached.isEmpty
|
||
? [WatchGoalEntity(id: UUID(), title: String(localized: "목표"))]
|
||
: cached
|
||
return goals.flatMap { goal in
|
||
WatchSpanOption.allCases.map { span in
|
||
let intent = GoalRateConfigIntent()
|
||
intent.goal = goal
|
||
intent.span = span
|
||
return AppIntentRecommendation(
|
||
intent: intent,
|
||
description: Text(verbatim: "\(goal.title) · \(span.label)")
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct GoalRateComplicationView: View {
|
||
@Environment(\.widgetFamily) private var envFamily
|
||
/// 워치 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
|
||
var previewFamily: WidgetFamily? = nil
|
||
private var family: WidgetFamily { previewFamily ?? envFamily }
|
||
let entry: GoalRateEntry
|
||
|
||
private var percent: Int { Int((entry.ratio * 100).rounded()) }
|
||
|
||
var body: some View {
|
||
Group {
|
||
if entry.locked {
|
||
ComplicationLockedView()
|
||
} else if let title = entry.title {
|
||
switch family {
|
||
case .accessoryInline:
|
||
Text("\(title) \(percent)%")
|
||
case .accessoryRectangular:
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: entry.symbolName)
|
||
.font(.system(size: 11))
|
||
Text(title)
|
||
.font(.headline)
|
||
.lineLimit(1)
|
||
}
|
||
Gauge(value: min(max(entry.ratio, 0), 1)) { EmptyView() }
|
||
.gaugeStyle(.accessoryLinearCapacity)
|
||
Text("\(entry.spanLabel) \(percent)%")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
default:
|
||
Gauge(value: min(max(entry.ratio, 0), 1)) {
|
||
Image(systemName: entry.symbolName)
|
||
} currentValueLabel: {
|
||
Text("\(percent)")
|
||
.font(.system(size: 13, weight: .bold).monospacedDigit())
|
||
}
|
||
.gaugeStyle(.accessoryCircular)
|
||
}
|
||
} else {
|
||
ComplicationEmptyView(message: String(localized: "목표 없음"))
|
||
}
|
||
}
|
||
.containerBackground(.clear, for: .widget)
|
||
}
|
||
}
|
||
|
||
struct GoalRateComplication: Widget {
|
||
var body: some WidgetConfiguration {
|
||
AppIntentConfiguration(
|
||
kind: "HaruWatchGoalRate",
|
||
intent: GoalRateConfigIntent.self,
|
||
provider: GoalRateProvider()
|
||
) { entry in
|
||
GoalRateComplicationView(entry: entry)
|
||
}
|
||
.configurationDisplayName("목표 달성률")
|
||
.description("선택한 목표의 하루/주간/월간 달성률을 표시해요. (프리미엄)")
|
||
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner])
|
||
}
|
||
}
|
||
|
||
// MARK: - 다짐 달성률
|
||
|
||
struct QuestRateConfigIntent: WidgetConfigurationIntent {
|
||
static let title: LocalizedStringResource = "다짐 달성률"
|
||
static let description = IntentDescription("표시할 다짐과 기간을 선택하세요.")
|
||
|
||
@Parameter(title: "다짐")
|
||
var quest: WatchQuestEntity?
|
||
|
||
@Parameter(title: "기간", default: .day)
|
||
var span: WatchSpanOption
|
||
}
|
||
|
||
struct QuestRateEntry: TimelineEntry {
|
||
let date: Date
|
||
let locked: Bool
|
||
let quest: WatchQuestInfo?
|
||
let spanRaw: String
|
||
let spanLabel: String
|
||
}
|
||
|
||
struct QuestRateProvider: AppIntentTimelineProvider {
|
||
static func makeEntry(_ configuration: QuestRateConfigIntent) -> QuestRateEntry {
|
||
guard let snapshot = ComplicationStore.snapshot() else {
|
||
return QuestRateEntry(date: .now, locked: false, quest: nil,
|
||
spanRaw: configuration.span.rawValue, spanLabel: configuration.span.label)
|
||
}
|
||
guard snapshot.isPremium else {
|
||
return QuestRateEntry(date: .now, locked: true, quest: nil,
|
||
spanRaw: configuration.span.rawValue, spanLabel: "")
|
||
}
|
||
let quests = snapshot.goals.flatMap(\.quests)
|
||
let quest = quests.first { $0.id == configuration.quest?.id } ?? quests.first
|
||
return QuestRateEntry(
|
||
date: .now,
|
||
locked: false,
|
||
quest: quest,
|
||
spanRaw: configuration.span.rawValue,
|
||
spanLabel: configuration.span.label
|
||
)
|
||
}
|
||
|
||
func placeholder(in context: Context) -> QuestRateEntry {
|
||
QuestRateEntry(date: .now, locked: false, quest: nil, spanRaw: "day", spanLabel: String(localized: "하루"))
|
||
}
|
||
|
||
func snapshot(for configuration: QuestRateConfigIntent, in context: Context) async -> QuestRateEntry {
|
||
Self.makeEntry(configuration)
|
||
}
|
||
|
||
func timeline(for configuration: QuestRateConfigIntent, in context: Context) async -> Timeline<QuestRateEntry> {
|
||
Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
|
||
}
|
||
|
||
/// 워치 페이스 갤러리에 노출할 추천 구성 (다짐 × 하루/주간/월간)
|
||
/// 주의: description에 문자열 보간 금지 — Text(verbatim:) 사용 (GoalRate 쪽 주석 참고)
|
||
func recommendations() -> [AppIntentRecommendation<QuestRateConfigIntent>] {
|
||
let cached = (ComplicationStore.snapshot()?.goals ?? [])
|
||
.flatMap { goal in goal.quests.map { WatchQuestEntity(id: $0.id, title: "\(goal.title) · \($0.name)") } }
|
||
.prefix(2)
|
||
let quests = cached.isEmpty
|
||
? [WatchQuestEntity(id: UUID(), title: String(localized: "다짐"))]
|
||
: Array(cached)
|
||
return quests.flatMap { quest in
|
||
WatchSpanOption.allCases.map { span in
|
||
let intent = QuestRateConfigIntent()
|
||
intent.quest = quest
|
||
intent.span = span
|
||
return AppIntentRecommendation(
|
||
intent: intent,
|
||
description: Text(verbatim: "\(quest.title) · \(span.label)")
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct QuestRateComplicationView: View {
|
||
@Environment(\.widgetFamily) private var envFamily
|
||
/// 워치 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
|
||
var previewFamily: WidgetFamily? = nil
|
||
private var family: WidgetFamily { previewFamily ?? envFamily }
|
||
let entry: QuestRateEntry
|
||
|
||
var body: some View {
|
||
Group {
|
||
if entry.locked {
|
||
ComplicationLockedView()
|
||
} else if let quest = entry.quest {
|
||
switch family {
|
||
case .accessoryInline:
|
||
Text("\(quest.name) \(inlineText(quest))")
|
||
case .accessoryRectangular:
|
||
// 크기가 큰 패밀리는 하루/주간/월간 세 개를 모두 표기 (스펙 §9.2-3)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: quest.symbolName)
|
||
.font(.system(size: 11))
|
||
Text(quest.name)
|
||
.font(.headline)
|
||
.lineLimit(1)
|
||
if quest.isAtMost {
|
||
Text("한도")
|
||
.font(.system(size: 9, weight: .bold))
|
||
.padding(.horizontal, 3)
|
||
.background(.secondary.opacity(0.3), in: Capsule())
|
||
}
|
||
}
|
||
HStack(spacing: 8) {
|
||
// String 파라미터는 카탈로그로 추출되지 않으므로 String(localized:) 필수
|
||
spanGauge(quest, raw: "day", label: String(localized: "하루"))
|
||
spanGauge(quest, raw: "week", label: String(localized: "주간"))
|
||
spanGauge(quest, raw: "month", label: String(localized: "월간"))
|
||
}
|
||
}
|
||
default:
|
||
circular(quest)
|
||
}
|
||
} else {
|
||
ComplicationEmptyView(message: String(localized: "다짐 없음"))
|
||
}
|
||
}
|
||
.containerBackground(.clear, for: .widget)
|
||
}
|
||
|
||
/// '이하 유지' 다짐은 게이지 대신 한도 상태로 표현 (스펙 §9.2-3)
|
||
@ViewBuilder
|
||
private func circular(_ quest: WatchQuestInfo) -> some View {
|
||
if quest.isAtMost {
|
||
VStack(spacing: 1) {
|
||
Image(systemName: quest.achieved(forSpanRaw: entry.spanRaw)
|
||
? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||
.font(.system(size: 16))
|
||
Text(quest.achieved(forSpanRaw: entry.spanRaw) ? String(localized: "한도 안") : String(localized: "초과"))
|
||
.font(.system(size: 9))
|
||
}
|
||
} else {
|
||
Gauge(value: min(max(quest.ratio(forSpanRaw: entry.spanRaw), 0), 1)) {
|
||
Image(systemName: quest.symbolName)
|
||
} currentValueLabel: {
|
||
Text("\(Int((quest.ratio(forSpanRaw: entry.spanRaw) * 100).rounded()))")
|
||
.font(.system(size: 13, weight: .bold).monospacedDigit())
|
||
}
|
||
.gaugeStyle(.accessoryCircular)
|
||
}
|
||
}
|
||
|
||
private func inlineText(_ quest: WatchQuestInfo) -> String {
|
||
if quest.isAtMost {
|
||
return quest.achieved(forSpanRaw: entry.spanRaw) ? String(localized: "한도 안") : String(localized: "한도 초과")
|
||
}
|
||
return "\(Int((quest.ratio(forSpanRaw: entry.spanRaw) * 100).rounded()))%"
|
||
}
|
||
|
||
private func spanGauge(_ quest: WatchQuestInfo, raw: String, label: String) -> some View {
|
||
VStack(spacing: 1) {
|
||
Text(label)
|
||
.font(.system(size: 8))
|
||
.foregroundStyle(.secondary)
|
||
if quest.isAtMost {
|
||
Image(systemName: quest.achieved(forSpanRaw: raw) ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||
.font(.system(size: 12))
|
||
} else {
|
||
Text("\(Int((quest.ratio(forSpanRaw: raw) * 100).rounded()))%")
|
||
.font(.system(size: 11, weight: .semibold).monospacedDigit())
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
|
||
struct QuestRateComplication: Widget {
|
||
var body: some WidgetConfiguration {
|
||
AppIntentConfiguration(
|
||
kind: "HaruWatchQuestRate",
|
||
intent: QuestRateConfigIntent.self,
|
||
provider: QuestRateProvider()
|
||
) { entry in
|
||
QuestRateComplicationView(entry: entry)
|
||
}
|
||
.configurationDisplayName("다짐 달성률")
|
||
.description("선택한 다짐의 달성률을 표시해요. 한도형 다짐은 상태로 표시돼요. (프리미엄)")
|
||
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner])
|
||
}
|
||
}
|