mycode/myApp/HaruDanim/Widgets/ActionRunWidget.swift
songyc macbook 8818578758 feat(widget,watch): instant tap feedback + stale complication timer guard
체감 개선 2건 (사용자 리포트 기반):

1) 홈 위젯 버튼의 '안 눌렸나?' 문제 — ①행동 실행·③다짐 진행률
   위젯의 변하는 값(누적·퍼센트·링)에 invalidatableContent() 적용.
   탭 즉시 해당 값이 '갱신 중' 표시로 바뀌어, 새 타임라인 도착
   (1~3초, WidgetKit 구조상 단축 불가) 전에도 탭 접수가 보인다
   → 중복 탭으로 횟수가 여러 번 올라가는 문제 방지.

2) 워치 컴플리케이션 유령 타이머 — 종료 푸시가 유실되면 '측정 중'
   타이머가 무한히 흐르던 문제. 스냅숏 generatedAt 기준 2시간
   (runningTrustInterval)을 넘긴 '측정 중'은 '새로고침 필요' 표시로
   강등. 워치 앱을 열면 즉시 복구되고 진짜 장시간 측정도 다음
   스냅숏에 타이머가 돌아온다 (데이터 무영향).

검증: 위젯 미리보기 레이아웃 무영향 확인, 워치 미리보기에 강등
샘플 행 추가(-complicationScrollTo stale)로 원형/사각형 렌더링 확인.
새 문자열 4개 워치 앱·워치 위젯 카탈로그 ko/en/ja 번역.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-14 13:01:05 +09:00

347 lines
14 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.

