complete prototype app(하루다님)

This commit is contained in:
songyc macbook 2026-07-07 15:20:53 +09:00
parent 7a53bb9ce4
commit c854c8f265
27 changed files with 4940 additions and 0 deletions

280
myApp/Haru_Danim/CLAUDE.md Normal file
View File

@ -0,0 +1,280 @@
# 하루 다님 (HaruDanim) — 앱 스펙 문서
> **한 줄 소개**: 하루의 습관과 시간을 추적하는 iOS 앱. 사용자가 등록한 "행동"의 **소요 시간을 측정**하거나 **횟수를 기록**하고, 이를 바탕으로 **목표(Goal)와 다짐(Quest)** 을 세워 달성 여부를 관리한다.
>
> 문서 버전: v1.0 (2026-07-06) / 이 문서는 개발자·AI에게 전달하기 위한 기획 명세서입니다.
---
## 1. 앱 개요
| 항목 | 내용 |
|---|---|
| 앱 이름 | 하루 다님 — 하루 습관 및 시간 추적 |
| 플랫폼 | iOS (추후 iPadOS 동기화, watchOS 앱 — 유료 기능) |
| 기본 언어 | 한국어 (설정에서 영어 / 일본어 전환 가능) |
| 핵심 콘셉트 | 시간 추적형 타임 트래커 + 습관 카운터/스트릭 관리를 하나로 합친 앱. 행동을 버튼 한 번으로 기록하고, 그 기록들이 모여 목표 달성률로 이어지는 구조 |
| 수익 모델 | 부분 유료화 (무료 사용 + 프리미엄 결제 시 제한 해제 및 확장 기능) |
---
## 2. 개발 환경 및 기술 스택
### 2.1 개발 환경
- **장비/도구**: MacBook + Xcode
- **Apple Developer Program**: 현재 미가입 상태.
- **1단계**: 미가입 상태로 구현 가능한 기능을 모두 먼저 구현
- **2단계**: 구현 완료 후 가입하여 유료 계정이 필요한 기능(인앱결제, iCloud 동기화, 앱 배포 등) 추가 구현
- **프로젝트 구조**: Xcode 프로젝트 생성 완료. 최상위 폴더명 `HaruDanim`, 그 하위의 앱 타깃 폴더명은 `iOS`로 변경했으며 관련 설정(빌드 세팅, 경로 등)도 모두 반영된 상태
### 2.2 기술 스택
| 영역 | 기술 | 비고 |
|---|---|---|
| UI | SwiftUI | |
| 데이터 저장 | **SwiftData** | 프로젝트 생성 시 Storage는 None으로 선택했으나 실제 구현은 SwiftData 사용 |
| 시간 추적 (백그라운드/다이나믹 아일랜드/잠금화면) | ActivityKit — **Live Activities** | 앱을 나가도 추적 중인 시간 표시 |
| 위젯 | WidgetKit | 홈 화면 / 잠금 화면 위젯 (유료) |
| 워치 앱 | watchOS App + WidgetKit 기반 컴플리케이션 | 유료 |
| 통계 그래프 | Swift Charts | |
| 아이콘 | SF Symbols | 가능한 전체 심볼을 선택할 수 있도록 제공 |
| 결제 | StoreKit 2 | 개발자 프로그램 가입 후 |
| 기기 간 동기화 | CloudKit (SwiftData + CloudKit 연동) | 개발자 프로그램 가입 후, 유료 기능 |
| 다국어 | String Catalog (ko 기본, en / ja) | |
### 2.3 개발 단계 구분 (개발자 프로그램 가입 기준)
- **가입 전 구현**: 앱 본체 전체(행동/목표/다짐/꼬리표/기록/설정), SwiftData 저장, Live Activity·다이나믹 아일랜드, 위젯·워치 앱의 로직/UI (실기기 테스트는 무료 프로비저닝 범위 내)
- **가입 후 구현**: 인앱결제(StoreKit), iCloud(CloudKit) 기기 간 동기화, App Store 배포
---
## 3. 핵심 용어 (도메인 개념)
### 3.1 행동 (Action)
- 추적·기록의 최소 단위. 습관(habit) 또는 할 일(task)을 등록한다.
- **추적 방식은 생성 시 둘 중 하나로 지정**:
1. **시간 추적형**: 시작/종료 토글로 소요 시간을 측정. 앱이 백그라운드로 가도 측정이 계속 유지되어야 함 (Live Activity로 표시)
2. **횟수 추적형**: 탭할 때마다 카운트 +1
- 구성 요소: 이름, 아이콘(SF Symbols), 꼬리표(복수 지정 가능), 추적 방식(시간/횟수)
- 행동 버튼의 색은 **지정된 꼬리표의 색**을 따른다.
### 3.2 목표 (Goal)
- 상위 개념의 큰 목표. 사용자가 직접 텍스트로 작성 (예: "토익 700점 이상 받기")
- 구성 요소: 목표 내용(텍스트), 아이콘, 색, **시작일(필수, 과거 날짜 가능)**, **종료일(선택)**
- 종료일 미지정 시 → **수동 완료** 처리 가능해야 함. 이때 달성 여부는 하위 다짐들의 전체 달성 여부에 따라 결정
- 종료일 지정 시 → 목표 탭에 **진행률** 표시
### 3.3 다짐 (Quest)
- 목표의 **하위 단위**. 목표를 먼저 만들고, 그 목표 안에 다짐을 하나씩 추가하는 흐름
- 대상: 특정 **행동** 또는 특정 **꼬리표**를 선택
- **반복 주기** 옵션:
- 하루 단위 / 일주일 단위 / 한 달 단위 / 특정 기간 단위
- 요일 지정: "매주 월·수·금" 등 복수 요일
- 날짜 지정: "매달 15일, 20일, 25일" 등 복수 날짜
- 주차+요일 지정: "매달 셋째 주 화요일" 등
- **목표량과 방향**:
- 시간 추적형 행동 → 해당 기간 동안 특정 시간을 **초과 달성(이상)** 이 목표인지 / **초과하지 않음(이하)** 이 목표인지 선택
- 횟수 추적형 행동 → 특정 횟수 **이상**이 목표인지 / **넘지 않음**이 목표인지 선택
### 3.4 꼬리표 (Tag)
- 행동을 분류하는 태그. **고유의 색**을 가지며 생성 시 색을 지정
- 생성 위치: 꼬리표 탭, 또는 행동 추가 화면의 꼬리표 지정 단계에서 즉석 추가
- **하나의 행동은 여러 개의 꼬리표를 가질 수 있음** (다대다 관계)
---
## 4. 시간 추적 시스템 (핵심 동작 명세)
### 4.1 기본 동작
- 시간 추적형 행동 버튼을 탭 → 측정 시작 / 다시 탭 → 측정 종료 (토글)
- 앱을 나가도 측정은 계속되며, **다이나믹 아일랜드**와 **잠금화면 실시간 현황(Live Activity)** 에 추적 중인 시간이 표시됨
### 4.2 동시(멀티) 추적
- 한 행동을 측정 중인 상태에서, 종료하지 않고 다른 행동을 탭하면 **그 행동도 함께 추적 시작** (동시 추적 개수 제한 없음)
- 다이나믹 아일랜드/잠금화면에 표시할 대표 시간은 **설정에서 선택**:
- 가장 **먼저** 시작한 행동 표시 / 가장 **나중에** 시작한 행동 표시 *(기본값 제안: 가장 나중에 시작한 것)*
- 여러 개를 동시에 추적 중이면 대표 시간 옆에 **`+1`, `+2`** 형식으로 추가 추적 중인 개수를 표시
### 4.3 하루 경계(하루 시작 시간) 예외 처리
- 설정에서 "하루 시작 시간"을 지정 가능 (예: 오전 6시)
- **세션이 하루 경계를 걸치는 경우** (예: 하루 시작이 06:00일 때, 05:00 시작 → 07:00 종료):
- 저장: 세션 자체는 실제 시작~종료 시각 그대로 하나의 기록으로 저장
- **집계/통계**: 하루 시작 시간을 기준으로 자동 분할하여 각 날짜에 귀속 (위 예시에서는 05:00~06:00은 전날, 06:00~07:00은 당일 실적으로 계산)
- 기록 탭 표시/다짐 진행률 계산에도 동일한 분할 규칙 적용
---
## 5. 화면 구성
### 5.1 로딩(스플래시) 화면
- 앱 실행 시 **앱 로고 + 앱 이름**이 표시되는 로딩 화면 필요 (로고는 아직 미제작 → 플레이스홀더로 진행 후 교체)
### 5.2 테마
- **라이트 / 다크 두 가지 모드**
- 컬러 팔레트: 두 모드 모두 **초록 + 노랑 + 배경색(라이트=흰색 계열 / 다크=검정 계열)** 구성
- 모드별로 초록·노랑의 컬러 값을 **다르게** 하여 각 배경에 어울리게 조정. 흔한 원색 계열 초록/노랑은 배제
- **제안 컬러 값** (구현 시 조정 가능):
| 역할 | 라이트 모드 | 다크 모드 |
|---|---|---|
| Primary Green | `#2F6B4F` (딥 모스 그린) | `#7FBF9E` (세이지 민트) |
| Accent Yellow | `#D9A621` (머스터드 골드) | `#E8C558` (소프트 앰버) |
| Background | `#FAFAF6` (웜 화이트) | `#111512` (그린 틴트 블랙) |
### 5.3 탭 구조 (총 6개, 순서 고정)
`메인``행동``꼬리표``목표``기록``설정`
---
## 6. 탭별 상세 명세
### 6.1 메인 탭
- **행동 버튼 그리드**: 등록된 행동들이 스마트폰 홈 화면의 앱 아이콘처럼 나열됨
- 각 버튼: **라운드가 있는 직사각형** / 꼬리표 색 배경 + SF Symbols 아이콘 + 행동 이름
- 시간 추적형: 탭 → 측정 시작, 다시 탭 → 종료. **측정 중/아닐 때 시각적으로 구분** 필수
- 횟수 추적형: 탭 → 카운트 +1
- **진행 중 영역**: 측정 중인 행동은 메인 탭 **상단의 "현재 진행 중" 영역**에 표시. 옆의 종료 버튼으로 종료 가능 (아래 그리드의 버튼을 다시 탭해도 동일하게 종료)
- **길게 누르기(롱프레스) 메뉴**:
- 횟수 추적형: ① 횟수 직접 입력/수정 (증가 취소 포함. 횟수를 여러 개 추가할 경우 각 건마다 "지금 시각"으로 기록할지 특정 시각을 지정할지 선택 가능) ② 행동 설정 수정 ③ 행동 삭제
- 시간 추적형: ① 시작/종료 시각 수동 입력·수정 ② 행동 설정 수정 ③ 행동 삭제
- **배치 편집 모드**: 배치 수정 버튼 존재(위치는 구현 시 재량). 누르면 iPhone 홈 화면 편집처럼 **버튼들이 흔들리며(지글 애니메이션) 드래그로 순서 변경** 가능. "완료"를 누르면 배치가 저장·유지됨
### 6.2 행동 탭
- 등록된 **모든 행동을 꼬리표별로 그룹화한 리스트**로 표시
- 리스트에서 행동을 탭해도 측정이 동작하지 않음 → 대신 **설정 내용 상세가 표시**되고, 수정 버튼으로 수정 / 삭제 가능
- **행동 추가**: 추가 버튼 → 이름, 아이콘, 꼬리표, 추적 방식(시간/횟수) 설정
- 아이콘 선택기는 **SF Symbols를 최대한 폭넓게 전부 사용**할 수 있도록 구성 (검색/카테고리 탐색 제공 권장)
### 6.3 꼬리표 탭
- 생성된 태그 목록 확인 / 새 태그 추가 / 기존 태그의 이름·색 수정
- 태그 생성 시 **색 지정** 필수
### 6.4 목표 탭
- **목표 생성**: 내용(텍스트) + 아이콘 + 색 + 시작일(필수, 과거 가능) + 종료일(선택)
- **목표 상태 표시** (각각 다른 시각 상태 필요):
1. 진행 중 (종료일 있으면 진행률 함께 표시)
2. 달성 완료
3. 미달성 종료
4. **확인 필요**: 다짐이 하나도 등록되지 않은 채 종료일이 도래한 목표 → "달성했나요?" 질문 상태로 전환, 사용자가 탭하여 달성/미달성 직접 선택 → 선택에 따라 2 또는 3 상태로 변경
- 종료일 없는 목표 → 수동 종료 버튼 제공. 종료 시 달성 여부는 하위 다짐 전체 달성 여부로 판정
- **다짐 추가 플로우**: 목표 상세에서 [다짐 추가] → ① 대상 선택(행동 목록 또는 꼬리표 목록에서 선택) → ② 주기 선택(하루/주/월/특정 기간 + 요일·날짜·몇째 주 요일 옵션) → ③ 목표량 입력(시간 또는 횟수) + 방향 선택(이상 달성 / 이하 유지)
- **표시 구조**: 목표 리스트 아래에 해당 목표의 다짐들이 하위 리스트로 표시되고, 각 다짐마다 **하루 진행률 / 주간 진행률 / 월간 진행률**이 표기됨
### 6.5 기록 탭
- **기본(일간) 뷰**: 진입 시 오늘 하루의 기록 표시
- 시간 추적형: 각 행동을 언제부터 언제까지, 총 몇 시간 했는지
- 횟수 추적형: 언제 몇 번을 했는지(시각 포함)
- 각 기록을 탭하면 **바로 수정 화면**이 떠서 세부 데이터 수정 가능
- 상단 **날짜 이동 버튼**으로 이전/이후 날짜의 기록도 동일한 형태로 조회
- **타임테이블 뷰** (전환 버튼 제공):
- 24시간 축의 타임테이블. **하루 단위 / 일주일 단위** 보기 전환 가능
- 시간 추적형 → 해당 시간 구간만큼 영역(블록)을 차지. 횟수 추적형 → 기록된 그 시점에 점/마커로 표시
- 표시는 행동의 **아이콘 + 꼬리표 색** 사용
- **통계 영역** (아래로 스크롤):
- 태그별 하루 시간 투자량 / 횟수 달성량
- 일주일별, 한 달별 집계
- 그래프(차트)로 빈도·비율을 한눈에 파악 가능하게 (Swift Charts)
### 6.6 설정 탭
- 테마: 라이트 / 다크 선택
- 언어: 한국어(기본) / 영어 / 일본어
- **주 시작 요일** 설정 *(기본값 제안: 월요일)*
- **하루 시작 시간** 설정 *(기본값 제안: 00:00)*
- Live Activity/다이나믹 아일랜드 대표 시간 기준: 가장 먼저 시작한 행동 / 가장 나중에 시작한 행동
- **프리미엄 기능** 메뉴:
- 미결제 상태 → 유료 기능 소개 + 결제 화면으로 연결
- 결제 완료 상태 → 홈/잠금화면 위젯 사용 가능, 애플워치 앱 사용 가능, **기기 간 동기화 on/off 토글** 제공
---
## 7. 무료 / 유료(프리미엄) 구분
### 7.1 무료 사용 제한
| 항목 | 무료 한도 |
|---|---|
| 행동(Action) | 최대 **10개** |
| 목표(Goal) | 최대 **2개** |
| 다짐(Quest) | **각 목표당 최대 3개** |
### 7.2 프리미엄 결제 시
- 위 개수 제한 **전부 해제 (무제한)**
- **홈 화면 및 잠금화면 위젯** 사용 가능
- **아이패드 앱과의 데이터 동기화** (iCloud)
- **애플워치 앱** + 워치 페이스 **컴플리케이션** 사용 가능
---
## 8. 위젯 명세 (프리미엄)
### 8.1 공통 — 위젯 테마 옵션
위젯 설정에서 선택:
1. 앱 테마와 일치
2. 라이트 고정 / 다크 고정
3. **리퀴드 글라스(Liquid Glass) 스타일** — 유리 질감 배경 위에서 잘 보이도록 별도 디자인 변형 필요
### 8.2 행동 실행 위젯 (홈 화면)
| 크기 | 구성 |
|---|---|
| 소형 A | 행동 **1개**의 실행 버튼. 시간 추적형은 탭으로 시작/종료 토글 + 누적 시간 표시, 횟수 추적형은 누적 횟수 표시. **표시 기간을 옵션에서 선택: 오늘 / 이번 주 / 이번 달** |
| 소형 B | 소형 A를 축소·간소화한 셀을 2×2로 배치 → **행동 4개** |
| 중형 A | 소형 A 스타일 셀 **3개** |
| 중형 B | 소형 B 스타일 셀 **8개** |
| 대형 | 중형 기준으로 동일한 방식으로 확장 (중형 A 확장형 / 중형 B 확장형) |
### 8.3 목표/다짐 통계 위젯 (홈 화면)
- 옵션에서 **어떤 목표를 표시할지 선택** 가능. **목표 전체** 또는 **그 목표의 특정 다짐만** 표시하는 것도 가능
- 진행 정보는 **하루 / 주간 / 월간 진행률** 등으로 표기
| 크기 | 구성 |
|---|---|
| 소형 | 목표 1개의 달성률/통계 |
| 중형 | ① 목표 1개를 소형보다 상세하게, 또는 ② 목표 2개를 소형처럼 간단하게 |
| 대형 | ① 목표 2개를 중형 수준으로 상세하게, 또는 ② 목표 4개를 간단하게 |
### 8.4 잠금화면 위젯
- **소형(1칸) 위주로 최소한만** 구성
- 종류: 특정 목표 1개의 달성률 / 해당 목표에 속한 다짐들의 달성 여부 표시 등 간단한 정보 위주
---
## 9. 애플워치 앱 (프리미엄)
### 9.1 워치 앱 본체
- 진입 시 **꼬리표(태그) 목록**이 나열됨 → 태그를 탭하면 그 태그에 속한 **행동 목록** 표시
- 행동을 탭하면: 횟수 추적형 → 카운트 +1 / 시간 추적형 → 측정 시작·정지 토글
- **iPhone 앱과 실시간 데이터 연동** 필수 (워치에서 시작한 측정이 폰에도 반영, 그 반대도 동일)
### 9.2 컴플리케이션 (워치 페이스 요소)
1. **현재 현황 컴플리케이션**
- 시간 측정 중이면 어떤 행동이 실행 중인지 표시
- 표시 기준은 다이나믹 아일랜드/잠금화면과 **동일한 설정을 따름** (여러 개면 설정된 기준의 대표 1개 + 추가 실행 개수 별도 표시, 추가 개수가 없으면 개수 표시 생략)
- 실행 중인 것이 없으면 "실행 중 아님" 상태로 표시
2. **목표 달성률 컴플리케이션**
- 지정한 특정 목표의 달성률 표시
- **"넘기면 안 되는" 목표**(시간/횟수 상한형)의 경우 일반 달성형과 **다른 시각 표현** 사용 (예: 한도 대비 사용량으로 표시)
---
## 10. 데이터 모델 (SwiftData 설계 제안)
> 사용자가 기술한 기능을 구현하기 위한 권장 모델 구조. 세부 필드는 구현 시 조정 가능.
- **Tag**: id, 이름, 색상, 생성일
- **Action**: id, 이름, SF Symbol 이름, 추적방식(`time` | `count`), 태그들(다대다), 메인 화면 정렬 순서
- **TimeSession** (시간 기록): id, action 참조, 시작 시각, 종료 시각(진행 중이면 nil)
- **CountEntry** (횟수 기록): id, action 참조, 기록 시각, 수량(기본 1)
- **Goal**: id, 내용 텍스트, 아이콘, 색, 시작일, 종료일(옵션), 상태(`진행중` | `달성` | `미달성` | `확인필요`)
- **Quest**: id, goal 참조, 대상(행동 또는 태그), 주기 유형(일/주/월/기간), 주기 상세(요일 배열, 날짜 배열, 몇째주+요일 등), 목표값(시간 또는 횟수), 방향(`이상` | `이하`)
- **AppSettings**: 테마, 언어, 주 시작 요일, 하루 시작 시간, Live Activity 대표 시간 기준, 프리미엄 여부
---
## 11. 문서에서 확정한 기본값 (원문에 명시되지 않아 합리적으로 정한 것)
| 항목 | 확정값 | 근거 |
|---|---|---|
| 하루 시작 시간 기본값 | 00:00 | 설정에서 변경 가능하므로 표준값으로 시작 |
| 주 시작 요일 기본값 | 월요일 | 한국 사용 관행, 설정에서 변경 가능 |
| 다이나믹 아일랜드 대표 시간 기본값 | 가장 나중에 시작한 행동 | 워치 컴플리케이션 설명에서 "여러 개면 가장 나중 것"이라 언급된 것에 맞춤 |
| 하루 경계 걸침 세션 처리 | 저장은 1개 세션, 집계 시 경계 기준 분할 | 원문 데이터 보존 + 일간 통계 정확성 양립 |
| 테마 컬러 값 | §5.2의 제안 팔레트 | "흔하지 않은 초록/노랑" 요구 반영, 조정 가능 |
| 동시 추적 개수 | 제한 없음 | 원문에 제한 언급 없음 |

