mycode/myApp/HaruDanim/IOS/Views/ActionViews.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

525 lines
20 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.

//
// ActionViews.swift
// Haru_Danim
//
// : + + / (CLAUDE.md §6.2)
//
import SwiftUI
import SwiftData
// MARK: - ( )
struct ActionListView: View {
@Environment(\.modelContext) private var context
@Query(sort: [SortDescriptor(\Tag.sortOrder), SortDescriptor(\Tag.createdAt)]) private var tags: [Tag]
@Query(sort: \Action.createdAt) private var allActions: [Action]
private let premium = PremiumManager.shared
@State private var showingAdd = false
@State private var showLimitAlert = false
@State private var showingPremiumSheet = false
@State private var showingTagOrder = false
// (LocalPrefs )
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
private var actions: [Action] {
LocalPrefs.orderedActions(allActions, raw: actionOrderRaw)
}
private var favoriteActions: [Action] {
actions.filter(\.isFavorite)
}
private var untaggedActions: [Action] {
actions.filter { $0.tags.isEmpty && !$0.isFavorite }
}
var body: some View {
List {
if !favoriteActions.isEmpty {
Section {
ForEach(favoriteActions) { action in
actionLink(action)
}
} header: {
Label("즐겨찾기", systemImage: "star.fill")
.foregroundStyle(AppTheme.yellow)
}
}
ForEach(tags) { tag in
let group = actions.filter { $0.tags.contains(tag) && !$0.isFavorite }
if !group.isEmpty {
Section {
ForEach(group) { action in
actionLink(action)
}
} header: {
HStack(spacing: 6) {
Circle().fill(tag.color).frame(width: 10, height: 10)
Text(tag.name)
}
}
}
}
if !untaggedActions.isEmpty {
Section("꼬리표 없음") {
ForEach(untaggedActions) { action in
actionLink(action)
}
}
}
if actions.isEmpty {
ContentUnavailableView(
"등록된 행동이 없어요",
systemImage: "figure.walk",
description: Text("오른쪽 위 + 버튼으로 행동을 추가하세요.")
)
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("행동")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
showingTagOrder = true
} label: {
Label("꼬리표 순서 변경", systemImage: "arrow.up.arrow.down")
}
} label: {
Image(systemName: "ellipsis.circle")
}
.accessibilityLabel(Text("메뉴"))
}
ToolbarItem(placement: .topBarTrailing) {
Button {
if !premium.canAddAction(currentCount: actions.count) {
showLimitAlert = true
} else {
showingAdd = true
}
} label: {
Image(systemName: "plus")
}
.accessibilityLabel(Text("행동 추가"))
}
}
.sheet(isPresented: $showingAdd) {
ActionEditorView(action: nil)
}
.sheet(isPresented: $showingTagOrder) {
TagOrderSheet()
}
.alert("무료 사용 한도", isPresented: $showLimitAlert) {
Button("프리미엄 알아보기") { showingPremiumSheet = true }
Button("확인", role: .cancel) {}
} message: {
Text("무료 버전에서는 행동을 최대 \(FreeLimits.actions)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
}
.sheet(isPresented: $showingPremiumSheet) {
PremiumSheetView()
}
}
private func actionLink(_ action: Action) -> some View {
NavigationLink {
ActionDetailView(action: action)
} label: {
ActionRow(action: action)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
action.isFavorite.toggle()
DataChange.commit(context: context)
} label: {
Label(
action.isFavorite ? String(localized: "즐겨찾기 해제") : String(localized: "즐겨찾기"),
systemImage: action.isFavorite ? "star.slash" : "star.fill"
)
}
.tint(AppTheme.yellow)
}
}
}
struct ActionRow: View {
let action: Action
var body: some View {
HStack(spacing: 12) {
Image(systemName: action.symbolName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
.background(action.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(action.name)
.font(.body.weight(.medium))
if action.isFavorite {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(AppTheme.yellow)
}
}
Text(action.trackingType.label)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
// MARK: - ( )
struct TagOrderSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var context
@Query(sort: [SortDescriptor(\Tag.sortOrder), SortDescriptor(\Tag.createdAt)]) private var tags: [Tag]
///
/// ·
@State private var orderChanged = false
var body: some View {
NavigationStack {
List {
ForEach(tags) { tag in
HStack(spacing: 10) {
Circle().fill(tag.color).frame(width: 14, height: 14)
Text(tag.name)
}
}
.onMove { source, destination in
var ordered = tags
ordered.move(fromOffsets: source, toOffset: destination)
for (index, tag) in ordered.enumerated() {
tag.sortOrder = index
}
orderChanged = true
}
}
.environment(\.editMode, .constant(.active))
.navigationTitle("꼬리표 순서")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("완료") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
.onDisappear {
//
// ('' )
if orderChanged { DataChange.commit(context: context) }
}
}
}
// MARK: -
struct ActionDetailView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let action: Action
@State private var showingEdit = false
@State private var showingDelete = false
var body: some View {
List {
Section("기본 정보") {
HStack {
Text("이름")
Spacer()
Text(action.name).foregroundStyle(.secondary)
}
// ( )
Toggle(isOn: Binding(
get: { action.isFavorite },
set: { action.isFavorite = $0; DataChange.commit(context: context) }
)) {
Label {
Text("즐겨찾기")
} icon: {
Image(systemName: "star.fill")
.foregroundStyle(AppTheme.yellow)
}
}
// (CloudKit , LocalPrefs )
Toggle(isOn: Binding(
get: { LocalPrefs.promptsForNote(action.uuid) },
set: { LocalPrefs.setPromptsForNote(action.uuid, enabled: $0) }
)) {
Label {
Text("기록 시 메모 창 표시")
} icon: {
Image(systemName: "note.text")
.foregroundStyle(AppTheme.green)
}
}
HStack {
Text("아이콘")
Spacer()
Image(systemName: action.symbolName)
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(action.color, in: RoundedRectangle(cornerRadius: 8))
}
HStack {
Text("추적 방식")
Spacer()
Label(action.trackingType.label, systemImage: action.trackingType.symbol)
.foregroundStyle(.secondary)
}
}
Section("꼬리표") {
if action.tags.isEmpty {
Text("지정된 꼬리표 없음").foregroundStyle(.secondary)
} else {
ForEach(action.sortedTags) { tag in
HStack(spacing: 8) {
Circle().fill(tag.color).frame(width: 10, height: 10)
Text(tag.name)
}
}
}
}
Section("누적 기록") {
statsRows
}
Section {
Button("행동 삭제", role: .destructive) {
showingDelete = true
}
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle(action.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("수정") { showingEdit = true }
}
}
.sheet(isPresented: $showingEdit) {
ActionEditorView(action: action)
}
.alert("행동 삭제", isPresented: $showingDelete) {
Button("삭제", role: .destructive) {
context.delete(action)
DataChange.commit(context: context)
dismiss()
}
Button("취소", role: .cancel) {}
} message: {
Text("\(action.name) 행동을 삭제할까요? 기록도 함께 삭제됩니다.")
}
}
@ViewBuilder
private var statsRows: some View {
let agg = Aggregator()
let math = agg.math
let now = Date.now
switch action.trackingType {
case .time:
statRow(String(localized: "오늘"), Format.durationShort(agg.seconds(for: action, in: math.dayRange(containing: now))))
statRow(String(localized: "이번 주"), Format.durationShort(agg.seconds(for: action, in: math.weekRange(containing: now))))
statRow(String(localized: "이번 달"), Format.durationShort(agg.seconds(for: action, in: math.monthRange(containing: now))))
case .count:
statRow(String(localized: "오늘"), String(localized: "\(agg.count(for: action, in: math.dayRange(containing: now)))"))
statRow(String(localized: "이번 주"), String(localized: "\(agg.count(for: action, in: math.weekRange(containing: now)))"))
statRow(String(localized: "이번 달"), String(localized: "\(agg.count(for: action, in: math.monthRange(containing: now)))"))
}
}
private func statRow(_ title: String, _ value: String) -> some View {
HStack {
Text(title)
Spacer()
Text(value).foregroundStyle(.secondary)
}
}
}
// MARK: - /
struct ActionEditorView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Query(sort: \Tag.createdAt) private var allTags: [Tag]
@Query private var allActions: [Action]
/// nil
let action: Action?
@State private var name = ""
@State private var symbolName = "star.fill"
@State private var trackingType: TrackingType = .time
@State private var promptsForNote = false
@State private var selectedTags: Set<PersistentIdentifier> = []
@State private var showingSymbolPicker = false
@State private var showingNewTag = false
var body: some View {
NavigationStack {
Form {
Section("이름") {
TextField("행동 이름 (예: 독서)", text: $name)
}
Section("아이콘") {
Button {
showingSymbolPicker = true
} label: {
HStack {
Image(systemName: symbolName)
.font(.system(size: 20))
.foregroundStyle(AppTheme.green)
.frame(width: 36, height: 36)
Text("아이콘 선택")
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
Section {
ForEach(allTags) { tag in
Button {
toggleTag(tag)
} label: {
HStack(spacing: 8) {
Circle().fill(tag.color).frame(width: 12, height: 12)
Text(tag.name)
.foregroundStyle(.primary)
Spacer()
if selectedTags.contains(tag.persistentModelID) {
Image(systemName: "checkmark")
.foregroundStyle(AppTheme.green)
.fontWeight(.semibold)
}
}
.contentShape(Rectangle())
}
}
Button {
showingNewTag = true
} label: {
Label("새 꼬리표 만들기", systemImage: "plus.circle")
.foregroundStyle(AppTheme.green)
}
} header: {
Text("꼬리표 (복수 선택 가능)")
} footer: {
Text("행동 버튼의 색은 지정한 꼬리표의 색을 따라요.")
}
Section {
Picker("추적 방식", selection: $trackingType) {
ForEach(TrackingType.allCases) { type in
Label(type.label, systemImage: type.symbol).tag(type)
}
}
.pickerStyle(.inline)
.labelsHidden()
// ·
// ( )
.disabled(action != nil)
} header: {
Text("추적 방식")
} footer: {
if action != nil {
Text("추적 방식은 행동을 만들 때만 선택할 수 있어요.")
}
}
Section {
Toggle(isOn: $promptsForNote) {
Label {
Text("기록 시 메모 창 표시")
} icon: {
Image(systemName: "note.text")
.foregroundStyle(AppTheme.green)
}
}
} footer: {
Text(trackingType == .time
? String(localized: "켜면 시간 측정을 종료할 때 메모 창이 떠요. 건너뛸 수 있어요.")
: String(localized: "켜면 횟수를 추가할 때마다 메모 창이 떠요. 건너뛸 수 있어요."))
}
}
.navigationTitle(action == nil ? String(localized: "행동 추가") : String(localized: "행동 수정"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") { save() }
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.sheet(isPresented: $showingSymbolPicker) {
SymbolPickerView(selection: $symbolName)
}
.sheet(isPresented: $showingNewTag) {
TagEditorView(tag: nil)
}
.onAppear(perform: load)
}
}
private func load() {
guard let action else { return }
name = action.name
symbolName = action.symbolName
trackingType = action.trackingType
promptsForNote = LocalPrefs.promptsForNote(action.uuid)
selectedTags = Set(action.tags.map(\.persistentModelID))
}
private func toggleTag(_ tag: Tag) {
if selectedTags.contains(tag.persistentModelID) {
selectedTags.remove(tag.persistentModelID)
} else {
selectedTags.insert(tag.persistentModelID)
}
}
private func save() {
let tags = allTags.filter { selectedTags.contains($0.persistentModelID) }
let trimmed = name.trimmingCharacters(in: .whitespaces)
if let action {
action.name = trimmed
action.symbolName = symbolName
action.trackingType = trackingType
action.tags = tags
LocalPrefs.setPromptsForNote(action.uuid, enabled: promptsForNote)
} else {
// sortOrder ( · )
let maxOrder = allActions.map(\.sortOrder).max() ?? -1
let newAction = Action(
name: trimmed,
symbolName: symbolName,
trackingType: trackingType,
sortOrder: maxOrder + 1
)
newAction.tags = tags
context.insert(newAction)
// +
LocalPrefs.appendActionToOrder(newAction.uuid)
LocalPrefs.setPromptsForNote(newAction.uuid, enabled: promptsForNote)
}
DataChange.commit(context: context)
dismiss()
}
}