mycode/myApp/HaruDanim/Widgets/NowRunningWidget.swift
songyc macbook 5b526fa650 feat(compat): iOS 하한 18.0 인하 — 글라스 버블 머티리얼 폴백·사용자 심볼 안전 렌더·Release 인라이너 크래시 우회
- 배포 타깃 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
2026-08-11 16:31:40 +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(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)
}
}