- 앱 인텐트 설명·위젯 설명의 iPhone → '하루 다님 앱' (금칙 규칙 코드 주석 기록) - 워치 카탈로그 2종 구 키 제거·신 키 en/ja 번역, missing/stale 0 - 화면 뷰 문구(빈 상태 안내 등)의 iPhone은 1.4부터 통과된 스캔 비대상이라 유지 - Debug/Store/워치 빌드 성공, CURRENT_PROJECT_VERSION 3 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
299 lines
13 KiB
Swift
299 lines
13 KiB
Swift
//
|
||
// ActionValueComplication.swift
|
||
// Haru_DanimWatchWidgetsExtension
|
||
//
|
||
// 행동 기록 컴플리케이션 (1.5 — CLAUDE.md §9.2, Docs/plan-1.5.md §2)
|
||
// - 아이폰 행동 편집에서 옵트인한 행동의 "설정 기간 누적값"(하루/이번 주/이번 달/지난 7일/지난 30일)을 표시
|
||
// - 측정 중이면 노란 시그널 + 실시간 타이머 (기간 누적 기준)
|
||
// - 탭하면 widgetURL 딥링크로 워치 앱의 미니 행동 화면에 착지 (자동 실행 없음 — 오탭 방지)
|
||
// - 기간은 컴플리케이션 파라미터가 아니라 아이폰 설정이 결정한다 (스냅숏의 complicationPeriodRaw)
|
||
//
|
||
|
||
import AppIntents
|
||
import SwiftUI
|
||
import WidgetKit
|
||
|
||
// MARK: - 행동 엔티티 (캐시된 스냅숏의 옵트인 행동)
|
||
|
||
struct WatchActionEntity: AppEntity {
|
||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "행동")
|
||
static let defaultQuery = WatchActionEntityQuery()
|
||
|
||
let id: UUID
|
||
let title: String
|
||
|
||
var displayRepresentation: DisplayRepresentation {
|
||
DisplayRepresentation(title: "\(title)")
|
||
}
|
||
}
|
||
|
||
struct WatchActionEntityQuery: EntityQuery {
|
||
private func all() -> [WatchActionEntity] {
|
||
(ComplicationStore.snapshot()?.actions ?? [])
|
||
.filter { $0.complicationPeriodRaw != nil }
|
||
.map { WatchActionEntity(id: $0.id, title: $0.name) }
|
||
}
|
||
|
||
func entities(for identifiers: [UUID]) async throws -> [WatchActionEntity] {
|
||
all().filter { identifiers.contains($0.id) }
|
||
}
|
||
|
||
func suggestedEntities() async throws -> [WatchActionEntity] {
|
||
all()
|
||
}
|
||
}
|
||
|
||
// MARK: - 구성 인텐트
|
||
|
||
struct ActionValueConfigIntent: WidgetConfigurationIntent {
|
||
static let title: LocalizedStringResource = "행동 기록"
|
||
// ⚠️ 인텐트 메타데이터에는 'iPhone' 단어 금지 — ASC 전달 검증 ITMS-90626 (2026-08-22 실측).
|
||
// 기기명 대신 '하루 다님 앱'으로 표현한다 (화면 뷰 문구는 스캔 대상 아님 — 빈 상태 안내는 무관)
|
||
static let description = IntentDescription("표시할 행동을 선택하세요. 표시 기간은 하루 다님 앱의 행동 편집에서 정해요.")
|
||
|
||
@Parameter(title: "행동")
|
||
var action: WatchActionEntity?
|
||
}
|
||
|
||
// MARK: - 타임라인
|
||
|
||
struct ActionValueEntry: TimelineEntry {
|
||
let date: Date
|
||
let locked: Bool
|
||
/// 표시할 옵트인 행동이 하나도 없음 (빈 상태 안내)
|
||
let empty: Bool
|
||
let actionID: UUID?
|
||
let name: String
|
||
let symbolName: String
|
||
let colorHex: String
|
||
let isCount: Bool
|
||
/// 설정 기간 누적값 (시간형 초 / 횟수형 회)
|
||
let value: Double
|
||
let periodLabel: String
|
||
let isRunning: Bool
|
||
/// 측정 중일 때 기간 누적 실시간 타이머 기준 (스냅숏이 오래됐으면 nil — 유령 타이머 방지)
|
||
let tickingBase: Date?
|
||
}
|
||
|
||
struct ActionValueProvider: AppIntentTimelineProvider {
|
||
static func makeEntry(_ configuration: ActionValueConfigIntent) -> ActionValueEntry {
|
||
func blank(locked: Bool, empty: Bool) -> ActionValueEntry {
|
||
ActionValueEntry(date: .now, locked: locked, empty: empty, actionID: nil,
|
||
name: "", symbolName: "figure.walk", colorHex: "#2F6B4F",
|
||
isCount: true, value: 0, periodLabel: "", isRunning: false, tickingBase: nil)
|
||
}
|
||
guard let snapshot = ComplicationStore.snapshot() else { return blank(locked: false, empty: true) }
|
||
guard snapshot.isPremium else { return blank(locked: true, empty: false) }
|
||
let candidates = snapshot.actions.filter { $0.complicationPeriodRaw != nil }
|
||
guard let action = candidates.first(where: { $0.id == configuration.action?.id }) ?? candidates.first
|
||
else { return blank(locked: false, empty: true) }
|
||
|
||
// 유령 타이머 안전장치: 종료 푸시가 유실된 오래된 스냅숏의 '측정 중'은
|
||
// 무한 타이머 대신 정적 값으로 강등 (현재 현황 컴플리케이션과 같은 한도)
|
||
let stale = Date.now.timeIntervalSince(snapshot.generatedAt) > StatusProvider.runningTrustInterval
|
||
let running = action.isRunning && !stale
|
||
let period = action.complicationPeriodRaw.flatMap(WatchActionPeriod.init(rawValue:)) ?? .day
|
||
return ActionValueEntry(
|
||
date: .now,
|
||
locked: false,
|
||
empty: false,
|
||
actionID: action.id,
|
||
name: action.name,
|
||
symbolName: action.symbolName,
|
||
colorHex: action.colorHex,
|
||
isCount: action.isCount,
|
||
value: action.periodValue ?? action.todayValue,
|
||
periodLabel: period.label,
|
||
isRunning: running,
|
||
tickingBase: running && !action.isCount
|
||
? (action.periodTickingBase ?? action.tickingBase) : nil
|
||
)
|
||
}
|
||
|
||
func placeholder(in context: Context) -> ActionValueEntry {
|
||
ActionValueEntry(date: .now, locked: false, empty: false, actionID: nil,
|
||
name: String(localized: "독서"), symbolName: "book.fill", colorHex: "#2F6B4F",
|
||
isCount: false, value: 47 * 60, periodLabel: String(localized: "하루"),
|
||
isRunning: false, tickingBase: nil)
|
||
}
|
||
|
||
func snapshot(for configuration: ActionValueConfigIntent, in context: Context) async -> ActionValueEntry {
|
||
Self.makeEntry(configuration)
|
||
}
|
||
|
||
func timeline(for configuration: ActionValueConfigIntent, in context: Context) async -> Timeline<ActionValueEntry> {
|
||
Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60)))
|
||
}
|
||
|
||
/// 워치 페이스 갤러리에 노출할 추천 구성 — 옵트인 행동 전부 (상한 금지, §11 규칙).
|
||
/// 기간은 아이폰 설정이 결정하므로 행동당 1개만 나열한다.
|
||
/// 주의: description에 문자열 보간 금지 — Text(verbatim:)만 (GoalRate 쪽 주석 참고)
|
||
func recommendations() -> [AppIntentRecommendation<ActionValueConfigIntent>] {
|
||
let cached = (ComplicationStore.snapshot()?.actions ?? [])
|
||
.filter { $0.complicationPeriodRaw != nil }
|
||
// 스냅숏이 아직 없으면 자리 표시용 엔티티로 추천 (표시 시점엔 첫 옵트인 행동으로 대체됨)
|
||
if cached.isEmpty {
|
||
let intent = ActionValueConfigIntent()
|
||
intent.action = WatchActionEntity(id: UUID(), title: String(localized: "행동"))
|
||
return [AppIntentRecommendation(intent: intent, description: Text(verbatim: String(localized: "행동 기록")))]
|
||
}
|
||
return cached.map { action in
|
||
let period = action.complicationPeriodRaw.flatMap(WatchActionPeriod.init(rawValue:)) ?? .day
|
||
let intent = ActionValueConfigIntent()
|
||
intent.action = WatchActionEntity(id: action.id, title: action.name)
|
||
return AppIntentRecommendation(
|
||
intent: intent,
|
||
description: Text(verbatim: "\(action.name) · \(period.label)")
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 값 포맷 (위젯 확장 전용 — 워치 앱의 WatchFormat과 별개 타깃이라 여기 둔다)
|
||
|
||
nonisolated enum ActionValueFormat {
|
||
/// 원형용 압축 시간: 1시간 미만 "47분", 이상 "1:12" (숫자 표기라 언어 무관)
|
||
static func compactDuration(_ seconds: Double) -> String {
|
||
let total = Int(seconds.rounded())
|
||
let h = total / 3600
|
||
let m = (total % 3600) / 60
|
||
if h > 0 {
|
||
let mm = m < 10 ? "0\(m)" : "\(m)"
|
||
return "\(h):\(mm)"
|
||
}
|
||
return String(localized: "\(m)분")
|
||
}
|
||
|
||
/// 넉넉한 표기 (rectangular·inline): "1시간 12분" / "47분" / "0분"
|
||
static func duration(_ seconds: Double) -> String {
|
||
let total = Int(seconds.rounded())
|
||
let h = total / 3600
|
||
let m = (total % 3600) / 60
|
||
if h > 0 {
|
||
return m > 0
|
||
? String(localized: "\(h)시간 \(m)분")
|
||
: String(localized: "\(h)시간")
|
||
}
|
||
return String(localized: "\(m)분")
|
||
}
|
||
}
|
||
|
||
// MARK: - 뷰
|
||
|
||
struct ActionValueComplicationView: View {
|
||
@Environment(\.widgetFamily) private var envFamily
|
||
/// 워치 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용
|
||
var previewFamily: WidgetFamily? = nil
|
||
private var family: WidgetFamily { previewFamily ?? envFamily }
|
||
let entry: ActionValueEntry
|
||
|
||
private var staticValueText: String {
|
||
entry.isCount
|
||
? String(localized: "\(Int(entry.value))회")
|
||
: ActionValueFormat.duration(entry.value)
|
||
}
|
||
|
||
var body: some View {
|
||
Group {
|
||
if entry.locked {
|
||
ComplicationLockedView()
|
||
} else if entry.empty {
|
||
// 옵트인(기본 전부 꺼짐) — rectangular에서는 해결 방법을 함께 안내 (목표 쪽과 동일 문법)
|
||
ComplicationEmptyView(message: family == .accessoryRectangular
|
||
? String(localized: "표시할 행동이 없어요 — iPhone 행동 편집에서 '애플워치 컴플리케이션'을 켜면 나타나요")
|
||
: String(localized: "행동 없음"))
|
||
} else {
|
||
content
|
||
}
|
||
}
|
||
.containerBackground(.clear, for: .widget)
|
||
// 탭 = 워치 앱의 미니 행동 화면으로 착지 (자동 실행 없음)
|
||
.widgetURL(entry.actionID.flatMap { URL(string: "harudanim://watch-action/\($0.uuidString)") })
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var content: some View {
|
||
switch family {
|
||
case .accessoryInline:
|
||
if entry.isRunning, let base = entry.tickingBase {
|
||
Text("\(entry.name) \(Text(base, style: .timer))")
|
||
} else {
|
||
Text("\(entry.name) \(staticValueText)")
|
||
}
|
||
case .accessoryRectangular:
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
HStack(spacing: 4) {
|
||
Image(safeSymbol: entry.symbolName)
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(entry.isRunning ? .yellow : .primary)
|
||
Text(entry.name)
|
||
.font(.headline)
|
||
.lineLimit(1)
|
||
if entry.isRunning {
|
||
Image(systemName: "record.circle")
|
||
.font(.system(size: 9))
|
||
.foregroundStyle(.yellow)
|
||
}
|
||
}
|
||
if entry.isRunning, let base = entry.tickingBase {
|
||
Text(base, style: .timer)
|
||
.font(.system(.body, design: .rounded).monospacedDigit())
|
||
} else {
|
||
Text(staticValueText)
|
||
.font(.system(.body, design: .rounded).weight(.semibold).monospacedDigit())
|
||
}
|
||
Text(entry.periodLabel)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
case .accessoryCorner:
|
||
Image(safeSymbol: entry.symbolName)
|
||
.font(.system(size: 18, weight: .semibold))
|
||
.foregroundStyle(entry.isRunning ? .yellow : .primary)
|
||
.widgetLabel {
|
||
if entry.isRunning, let base = entry.tickingBase {
|
||
Text(base, style: .timer)
|
||
} else {
|
||
Text(verbatim: "\(entry.name) \(entry.isCount ? staticValueText : ActionValueFormat.compactDuration(entry.value))")
|
||
}
|
||
}
|
||
default: // accessoryCircular
|
||
VStack(spacing: 1) {
|
||
Image(safeSymbol: entry.symbolName)
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(entry.isRunning ? .yellow : .primary)
|
||
if entry.isRunning, let base = entry.tickingBase {
|
||
Text(base, style: .timer)
|
||
.font(.system(size: 11).monospacedDigit())
|
||
.multilineTextAlignment(.center)
|
||
.minimumScaleFactor(0.7)
|
||
} else if entry.isCount {
|
||
Text("\(Int(entry.value))")
|
||
.font(.system(size: 15, weight: .bold).monospacedDigit())
|
||
} else {
|
||
Text(ActionValueFormat.compactDuration(entry.value))
|
||
.font(.system(size: 11, weight: .semibold).monospacedDigit())
|
||
.minimumScaleFactor(0.7)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 위젯 정의
|
||
|
||
struct ActionValueComplication: Widget {
|
||
var body: some WidgetConfiguration {
|
||
AppIntentConfiguration(
|
||
kind: "HaruWatchActionValue",
|
||
intent: ActionValueConfigIntent.self,
|
||
provider: ActionValueProvider()
|
||
) { entry in
|
||
ActionValueComplicationView(entry: entry)
|
||
}
|
||
.configurationDisplayName("행동 기록")
|
||
.description("선택한 행동의 기간 누적 기록을 표시해요. 기간은 하루 다님 앱의 행동 편집에서 정해요. (프리미엄)")
|
||
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner])
|
||
}
|
||
}
|