mycode/myApp/HaruDanim/Widgets/QuestRingWidget.swift
songyc macbook bc6cd010b7 fix(widgets): 리퀴드 글라스 테마 시인성 개선 및 틴트/클리어 렌더링 모드 대응
- 글라스 테마: 틴트 셀 불투명도 0.5→0.72, 테두리 강화로 흰 텍스트 대비 확보
- 글라스 테마 전용 텍스트 그림자(glassTextShadow) 추가 — 홈 위젯 5종 셀 콘텐츠에 적용
- widgetRenderingMode != .fullColor(홈 화면 틴트/클리어)일 때 커스텀 배경·강제
  컬러 스킴을 끄고 시스템 바이브런트 렌더링에 맡기도록 분기
- 잠금화면 위젯: AccessoryWidgetBackground + widgetAccentable + .primary 전경색으로
  비브런트/악센트 렌더링에서 시인성 확보
- 미리보기 하네스: 글라스 테마가 시스템 컬러 스킴을 따르도록 수정

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-10 21:18:43 +09:00

220 lines
7.4 KiB
Swift

//
// QuestRingWidget.swift
// Haru_DanimWidgets
//
// (CLAUDE.md §8.2 C / C / C)
// (Interactive)
//
import SwiftUI
import WidgetKit
import AppIntents
// MARK: -
struct QuestRingConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "다짐 진행률 위젯"
static let description = IntentDescription("원형 진행률로 표시할 다짐과 기간을 선택하세요.")
@Parameter(title: "진행률 기간", default: .day)
var span: SpanOption
@Parameter(title: "위젯 테마", default: .matchApp)
var theme: WidgetThemeOption
@Parameter(title: "다짐 1")
var quest1: QuestEntity?
@Parameter(title: "다짐 2")
var quest2: QuestEntity?
@Parameter(title: "다짐 3")
var quest3: QuestEntity?
@Parameter(title: "다짐 4")
var quest4: QuestEntity?
}
// MARK: -
struct QuestRingEntry: TimelineEntry {
let date: Date
let locked: Bool
let theme: WidgetThemeOption
let spanLabel: String
let cells: [QuestCellSnapshot]
}
struct QuestRingProvider: AppIntentTimelineProvider {
@MainActor
static func makeEntry(_ configuration: QuestRingConfigIntent) -> QuestRingEntry {
guard WidgetStore.isUnlocked else {
return QuestRingEntry(date: .now, locked: true, theme: configuration.theme, spanLabel: "", cells: [])
}
let span = configuration.span.statSpan
var quests = [configuration.quest1, configuration.quest2, configuration.quest3, configuration.quest4]
.compactMap { $0 }
.compactMap { WidgetStore.quest($0.id) }
if quests.isEmpty {
quests = Array(WidgetStore.defaultGoals(1).flatMap(\.sortedQuests).prefix(4))
}
return QuestRingEntry(
date: .now,
locked: false,
theme: configuration.theme,
spanLabel: configuration.span.label,
cells: quests.map { QuestCellSnapshot.make(quest: $0, span: span) }
)
}
func placeholder(in context: Context) -> QuestRingEntry {
QuestRingEntry(date: .now, locked: false, theme: .matchApp, spanLabel: "오늘", cells: [])
}
@MainActor
func snapshot(for configuration: QuestRingConfigIntent, in context: Context) async -> QuestRingEntry {
Self.makeEntry(configuration)
}
@MainActor
func timeline(for configuration: QuestRingConfigIntent, in context: Context) async -> Timeline<QuestRingEntry> {
Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
}
}
// MARK: -
struct QuestRingActionCellView: View {
let cell: QuestCellSnapshot
let spanLabel: String
var ringSize: CGFloat = 52
var body: some View {
if let target = cell.runTarget {
Button(intent: RunActionIntent(action: target)) {
content
}
.buttonStyle(.plain)
} else {
content
}
}
/// ()
var fullBleed = false
private var content: some View {
VStack(spacing: 4) {
ZStack {
QuestRingView(snapshot: cell, lineWidth: 5, iconSize: ringSize * 0.34)
.frame(width: ringSize, height: ringSize)
if cell.isRunning {
Circle()
.fill(AppTheme.yellow)
.frame(width: 9, height: 9)
.offset(x: ringSize * 0.38, y: -ringSize * 0.38)
}
}
Text(cell.targetName)
.font(.caption2.weight(.semibold))
.lineLimit(1)
Text(percentText)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
.glassTextShadow()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(fullBleed ? 10 : 6)
.background(WidgetCellBackground(fullBleed: fullBleed))
}
private var percentText: String {
if cell.isAtMost {
return cell.isAchieved
? String(localized: "\(spanLabel) 한도 지킴")
: String(localized: "\(spanLabel) 한도 초과")
}
return "\(spanLabel) \(Format.percent(cell.displayRatio))"
}
}
struct QuestRingWidgetView: View {
@Environment(\.widgetFamily) private var envFamily
/// DEBUG
var previewFamily: WidgetFamily? = nil
private var family: WidgetFamily { previewFamily ?? envFamily }
let entry: QuestRingEntry
private var visibleCount: Int {
switch family {
case .systemMedium: return 2
case .systemLarge: return 4
default: return 1
}
}
var body: some View {
Group {
if entry.locked {
WidgetLockedView()
.padding(12)
} else if entry.cells.isEmpty {
Text("앱의 목표 탭에서 다짐을 만들면 진행률이 표시돼요.")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(12)
} else {
let cells = Array(entry.cells.prefix(visibleCount))
switch family {
case .systemMedium:
HStack(spacing: 8) {
ForEach(cells) { QuestRingActionCellView(cell: $0, spanLabel: entry.spanLabel) }
}
.padding(10)
case .systemLarge:
VStack(spacing: 8) {
HStack(spacing: 8) {
ForEach(cells.prefix(2)) {
QuestRingActionCellView(cell: $0, spanLabel: entry.spanLabel, ringSize: 60)
}
}
if cells.count > 2 {
HStack(spacing: 8) {
ForEach(cells.dropFirst(2)) {
QuestRingActionCellView(cell: $0, spanLabel: entry.spanLabel, ringSize: 60)
}
}
}
}
.padding(10)
default:
// : ()
if let cell = cells.first {
QuestRingActionCellView(cell: cell, spanLabel: entry.spanLabel, ringSize: 58, fullBleed: true)
}
}
}
}
.widgetTheme(entry.theme)
}
}
// MARK: -
struct QuestRingWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "HaruQuestRingWidget",
intent: QuestRingConfigIntent.self,
provider: QuestRingProvider()
) { entry in
QuestRingWidgetView(entry: entry)
}
.configurationDisplayName("다짐 진행률")
.description("다짐의 진행률을 원형 링으로 보고, 눌러서 바로 실행해요. (프리미엄)")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
.contentMarginsDisabled()
}
}