View File

@ -0,0 +1,335 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
725A9E842FFCC65000846FFB /* Haru_Danim.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Haru_Danim.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
725A9E862FFCC65000846FFB /* IOS */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = IOS;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
725A9E812FFCC65000846FFB /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
725A9E7B2FFCC65000846FFB = {
isa = PBXGroup;
children = (
725A9E862FFCC65000846FFB /* IOS */,
725A9E852FFCC65000846FFB /* Products */,
);
sourceTree = "<group>";
};
725A9E852FFCC65000846FFB /* Products */ = {
isa = PBXGroup;
children = (
725A9E842FFCC65000846FFB /* Haru_Danim.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
725A9E832FFCC65000846FFB /* Haru_Danim */ = {
isa = PBXNativeTarget;
buildConfigurationList = 725A9E8F2FFCC65100846FFB /* Build configuration list for PBXNativeTarget "Haru_Danim" */;
buildPhases = (
725A9E802FFCC65000846FFB /* Sources */,
725A9E812FFCC65000846FFB /* Frameworks */,
725A9E822FFCC65000846FFB /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
725A9E862FFCC65000846FFB /* IOS */,
);
name = Haru_Danim;
packageProductDependencies = (
);
productName = Haru_Danim;
productReference = 725A9E842FFCC65000846FFB /* Haru_Danim.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
725A9E7C2FFCC65000846FFB /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2660;
LastUpgradeCheck = 2660;
TargetAttributes = {
725A9E832FFCC65000846FFB = {
CreatedOnToolsVersion = 26.6;
};
};
};
buildConfigurationList = 725A9E7F2FFCC65000846FFB /* Build configuration list for PBXProject "Haru_Danim" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 725A9E7B2FFCC65000846FFB;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 725A9E852FFCC65000846FFB /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
725A9E832FFCC65000846FFB /* Haru_Danim */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
725A9E822FFCC65000846FFB /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
725A9E802FFCC65000846FFB /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
725A9E8D2FFCC65100846FFB /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
725A9E8E2FFCC65100846FFB /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
725A9E902FFCC65100846FFB /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.yechan.Haru-Danim";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
725A9E912FFCC65100846FFB /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.yechan.Haru-Danim";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
725A9E7F2FFCC65000846FFB /* Build configuration list for PBXProject "Haru_Danim" */ = {
isa = XCConfigurationList;
buildConfigurations = (
725A9E8D2FFCC65100846FFB /* Debug */,
725A9E8E2FFCC65100846FFB /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
725A9E8F2FFCC65100846FFB /* Build configuration list for PBXNativeTarget "Haru_Danim" */ = {
isa = XCConfigurationList;
buildConfigurations = (
725A9E902FFCC65100846FFB /* Debug */,
725A9E912FFCC65100846FFB /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 725A9E7C2FFCC65000846FFB /* Project object */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>Haru_Danim.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,158 @@
//
// ContentView.swift
// Haru_Danim
//
// + 6 (CLAUDE.md §5)
//
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var context
@AppStorage(SettingsKeys.theme) private var theme: String = "light"
@State private var showSplash = true
var body: some View {
ZStack {
MainTabView()
if showSplash {
SplashView()
.transition(.opacity)
.zIndex(1)
}
}
.tint(AppTheme.green)
.preferredColorScheme(theme == "dark" ? .dark : .light)
.task {
#if DEBUG
DebugSeed.seedIfRequested(context: context)
#endif
try? await Task.sleep(for: .seconds(1.2))
withAnimation(.easeOut(duration: 0.4)) {
showSplash = false
}
}
}
}
// MARK: - ( , CLAUDE.md §5.1)
struct SplashView: View {
var body: some View {
ZStack {
AppTheme.background.ignoresSafeArea()
VStack(spacing: 20) {
ZStack {
RoundedRectangle(cornerRadius: 28, style: .continuous)
.fill(AppTheme.green)
.frame(width: 110, height: 110)
Image(systemName: "figure.walk")
.font(.system(size: 52, weight: .medium))
.foregroundStyle(AppTheme.yellow)
}
Text("하루 다님")
.font(.largeTitle.bold())
.foregroundStyle(AppTheme.green)
Text("하루 습관 및 시간 추적")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
// MARK: - ( 6, 5 )
enum AppTab: String, CaseIterable, Identifiable {
case main, action, tag, goal, history, settings
var id: String { rawValue }
var label: String {
switch self {
case .main: return "메인"
case .action: return "행동"
case .tag: return "꼬리표"
case .goal: return "목표"
case .history: return "기록"
case .settings: return "설정"
}
}
var symbol: String {
switch self {
case .main: return "square.grid.2x2.fill"
case .action: return "figure.walk"
case .tag: return "tag.fill"
case .goal: return "flag.checkered"
case .history: return "calendar"
case .settings: return "gearshape.fill"
}
}
}
struct MainTabView: View {
@State private var selection: AppTab = {
#if DEBUG
if let raw = UserDefaults.standard.string(forKey: "startTab"),
let tab = AppTab(rawValue: raw) {
return tab
}
#endif
return .main
}()
var body: some View {
VStack(spacing: 0) {
ZStack {
switch selection {
case .main: MainView()
case .action: ActionListView()
case .tag: TagListView()
case .goal: GoalListView()
case .history: HistoryView()
case .settings: SettingsView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
tabBar
}
.background(AppTheme.background)
}
private var tabBar: some View {
HStack {
ForEach(AppTab.allCases) { tab in
Button {
selection = tab
} label: {
VStack(spacing: 3) {
Image(systemName: tab.symbol)
.font(.system(size: 20))
Text(tab.label)
.font(.system(size: 10))
}
.frame(maxWidth: .infinity)
.foregroundStyle(selection == tab ? AppTheme.green : Color.secondary)
}
.buttonStyle(.plain)
}
}
.padding(.top, 8)
.padding(.bottom, 2)
.background(.bar)
.overlay(alignment: .top) {
Divider()
}
}
}
#Preview {
ContentView()
.modelContainer(for: [
Tag.self, Action.self, TimeSession.self,
CountEntry.self, Goal.self, Quest.self,
], inMemory: true)
}

View File

@ -0,0 +1,144 @@
//
// DayMath.swift
// Haru_Danim
//
// " " (CLAUDE.md §4.3)
//
import Foundation
/// ( ), / ,
struct DayMath {
let settings: TrackingSettings
init(settings: TrackingSettings = .current()) {
self.settings = settings
}
var calendar: Calendar { settings.calendar }
// MARK:
/// `date` ( Date).
/// : 06:00 7/7 05:00 7/6 .
func dayKey(for date: Date) -> Date {
let startOfDay = calendar.startOfDay(for: date)
let boundary = calendar.date(byAdding: .minute, value: settings.dayStartMinutes, to: startOfDay)!
if date < boundary {
return calendar.date(byAdding: .day, value: -1, to: startOfDay)!
}
return startOfDay
}
/// ( Date)
func dayRange(forKey key: Date) -> Range<Date> {
let start = calendar.date(byAdding: .minute, value: settings.dayStartMinutes, to: key)!
let nextKey = calendar.date(byAdding: .day, value: 1, to: key)!
let end = calendar.date(byAdding: .minute, value: settings.dayStartMinutes, to: nextKey)!
return start..<end
}
func dayRange(containing date: Date) -> Range<Date> {
dayRange(forKey: dayKey(for: date))
}
// MARK: /
/// `date` ( )
func weekRange(containing date: Date) -> Range<Date> {
var key = dayKey(for: date)
while calendar.component(.weekday, from: key) != calendar.firstWeekday {
key = calendar.date(byAdding: .day, value: -1, to: key)!
}
let lastKey = calendar.date(byAdding: .day, value: 6, to: key)!
return dayRange(forKey: key).lowerBound..<dayRange(forKey: lastKey).upperBound
}
/// `date`
func monthRange(containing date: Date) -> Range<Date> {
let key = dayKey(for: date)
let comps = calendar.dateComponents([.year, .month], from: key)
let firstKey = calendar.date(from: comps)!
let dayCount = calendar.range(of: .day, in: .month, for: firstKey)!.count
let lastKey = calendar.date(byAdding: .day, value: dayCount - 1, to: firstKey)!
return dayRange(forKey: firstKey).lowerBound..<dayRange(forKey: lastKey).upperBound
}
///
func dayKeys(in range: Range<Date>) -> [Date] {
var keys: [Date] = []
var key = dayKey(for: range.lowerBound)
while dayRange(forKey: key).lowerBound < range.upperBound {
keys.append(key)
key = calendar.date(byAdding: .day, value: 1, to: key)!
}
return keys
}
// MARK: ( )
struct DaySegment {
let dayKey: Date
let range: Range<Date>
}
/// ~
func splitByDay(start: Date, end: Date) -> [DaySegment] {
guard start < end else { return [] }
var segments: [DaySegment] = []
var cursor = start
while cursor < end {
let key = dayKey(for: cursor)
let dayEnd = dayRange(forKey: key).upperBound
let segmentEnd = min(dayEnd, end)
segments.append(DaySegment(dayKey: key, range: cursor..<segmentEnd))
cursor = segmentEnd
}
return segments
}
}
// MARK: -
/// / ( )
struct Aggregator {
let math: DayMath
init(math: DayMath = DayMath()) {
self.math = math
}
/// (). `now` .
func seconds(for action: Action, in range: Range<Date>, now: Date = .now) -> TimeInterval {
action.sessions.reduce(0) { total, session in
let end = session.endAt ?? now
let overlapStart = max(session.startAt, range.lowerBound)
let overlapEnd = min(end, range.upperBound)
return total + max(0, overlapEnd.timeIntervalSince(overlapStart))
}
}
///
func count(for action: Action, in range: Range<Date>) -> Int {
action.countEntries
.filter { range.contains($0.timestamp) }
.reduce(0) { $0 + $1.amount }
}
func seconds(for actions: [Action], in range: Range<Date>, now: Date = .now) -> TimeInterval {
actions.reduce(0) { $0 + seconds(for: $1, in: range, now: now) }
}
func count(for actions: [Action], in range: Range<Date>) -> Int {
actions.reduce(0) { $0 + count(for: $1, in: range) }
}
/// ( )
func todayValue(for action: Action, now: Date = .now) -> Double {
let range = math.dayRange(containing: now)
switch action.trackingType {
case .time: return seconds(for: action, in: range, now: now)
case .count: return Double(count(for: action, in: range))
}
}
}

View File

@ -0,0 +1,114 @@
//
// DebugSeed.swift
// Haru_Danim
//
// . `-seedDemo YES` . (DEBUG )
//
#if DEBUG
import Foundation
import SwiftData
enum DebugSeed {
static func seedIfRequested(context: ModelContext) {
guard UserDefaults.standard.bool(forKey: "seedDemo") else { return }
let existing = (try? context.fetchCount(FetchDescriptor<Action>())) ?? 0
guard existing == 0 else { return }
let study = Tag(name: "공부", colorHex: "#4A7A9D")
let workout = Tag(name: "운동", colorHex: "#9D5B4A")
let life = Tag(name: "생활", colorHex: "#2F6B4F")
context.insert(study)
context.insert(workout)
context.insert(life)
let reading = Action(name: "독서", symbolName: "book.fill", trackingType: .time, sortOrder: 0)
reading.tags = [study]
let english = Action(name: "영어 공부", symbolName: "graduationcap.fill", trackingType: .time, sortOrder: 1)
english.tags = [study]
let running = Action(name: "달리기", symbolName: "figure.run", trackingType: .time, sortOrder: 2)
running.tags = [workout]
let pushup = Action(name: "팔굽혀펴기", symbolName: "dumbbell.fill", trackingType: .count, sortOrder: 3)
pushup.tags = [workout]
let water = Action(name: "물 마시기", symbolName: "drop.fill", trackingType: .count, sortOrder: 4)
water.tags = [life]
for action in [reading, english, running, pushup, water] {
context.insert(action)
}
let now = Date.now
let cal = Calendar.current
func at(daysAgo: Int, hour: Int, minute: Int = 0) -> Date {
let day = cal.date(byAdding: .day, value: -daysAgo, to: now)!
return cal.date(bySettingHour: hour, minute: minute, second: 0, of: day)!
}
// ()
for daysAgo in 0...6 {
context.insert(TimeSession(action: english, startAt: at(daysAgo: daysAgo, hour: 8), endAt: at(daysAgo: daysAgo, hour: 9, minute: 10)))
if daysAgo % 2 == 0 {
context.insert(TimeSession(action: running, startAt: at(daysAgo: daysAgo, hour: 7), endAt: at(daysAgo: daysAgo, hour: 7, minute: 40)))
}
if daysAgo % 3 != 0 {
context.insert(TimeSession(action: reading, startAt: at(daysAgo: daysAgo, hour: 21), endAt: at(daysAgo: daysAgo, hour: 22, minute: 15)))
}
}
// ( )
context.insert(TimeSession(action: reading, startAt: at(daysAgo: 1, hour: 23, minute: 20), endAt: at(daysAgo: 0, hour: 0, minute: 40)))
//
context.insert(TimeSession(action: reading, startAt: now.addingTimeInterval(-25 * 60)))
//
for daysAgo in 0...6 {
for i in 0..<(3 + daysAgo % 4) {
context.insert(CountEntry(action: water, timestamp: at(daysAgo: daysAgo, hour: 9 + i * 2)))
}
if daysAgo % 2 == 0 {
context.insert(CountEntry(action: pushup, timestamp: at(daysAgo: daysAgo, hour: 19), amount: 20))
}
}
// +
let toeic = Goal(
title: "토익 700점 이상 받기",
symbolName: "graduationcap.fill",
colorHex: "#4A7A9D",
startDate: cal.date(byAdding: .day, value: -10, to: now)!,
endDate: cal.date(byAdding: .day, value: 30, to: now)!
)
context.insert(toeic)
let englishQuest = Quest(goal: toeic)
englishQuest.targetAction = english
englishQuest.measure = .time
englishQuest.period = .daily
englishQuest.targetSeconds = 3600
englishQuest.direction = .atLeast
context.insert(englishQuest)
let health = Goal(
title: "건강한 생활 습관 만들기",
symbolName: "heart.fill",
colorHex: "#2F6B4F",
startDate: cal.date(byAdding: .day, value: -20, to: now)!,
endDate: nil
)
context.insert(health)
let waterQuest = Quest(goal: health)
waterQuest.targetAction = water
waterQuest.measure = .count
waterQuest.period = .daily
waterQuest.targetCount = 8
waterQuest.direction = .atLeast
context.insert(waterQuest)
let runQuest = Quest(goal: health)
runQuest.targetAction = running
runQuest.measure = .time
runQuest.period = .daily
runQuest.scheduleMode = .weekdays
runQuest.weekdays = [2, 4, 6]
runQuest.targetSeconds = 30 * 60
runQuest.direction = .atLeast
context.insert(runQuest)
}
}
#endif

View File

@ -0,0 +1,52 @@
//
// Formatters.swift
// Haru_Danim
//
import Foundation
enum Format {
/// 1:23:45 23:45 ( )
static func timer(_ interval: TimeInterval) -> String {
let total = Int(interval.rounded(.down))
let h = total / 3600
let m = (total % 3600) / 60
let s = total % 60
if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) }
return String(format: "%d:%02d", m, s)
}
/// "2 30" / "45" / "30"
static func durationShort(_ interval: TimeInterval) -> String {
let total = Int(interval.rounded())
let h = total / 3600
let m = (total % 3600) / 60
if h > 0 {
return m > 0 ? "\(h)시간 \(m)" : "\(h)시간"
}
if m > 0 { return "\(m)" }
return "\(total)"
}
static func weekdayShort(_ weekday: Int) -> String {
let names = ["", "", "", "", "", "", ""]
let index = (weekday - 1) % 7
return names[max(0, index)]
}
static func shortDate(_ date: Date) -> String {
date.formatted(.dateTime.month(.defaultDigits).day())
}
static func fullDate(_ date: Date) -> String {
date.formatted(.dateTime.year().month().day().weekday(.short))
}
static func time(_ date: Date) -> String {
date.formatted(.dateTime.hour().minute())
}
static func percent(_ ratio: Double) -> String {
"\(Int((ratio * 100).rounded()))%"
}
}

View File

@ -0,0 +1,399 @@
//
// Models.swift
// Haru_Danim
//
// SwiftData (CLAUDE.md §10 )
//
import Foundation
import SwiftData
import SwiftUI
// MARK: -
enum TrackingType: String, CaseIterable, Identifiable {
case time
case count
var id: String { rawValue }
var label: String {
switch self {
case .time: return "시간 측정"
case .count: return "횟수 기록"
}
}
var symbol: String {
switch self {
case .time: return "timer"
case .count: return "number"
}
}
}
// MARK: - (Tag)
@Model
final class Tag {
var name: String = ""
var colorHex: String = "#2F6B4F"
var createdAt: Date = Date()
var actions: [Action] = []
@Relationship(deleteRule: .nullify, inverse: \Quest.targetTag)
var quests: [Quest] = []
init(name: String, colorHex: String) {
self.name = name
self.colorHex = colorHex
self.createdAt = .now
}
}
extension Tag {
var color: Color { Color(hex: colorHex) }
var sortedActions: [Action] {
actions.sorted { $0.sortOrder < $1.sortOrder }
}
}
// MARK: - (Action)
@Model
final class Action {
var name: String = ""
var symbolName: String = "star.fill"
var trackingTypeRaw: String = TrackingType.time.rawValue
var sortOrder: Int = 0
var createdAt: Date = Date()
@Relationship(inverse: \Tag.actions)
var tags: [Tag] = []
@Relationship(deleteRule: .cascade, inverse: \TimeSession.action)
var sessions: [TimeSession] = []
@Relationship(deleteRule: .cascade, inverse: \CountEntry.action)
var countEntries: [CountEntry] = []
@Relationship(deleteRule: .nullify, inverse: \Quest.targetAction)
var quests: [Quest] = []
init(name: String, symbolName: String, trackingType: TrackingType, sortOrder: Int) {
self.name = name
self.symbolName = symbolName
self.trackingTypeRaw = trackingType.rawValue
self.sortOrder = sortOrder
self.createdAt = .now
}
}
extension Action {
var trackingType: TrackingType {
get { TrackingType(rawValue: trackingTypeRaw) ?? .time }
set { trackingTypeRaw = newValue.rawValue }
}
/// : ( ) .
var color: Color {
tags.sorted { $0.createdAt < $1.createdAt }.first?.color ?? AppTheme.green
}
var sortedTags: [Tag] {
tags.sorted { $0.createdAt < $1.createdAt }
}
/// ( )
var runningSession: TimeSession? {
sessions.first { $0.endAt == nil }
}
var isRunning: Bool { runningSession != nil }
}
// MARK: - (TimeSession)
@Model
final class TimeSession {
var startAt: Date = Date()
var endAt: Date?
var action: Action?
init(action: Action, startAt: Date, endAt: Date? = nil) {
self.action = action
self.startAt = startAt
self.endAt = endAt
}
}
extension TimeSession {
func duration(asOf now: Date = .now) -> TimeInterval {
max(0, (endAt ?? now).timeIntervalSince(startAt))
}
}
// MARK: - (CountEntry)
@Model
final class CountEntry {
var timestamp: Date = Date()
var amount: Int = 1
var action: Action?
init(action: Action, timestamp: Date, amount: Int = 1) {
self.action = action
self.timestamp = timestamp
self.amount = amount
}
}
// MARK: - (Goal)
enum GoalStatus: String, CaseIterable {
case inProgress
case achieved
case notAchieved
var label: String {
switch self {
case .inProgress: return "진행 중"
case .achieved: return "달성 완료"
case .notAchieved: return "미달성 종료"
}
}
}
@Model
final class Goal {
var title: String = ""
var symbolName: String = "flag.fill"
var colorHex: String = "#2F6B4F"
var startDate: Date = Date()
var endDate: Date?
var statusRaw: String = GoalStatus.inProgress.rawValue
var createdAt: Date = Date()
@Relationship(deleteRule: .cascade, inverse: \Quest.goal)
var quests: [Quest] = []
init(title: String, symbolName: String, colorHex: String, startDate: Date, endDate: Date?) {
self.title = title
self.symbolName = symbolName
self.colorHex = colorHex
self.startDate = startDate
self.endDate = endDate
self.statusRaw = GoalStatus.inProgress.rawValue
self.createdAt = .now
}
}
extension Goal {
var status: GoalStatus {
get { GoalStatus(rawValue: statusRaw) ?? .inProgress }
set { statusRaw = newValue.rawValue }
}
var color: Color { Color(hex: colorHex) }
var sortedQuests: [Quest] {
quests.sorted { $0.createdAt < $1.createdAt }
}
/// ( )
func isPastEndDate(asOf now: Date = .now, calendar: Calendar = .current) -> Bool {
guard let endDate else { return false }
let endOfDay = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: endDate))!
return now >= endOfDay
}
/// " " (CLAUDE.md §6.4-4)
func needsConfirmation(asOf now: Date = .now) -> Bool {
status == .inProgress && quests.isEmpty && isPastEndDate(asOf: now)
}
/// (0...1)
func dateProgress(asOf now: Date = .now) -> Double? {
guard let endDate, endDate > startDate else { return nil }
let total = endDate.timeIntervalSince(startDate)
let elapsed = now.timeIntervalSince(startDate)
return min(max(elapsed / total, 0), 1)
}
}
// MARK: - (Quest)
enum QuestPeriod: String, CaseIterable, Identifiable {
case daily
case weekly
case monthly
case custom
var id: String { rawValue }
var label: String {
switch self {
case .daily: return "하루 단위"
case .weekly: return "일주일 단위"
case .monthly: return "한 달 단위"
case .custom: return "특정 기간"
}
}
}
///
enum QuestScheduleMode: String, CaseIterable, Identifiable {
case everyDay
case weekdays
case monthDays
case ordinalWeekday
var id: String { rawValue }
var label: String {
switch self {
case .everyDay: return "매일"
case .weekdays: return "요일 지정"
case .monthDays: return "날짜 지정"
case .ordinalWeekday: return "몇째 주 요일"
}
}
}
enum QuestDirection: String, CaseIterable, Identifiable {
case atLeast
case atMost
var id: String { rawValue }
var label: String {
switch self {
case .atLeast: return "이상 달성"
case .atMost: return "이하 유지"
}
}
}
@Model
final class Quest {
var goal: Goal?
/// :
var targetAction: Action?
var targetTag: Tag?
/// ( , )
var measureRaw: String = TrackingType.time.rawValue
var periodRaw: String = QuestPeriod.daily.rawValue
var scheduleModeRaw: String = QuestScheduleMode.everyDay.rawValue
/// Calendar.weekday (1= ... 7=)
var weekdays: [Int] = []
/// 1~31
var monthDays: [Int] = []
/// (1~5)
var ordinalWeek: Int = 1
/// Calendar.weekday
var ordinalWeekday: Int = 2
var customStart: Date?
var customEnd: Date?
/// () measure == .time
var targetSeconds: Double = 3600
/// measure == .count
var targetCount: Int = 1
var directionRaw: String = QuestDirection.atLeast.rawValue
var createdAt: Date = Date()
init(goal: Goal) {
self.goal = goal
self.createdAt = .now
}
}
extension Quest {
var measure: TrackingType {
get { TrackingType(rawValue: measureRaw) ?? .time }
set { measureRaw = newValue.rawValue }
}
var period: QuestPeriod {
get { QuestPeriod(rawValue: periodRaw) ?? .daily }
set { periodRaw = newValue.rawValue }
}
var scheduleMode: QuestScheduleMode {
get { QuestScheduleMode(rawValue: scheduleModeRaw) ?? .everyDay }
set { scheduleModeRaw = newValue.rawValue }
}
var direction: QuestDirection {
get { QuestDirection(rawValue: directionRaw) ?? .atLeast }
set { directionRaw = newValue.rawValue }
}
var targetName: String {
if let targetAction { return targetAction.name }
if let targetTag { return "#\(targetTag.name)" }
return "대상 없음"
}
var targetSymbol: String {
if let targetAction { return targetAction.symbolName }
return "tag.fill"
}
var targetColor: Color {
if let targetAction { return targetAction.color }
if let targetTag { return targetTag.color }
return AppTheme.green
}
///
var targetActions: [Action] {
if let targetAction { return [targetAction] }
if let targetTag { return targetTag.actions.filter { $0.trackingType == measure } }
return []
}
/// ( , )
var targetValue: Double {
measure == .time ? targetSeconds : Double(targetCount)
}
var targetValueLabel: String {
measure == .time ? Format.durationShort(targetSeconds) : "\(targetCount)"
}
/// (: " ··")
var scheduleLabel: String {
switch period {
case .daily:
switch scheduleMode {
case .everyDay:
return "매일"
case .weekdays:
let names = weekdays.sorted().map { Format.weekdayShort($0) }
return "매주 " + names.joined(separator: "·")
case .monthDays:
let names = monthDays.sorted().map { "\($0)" }
return "매달 " + names.joined(separator: ", ")
case .ordinalWeekday:
return "매달 \(ordinalWeek)째 주 \(Format.weekdayShort(ordinalWeekday))요일"
}
case .weekly:
return "매주"
case .monthly:
return "매달"
case .custom:
guard let customStart, let customEnd else { return "특정 기간" }
return "\(Format.shortDate(customStart)) ~ \(Format.shortDate(customEnd))"
}
}
}

View File

@ -0,0 +1,186 @@
//
// QuestProgress.swift
// Haru_Danim
//
// (CLAUDE.md §3.3, §6.4)
//
import Foundation
/// //
enum StatSpan: String, CaseIterable, Identifiable {
case day, week, month
var id: String { rawValue }
var label: String {
switch self {
case .day: return "하루"
case .week: return "주간"
case .month: return "월간"
}
}
}
struct QuestProgressResult {
let value: Double
let target: Double
let direction: QuestDirection
/// (0...1)
var ratio: Double {
guard target > 0 else { return 0 }
return min(value / target, 1)
}
///
var isAchieved: Bool {
switch direction {
case .atLeast: return value >= target
case .atMost: return value <= target
}
}
}
struct QuestProgress {
let quest: Quest
let math: DayMath
init(quest: Quest, math: DayMath = DayMath()) {
self.quest = quest
self.math = math
}
// MARK:
///
func isActiveDay(_ dayKey: Date) -> Bool {
guard quest.period == .daily else { return true }
let cal = math.calendar
switch quest.scheduleMode {
case .everyDay:
return true
case .weekdays:
return quest.weekdays.contains(cal.component(.weekday, from: dayKey))
case .monthDays:
return quest.monthDays.contains(cal.component(.day, from: dayKey))
case .ordinalWeekday:
return cal.component(.weekday, from: dayKey) == quest.ordinalWeekday
&& cal.component(.weekdayOrdinal, from: dayKey) == quest.ordinalWeek
}
}
func activeDayCount(in range: Range<Date>) -> Int {
math.dayKeys(in: range).filter(isActiveDay).count
}
// MARK:
/// . nil.
func currentPeriodRange(now: Date = .now) -> Range<Date>? {
switch quest.period {
case .daily:
let key = math.dayKey(for: now)
guard isActiveDay(key) else { return nil }
return math.dayRange(forKey: key)
case .weekly:
return math.weekRange(containing: now)
case .monthly:
return math.monthRange(containing: now)
case .custom:
guard let start = quest.customStart, let end = quest.customEnd else { return nil }
let cal = math.calendar
let lower = math.dayRange(forKey: cal.startOfDay(for: start)).lowerBound
let upper = math.dayRange(forKey: cal.startOfDay(for: end)).upperBound
guard lower < upper else { return nil }
return lower..<upper
}
}
func value(in range: Range<Date>, now: Date = .now) -> Double {
let agg = Aggregator(math: math)
switch quest.measure {
case .time: return agg.seconds(for: quest.targetActions, in: range, now: now)
case .count: return Double(agg.count(for: quest.targetActions, in: range))
}
}
///
func current(now: Date = .now) -> QuestProgressResult? {
guard let range = currentPeriodRange(now: now) else { return nil }
return QuestProgressResult(
value: value(in: range, now: now),
target: quest.targetValue,
direction: quest.direction
)
}
// MARK: // (CLAUDE.md §6.4 )
/// span , span
func spanProgress(_ span: StatSpan, now: Date = .now) -> QuestProgressResult {
let range: Range<Date>
switch span {
case .day: range = math.dayRange(containing: now)
case .week: range = math.weekRange(containing: now)
case .month: range = math.monthRange(containing: now)
}
return QuestProgressResult(
value: value(in: range, now: now),
target: scaledTarget(for: span, range: range, now: now),
direction: quest.direction
)
}
/// span
private func scaledTarget(for span: StatSpan, range: Range<Date>, now: Date) -> Double {
let target = quest.targetValue
let daysInRange = math.dayKeys(in: range).count
switch quest.period {
case .daily:
switch span {
case .day: return target
case .week, .month: return target * Double(max(activeDayCount(in: range), 1))
}
case .weekly:
switch span {
case .day: return target / 7
case .week: return target
case .month: return target * Double(daysInRange) / 7
}
case .monthly:
let daysInMonth = Double(math.dayKeys(in: math.monthRange(containing: now)).count)
switch span {
case .day: return target / daysInMonth
case .week: return target * 7 / daysInMonth
case .month: return target
}
case .custom:
return target
}
}
}
// MARK: -
extension Goal {
/// .
/// ( )
func evaluateIfEnded(math: DayMath = DayMath(), now: Date = .now) {
guard status == .inProgress, isPastEndDate(asOf: now), !quests.isEmpty else { return }
let allAchieved = quests.allSatisfy { quest in
QuestProgress(quest: quest, math: math).current(now: now)?.isAchieved ?? true
}
status = allAchieved ? .achieved : .notAchieved
}
/// : ( nil UI )
func manualFinish(math: DayMath = DayMath(), now: Date = .now) -> GoalStatus? {
guard !quests.isEmpty else { return nil }
let allAchieved = quests.allSatisfy { quest in
QuestProgress(quest: quest, math: math).current(now: now)?.isAchieved ?? true
}
status = allAchieved ? .achieved : .notAchieved
return status
}
}

View File

@ -0,0 +1,78 @@
//
// Settings.swift
// Haru_Danim
//
// , (CLAUDE.md §6.6, §7)
//
import Foundation
enum SettingsKeys {
/// "light" | "dark"
static let theme = "settings.theme"
/// "ko" | "en" | "ja"
static let language = "settings.language"
/// Calendar.weekday (1= ... 7=). 2()
static let weekStartWeekday = "settings.weekStartWeekday"
/// . 0 (00:00)
static let dayStartMinutes = "settings.dayStartMinutes"
/// "latest"( ) | "earliest"( ). latest
static let liveActivityMode = "settings.liveActivityMode"
///
static let isPremium = "settings.isPremium"
}
enum LiveActivityMode: String, CaseIterable, Identifiable {
case latest
case earliest
var id: String { rawValue }
var label: String {
switch self {
case .latest: return "가장 나중에 시작한 행동"
case .earliest: return "가장 먼저 시작한 행동"
}
}
}
enum AppLanguage: String, CaseIterable, Identifiable {
case ko, en, ja
var id: String { rawValue }
var label: String {
switch self {
case .ko: return "한국어"
case .en: return "English"
case .ja: return "日本語"
}
}
}
/// (CLAUDE.md §7.1)
enum FreeLimits {
static let actions = 10
static let goals = 2
static let questsPerGoal = 3
}
///
struct TrackingSettings {
var weekStartWeekday: Int
var dayStartMinutes: Int
static func current() -> TrackingSettings {
let defaults = UserDefaults.standard
return TrackingSettings(
weekStartWeekday: defaults.object(forKey: SettingsKeys.weekStartWeekday) as? Int ?? 2,
dayStartMinutes: defaults.object(forKey: SettingsKeys.dayStartMinutes) as? Int ?? 0
)
}
var calendar: Calendar {
var cal = Calendar.current
cal.firstWeekday = weekStartWeekday
return cal
}
}

View File

@ -0,0 +1,148 @@
//
// SymbolCatalog.swift
// Haru_Danim
//
// SF Symbols .
// OS .
//
import UIKit
enum SymbolCatalog {
struct Category: Identifiable {
let name: String
let symbols: [String]
var id: String { name }
}
/// ( )
static let categories: [Category] = rawCategories.map { name, symbols in
Category(name: name, symbols: symbols.filter { UIImage(systemName: $0) != nil })
}
static var allSymbols: [String] {
var seen = Set<String>()
return categories.flatMap(\.symbols).filter { seen.insert($0).inserted }
}
private static let rawCategories: [(String, [String])] = [
("인기", [
"star.fill", "heart.fill", "flame.fill", "bolt.fill", "leaf.fill",
"book.fill", "pencil", "figure.walk", "figure.run", "dumbbell.fill",
"cup.and.saucer.fill", "fork.knife", "bed.double.fill", "alarm.fill",
"laptopcomputer", "gamecontroller.fill", "music.note", "drop.fill",
"pills.fill", "brain.head.profile", "checkmark.circle.fill", "timer",
]),
("운동", [
"figure.walk", "figure.run", "figure.hiking", "figure.yoga",
"figure.cooldown", "figure.strengthtraining.traditional",
"figure.strengthtraining.functional", "figure.core.training",
"figure.cross.training", "figure.dance", "figure.boxing",
"figure.climbing", "figure.pool.swim", "figure.open.water.swim",
"figure.outdoor.cycle", "figure.indoor.cycle", "figure.golf",
"figure.tennis", "figure.badminton", "figure.basketball",
"figure.soccer", "figure.baseball", "figure.bowling",
"figure.archery", "figure.fencing", "figure.jumprope",
"figure.pilates", "figure.skiing.downhill", "figure.snowboarding",
"figure.surfing", "figure.martial.arts", "figure.mind.and.body",
"dumbbell.fill", "sportscourt.fill", "bicycle", "skateboard",
"tennisball.fill", "basketball.fill", "soccerball", "baseball.fill",
"volleyball.fill", "american.football.fill", "cricket.ball.fill",
"hockey.puck.fill", "medal.fill", "trophy.fill", "stopwatch.fill",
]),
("건강", [
"heart.fill", "heart.text.square.fill", "cross.case.fill",
"pills.fill", "pill.fill", "syringe.fill", "bandage.fill",
"stethoscope", "thermometer.variable", "lungs.fill", "brain.fill",
"brain.head.profile", "eye.fill", "ear.fill", "mouth.fill",
"hand.raised.fill", "figure.mind.and.body", "zzz", "bed.double.fill",
"drop.fill", "drop.degreesign.fill", "waveform.path.ecg",
"staroflife.fill", "leaf.fill", "carrot.fill", "scalemass.fill",
"toilet.fill", "shower.fill", "bathtub.fill", "comb.fill",
]),
("학습", [
"book.fill", "book.closed.fill", "books.vertical.fill",
"text.book.closed.fill", "bookmark.fill", "graduationcap.fill",
"pencil", "pencil.and.ruler.fill", "highlighter",
"pencil.and.outline", "square.and.pencil", "note.text",
"doc.text.fill", "doc.richtext.fill", "newspaper.fill",
"magazine.fill", "character.book.closed.fill", "translate",
"function", "sum", "percent", "x.squareroot", "number",
"abc", "textformat", "globe", "globe.asia.australia.fill",
"brain.head.profile", "lightbulb.fill", "puzzlepiece.fill",
"backpack.fill", "ruler.fill", "paperclip", "folder.fill",
]),
("일·업무", [
"briefcase.fill", "laptopcomputer", "desktopcomputer",
"keyboard.fill", "printer.fill", "envelope.fill", "phone.fill",
"video.fill", "person.2.fill", "person.3.fill", "calendar",
"calendar.badge.clock", "clock.fill", "alarm.fill", "timer",
"hourglass", "chart.bar.fill", "chart.pie.fill",
"chart.line.uptrend.xyaxis", "dollarsign.circle.fill",
"wonsign.circle.fill", "creditcard.fill", "banknote.fill",
"building.2.fill", "building.columns.fill", "signature",
"checklist", "list.bullet.clipboard.fill", "tray.full.fill",
"archivebox.fill", "presentation.fill" ,"megaphone.fill",
]),
("생활", [
"house.fill", "sofa.fill", "bed.double.fill", "lamp.desk.fill",
"lightbulb.fill", "washer.fill", "dryer.fill", "dishwasher.fill",
"refrigerator.fill", "oven.fill", "stove.fill", "microwave.fill",
"sink.fill", "shower.fill", "bathtub.fill", "toilet.fill",
"trash.fill", "basket.fill", "cart.fill", "bag.fill",
"creditcard.fill", "key.fill", "lock.fill", "hammer.fill",
"wrench.adjustable.fill", "screwdriver.fill", "paintbrush.fill",
"broom.fill" ,"bubbles.and.sparkles.fill", "tshirt.fill",
"shoe.fill", "comb.fill", "scissors", "gift.fill",
"pawprint.fill", "dog.fill", "cat.fill", "fish.fill",
"bird.fill", "teddybear.fill", "stroller.fill",
]),
("음식", [
"fork.knife", "cup.and.saucer.fill", "mug.fill", "wineglass.fill",
"waterbottle.fill", "takeoutbag.and.cup.and.straw.fill",
"birthday.cake.fill", "carrot.fill", "fish.fill",
"frying.pan.fill", "popcorn.fill", "cooktop.fill",
"drop.fill", "flame.fill", "leaf.fill",
]),
("취미", [
"gamecontroller.fill", "arcade.stick.console.fill", "dice.fill",
"puzzlepiece.fill", "music.note", "music.mic", "guitars.fill",
"pianokeys", "headphones", "hifispeaker.fill", "radio.fill",
"tv.fill", "play.rectangle.fill", "film.fill", "ticket.fill",
"theatermasks.fill", "photo.fill", "camera.fill", "video.fill",
"paintpalette.fill", "paintbrush.pointed.fill", "text.below.photo.fill",
"book.fill", "magazine.fill", "binoculars.fill", "tent.fill",
"backpack.fill", "map.fill", "mountain.2.fill", "beach.umbrella.fill",
"fishing.rod" ,"tree.fill", "camera.macro", "bird.fill",
"balloon.2.fill", "party.popper.fill", "crown.fill",
]),
("이동", [
"car.fill", "bus.fill", "tram.fill", "train.side.front.car",
"airplane", "ferry.fill", "sailboat.fill", "bicycle",
"scooter", "motorcycle.fill" ,"fuelpump.fill", "ev.charger.fill",
"figure.walk", "figure.wave", "location.fill", "map.fill",
"mappin.and.ellipse", "signpost.right.fill", "road.lanes",
"parkingsign", "steeringwheel", "suitcase.rolling.fill",
]),
("자연", [
"sun.max.fill", "moon.fill", "moon.stars.fill", "sparkles",
"cloud.fill", "cloud.rain.fill", "cloud.snow.fill",
"cloud.sun.fill", "wind", "snowflake", "drop.fill",
"flame.fill", "bolt.fill", "leaf.fill", "tree.fill",
"camera.macro", "mountain.2.fill", "water.waves",
"globe.asia.australia.fill", "star.fill", "rainbow",
]),
("기호", [
"circle.fill", "square.fill", "triangle.fill", "diamond.fill",
"seal.fill", "shield.fill", "flag.fill", "flag.checkered",
"tag.fill", "bell.fill", "bookmark.fill", "pin.fill",
"paperplane.fill", "arrow.up.circle.fill", "arrow.right.circle.fill",
"checkmark.circle.fill", "checkmark.seal.fill", "xmark.circle.fill",
"plus.circle.fill", "minus.circle.fill", "questionmark.circle.fill",
"exclamationmark.triangle.fill", "info.circle.fill",
"heart.fill", "star.fill", "moon.fill", "sun.max.fill",
"hand.thumbsup.fill", "hand.raised.fill", "face.smiling.inverse",
"number", "at", "asterisk", "infinity", "target",
]),
]
}

View File

@ -0,0 +1,58 @@
//
// Theme.swift
// Haru_Danim
//
// (CLAUDE.md §5.2)
//
import SwiftUI
import UIKit
extension Color {
/// "#RRGGBB" hex
init(hex: String) {
let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var value: UInt64 = 0
Scanner(string: cleaned).scanHexInt64(&value)
let r = Double((value >> 16) & 0xFF) / 255
let g = Double((value >> 8) & 0xFF) / 255
let b = Double(value & 0xFF) / 255
self.init(red: r, green: g, blue: b)
}
/// /
init(light: Color, dark: Color) {
self.init(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light)
})
}
/// "#RRGGBB" hex
var hexString: String {
let ui = UIColor(self)
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
ui.getRed(&r, green: &g, blue: &b, alpha: &a)
return String(
format: "#%02X%02X%02X",
Int(round(r * 255)), Int(round(g * 255)), Int(round(b * 255))
)
}
}
enum AppTheme {
/// Primary Green: /
static let green = Color(light: Color(hex: "#2F6B4F"), dark: Color(hex: "#7FBF9E"))
/// Accent Yellow: /
static let yellow = Color(light: Color(hex: "#D9A621"), dark: Color(hex: "#E8C558"))
/// Background: /
static let background = Color(light: Color(hex: "#FAFAF6"), dark: Color(hex: "#111512"))
///
static let surface = Color(light: .white, dark: Color(hex: "#1B211D"))
/// (/ )
static let tagPresets: [String] = [
"#2F6B4F", "#D9A621", "#4A7A9D", "#9D5B4A",
"#7A5C9D", "#4F8A8B", "#C06C84", "#6B705C",
"#B5651D", "#3D5A80", "#8D6B94", "#588157",
]
}

View File

@ -0,0 +1,26 @@
//
// Haru_DanimApp.swift
// Haru_Danim
//
// Created by on 7/7/26.
//
import SwiftUI
import SwiftData
@main
struct Haru_DanimApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [
Tag.self,
Action.self,
TimeSession.self,
CountEntry.self,
Goal.self,
Quest.self,
])
}
}

View File

@ -0,0 +1,359 @@
//
// ActionViews.swift
// Haru_Danim
//
// : + + / (CLAUDE.md §6.2)
//
import SwiftUI
import SwiftData
// MARK: - ( )
struct ActionListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Tag.createdAt) private var tags: [Tag]
@Query(sort: \Action.sortOrder) private var actions: [Action]
@AppStorage(SettingsKeys.isPremium) private var isPremium = false
@State private var showingAdd = false
@State private var showLimitAlert = false
private var untaggedActions: [Action] {
actions.filter { $0.tags.isEmpty }
}
var body: some View {
NavigationStack {
List {
ForEach(tags) { tag in
if !tag.actions.isEmpty {
Section {
ForEach(tag.sortedActions) { action in
NavigationLink {
ActionDetailView(action: action)
} label: {
ActionRow(action: 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
NavigationLink {
ActionDetailView(action: action)
} label: {
ActionRow(action: action)
}
}
}
}
if actions.isEmpty {
ContentUnavailableView(
"등록된 행동이 없어요",
systemImage: "figure.walk",
description: Text("오른쪽 위 + 버튼으로 행동을 추가하세요.")
)
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("행동")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
if !isPremium && actions.count >= FreeLimits.actions {
showLimitAlert = true
} else {
showingAdd = true
}
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAdd) {
ActionEditorView(action: nil)
}
.alert("무료 사용 한도", isPresented: $showLimitAlert) {
Button("확인", role: .cancel) {}
} message: {
Text("무료 버전에서는 행동을 최대 \(FreeLimits.actions)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
}
}
}
}
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) {
Text(action.name)
.font(.body.weight(.medium))
Text(action.trackingType.label)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
// 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)
}
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 {
ToolbarItem(placement: .topBarTrailing) {
Button("수정") { showingEdit = true }
}
}
.sheet(isPresented: $showingEdit) {
ActionEditorView(action: action)
}
.confirmationDialog(
"\(action.name) 행동을 삭제할까요? 기록도 함께 삭제됩니다.",
isPresented: $showingDelete,
titleVisibility: .visible
) {
Button("삭제", role: .destructive) {
context.delete(action)
dismiss()
}
Button("취소", role: .cancel) {}
}
}
@ViewBuilder
private var statsRows: some View {
let agg = Aggregator()
let math = agg.math
let now = Date.now
switch action.trackingType {
case .time:
statRow("오늘", Format.durationShort(agg.seconds(for: action, in: math.dayRange(containing: now))))
statRow("이번 주", Format.durationShort(agg.seconds(for: action, in: math.weekRange(containing: now))))
statRow("이번 달", Format.durationShort(agg.seconds(for: action, in: math.monthRange(containing: now))))
case .count:
statRow("오늘", "\(agg.count(for: action, in: math.dayRange(containing: now)))")
statRow("이번 주", "\(agg.count(for: action, in: math.weekRange(containing: now)))")
statRow("이번 달", "\(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 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)
}
}
.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)
}
}
}
}
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()
}
}
.navigationTitle(action == nil ? "행동 추가" : "행동 수정")
.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
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
} else {
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)
}
dismiss()
}
}