//
// ActionRunWidget.swift
// Haru_DanimWidgets
//
// (CLAUDE.md §8.2)
// ( = / + , = +1).
// . .
// - : 1 ()
// - : 4 (2×2)
// - : [ 4 (2×2)] [ 8 (2×4)]
//
import SwiftUI
import WidgetKit
import AppIntents
// MARK: -
/// ( )
enum ActionValueDisplayOption: String, AppEnum {
case day, week, month, hidden
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "누적값 표시")
static let caseDisplayRepresentations: [ActionValueDisplayOption: DisplayRepresentation] = [
.day: "오늘 누적",
.week: "이번 주 누적",
.month: "이번 달 누적",
.hidden: "표시 안 함",
]
/// ( .day )
var statSpan: StatSpan {
switch self {
case .day, .hidden: return .day
case .week: return .week
case .month: return .month
}
}
/// . nil =
var label: String? {
switch self {
case .day: return String(localized: "오늘")
case .week: return String(localized: "이번 주")
case .month: return String(localized: "이번 달")
case .hidden: return nil
}
}
}
///
enum ActionLargeStyle: String, AppEnum {
/// 4 ( 4 2×2)
case fourCells
/// 8 ( 2 2×4)
case eightCells
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "대형 위젯 구성")
static let caseDisplayRepresentations: [ActionLargeStyle: DisplayRepresentation] = [
.fourCells: "큰 셀 4개 (2×2)",
.eightCells: "작은 셀 8개 (2×4)",
]
}
struct ActionRunConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "행동 실행 위젯"
static let description = IntentDescription("실행할 행동들과 누적값 표시 방식, 테마를 선택하세요. 선택하지 않은 칸은 비워 둬요.")
@Parameter(title: "행동")
var actions: [ActionEntity]?
@Parameter(title: "누적값 표시", default: .day)
var valueDisplay: ActionValueDisplayOption
@Parameter(title: "대형 위젯 구성", default: .fourCells)
var largeStyle: ActionLargeStyle
@Parameter(title: "테마", default: .matchApp)
var theme: WidgetThemeOption
}
// MARK: -
struct ActionRunEntry: TimelineEntry {
let date: Date
let locked: Bool
let theme: WidgetThemeOption
/// . nil =
let periodLabel: String?
let largeStyle: ActionLargeStyle
/// (nil = )
let cells: [ActionCellSnapshot?]
}
struct ActionRunProvider: AppIntentTimelineProvider {
/// · ( 1 / 4 / 4 8)
static func capacity(_ family: WidgetFamily, largeStyle: ActionLargeStyle) -> Int {
switch family {
case .systemMedium: return 4
case .systemLarge: return largeStyle == .eightCells ? 8 : 4
default: return 1
}
}
@MainActor
static func makeEntry(_ configuration: ActionRunConfigIntent, family: WidgetFamily) -> ActionRunEntry {
IntentStore.refresh()
guard WidgetStore.isUnlocked else {
return ActionRunEntry(date: .now, locked: true, theme: configuration.theme,
periodLabel: nil, largeStyle: configuration.largeStyle, cells: [])
}
let capacity = Self.capacity(family, largeStyle: configuration.largeStyle)
let actions = WidgetStore.selectedActions(configuration.actions, defaultCount: capacity)
let span = configuration.valueDisplay.statSpan
return ActionRunEntry(
date: .now,
locked: false,
theme: configuration.theme,
periodLabel: configuration.valueDisplay.label,
largeStyle: configuration.largeStyle,
cells: WidgetStore.slots(actions.map { ActionCellSnapshot.make(action: $0, span: span) },
capacity: capacity)
)
}
func placeholder(in context: Context) -> ActionRunEntry {
ActionRunEntry(date: .now, locked: false, theme: .matchApp, periodLabel: String(localized: "오늘"),
largeStyle: .fourCells,
cells: Array(ActionCellSnapshot.samples.prefix(
Self.capacity(context.family, largeStyle: .fourCells))))
}
@MainActor
func snapshot(for configuration: ActionRunConfigIntent, in context: Context) async -> ActionRunEntry {
Self.makeEntry(configuration, family: context.family)
}
@MainActor
func timeline(for configuration: ActionRunConfigIntent, in context: Context) async -> Timeline<ActionRunEntry> {
// / reloadAllTimelines ,
// Text(_:style:.timer)
//
let first = Self.makeEntry(configuration, family: context.family)
return WidgetRefresh.timeline(first: first, live: false) { _ in first }
}
}
// MARK: -
struct ActionRunCellView: View {
/// . .fullColor (// , iOS 18 accented·vibrant)
/// white-on-white .
@Environment(\.widgetRenderingMode) private var renderingMode
let cell: ActionCellSnapshot
/// nil =
let periodLabel: String?
/// ( 2×2 / 2×4)
var rowLayout = false
/// ()
var fullBleed = false
private var isFullColor: Bool { renderingMode == .fullColor }
var body: some View {
Button(intent: RunActionIntent(actionID: cell.id.uuidString)) {
Group {
if rowLayout {
rowContent
} else {
tileContent
}
}
// : . / : .
.foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(cellBackground)
.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)
}
/// ( / 4)
private var tileContent: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: cell.symbolName)
.font(.system(size: 18, weight: .semibold))
Spacer()
runningDot
}
Spacer(minLength: 0)
Text(cell.name)
.font(.caption.weight(.semibold))
.lineLimit(1)
valueText
.font(.caption.monospacedDigit())
.opacity(0.85)
.lineLimit(1)
// ' ' (1~3)
//
.invalidatableContent()
}
.padding(fullBleed ? 14 : 12)
}
/// ( 2×2 / 8)
private var rowContent: some View {
HStack(spacing: 8) {
Image(systemName: cell.symbolName)
.font(.system(size: 15, weight: .semibold))
VStack(alignment: .leading, spacing: 1) {
Text(cell.name)
.font(.caption2.weight(.semibold))
.lineLimit(1)
valueText
.font(.caption2.monospacedDigit())
.opacity(0.85)
.lineLimit(1)
.invalidatableContent()
}
Spacer(minLength: 0)
runningDot
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
}
@ViewBuilder
private var runningDot: some View {
if cell.isRunning {
Image(systemName: "record.circle")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(isFullColor ? AnyShapeStyle(AppTheme.yellow) : AnyShapeStyle(.primary))
}
}
@ViewBuilder
private var valueText: some View {
if let base = cell.tickingBase {
// : ( )
Text(base, style: .timer)
} else if let periodLabel {
if cell.isCount {
Text("\(periodLabel) \(Int(cell.value))")
} else {
Text("\(periodLabel) \(Format.durationShort(cell.value))")
}
}
}
/// : , /
/// ( ) .
@ViewBuilder private var cellBackground: some View {
WidgetTintedCellBackground(
color: isFullColor ? cell.color : cell.color.opacity(0.2),
fullBleed: fullBleed
)
}
}
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.contains(where: { $0 != nil }) {
WidgetEmptyView(symbolName: "bolt.fill", message: "앱에서 행동을 만들면\n여기서 실행할 수 있어요")
} else {
switch family {
case .systemMedium:
slotGrid(rows: 2, rowLayout: true, spacing: 8)
.padding(10)
case .systemLarge:
if entry.largeStyle == .eightCells {
slotGrid(rows: 4, rowLayout: true, spacing: 8)
.padding(10)
} else {
slotGrid(rows: 2, rowLayout: false, spacing: 8)
.padding(10)
}
default:
// : ()
if let cell = entry.cells.first ?? nil {
ActionRunCellView(cell: cell, periodLabel: entry.periodLabel, fullBleed: true)
} else {
WidgetBlankCellView(fullBleed: true)
}
}
}
}
.widgetAppTheme(entry.theme)
}
/// 2 × rows . .
private func slotGrid(rows: Int, rowLayout: Bool, spacing: CGFloat) -> some View {
VStack(spacing: spacing) {
ForEach(0..<rows, id: \.self) { row in
HStack(spacing: spacing) {
ForEach(0..<2, id: \.self) { col in
let index = row * 2 + col
if index < entry.cells.count, let cell = entry.cells[index] {
ActionRunCellView(cell: cell, periodLabel: entry.periodLabel, rowLayout: rowLayout)
} else {
WidgetBlankCellView()
}
}
}
}
}
}
}
// 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()
}
}