- 홈 위젯 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
331 lines
12 KiB
Swift
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])
|
|
}
|
|
}
|