complete prototype app(하루다님)
This commit is contained in:
parent
7a53bb9ce4
commit
c854c8f265
280
myApp/Haru_Danim/CLAUDE.md
Normal file
280
myApp/Haru_Danim/CLAUDE.md
Normal 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의 제안 팔레트 | "흔하지 않은 초록/노랑" 요구 반영, 조정 가능 |
|
||||
| 동시 추적 개수 | 제한 없음 | 원문에 제한 언급 없음 |
|
||||
335
myApp/Haru_Danim/Haru_Danim.xcodeproj/project.pbxproj
Normal file
335
myApp/Haru_Danim/Haru_Danim.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
Binary file not shown.
@ -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>
|
||||
@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
6
myApp/Haru_Danim/IOS/Assets.xcassets/Contents.json
Normal file
6
myApp/Haru_Danim/IOS/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
158
myApp/Haru_Danim/IOS/ContentView.swift
Normal file
158
myApp/Haru_Danim/IOS/ContentView.swift
Normal 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)
|
||||
}
|
||||
144
myApp/Haru_Danim/IOS/Core/DayMath.swift
Normal file
144
myApp/Haru_Danim/IOS/Core/DayMath.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
114
myApp/Haru_Danim/IOS/Core/DebugSeed.swift
Normal file
114
myApp/Haru_Danim/IOS/Core/DebugSeed.swift
Normal 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
|
||||
52
myApp/Haru_Danim/IOS/Core/Formatters.swift
Normal file
52
myApp/Haru_Danim/IOS/Core/Formatters.swift
Normal 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()))%"
|
||||
}
|
||||
}
|
||||
399
myApp/Haru_Danim/IOS/Core/Models.swift
Normal file
399
myApp/Haru_Danim/IOS/Core/Models.swift
Normal 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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
186
myApp/Haru_Danim/IOS/Core/QuestProgress.swift
Normal file
186
myApp/Haru_Danim/IOS/Core/QuestProgress.swift
Normal 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
|
||||
}
|
||||
}
|
||||
78
myApp/Haru_Danim/IOS/Core/Settings.swift
Normal file
78
myApp/Haru_Danim/IOS/Core/Settings.swift
Normal 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
|
||||
}
|
||||
}
|
||||
148
myApp/Haru_Danim/IOS/Core/SymbolCatalog.swift
Normal file
148
myApp/Haru_Danim/IOS/Core/SymbolCatalog.swift
Normal 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",
|
||||
]),
|
||||
]
|
||||
}
|
||||
58
myApp/Haru_Danim/IOS/Core/Theme.swift
Normal file
58
myApp/Haru_Danim/IOS/Core/Theme.swift
Normal 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",
|
||||
]
|
||||
}
|
||||
26
myApp/Haru_Danim/IOS/Haru_DanimApp.swift
Normal file
26
myApp/Haru_Danim/IOS/Haru_DanimApp.swift
Normal 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,
|
||||
])
|
||||
}
|
||||
}
|
||||
359
myApp/Haru_Danim/IOS/Views/ActionViews.swift
Normal file
359
myApp/Haru_Danim/IOS/Views/ActionViews.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
441
myApp/Haru_Danim/IOS/Views/GoalViews.swift
Normal file
441
myApp/Haru_Danim/IOS/Views/GoalViews.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
631
myApp/Haru_Danim/IOS/Views/HistoryView.swift
Normal file
631
myApp/Haru_Danim/IOS/Views/HistoryView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
364
myApp/Haru_Danim/IOS/Views/MainView.swift
Normal file
364
myApp/Haru_Danim/IOS/Views/MainView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
318
myApp/Haru_Danim/IOS/Views/QuestEditorView.swift
Normal file
318
myApp/Haru_Danim/IOS/Views/QuestEditorView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
350
myApp/Haru_Danim/IOS/Views/RecordEditors.swift
Normal file
350
myApp/Haru_Danim/IOS/Views/RecordEditors.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
158
myApp/Haru_Danim/IOS/Views/SettingsView.swift
Normal file
158
myApp/Haru_Danim/IOS/Views/SettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
94
myApp/Haru_Danim/IOS/Views/SymbolPickerView.swift
Normal file
94
myApp/Haru_Danim/IOS/Views/SymbolPickerView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
174
myApp/Haru_Danim/IOS/Views/TagViews.swift
Normal file
174
myApp/Haru_Danim/IOS/Views/TagViews.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user