- 배포 타깃 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
356 lines
16 KiB
Swift
356 lines
16 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)))
|
||
}
|
||
|
||
/// 워치 페이스 갤러리에 노출할 추천 구성 (목표 × 하루/주간/월간)
|
||
/// 워치는 컴플리케이션 파라미터 편집 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])
|
||
}
|
||
}
|