View File

@ -0,0 +1,441 @@
//
// GoalViews.swift
// Haru_Danim
//
// : // + (CLAUDE.md §6.4)
//
import SwiftUI
import SwiftData
// MARK: -
struct GoalListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Goal.createdAt) private var goals: [Goal]
@AppStorage(SettingsKeys.isPremium) private var isPremium = false
@State private var showingAdd = false
@State private var showLimitAlert = false
var body: some View {
NavigationStack {
List {
ForEach(goals) { goal in
Section {
NavigationLink {
GoalDetailView(goal: goal)
} label: {
GoalRow(goal: goal)
}
ForEach(goal.sortedQuests) { quest in
QuestRow(quest: quest)
}
}
}
if goals.isEmpty {
ContentUnavailableView(
"목표가 없어요",
systemImage: "flag.checkered",
description: Text("큰 목표를 세우고, 그 안에 다짐을 추가해 보세요.")
)
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("목표")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
if !isPremium && goals.count >= FreeLimits.goals {
showLimitAlert = true
} else {
showingAdd = true
}
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAdd) {
GoalEditorView(goal: nil)
}
.alert("무료 사용 한도", isPresented: $showLimitAlert) {
Button("확인", role: .cancel) {}
} message: {
Text("무료 버전에서는 목표를 최대 \(FreeLimits.goals)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
}
.onAppear {
let math = DayMath()
for goal in goals {
goal.evaluateIfEnded(math: math)
}
}
}
}
}
// MARK: - ( )
struct GoalRow: View {
@Environment(\.modelContext) private var context
let goal: Goal
@State private var showingConfirm = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Image(systemName: goal.symbolName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
.background(goal.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text(goal.title)
.font(.body.weight(.semibold))
Text(periodLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
statusBadge
}
if goal.needsConfirmation() {
Button {
showingConfirm = true
} label: {
Label("달성했나요? 탭해서 선택", systemImage: "questionmark.circle.fill")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.yellow)
}
.buttonStyle(.plain)
} else if goal.status == .inProgress, let progress = goal.dateProgress() {
VStack(alignment: .leading, spacing: 3) {
ProgressView(value: progress)
.tint(goal.color)
Text("기간 진행률 \(Format.percent(progress))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 2)
.confirmationDialog(
"\(goal.title) 목표를 달성했나요?",
isPresented: $showingConfirm,
titleVisibility: .visible
) {
Button("달성했어요") { goal.status = .achieved }
Button("달성하지 못했어요", role: .destructive) { goal.status = .notAchieved }
Button("취소", role: .cancel) {}
}
}
private var periodLabel: String {
if let end = goal.endDate {
return "\(Format.shortDate(goal.startDate)) ~ \(Format.shortDate(end))"
}
return "\(Format.shortDate(goal.startDate)) 시작 · 종료일 없음"
}
@ViewBuilder
private var statusBadge: some View {
let (text, color): (String, Color) = {
if goal.needsConfirmation() { return ("확인 필요", AppTheme.yellow) }
switch goal.status {
case .inProgress: return ("진행 중", AppTheme.green)
case .achieved: return ("달성", AppTheme.green)
case .notAchieved: return ("미달성", Color.secondary)
}
}()
Text(text)
.font(.caption2.weight(.bold))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color.opacity(0.15), in: Capsule())
.foregroundStyle(color)
}
}
// MARK: - (// )
struct QuestRow: View {
let quest: Quest
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: quest.targetSymbol)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 24, height: 24)
.background(quest.targetColor, in: RoundedRectangle(cornerRadius: 6, style: .continuous))
Text(quest.targetName)
.font(.subheadline.weight(.medium))
Spacer()
Text("\(quest.scheduleLabel) · \(quest.targetValueLabel) \(quest.direction.label)")
.font(.caption2)
.foregroundStyle(.secondary)
}
HStack(spacing: 10) {
ForEach(StatSpan.allCases) { span in
spanGauge(span)
}
}
}
.padding(.leading, 8)
.padding(.vertical, 2)
}
private func spanGauge(_ span: StatSpan) -> some View {
let progress = QuestProgress(quest: quest)
let result = progress.spanProgress(span)
let over = quest.direction == .atMost && result.value > result.target
return VStack(alignment: .leading, spacing: 2) {
Text(span.label)
.font(.caption2)
.foregroundStyle(.secondary)
ProgressView(value: result.ratio)
.tint(over ? .red : quest.targetColor)
Text(Format.percent(result.target > 0 ? result.value / result.target : 0))
.font(.caption2.weight(.semibold).monospacedDigit())
.foregroundStyle(over ? .red : .primary)
}
.frame(maxWidth: .infinity)
}
}
// MARK: -
struct GoalDetailView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@AppStorage(SettingsKeys.isPremium) private var isPremium = false
let goal: Goal
@State private var showingEdit = false
@State private var showingDelete = false
@State private var showingAddQuest = false
@State private var showQuestLimitAlert = false
@State private var showingManualFinish = false
@State private var askManualResult = false
@State private var editingQuest: Quest?
var body: some View {
List {
Section("목표") {
GoalRow(goal: goal)
}
Section {
ForEach(goal.sortedQuests) { quest in
Button {
editingQuest = quest
} label: {
QuestRow(quest: quest)
}
.buttonStyle(.plain)
.swipeActions {
Button("삭제", role: .destructive) {
context.delete(quest)
}
}
}
Button {
if !isPremium && goal.quests.count >= FreeLimits.questsPerGoal {
showQuestLimitAlert = true
} else {
showingAddQuest = true
}
} label: {
Label("다짐 추가", systemImage: "plus.circle")
.foregroundStyle(AppTheme.green)
}
} header: {
Text("다짐")
} footer: {
if goal.quests.isEmpty {
Text("이 목표를 이루기 위한 다짐(행동/꼬리표 + 주기 + 목표량)을 추가하세요.")
}
}
if goal.status == .inProgress && goal.endDate == nil {
Section {
Button("목표 수동 종료") {
showingManualFinish = true
}
.foregroundStyle(AppTheme.yellow)
} footer: {
Text("종료 시 하위 다짐들의 달성 여부에 따라 달성/미달성이 결정돼요.")
}
}
Section {
Button("목표 삭제", role: .destructive) {
showingDelete = true
}
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle(goal.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("수정") { showingEdit = true }
}
}
.sheet(isPresented: $showingEdit) {
GoalEditorView(goal: goal)
}
.sheet(isPresented: $showingAddQuest) {
QuestEditorView(goal: goal, quest: nil)
}
.sheet(item: $editingQuest) { quest in
QuestEditorView(goal: goal, quest: quest)
}
.alert("무료 사용 한도", isPresented: $showQuestLimitAlert) {
Button("확인", role: .cancel) {}
} message: {
Text("무료 버전에서는 목표당 다짐을 최대 \(FreeLimits.questsPerGoal)개까지 만들 수 있어요.")
}
.confirmationDialog(
"목표를 지금 종료할까요?",
isPresented: $showingManualFinish,
titleVisibility: .visible
) {
Button("종료") {
if goal.manualFinish() == nil {
askManualResult = true
}
}
Button("취소", role: .cancel) {}
}
.confirmationDialog(
"다짐이 없는 목표예요. 달성했나요?",
isPresented: $askManualResult,
titleVisibility: .visible
) {
Button("달성했어요") { goal.status = .achieved }
Button("달성하지 못했어요", role: .destructive) { goal.status = .notAchieved }
Button("취소", role: .cancel) {}
}
.confirmationDialog(
"\(goal.title) 목표를 삭제할까요? 다짐도 함께 삭제됩니다.",
isPresented: $showingDelete,
titleVisibility: .visible
) {
Button("삭제", role: .destructive) {
context.delete(goal)
dismiss()
}
Button("취소", role: .cancel) {}
}
}
}
// MARK: - /
struct GoalEditorView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
/// nil
let goal: Goal?
@State private var title = ""
@State private var symbolName = "flag.fill"
@State private var color: Color = Color(hex: "#2F6B4F")
@State private var startDate: Date = .now
@State private var hasEndDate = false
@State private var endDate: Date = .now
@State private var showingSymbolPicker = false
var body: some View {
NavigationStack {
Form {
Section("목표 내용") {
TextField("예: 토익 700점 이상 받기", text: $title, axis: .vertical)
}
Section("아이콘과 색") {
Button {
showingSymbolPicker = true
} label: {
HStack {
Image(systemName: symbolName)
.font(.system(size: 18))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
.background(color, in: RoundedRectangle(cornerRadius: 9))
Text("아이콘 선택")
.foregroundStyle(.primary)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
ColorPicker("", selection: $color, supportsOpacity: false)
}
Section("기간") {
DatePicker("시작일 (과거 가능)", selection: $startDate, displayedComponents: .date)
Toggle("종료일 지정", isOn: $hasEndDate.animation())
if hasEndDate {
DatePicker(
"종료일",
selection: $endDate,
in: startDate...,
displayedComponents: .date
)
}
}
}
.navigationTitle(goal == nil ? "목표 추가" : "목표 수정")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") { save() }
.disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.sheet(isPresented: $showingSymbolPicker) {
SymbolPickerView(selection: $symbolName)
}
.onAppear(perform: load)
}
}
private func load() {
guard let goal else { return }
title = goal.title
symbolName = goal.symbolName
color = goal.color
startDate = goal.startDate
if let end = goal.endDate {
hasEndDate = true
endDate = end
}
}
private func save() {
let trimmed = title.trimmingCharacters(in: .whitespaces)
if let goal {
goal.title = trimmed
goal.symbolName = symbolName
goal.colorHex = color.hexString
goal.startDate = startDate
goal.endDate = hasEndDate ? endDate : nil
} else {
context.insert(Goal(
title: trimmed,
symbolName: symbolName,
colorHex: color.hexString,
startDate: startDate,
endDate: hasEndDate ? endDate : nil
))
}
dismiss()
}
}

View File

@ -0,0 +1,631 @@
//
// HistoryView.swift
// Haru_Danim
//
// : / (·) / (CLAUDE.md §6.5)
//
import SwiftUI
import SwiftData
import Charts
enum HistoryMode: String, CaseIterable, Identifiable {
case list, timetable, stats
var id: String { rawValue }
var label: String {
switch self {
case .list: return "목록"
case .timetable: return "타임테이블"
case .stats: return "통계"
}
}
}
// MARK: -
struct SessionSegmentItem: Identifiable {
let session: TimeSession
let action: Action
let range: Range<Date>
var id: String {
"\(String(describing: session.persistentModelID))-\(range.lowerBound.timeIntervalSinceReferenceDate)"
}
var duration: TimeInterval { range.upperBound.timeIntervalSince(range.lowerBound) }
}
struct CountItem: Identifiable {
let entry: CountEntry
let action: Action
var id: String { String(describing: entry.persistentModelID) }
}
// MARK: -
struct HistoryView: View {
@Query private var sessions: [TimeSession]
@Query private var entries: [CountEntry]
@State private var selectedDayKey: Date = DayMath().dayKey(for: .now)
@State private var mode: HistoryMode = {
#if DEBUG
if let raw = UserDefaults.standard.string(forKey: "historyMode"),
let mode = HistoryMode(rawValue: raw) {
return mode
}
#endif
return .list
}()
@State private var timetableWeekly = false
@State private var statSpan: StatSpan = .day
@State private var editingSession: TimeSession?
@State private var editingEntry: CountEntry?
private var math: DayMath { DayMath() }
private var dayRange: Range<Date> { math.dayRange(forKey: selectedDayKey) }
var body: some View {
NavigationStack {
VStack(spacing: 0) {
dateHeader
Picker("보기", selection: $mode) {
ForEach(HistoryMode.allCases) { m in
Text(m.label).tag(m)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.bottom, 8)
switch mode {
case .list:
listView
case .timetable:
TimetableView(
weekly: $timetableWeekly,
selectedDayKey: selectedDayKey,
segments: segments(in:),
counts: counts(in:),
math: math,
onTapSession: { editingSession = $0 },
onTapEntry: { editingEntry = $0 }
)
case .stats:
StatsView(
statSpan: $statSpan,
selectedDayKey: selectedDayKey,
segments: segments(in:),
counts: counts(in:),
math: math
)
}
}
.background(AppTheme.background)
.navigationTitle("기록")
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $editingSession) { session in
SessionEditorView(session: session)
}
.sheet(item: $editingEntry) { entry in
CountEntryEditorView(entry: entry)
}
}
}
// MARK:
private var dateHeader: some View {
HStack {
Button {
moveDay(-1)
} label: {
Image(systemName: "chevron.left")
}
Spacer()
VStack(spacing: 1) {
Text(Format.fullDate(selectedDayKey))
.font(.headline)
if math.dayKey(for: .now) == selectedDayKey {
Text("오늘")
.font(.caption2)
.foregroundStyle(AppTheme.green)
}
}
Spacer()
Button {
moveDay(1)
} label: {
Image(systemName: "chevron.right")
}
Button("오늘") {
selectedDayKey = math.dayKey(for: .now)
}
.font(.caption)
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
}
.padding(.horizontal)
.padding(.vertical, 10)
.tint(AppTheme.green)
}
private func moveDay(_ delta: Int) {
selectedDayKey = math.calendar.date(byAdding: .day, value: delta, to: selectedDayKey)!
}
// MARK: ( )
private func segments(in range: Range<Date>) -> [SessionSegmentItem] {
let now = Date.now
var items: [SessionSegmentItem] = []
for session in sessions {
guard let action = session.action else { continue }
let end = session.endAt ?? now
let lower = max(session.startAt, range.lowerBound)
let upper = min(end, range.upperBound)
guard lower < upper else { continue }
items.append(SessionSegmentItem(session: session, action: action, range: lower..<upper))
}
return items.sorted { $0.range.lowerBound < $1.range.lowerBound }
}
private func counts(in range: Range<Date>) -> [CountItem] {
entries
.filter { range.contains($0.timestamp) }
.compactMap { entry in
entry.action.map { CountItem(entry: entry, action: $0) }
}
.sorted { $0.entry.timestamp < $1.entry.timestamp }
}
// MARK:
private var listView: some View {
let daySegments = segments(in: dayRange)
let dayCounts = counts(in: dayRange)
return List {
Section("시간 기록") {
if daySegments.isEmpty {
Text("시간 기록이 없어요").foregroundStyle(.secondary)
}
ForEach(daySegments) { item in
Button {
editingSession = item.session
} label: {
HStack(spacing: 10) {
Image(systemName: item.action.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(item.action.color, in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 1) {
Text(item.action.name)
.font(.subheadline.weight(.medium))
Text("\(Format.time(item.range.lowerBound)) ~ \(item.session.endAt == nil ? "진행 중" : Format.time(item.range.upperBound))")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(Format.durationShort(item.duration))
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
}
}
.buttonStyle(.plain)
}
}
Section("횟수 기록") {
if dayCounts.isEmpty {
Text("횟수 기록이 없어요").foregroundStyle(.secondary)
}
ForEach(dayCounts) { item in
Button {
editingEntry = item.entry
} label: {
HStack(spacing: 10) {
Image(systemName: item.action.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(item.action.color, in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 1) {
Text(item.action.name)
.font(.subheadline.weight(.medium))
Text(Format.time(item.entry.timestamp))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text("+\(item.entry.amount)")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
}
}
.buttonStyle(.plain)
}
}
}
.scrollContentBackground(.hidden)
}
}
// MARK: -
struct TimetableView: View {
@Binding var weekly: Bool
let selectedDayKey: Date
let segments: (Range<Date>) -> [SessionSegmentItem]
let counts: (Range<Date>) -> [CountItem]
let math: DayMath
let onTapSession: (TimeSession) -> Void
let onTapEntry: (CountEntry) -> Void
private var hourHeight: CGFloat { weekly ? 26 : 48 }
var body: some View {
VStack(spacing: 8) {
Picker("범위", selection: $weekly) {
Text("하루").tag(false)
Text("일주일").tag(true)
}
.pickerStyle(.segmented)
.padding(.horizontal)
ScrollView {
HStack(alignment: .top, spacing: 4) {
hourLabels
if weekly {
weekColumns
} else {
dayColumn(for: selectedDayKey)
.frame(maxWidth: .infinity)
}
}
.padding(.horizontal)
.padding(.bottom, 20)
}
}
}
private var startHour: Int {
let start = math.dayRange(forKey: selectedDayKey).lowerBound
return math.calendar.component(.hour, from: start)
}
private var hourLabels: some View {
VStack(alignment: .trailing, spacing: 0) {
ForEach(0..<24, id: \.self) { i in
Text(String(format: "%02d", (startHour + i) % 24))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.frame(height: hourHeight, alignment: .top)
}
}
.frame(width: 26)
}
private var weekColumns: some View {
let weekRange = math.weekRange(containing: math.dayRange(forKey: selectedDayKey).lowerBound)
let keys = math.dayKeys(in: weekRange)
return HStack(alignment: .top, spacing: 3) {
ForEach(keys, id: \.self) { key in
VStack(spacing: 2) {
Text(Format.weekdayShort(math.calendar.component(.weekday, from: key)))
.font(.caption2)
.foregroundStyle(key == selectedDayKey ? AppTheme.green : .secondary)
dayColumn(for: key)
}
.frame(maxWidth: .infinity)
}
}
}
private func dayColumn(for key: Date) -> some View {
let range = math.dayRange(forKey: key)
let daySegments = segments(range)
let dayCounts = counts(range)
return GeometryReader { geo in
ZStack(alignment: .topLeading) {
gridLines(width: geo.size.width)
ForEach(daySegments) { item in
block(item, in: range, width: geo.size.width)
}
ForEach(Array(dayCounts.enumerated()), id: \.element.id) { index, item in
marker(item, in: range, width: geo.size.width, index: index)
}
}
}
.frame(height: hourHeight * 24)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
private func gridLines(width: CGFloat) -> some View {
VStack(spacing: 0) {
ForEach(0..<24, id: \.self) { _ in
Divider().opacity(0.4)
Spacer(minLength: 0)
}
}
.frame(width: width, height: hourHeight * 24)
}
private func yOffset(for date: Date, in range: Range<Date>) -> CGFloat {
let seconds = date.timeIntervalSince(range.lowerBound)
return CGFloat(seconds / 3600) * hourHeight
}
private func block(_ item: SessionSegmentItem, in range: Range<Date>, width: CGFloat) -> some View {
let y = yOffset(for: item.range.lowerBound, in: range)
let height = max(CGFloat(item.duration / 3600) * hourHeight, 8)
return Button {
onTapSession(item.session)
} label: {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(item.action.color.opacity(0.85))
.overlay(alignment: .topLeading) {
if !weekly && height >= 18 {
Label(item.action.name, systemImage: item.action.symbolName)
.font(.caption2.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(1)
.padding(4)
} else if height >= 14 {
Image(systemName: item.action.symbolName)
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
.padding(2)
}
}
}
.buttonStyle(.plain)
.frame(width: max(width - 4, 8), height: height)
.offset(x: 2, y: y)
}
private func marker(_ item: CountItem, in range: Range<Date>, width: CGFloat, index: Int) -> some View {
let y = yOffset(for: item.entry.timestamp, in: range)
let size: CGFloat = weekly ? 10 : 18
let x = 4 + CGFloat(index % 5) * (size + 4)
return Button {
onTapEntry(item.entry)
} label: {
ZStack {
Circle()
.fill(item.action.color)
Circle()
.strokeBorder(AppTheme.surface, lineWidth: 2)
if !weekly {
Image(systemName: item.action.symbolName)
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
}
}
.frame(width: size, height: size)
}
.buttonStyle(.plain)
.offset(x: min(x, width - size), y: max(y - size / 2, 0))
}
}
// MARK: - (Swift Charts)
struct TagStat: Identifiable {
let name: String
let color: Color
let seconds: TimeInterval
let count: Int
var id: String { name }
}
struct DailyTagStat: Identifiable {
let dayKey: Date
let tagName: String
let color: Color
let hours: Double
var id: String { "\(dayKey.timeIntervalSinceReferenceDate)-\(tagName)" }
}
struct StatsView: View {
@Binding var statSpan: StatSpan
let selectedDayKey: Date
let segments: (Range<Date>) -> [SessionSegmentItem]
let counts: (Range<Date>) -> [CountItem]
let math: DayMath
private var spanRange: Range<Date> {
let anchor = math.dayRange(forKey: selectedDayKey).lowerBound
switch statSpan {
case .day: return math.dayRange(containing: anchor)
case .week: return math.weekRange(containing: anchor)
case .month: return math.monthRange(containing: anchor)
}
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Picker("기간", selection: $statSpan) {
ForEach(StatSpan.allCases) { span in
Text(span.label).tag(span)
}
}
.pickerStyle(.segmented)
let stats = tagStats(in: spanRange)
timeChartCard(stats)
countChartCard(stats)
weeklyTrendCard
}
.padding()
}
}
// MARK:
private func tagStats(in range: Range<Date>) -> [TagStat] {
var seconds: [String: TimeInterval] = [:]
var countsByTag: [String: Int] = [:]
var colors: [String: Color] = [:]
func tagNames(for action: Action) -> [(String, Color)] {
if action.tags.isEmpty { return [("꼬리표 없음", Color.gray)] }
return action.sortedTags.map { ($0.name, $0.color) }
}
for item in segments(range) {
for (name, color) in tagNames(for: item.action) {
seconds[name, default: 0] += item.duration
colors[name] = color
}
}
for item in counts(range) {
for (name, color) in tagNames(for: item.action) {
countsByTag[name, default: 0] += item.entry.amount
colors[name] = color
}
}
let names = Set(seconds.keys).union(countsByTag.keys)
return names
.map { name in
TagStat(
name: name,
color: colors[name] ?? .gray,
seconds: seconds[name] ?? 0,
count: countsByTag[name] ?? 0
)
}
.sorted { $0.seconds > $1.seconds }
}
// MARK:
@ViewBuilder
private func timeChartCard(_ stats: [TagStat]) -> some View {
let timeStats = stats.filter { $0.seconds > 0 }
chartCard("꼬리표별 시간 투자") {
if timeStats.isEmpty {
emptyChartText
} else {
Chart(timeStats) { stat in
BarMark(
x: .value("시간", stat.seconds / 3600),
y: .value("꼬리표", stat.name)
)
.foregroundStyle(stat.color)
.cornerRadius(4)
.annotation(position: .trailing) {
Text(Format.durationShort(stat.seconds))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartXAxisLabel("시간(h)")
.frame(height: CGFloat(timeStats.count) * 44 + 30)
}
}
}
@ViewBuilder
private func countChartCard(_ stats: [TagStat]) -> some View {
let countStats = stats.filter { $0.count > 0 }.sorted { $0.count > $1.count }
chartCard("꼬리표별 횟수 달성") {
if countStats.isEmpty {
emptyChartText
} else {
Chart(countStats) { stat in
BarMark(
x: .value("횟수", stat.count),
y: .value("꼬리표", stat.name)
)
.foregroundStyle(stat.color)
.cornerRadius(4)
.annotation(position: .trailing) {
Text("\(stat.count)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartXAxisLabel("횟수")
.frame(height: CGFloat(countStats.count) * 44 + 30)
}
}
}
/// 7 ( )
private var weeklyTrendCard: some View {
let data = recentDailyStats()
let names = orderedNames(from: data)
let colors = orderedColors(from: data, names: names)
return chartCard("최근 7일 시간 추이") {
if data.isEmpty {
emptyChartText
} else {
Chart(data) { item in
BarMark(
x: .value("날짜", item.dayKey, unit: .day),
y: .value("시간", item.hours)
)
.foregroundStyle(by: .value("꼬리표", item.tagName))
.cornerRadius(3)
}
.chartForegroundStyleScale(domain: names, range: colors)
.chartYAxisLabel("시간(h)")
.frame(height: 220)
}
}
}
private func recentDailyStats() -> [DailyTagStat] {
var result: [DailyTagStat] = []
for offset in (0..<7).reversed() {
guard let key = math.calendar.date(byAdding: .day, value: -offset, to: selectedDayKey) else { continue }
let range = math.dayRange(forKey: key)
for stat in tagStats(in: range) where stat.seconds > 0 {
result.append(DailyTagStat(
dayKey: key,
tagName: stat.name,
color: stat.color,
hours: stat.seconds / 3600
))
}
}
return result
}
private func orderedNames(from data: [DailyTagStat]) -> [String] {
var seen = Set<String>()
return data.map(\.tagName).filter { seen.insert($0).inserted }
}
private func orderedColors(from data: [DailyTagStat], names: [String]) -> [Color] {
names.map { name in
data.first { $0.tagName == name }?.color ?? .gray
}
}
private func chartCard(_ title: String, @ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.subheadline.weight(.semibold))
content()
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
private var emptyChartText: some View {
Text("표시할 기록이 없어요")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
}
}

View File

@ -0,0 +1,364 @@
//
// MainView.swift
// Haru_Danim
//
// : + + (CLAUDE.md §6.1)
//
import SwiftUI
import SwiftData
import UniformTypeIdentifiers
struct MainView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Action.sortOrder) private var actions: [Action]
@Query(filter: #Predicate<TimeSession> { $0.endAt == nil }, sort: \TimeSession.startAt)
private var runningSessions: [TimeSession]
@State private var isEditing = false
@State private var jiggle = false
@State private var draggingAction: Action?
@State private var settingsEditingAction: Action?
@State private var recordsAction: Action?
@State private var deletingAction: Action?
private var anyRunning: Bool { !runningSessions.isEmpty }
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if anyRunning {
runningArea
}
if actions.isEmpty {
emptyState
} else {
grid
}
}
.padding()
}
.background(AppTheme.background)
.navigationTitle("하루 다님")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(isEditing ? "완료" : "배치 편집") {
withAnimation {
isEditing.toggle()
jiggle = isEditing
}
}
.fontWeight(isEditing ? .bold : .regular)
}
}
.sheet(item: $settingsEditingAction) { action in
ActionEditorView(action: action)
}
.sheet(item: $recordsAction) { action in
ActionRecordsSheet(action: action)
}
.confirmationDialog(
"\(deletingAction?.name ?? "") 행동을 삭제할까요? 기록도 함께 삭제됩니다.",
isPresented: Binding(
get: { deletingAction != nil },
set: { if !$0 { deletingAction = nil } }
),
titleVisibility: .visible
) {
Button("삭제", role: .destructive) {
if let action = deletingAction {
context.delete(action)
}
deletingAction = nil
}
Button("취소", role: .cancel) { deletingAction = nil }
}
}
}
// MARK:
private var runningArea: some View {
VStack(alignment: .leading, spacing: 8) {
Label("현재 진행 중", systemImage: "record.circle")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
ForEach(runningSessions) { session in
if let action = session.action {
RunningSessionRow(session: session, action: action) {
stop(session)
}
}
}
}
.padding(14)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
// MARK:
private var grid: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 104), spacing: 12)], spacing: 12) {
ForEach(actions) { action in
cell(for: action)
}
}
.animation(.default, value: actions.map(\.persistentModelID))
}
@ViewBuilder
private func cell(for action: Action) -> some View {
let base = ActionButtonCell(action: action, isEditing: isEditing) {
handleTap(action)
}
.rotationEffect(.degrees(isEditing ? (jiggle ? 1.6 : -1.6) : 0))
.animation(
isEditing ? .easeInOut(duration: 0.13).repeatForever(autoreverses: true) : .default,
value: jiggle
)
if isEditing {
base
.onDrag {
draggingAction = action
return NSItemProvider(object: action.name as NSString)
}
.onDrop(
of: [UTType.text],
delegate: ActionReorderDelegate(
item: action,
dragging: $draggingAction,
move: moveDragging(to:)
)
)
} else {
base
.contextMenu {
Button {
recordsAction = action
} label: {
Label(
action.trackingType == .time ? "시작·종료 시각 수동 입력" : "횟수 직접 입력·수정",
systemImage: "square.and.pencil"
)
}
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")
.font(.system(size: 44))
.foregroundStyle(.tertiary)
Text("아직 등록된 행동이 없어요")
.font(.headline)
Text("행동 탭에서 추적할 행동을 추가해 보세요.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 60)
}
// MARK:
private func handleTap(_ action: Action) {
guard !isEditing else { return }
switch action.trackingType {
case .time:
if let session = action.runningSession {
session.endAt = .now
} else {
context.insert(TimeSession(action: action, startAt: .now))
}
case .count:
context.insert(CountEntry(action: action, timestamp: .now))
}
}
private func stop(_ session: TimeSession) {
session.endAt = .now
}
private func moveDragging(to target: Action) {
guard let dragging = draggingAction, dragging != target else { return }
var ordered = actions
guard let from = ordered.firstIndex(of: dragging),
let to = ordered.firstIndex(of: target) else { return }
ordered.remove(at: from)
ordered.insert(dragging, at: to)
for (index, action) in ordered.enumerated() {
action.sortOrder = index
}
}
}
// MARK: -
private struct ActionReorderDelegate: DropDelegate {
let item: Action
@Binding var dragging: Action?
let move: (Action) -> Void
func dropEntered(info: DropInfo) {
move(item)
}
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
dragging = nil
return true
}
}
// MARK: - ( )
struct RunningSessionRow: View {
let session: TimeSession
let action: Action
let onStop: () -> Void
var body: some View {
HStack(spacing: 10) {
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: 1) {
Text(action.name)
.font(.subheadline.weight(.semibold))
Text("\(Format.time(session.startAt)) 시작")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
TimelineView(.periodic(from: .now, by: 1)) { timeline in
Text(Format.timer(timeline.date.timeIntervalSince(session.startAt)))
.font(.body.weight(.semibold).monospacedDigit())
.foregroundStyle(AppTheme.green)
}
Button(action: onStop) {
Image(systemName: "stop.circle.fill")
.font(.system(size: 28))
.foregroundStyle(AppTheme.yellow)
}
.buttonStyle(.plain)
}
}
}
// MARK: -
struct ActionButtonCell: View {
let action: Action
let isEditing: Bool
let onTap: () -> Void
@State private var bounce = false
var body: some View {
Button {
onTap()
if action.trackingType == .count {
bounce.toggle()
}
} label: {
VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: action.symbolName)
.font(.system(size: 26, weight: .medium))
.foregroundStyle(.white)
Spacer()
if action.isRunning {
Image(systemName: "record.circle")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(AppTheme.yellow)
.symbolEffect(.pulse, options: .repeating)
}
}
Spacer(minLength: 0)
Text(action.name)
.font(.footnote.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(2)
.multilineTextAlignment(.leading)
footer
}
.padding(12)
.frame(minHeight: 104, alignment: .topLeading)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(action.color.gradient)
)
.overlay {
if action.isRunning {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(AppTheme.yellow, lineWidth: 3)
}
}
.scaleEffect(bounce ? 1.0 : 1.0)
}
.buttonStyle(PressableButtonStyle())
.sensoryFeedback(.impact(weight: .light), trigger: bounce)
.disabled(isEditing)
}
@ViewBuilder
private var footer: some View {
switch action.trackingType {
case .time:
if let session = action.runningSession {
TimelineView(.periodic(from: .now, by: 1)) { timeline in
Text(Format.timer(timeline.date.timeIntervalSince(session.startAt)))
.font(.caption.weight(.bold).monospacedDigit())
.foregroundStyle(AppTheme.yellow)
}
} else {
Text(todayLabel)
.font(.caption2)
.foregroundStyle(.white.opacity(0.85))
}
case .count:
Text(todayLabel)
.font(.caption2)
.foregroundStyle(.white.opacity(0.85))
}
}
private var todayLabel: String {
let agg = Aggregator()
switch action.trackingType {
case .time:
let seconds = agg.seconds(for: action, in: agg.math.dayRange(containing: .now))
return seconds > 0 ? "오늘 \(Format.durationShort(seconds))" : "오늘 0분"
case .count:
let count = agg.count(for: action, in: agg.math.dayRange(containing: .now))
return "오늘 \(count)"
}
}
}
struct PressableButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.93 : 1)
.animation(.spring(duration: 0.2), value: configuration.isPressed)
}
}

