- 배포 타깃 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
190 lines
8.3 KiB
Swift
190 lines
8.3 KiB
Swift
//
|
|
// StatusComplication.swift
|
|
// Haru_DanimWatchWidgetsExtension
|
|
//
|
|
// 현재 현황 컴플리케이션 (CLAUDE.md §9.2-1)
|
|
// - 측정 중인 대표 행동(다이나믹 아일랜드와 동일 기준) + 추가 측정 개수
|
|
// - 실행 중인 것이 없으면 "실행 중 아님"
|
|
//
|
|
|
|
import SwiftUI
|
|
import WidgetKit
|
|
|
|
struct StatusEntry: TimelineEntry {
|
|
let date: Date
|
|
let locked: Bool
|
|
let runningName: String?
|
|
let runningSymbol: String?
|
|
let runningStartAt: Date?
|
|
let extraCount: Int
|
|
/// '측정 중' 스냅숏이 신뢰 한도를 넘겨 유령 타이머일 수 있음 — 새로고침 안내로 강등
|
|
var stale: Bool = false
|
|
}
|
|
|
|
struct StatusProvider: TimelineProvider {
|
|
/// '측정 중' 스냅숏의 신뢰 한도. 아이폰은 데이터가 바뀌는 순간에만 스냅숏을 밀어 주므로
|
|
/// 종료 푸시가 유실되면 이후 갱신이 없어 컴플리케이션 타이머가 유령처럼 무한히 흐른다.
|
|
/// 이 시간을 넘긴 '측정 중' 표시는 새로고침 안내로 강등한다 — 워치 앱을 열면 즉시 복구되고,
|
|
/// 진짜 장시간 측정이었더라도 다음 스냅숏 수신 때 타이머가 그대로 돌아온다 (데이터 무영향)
|
|
static let runningTrustInterval: TimeInterval = 2 * 3600
|
|
|
|
static func makeEntry() -> StatusEntry {
|
|
guard let snapshot = ComplicationStore.snapshot() else {
|
|
return StatusEntry(date: .now, locked: false, runningName: nil, runningSymbol: nil,
|
|
runningStartAt: nil, extraCount: 0)
|
|
}
|
|
guard snapshot.isPremium else {
|
|
return StatusEntry(date: .now, locked: true, runningName: nil, runningSymbol: nil,
|
|
runningStartAt: nil, extraCount: 0)
|
|
}
|
|
let stale = snapshot.runningStartAt != nil
|
|
&& Date.now.timeIntervalSince(snapshot.generatedAt) > Self.runningTrustInterval
|
|
return StatusEntry(
|
|
date: .now,
|
|
locked: false,
|
|
runningName: snapshot.runningName,
|
|
runningSymbol: snapshot.runningSymbol,
|
|
runningStartAt: snapshot.runningStartAt,
|
|
extraCount: snapshot.extraRunningCount,
|
|
stale: stale
|
|
)
|
|
}
|
|
|
|
func placeholder(in context: Context) -> StatusEntry {
|
|
StatusEntry(date: .now, locked: false, runningName: String(localized: "독서"), runningSymbol: "book.fill",
|
|
runningStartAt: .now, extraCount: 0)
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (StatusEntry) -> Void) {
|
|
completion(Self.makeEntry())
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<StatusEntry>) -> Void) {
|
|
completion(Timeline(entries: [Self.makeEntry()], policy: .after(.now.addingTimeInterval(15 * 60))))
|
|
}
|
|
}
|
|
|
|
struct StatusComplicationView: View {
|
|
@Environment(\.widgetFamily) private var envFamily
|
|
/// 워치 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
|
|
var previewFamily: WidgetFamily? = nil
|
|
private var family: WidgetFamily { previewFamily ?? envFamily }
|
|
let entry: StatusEntry
|
|
|
|
var body: some View {
|
|
Group {
|
|
if entry.locked {
|
|
ComplicationLockedView()
|
|
} else if entry.stale {
|
|
// 신뢰 한도를 넘긴 '측정 중' — 무한 타이머 대신 새로고침 안내
|
|
switch family {
|
|
case .accessoryInline:
|
|
Text("측정 표시 새로고침 필요")
|
|
case .accessoryRectangular:
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "arrow.triangle.2.circlepath")
|
|
.font(.system(size: 11))
|
|
Text("하루 다님")
|
|
.font(.headline)
|
|
}
|
|
Text("측정 표시가 오래됐어요")
|
|
.font(.caption2)
|
|
Text("앱을 열면 새로고침돼요")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
default:
|
|
VStack(spacing: 2) {
|
|
Image(systemName: "arrow.triangle.2.circlepath")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
Text("새로고침")
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
} else if let name = entry.runningName {
|
|
switch family {
|
|
case .accessoryInline:
|
|
if entry.extraCount > 0 {
|
|
Text("\(name) +\(entry.extraCount)")
|
|
} else {
|
|
Text(name)
|
|
}
|
|
case .accessoryRectangular:
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
HStack(spacing: 4) {
|
|
Image(safeSymbol: entry.runningSymbol ?? "record.circle")
|
|
.font(.system(size: 11))
|
|
Text(name)
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
// 추가 측정이 있을 때만 개수 표시 (스펙 §9.2-1)
|
|
if entry.extraCount > 0 {
|
|
Text("+\(entry.extraCount)")
|
|
.font(.caption2.weight(.bold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if let start = entry.runningStartAt {
|
|
Text(start, style: .timer)
|
|
.font(.system(.body, design: .rounded).monospacedDigit())
|
|
}
|
|
Text("측정 중")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
default:
|
|
VStack(spacing: 1) {
|
|
Image(safeSymbol: entry.runningSymbol ?? "record.circle")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
if let start = entry.runningStartAt {
|
|
Text(start, style: .timer)
|
|
.font(.system(size: 11).monospacedDigit())
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
if entry.extraCount > 0 {
|
|
Text("+\(entry.extraCount)")
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
switch family {
|
|
case .accessoryInline:
|
|
Text("실행 중 아님")
|
|
case .accessoryRectangular:
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text("하루 다님")
|
|
.font(.headline)
|
|
Text("실행 중 아님")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
default:
|
|
VStack(spacing: 2) {
|
|
Image(systemName: "pause.circle")
|
|
.font(.system(size: 16))
|
|
Text("없음")
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.containerBackground(.clear, for: .widget)
|
|
}
|
|
}
|
|
|
|
struct StatusComplication: Widget {
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: "HaruWatchStatus", provider: StatusProvider()) { entry in
|
|
StatusComplicationView(entry: entry)
|
|
}
|
|
.configurationDisplayName("현재 현황")
|
|
.description("측정 중인 행동과 경과 시간을 표시해요. (프리미엄)")
|
|
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner])
|
|
}
|
|
}
|