mycode/myApp/HaruDanim/Haru_DanimWatchWidgets/ActionValueComplication.swift
songyc macbook fb1b8e8607 fix(1.5): ASC 검증 ITMS-90626 — 인텐트 메타데이터에서 'iPhone' 제거, 빌드 3
- 앱 인텐트 설명·위젯 설명의 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
2026-08-22 08:34:49 +09:00

299 lines
13 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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])
}
}