View File

@ -0,0 +1,318 @@
//
// QuestEditorView.swift
// Haru_Danim
//
// /: (/) + (CLAUDE.md §6.4)
//
import SwiftUI
import SwiftData
struct QuestEditorView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Query(sort: \Action.sortOrder) private var allActions: [Action]
@Query(sort: \Tag.createdAt) private var allTags: [Tag]
let goal: Goal
/// nil
let quest: Quest?
enum TargetKind: String, CaseIterable, Identifiable {
case action, tag
var id: String { rawValue }
var label: String { self == .action ? "행동" : "꼬리표" }
}
//
@State private var targetKind: TargetKind = .action
@State private var selectedAction: Action?
@State private var selectedTag: Tag?
@State private var measure: TrackingType = .time
//
@State private var period: QuestPeriod = .daily
@State private var scheduleMode: QuestScheduleMode = .everyDay
@State private var weekdays: Set<Int> = []
@State private var monthDays: Set<Int> = []
@State private var ordinalWeek = 1
@State private var ordinalWeekday = 2
@State private var customStart: Date = .now
@State private var customEnd: Date = .now
// +
@State private var targetHours = 1
@State private var targetMinutes = 0
@State private var targetCount = 1
@State private var direction: QuestDirection = .atLeast
var body: some View {
NavigationStack {
Form {
targetSection
periodSection
amountSection
}
.navigationTitle(quest == nil ? "다짐 추가" : "다짐 수정")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") { save() }
.disabled(!isValid)
}
}
.onAppear(perform: load)
}
}
// MARK:
private var targetSection: some View {
Section("① 대상 선택") {
Picker("대상 종류", selection: $targetKind.animation()) {
ForEach(TargetKind.allCases) { kind in
Text(kind.label).tag(kind)
}
}
.pickerStyle(.segmented)
switch targetKind {
case .action:
if allActions.isEmpty {
Text("등록된 행동이 없어요").foregroundStyle(.secondary)
} else {
Picker("행동", selection: $selectedAction) {
Text("선택").tag(Action?.none)
ForEach(allActions) { action in
Label(action.name, systemImage: action.symbolName)
.tag(Optional(action))
}
}
}
case .tag:
if allTags.isEmpty {
Text("등록된 꼬리표가 없어요").foregroundStyle(.secondary)
} else {
Picker("꼬리표", selection: $selectedTag) {
Text("선택").tag(Tag?.none)
ForEach(allTags) { tag in
Text(tag.name).tag(Optional(tag))
}
}
Picker("측정 기준", selection: $measure) {
ForEach(TrackingType.allCases) { type in
Text(type.label).tag(type)
}
}
}
}
}
}
// MARK:
@ViewBuilder
private var periodSection: some View {
Section("② 주기 선택") {
Picker("반복 주기", selection: $period.animation()) {
ForEach(QuestPeriod.allCases) { p in
Text(p.label).tag(p)
}
}
if period == .daily {
Picker("적용일", selection: $scheduleMode.animation()) {
ForEach(QuestScheduleMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
switch scheduleMode {
case .everyDay:
EmptyView()
case .weekdays:
weekdayPicker
case .monthDays:
monthDayPicker
case .ordinalWeekday:
Picker("몇째 주", selection: $ordinalWeek) {
ForEach(1...5, id: \.self) { n in
Text("\(n)째 주").tag(n)
}
}
Picker("요일", selection: $ordinalWeekday) {
ForEach(1...7, id: \.self) { w in
Text("\(Format.weekdayShort(w))요일").tag(w)
}
}
}
}
if period == .custom {
DatePicker("시작", selection: $customStart, displayedComponents: .date)
DatePicker("", selection: $customEnd, in: customStart..., displayedComponents: .date)
}
}
}
private var weekdayPicker: some View {
HStack(spacing: 6) {
ForEach(1...7, id: \.self) { weekday in
let on = weekdays.contains(weekday)
Button {
if on { weekdays.remove(weekday) } else { weekdays.insert(weekday) }
} label: {
Text(Format.weekdayShort(weekday))
.font(.subheadline.weight(.semibold))
.frame(width: 36, height: 36)
.background(on ? AppTheme.green : AppTheme.surface, in: Circle())
.foregroundStyle(on ? Color.white : Color.primary)
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity)
}
private var monthDayPicker: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 7), spacing: 6) {
ForEach(1...31, id: \.self) { day in
let on = monthDays.contains(day)
Button {
if on { monthDays.remove(day) } else { monthDays.insert(day) }
} label: {
Text("\(day)")
.font(.footnote.weight(.medium))
.frame(width: 34, height: 34)
.background(on ? AppTheme.green : AppTheme.surface, in: Circle())
.foregroundStyle(on ? Color.white : Color.primary)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
// MARK: +
private var effectiveMeasure: TrackingType {
targetKind == .action ? (selectedAction?.trackingType ?? .time) : measure
}
@ViewBuilder
private var amountSection: some View {
Section {
if effectiveMeasure == .time {
HStack {
Picker("시간", selection: $targetHours) {
ForEach(0...23, id: \.self) { h in
Text("\(h)시간").tag(h)
}
}
.pickerStyle(.wheel)
.frame(maxWidth: .infinity)
Picker("", selection: $targetMinutes) {
ForEach(Array(stride(from: 0, through: 55, by: 5)), id: \.self) { m in
Text("\(m)").tag(m)
}
}
.pickerStyle(.wheel)
.frame(maxWidth: .infinity)
}
.frame(height: 110)
} else {
Stepper(value: $targetCount, in: 1...9999) {
HStack {
Text("목표 횟수")
Spacer()
Text("\(targetCount)").foregroundStyle(.secondary)
}
}
}
Picker("방향", selection: $direction) {
ForEach(QuestDirection.allCases) { dir in
Text(dir.label).tag(dir)
}
}
.pickerStyle(.segmented)
} header: {
Text("③ 목표량과 방향")
} footer: {
Text(direction == .atLeast
? "주기마다 목표량 이상을 달성하는 것이 목표예요."
: "주기마다 목표량을 넘지 않는 것이 목표예요.")
}
}
// MARK:
private var isValid: Bool {
let hasTarget = targetKind == .action ? selectedAction != nil : selectedTag != nil
guard hasTarget else { return false }
if period == .daily {
switch scheduleMode {
case .weekdays: if weekdays.isEmpty { return false }
case .monthDays: if monthDays.isEmpty { return false }
default: break
}
}
if period == .custom && customEnd < customStart { return false }
if effectiveMeasure == .time && targetHours == 0 && targetMinutes == 0 { return false }
return true
}
private func load() {
guard let quest else { return }
if let action = quest.targetAction {
targetKind = .action
selectedAction = action
} else if let tag = quest.targetTag {
targetKind = .tag
selectedTag = tag
}
measure = quest.measure
period = quest.period
scheduleMode = quest.scheduleMode
weekdays = Set(quest.weekdays)
monthDays = Set(quest.monthDays)
ordinalWeek = quest.ordinalWeek
ordinalWeekday = quest.ordinalWeekday
customStart = quest.customStart ?? .now
customEnd = quest.customEnd ?? .now
targetHours = Int(quest.targetSeconds) / 3600
targetMinutes = (Int(quest.targetSeconds) % 3600) / 60
targetCount = quest.targetCount
direction = quest.direction
}
private func save() {
let target = quest ?? Quest(goal: goal)
switch targetKind {
case .action:
target.targetAction = selectedAction
target.targetTag = nil
target.measure = selectedAction?.trackingType ?? .time
case .tag:
target.targetTag = selectedTag
target.targetAction = nil
target.measure = measure
}
target.period = period
target.scheduleMode = period == .daily ? scheduleMode : .everyDay
target.weekdays = Array(weekdays).sorted()
target.monthDays = Array(monthDays).sorted()
target.ordinalWeek = ordinalWeek
target.ordinalWeekday = ordinalWeekday
target.customStart = period == .custom ? customStart : nil
target.customEnd = period == .custom ? customEnd : nil
target.targetSeconds = Double(targetHours * 3600 + targetMinutes * 60)
target.targetCount = targetCount
target.direction = direction
if quest == nil {
context.insert(target)
}
dismiss()
}
}

View File

@ -0,0 +1,350 @@
//
// RecordEditors.swift
// Haru_Danim
//
// · ( / , CLAUDE.md §6.1, §6.5)
//
import SwiftUI
import SwiftData
// MARK: - ( )
struct ActionRecordsSheet: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let action: Action
@State private var editingSession: TimeSession?
@State private var editingEntry: CountEntry?
@State private var showingAdd = false
private var recentSessions: [TimeSession] {
action.sessions.sorted { $0.startAt > $1.startAt }
}
private var recentEntries: [CountEntry] {
action.countEntries.sorted { $0.timestamp > $1.timestamp }
}
var body: some View {
NavigationStack {
List {
Section {
Button {
showingAdd = true
} label: {
Label(
action.trackingType == .time ? "기록 직접 추가" : "횟수 직접 추가",
systemImage: "plus.circle"
)
.foregroundStyle(AppTheme.green)
}
}
Section("기록 (최신순)") {
if action.trackingType == .time {
if recentSessions.isEmpty {
Text("기록이 없어요").foregroundStyle(.secondary)
}
ForEach(recentSessions) { session in
Button {
editingSession = session
} label: {
SessionRowLabel(session: session)
}
.buttonStyle(.plain)
.swipeActions {
Button("삭제", role: .destructive) {
context.delete(session)
}
}
}
} else {
if recentEntries.isEmpty {
Text("기록이 없어요").foregroundStyle(.secondary)
}
ForEach(recentEntries) { entry in
Button {
editingEntry = entry
} label: {
CountRowLabel(entry: entry)
}
.buttonStyle(.plain)
.swipeActions {
Button("삭제", role: .destructive) {
context.delete(entry)
}
}
}
}
}
}
.navigationTitle("\(action.name) 기록")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("완료") { dismiss() }
}
}
.sheet(item: $editingSession) { session in
SessionEditorView(session: session)
}
.sheet(item: $editingEntry) { entry in
CountEntryEditorView(entry: entry)
}
.sheet(isPresented: $showingAdd) {
if action.trackingType == .time {
SessionAddView(action: action)
} else {
CountAddView(action: action)
}
}
}
}
}
struct SessionRowLabel: View {
let session: TimeSession
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(Format.fullDate(session.startAt))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if session.endAt == nil {
Text("진행 중")
.font(.caption.weight(.bold))
.foregroundStyle(AppTheme.yellow)
}
}
HStack {
Text("\(Format.time(session.startAt)) ~ \(session.endAt.map(Format.time) ?? "")")
.font(.body)
Spacer()
Text(Format.durationShort(session.duration()))
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
}
}
}
}
struct CountRowLabel: View {
let entry: CountEntry
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(Format.fullDate(entry.timestamp))
.font(.caption)
.foregroundStyle(.secondary)
Text(Format.time(entry.timestamp))
.font(.body)
}
Spacer()
Text("+\(entry.amount)")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
}
}
}
// MARK: -
struct SessionEditorView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let session: TimeSession
@State private var startAt: Date = .now
@State private var isFinished = true
@State private var endAt: Date = .now
var body: some View {
NavigationStack {
Form {
Section("시작") {
DatePicker("시작 시각", selection: $startAt)
}
Section("종료") {
Toggle("종료됨", isOn: $isFinished.animation())
if isFinished {
DatePicker("종료 시각", selection: $endAt, in: startAt...)
} else {
Text("종료 시각을 끄면 진행 중 상태가 돼요.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section {
Button("기록 삭제", role: .destructive) {
context.delete(session)
dismiss()
}
}
}
.navigationTitle("시간 기록 수정")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") {
session.startAt = startAt
session.endAt = isFinished ? max(endAt, startAt) : nil
dismiss()
}
}
}
.onAppear {
startAt = session.startAt
if let end = session.endAt {
isFinished = true
endAt = end
} else {
isFinished = false
endAt = .now
}
}
}
}
}
// MARK: -
struct SessionAddView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let action: Action
@State private var startAt: Date = .now.addingTimeInterval(-3600)
@State private var endAt: Date = .now
var body: some View {
NavigationStack {
Form {
DatePicker("시작 시각", selection: $startAt)
DatePicker("종료 시각", selection: $endAt, in: startAt...)
}
.navigationTitle("시간 기록 추가")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("추가") {
context.insert(TimeSession(action: action, startAt: startAt, endAt: max(endAt, startAt)))
dismiss()
}
}
}
}
}
}
// MARK: -
struct CountEntryEditorView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let entry: CountEntry
@State private var timestamp: Date = .now
@State private var amount = 1
var body: some View {
NavigationStack {
Form {
DatePicker("기록 시각", selection: $timestamp)
Stepper(value: $amount, in: 1...9999) {
HStack {
Text("수량")
Spacer()
Text("\(amount)").foregroundStyle(.secondary)
}
}
Section {
Button("기록 삭제 (증가 취소)", role: .destructive) {
context.delete(entry)
dismiss()
}
}
}
.navigationTitle("횟수 기록 수정")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") {
entry.timestamp = timestamp
entry.amount = amount
dismiss()
}
}
}
.onAppear {
timestamp = entry.timestamp
amount = entry.amount
}
}
}
}
// MARK: - ( / )
struct CountAddView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let action: Action
@State private var amount = 1
@State private var useNow = true
@State private var timestamp: Date = .now
var body: some View {
NavigationStack {
Form {
Stepper(value: $amount, in: 1...9999) {
HStack {
Text("추가할 횟수")
Spacer()
Text("\(amount)").foregroundStyle(.secondary)
}
}
Section("기록 시각") {
Picker("기록 시각", selection: $useNow.animation()) {
Text("지금 시각").tag(true)
Text("특정 시각 지정").tag(false)
}
.pickerStyle(.segmented)
if !useNow {
DatePicker("시각", selection: $timestamp)
}
}
}
.navigationTitle("횟수 추가")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("추가") {
context.insert(CountEntry(
action: action,
timestamp: useNow ? .now : timestamp,
amount: amount
))
dismiss()
}
}
}
}
}
}

