- 배포 타깃 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
265 lines
10 KiB
Swift
265 lines
10 KiB
Swift
//
|
|
// LockScreenWidgets.swift
|
|
// Haru_DanimWidgets
|
|
//
|
|
// 잠금화면 위젯 (CLAUDE.md §8.4) — 목표 1개의 달성률/다짐 현황을 간단하게
|
|
// 표시 방식 옵션: 원형 게이지 / 숫자만 / 다짐 점 표시
|
|
//
|
|
|
|
import SwiftUI
|
|
import WidgetKit
|
|
import AppIntents
|
|
|
|
// MARK: - 설정
|
|
|
|
enum LockStyleOption: String, AppEnum {
|
|
case gauge
|
|
case number
|
|
case dots
|
|
|
|
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "표시 방식")
|
|
static let caseDisplayRepresentations: [LockStyleOption: DisplayRepresentation] = [
|
|
.gauge: "원형 게이지",
|
|
.number: "숫자만",
|
|
.dots: "다짐 점 표시",
|
|
]
|
|
}
|
|
|
|
struct LockGoalConfigIntent: WidgetConfigurationIntent {
|
|
static let title: LocalizedStringResource = "잠금화면 목표 위젯"
|
|
static let description = IntentDescription("잠금화면에 표시할 목표와 기간, 표시 방식을 선택하세요.")
|
|
|
|
@Parameter(title: "목표")
|
|
var goal: GoalEntity?
|
|
|
|
@Parameter(title: "달성률 기간", default: .day)
|
|
var span: SpanOption
|
|
|
|
@Parameter(title: "표시 방식", default: .gauge)
|
|
var style: LockStyleOption
|
|
|
|
static var parameterSummary: some ParameterSummary {
|
|
Summary("목표 달성률을 \(\.$span) 기준 \(\.$style)(으)로 표시") {
|
|
\.$goal
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 타임라인
|
|
|
|
struct LockGoalEntry: TimelineEntry {
|
|
let date: Date
|
|
let locked: Bool
|
|
let spanLabel: String
|
|
let style: LockStyleOption
|
|
let goal: GoalSnapshot?
|
|
let ratio: Double
|
|
|
|
var isLive: Bool { goal?.quests.contains { $0.isRunning } ?? false }
|
|
}
|
|
|
|
struct LockGoalProvider: AppIntentTimelineProvider {
|
|
@MainActor
|
|
static func makeEntry(_ configuration: LockGoalConfigIntent, now: Date = .now) -> LockGoalEntry {
|
|
IntentStore.refresh()
|
|
guard PremiumGate.isUnlocked(.lockScreenWidgets) else {
|
|
return LockGoalEntry(date: now, locked: true, spanLabel: "", style: .gauge, goal: nil, ratio: 0)
|
|
}
|
|
let span = configuration.span.statSpan
|
|
let model = WidgetStore.goal(configuration.goal?.id) ?? WidgetStore.defaultGoals(1).first
|
|
let snapshot = model.map { GoalSnapshot.make(goal: $0, span: span, now: now) }
|
|
return LockGoalEntry(
|
|
date: now,
|
|
locked: false,
|
|
spanLabel: configuration.span.label,
|
|
style: configuration.style,
|
|
goal: snapshot,
|
|
ratio: snapshot?.ratio(for: span) ?? 0
|
|
)
|
|
}
|
|
|
|
func placeholder(in context: Context) -> LockGoalEntry {
|
|
let goal = GoalSnapshot.sample
|
|
return LockGoalEntry(date: .now, locked: false, spanLabel: String(localized: "오늘"), style: .gauge, goal: goal, ratio: goal.dayRatio)
|
|
}
|
|
|
|
@MainActor
|
|
func snapshot(for configuration: LockGoalConfigIntent, in context: Context) async -> LockGoalEntry {
|
|
Self.makeEntry(configuration)
|
|
}
|
|
|
|
@MainActor
|
|
func timeline(for configuration: LockGoalConfigIntent, in context: Context) async -> Timeline<LockGoalEntry> {
|
|
let first = Self.makeEntry(configuration)
|
|
return WidgetRefresh.timeline(first: first, live: first.isLive) { date in
|
|
Self.makeEntry(configuration, now: date)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 뷰
|
|
|
|
struct LockGoalWidgetView: View {
|
|
@Environment(\.widgetFamily) private var envFamily
|
|
/// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
|
|
var previewFamily: WidgetFamily? = nil
|
|
private var family: WidgetFamily { previewFamily ?? envFamily }
|
|
let entry: LockGoalEntry
|
|
|
|
var body: some View {
|
|
Group {
|
|
if entry.locked {
|
|
switch family {
|
|
case .accessoryInline:
|
|
Text("하루 다님 · 프리미엄 기능")
|
|
default:
|
|
VStack(spacing: 2) {
|
|
Image(systemName: "crown.fill")
|
|
.widgetAccentable()
|
|
Text("프리미엄")
|
|
.font(.caption2)
|
|
}
|
|
}
|
|
} else if let goal = entry.goal {
|
|
switch family {
|
|
case .accessoryInline:
|
|
// 한 줄: 아이콘 + 목표명 + %
|
|
Text("\(goal.title) \(Format.percent(entry.ratio))")
|
|
case .accessoryRectangular:
|
|
rectangularView(goal)
|
|
default:
|
|
circularView(goal)
|
|
}
|
|
} else {
|
|
switch family {
|
|
case .accessoryInline:
|
|
Text("하루 다님 · 목표 없음")
|
|
default:
|
|
VStack(spacing: 2) {
|
|
Image(systemName: "flag.checkered")
|
|
Text("목표 없음")
|
|
.font(.caption2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.containerBackground(.clear, for: .widget)
|
|
}
|
|
|
|
/// 잠금화면(비브런트/악센트 렌더링)에서는 시스템이 색을 덮어쓰므로
|
|
/// 배경은 시스템 표준(AccessoryWidgetBackground)만 쓰고,
|
|
/// 텍스트는 .primary + 강조 요소는 .widgetAccentable()로 렌더링을 맡긴다
|
|
@ViewBuilder
|
|
private func circularView(_ goal: GoalSnapshot) -> some View {
|
|
switch entry.style {
|
|
case .gauge:
|
|
Gauge(value: min(max(entry.ratio, 0), 1)) {
|
|
Image(safeSymbol: goal.symbolName)
|
|
.widgetAccentable()
|
|
} currentValueLabel: {
|
|
Text("\(Int((entry.ratio * 100).rounded()))")
|
|
.font(.system(size: 14, weight: .bold).monospacedDigit())
|
|
.foregroundStyle(.primary)
|
|
}
|
|
.gaugeStyle(.accessoryCircular)
|
|
case .number:
|
|
ZStack {
|
|
AccessoryWidgetBackground()
|
|
VStack(spacing: 0) {
|
|
Text("\(Int((entry.ratio * 100).rounded()))%")
|
|
.font(.system(size: 16, weight: .bold).monospacedDigit())
|
|
.foregroundStyle(.primary)
|
|
.widgetAccentable()
|
|
Text(entry.spanLabel)
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
case .dots:
|
|
// 다짐 달성 여부를 점으로 (최대 6개)
|
|
ZStack {
|
|
AccessoryWidgetBackground()
|
|
VStack(spacing: 3) {
|
|
Image(safeSymbol: goal.symbolName)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.widgetAccentable()
|
|
questDots(goal, size: 6)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func rectangularView(_ goal: GoalSnapshot) -> some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 4) {
|
|
Image(safeSymbol: goal.symbolName)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
Text(goal.title)
|
|
.font(.caption.weight(.semibold))
|
|
.lineLimit(1)
|
|
}
|
|
.foregroundStyle(.primary)
|
|
.widgetAccentable()
|
|
Gauge(value: min(max(entry.ratio, 0), 1)) { EmptyView() }
|
|
.gaugeStyle(.accessoryLinearCapacity)
|
|
HStack {
|
|
Text("\(entry.spanLabel) \(Format.percent(entry.ratio))")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
if !goal.quests.isEmpty {
|
|
questDots(goal, size: 5)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 다짐 진행률 점 표시 — 미완료는 링(호)이 진행률만큼 감기고, 완료는 꽉 찬 원반.
|
|
/// 예전 '아래에서 차오르는 물높이' 방식은 5~6pt 크기에서 90%와 100%의 차이(위쪽 1px 남짓)가
|
|
/// 사실상 안 보였다 — 링은 끝의 '틈'이 둘레에 생겨 눈에 띄고(완료 직전에도 틈이 남게
|
|
/// 미완료는 최대 88%까지만 감는다), 완료는 면적 자체(빈 링 vs 원반)가 달라 어떤 배경에서도
|
|
/// 구분된다. ratio를 그대로 쓰므로 '이하 유지'는 한도 안=원반 / 초과=빈 트랙으로 의미 유지.
|
|
private func questDots(_ goal: GoalSnapshot, size: CGFloat) -> some View {
|
|
HStack(spacing: 3) {
|
|
ForEach(goal.quests.prefix(6)) { quest in
|
|
let ratio = min(max(quest.ratio, 0), 1)
|
|
let lineWidth = max(size * 0.3, 1.4)
|
|
ZStack {
|
|
if ratio >= 0.999 {
|
|
Circle()
|
|
.fill(.primary)
|
|
} else {
|
|
Circle()
|
|
.inset(by: lineWidth / 2)
|
|
.stroke(.primary.opacity(0.35), lineWidth: lineWidth)
|
|
Circle()
|
|
.inset(by: lineWidth / 2)
|
|
// 시작도 보이게 최소 10%, 완료 직전에도 틈이 남게 최대 88%
|
|
.trim(from: 0, to: ratio > 0 ? min(max(ratio, 0.1), 0.88) : 0)
|
|
.stroke(.primary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
}
|
|
}
|
|
.frame(width: size, height: size)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 위젯 정의
|
|
|
|
struct LockGoalWidget: Widget {
|
|
var body: some WidgetConfiguration {
|
|
AppIntentConfiguration(
|
|
kind: "HaruLockGoalWidget",
|
|
intent: LockGoalConfigIntent.self,
|
|
provider: LockGoalProvider()
|
|
) { entry in
|
|
LockGoalWidgetView(entry: entry)
|
|
}
|
|
.configurationDisplayName("목표 달성률 (잠금화면)")
|
|
.description("잠금화면에서 목표 달성률과 다짐 현황을 확인해요. (프리미엄)")
|
|
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
|
|
}
|
|
}
|