mycode/myApp/HaruDanim/Widgets/ActionRunWidget.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

350 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)
}
}
}
// plain (Spacer·)
// , ( )
.contentShape(.rect)
}
.buttonStyle(.plain)
}
/// ( / 4)
private var tileContent: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(safeSymbol: 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(safeSymbol: 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()
}
}