mycode/myApp/HaruDanim/Widgets/ActionRunWidget.swift
songyc macbook f7ddd42260 refactor(widget): complete widget architecture overhaul for reliable interactions and removed bloated configurations
- IntentStore now recreates the ModelContainer on demand in the widget
  extension (IntentStore.refresh()), called before every AppIntent
  perform() and every provider's makeEntry(). Fixes stale/blank widget
  buttons and double-toggle bugs caused by the extension process
  reading an old in-memory snapshot after CloudKit remote changes.
- Replaced the fixed 15/30-minute polling Timeline policy (which burns
  WidgetKit's daily reload budget across 6 widget kinds) with
  WidgetRefresh.timeline: idle widgets wait until the next logical-day
  boundary since value changes are already pushed via
  reloadAllTimelines, while widgets showing a live ratio (a currently
  running quest/goal) get pre-computed future entries so progress
  rings and bars keep advancing without extra reloads.
- Removed the per-widget "위젯 테마" (match app / light / dark / liquid
  glass) configuration option from all 5 home-screen widgets. It
  duplicated the same enum five times, and the liquid-glass variant's
  custom background/scheme override fought the system's own
  vibrant/tinted rendering in tinted Home Screens, which was part of
  the "블랙 화면"/렌더링 오류 reports. Widgets now simply follow the
  app's own theme setting (WidgetAppearance.appScheme), which is what
  "앱 테마와 일치" already defaulted to.
- Added shared gallery/placeholder sample data (ActionCellSnapshot,
  QuestCellSnapshot, GoalSnapshot, StatsChart sample points) so the
  widget gallery and redacted placeholders show a realistic shape
  instead of an empty/blank card.
- Unified empty-state UI via WidgetEmptyView across all 5 widgets.

Scope: Widgets/*, Shared/HaruDanimIntents.swift (widget-facing
IntentStore only), IOS/Views/WidgetPreviewScreen.swift (DEBUG preview
tool). No changes to app core data models, sync, or view logic.
2026-07-11 22:41:54 +09:00

212 lines
8.0 KiB
Swift

//
// ActionRunWidget.swift
// Haru_DanimWidgets
//
// (CLAUDE.md §8.2 A / A / A)
// Interactive Widgets: ( / +1)
//
import SwiftUI
import WidgetKit
import AppIntents
// MARK: -
struct ActionRunConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "행동 실행 위젯"
static let description = IntentDescription("표시할 행동과 누적 기간을 선택하세요.")
@Parameter(title: "표시 기간", default: .day)
var period: SpanOption
@Parameter(title: "행동 1")
var action1: ActionEntity?
@Parameter(title: "행동 2")
var action2: ActionEntity?
@Parameter(title: "행동 3")
var action3: ActionEntity?
@Parameter(title: "행동 4")
var action4: ActionEntity?
}
// MARK: -
struct ActionRunEntry: TimelineEntry {
let date: Date
let locked: Bool
let periodLabel: String
let cells: [ActionCellSnapshot]
}
struct ActionRunProvider: AppIntentTimelineProvider {
@MainActor
static func makeEntry(_ configuration: ActionRunConfigIntent) -> ActionRunEntry {
IntentStore.refresh()
guard WidgetStore.isUnlocked else {
return ActionRunEntry(date: .now, locked: true, periodLabel: "", cells: [])
}
let span = configuration.period.statSpan
let chosen = [configuration.action1, configuration.action2, configuration.action3, configuration.action4]
.compactMap { $0 }
.compactMap { WidgetStore.action($0.id) }
let actions = chosen.isEmpty ? WidgetStore.defaultActions(4) : chosen
return ActionRunEntry(
date: .now,
locked: false,
periodLabel: configuration.period.label,
cells: actions.map { ActionCellSnapshot.make(action: $0, span: span) }
)
}
func placeholder(in context: Context) -> ActionRunEntry {
ActionRunEntry(date: .now, locked: false, periodLabel: "오늘", cells: ActionCellSnapshot.samples)
}
@MainActor
func snapshot(for configuration: ActionRunConfigIntent, in context: Context) async -> ActionRunEntry {
Self.makeEntry(configuration)
}
@MainActor
func timeline(for configuration: ActionRunConfigIntent, in context: Context) async -> Timeline<ActionRunEntry> {
// / reloadAllTimelines ,
// Text(_:style:.timer)
//
let first = Self.makeEntry(configuration)
return WidgetRefresh.timeline(first: first, live: false) { _ in first }
}
}
// MARK: -
struct ActionRunCellView: View {
let cell: ActionCellSnapshot
let periodLabel: String
var compact = false
/// ()
var fullBleed = false
var body: some View {
Button(intent: RunActionIntent(action: cell.entity)) {
VStack(alignment: .leading, spacing: compact ? 2 : 4) {
HStack {
Image(systemName: cell.symbolName)
.font(.system(size: compact ? 14 : 18, weight: .semibold))
Spacer()
if cell.isRunning {
Image(systemName: "record.circle")
.font(.system(size: compact ? 10 : 12, weight: .bold))
.foregroundStyle(AppTheme.yellow)
}
}
Spacer(minLength: 0)
Text(cell.name)
.font(compact ? .caption2.weight(.semibold) : .caption.weight(.semibold))
.lineLimit(1)
Group {
if cell.isCount {
Text("\(periodLabel) \(Int(cell.value))")
} else if let base = cell.tickingBase {
// :
Text(base, style: .timer)
} else {
Text("\(periodLabel) \(Format.durationShort(cell.value))")
}
}
.font(compact ? .caption2.monospacedDigit() : .caption.monospacedDigit())
.opacity(0.85)
.lineLimit(1)
}
.foregroundStyle(.white)
.padding(fullBleed ? 14 : (compact ? 8 : 12))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(WidgetTintedCellBackground(color: cell.color, fullBleed: fullBleed))
.overlay {
if cell.isRunning {
if fullBleed {
ContainerRelativeShape()
.strokeBorder(AppTheme.yellow, lineWidth: 2)
} else {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(AppTheme.yellow, lineWidth: 2)
}
}
}
}
.buttonStyle(.plain)
}
}
struct ActionRunWidgetView: View {
@Environment(\.widgetFamily) private var envFamily
/// DEBUG
var previewFamily: WidgetFamily? = nil
private var family: WidgetFamily { previewFamily ?? envFamily }
let entry: ActionRunEntry
var body: some View {
Group {
if entry.locked {
WidgetLockedView()
.padding(12)
} else if entry.cells.isEmpty {
WidgetEmptyView(symbolName: "bolt.fill", message: "앱에서 행동을 만들면\n여기서 실행할 수 있어요")
} else {
switch family {
case .systemMedium:
HStack(spacing: 8) {
ForEach(entry.cells.prefix(3)) { cell in
ActionRunCellView(cell: cell, periodLabel: entry.periodLabel, compact: true)
}
}
.padding(10)
case .systemLarge:
let cells = Array(entry.cells.prefix(4))
VStack(spacing: 8) {
HStack(spacing: 8) {
ForEach(cells.prefix(2)) { cell in
ActionRunCellView(cell: cell, periodLabel: entry.periodLabel)
}
}
if cells.count > 2 {
HStack(spacing: 8) {
ForEach(cells.dropFirst(2)) { cell in
ActionRunCellView(cell: cell, periodLabel: entry.periodLabel)
}
}
}
}
.padding(10)
default:
// : ()
if let cell = entry.cells.first {
ActionRunCellView(cell: cell, periodLabel: entry.periodLabel, fullBleed: true)
}
}
}
}
.widgetAppTheme()
}
}
// MARK: -
struct ActionRunWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "HaruActionRunWidget",
intent: ActionRunConfigIntent.self,
provider: ActionRunProvider()
) { entry in
ActionRunWidgetView(entry: entry)
}
.configurationDisplayName("행동 실행")
.description("행동을 위젯에서 바로 실행하고 누적값을 확인해요. (프리미엄)")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
.contentMarginsDisabled()
}
}