mycode/myApp/HaruDanim/Haru_DanimWatchWidgets/RateComplications.swift
songyc macbook fced5d1958 fix(l10n): localize widget/complication placeholder sample labels
- 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
2026-07-16 02:53:43 +09:00

345 lines
14 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.

//
// 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])
}
}