mycode/myApp/HaruDanim/Widgets/NowRunningWidget.swift
songyc macbook df6c03e18c fix(widget): NowRunning stop button opened the app instead of stopping
- ⑥ 현재 진행 중 위젯의 종료 버튼 탭이 인텐트 대신 앱 열기로 새던 원인 = 버튼 라벨(정지 아이콘)에 붙어 있던 invalidatableContent() — §10에 문서화된 금지 패턴(invalidatable 래스터 서브트리는 별도 레이어로 분리돼 Button(intent:) 히트 영역에서 빠짐, ③ 링 실기기 버그와 동일 기전)을 이 버튼만 어기고 있었음
- 아이콘의 invalidatableContent() 한 줄 제거 (외형·레이아웃 불변) — 탭 접수 깜빡임 피드백은 바로 옆 타이머 텍스트의 invalidatableContent가 기존대로 담당(①③과 같은 패턴). 소형·중형 대표 카드·대형 나열 행 모두 같은 NowRunningStopButton이라 한 번에 수정
- CLAUDE.md §10 경고문에 ⑥ 재현 사례 병기
- 검증: Debug·Store·워치 3스킴 빌드 성공, 위젯 미리보기 렌더 수정 전과 동일. 히트 영역 자체는 홈 화면 UI라 실기기 탭 확인 필요

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-16 09:06:18 +09:00

451 lines
18 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

