feat(main): 배치 편집을 즐겨찾기 섹션 인지형으로 — 끌어서 지정·해제 + '지정 순서' 규칙
즐겨찾기 v2(e09a0b7) 후속 실사용 피드백 세 가지 반영: ①즐겨찾기 안에서 순서 바꾸기가 어렵다 ②새로 지정하면 꼬리표(전역) 순서에 끼어들어 예측이 안 된다 ③배치 편집이 즐겨찾기가 없다는 듯 통짜 그리드로 떠서 디테일이 떨어진다. - 배치 편집도 즐겨찾기/나머지 섹션 구조 그대로(항상 펼침): 섹션 안 드래그는 실시간 순서 변경, **섹션 사이 드래그는 드롭 확정 시점에 즐겨찾기 지정·해제 + 그 위치로 이동**(commitDrop). 호버 중 교차 이동은 자기 섹션 안 점프로 보여 moveDragging이 무시 - 빈 섹션은 점선 드롭 존(호버 하이라이트)이 받는다 — 즐겨찾기 0개 상태에서도 끌어서 지정 가능, 전부 즐겨찾기면 해제 존 표시 - **새 즐겨찾기는 배치 순서상 블록 끝으로**(LocalPrefs.placeNewFavoriteAtEnd — 롱프레스 메뉴·행동 탭 스와이프·상세 토글 3경로 공통). 해제는 위치 무이동(나머지 상단 근처에 남음), 첫 즐겨찾기도 무이동 - 도움말 본문에 순서 규칙·끌어서 지정 사용법 반영, en/ja 번역 + stale 1키 정리 (드롭 존 문구 2키 포함 missing/stale 0) - 검증: Debug/Store 빌드, 아이폰·아이패드 편집 모드 + 브라우즈 스크린샷 (isFavorite는 모델 필드라 DataChange.commit 경유 유지) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6c4cac74cb
commit
28a453485d
@ -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-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 — 행동 많아질 때 혼잡도 대응, 사용자 승인 소기능. v1 별도 카드 가로 줄[대안 A~F 중 A]이 "따로 노는 느낌" 실사용 피드백으로 v2 그리드 섹션 통합+나머지 접기로 교체, 2026-07-22)·**en 복수형 정리**(2026-07-22 — 수량 1일 때 "1 times/quests" 류 오류를 카탈로그 plural variation으로 14건 전환[iOS 10·위젯 3·워치 1], 모음 탭 섹션 제목 en "Favorite"→"Favorites"로 워치와 통일. ko/ja 무영향). **App Store 등록 자료는 `Marketing/`에 완비**(설명·키워드·구독 메타·스크린샷 원본/합성판, README에 재생성 절차 — 앱 변경이 자료에 영향 주면 요청 없이 함께 갱신할 것. 2026-07-22 즐겨찾기 섹션(v2) 반영해 아이폰·아이패드 01-main과 help-main ko/en/ja 재촬영·합성판 재생성). **사업자 등록·통신판매업 신고 완료(2026-07-22)** — 서류 절차 끝, 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 — 행동 많아질 때 혼잡도 대응, 사용자 승인 소기능. v1 별도 카드 가로 줄[대안 A~F 중 A]이 "따로 노는 느낌" 실사용 피드백으로 v2 그리드 섹션 통합+나머지 접기로 교체, 같은 날 후속 피드백으로 편집 모드 섹션 인지·섹션 간 드래그 지정/해제·"지정 순서대로 뒤에" 규칙 추가, 2026-07-22)·**en 복수형 정리**(2026-07-22 — 수량 1일 때 "1 times/quests" 류 오류를 카탈로그 plural variation으로 14건 전환[iOS 10·위젯 3·워치 1], 모음 탭 섹션 제목 en "Favorite"→"Favorites"로 워치와 통일. ko/ja 무영향). **App Store 등록 자료는 `Marketing/`에 완비**(설명·키워드·구독 메타·스크린샷 원본/합성판, README에 재생성 절차 — 앱 변경이 자료에 영향 주면 요청 없이 함께 갱신할 것. 2026-07-22 즐겨찾기 섹션(v2) 반영해 아이폰·아이패드 01-main과 help-main ko/en/ja 재촬영·합성판 재생성). **사업자 등록·통신판매업 신고 완료(2026-07-22)** — 서류 절차 끝, 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,12 +175,12 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
|
||||
### 6.1 모음 탭 (`MainView`)
|
||||
- 상단: 노출 목표 진행 현황 카드(1개=단독, 2개+=스냅 페이징 가로 스크롤 + 페이지 점, iPad=그리드). 카드 스타일 설정: 다짐별 각각(perQuest, 접힘 시 3개+더보기) / 전체 합산(combined)
|
||||
- "현재 진행 중" 영역: 측정 중 세션 나열 + 실시간 타이머 + 종료 버튼
|
||||
- **즐겨찾기·나머지 섹션**(`favoritesSection`/`othersSection`, `!favoriteActions.isEmpty && !isEditing`일 때 — 없으면 기존 통짜 그리드): `Action.isFavorite`(행동 탭과 동일 필드, §6.2) 켠 행동이 **그리드의 첫 섹션**(별 헤더)으로 올라오고, 나머지는 "나머지 행동 · N개" 섹션으로 나뉜다. 두 섹션 모두 통짜 그리드와 같은 `actionGrid(_:)` 빌더를 써서 셀 크기·열 규칙·맥 보정이 항상 동일(시각적으로 하나의 그리드). **v1(별도 카드+컴팩트 셀 가로 스크롤 줄, 2026-07-21)은 그리드와 이질적이고 같은 행동이 두 번 보여 "따로 노는 느낌"이라는 실사용 피드백(2026-07-22)으로 v2로 교체**. **'나머지 행동' 헤더를 탭하면 접힘/펼침**(기기별 `local.mainOthersCollapsed`, 기본 펼침, 셰브론 회전) — 행동이 많아도 접으면 즐겨찾기만 남는다. **숨기는 행동은 없음**(전부 같은 화면, 위치만 승격 — 그리드에서 완전히 숨기는 방식은 기각 유지). 편집(배치) 중에는 배치 순서가 하나뿐이므로 항상 통짜 그리드로 전환. 각 섹션 순서는 기기별 배치 순서(`orderedActions`)를 그대로 따름. 탭 동작(`handleTap`)과 롱프레스 메뉴(`actionContextMenu(for:)` — 기록 확인/통계 보기/기록 직접 입력·수정/**즐겨찾기 지정·해제**/행동 설정 수정/삭제)는 전 섹션 공유. 즐겨찾기 지정·해제는 이 메뉴 또는 행동 탭에서 가능(§6.2)
|
||||
- **즐겨찾기·나머지 섹션**(`favoritesSection`/`othersSection`, `!favoriteActions.isEmpty && !isEditing`일 때 — 없으면 기존 통짜 그리드): `Action.isFavorite`(행동 탭과 동일 필드, §6.2) 켠 행동이 **그리드의 첫 섹션**(별 헤더)으로 올라오고, 나머지는 "나머지 행동 · N개" 섹션으로 나뉜다. 두 섹션 모두 통짜 그리드와 같은 `actionGrid(_:)` 빌더를 써서 셀 크기·열 규칙·맥 보정이 항상 동일(시각적으로 하나의 그리드). **v1(별도 카드+컴팩트 셀 가로 스크롤 줄, 2026-07-21)은 그리드와 이질적이고 같은 행동이 두 번 보여 "따로 노는 느낌"이라는 실사용 피드백(2026-07-22)으로 v2로 교체**. **'나머지 행동' 헤더를 탭하면 접힘/펼침**(기기별 `local.mainOthersCollapsed`, 기본 펼침, 셰브론 회전) — 행동이 많아도 접으면 즐겨찾기만 남는다. **숨기는 행동은 없음**(전부 같은 화면, 위치만 승격 — 그리드에서 완전히 숨기는 방식은 기각 유지). 편집(배치)도 같은 섹션 구조 유지(아래 '배치 편집' 항목). 각 섹션 순서는 기기별 배치 순서(`orderedActions`)를 그대로 따르고, **새로 즐겨찾기 지정하면(롱프레스 메뉴·행동 탭 스와이프·상세 토글 어느 경로든) 배치 순서상 즐겨찾기 블록 끝으로 이동**(`LocalPrefs.placeNewFavoriteAtEnd` — "지정한 순서대로 뒤에 붙는" 규칙. 해제는 위치 무이동이라 방금 뺀 행동이 나머지 상단 근처에 남고, 첫 즐겨찾기도 무이동). 탭 동작(`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, 현재는 v2 섹션형+나머지 접기)에 더해, 향후 후보로 ⓑ꼬리표별 접기(출시 후), ⓒ검색/필터(출시 후), ⓓ한 줄 개수 늘릴 때 경고 문구(미착수, 저위험)가 있음. **명시적으로 기각**: 즐겨찾기 등을 전체 그리드에서 숨기는 옵트인 방식(행동이 눌러도 안 보이게 될 위험 > 이득) — 다시 제안하지 말 것
|
||||
- 배치 편집: 지글 애니메이션 + 드래그 재정렬 → `local.actionOrder` 저장 (CloudKit 모델에 쓰지 않음)
|
||||
- 배치 편집: **편집 모드도 즐겨찾기/나머지 섹션 구조 그대로**(항상 펼침·접기 버튼 없음 — "편집이 즐겨찾기 없다고 가정한다"는 2026-07-22 후속 피드백 해소). 섹션 안 드래그=순서 변경(실시간, 전역 `local.actionOrder` 하나에 반영 — CloudKit 모델에 쓰지 않음), **섹션 사이 드래그=즐겨찾기 지정·해제**(`commitDrop` — 드롭 확정 시점에만 처리. 호버 중 교차 이동은 소속이 안 바뀐 채 전역 순서만 바뀌어 자기 섹션 안에서 점프해 보이므로 `moveDragging`이 무시). **빈 섹션은 점선 드롭 존**(`favoriteDropZone`+`FavoriteZoneDropDelegate`, 호버 하이라이트)이 받아 즐겨찾기 0개여도 끌어서 지정 가능. 지글 애니메이션 유지, isFavorite 변경은 모델 필드라 `DataChange.commit` 경유(워치 즐겨찾기·위젯 갱신)
|
||||
- "짧은 기록 무시"(minSessionSeconds): 설정보다 짧은 세션은 종료 시 삭제. 메모 창 옵션 켠 행동은 종료/+1 시 메모 시트(건너뛰기 가능)
|
||||
- 툴바: 새로고침(좌) / 배치 편집(우)
|
||||
- 빈 상태(행동 0개): 개념 한 줄 소개 + '첫 행동 만들기'(추가 시트 직행) + '예시 행동으로 시작해 보기'(꼬리표 생활·건강 + 독서/운동/물 마시기 생성)
|
||||
|
||||
@ -12539,6 +12539,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"행동을 여기로 끌면 즐겨찾기로 지정돼요" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Drag an action here to make it a favorite"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "アクションをここへドラッグするとお気に入りに指定されます"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"행동을 여기로 끌면 즐겨찾기에서 빠져요" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Drag an action here to remove it from favorites"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "アクションをここへドラッグするとお気に入りから外れます"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"행동을 위젯에서 바로 실행하고 누적값을 확인해요. (프리미엄)" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -12555,18 +12587,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"행동을 즐겨찾기로 지정하면 모음 탭 행동 목록이 '즐겨찾기'와 '나머지 행동' 두 부분으로 나뉘고, 즐겨찾기가 항상 맨 위에 와요. '나머지 행동' 제목을 누르면 그 아래를 접어 둘 수 있어서 행동이 많아져도 자주 쓰는 것만 남길 수 있어요(접힘은 기기마다 저장). 지정은 행동 버튼을 길게 눌러 '즐겨찾기'를 선택하거나, 행동 탭에서 행동을 열어 별 아이콘을 눌러도 돼요." : {
|
||||
"행동을 즐겨찾기로 지정하면 모음 탭 행동 목록이 '즐겨찾기'와 '나머지 행동' 두 부분으로 나뉘고, 즐겨찾기가 항상 맨 위에 와요. 새로 지정한 행동은 즐겨찾기 맨 뒤에 붙어요. 순서를 바꾸려면 '배치 편집'에서 버튼을 길게 눌러 끌어 옮기면 되고, 즐겨찾기와 나머지 사이로 끌어서 지정·해제할 수도 있어요. '나머지 행동' 제목을 누르면 그 아래를 접어 둘 수 있어서 행동이 많아져도 자주 쓰는 것만 남길 수 있어요(접힘은 기기마다 저장). 지정은 행동 버튼을 길게 눌러 '즐겨찾기'를 선택하거나, 행동 탭에서 행동을 열어 별 아이콘을 눌러도 돼요." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Mark actions as favorites and the Home tab list splits into two parts — 'Favorites' and 'Other actions' — with favorites always at the top. Tap the 'Other actions' title to collapse everything below it, so even with lots of actions only the ones you use most stay in view (the collapsed state is saved per device). To mark a favorite, long-press an action button and choose 'Favorites', or open the action in the Actions tab and tap the star icon."
|
||||
"value" : "Mark actions as favorites and the Home tab list splits into two parts — 'Favorites' and 'Other actions' — with favorites always at the top. Newly favorited actions are added to the end of the favorites. To reorder, use 'Edit Layout' and drag buttons around; you can also drag between the two sections to favorite or unfavorite. Tap the 'Other actions' title to collapse everything below it, so even with lots of actions only the ones you use most stay in view (the collapsed state is saved per device). To mark a favorite, long-press an action button and choose 'Favorites', or open the action in the Actions tab and tap the star icon."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "アクションをお気に入りに指定すると、ホームタブの一覧が「お気に入り」と「その他のアクション」の2つに分かれ、お気に入りが常に一番上に表示されます。「その他のアクション」のタイトルをタップすると下を折りたたむことができ、アクションが増えてもよく使うものだけを残せます(折りたたみ状態は端末ごとに保存)。指定はアクションボタンを長押しして「お気に入り」を選ぶか、アクションタブでアクションを開いて星アイコンをタップしてもできます。"
|
||||
"value" : "アクションをお気に入りに指定すると、ホームタブの一覧が「お気に入り」と「その他のアクション」の2つに分かれ、お気に入りが常に一番上に表示されます。新しく指定したアクションはお気に入りの最後に追加されます。順序を変えるには「配置を編集」でボタンをドラッグして動かします。お気に入りとその他の間へドラッグして指定・解除することもできます。「その他のアクション」のタイトルをタップすると下を折りたたむことができ、アクションが増えてもよく使うものだけを残せます(折りたたみ状態は端末ごとに保存)。指定はアクションボタンを長押しして「お気に入り」を選ぶか、アクションタブでアクションを開いて星アイコンをタップしてもできます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,6 +133,8 @@ struct ActionListView: View {
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
Button {
|
||||
action.isFavorite.toggle()
|
||||
// 새 즐겨찾기는 모음 탭 배치 순서상 즐겨찾기 블록 끝으로 (§6.1 "지정한 순서" 규칙)
|
||||
if action.isFavorite { LocalPrefs.placeNewFavoriteAtEnd(action, context: context) }
|
||||
DataChange.commit(context: context)
|
||||
} label: {
|
||||
Label(
|
||||
@ -241,7 +243,12 @@ struct ActionDetailView: View {
|
||||
// 리스트 스와이프 토글과 동일하게 변경 즉시 커밋 (저장 확정)
|
||||
Toggle(isOn: Binding(
|
||||
get: { action.isFavorite },
|
||||
set: { action.isFavorite = $0; DataChange.commit(context: context) }
|
||||
set: {
|
||||
action.isFavorite = $0
|
||||
// 새 즐겨찾기는 모음 탭 배치 순서상 즐겨찾기 블록 끝으로 (§6.1 "지정한 순서" 규칙)
|
||||
if $0 { LocalPrefs.placeNewFavoriteAtEnd(action, context: context) }
|
||||
DataChange.commit(context: context)
|
||||
}
|
||||
)) {
|
||||
Label {
|
||||
Text("즐겨찾기")
|
||||
|
||||
@ -189,7 +189,7 @@ struct HelpView: View {
|
||||
HelpTopic(
|
||||
symbol: "star.fill",
|
||||
title: "즐겨찾기 모아 보기",
|
||||
body: "행동을 즐겨찾기로 지정하면 모음 탭 행동 목록이 '즐겨찾기'와 '나머지 행동' 두 부분으로 나뉘고, 즐겨찾기가 항상 맨 위에 와요. '나머지 행동' 제목을 누르면 그 아래를 접어 둘 수 있어서 행동이 많아져도 자주 쓰는 것만 남길 수 있어요(접힘은 기기마다 저장). 지정은 행동 버튼을 길게 눌러 '즐겨찾기'를 선택하거나, 행동 탭에서 행동을 열어 별 아이콘을 눌러도 돼요."
|
||||
body: "행동을 즐겨찾기로 지정하면 모음 탭 행동 목록이 '즐겨찾기'와 '나머지 행동' 두 부분으로 나뉘고, 즐겨찾기가 항상 맨 위에 와요. 새로 지정한 행동은 즐겨찾기 맨 뒤에 붙어요. 순서를 바꾸려면 '배치 편집'에서 버튼을 길게 눌러 끌어 옮기면 되고, 즐겨찾기와 나머지 사이로 끌어서 지정·해제할 수도 있어요. '나머지 행동' 제목을 누르면 그 아래를 접어 둘 수 있어서 행동이 많아져도 자주 쓰는 것만 남길 수 있어요(접힘은 기기마다 저장). 지정은 행동 버튼을 길게 눌러 '즐겨찾기'를 선택하거나, 행동 탭에서 행동을 열어 별 아이콘을 눌러도 돼요."
|
||||
),
|
||||
HelpTopic(
|
||||
symbol: "hand.tap.fill",
|
||||
|
||||
@ -58,6 +58,8 @@ struct MainView: View {
|
||||
#endif
|
||||
}()
|
||||
@State private var draggingAction: Action?
|
||||
@State private var favoriteZoneTargeted = false
|
||||
@State private var otherZoneTargeted = false
|
||||
@State private var showingAddAction = false
|
||||
@State private var settingsEditingAction: Action?
|
||||
@State private var recordsAction: Action?
|
||||
@ -83,8 +85,11 @@ struct MainView: View {
|
||||
}
|
||||
if actions.isEmpty {
|
||||
emptyState
|
||||
} else if isEditing || favoriteActions.isEmpty {
|
||||
// 편집 중엔 순서가 하나(전체 배치)이므로 항상 통짜 그리드
|
||||
} else if isEditing {
|
||||
// 편집도 같은 섹션 구조 — 섹션 안 드래그=순서 변경, 섹션 사이 드래그=지정·해제
|
||||
editingFavoritesSection
|
||||
editingOthersSection
|
||||
} else if favoriteActions.isEmpty {
|
||||
grid
|
||||
} else {
|
||||
favoritesSection
|
||||
@ -272,21 +277,96 @@ struct MainView: View {
|
||||
return othersCollapsed
|
||||
}
|
||||
|
||||
private var favoritesHeader: some View {
|
||||
Label {
|
||||
Text("즐겨찾기")
|
||||
} icon: {
|
||||
Image(systemName: "star.fill")
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AppTheme.green)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
|
||||
private var favoritesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label {
|
||||
Text("즐겨찾기")
|
||||
} icon: {
|
||||
Image(systemName: "star.fill")
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AppTheme.green)
|
||||
.padding(.leading, 2)
|
||||
favoritesHeader
|
||||
actionGrid(favoriteActions)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 배치 편집용 섹션 (편집 중에도 같은 구조 — 항상 펼침, 접기 버튼 없음)
|
||||
|
||||
private var editingFavoritesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
favoritesHeader
|
||||
if favoriteActions.isEmpty {
|
||||
favoriteDropZone(
|
||||
makeFavorite: true,
|
||||
targeted: $favoriteZoneTargeted,
|
||||
icon: "star",
|
||||
hint: Text("행동을 여기로 끌면 즐겨찾기로 지정돼요")
|
||||
)
|
||||
} else {
|
||||
actionGrid(favoriteActions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var editingOthersSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 6) {
|
||||
Text("나머지 행동")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AppTheme.green)
|
||||
if !otherActions.isEmpty {
|
||||
Text("\(otherActions.count)개")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 2)
|
||||
if otherActions.isEmpty {
|
||||
favoriteDropZone(
|
||||
makeFavorite: false,
|
||||
targeted: $otherZoneTargeted,
|
||||
icon: "star.slash",
|
||||
hint: Text("행동을 여기로 끌면 즐겨찾기에서 빠져요")
|
||||
)
|
||||
} else {
|
||||
actionGrid(otherActions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 빈 섹션의 점선 드롭 존 — 셀이 하나도 없으면 드롭 대상이 없어지므로 이 존이 받는다
|
||||
private func favoriteDropZone(makeFavorite: Bool, targeted: Binding<Bool>, icon: String, hint: Text) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
hint
|
||||
}
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, minHeight: 72)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(targeted.wrappedValue ? AppTheme.yellow.opacity(0.10) : Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(
|
||||
targeted.wrappedValue ? AppTheme.yellow : Color.secondary.opacity(0.35),
|
||||
style: StrokeStyle(lineWidth: 1.5, dash: [6, 4])
|
||||
)
|
||||
)
|
||||
.onDrop(of: [UTType.text], delegate: FavoriteZoneDropDelegate(
|
||||
dragging: $draggingAction,
|
||||
targeted: targeted,
|
||||
perform: { dropOnZone(makeFavorite: makeFavorite) }
|
||||
))
|
||||
}
|
||||
|
||||
private var othersSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Button {
|
||||
@ -402,7 +482,8 @@ struct MainView: View {
|
||||
delegate: ActionReorderDelegate(
|
||||
item: action,
|
||||
dragging: $draggingAction,
|
||||
move: moveDragging(to:)
|
||||
move: moveDragging(to:),
|
||||
commit: commitDrop(on:)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@ -444,6 +525,8 @@ struct MainView: View {
|
||||
}
|
||||
Button {
|
||||
action.isFavorite.toggle()
|
||||
// 새 즐겨찾기는 배치 순서상 즐겨찾기 블록 끝으로 — "지정한 순서대로 뒤에 붙는" 규칙 (§6.1)
|
||||
if action.isFavorite { LocalPrefs.placeNewFavoriteAtEnd(action, context: context) }
|
||||
DataChange.commit(context: context)
|
||||
} label: {
|
||||
Label(
|
||||
@ -566,6 +649,9 @@ struct MainView: View {
|
||||
|
||||
private func moveDragging(to target: Action) {
|
||||
guard let dragging = draggingAction, dragging != target else { return }
|
||||
// 교차 섹션 호버는 실시간 이동 금지 — 소속(isFavorite)이 안 바뀐 채 전역 순서만 바뀌면
|
||||
// 자기 섹션 안에서 엉뚱한 위치로 점프해 보인다. 교차 이동은 드롭 확정 때 처리(commitDrop)
|
||||
guard dragging.isFavorite == target.isFavorite else { return }
|
||||
var ordered = orderedActions
|
||||
guard let from = ordered.firstIndex(of: dragging),
|
||||
let to = ordered.firstIndex(of: target) else { return }
|
||||
@ -574,6 +660,33 @@ struct MainView: View {
|
||||
// 배치는 기기별 로컬 설정 — CloudKit으로 동기화되는 모델(sortOrder)에는 쓰지 않는다
|
||||
actionOrderRaw = LocalPrefs.rawValue(ordered.map(\.uuid))
|
||||
}
|
||||
|
||||
/// 드롭 확정 — 다른 섹션의 셀 위에 놓았으면 즐겨찾기 상태를 목적지 섹션에 맞추고 그 위치로 이동
|
||||
private func commitDrop(on target: Action) {
|
||||
guard let dragging = draggingAction, dragging != target,
|
||||
dragging.isFavorite != target.isFavorite else { return }
|
||||
dragging.isFavorite = target.isFavorite
|
||||
var ordered = orderedActions
|
||||
if let from = ordered.firstIndex(of: dragging) {
|
||||
ordered.remove(at: from)
|
||||
if let to = ordered.firstIndex(of: target) {
|
||||
ordered.insert(dragging, at: to)
|
||||
} else {
|
||||
ordered.append(dragging)
|
||||
}
|
||||
actionOrderRaw = LocalPrefs.rawValue(ordered.map(\.uuid))
|
||||
}
|
||||
// isFavorite는 모델 필드(기기 간 동기화·워치 즐겨찾기) — 저장 + 위젯·워치 갱신 경로
|
||||
DataChange.commit(context: context)
|
||||
}
|
||||
|
||||
/// 빈 섹션의 점선 존에 드롭 — 소속만 바꾼다 (섹션에 하나뿐이라 위치는 자명)
|
||||
private func dropOnZone(makeFavorite: Bool) {
|
||||
guard let dragging = draggingAction, dragging.isFavorite != makeFavorite else { return }
|
||||
dragging.isFavorite = makeFavorite
|
||||
if makeFavorite { LocalPrefs.placeNewFavoriteAtEnd(dragging, context: context) }
|
||||
DataChange.commit(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 목표 진행 현황 카드 (설정에서 선택한 목표, 탭하면 목표 상세로 이동)
|
||||
@ -918,9 +1031,10 @@ private struct ActionReorderDelegate: DropDelegate {
|
||||
let item: Action
|
||||
@Binding var dragging: Action?
|
||||
let move: (Action) -> Void
|
||||
let commit: (Action) -> Void
|
||||
|
||||
func dropEntered(info: DropInfo) {
|
||||
move(item)
|
||||
move(item) // 같은 섹션이면 실시간 순서 이동 (교차 섹션 호버는 move가 무시)
|
||||
}
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
@ -928,6 +1042,28 @@ private struct ActionReorderDelegate: DropDelegate {
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
commit(item) // 교차 섹션 드롭이면 즐겨찾기 지정·해제 + 그 위치로 이동
|
||||
dragging = nil
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// 빈 섹션의 점선 드롭 존 전용 — 하이라이트 표시 + 드롭 시 지정·해제
|
||||
private struct FavoriteZoneDropDelegate: DropDelegate {
|
||||
@Binding var dragging: Action?
|
||||
@Binding var targeted: Bool
|
||||
let perform: () -> Void
|
||||
|
||||
func dropEntered(info: DropInfo) { targeted = true }
|
||||
func dropExited(info: DropInfo) { targeted = false }
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
DropProposal(operation: .move)
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
perform()
|
||||
targeted = false
|
||||
dragging = nil
|
||||
return true
|
||||
}
|
||||
|
||||
@ -105,6 +105,20 @@ nonisolated enum LocalPrefs {
|
||||
defaults.set(rawValue(ids), forKey: LocalPrefsKeys.actionOrder)
|
||||
}
|
||||
|
||||
/// 즐겨찾기 '지정' 직후 호출: 그 행동을 기기별 배치 순서에서 즐겨찾기 블록 끝으로 옮긴다.
|
||||
/// 모음 탭 즐겨찾기 섹션이 "지정한 순서대로 뒤에 붙는" 예측 가능한 동작이 되게 하는 규칙 —
|
||||
/// 기존 즐겨찾기가 없으면 이동하지 않고(첫 즐겨찾기는 위치가 자명), 해제 때는 호출하지
|
||||
/// 않아 방금 뺀 행동이 원래 전역 위치(≈나머지 상단)에 남는다 (§6.1 v2)
|
||||
static func placeNewFavoriteAtEnd(_ action: Action, context: ModelContext) {
|
||||
let all = (try? context.fetch(FetchDescriptor<Action>())) ?? []
|
||||
var ordered = orderedActions(all)
|
||||
guard let from = ordered.firstIndex(where: { $0.uuid == action.uuid }) else { return }
|
||||
let moved = ordered.remove(at: from)
|
||||
guard let lastFavorite = ordered.lastIndex(where: { $0.isFavorite }) else { return }
|
||||
ordered.insert(moved, at: lastFavorite + 1)
|
||||
saveActionOrder(ordered)
|
||||
}
|
||||
|
||||
// MARK: 메모 창 팝업 여부 (기록 시점에 뷰 밖에서도 읽음)
|
||||
|
||||
static func promptsForNote(_ id: UUID) -> Bool {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user