- 배포 타깃 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
350 lines
14 KiB
Swift
350 lines
14 KiB
Swift
//
|
||
// 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()
|
||
}
|
||
}
|