mycode/myApp/HaruDanim/Widgets/GoalWidgets.swift
songyc macbook 9bf617f3e5 feat(widgets): 위젯 테마 옵션 추가 — 앱 테마 일치/라이트·다크 고정/리퀴드 글라스 (CLAUDE.md §8.1)
- 홈 위젯 5종(행동 실행/목표 진행률/다짐 진행률/다짐 현황/통계) 설정에
  '위젯 테마' 옵션 추가
- 앱 테마와 일치: 앱의 라이트/다크 설정을 따름 (테마 키를 App Group으로 이관해
  위젯이 읽을 수 있게 함)
- 라이트/다크 고정: 컬러 스킴 강제 + 해당 모드 팔레트
- 리퀴드 글라스: 반투명 머티리얼 배경 + 표면 셀도 머티리얼로 변형해
  유리 질감 위에서 가독성 유지
- DEBUG 미리보기가 테마를 재현하도록 개선 (-widgetTheme light|dark|glass,
  글라스는 그라데이션 배경 위에 렌더링)

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

331 lines
12 KiB
Swift

//
// GoalWidgets.swift
// Haru_DanimWidgets
//
// (CLAUDE.md §8.2 // B, // D)
//
import SwiftUI
import WidgetKit
import AppIntents
// MARK: - B:
struct GoalBarsConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "목표 진행률 위젯"
static let description = IntentDescription("하루·주간·월간 진행률을 표시할 목표를 선택하세요.")
@Parameter(title: "목표 1")
var goal1: GoalEntity?
@Parameter(title: "목표 2")
var goal2: GoalEntity?
@Parameter(title: "목표 3")
var goal3: GoalEntity?
@Parameter(title: "목표 4")
var goal4: GoalEntity?
@Parameter(title: "위젯 테마", default: .matchApp)
var theme: WidgetThemeOption
}
struct GoalBarsEntry: TimelineEntry {
let date: Date
let locked: Bool
let theme: WidgetThemeOption
let goals: [GoalSnapshot]
}
struct GoalBarsProvider: AppIntentTimelineProvider {
@MainActor
static func makeEntry(_ configuration: GoalBarsConfigIntent) -> GoalBarsEntry {
guard WidgetStore.isUnlocked else {
return GoalBarsEntry(date: .now, locked: true, theme: configuration.theme, goals: [])
}
let chosen = [configuration.goal1, configuration.goal2, configuration.goal3, configuration.goal4]
.compactMap { $0 }
.compactMap { WidgetStore.goal($0.id) }
let goals = chosen.isEmpty ? WidgetStore.defaultGoals(4) : chosen
return GoalBarsEntry(date: .now, locked: false, theme: configuration.theme, goals: goals.map { GoalSnapshot.make(goal: $0) })
}
func placeholder(in context: Context) -> GoalBarsEntry {
GoalBarsEntry(date: .now, locked: false, theme: .matchApp, goals: [])
}
@MainActor
func snapshot(for configuration: GoalBarsConfigIntent, in context: Context) async -> GoalBarsEntry {
Self.makeEntry(configuration)
}
@MainActor
func timeline(for configuration: GoalBarsConfigIntent, in context: Context) async -> Timeline<GoalBarsEntry> {
Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
}
}
struct GoalBarsCellView: View {
let goal: GoalSnapshot
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 5) {
Image(systemName: goal.symbolName)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(goal.color)
Text(goal.title)
.font(.caption.weight(.semibold))
.lineLimit(1)
}
SpanBarRow(label: "하루", ratio: goal.dayRatio, color: goal.color)
SpanBarRow(label: "주간", ratio: goal.weekRatio, color: goal.color)
SpanBarRow(label: "월간", ratio: goal.monthRatio, color: goal.color)
}
.padding(10)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(WidgetCellBackground())
}
}
struct GoalBarsWidgetView: View {
@Environment(\.widgetFamily) private var envFamily
/// DEBUG
var previewFamily: WidgetFamily? = nil
private var family: WidgetFamily { previewFamily ?? envFamily }
let entry: GoalBarsEntry
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()
} else if entry.goals.isEmpty {
Text("앱의 목표 탭에서 목표를 만들면 진행률이 표시돼요.")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else {
let goals = Array(entry.goals.prefix(visibleCount))
switch family {
case .systemMedium:
HStack(spacing: 8) {
ForEach(goals) { GoalBarsCellView(goal: $0) }
}
case .systemLarge:
VStack(spacing: 8) {
HStack(spacing: 8) {
ForEach(goals.prefix(2)) { GoalBarsCellView(goal: $0) }
}
if goals.count > 2 {
HStack(spacing: 8) {
ForEach(goals.dropFirst(2)) { GoalBarsCellView(goal: $0) }
}
}
}
default:
if let goal = goals.first {
GoalBarsCellView(goal: goal)
}
}
}
}
.widgetTheme(entry.theme)
}
}
struct GoalBarsWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "HaruGoalBarsWidget",
intent: GoalBarsConfigIntent.self,
provider: GoalBarsProvider()
) { entry in
GoalBarsWidgetView(entry: entry)
}
.configurationDisplayName("목표 진행률")
.description("목표의 하루·주간·월간 진행률을 진행바로 확인해요. (프리미엄)")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
// MARK: - D:
struct GoalQuestGridConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "다짐 현황 위젯"
static let description = IntentDescription("다짐들의 진행률 링을 표시할 목표와 기간을 선택하세요.")
@Parameter(title: "목표")
var goal: GoalEntity?
@Parameter(title: "목표 2 (대형 전용)")
var secondGoal: GoalEntity?
@Parameter(title: "진행률 기간", default: .day)
var span: SpanOption
@Parameter(title: "위젯 테마", default: .matchApp)
var theme: WidgetThemeOption
}
struct GoalQuestGridEntry: TimelineEntry {
let date: Date
let locked: Bool
let theme: WidgetThemeOption
let spanLabel: String
let goals: [GoalSnapshot]
}
struct GoalQuestGridProvider: AppIntentTimelineProvider {
@MainActor
static func makeEntry(_ configuration: GoalQuestGridConfigIntent) -> GoalQuestGridEntry {
guard WidgetStore.isUnlocked else {
return GoalQuestGridEntry(date: .now, locked: true, theme: configuration.theme, spanLabel: "", goals: [])
}
let span = configuration.span.statSpan
var goals: [Goal] = []
if let first = WidgetStore.goal(configuration.goal?.id) { goals.append(first) }
if let second = WidgetStore.goal(configuration.secondGoal?.id) { goals.append(second) }
if goals.isEmpty { goals = WidgetStore.defaultGoals(2) }
return GoalQuestGridEntry(
date: .now,
locked: false,
theme: configuration.theme,
spanLabel: configuration.span.label,
goals: goals.map { GoalSnapshot.make(goal: $0, span: span) }
)
}
func placeholder(in context: Context) -> GoalQuestGridEntry {
GoalQuestGridEntry(date: .now, locked: false, theme: .matchApp, spanLabel: "오늘", goals: [])
}
@MainActor
func snapshot(for configuration: GoalQuestGridConfigIntent, in context: Context) async -> GoalQuestGridEntry {
Self.makeEntry(configuration)
}
@MainActor
func timeline(for configuration: GoalQuestGridConfigIntent, in context: Context) async -> Timeline<GoalQuestGridEntry> {
Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
}
}
struct QuestRingCellView: View {
let quest: QuestCellSnapshot
var ringSize: CGFloat = 34
var body: some View {
VStack(spacing: 3) {
QuestRingView(snapshot: quest, lineWidth: 4, iconSize: ringSize * 0.36)
.frame(width: ringSize, height: ringSize)
Text(quest.targetName)
.font(.system(size: 8, weight: .medium))
.lineLimit(1)
.foregroundStyle(.secondary)
}
}
}
struct GoalQuestGridWidgetView: View {
@Environment(\.widgetFamily) private var envFamily
/// DEBUG
var previewFamily: WidgetFamily? = nil
private var family: WidgetFamily { previewFamily ?? envFamily }
let entry: GoalQuestGridEntry
var body: some View {
Group {
if entry.locked {
WidgetLockedView()
} else if entry.goals.isEmpty {
Text("앱의 목표 탭에서 목표와 다짐을 만들어 보세요.")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else {
switch family {
case .systemMedium:
// D: 1 x 4 , 4 2 8
if let goal = entry.goals.first {
goalSection(goal, maxCount: 8, columns: 4, ringSize: goal.quests.count > 4 ? 30 : 38)
}
case .systemLarge:
// D: 2 2, 1 8
if entry.goals.count > 1 {
VStack(spacing: 10) {
ForEach(entry.goals.prefix(2)) { goal in
goalSection(goal, maxCount: 4, columns: 4, ringSize: 40)
}
}
} else if let goal = entry.goals.first {
goalSection(goal, maxCount: 8, columns: 4, ringSize: 44)
}
default:
// D: 2 x 2 +
if let goal = entry.goals.first {
goalSection(goal, maxCount: 4, columns: 2, ringSize: 32)
}
}
}
}
.widgetTheme(entry.theme)
}
private func goalSection(_ goal: GoalSnapshot, maxCount: Int, columns: Int, ringSize: CGFloat) -> some View {
VStack(spacing: 6) {
HStack(spacing: 4) {
Image(systemName: goal.symbolName)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(goal.color)
Text(goal.title)
.font(.caption2.weight(.semibold))
.lineLimit(1)
Spacer()
Text(entry.spanLabel)
.font(.system(size: 8))
.foregroundStyle(.tertiary)
}
if goal.quests.isEmpty {
Spacer()
Text("다짐이 없어요")
.font(.caption2)
.foregroundStyle(.secondary)
Spacer()
} else {
let quests = Array(goal.quests.prefix(maxCount))
let grid = [GridItem](repeating: GridItem(.flexible(), spacing: 4), count: columns)
LazyVGrid(columns: grid, spacing: 6) {
ForEach(quests) { quest in
QuestRingCellView(quest: quest, ringSize: ringSize)
}
}
Spacer(minLength: 0)
}
}
}
}
struct GoalQuestGridWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "HaruGoalQuestGridWidget",
intent: GoalQuestGridConfigIntent.self,
provider: GoalQuestGridProvider()
) { entry in
GoalQuestGridWidgetView(entry: entry)
}
.configurationDisplayName("다짐 현황")
.description("목표에 속한 다짐들의 진행률을 원형 링으로 한눈에 봐요. (프리미엄)")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}