feat(main): add favorites quick-access row to Home tab

행동이 많아질수록 모음 탭이 복잡해진다는 실사용 피드백에 대한 1차 대응.
22개 시드로 iPhone·iPad·맥 4가지 레이아웃을 직접 시뮬레이션해 대안 A~F를
검토했고, 가장 저위험인 A(즐겨찾기 재사용)만 이번에 구현했다.

- Action.isFavorite(행동 탭과 동일 필드)를 켠 행동을 모음 탭 상단에
  가로 스크롤 한 줄로 노출 (전체 그리드에서는 제외하지 않음 — 숨기는
  방식은 검토 후 기각)
- 롱프레스 메뉴에 즐겨찾기 지정·해제 버튼 추가, 전체 그리드·즐겨찾기
  줄이 메뉴 구성을 공유하도록 리팩터
- 도움말에 새 주제 추가 + 관련 en/ja 번역, CLAUDE.md §6.1 문서화
- 감사 겸 아이콘 전용 버튼 접근성 라벨 누락 3건 수정(일기 할 일 체크박스,
  아이콘 선택기, 꼬리표 색 프리셋) — §15 컨벤션 위반이라 저위험 즉시 수정

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
This commit is contained in:
songyc macbook 2026-07-21 17:51:53 +09:00
parent dbdf4e32df
commit 7d17917609
7 changed files with 971 additions and 830 deletions

View File

