mycode/myApp/HaruDanim/Haru_DanimWatchWidgets/RateComplications.swift
songyc macbook 5b526fa650 feat(compat): iOS 하한 18.0 인하 — 글라스 버블 머티리얼 폴백·사용자 심볼 안전 렌더·Release 인라이너 크래시 우회
- 배포 타깃 26.0→18.0 (앱·위젯. 워치 10.0 불변)
- RadialNavigationView: glassEffect 계열을 @available(iOS 26) 선언 격리 + 18 머티리얼 원 폴백 (배치·연출 동일)
- DiaryZoomContainer.Coordinator → 비제네릭 톱레벨 DiaryZoomCoordinator + AnyView 소거 (하한 18 Release wholemodule에서 swift-frontend SILPerformanceInliner 무한 재귀 크래시 실측·우회)
- Image(safeSymbol:)/SymbolCompat: 카탈로그의 상위 OS 전용 심볼(18 기준 3개)이 교차 기기에서 빈 아이콘이 되지 않게 사용자 심볼 렌더 48곳+워치 7곳 폴백
- -symbolAuditDump 검증 인자 추가, iOS 18.5 시뮬 QA(빌드·radial 폴백·일기 줌·progressSelfTest 49 ALL PASS)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
2026-08-11 16:31:40 +09:00

356 lines
16 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)))
}
/// ( × //)
/// UI ** **
///
/// ( prefix(2) "3 " ). .
/// : description ( ) WidgetKit assertion
/// .
/// Text(verbatim:) .
func recommendations() -> [AppIntentRecommendation<GoalRateConfigIntent>] {
let cached = (ComplicationStore.snapshot()?.goals ?? [])
.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(safeSymbol: 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(safeSymbol: entry.symbolName)
} currentValueLabel: {
Text("\(percent)")
.font(.system(size: 13, weight: .bold).monospacedDigit())
}
.gaugeStyle(.accessoryCircular)
}
} else {
// ( , §9.1) rectangular
//
ComplicationEmptyView(message: family == .accessoryRectangular
? String(localized: "표시할 목표가 없어요 — iPhone 목표 편집에서 '애플워치에서 보기'를 켜면 나타나요")
: 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)))
}
/// ( × //)
/// (GoalRate
/// prefix(2) " " ).
/// : 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)") } }
let quests = cached.isEmpty
? [WatchQuestEntity(id: UUID(), title: String(localized: "다짐"))]
: 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(safeSymbol: 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 {
// GoalRate rectangular
ComplicationEmptyView(message: family == .accessoryRectangular
? String(localized: "표시할 다짐이 없어요 — iPhone 목표 편집에서 '애플워치에서 보기'를 켜면 나타나요")
: 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(safeSymbol: 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])
}
}