// // 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 { // 값 변경은 인텐트 실행/앱 사용 시 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(systemName: 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(systemName: 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..