mycode/myApp/HaruDanim/Widgets/NowRunningWidget.swift
songyc macbook 9c9c24f7df feat(a11y): VoiceOver labels for icon-only buttons + combined action-cell narration
접근성 감사 ③ — 전 화면의 아이콘 전용 버튼과 텍스트 없는 시각
요소에 보이스오버 라벨 부여 (시각 UI 변화 없음):

- 모음 탭: 행동 버튼 셀을 label(이름)+value(측정 중/오늘 누적)로
  한 덩어리 낭독 — 아이콘·타이머가 따로 읽히지 않음. 진행 중 행의
  정지 버튼 "측정 종료".
- 기록 탭: 날짜 이동 화살표(이전/다음 날짜), 내보내기, 기록 필터,
  필터 칩 해제 버튼. 타임테이블의 색 블록·횟수 점은 "행동 이름 +
  시각(구간)" verbatim 라벨로 낭독 가능하게.
- 통계 탭: 기간 이동 화살표(이전/다음 기간), 내보내기, 통계 필터,
  필터 해제.
- 행동/꼬리표/목표 탭: + 추가 버튼(행동/꼬리표/목표 추가), ⋯ 메뉴.
- 일기: 달 이동 화살표(이전/다음 달), 내보내기, ⋯ 메뉴, 기분 타일
  ("오늘 기분"), 할 일 삭제 버튼.
- ⑥ 현재 진행 중 위젯: 종료 버튼 "측정 종료".
- CLAUDE.md §15에 컨벤션 추가: 아이콘 전용 버튼 accessibilityLabel
  필수, 복합 셀은 label+value 한 덩어리, 텍스트 없는 시각 요소는
  verbatim 라벨.

신규 문구 9종 ko/en/ja 번역 + 위젯 카탈로그 '측정 종료' 번역 복사
(전 카탈로그 missing 0/stale 0). Debug·Store 빌드 성공, 접근성
수식어는 시각 렌더링에 영향 없음(스모크 런치 확인). 실기기에서
보이스오버 켜고 핵심 흐름(행동 시작/종료·날짜 이동) 낭독 확인 권장.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-14 22:24:21 +09:00

448 lines
18 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.

