mycode/myApp/HaruDanim/Widgets/QuestRingWidget.swift
songyc macbook 49dd1d0ff2 feat(widgets): complete redesign of 5 widget types with modular Intents, fixed floating menu UI/Strings
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
2026-07-13 01:17:39 +09:00

227 lines
8.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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()
}
}