- 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.
212 lines
8.0 KiB
Swift
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()
|
|
}
|
|
}
|