- 배포 타깃 26.0→18.0 (앱·위젯. 워치 10.0 불변) - RadialNavigationView: glassEffect 계열을 @available(iOS 26) 선언 격리 + 18 머티리얼 원 폴백 (배치·연출 동일) - DiaryZoomContainer.Coordinator → 비제네릭 톱레벨 DiaryZoomCoordinator + AnyView 소거 (하한 18 Release wholemodule에서 swift-frontend SILPerformanceInliner 무한 재귀 크래시 실측·우회) - Image(safeSymbol:)/SymbolCompat: 카탈로그의 상위 OS 전용 심볼(18 기준 3개)이 교차 기기에서 빈 아이콘이 되지 않게 사용자 심볼 렌더 48곳+워치 7곳 폴백 - -symbolAuditDump 검증 인자 추가, iOS 18.5 시뮬 QA(빌드·radial 폴백·일기 줌·progressSelfTest 49 ALL PASS) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
497 lines
20 KiB
Swift
497 lines
20 KiB
Swift
//
|
||
// 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(safeSymbol: 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(safeSymbol: 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(safeSymbol: 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(safeSymbol: 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)
|
||
}
|
||
}
|