Widget system rebuilt as 5 independent widgets, each with its own WidgetConfigurationIntent registered in the WidgetBundle: - ① ActionRun: multi-action selection, value display option (day/week/ month/hidden), full tag-color cells; small 1 / medium 4 (2x2) / large 4 big cells or 8 compact cells (2x4) via largeStyle. Unfilled slots render as dashed blank cells. - ② GoalProgress: multi-goal selection; medium [2 goals | goal+top quests], large [4 goals | 2x goal+quests | 1 goal + all quests] via medium/largeStyle. New QuestBarRow / GoalWithQuestsCellView. - ③ QuestRing: multi-quest selection + span; surface-card ring (tag color) with centered icon, name and "오늘 N%" text; interactive run via RunActionIntent; small 1 / medium 2 / large 4 (2x2). - ④ QuestStatus (display-only): goal selection + span; rings with name only (no %), goal icon+name top-left; small 4 (2x2) / medium 8 (4x2) / large [16 (4x4) | 4 goals | 2 goals] via largeStyle. - ⑤ StatsChart: multi-action selection capped per family (2/4/6) + 3 span modes; same design scaled by family. Common foundation: - Theme option revived on every intent: match app / light fixed / dark fixed (WidgetThemeOption AppEnum) applied through widgetAppTheme(_:) with proper iOS 17 containerBackground; non-fullColor rendering modes (tinted/clear/accented) keep the Color.clear + semantic-color defense so content never goes white-on-white. - Providers are family-aware (capacity by size) and slot-pad selections (WidgetStore.slots); defaults fill from existing data so fresh widgets never look empty. - Interactive path unchanged: RunActionIntent(actionID:) + IntentStore.performWrite -> reloadAllTimelines. Floating menu polish: - All glass capsules now share a uniform width (longest label wins, locale-proof) for a tidy popped-up list. - Settings strings updated: nav style renamed to '플로팅 글래스 캡슐' with description matching the capsule popup, order editor retitled '캡슐 메뉴 순서' with bottom-to-top wording. Verified: BUILD SUCCEEDED (iPhone 17 Pro sim); widget preview harness extended with per-variant scroll anchors and all 15 layout variants screenshot-checked, including forced accented mode (legible translucent cells) and blank-slot rendering; capsule menu and settings text screenshot-checked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
227 lines
8.3 KiB
Swift
227 lines
8.3 KiB
Swift
//
|
||
// QuestRingWidget.swift
|
||
// Haru_DanimWidgets
|
||
//
|
||
// ③ 다짐 진행률 위젯 (CLAUDE.md §8.2)
|
||
// 선택한 다짐들의 진행률을 원형 링(꼬리표 색)으로 표시 + 눌러서 바로 실행 (Interactive).
|
||
// 표면색 카드 배경 위 링 중앙 아이콘, 아래에 이름과 "오늘 0%" 텍스트.
|
||
// - 소형: 다짐 1개 (풀블리드)
|
||
// - 중형: 다짐 2개 (좌우)
|
||
// - 대형: 다짐 4개 (2×2)
|
||
//
|
||
|
||
import SwiftUI
|
||
import WidgetKit
|
||
import AppIntents
|
||
|
||
// MARK: - 설정
|
||
|
||
struct QuestRingConfigIntent: WidgetConfigurationIntent {
|
||
static let title: LocalizedStringResource = "다짐 진행률 위젯"
|
||
static let description = IntentDescription("원형 진행률로 표시할 다짐들과 기간, 테마를 선택하세요. 선택하지 않은 칸은 비워 둬요.")
|
||
|
||
@Parameter(title: "다짐")
|
||
var quests: [QuestEntity]?
|
||
|
||
@Parameter(title: "진행률 기간", default: .day)
|
||
var span: SpanOption
|
||
|
||
@Parameter(title: "테마", default: .matchApp)
|
||
var theme: WidgetThemeOption
|
||
}
|
||
|
||
// MARK: - 타임라인
|
||
|
||
struct QuestRingEntry: TimelineEntry {
|
||
let date: Date
|
||
let locked: Bool
|
||
let theme: WidgetThemeOption
|
||
let spanLabel: String
|
||
/// 패밀리별 슬롯 수만큼 채워짐 (nil = 사용자가 비워 둔 칸)
|
||
let cells: [QuestCellSnapshot?]
|
||
|
||
/// 시간형 다짐의 대상 행동이 측정 중이면 링이 계속 채워지므로 미래 엔트리를 미리 계산해 둔다
|
||
var isLive: Bool { cells.contains { $0?.isRunning == true } }
|
||
}
|
||
|
||
struct QuestRingProvider: AppIntentTimelineProvider {
|
||
/// 패밀리별 슬롯 수 (소형 1 / 중형 2 / 대형 4)
|
||
static func capacity(_ family: WidgetFamily) -> Int {
|
||
switch family {
|
||
case .systemMedium: return 2
|
||
case .systemLarge: return 4
|
||
default: return 1
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
static func makeEntry(_ configuration: QuestRingConfigIntent, family: WidgetFamily,
|
||
now: Date = .now) -> QuestRingEntry {
|
||
IntentStore.refresh()
|
||
guard WidgetStore.isUnlocked else {
|
||
return QuestRingEntry(date: now, locked: true, theme: configuration.theme, spanLabel: "", cells: [])
|
||
}
|
||
let capacity = Self.capacity(family)
|
||
let quests = WidgetStore.selectedQuests(configuration.quests, defaultCount: capacity)
|
||
let span = configuration.span.statSpan
|
||
return QuestRingEntry(
|
||
date: now,
|
||
locked: false,
|
||
theme: configuration.theme,
|
||
spanLabel: configuration.span.label,
|
||
cells: WidgetStore.slots(quests.map { QuestCellSnapshot.make(quest: $0, span: span, now: now) },
|
||
capacity: capacity)
|
||
)
|
||
}
|
||
|
||
func placeholder(in context: Context) -> QuestRingEntry {
|
||
QuestRingEntry(date: .now, locked: false, theme: .matchApp, spanLabel: "오늘",
|
||
cells: Array(QuestCellSnapshot.samples.prefix(Self.capacity(context.family))))
|
||
}
|
||
|
||
@MainActor
|
||
func snapshot(for configuration: QuestRingConfigIntent, in context: Context) async -> QuestRingEntry {
|
||
Self.makeEntry(configuration, family: context.family)
|
||
}
|
||
|
||
@MainActor
|
||
func timeline(for configuration: QuestRingConfigIntent, in context: Context) async -> Timeline<QuestRingEntry> {
|
||
let first = Self.makeEntry(configuration, family: context.family)
|
||
return WidgetRefresh.timeline(first: first, live: first.isLive) { date in
|
||
Self.makeEntry(configuration, family: context.family, now: date)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 뷰
|
||
|
||
struct QuestRingActionCellView: View {
|
||
let cell: QuestCellSnapshot
|
||
let spanLabel: String
|
||
var ringSize: CGFloat = 52
|
||
/// 셀 하나가 위젯 전체를 차지할 때 (소형) — 위젯 모서리에 맞춰 여백 없이 채움
|
||
var fullBleed = false
|
||
|
||
var body: some View {
|
||
// 단일 행동 대상 다짐만 눌러서 실행 가능 (꼬리표 대상은 표시 전용)
|
||
if let target = cell.runTarget {
|
||
Button(intent: RunActionIntent(actionID: target.id.uuidString)) {
|
||
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(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
|
||
|
||
var body: some View {
|
||
Group {
|
||
if entry.locked {
|
||
WidgetLockedView()
|
||
.padding(12)
|
||
} else if !entry.cells.contains(where: { $0 != nil }) {
|
||
WidgetEmptyView(symbolName: "checkmark.circle", message: "목표 탭에서 다짐을 만들면\n진행률이 표시돼요")
|
||
} else {
|
||
switch family {
|
||
case .systemMedium:
|
||
HStack(spacing: 8) {
|
||
ForEach(0..<2, id: \.self) { index in
|
||
slotCell(index, ringSize: 52)
|
||
}
|
||
}
|
||
.padding(10)
|
||
case .systemLarge:
|
||
VStack(spacing: 8) {
|
||
ForEach(0..<2, id: \.self) { row in
|
||
HStack(spacing: 8) {
|
||
ForEach(0..<2, id: \.self) { col in
|
||
slotCell(row * 2 + col, ringSize: 60)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(10)
|
||
default:
|
||
// 소형: 셀 하나가 위젯 전체를 여백 없이 채움 (풀블리드)
|
||
if let cell = entry.cells.first ?? nil {
|
||
QuestRingActionCellView(cell: cell, spanLabel: entry.spanLabel,
|
||
ringSize: 58, fullBleed: true)
|
||
} else {
|
||
WidgetBlankCellView(fullBleed: true)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.widgetAppTheme(entry.theme)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func slotCell(_ index: Int, ringSize: CGFloat) -> some View {
|
||
if index < entry.cells.count, let cell = entry.cells[index] {
|
||
QuestRingActionCellView(cell: cell, spanLabel: entry.spanLabel, ringSize: ringSize)
|
||
} else {
|
||
WidgetBlankCellView()
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
}
|