mycode/myApp/HaruDanim/Widgets/NowRunningWidget.swift
songyc macbook e1537b32f3 feat(widget): stop button on Live Activity lock-screen banner
- 잠금화면 실시간 현황(라이브 액티비티)에 종료 수단이 없다는 보고 → 배너 오른쪽에 노란 정지 버튼 추가. 워치·⑥ 위젯과 같은 검증된 경로(NowRunningStopButton → RunActionIntent 토글) 재사용이라 위험 낮음 판단 후 진행
- ContentState에 actionID(optional) 추가 — 앱 업데이트 전 인플라이트 액티비티(키 없음)도 안전 디코딩(WatchSnapshot 선례), 없으면 버튼 없이 기존 표시
- LockScreenActivityView를 NowRunningWidget.swift로 이동 — @main 파일은 앱 타깃에서 제외라 앱 내 위젯 미리보기(-widgetPreview)에서 배너를 렌더하기 위함(동작 불변). 미리보기 최상단에 배너 박스 신설
- 다이나믹 아일랜드 스모크 확인(상태 필드 추가 회귀 없음 — 콤팩트 정상 표시). 실기기 확인 항목: 잠금화면에서 정지 버튼 탭 시 즉시 종료
- 도움말 '다이나믹 아일랜드·잠금화면' 문구에 정지 버튼 안내 추가(ko/en/ja, stale 정리), CLAUDE.md §9 갱신. Debug/Store 빌드 성공, 카탈로그 9종 클린

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
2026-07-19 04:46:38 +09:00

497 lines
20 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()
}
}
// MARK: -
// (HaruDanimWidgets.swift @main
// . ActivityConfiguration TrackingLiveActivity)
struct LockScreenActivityView: View {
let state: TrackingActivityAttributes.ContentState
var body: some View {
HStack(spacing: 12) {
Image(systemName: state.symbolName)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 40, height: 40)
.background(
state.tintColor,
in: RoundedRectangle(cornerRadius: 11, style: .continuous)
)
VStack(alignment: .leading, spacing: 2) {
Text(state.actionName)
.font(.headline)
.lineLimit(1)
if state.extraCount > 0 {
Text("+\(state.extraCount)개 함께 추적 중")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("측정 중")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Text(timerInterval: state.timerRange, countsDown: false)
.font(.title2.weight(.bold).monospacedDigit())
.multilineTextAlignment(.trailing)
.frame(maxWidth: 90)
// (RunActionIntent ).
// (actionID )
if let id = state.actionID.flatMap(UUID.init(uuidString:)) {
NowRunningStopButton(actionID: id, size: 36)
}
}
.padding(14)
}
}