View File

@ -0,0 +1,158 @@
//
// SettingsView.swift
// Haru_Danim
//
// (CLAUDE.md §6.6)
//
import SwiftUI
struct SettingsView: View {
@AppStorage(SettingsKeys.theme) private var theme = "light"
@AppStorage(SettingsKeys.language) private var language = AppLanguage.ko.rawValue
@AppStorage(SettingsKeys.weekStartWeekday) private var weekStartWeekday = 2
@AppStorage(SettingsKeys.dayStartMinutes) private var dayStartMinutes = 0
@AppStorage(SettingsKeys.liveActivityMode) private var liveActivityMode = LiveActivityMode.latest.rawValue
@AppStorage(SettingsKeys.isPremium) private var isPremium = false
/// DatePicker
private var dayStartDate: Binding<Date> {
Binding {
let cal = Calendar.current
return cal.date(byAdding: .minute, value: dayStartMinutes, to: cal.startOfDay(for: .now))!
} set: { newValue in
let cal = Calendar.current
let comps = cal.dateComponents([.hour, .minute], from: newValue)
dayStartMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
}
}
var body: some View {
NavigationStack {
Form {
Section("화면") {
Picker("테마", selection: $theme) {
Text("라이트").tag("light")
Text("다크").tag("dark")
}
Picker("언어", selection: $language) {
ForEach(AppLanguage.allCases) { lang in
Text(lang.label).tag(lang.rawValue)
}
}
}
Section {
Picker("주 시작 요일", selection: $weekStartWeekday) {
ForEach(1...7, id: \.self) { weekday in
Text("\(Format.weekdayShort(weekday))요일").tag(weekday)
}
}
DatePicker("하루 시작 시간", selection: dayStartDate, displayedComponents: .hourAndMinute)
} header: {
Text("날짜 기준")
} footer: {
Text("하루 시작 시간을 걸치는 기록은 통계에서 자동으로 날짜별로 나누어 계산돼요.")
}
Section {
Picker("대표 시간 기준", selection: $liveActivityMode) {
ForEach(LiveActivityMode.allCases) { mode in
Text(mode.label).tag(mode.rawValue)
}
}
} header: {
Text("다이나믹 아일랜드 · 잠금화면")
} footer: {
Text("여러 행동을 동시에 추적 중일 때 대표로 표시할 시간이에요. (Live Activity 지원은 추후 추가 예정)")
}
Section("프리미엄") {
NavigationLink {
PremiumView()
} label: {
HStack {
Label("프리미엄 기능", systemImage: "crown.fill")
.foregroundStyle(AppTheme.yellow)
Spacer()
Text(isPremium ? "사용 중" : "무료 사용 중")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Section {
HStack {
Text("버전")
Spacer()
Text("1.0").foregroundStyle(.secondary)
}
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("설정")
}
}
}
// MARK: -
struct PremiumView: View {
@AppStorage(SettingsKeys.isPremium) private var isPremium = false
var body: some View {
List {
Section {
featureRow("infinity", "개수 제한 해제", "행동·목표·다짐을 무제한으로 만들 수 있어요.")
featureRow("square.grid.2x2.fill", "홈·잠금화면 위젯", "위젯에서 바로 행동을 실행하고 달성률을 확인해요.")
featureRow("icloud.fill", "아이패드 동기화", "iCloud로 기기 간 데이터가 동기화돼요.")
featureRow("applewatch", "애플워치 앱", "워치에서 추적하고 컴플리케이션으로 확인해요.")
} header: {
Text("프리미엄으로 할 수 있는 것")
} footer: {
Text("무료: 행동 \(FreeLimits.actions)개 · 목표 \(FreeLimits.goals)개 · 목표당 다짐 \(FreeLimits.questsPerGoal)개까지")
}
if isPremium {
Section("동기화") {
Toggle("기기 간 동기화", isOn: .constant(false))
.disabled(true)
Text("iCloud 동기화는 준비 중이에요.")
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
Section {
Button {
// StoreKit Apple Developer Program (CLAUDE.md §2.3)
} label: {
Label("프리미엄 구매 (준비 중)", systemImage: "cart.fill")
}
.disabled(true)
} footer: {
Text("결제 기능은 App Store 배포 준비와 함께 제공될 예정이에요.")
}
}
#if DEBUG
Section("개발용") {
Toggle("프리미엄 상태 (테스트)", isOn: $isPremium)
}
#endif
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("프리미엄")
.navigationBarTitleDisplayMode(.inline)
}
private func featureRow(_ symbol: String, _ title: String, _ detail: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: symbol)
.font(.system(size: 18))
.foregroundStyle(AppTheme.green)
.frame(width: 30)
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.body.weight(.semibold))
Text(detail).font(.caption).foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}

View File

@ -0,0 +1,94 @@
//
// SymbolPickerView.swift
// Haru_Danim
//
// SF Symbols ( + )
//
import SwiftUI
struct SymbolPickerView: View {
@Environment(\.dismiss) private var dismiss
@Binding var selection: String
@State private var searchText = ""
@State private var category: String
init(selection: Binding<String>) {
_selection = selection
_category = State(initialValue: SymbolCatalog.categories.first?.name ?? "")
}
private var symbols: [String] {
let trimmed = searchText.trimmingCharacters(in: .whitespaces).lowercased()
if !trimmed.isEmpty {
return SymbolCatalog.allSymbols.filter { $0.contains(trimmed) }
}
return SymbolCatalog.categories.first { $0.name == category }?.symbols ?? []
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
if searchText.isEmpty {
categoryBar
}
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 52), spacing: 10)], spacing: 10) {
ForEach(symbols, id: \.self) { symbol in
Button {
selection = symbol
dismiss()
} label: {
Image(systemName: symbol)
.font(.system(size: 22))
.foregroundStyle(symbol == selection ? Color.white : AppTheme.green)
.frame(width: 52, height: 52)
.background(
symbol == selection ? AppTheme.green : AppTheme.surface,
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
}
.buttonStyle(.plain)
}
}
.padding()
}
}
.background(AppTheme.background)
.navigationTitle("아이콘 선택")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $searchText, prompt: "심볼 이름 검색 (영문)")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("닫기") { dismiss() }
}
}
}
}
private var categoryBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(SymbolCatalog.categories) { cat in
Button {
category = cat.name
} label: {
Text(cat.name)
.font(.subheadline.weight(category == cat.name ? .semibold : .regular))
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(
category == cat.name ? AppTheme.green : AppTheme.surface,
in: Capsule()
)
.foregroundStyle(category == cat.name ? Color.white : Color.primary)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
}
}

