- 홈 위젯 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
208 lines
6.9 KiB
Swift
208 lines
6.9 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
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.padding(6)
|
|
.background(WidgetCellBackground())
|
|
}
|
|
|
|
private var percentText: String {
|
|
if cell.isAtMost {
|
|
return cell.isAchieved ? "\(spanLabel) 한도 지킴" : "\(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()
|
|
} else if entry.cells.isEmpty {
|
|
Text("앱의 목표 탭에서 다짐을 만들면 진행률이 표시돼요.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
} else {
|
|
let cells = Array(entry.cells.prefix(visibleCount))
|
|
switch family {
|
|
case .systemMedium:
|
|
HStack(spacing: 8) {
|
|
ForEach(cells) { QuestRingActionCellView(cell: $0, spanLabel: entry.spanLabel) }
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
if let cell = cells.first {
|
|
QuestRingActionCellView(cell: cell, spanLabel: entry.spanLabel, ringSize: 58)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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])
|
|
}
|
|
}
|