From c854c8f2659784857811c2e22ac675c6b7fd0221 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Tue, 7 Jul 2026 15:20:53 +0900 Subject: [PATCH] =?UTF-8?q?complete=20prototype=20app(=ED=95=98=EB=A3=A8?= =?UTF-8?q?=EB=8B=A4=EB=8B=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- myApp/Haru_Danim/CLAUDE.md | 280 ++++++++ .../Haru_Danim.xcodeproj/project.pbxproj | 335 ++++++++++ .../contents.xcworkspacedata | 7 + .../UserInterfaceState.xcuserstate | Bin 0 -> 21091 bytes .../xcschemes/xcschememanagement.plist | 14 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 + .../IOS/Assets.xcassets/Contents.json | 6 + myApp/Haru_Danim/IOS/ContentView.swift | 158 +++++ myApp/Haru_Danim/IOS/Core/DayMath.swift | 144 ++++ myApp/Haru_Danim/IOS/Core/DebugSeed.swift | 114 ++++ myApp/Haru_Danim/IOS/Core/Formatters.swift | 52 ++ myApp/Haru_Danim/IOS/Core/Models.swift | 399 +++++++++++ myApp/Haru_Danim/IOS/Core/QuestProgress.swift | 186 ++++++ myApp/Haru_Danim/IOS/Core/Settings.swift | 78 +++ myApp/Haru_Danim/IOS/Core/SymbolCatalog.swift | 148 ++++ myApp/Haru_Danim/IOS/Core/Theme.swift | 58 ++ myApp/Haru_Danim/IOS/Haru_DanimApp.swift | 26 + myApp/Haru_Danim/IOS/Views/ActionViews.swift | 359 ++++++++++ myApp/Haru_Danim/IOS/Views/GoalViews.swift | 441 ++++++++++++ myApp/Haru_Danim/IOS/Views/HistoryView.swift | 631 ++++++++++++++++++ myApp/Haru_Danim/IOS/Views/MainView.swift | 364 ++++++++++ .../IOS/Views/QuestEditorView.swift | 318 +++++++++ .../Haru_Danim/IOS/Views/RecordEditors.swift | 350 ++++++++++ myApp/Haru_Danim/IOS/Views/SettingsView.swift | 158 +++++ .../IOS/Views/SymbolPickerView.swift | 94 +++ myApp/Haru_Danim/IOS/Views/TagViews.swift | 174 +++++ 27 files changed, 4940 insertions(+) create mode 100644 myApp/Haru_Danim/CLAUDE.md create mode 100644 myApp/Haru_Danim/Haru_Danim.xcodeproj/project.pbxproj create mode 100644 myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 myApp/Haru_Danim/Haru_Danim.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 myApp/Haru_Danim/IOS/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 myApp/Haru_Danim/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 myApp/Haru_Danim/IOS/Assets.xcassets/Contents.json create mode 100644 myApp/Haru_Danim/IOS/ContentView.swift create mode 100644 myApp/Haru_Danim/IOS/Core/DayMath.swift create mode 100644 myApp/Haru_Danim/IOS/Core/DebugSeed.swift create mode 100644 myApp/Haru_Danim/IOS/Core/Formatters.swift create mode 100644 myApp/Haru_Danim/IOS/Core/Models.swift create mode 100644 myApp/Haru_Danim/IOS/Core/QuestProgress.swift create mode 100644 myApp/Haru_Danim/IOS/Core/Settings.swift create mode 100644 myApp/Haru_Danim/IOS/Core/SymbolCatalog.swift create mode 100644 myApp/Haru_Danim/IOS/Core/Theme.swift create mode 100644 myApp/Haru_Danim/IOS/Haru_DanimApp.swift create mode 100644 myApp/Haru_Danim/IOS/Views/ActionViews.swift create mode 100644 myApp/Haru_Danim/IOS/Views/GoalViews.swift create mode 100644 myApp/Haru_Danim/IOS/Views/HistoryView.swift create mode 100644 myApp/Haru_Danim/IOS/Views/MainView.swift create mode 100644 myApp/Haru_Danim/IOS/Views/QuestEditorView.swift create mode 100644 myApp/Haru_Danim/IOS/Views/RecordEditors.swift create mode 100644 myApp/Haru_Danim/IOS/Views/SettingsView.swift create mode 100644 myApp/Haru_Danim/IOS/Views/SymbolPickerView.swift create mode 100644 myApp/Haru_Danim/IOS/Views/TagViews.swift diff --git a/myApp/Haru_Danim/CLAUDE.md b/myApp/Haru_Danim/CLAUDE.md new file mode 100644 index 0000000..de76d26 --- /dev/null +++ b/myApp/Haru_Danim/CLAUDE.md @@ -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의 제안 팔레트 | "흔하지 않은 초록/노랑" 요구 반영, 조정 가능 | +| 동시 추적 개수 | 제한 없음 | 원문에 제한 언급 없음 | diff --git a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.pbxproj b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.pbxproj new file mode 100644 index 0000000..eea9049 --- /dev/null +++ b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.pbxproj @@ -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 = ""; + }; +/* 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 = ""; + }; + 725A9E852FFCC65000846FFB /* Products */ = { + isa = PBXGroup; + children = ( + 725A9E842FFCC65000846FFB /* Haru_Danim.app */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..783e7eb9405f2980741f2563d581d1f16af4d5e1 GIT binary patch literal 21091 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 + + + + SchemeUserState + + Haru_Danim.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/myApp/Haru_Danim/IOS/Assets.xcassets/AccentColor.colorset/Contents.json b/myApp/Haru_Danim/IOS/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/Haru_Danim/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/myApp/Haru_Danim/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/myApp/Haru_Danim/IOS/Assets.xcassets/Contents.json b/myApp/Haru_Danim/IOS/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/Haru_Danim/IOS/ContentView.swift b/myApp/Haru_Danim/IOS/ContentView.swift new file mode 100644 index 0000000..e2eefb9 --- /dev/null +++ b/myApp/Haru_Danim/IOS/ContentView.swift @@ -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) +} diff --git a/myApp/Haru_Danim/IOS/Core/DayMath.swift b/myApp/Haru_Danim/IOS/Core/DayMath.swift new file mode 100644 index 0000000..cefb771 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Core/DayMath.swift @@ -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 { + 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.. Range { + dayRange(forKey: dayKey(for: date)) + } + + // MARK: 주 / 월 범위 + + /// `date`가 속한 논리적 하루를 포함하는 주(주 시작 요일 설정 반영)의 범위 + func weekRange(containing date: Date) -> Range { + 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.. Range { + 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..) -> [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 + } + + /// 시작~종료 구간을 논리적 하루 경계 기준으로 분할 + 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.., 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) -> Int { + action.countEntries + .filter { range.contains($0.timestamp) } + .reduce(0) { $0 + $1.amount } + } + + func seconds(for actions: [Action], in range: Range, now: Date = .now) -> TimeInterval { + actions.reduce(0) { $0 + seconds(for: $1, in: range, now: now) } + } + + func count(for actions: [Action], in range: Range) -> 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)) + } + } +} diff --git a/myApp/Haru_Danim/IOS/Core/DebugSeed.swift b/myApp/Haru_Danim/IOS/Core/DebugSeed.swift new file mode 100644 index 0000000..91089b0 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Core/DebugSeed.swift @@ -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())) ?? 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 diff --git a/myApp/Haru_Danim/IOS/Core/Formatters.swift b/myApp/Haru_Danim/IOS/Core/Formatters.swift new file mode 100644 index 0000000..08afb46 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Core/Formatters.swift @@ -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()))%" + } +} diff --git a/myApp/Haru_Danim/IOS/Core/Models.swift b/myApp/Haru_Danim/IOS/Core/Models.swift new file mode 100644 index 0000000..05f3e6f --- /dev/null +++ b/myApp/Haru_Danim/IOS/Core/Models.swift @@ -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))" + } + } +} diff --git a/myApp/Haru_Danim/IOS/Core/QuestProgress.swift b/myApp/Haru_Danim/IOS/Core/QuestProgress.swift new file mode 100644 index 0000000..239dde9 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Core/QuestProgress.swift @@ -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) -> Int { + math.dayKeys(in: range).filter(isActiveDay).count + } + + // MARK: 현재 주기 진행률 + + /// 현재 주기의 실제 시각 범위. 하루 단위 다짐이 오늘 적용일이 아니면 nil. + func currentPeriodRange(now: Date = .now) -> Range? { + 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.., 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 + 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, 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 + } +} diff --git a/myApp/Haru_Danim/IOS/Core/Settings.swift b/myApp/Haru_Danim/IOS/Core/Settings.swift new file mode 100644 index 0000000..460a739 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Core/Settings.swift @@ -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 + } +} diff --git a/myApp/Haru_Danim/IOS/Core/SymbolCatalog.swift b/myApp/Haru_Danim/IOS/Core/SymbolCatalog.swift new file mode 100644 index 0000000..0994bdd --- /dev/null +++ b/myApp/Haru_Danim/IOS/Core/SymbolCatalog.swift @@ -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() + 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", + ]), + ] +} diff --git a/myApp/Haru_Danim/IOS/Core/Theme.swift b/myApp/Haru_Danim/IOS/Core/Theme.swift new file mode 100644 index 0000000..a024095 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Core/Theme.swift @@ -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", + ] +} diff --git a/myApp/Haru_Danim/IOS/Haru_DanimApp.swift b/myApp/Haru_Danim/IOS/Haru_DanimApp.swift new file mode 100644 index 0000000..654db88 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Haru_DanimApp.swift @@ -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, + ]) + } +} diff --git a/myApp/Haru_Danim/IOS/Views/ActionViews.swift b/myApp/Haru_Danim/IOS/Views/ActionViews.swift new file mode 100644 index 0000000..cfa22ee --- /dev/null +++ b/myApp/Haru_Danim/IOS/Views/ActionViews.swift @@ -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 = [] + @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() + } +} diff --git a/myApp/Haru_Danim/IOS/Views/GoalViews.swift b/myApp/Haru_Danim/IOS/Views/GoalViews.swift new file mode 100644 index 0000000..11f3345 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Views/GoalViews.swift @@ -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() + } +} diff --git a/myApp/Haru_Danim/IOS/Views/HistoryView.swift b/myApp/Haru_Danim/IOS/Views/HistoryView.swift new file mode 100644 index 0000000..0900f8e --- /dev/null +++ b/myApp/Haru_Danim/IOS/Views/HistoryView.swift @@ -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 + + 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 { 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) -> [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..) -> [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) -> [SessionSegmentItem] + let counts: (Range) -> [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) -> CGFloat { + let seconds = date.timeIntervalSince(range.lowerBound) + return CGFloat(seconds / 3600) * hourHeight + } + + private func block(_ item: SessionSegmentItem, in range: Range, 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, 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) -> [SessionSegmentItem] + let counts: (Range) -> [CountItem] + let math: DayMath + + private var spanRange: Range { + 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) -> [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() + 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) + } +} diff --git a/myApp/Haru_Danim/IOS/Views/MainView.swift b/myApp/Haru_Danim/IOS/Views/MainView.swift new file mode 100644 index 0000000..031701f --- /dev/null +++ b/myApp/Haru_Danim/IOS/Views/MainView.swift @@ -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 { $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) + } +} diff --git a/myApp/Haru_Danim/IOS/Views/QuestEditorView.swift b/myApp/Haru_Danim/IOS/Views/QuestEditorView.swift new file mode 100644 index 0000000..1ab43a5 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Views/QuestEditorView.swift @@ -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 = [] + @State private var monthDays: Set = [] + @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() + } +} diff --git a/myApp/Haru_Danim/IOS/Views/RecordEditors.swift b/myApp/Haru_Danim/IOS/Views/RecordEditors.swift new file mode 100644 index 0000000..2011d87 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Views/RecordEditors.swift @@ -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() + } + } + } + } + } +} diff --git a/myApp/Haru_Danim/IOS/Views/SettingsView.swift b/myApp/Haru_Danim/IOS/Views/SettingsView.swift new file mode 100644 index 0000000..3086295 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Views/SettingsView.swift @@ -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 { + 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) + } +} diff --git a/myApp/Haru_Danim/IOS/Views/SymbolPickerView.swift b/myApp/Haru_Danim/IOS/Views/SymbolPickerView.swift new file mode 100644 index 0000000..00353cd --- /dev/null +++ b/myApp/Haru_Danim/IOS/Views/SymbolPickerView.swift @@ -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) { + _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) + } + } +} diff --git a/myApp/Haru_Danim/IOS/Views/TagViews.swift b/myApp/Haru_Danim/IOS/Views/TagViews.swift new file mode 100644 index 0000000..f94ac41 --- /dev/null +++ b/myApp/Haru_Danim/IOS/Views/TagViews.swift @@ -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() + } +}