@ -2,7 +2,7 @@
> **한 줄 소개**: 하루의 습관과 시간을 추적하는 iOS 앱. "행동"의 **시간을 측정**하거나 **횟수를 기록**하고, 이를 바탕으로 **목표(Goal)와 다짐(Quest)** 의 달성률을 관리한다. 프리미엄(StoreKit 2)으로 위젯·워치·iCloud 동기화·iPad 일기를 제공한다.
>
> 문서 버전: v2.2 (2026-07-17) — **실제 구현 코드를 기준으로 작성된 현행 명세**. 새 세션에서 이 문서만 읽어도 앱 전체를 파악할 수 있도록 유지한다. 기능이 바뀌면 이 문서도 함께 갱신할 것.
> 문서 버전: v2.3 (2026-07-21) — **실제 구현 코드를 기준으로 작성된 현행 명세**. 새 세션에서 이 문서만 읽어도 앱 전체를 파악할 수 있도록 유지한다. 기능이 바뀌면 이 문서도 함께 갱신할 것.
---
@ -16,7 +16,7 @@
| 번들 ID | `com.yechan.HaruDanim` / App Group `group.com.yechan.HaruDanim` / CloudKit `iCloud.com.yechan.HaruDanim` |
| 수익 모델 | 부분 유료화. 무료 한도(행동 10·목표 2·목표당 다짐 3) + 프리미엄 결제 시 해제 |
| 결제 상품 | `com.yechan.HaruDanim.premium.monthly`(월 5,000원) / `.yearly`(년 50,000원) / `.lifetime`(80,000원) — 임시 가격, App Store Connect 등록은 아직 안 됨 |
| 현재 상태 (2026-07-17) | **기능 동결(release freeze)** — 첫 배포 후보(RC)는 커밋 `f05e611`(버전 1.0 (1)). 이후 실사용 보고 기반 수정 라운드가 진행됨(전부 커밋·푸시·실기기 확인 완료): l10n 누락 일괄·ja 앱 이름 'Haru Danim' 통일·워치 컴플리케이션 추천 캡 제거(§11)·⑥ 위젯 종료 버튼(§10)·진행률 환산 캡+수행일 집계+수행일 아님 표기(§4.2)·파일명/CSV 헤더 현지화(§7)·워치 컴플리케이션 목표 필터(§6.4·§11, 사용자 승인 소기능)·기록 시각 입력 상시 휠 다이얼+분 절사(§6.5)·일기 달력 기분 사진 백레이어(§6.7)·다짐 없는 목표 '다짐 없음' 표기(§4.3)·워치 측정 중 행 탭 종료(§11)·도움말 예시 스크린샷 18장(§6.8)·설정 하루 시작 시간 접힘 행(§6.5). **App Store 등록 자료는 `Marketing/`에 완비**(설명·키워드·구독 메타·스크린샷 원본/합성판, README에 재생성 절차 — 앱 변경이 자료에 영향 주면 요청 없이 함께 갱신할 것). **사업자 등록 완료, 통신판매업 신고 처리 대기** — 처리되면 ASC 등록·유료 계약 진행(순서는 Marketing/README 체크리스트). 사용자는 계속 실사용 테스트 중이며 버그 보고 시: 최소 변경 + 시뮬레이터 재검증 + l10n 루틴(§2.3) + Debug/Store(워치 파일이면 워치까지) 빌드 + 커밋·푸시 |
| 현재 상태 (2026-07-21) | **기능 동결(release freeze)** — 첫 배포 후보(RC)는 커밋 `f05e611`(버전 1.0 (1)). 이후 실사용 보고 기반 수정 라운드가 진행됨(전부 커밋·푸시·실기기 확인 완료): l10n 누락 일괄·ja 앱 이름 'Haru Danim' 통일·워치 컴플리케이션 추천 캡 제거(§11)·⑥ 위젯 종료 버튼(§10)·진행률 환산 캡+수행일 집계+수행일 아님 표기(§4.2)·파일명/CSV 헤더 현지화(§7)·워치 컴플리케이션 목표 필터(§6.4·§11, 사용자 승인 소기능)·기록 시각 입력 상시 휠 다이얼+분 절사(§6.5)·일기 달력 기분 사진 백레이어(§6.7)·다짐 없는 목표 '다짐 없음' 표기(§4.3)·워치 측정 중 행 탭 종료(§11)·도움말 예시 스크린샷 18장(§6.8)·설정 하루 시작 시간 접힘 행(§6.5)·**모음 탭 즐겨찾기 빠른 실행 영역 추가**(§6.1, 행동 많아질 때 혼잡도 문제의 1차 대응 — 사용자 승인 소기능, 시뮬레이션 기반 대안 A~F 중 A만 채택). **App Store 등록 자료는 `Marketing/`에 완비**(설명·키워드·구독 메타·스크린샷 원본/합성판, README에 재생성 절차 — 앱 변경이 자료에 영향 주면 요청 없이 함께 갱신할 것). **사업자 등록 완료, 통신판매업 신고 처리 대기** — 처리되면 ASC 등록·유료 계약 진행(순서는 Marketing/README 체크리스트). 사용자는 계속 실사용 테스트 중이며 버그 보고 시: 최소 변경 + 시뮬레이터 재검증 + l10n 루틴(§2.3) + Debug/Store(워치 파일이면 워치까지) 빌드 + 커밋·푸시 |
| 남은 배포 작업 | App Store Connect 상품 3종 등록(구독 그룹 + 비소모성, 유료 앱 계약), **CloudKit 프로덕션 스키마 배포**(대시보드 Deploy Schema to Production — DiaryTemplate 등 새 레코드 타입/필드는 개발 환경에만 자동 생성되므로 출시 전 필수), 실기기 검증(펜슬 필기감·캘린더 권한), App Store 배포. 코드 측 잔여 없음 — `ITSAppUsesNonExemptEncryption=NO` 등록 완료, **개인정보 매니페스트(PrivacyInfo.xcprivacy) 4개 번들 완비**(UserDefaults CA92.1·1C8F.1, 수집·트래킹 없음 — 위젯·워치위젯 폴더 것은 앱·워치 앱 타깃에서 membershipException으로 중복 복사 제외), 결제 화면 약관 링크 완료(개인정보처리방침 `Docs/PRIVACY.md` ko/en/ja는 `github.com/notalentprogrammer/HaruDanim` main의 PRIVACY.md로 게시됨, 이용약관은 Apple 표준 EULA URL). ASC 등록 시 앱 설명에도 두 링크 기재 + '앱 개인정보 보호'는 "데이터 수집 안 함" 선택 |
---
@ -175,9 +175,11 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
### 6.1 모음 탭 (`MainView`)
- 상단: 노출 목표 진행 현황 카드(1개=단독, 2개+=스냅 페이징 가로 스크롤 + 페이지 점, iPad=그리드). 카드 스타일 설정: 다짐별 각각(perQuest, 접힘 시 3개+더보기) / 전체 합산(combined)
- "현재 진행 중" 영역: 측정 중 세션 나열 + 실시간 타이머 + 종료 버튼
- **즐겨찾기 빠른 실행 영역**(`favoritesArea`, `runningArea` 아래·전체 그리드 위, `!favoriteActions.isEmpty && !isEditing`일 때만): `Action.isFavorite`(행동 탭과 동일 필드, §6.2) 켠 행동을 기기별 배치 순서(`orderedActions` 기준) 그대로 가로 스크롤 한 줄로 노출 — 행동이 많아져도 즐겨찾기는 스크롤 없이 바로 접근 가능하게 하는 목적(2026-07-21, 실사용 크라우딩 피드백 대응, §15 하단 이력 참고). **추가만 하고 전체 그리드에서 제외하지 않음**(즐겨찾기 아닌 행동을 찾기 더 어려워지는 트레이드오프 방지가 의도된 설계 — 그리드에서 숨기는 방식은 검토 후 기각됨). 셀은 `ActionButtonCell(compact: true)` 고정 폭(108pt) 재사용, 탭 동작(`handleTap`)과 롱프레스 메뉴(`actionContextMenu(for:)` — 기록 확인/통계 보기/기록 직접 입력·수정/**즐겨찾기 지정·해제**/행동 설정 수정/삭제)는 전체 그리드 셀과 완전히 공유. 즐겨찾기 지정·해제는 이 메뉴 또는 행동 탭에서 가능(§6.2)
- 행동 버튼 그리드: 꼬리표 색 그라데이션 배경 + 아이콘 + 이름 + 오늘 누적(시간형) 또는 오늘 횟수(횟수형, 숫자 전환 애니메이션). 측정 중이면 노란 테두리 + record 점. 탭 = 시작/종료 토글 또는 +1 (스프링 눌림 + 햅틱)
- 한 줄 개수: iPhone 2~6개 설정(5개 이상은 compact 셀), iPad는 화면 폭 자동 채움 — **iPad의 목표 카드·행동 그리드는 LazyVGrid가 아니라 비-lazy `AdaptiveColumnsLayout`**(MainView 내 커스텀 Layout, LazyVGrid(.adaptive)와 같은 열 계산). lazy 높이 추정이 높이 제각각인 목표 카드 + 1초 타이머 갱신과 결합해 맨 아래 스크롤 시 화면이 무한 진동(터치 불능)하는 실기기 버그의 수정 — iPhone(고정 열·페이징 높이 고정)은 LazyVGrid 유지. iPad 모음 그리드를 다시 lazy로 되돌리지 말 것. **행동 셀 높이 통일 규칙**: 시간형·횟수형 푸터 높이가 달라(횟수형 27pt 숫자) 셀 자연 높이가 달랐음 — 푸터에 숫자 크기의 hidden 템플릿(Text "0")을 깔아 두 유형의 자연 높이를 맞추고, AdaptiveColumnsLayout.place가 LazyVGrid처럼 행 높이를 제안해 행 안 셀도 균일(측정 패스는 불변이라 진동 버그와 무관, 고정 높이 목표 카드 무영향)
- 롱프레스 메뉴: 기록 확인(기록 탭 필터 이동) / 통계 보기 / 기록 직접 입력·수정 / 행동 설정 수정 / 삭제
- 롱프레스 메뉴: 기록 확인(기록 탭 필터 이동) / 통계 보기 / 기록 직접 입력·수정 / **즐겨찾기 지정·해제** / 행동 설정 수정 / 삭제
- **행동이 많아질 때의 혼잡도 문제**(2026-07-21 실사용 피드백): 22개 시드로 iPhone(3열·6열)·iPad·맥 4가지를 직접 시뮬레이션해 6가지 대안(A~F)을 검토 — 이번에 구현한 즐겨찾기 빠른 실행(A)에 더해, 향후 후보로 ⓑ꼬리표별 접기(출시 후), ⓒ검색/필터(출시 후), ⓓ한 줄 개수 늘릴 때 경고 문구(미착수, 저위험)가 있음. **명시적으로 기각**: 즐겨찾기 등을 전체 그리드에서 숨기는 옵트인 방식(행동이 눌러도 안 보이게 될 위험 > 이득) — 다시 제안하지 말 것
- 배치 편집: 지글 애니메이션 + 드래그 재정렬 → `local.actionOrder` 저장 (CloudKit 모델에 쓰지 않음)
- "짧은 기록 무시"(minSessionSeconds): 설정보다 짧은 세션은 종료 시 삭제. 메모 창 옵션 켠 행동은 종료/+1 시 메모 시트(건너뛰기 가능)
- 툴바: 새로고침(좌) / 배치 편집(우)