//
// NowRunningWidget.swift
// Haru_DanimWidgets
//
// (CLAUDE.md §10)
// , (Interactive).
// - :
// - : ( ' ' )
// + + +
// - : · 1 + "+N ",
//
//
import SwiftUI
import SwiftData
import WidgetKit
import AppIntents
// MARK: -
struct NowRunningConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "현재 진행 중 위젯"
static let description = IntentDescription("측정 중인 행동을 실시간으로 보여 주고 바로 종료할 수 있어요. 테마를 선택하세요.")
@Parameter(title: "테마", default: .matchApp)
var theme: WidgetThemeOption
}
// MARK: -
///
struct RunningItemSnapshot: Identifiable {
/// Action.uuid (RunActionIntent)
let id: UUID
let name: String
let symbolName: String
let colorHex: String
let startAt: Date
var color: Color { Color(hex: colorHex) }
}
struct NowRunningEntry: TimelineEntry {
let date: Date
let locked: Bool
let theme: WidgetThemeOption
/// ( ' ' ). nil =
let primary: RunningItemSnapshot?
/// ( )
let others: [RunningItemSnapshot]
/// (now , )
let todayTickingBase: Date?
}
extension NowRunningEntry {
/// · ( 2 )
static func sample(theme: WidgetThemeOption = .matchApp) -> NowRunningEntry {
let now = Date.now
return NowRunningEntry(
date: now, locked: false, theme: theme,
primary: RunningItemSnapshot(id: UUID(), name: String(localized: "독서"),
symbolName: "book.fill", colorHex: "#2F6B4F",
startAt: now.addingTimeInterval(-45 * 60)),
others: [
RunningItemSnapshot(id: UUID(), name: String(localized: "달리기"),
symbolName: "figure.run", colorHex: "#9D5B4A",
startAt: now.addingTimeInterval(-12 * 60)),
],
todayTickingBase: now.addingTimeInterval(-65 * 60)
)
}
}
struct NowRunningProvider: AppIntentTimelineProvider {
@MainActor
static func makeEntry(_ configuration: NowRunningConfigIntent, now: Date = .now) -> NowRunningEntry {
IntentStore.refresh()
guard WidgetStore.isUnlocked else {
return NowRunningEntry(date: now, locked: true, theme: configuration.theme,
primary: nil, others: [], todayTickingBase: nil)
}
let descriptor = FetchDescriptor<TimeSession>(
predicate: #Predicate { $0.endAt == nil },
sortBy: [SortDescriptor(\.startAt, order: .forward)]
)
let running = ((try? IntentStore.context.fetch(descriptor)) ?? []).filter { $0.action != nil }
// (LiveActivityManager)
let mode = LiveActivityMode(
rawValue: AppGroup.defaults.string(forKey: SettingsKeys.liveActivityMode)
?? UserDefaults.standard.string(forKey: SettingsKeys.liveActivityMode) ?? ""
) ?? .latest
guard let session = (mode == .earliest ? running.first : running.last),
let action = session.action else {
return NowRunningEntry(date: now, locked: false, theme: configuration.theme,
primary: nil, others: [], todayTickingBase: nil)
}
let others: [RunningItemSnapshot] = running.filter { $0 !== session }.compactMap { other in
guard let otherAction = other.action else { return nil }
return RunningItemSnapshot(
id: otherAction.uuid, name: otherAction.name, symbolName: otherAction.symbolName,
colorHex: otherAction.sortedTags.first?.colorHex ?? "#2F6B4F", startAt: other.startAt
)
}
let math = DayMath()
let todaySeconds = Aggregator(math: math)
.seconds(for: action, in: math.dayRange(containing: now), now: now)
return NowRunningEntry(
date: now, locked: false, theme: configuration.theme,
primary: RunningItemSnapshot(
id: action.uuid, name: action.name, symbolName: action.symbolName,
colorHex: action.sortedTags.first?.colorHex ?? "#2F6B4F", startAt: session.startAt
),
others: others,
todayTickingBase: now.addingTimeInterval(-todaySeconds)
)
}
func placeholder(in context: Context) -> NowRunningEntry {
.sample()
}
@MainActor
func snapshot(for configuration: NowRunningConfigIntent, in context: Context) async -> NowRunningEntry {
let entry = Self.makeEntry(configuration)
//
if context.isPreview, entry.locked || entry.primary == nil {
return .sample(theme: configuration.theme)
}
return entry
}
@MainActor
func timeline(for configuration: NowRunningConfigIntent, in context: Context) async -> Timeline<NowRunningEntry> {
// Text(style:.timer) , /
// DataChange.commit/ ( )
let first = Self.makeEntry(configuration)
return WidgetRefresh.timeline(first: first, live: false) { _ in first }
}
}
// MARK: -
struct NowRunningWidgetView: View {
@Environment(\.widgetFamily) private var envFamily
/// DEBUG
var previewFamily: WidgetFamily? = nil
private var family: WidgetFamily { previewFamily ?? envFamily }
let entry: NowRunningEntry
var body: some View {
Group {
if entry.locked {
WidgetLockedView()
.padding(12)
} else if let primary = entry.primary {
switch family {
case .systemMedium:
NowRunningHeroView(primary: primary, extraCount: entry.others.count, fullBleed: true)
case .systemLarge:
NowRunningLargeView(primary: primary, others: entry.others,
todayTickingBase: entry.todayTickingBase)
default:
NowRunningSmallView(primary: primary, extraCount: entry.others.count)
}
} else {
emptyView
}
}
.widgetAppTheme(entry.theme)
}
///
private var emptyView: some View {
VStack(spacing: 6) {
Image(systemName: "timer")
.font(.title3)
.foregroundStyle(.secondary)
Text("지금 측정 중인 행동이 없어요")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if family != .systemSmall {
Text("행동을 시작하면 여기에 실시간으로 표시돼요")
.font(.caption2)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
}
.padding(12)
}
}
/// ( '' )
struct NowRunningStopButton: View {
@Environment(\.widgetRenderingMode) private var renderingMode
let actionID: UUID
var size: CGFloat = 32
var body: some View {
Button(intent: RunActionIntent(actionID: actionID.uuidString)) {
Image(systemName: "stop.fill")
.font(.system(size: size * 0.38, weight: .bold))
.foregroundStyle(renderingMode == .fullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.frame(width: size, height: size)
.background(
renderingMode == .fullColor
? AnyShapeStyle(AppTheme.yellow)
: AnyShapeStyle(Color.white.opacity(0.14)),
in: Circle()
)
// invalidatableContent() (§10) ·
// Button(intent:) ,
// (
// . contentShape ).
// invalidatableContent .
// (§10 )
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("측정 종료"))
}
}
/// : ()
struct NowRunningSmallView: View {
@Environment(\.widgetRenderingMode) private var renderingMode
let primary: RunningItemSnapshot
let extraCount: Int
private var isFullColor: Bool { renderingMode == .fullColor }
var body: some View {
VStack(alignment: .leading, spacing: 3) {
HStack {
Image(systemName: primary.symbolName)
.font(.system(size: 18, weight: .semibold))
Spacer()
Image(systemName: "record.circle")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(isFullColor ? AnyShapeStyle(AppTheme.yellow) : AnyShapeStyle(.primary))
}
Spacer(minLength: 0)
Text(primary.name)
.font(.caption.weight(.semibold))
.lineLimit(1)
HStack(alignment: .bottom) {
VStack(alignment: .leading, spacing: 1) {
Text(primary.startAt, style: .timer)
.font(.title3.weight(.bold).monospacedDigit())
.lineLimit(1)
.minimumScaleFactor(0.6)
.invalidatableContent()
if extraCount > 0 {
Text("+\(extraCount)개 함께 추적 중")
.font(.system(size: 9))
.opacity(0.85)
.lineLimit(1)
}
}
Spacer(minLength: 4)
NowRunningStopButton(actionID: primary.id, size: 30)
}
}
.foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.padding(14)
.background(WidgetTintedCellBackground(
color: isFullColor ? primary.color : primary.color.opacity(0.2),
fullBleed: true
))
}
}
/// (fullBleed),
struct NowRunningHeroView: View {
@Environment(\.widgetRenderingMode) private var renderingMode
let primary: RunningItemSnapshot
/// "+N " ( 0)
var extraCount = 0
var fullBleed = false
private var isFullColor: Bool { renderingMode == .fullColor }
var body: some View {
HStack(spacing: 12) {
Image(systemName: primary.symbolName)
.font(.system(size: 22, weight: .semibold))
.frame(width: 46, height: 46)
.background(
Color.white.opacity(isFullColor ? 0.22 : 0.14),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
Text(primary.name)
.font(.headline)
.lineLimit(1)
Image(systemName: "record.circle")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(isFullColor ? AnyShapeStyle(AppTheme.yellow) : AnyShapeStyle(.primary))
}
Text("\(primary.startAt, format: .dateTime.hour().minute()) 시작")
.font(.caption)
.opacity(0.85)
if extraCount > 0 {
Text("+\(extraCount)개 함께 추적 중")
.font(.caption2)
.opacity(0.85)
}
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 8) {
Text(primary.startAt, style: .timer)
.font(.title2.weight(.bold).monospacedDigit())
.multilineTextAlignment(.trailing)
.frame(maxWidth: 105, alignment: .trailing)
.lineLimit(1)
.minimumScaleFactor(0.6)
.invalidatableContent()
NowRunningStopButton(actionID: primary.id, size: 32)
}
}
.foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.padding(16)
.background(WidgetTintedCellBackground(
color: isFullColor ? primary.color : primary.color.opacity(0.2),
fullBleed: fullBleed
))
}
}
/// : + [ ( )] [ · ]
struct NowRunningLargeView: View {
let primary: RunningItemSnapshot
let others: [RunningItemSnapshot]
let todayTickingBase: Date?
/// ( " N")
private static let maxRows = 4
var body: some View {
VStack(spacing: 8) {
NowRunningHeroView(primary: primary)
if others.isEmpty {
todayCard
} else {
othersList
}
}
.padding(10)
}
/// ( )
private var othersList: some View {
VStack(spacing: 6) {
ForEach(Array(others.prefix(Self.maxRows).enumerated()), id: \.offset) { _, item in
NowRunningRowView(item: item)
}
if others.count > Self.maxRows {
Text("\(others.count - Self.maxRows)개 측정 중")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
/// : ()
private var todayCard: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("오늘 누적")
.font(.caption)
.foregroundStyle(.secondary)
if let base = todayTickingBase {
Text(base, style: .timer)
.font(.headline.monospacedDigit())
.invalidatableContent()
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("시작 시각")
.font(.caption)
.foregroundStyle(.secondary)
Text(primary.startAt, format: .dateTime.hour().minute())
.font(.headline.monospacedDigit())
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(WidgetCellBackground())
}
}
/// ' ' +
struct NowRunningRowView: View {
@Environment(\.widgetRenderingMode) private var renderingMode
let item: RunningItemSnapshot
private var isFullColor: Bool { renderingMode == .fullColor }
var body: some View {
HStack(spacing: 10) {
Image(systemName: item.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.frame(width: 30, height: 30)
.background(
isFullColor ? AnyShapeStyle(item.color) : AnyShapeStyle(item.color.opacity(0.2)),
in: RoundedRectangle(cornerRadius: 9, style: .continuous)
)
Text(item.name)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Spacer(minLength: 6)
Text(item.startAt, style: .timer)
.font(.subheadline.weight(.semibold).monospacedDigit())
.multilineTextAlignment(.trailing)
.frame(maxWidth: 80, alignment: .trailing)
.lineLimit(1)
.minimumScaleFactor(0.6)
.invalidatableContent()
NowRunningStopButton(actionID: item.id, size: 28)
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(WidgetCellBackground())
}
}
// MARK: -
struct NowRunningWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "HaruNowRunningWidget",
intent: NowRunningConfigIntent.self,
provider: NowRunningProvider()
) { entry in
NowRunningWidgetView(entry: entry)
}
.configurationDisplayName("현재 진행 중")
.description("측정 중인 행동을 실시간으로 보고 위젯에서 바로 종료해요. (프리미엄)")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
.contentMarginsDisabled()
}
}