//
// NowRunningWidget.swift
// Haru_DanimWidgets
//
// (CLAUDE.md §10)
// , (Interactive).
// - :
// - : ( ' ' )
// + + +
// - : · 1 + "+N ",
//
//
import SwiftUI
import SwiftData
import WidgetKit
import AppIntents
// MARK: -
struct NowRunningConfigIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "현재 진행 중 위젯"
static let description = IntentDescription("측정 중인 행동을 실시간으로 보여 주고 바로 종료할 수 있어요. 테마를 선택하세요.")
@Parameter(title: "테마", default: .matchApp)
var theme: WidgetThemeOption
}
// MARK: -
///
struct RunningItemSnapshot: Identifiable {
/// Action.uuid (RunActionIntent)
let id: UUID
let name: String
let symbolName: String
let colorHex: String
let startAt: Date
var color: Color { Color(hex: colorHex) }
}
struct NowRunningEntry: TimelineEntry {
let date: Date
let locked: Bool
let theme: WidgetThemeOption
/// ( ' ' ). nil =
let primary: RunningItemSnapshot?
/// ( )
let others: [RunningItemSnapshot]
/// (now , )
let todayTickingBase: Date?
}
extension NowRunningEntry {
/// · ( 2 )
static func sample(theme: WidgetThemeOption = .matchApp) -> NowRunningEntry {
let now = Date.now
return NowRunningEntry(
date: now, locked: false, theme: theme,
primary: RunningItemSnapshot(id: UUID(), name: String(localized: "독서"),
symbolName: "book.fill", colorHex: "#2F6B4F",
startAt: now.addingTimeInterval(-45 * 60)),
others: [
RunningItemSnapshot(id: UUID(), name: String(localized: "달리기"),
symbolName: "figure.run", colorHex: "#9D5B4A",
startAt: now.addingTimeInterval(-12 * 60)),
],
todayTickingBase: now.addingTimeInterval(-65 * 60)
)
}
}
struct NowRunningProvider: AppIntentTimelineProvider {
@MainActor
static func makeEntry(_ configuration: NowRunningConfigIntent, now: Date = .now) -> NowRunningEntry {
IntentStore.refresh()
guard WidgetStore.isUnlocked else {
return NowRunningEntry(date: now, locked: true, theme: configuration.theme,
primary: nil, others: [], todayTickingBase: nil)
}
let descriptor = FetchDescriptor<TimeSession>(
predicate: #Predicate { $0.endAt == nil },
sortBy: [SortDescriptor(\.startAt, order: .forward)]
)
let running = ((try? IntentStore.context.fetch(descriptor)) ?? []).filter { $0.action != nil }
// (LiveActivityManager)
let mode = LiveActivityMode(
rawValue: AppGroup.defaults.string(forKey: SettingsKeys.liveActivityMode)
?? UserDefaults.standard.string(forKey: SettingsKeys.liveActivityMode) ?? ""
) ?? .latest
guard let session = (mode == .earliest ? running.first : running.last),
let action = session.action else {
return NowRunningEntry(date: now, locked: false, theme: configuration.theme,
primary: nil, others: [], todayTickingBase: nil)
}
let others: [RunningItemSnapshot] = running.filter { $0 !== session }.compactMap { other in
guard let otherAction = other.action else { return nil }
return RunningItemSnapshot(
id: otherAction.uuid, name: otherAction.name, symbolName: otherAction.symbolName,
colorHex: otherAction.sortedTags.first?.colorHex ?? "#2F6B4F", startAt: other.startAt
)
}
let math = DayMath()
let todaySeconds = Aggregator(math: math)
.seconds(for: action, in: math.dayRange(containing: now), now: now)
return NowRunningEntry(
date: now, locked: false, theme: configuration.theme,
primary: RunningItemSnapshot(
id: action.uuid, name: action.name, symbolName: action.symbolName,
colorHex: action.sortedTags.first?.colorHex ?? "#2F6B4F", startAt: session.startAt
),
others: others,
todayTickingBase: now.addingTimeInterval(-todaySeconds)
)
}
func placeholder(in context: Context) -> NowRunningEntry {
.sample()
}
@MainActor
func snapshot(for configuration: NowRunningConfigIntent, in context: Context) async -> NowRunningEntry {
let entry = Self.makeEntry(configuration)
//
if context.isPreview, entry.locked || entry.primary == nil {
return .sample(theme: configuration.theme)
}
return entry
}
@MainActor
func timeline(for configuration: NowRunningConfigIntent, in context: Context) async -> Timeline<NowRunningEntry> {
// Text(style:.timer) , /
// DataChange.commit/ ( )
let first = Self.makeEntry(configuration)
return WidgetRefresh.timeline(first: first, live: false) { _ in first }
}
}
// MARK: -
struct NowRunningWidgetView: View {
@Environment(\.widgetFamily) private var envFamily
/// DEBUG
var previewFamily: WidgetFamily? = nil
private var family: WidgetFamily { previewFamily ?? envFamily }
let entry: NowRunningEntry
var body: some View {
Group {
if entry.locked {
WidgetLockedView()
.padding(12)
} else if let primary = entry.primary {
switch family {
case .systemMedium:
NowRunningHeroView(primary: primary, extraCount: entry.others.count, fullBleed: true)
case .systemLarge:
NowRunningLargeView(primary: primary, others: entry.others,
todayTickingBase: entry.todayTickingBase)
default:
NowRunningSmallView(primary: primary, extraCount: entry.others.count)
}
} else {
emptyView
}
}
.widgetAppTheme(entry.theme)
}
///
private var emptyView: some View {
VStack(spacing: 6) {
Image(systemName: "timer")
.font(.title3)
.foregroundStyle(.secondary)
Text("지금 측정 중인 행동이 없어요")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if family != .systemSmall {
Text("행동을 시작하면 여기에 실시간으로 표시돼요")
.font(.caption2)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
}
.padding(12)
}
}
/// ( '' )
struct NowRunningStopButton: View {
@Environment(\.widgetRenderingMode) private var renderingMode
let actionID: UUID
var size: CGFloat = 32
var body: some View {
Button(intent: RunActionIntent(actionID: actionID.uuidString)) {
Image(systemName: "stop.fill")
.font(.system(size: size * 0.38, weight: .bold))
.foregroundStyle(renderingMode == .fullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.frame(width: size, height: size)
.background(
renderingMode == .fullColor
? AnyShapeStyle(AppTheme.yellow)
: AnyShapeStyle(Color.white.opacity(0.14)),
in: Circle()
)
// ' ' ( )
.invalidatableContent()
// (§10 )
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("측정 종료"))
}
}
/// : ()
struct NowRunningSmallView: View {
@Environment(\.widgetRenderingMode) private var renderingMode
let primary: RunningItemSnapshot
let extraCount: Int
private var isFullColor: Bool { renderingMode == .fullColor }
var body: some View {
VStack(alignment: .leading, spacing: 3) {
HStack {
Image(systemName: primary.symbolName)
.font(.system(size: 18, weight: .semibold))
Spacer()
Image(systemName: "record.circle")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(isFullColor ? AnyShapeStyle(AppTheme.yellow) : AnyShapeStyle(.primary))
}
Spacer(minLength: 0)
Text(primary.name)
.font(.caption.weight(.semibold))
.lineLimit(1)
HStack(alignment: .bottom) {
VStack(alignment: .leading, spacing: 1) {
Text(primary.startAt, style: .timer)
.font(.title3.weight(.bold).monospacedDigit())
.lineLimit(1)
.minimumScaleFactor(0.6)
.invalidatableContent()
if extraCount > 0 {
Text("+\(extraCount)개 함께 추적 중")
.font(.system(size: 9))
.opacity(0.85)
.lineLimit(1)
}
}
Spacer(minLength: 4)
NowRunningStopButton(actionID: primary.id, size: 30)
}
}
.foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.padding(14)
.background(WidgetTintedCellBackground(
color: isFullColor ? primary.color : primary.color.opacity(0.2),
fullBleed: true
))
}
}
/// (fullBleed),
struct NowRunningHeroView: View {
@Environment(\.widgetRenderingMode) private var renderingMode
let primary: RunningItemSnapshot
/// "+N " ( 0)
var extraCount = 0
var fullBleed = false
private var isFullColor: Bool { renderingMode == .fullColor }
var body: some View {
HStack(spacing: 12) {
Image(systemName: primary.symbolName)
.font(.system(size: 22, weight: .semibold))
.frame(width: 46, height: 46)
.background(
Color.white.opacity(isFullColor ? 0.22 : 0.14),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
Text(primary.name)
.font(.headline)
.lineLimit(1)
Image(systemName: "record.circle")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(isFullColor ? AnyShapeStyle(AppTheme.yellow) : AnyShapeStyle(.primary))
}
Text("\(primary.startAt, format: .dateTime.hour().minute()) 시작")
.font(.caption)
.opacity(0.85)
if extraCount > 0 {
Text("+\(extraCount)개 함께 추적 중")
.font(.caption2)
.opacity(0.85)
}
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 8) {
Text(primary.startAt, style: .timer)
.font(.title2.weight(.bold).monospacedDigit())
.multilineTextAlignment(.trailing)
.frame(maxWidth: 105, alignment: .trailing)
.lineLimit(1)
.minimumScaleFactor(0.6)
.invalidatableContent()
NowRunningStopButton(actionID: primary.id, size: 32)
}
}
.foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.padding(16)
.background(WidgetTintedCellBackground(
color: isFullColor ? primary.color : primary.color.opacity(0.2),
fullBleed: fullBleed
))
}
}
/// : + [ ( )] [ · ]
struct NowRunningLargeView: View {
let primary: RunningItemSnapshot
let others: [RunningItemSnapshot]
let todayTickingBase: Date?
/// ( " N")
private static let maxRows = 4
var body: some View {
VStack(spacing: 8) {
NowRunningHeroView(primary: primary)
if others.isEmpty {
todayCard
} else {
othersList
}
}
.padding(10)
}
/// ( )
private var othersList: some View {
VStack(spacing: 6) {
ForEach(Array(others.prefix(Self.maxRows).enumerated()), id: \.offset) { _, item in
NowRunningRowView(item: item)
}
if others.count > Self.maxRows {
Text("\(others.count - Self.maxRows)개 측정 중")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
/// : ()
private var todayCard: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("오늘 누적")
.font(.caption)
.foregroundStyle(.secondary)
if let base = todayTickingBase {
Text(base, style: .timer)
.font(.headline.monospacedDigit())
.invalidatableContent()
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("시작 시각")
.font(.caption)
.foregroundStyle(.secondary)
Text(primary.startAt, format: .dateTime.hour().minute())
.font(.headline.monospacedDigit())
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(WidgetCellBackground())
}
}
/// ' ' +
struct NowRunningRowView: View {
@Environment(\.widgetRenderingMode) private var renderingMode
let item: RunningItemSnapshot
private var isFullColor: Bool { renderingMode == .fullColor }
var body: some View {
HStack(spacing: 10) {
Image(systemName: item.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.frame(width: 30, height: 30)
.background(
isFullColor ? AnyShapeStyle(item.color) : AnyShapeStyle(item.color.opacity(0.2)),
in: RoundedRectangle(cornerRadius: 9, style: .continuous)
)
Text(item.name)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Spacer(minLength: 6)
Text(item.startAt, style: .timer)
.font(.subheadline.weight(.semibold).monospacedDigit())
.multilineTextAlignment(.trailing)
.frame(maxWidth: 80, alignment: .trailing)
.lineLimit(1)
.minimumScaleFactor(0.6)
.invalidatableContent()
NowRunningStopButton(actionID: item.id, size: 28)
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(WidgetCellBackground())
}
}
// MARK: -
struct NowRunningWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "HaruNowRunningWidget",
intent: NowRunningConfigIntent.self,
provider: NowRunningProvider()
) { entry in
NowRunningWidgetView(entry: entry)
}
.configurationDisplayName("현재 진행 중")
.description("측정 중인 행동을 실시간으로 보고 위젯에서 바로 종료해요. (프리미엄)")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
.contentMarginsDisabled()
}
}