File diff suppressed because it is too large Load Diff

View File

@ -1965,6 +1965,7 @@ private struct DiaryTodoRow: View {
}
.buttonStyle(.plain)
.disabled(readOnly)
.accessibilityLabel(todo.isDone ? String(localized: "완료 표시 해제") : String(localized: "완료로 표시"))
TextField("할 일", text: $todo.text)
.font(.subheadline)

View File

@ -186,10 +186,15 @@ struct HelpView: View {
title: "짧은 기록 무시",
body: "설정 → 측정에서 '짧은 기록 무시'를 켜면 설정한 시간보다 짧게 측정하고 끝낸 기록은 저장하지 않아요. 행동 버튼을 실수로 눌렀다가 바로 껐을 때 의미 없는 기록이 쌓이는 것을 막아 줘요. 기준 시간은 3초부터 1분까지 고를 수 있고, 위젯·워치·시리로 종료할 때도 적용돼요. 이 설정은 기기마다 따로 저장되고 측정을 끝낸 기기의 설정이 적용되니, 아이폰과 아이패드를 함께 쓴다면 두 기기 모두 설정해 주세요. 애플워치에서 끝낸 측정은 페어링된 아이폰의 설정을 따라요."
),
HelpTopic(
symbol: "star.fill",
title: "즐겨찾기 빠른 실행",
body: "행동을 즐겨찾기로 지정하면 모음 탭 상단에 '즐겨찾기' 줄이 생겨 가로로 훑어 바로 실행할 수 있어요. 행동이 많아져 아래 전체 목록을 찾기 번거로울 때 자주 쓰는 행동만 모아 두는 용도예요 — 즐겨찾기해도 전체 목록에서는 사라지지 않아요. 지정은 행동 버튼을 길게 눌러 '즐겨찾기'를 선택하거나, 행동 탭에서 행동을 열어 별 아이콘을 눌러도 돼요."
),
HelpTopic(
symbol: "hand.tap.fill",
title: "길게 누르기 메뉴",
body: "행동 버튼을 길게 누르면 기록 직접 입력·수정, 메모, 기록 확인, 통계 보기, 행동 설정 수정, 삭제를 할 수 있어요. 시간형은 시작·종료 시각을, 횟수형은 횟수와 시각을 직접 고칠 수 있어요."
body: "행동 버튼을 길게 누르면 기록 직접 입력·수정, 즐겨찾기 지정·해제, 기록 확인, 통계 보기, 행동 설정 수정, 삭제를 할 수 있어요. 시간형은 시작·종료 시각을, 횟수형은 횟수와 시각을 직접 고칠 수 있어요."
),
HelpTopic(
symbol: "note.text",

View File

@ -25,6 +25,13 @@ struct MainView: View {
LocalPrefs.orderedActions(actions, raw: actionOrderRaw)
}
/// ( ).
///
/// ( , CLAUDE.md §6.1)
private var favoriteActions: [Action] {
orderedActions.filter(\.isFavorite)
}
private var pinnedGoals: [Goal] {
allGoals.filter { LocalPrefs.contains($0.uuid, in: pinnedGoalsRaw) }
}
@ -63,6 +70,9 @@ struct MainView: View {
if anyRunning && !isEditing {
runningArea
}
if !favoriteActions.isEmpty && !isEditing {
favoritesArea
}
if isEditing {
layoutControl
}
@ -241,6 +251,31 @@ struct MainView: View {
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
// MARK: ( )
private var favoritesArea: some View {
VStack(alignment: .leading, spacing: 8) {
Label("즐겨찾기", systemImage: "star.fill")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(favoriteActions) { action in
ActionButtonCell(action: action, isEditing: false, compact: true) {
handleTap(action)
}
.frame(width: 108)
.contextMenu {
actionContextMenu(for: action)
}
}
}
}
}
.padding(14)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
// MARK: ( )
private var layoutControl: some View {
@ -329,46 +364,61 @@ struct MainView: View {
} else {
base
.contextMenu {
Button {
//
router.openHistory(
filtering: action.persistentModelID,
visibleTabsRaw: visibleTabsRaw
)
} label: {
Label("기록 확인", systemImage: "list.bullet.rectangle")
}
Button {
//
router.openStats(
filtering: action.persistentModelID,
visibleTabsRaw: visibleTabsRaw
)
} label: {
Label("통계 보기", systemImage: "chart.xyaxis.line")
}
Button {
recordsAction = action
} label: {
Label(
action.trackingType == .time ? String(localized: "시작·종료 시각 수동 입력") : String(localized: "횟수 직접 입력·수정"),
systemImage: "square.and.pencil"
)
}
Button {
settingsEditingAction = action
} label: {
Label("행동 설정 수정", systemImage: "slider.horizontal.3")
}
Button(role: .destructive) {
deletingAction = action
} label: {
Label("행동 삭제", systemImage: "trash")
}
actionContextMenu(for: action)
}
}
}
///
@ViewBuilder
private func actionContextMenu(for action: Action) -> some View {
Button {
//
router.openHistory(
filtering: action.persistentModelID,
visibleTabsRaw: visibleTabsRaw
)
} label: {
Label("기록 확인", systemImage: "list.bullet.rectangle")
}
Button {
//
router.openStats(
filtering: action.persistentModelID,
visibleTabsRaw: visibleTabsRaw
)
} label: {
Label("통계 보기", systemImage: "chart.xyaxis.line")
}
Button {
recordsAction = action
} label: {
Label(
action.trackingType == .time ? String(localized: "시작·종료 시각 수동 입력") : String(localized: "횟수 직접 입력·수정"),
systemImage: "square.and.pencil"
)
}
Button {
action.isFavorite.toggle()
DataChange.commit(context: context)
} label: {
Label(
action.isFavorite ? String(localized: "즐겨찾기 해제") : String(localized: "즐겨찾기"),
systemImage: action.isFavorite ? "star.slash" : "star.fill"
)
}
Button {
settingsEditingAction = action
} label: {
Label("행동 설정 수정", systemImage: "slider.horizontal.3")
}
Button(role: .destructive) {
deletingAction = action
} label: {
Label("행동 삭제", systemImage: "trash")
}
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "square.grid.2x2")

View File

@ -50,6 +50,7 @@ struct SymbolPickerView: View {
)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(verbatim: symbol))
}
}
.padding()

View File

@ -125,7 +125,7 @@ struct TagEditorView: View {
Section("") {
ColorPicker("직접 선택", selection: $color, supportsOpacity: false)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 40), spacing: 10)], spacing: 10) {
ForEach(AppTheme.tagPresets, id: \.self) { hex in
ForEach(Array(AppTheme.tagPresets.enumerated()), id: \.offset) { index, hex in
Button {
color = Color(hex: hex)
} label: {
@ -141,6 +141,8 @@ struct TagEditorView: View {
}
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "미리 설정된 색 \(index + 1)"))
.accessibilityAddTraits(color.hexString == hex ? .isSelected : [])
}
}
.padding(.vertical, 4)