체감 개선 2건 (사용자 리포트 기반): 1) 홈 위젯 버튼의 '안 눌렸나?' 문제 — ①행동 실행·③다짐 진행률 위젯의 변하는 값(누적·퍼센트·링)에 invalidatableContent() 적용. 탭 즉시 해당 값이 '갱신 중' 표시로 바뀌어, 새 타임라인 도착 (1~3초, WidgetKit 구조상 단축 불가) 전에도 탭 접수가 보인다 → 중복 탭으로 횟수가 여러 번 올라가는 문제 방지. 2) 워치 컴플리케이션 유령 타이머 — 종료 푸시가 유실되면 '측정 중' 타이머가 무한히 흐르던 문제. 스냅숏 generatedAt 기준 2시간 (runningTrustInterval)을 넘긴 '측정 중'은 '새로고침 필요' 표시로 강등. 워치 앱을 열면 즉시 복구되고 진짜 장시간 측정도 다음 스냅숏에 타이머가 돌아온다 (데이터 무영향). 검증: 위젯 미리보기 레이아웃 무영향 확인, 워치 미리보기에 강등 샘플 행 추가(-complicationScrollTo stale)로 원형/사각형 렌더링 확인. 새 문자열 4개 워치 앱·워치 위젯 카탈로그 ko/en/ja 번역. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
347 lines
14 KiB
Swift
347 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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.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()
|
||
}
|
||
}
|