View File

@ -0,0 +1,174 @@
//
// TagViews.swift
// Haru_Danim
//
// : / / · (CLAUDE.md §6.3)
//
import SwiftUI
import SwiftData
struct TagListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Tag.createdAt) private var tags: [Tag]
@State private var editingTag: Tag?
@State private var showingAdd = false
@State private var deletingTag: Tag?
var body: some View {
NavigationStack {
List {
ForEach(tags) { tag in
Button {
editingTag = tag
} label: {
HStack(spacing: 12) {
Circle()
.fill(tag.color)
.frame(width: 22, height: 22)
Text(tag.name)
.font(.body.weight(.medium))
.foregroundStyle(.primary)
Spacer()
Text("행동 \(tag.actions.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions {
Button("삭제", role: .destructive) {
deletingTag = tag
}
}
}
if tags.isEmpty {
ContentUnavailableView(
"꼬리표가 없어요",
systemImage: "tag",
description: Text("오른쪽 위 + 버튼으로 꼬리표를 추가하세요.")
)
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("꼬리표")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingAdd = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(item: $editingTag) { tag in
TagEditorView(tag: tag)
}
.sheet(isPresented: $showingAdd) {
TagEditorView(tag: nil)
}
.confirmationDialog(
"\(deletingTag?.name ?? "") 꼬리표를 삭제할까요? 행동은 삭제되지 않아요.",
isPresented: Binding(
get: { deletingTag != nil },
set: { if !$0 { deletingTag = nil } }
),
titleVisibility: .visible
) {
Button("삭제", role: .destructive) {
if let tag = deletingTag {
context.delete(tag)
}
deletingTag = nil
}
Button("취소", role: .cancel) { deletingTag = nil }
}
}
}
}
// MARK: - /
struct TagEditorView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
/// nil
let tag: Tag?
@State private var name = ""
@State private var color: Color = Color(hex: AppTheme.tagPresets[0])
var body: some View {
NavigationStack {
Form {
Section("이름") {
TextField("꼬리표 이름 (예: 공부)", text: $name)
}
Section("") {
ColorPicker("직접 선택", selection: $color, supportsOpacity: false)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 40), spacing: 10)], spacing: 10) {
ForEach(AppTheme.tagPresets, id: \.self) { hex in
Button {
color = Color(hex: hex)
} label: {
Circle()
.fill(Color(hex: hex))
.frame(width: 36, height: 36)
.overlay {
if color.hexString == hex {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
}
}
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
Section {
HStack(spacing: 8) {
Circle().fill(color).frame(width: 14, height: 14)
Text(name.isEmpty ? "미리 보기" : name)
.foregroundStyle(name.isEmpty ? .secondary : .primary)
}
} header: {
Text("미리 보기")
}
}
.navigationTitle(tag == nil ? "꼬리표 추가" : "꼬리표 수정")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") { save() }
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.onAppear {
if let tag {
name = tag.name
color = tag.color
}
}
}
}
private func save() {
let trimmed = name.trimmingCharacters(in: .whitespaces)
if let tag {
tag.name = trimmed
tag.colorHex = color.hexString
} else {
context.insert(Tag(name: trimmed, colorHex: color.hexString))
}
dismiss()
}
}