mycode/myApp/HaruDanim/Widgets/ActionRunWidget.swift
songyc macbook 6e0af0c33c fix(widget): make the whole interactive cell tappable, not just its visible content
인터랙티브 위젯(①행동 실행·③다짐 진행률)에서 셀의 일부 지점을
누르면 인텐트가 실행되지 않고 앱이 열리던 버그 수정.

원인: .plain 스타일 Button은 라벨의 투명한 영역(Spacer로 벌어진
공간, 여백, 링 안쪽)을 탭 판정에서 제외하는 경우가 있어, 해당
지점의 탭이 버튼에 잡히지 않고 위젯 기본 동작(앱 열기)으로 샜다.

수정: 두 위젯의 버튼 라벨 끝에 .contentShape(.rect)로 히트 영역을
셀 프레임 전체로 명시 — 아이콘·글자·프로그레스 링은 물론 셀 안
빈 공간까지 어디를 눌러도 인텐트가 실행된다. 시각적 변화 없음.
(③의 꼬리표 대상 다짐 셀은 표시 전용이라 기존대로 앱 열기)

검증: Debug·Store 빌드 성공, 위젯 미리보기 스크린샷으로 레이아웃
무영향 확인. CLAUDE.md §10에 히트 영역 규칙 추가.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-14 13:55:59 +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(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..<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()
}
}