From 67781c231e29f8b32a5ad555585742992e3097ec Mon Sep 17 00:00:00 2001 From: Hanseung Cho <1win2@naver.com> Date: Tue, 5 May 2026 19:42:03 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 - CLAUDE.md | 73 +++++ docs/ADR.md | 60 ++++ docs/ARCHITECTURE.md | 162 +++++++++++ docs/PRD.md | 31 +++ docs/UI-GUIDE.md | 258 ++++++++++++++++++ docs/VIEW-GUIDE.md | 251 +++++++++++++++++ docs/VIEWMODEL-GUIDE.md | 169 ++++++++++++ saerok.xcodeproj/project.pbxproj | 44 +-- .../contents.xcworkspacedata | 10 - .../xcshareddata/swiftpm/Package.resolved | 4 +- 11 files changed, 1010 insertions(+), 58 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/ADR.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/PRD.md create mode 100644 docs/UI-GUIDE.md create mode 100644 docs/VIEW-GUIDE.md create mode 100644 docs/VIEWMODEL-GUIDE.md diff --git a/.gitignore b/.gitignore index bc15406..b9659b7 100644 --- a/.gitignore +++ b/.gitignore @@ -68,12 +68,6 @@ Config/Secrets.Release.xcconfig # AI Agentic Coding .claude/ -CLAUDE.md -AGENTS.md -repo-map.md -feature-template.md -DESIGN.md -docs/ scripts/ phases/**/phase*-output.json phases/**/step*-output.json \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2b50c30 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# 프로젝트: 새록 (Saerok) + +탐조(새 관찰) 기록·도감·커뮤니티·지도를 통합한 iOS 앱. + +## 기술 스택 +- **UI**: SwiftUI + `@Observable` (iOS 17+) +- **아키텍처**: MVVM + Clean Architecture (5계층) +- **로컬 저장소**: SwiftData +- **네트워킹**: Alamofire 5 (`SRNetworkService` 프로토콜 추상화) +- **전역 상태**: Combine 기반 `Store` +- **의존성 주입**: `DIContainer` + `@Environment` +- **주요 SDK**: Kakao(소셜 로그인), Firebase(푸시·원격설정), Naver Maps, Lottie, Amplitude + +## 디렉토리 지도 + +| 작업 목적 | 찾아갈 경로 | +|----------|------------| +| 앱 진입·전역 상태 | `saerok/Sources/App/` | +| 새 화면 추가 | `saerok/Sources/Feature/{기능}/View/` + `ViewModel/` — **반드시 [VIEW-GUIDE](docs/VIEW-GUIDE.md)·[VIEWMODEL-GUIDE](docs/VIEWMODEL-GUIDE.md) 참고** | +| 비즈니스 로직 수정 | `saerok/Sources/Interactors/` | +| 데이터 모델·API 응답 변환 | `saerok/Sources/Repositories/Models/` | +| API 엔드포인트 추가 | `saerok/Sources/Network/EndPoint/` | +| 공통 컴포넌트·디자인 토큰 | `saerok/Sources/Common/SRDesignSystem/` | +| 공통 뷰 재사용 | `saerok/Sources/Common/Views/` | +| 유틸·익스텐션 | `saerok/Sources/Common/Utils/` | +| 화면 전환 | `saerok/Sources/App/AppCoordinator.swift` | +| 의존성 부트스트랩 | `saerok/Sources/App/Dependency/` | + +## 개발 가이드 문서 +- [VIEW-GUIDE](docs/VIEW-GUIDE.md) — View 작성 규칙 (라우팅, 생명주기, 컴포넌트 사용, 금지 패턴) +- [VIEWMODEL-GUIDE](docs/VIEWMODEL-GUIDE.md) — ViewModel 작성 규칙 (LoadState, Output, CancelBag, 비동기 패턴) + +## 아키텍처 규칙 +- CRITICAL: 레이어 흐름은 **View → ViewModel → Interactor → Repository → Network/SwiftData** 방향만 허용. 역방향 참조 금지. +- CRITICAL: 비즈니스 로직은 반드시 **Interactor**에 작성. ViewModel은 상태 관리·UI 이벤트만 처리. +- CRITICAL: 화면 전환은 반드시 **AppCoordinator**를 통해서만. View에서 직접 NavigationLink로 다음 화면을 참조하지 않는다. +- 새 기능 추가 시 파일 생성 순서: Model → Repository → Interactor(프로토콜+구현체) → ViewModel → View +- Interactor는 프로토콜 기반으로 작성해 Mock 구현체와 분리 (프리뷰·테스트용) +- 전역 상태 변경은 `appState.bulkUpdate()`로 묶어 불필요한 렌더링 방지 + +## 상태 관리 패턴 + +```swift +// ViewModel: 비동기 상태 +@Observable class SomeViewModel { + var items: LoadState<[Item]> = .notRequested +} + +// 전역 상태 읽기/쓰기 +container.appState[\.currentUser] +container.appState.bulkUpdate { $0.routing.xxx = ... } +``` + +## UI 규칙 +- CRITICAL: 색상은 반드시 **Asset Catalog 이름**으로 참조 (`Color.main`, `.srWhite`, `.srGray` 등). hex 하드코딩 금지. +- CRITICAL: 폰트는 반드시 **`SRFontSet`** 을 통해 적용 (`.font(.SRFontSet.body1)`). 시스템 폰트 직접 사용 금지. +- 레이아웃 상수는 가능하면 `SRDesignConstant` 사용 (cornerRadius: 24, cardCornerRadius: 10, defaultPadding: 24) +- 버튼 스타일: `PrimaryButtonStyle` / `SecondaryButtonStyle` / `FilterButtonStyle` / `SRIconButtonStyle` 재사용 +- 바텀시트·팝업·토스트는 `.srBottomSheet()` / `.srPopup()` / `.srToast()` modifier 사용 + +**UI 안티패턴 (절대 금지)** +- `blur` / `ultraThinMaterial` 남용 +- 그라데이션 텍스트, 네온·글로우 shadow +- 보라/인디고 계열 임의 색상 추가 +- 배경 장식 도형·그라데이션 orb +- 화면 전체 shake·반복 pulse 애니메이션 +- `.main` 컬러를 CTA·활성 상태 외에 남용 + +## 이미지 업로드 흐름 +S3 Presigned URL 방식: 백엔드에서 URL 발급 → S3 직접 업로드 → 백엔드에 메타데이터 등록 → 실패 시 채집 기록 전체 롤백 + +## 커밋 메시지 +`feat:` / `fix:` / `refactor:` / `docs:` / `chore:` conventional commits 형식 사용 diff --git a/docs/ADR.md b/docs/ADR.md new file mode 100644 index 0000000..8bbc2c4 --- /dev/null +++ b/docs/ADR.md @@ -0,0 +1,60 @@ +# Architecture Decision Records + +## 철학 +기능 단위 독립성과 테스트 가능성을 유지하면서 빠른 개발 속도를 확보한다. Apple 네이티브 스택을 최우선으로 선택하고, 외부 의존성은 명확한 이유가 있을 때만 도입한다. 복잡한 추상화보다 작동하는 최소 구현을 선택하되, 레이어 간 경계는 명확히 유지한다. + +--- + +### ADR-001: MVVM + Clean Architecture (레이어드 아키텍처) 채택 +**결정**: View → ViewModel → Interactor → Repository → Network/Storage 5계층 구조 채택 +**이유**: 기능이 증가해도 비즈니스 로직(Interactor)과 데이터 접근(Repository)이 UI와 분리되어 있어 독립적으로 테스트 가능하다. Interactor는 프로토콜 기반이므로 Mock 구현체로 교체가 쉬워 프리뷰·단위 테스트에서 네트워크 없이 동작 검증이 가능하다. +**트레이드오프**: 단순 CRUD 화면에서도 4~5개 파일(View, ViewModel, Interactor, Repository, Model)을 만들어야 해 초기 코드량이 증가한다. 레이어 경계를 잘못 이해하면 로직이 View나 Repository에 흘러들기 쉽다. + +--- + +### ADR-002: SwiftUI + @Observable 선택 (UIKit 대신) +**결정**: 모든 UI를 SwiftUI로 구현하고, ViewModel 상태 관찰에 iOS 17+ `@Observable` 매크로 사용 +**이유**: 선언형 UI로 상태-화면 동기화 코드를 제거하고 개발 속도를 높인다. `@Observable`은 ObservableObject보다 불필요한 렌더링이 적고, Xcode 프리뷰와의 연동이 자연스럽다. +**트레이드오프**: iOS 17 미만 지원 불가. UIKit 컴포넌트가 필요한 엣지 케이스(예: 복잡한 지도 인터랙션, 커스텀 텍스트 에디터)에서 UIViewRepresentable 브릿징이 필요하다. + +--- + +### ADR-003: DIContainer + @Environment 기반 의존성 주입 (3rd-party DI 프레임워크 대신) +**결정**: `AppEnvironment.bootstrap()`에서 모든 의존성을 조립하고, `DIContainer`를 SwiftUI `@Environment`로 뷰 트리에 전파 +**이유**: Swinject 등 외부 DI 프레임워크 없이 Apple 기본 기능만으로 동일한 효과를 얻는다. 루트에서 한 번만 주입하면 하위 뷰 어디서든 꺼내 쓸 수 있어 객체를 수동으로 전달(prop drilling)할 필요가 없다. 테스트·프리뷰에서는 stub 구현체를 주입해 네트워크 없이 UI 확인이 가능하다. +**트레이드오프**: `@Environment` 접근은 런타임에 타입을 조회하므로 누락 시 컴파일 에러 대신 런타임 에러가 발생한다. 의존성 그래프가 복잡해질수록 `bootstrap()` 함수가 비대해진다. + +--- + +### ADR-004: AppCoordinator 패턴으로 네비게이션 중앙 관리 +**결정**: 화면 전환 로직을 `AppCoordinator`에 집중하고, ViewModel에서는 Coordinator를 통해서만 라우팅 요청 +**이유**: 뷰가 다음 화면을 직접 알지 않아도 되므로 화면 간 결합도가 낮아진다. 딥링크·알림 등 외부 진입점에서도 Coordinator 한 곳을 수정해 모든 네비게이션 흐름을 제어할 수 있다. ViewModel 팩토리 역할도 겸하여 의존성 생성 책임을 한 곳으로 모은다. +**트레이드오프**: 화면 전환마다 Coordinator를 거쳐야 하므로 간단한 팝업·시트도 라우팅 케이스로 등록해야 해 초기 설계 비용이 있다. + +--- + +### ADR-005: Combine 기반 `Store`로 전역 상태 관리 +**결정**: `AppState`를 `Store` (`CurrentValueSubject` 래퍼)로 관리하고, `KeyPath` 서브스크립트로 타입 안전하게 접근 +**이유**: TCA·Redux 등 외부 상태관리 프레임워크 없이 Combine만으로 단방향 데이터 흐름을 구현한다. `bulkUpdate()`로 여러 상태를 한 트랜잭션에 변경해 불필요한 중간 렌더링을 방지한다. +**트레이드오프**: Combine에 대한 이해가 부족하면 메모리 누수(AnyCancellable 미해제)가 발생하기 쉽다. 전역 상태가 커질수록 `AppState` 구조체가 비대해진다. + +--- + +### ADR-006: SwiftData 로컬 영속성 (CoreData/Realm 대신) +**결정**: 로컬 캐싱 및 영속성 저장에 iOS 17+ SwiftData 사용 +**이유**: CoreData보다 Swift-native API로 보일러플레이트가 적고, `@Model` 매크로로 모델 정의가 간결하다. 앱이 iOS 17을 최소 지원 버전으로 타겟하므로 호환성 문제가 없다. Realm은 추가 외부 의존성을 도입하므로 제외했다. +**트레이드오프**: SwiftData는 CoreData 대비 성숙도가 낮아 복잡한 마이그레이션·쿼리 최적화에서 제약이 있다. CloudKit 연동이 필요할 때 CoreData만큼 검증된 사례가 부족하다. + +--- + +### ADR-007: S3 Presigned URL 방식으로 이미지 업로드 +**결정**: 이미지 업로드 시 백엔드에서 S3 Presigned URL을 발급받아 클라이언트가 S3에 직접 업로드 +**이유**: 이미지 바이너리를 백엔드 서버를 거치지 않아 서버 트래픽·부하가 줄어든다. 업로드 실패 시 채집 기록 자체를 롤백하는 흐름으로 데이터 정합성을 보장한다. +**트레이드오프**: 클라이언트가 업로드 성공 여부를 직접 처리해야 하므로 에러 핸들링 흐름이 복잡해진다(Presigned URL 발급 → S3 업로드 → 메타데이터 등록 → 실패 시 롤백). 네트워크 단절 중간 단계에서 부분 실패 시나리오를 별도로 고려해야 한다. + +--- + +### ADR-008: Alamofire 기반 HTTP 네트워킹 (URLSession 직접 사용 대신) +**결정**: 네트워크 레이어를 `SRNetworkService` 프로토콜로 추상화하고 내부 구현은 Alamofire 5 사용 +**이유**: 인터셉터·리트라이·멀티파트 업로드 등 반복적인 네트워킹 보일러플레이트를 Alamofire가 대신 처리한다. 프로토콜 추상화 덕분에 테스트 시 URLSession 레벨 Mocking 없이 서비스 구현체만 교체 가능하다. +**트레이드오프**: Swift Concurrency 시대에 URLSession async/await만으로도 충분한 요구사항에서 불필요한 외부 의존성이 된다. Alamofire 버전 업그레이드 시 인터페이스 변화에 대응해야 하는 유지보수 비용이 있다. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..16b0baf --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,162 @@ +# 아키텍처 + +## 디렉토리 구조 + +``` +saerok/Sources/ +├── App/ # 앱 진입점, 상태, 의존성 주입 +│ ├── AppState.swift # 전역 상태 구조체 +│ ├── AppCoordinator.swift # 네비게이션 코디네이터 + ViewModel 팩토리 +│ └── Dependency/ # DIContainer, AppEnvironment 부트스트랩 +├── Common/ # 공통 유틸리티 + 디자인 시스템 +│ ├── DesignSystem/ # 색상, 폰트, 컴포넌트 +│ ├── Utils/ # Extensions, Helper, Store, Loadable +│ └── Views/ # 공통 SwiftUI 컴포넌트 +├── Feature/ # 기능별 모듈 +│ ├── Collection/ # 채집 기록 기능 +│ ├── Community/ # 커뮤니티 기능 +│ ├── FieldGuide/ # 도감 기능 +│ ├── Login/ # 로그인/인증 +│ ├── Map/ # 지도 기능 +│ ├── MyPage/ # 마이페이지 +│ ├── Onboarding/ # 온보딩 +│ └── Root/ # 루트 탭 뷰 +├── Interactors/ # 비즈니스 로직 (Use Case) +├── Network/ # HTTP 네트워킹 레이어 +│ ├── API/ # APIClient (URLSession 래퍼) +│ ├── EndPoint/ # SREndpoint, KakaoEndpoint +│ └── SRNetworkService.swift # 네트워크 서비스 프로토콜 +└── Repositories/ # 데이터 접근 추상화 + └── Models/ # DTO ↔ Local 도메인 모델 +``` + +각 Feature 내부 구조: +``` +Feature/{FeatureName}/ +├── View/ # SwiftUI 뷰 +└── ViewModel/ # @Observable ViewModel +``` + +## 패턴 + +**MVVM + Clean Architecture (레이어드 아키텍처)** + +- **View**: SwiftUI, `@Environment`로 주입된 ViewModel·DIContainer를 구독 +- **ViewModel**: `@Observable` 매크로 사용, LoadState로 비동기 상태 관리 +- **Interactor (Use Case)**: 비즈니스 로직 담당, 프로토콜 기반으로 Mock 구현체와 분리 +- **Repository**: 네트워크·로컬 스토리지 추상화, DTO → Local 모델 변환 담당 +- **Network**: 타입 안전한 Endpoint 프로토콜, Alamofire 기반 `DefaultAPIClient` + +**디자인 패턴 요약** + +| 패턴 | 사용처 | +|------|--------| +| Repository Pattern | 데이터 소스 추상화 | +| Interactor / Use Case | 비즈니스 로직 캡슐화 | +| Coordinator Pattern | 화면 전환 (`AppCoordinator`) | +| Dependency Injection | `DIContainer` + `@Environment` | +| Observer Pattern | Combine (`Store`), `@Observable` | +| Builder Pattern | `@resultBuilder` 기반 `CancelBag` | + +## 데이터 흐름 + +``` +사용자 입력 + ↓ +SwiftUI View (@Environment 주입, @Observable 구독) + ↓ +ViewModel (LoadState 관리, async 메서드 호출) + ↓ +Interactor (비즈니스 로직, 유효성 검사, 정렬·필터) + ↓ +Repository (데이터 소스 조합: 네트워크 + 로컬) + ↓ +SRNetworkService / SwiftData + ↓ ↑ +외부 API 응답 (DTO) → Local 모델 변환 + ↑ +응답 → ViewModel 상태 업데이트 → UI 자동 갱신 +``` + +**이미지 업로드 특수 흐름:** +``` +이미지 선택 + → 백엔드에서 S3 Presigned URL 발급 + → 이미지를 S3에 직접 업로드 + → 백엔드에 이미지 메타데이터 등록 + → 실패 시 채집 기록 롤백 +``` + +**비동기 병렬 로딩:** +`withTaskGroup`으로 상세 데이터, 댓글, 연관 항목을 동시에 로드 + +## 상태 관리 + +### 전역 상태 — `AppState` + `Store` + +```swift +// Store = CurrentValueSubject (Combine) +let appState = Store(AppState()) +``` + +`AppState`에는 아래 하위 상태가 포함됨: +- `routing` — 화면별 네비게이션 경로 +- `system` — 앱 버전, 네트워크 상태 +- `authStatus` — 인증 여부 +- `currentUser` — 로그인 유저 정보 + +`KeyPath` 서브스크립트로 타입 안전하게 접근하고, `bulkUpdate()`로 여러 상태를 배치 업데이트한다. + +### 로컬 컴포넌트 상태 — `LoadState` + `@Observable` + +```swift +enum LoadState { + case notRequested + case loading + case success(T) + case failure(Error) +} +``` + +ViewModel이 `@Observable`로 선언되어 SwiftUI가 자동으로 변경 감지. + +### 구독 관리 — `CancelBag` + +Combine 구독을 `@resultBuilder` 기반의 `CancelBag`으로 한 곳에서 관리해 메모리 누수를 방지한다. + +## 의존성 주입 + +앱 시작 시 `AppEnvironment.bootstrap()`에서 모든 의존성을 구성한다: + +``` +AppEnvironment.bootstrap() + → SRNetworkServiceImpl + → SwiftData ModelContainer + → Repositories (MainRepository 등) + → Interactors (각 feature별) + → DIContainer (AppState + Interactors + NetworkService) +``` + +뷰에 주입: +```swift +// 루트에서 한 번만 inject +ContentView().inject(diContainer) + +// 하위 뷰에서 꺼내 쓰기 +@Environment(\.injected) var container +``` + +테스트·프리뷰에서는 `DIContainer.Interactors.stub`의 Mock 구현체가 자동으로 사용된다. + +## 주요 외부 의존성 + +| 라이브러리 | 용도 | +|-----------|------| +| Alamofire 5 | HTTP 네트워킹 | +| SwiftData | 로컬 영속성 (Apple 네이티브) | +| Kakao iOS SDK | 소셜 로그인 | +| Firebase 12 | 푸시 알림, 원격 설정 | +| Naver Maps SDK | 지도 | +| Lottie 4 | 애니메이션 | +| Amplitude | 사용자 이벤트 분석 | +| AdFit | 광고 | diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..73c1f2d --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,31 @@ +# PRD: 새록 + +## 목표 +탐조(새 관찰) 기록을 모아두고, 조류 도감을 탐색하며, 같은 취미를 가진 사람들과 관찰 기록을 공유하는 iOS 앱 + +## 사용자 +- 탐조를 즐기는 아마추어·취미 탐조인 +- 관찰한 새를 기록하고 나중에 다시 찾아보고 싶은 사람 +- 새 이름을 모를 때 커뮤니티에 물어보고 싶은 사람 + +## 핵심 기능 + +1. **새록 (관찰 기록)** — 새 이름, 사진, 위치, 날짜, 메모를 담은 관찰 기록 CRUD. 다른 사용자가 새 이름을 제안하거나 투표할 수 있는 조류 식별 기능 포함 +2. **도감 (조류 도감)** — 전체 조류 목록을 앱 내 캐싱하여 오프라인에서도 탐색·검색 가능. 즐겨찾기 북마크 지원 +3. **둥지 (커뮤니티)** — 다른 사용자의 관찰 기록 피드. 새 이름 미정 게시물, 인기순·최신순 탐색 및 사용자 검색 +4. **지도** — 관찰 기록 위치를 Naver Maps로 시각화. 마커 클러스터링, 내 위치 기반 주변 기록 조회 +5. **마이페이지** — 프로필 관리, 푸시 알림 설정, 차단 사용자 관리, 공지사항 + +## MVP 제외 사항 +- Android 버전 +- 웹 서비스 +- 탐조 일정 예약·그룹 탐조 조율 기능 +- 새 울음소리 인식·오디오 기록 +- 지역 탐조 핫스팟 큐레이션 (운영자 편집 콘텐츠) + +## 디자인 +- 라이트 모드 기반 (다크모드 미지원) +- 메인 컬러 + 포인트 컬러 1가지 조합의 미니멀 팔레트; 무채색 계열(SRWhite, SRGray, SRLightGray) 중심 +- 서체: 본문·UI는 Pretendard(가변체), 헤드라인은 잘풀리는하루, 숫자·액센트는 Moneygraphy Rounded +- 모서리 반경 24pt, 기본 패딩 24pt로 여유 있는 레이아웃 +- 탭별 온보딩 오버레이로 신규 사용자 진입 장벽 최소화 diff --git a/docs/UI-GUIDE.md b/docs/UI-GUIDE.md new file mode 100644 index 0000000..97ea620 --- /dev/null +++ b/docs/UI-GUIDE.md @@ -0,0 +1,258 @@ +# UI 디자인 가이드 + +## 디자인 원칙 +1. **기록 도구처럼 보여야 한다.** 마케팅 앱이 아니라 매일 쓰는 도감 앱이다. 장식보다 정보 밀도가 우선이다. +2. **밝고 가벼운 화면.** 배경은 흰색 계열(`.srWhite`, `.srLightGray`)이 기본이다. 어둡고 글로우가 뜨는 다크 테마는 이 앱의 언어가 아니다. +3. **브랜드 색상은 한 번만 써야 눈에 띈다.** `.main(#91BFFF)`은 CTA, 활성 상태, 핵심 강조에만 쓴다. 남발하면 어디도 강조되지 않는다. + +## AI 슬롭 안티패턴 — 하지 마라 +| 금지 사항 | 이유 | +|-----------|------| +| `blur`/`ultraThinMaterial` 남용 | glass morphism은 AI 템플릿의 가장 흔한 징후. 탭바 배경 등 꼭 필요한 곳 외에는 쓰지 않는다 | +| 그라데이션 텍스트 | 이 앱의 텍스트 색상은 단색이다. 그라데이션 텍스트를 쓰면 앱 언어에서 벗어난다 | +| 네온/글로우 Shadow | `shadow(color: .main, radius: 20)` 류의 글로우 효과는 사용하지 않는다 | +| 보라/인디고 계열 색상 추가 | 브랜드 컬러는 이미 정해져 있다. 임의로 새로운 색상을 추가하지 않는다 | +| 모든 컨테이너에 동일한 큰 radius | radius 값은 용도마다 다르다 (대: 24, 카드: 10, 버튼: 20). 무조건 큰 값을 쓰지 않는다 | +| 배경 장식 도형/그라데이션 orb | 내용 없는 배경 장식은 쓰지 않는다 | +| 과도한 애니메이션 | 스프링 기반의 짧은 피드백만 허용한다. 화면 전체가 움직이는 애니메이션은 금지 | + +--- + +## 색상 +색상은 반드시 Asset Catalog의 이름으로 참조한다. 하드코딩 hex 값은 쓰지 않는다. + +### 배경 +| 용도 | 에셋 이름 | 근사 Hex | +|------|-----------|---------| +| 페이지 배경 | `SRWhite` | `#FEFEFE` | +| 보조 배경, 바텀시트 | `SRLightGray` | `#F2F2F2` | +| 텍스트 필드 배경 | `SRWhite` | `#FEFEFE` | + +### 텍스트 +| 용도 | 에셋/색상 | 근사 Hex | +|------|-----------|---------| +| 주 텍스트 | `.primary` (시스템) | — | +| 보조 텍스트 | `SRGray` | `#969696` | +| 비활성/플레이스홀더 | `BorderColor` | `#D9D9D9` | + +### 브랜드/시맨틱 색상 +| 용도 | 에셋 이름 | 근사 Hex | +|------|-----------|---------| +| 핵심 브랜드, CTA, 활성 상태 | `MainColor` | `#91BFFF` | +| 브랜드 연하게 | `MainLightColor` | `#CDD9F3` | +| 포인트/강조 | `Point` | `#F9E2BE` | +| 포인트 연하게 | `PointLight` | `#F1E9DD` | +| 성공/긍정 | `srGreen` | `#51BBA6` | +| 에러/경고/삭제 | `fire` | `#F77A65` | +| 성공 토스트 | `splash` | `#4190FF` | +| 아이콘 반투명 배경 | `GlassWhite` | `#FEFEFE @ 60%` | +| 탭바/토글 비활성 배경 | `whiteGray` | — | + +> **참고**: 코드에서는 Color extension을 통해 `.main`, `.srWhite`, `.srGray`, `.border`, `.srLightGray`, `.srGreen`, `.fire`, `.splash`, `.glassWhite`, `.whiteGray`, `.point`, `.pointLight` 등의 단축 프로퍼티로 접근한다. + +--- + +## 타이포그래피 +폰트는 반드시 `SRFontSet` enum을 통해 적용한다 (`.font(.SRFontSet.body1)`). 시스템 폰트를 직접 쓰지 않는다. + +### 폰트 패밀리 +- **Pretendard** — 기본 UI 폰트. 가독성 최우선. +- **잘풀리는하루(Jalpullineunharu)** — 디스플레이/헤드라인 전용. 앱의 감성을 담는다. +- **Moneygraphy** — 악센트/포인트 전용. 서브타이틀이나 캡션에 제한적으로 사용한다. + +### 주요 스타일 대응표 +| 용도 | SRFontSet 키 | 폰트 | 크기 | 굵기 | +|------|-------------|------|------|------| +| 페이지 타이틀 | `headline1` | 자필리는하루 | 30 | Regular | +| 섹션 타이틀 | `headline2` | 자필리는하루 | 22 | Regular | +| 섹션 타이틀 (강조) | `headline2_3` | Pretendard | 22 | SemiBold | +| 카드 타이틀 | `subtitle1` | Moneygraphy | 20 | Regular | +| 서브타이틀 | `subtitle2` | 자필리는하루 | 18 | Regular | +| 본문 | `body1` | Pretendard | 15 | Regular | +| 본문 (SemiBold) | `body2_3` | Pretendard | 15 | SemiBold | +| 작은 본문 | `body4` | Pretendard | 14 | Regular | +| 캡션 | `caption1` | Pretendard | 13 | Regular | +| 작은 캡션 | `caption3` | Pretendard | 12 | Regular | +| Primary 버튼 텍스트 | `button1` | Pretendard | 18 | Bold | +| Secondary 버튼 텍스트 | `button2` | Pretendard | 15 | SemiBold | +| 탭바 비활성 | `tabbar` | Pretendard | 11 | Regular | +| 탭바 활성 | `tabbarSelected` | Pretendard | 11 | Medium/Bold | +| 대형 수치 표시 | `heavy` | Pretendard | 40 | SemiBold | + +--- + +## 컴포넌트 +### 버튼 + +**Primary 버튼** (`PrimaryButtonStyle`) +- 배경: `.main` / 비활성: `.border` +- 텍스트: `.srWhite`, `SRFontSet.button1` +- Corner Radius: 20 +- Vertical Padding: 16 +- Width: `maxWidth: .infinity` +- Press: scale 0.98 + 햅틱 (Light) + +```swift +Button("확인") { } + .buttonStyle(PrimaryButtonStyle()) +``` + +**Secondary/Alert 버튼** (`SecondaryButtonStyle`, `AlertButtonStyle`) +- 배경: 투명 또는 흰색, 테두리 강조 +- 텍스트: `.primary` 또는 `.main` +- Corner Radius: 10 (secondary), 15 (alert) +- Vertical Padding: 11 +- Press: scale 0.98 + +**Filter 버튼** (`FilterButtonStyle`) +- 활성: `.main` 배경 + `.srWhite` 텍스트 +- 비활성: `.srWhite` 배경 + `.primary` 텍스트 + `.srGray` 0.35pt 테두리 +- Corner Radius: Infinity (pill) +- Padding: 15H × 9V + +**아이콘 버튼** (`SRIconButtonStyle`, `BorderedIconButtonStyle`) +- 배경 원: `.glassWhite` 40×40 +- Bordered 변형: `.srLightGray` 1pt 테두리 +- Press: scale 0.98 + +--- + +### 텍스트 필드 (`SRTextFieldStyle`) +- 배경: `.srWhite` +- Border Radius: 17 +- Border: 2pt — 비포커스 `.border`, 포커스 `.main` +- 폰트: `SRFontSet.body2` +- 자동수정: 비활성화 +- 변형: `alwaysFocused` (항상 `.main` 테두리 유지) + +--- + +### 카드/아이템 +| 컴포넌트 | 배경 | Corner Radius | Shadow | +|----------|------|---------------|--------| +| 일반 카드 | `.srWhite` | 20 | black 0.2, blur 5 | +| 내부 뱃지/태그 | `.main` | 10 | 없음 | +| 팝업 | `.srWhite` | 20 | dim overlay black 0.4 | + +--- + +### 탭바 (`TabbarView`) +- 높이: 78 +- 배경: White +- Corner Radius: Infinity +- Shadow: black 0.15, blur 15 +- 수평 패딩: 16 + +--- + +### 내비게이션 바 (`NavigationBar`) +- 높이: 62 +- 배경: `.srWhite` (변경 가능) +- 수평 패딩: 24 +- 레이아웃: 양쪽 끝 Leading/Trailing + 중앙 타이틀 + +--- + +### 바텀시트 (`SRBottomSheetModifier`) +- 배경: `.srLightGray` (기본, 변경 가능) +- Drag Indicator: 숨김 → 커스텀 Indicator 사용 (Capsule 110×3, `.whiteGray`, 상단 5pt 패딩) +- Bottom Safe Area: 무시 + +--- + +### 토스트 (`SRToastModifier`) +- 배경: White 0.8 opacity +- Corner Radius: 12 +- Border: 1pt (타입별 색상 — success: green, failure: red, normal: gray) +- 폰트: `SRFontSet.body2` +- 아이콘 영역: 25×25, corner 8 +- 패딩: leading 6, trailing 13, vertical 5 +- 닫기 제스처: 30pt 아래 스와이프 + +--- + +### 팝업 (`SRPopup`) +- 배경: `.srWhite`, Corner Radius 20 +- 최대 너비: 300 +- Overlay: black 0.4 +- 타이틀 폰트: `SRFontSet.body3` (Moneygraphy 15) +- 본문 폰트: `SRFontSet.body2` (Pretendard 15) +- 트랜지션: scale insert + opacity remove + +--- + +### 토글 (`ToggleButton`) +- 프레임: 55×30, Corner Radius Infinity +- OFF: `.whiteGray` 배경 +- ON: `.main` 배경 +- 핸들: `.srWhite` 원 25×25, 패딩 2.5pt + +--- + +### 아바타 (`SRAvatarStyle`) +- 프레임: 25×25, Circle +- 테두리: `.srLightGray` 2pt stroke, inset 0.6pt + +--- + +## 아이콘 +- **SF Symbols**: `chevronLeft`, `chevronRight`, `xmark`, `xmarkCircleFill`, `pencil.fill`, `textformat` +- **커스텀 PNG**: `SRIconSet` enum을 통해 에셋 이름으로 참조 +- 렌더링: `.renderingMode(.template)` 후 `.foregroundColor()` 적용 +- 크기 상수 (`SRIconSet.Metric`): + | 이름 | 크기 | + |------|------| + | `defaultIconSizeSmall` | 13×13 | + | `defaultIconSize` | 17×17 | + | `defaultIconSizeLarge` | 24×24 | + | `defaultIconSizeVeryLarge` | 40×40 | + | `floatingButton` | 61×61 | +- **아이콘을 둥근 배경 박스로 감싸지 않는다.** 단독 또는 `.glassWhite` 원형 배경(`SRIconButtonStyle`) 중 하나만 선택. + +--- + +## 레이아웃 +- 기본 수평 패딩: `SRDesignConstant.defaultPadding` = **24pt** +- 카드 Corner Radius: `SRDesignConstant.cardCornerRadius` = **10pt** +- 대형 Corner Radius: `SRDesignConstant.cornerRadius` = **24pt** +- 요소 간격: 4–8pt (tight), 12–16pt (standard), 24pt+ (섹션 간) +- 좌측 정렬 기본. 리스트/그리드는 `AdaptiveLeftAlignedGrid` 활용. + +--- + +## 애니메이션 +허용하는 애니메이션만 나열한다. 그 외는 사용하지 않는다. + +| 종류 | 스펙 | 사용처 | +|------|------|--------| +| 버튼 press scale | `scaleEffect(0.98)`, `Animation(.spring(duration: 0.2))` | 모든 탭 가능 요소 | +| 팝업 등장 | `.scale` insert + `.opacity` remove, spring 0.2s | SRPopup | +| 모달 등장 | interpolating spring (duration 0.35, bounce 0) | 바텀시트, 풀스크린 | +| 탭 전환 | 없음 (즉각 전환) | TabbarView | + +- 화면 전체 shake, 반복 pulse, 글로우 애니메이션은 **금지**. +- 햅틱 피드백: 탭/선택 시 `HapticManager.shared.trigger(.light)` + +--- + +## 확장 패턴 (View Extension) +새 UI를 만들 때 아래 modifier 패턴을 먼저 확인하고 재사용한다. + +| Extension | 역할 | +|-----------|------| +| `.srStyled(style:)` | SRComponentStyle 기반 통합 스타일 적용 | +| `.srBottomSheet(isPresented:)` | SRBottomSheetModifier 적용 | +| `.srPopup(isPresented:popup:)` | SRPopup 오버레이 표시 | +| `.srToast(data:)` | SRToastModifier 토스트 표시 | +| `.srAvatarStyle()` | SRAvatarStyle 적용 | + +--- + +## 설계 상수 참조 +```swift +enum SRDesignConstant { + static let cornerRadius: CGFloat = 24.0 // 대형 컨테이너 + static let cardCornerRadius: CGFloat = 10.0 // 카드, 뱃지 + static let defaultPadding: CGFloat = 24.0 // 기본 수평 패딩 +} +``` diff --git a/docs/VIEW-GUIDE.md b/docs/VIEW-GUIDE.md new file mode 100644 index 0000000..523f870 --- /dev/null +++ b/docs/VIEW-GUIDE.md @@ -0,0 +1,251 @@ +# View 작성 규칙 + +## 기본 구조 + +```swift +struct CollectionView: View { + typealias Route = CollectionRoute // 라우트 타입 별칭 + + // 1. 환경 의존성 + @Environment(\.injected) private var container + @EnvironmentObject private var coordinator: AppCoordinator + + // 2. ViewModel (Bindable — @Observable 양방향 바인딩) + @Bindable private var viewModel: ViewModel + + // 3. 로컬 UI 상태만 @State 허용 + @State private var scrollToTopTrigger = false + + init(viewModel: ViewModel) { + self.viewModel = viewModel + } + + var body: some View { + VStack(spacing: 0) { + navigationBar + content + } + .task { await viewModel.loadPosts() } + .navigationDestination(for: Route.self) { route in + routeView(for: route) + } + .onChange(of: viewModel.output, initial: true) { _, output in + handleOutput(output) + } + } +} +``` + +--- + +## 라우팅 + +화면 전환은 반드시 `coordinator.push()` / `coordinator.pop()`을 사용한다. View에서 다음 화면을 직접 생성하지 않는다. + +```swift +// Route enum 정의 (파일 상단 또는 별도 파일) +enum CollectionRoute: AppRoute { + case collectionDetail(Int) + case addCollection(bird: Local.Bird?) + case notification +} + +// navigationDestination에서 라우트별 뷰 분기 +@ViewBuilder +private func routeView(for route: Route) -> some View { + switch route { + case .collectionDetail(let id): + CollectionDetailView(viewModel: coordinator.makeCollectionDetailViewModel(id: id)) + case .addCollection(let bird): + AddCollectionView(viewModel: coordinator.makeAddCollectionViewModel(bird: bird)) + case .notification: + NotificationView(viewModel: coordinator.makeNotificationViewModel()) + } +} + +// 이동 +coordinator.push(Route.collectionDetail(id)) + +// 뒤로가기 +coordinator.pop() +``` + +--- + +## Output 처리 (ViewModel → View 이벤트) + +```swift +private func handleOutput(_ output: ViewModel.Output?) { + guard let output else { return } + switch output { + case .navigateToDetail(let id): + coordinator.push(Route.collectionDetail(id)) + case .scrollToTop: + scrollToTopTrigger.toggle() + } + viewModel.resetOutput() +} +``` + +`.onChange(of: viewModel.output, initial: true)`로 등록하고, 처리 후 반드시 `resetOutput()` 호출. + +--- + +## 생명주기 + +| 상황 | 사용 | +|------|------| +| 최초 데이터 로드 | `.task { await viewModel.load() }` | +| 재진입 시 갱신 | `.onAppear { Task { await viewModel.refresh() } }` | +| 화면 이탈 처리 | `.onDisappear { ... }` | +| 앱 포그라운드 복귀 | `.onChange(of: scenePhase) { _, phase in if phase == .active { ... } }` | + +`.task`는 뷰 생애주기에 맞춰 자동 취소되므로 최초 로딩에 우선 사용한다. + +--- + +## NavigationBar + +커스텀 `NavigationBar` 컴포넌트를 사용한다. SwiftUI 기본 `.navigationTitle`을 사용하지 않는다. + +```swift +private var navigationBar: some View { + NavigationBar( + leading: { + Button { coordinator.pop() } label: { + Image.SRIconSet.chevronLeft + .frame(.defaultIconSize) + } + .srStyled(.borderedIconButton) + }, + center: { + Text("제목") + .font(.SRFontSet.subtitle2) + }, + trailing: { + Button { coordinator.push(Route.notification) } label: { + Image.SRIconSet.bell + .frame(.defaultIconSizeLarge) + } + .srStyled(.iconButton) + } + ) +} +``` + +--- + +## LoadState 분기 렌더링 + +```swift +@ViewBuilder +private var content: some View { + switch viewModel.loadingState { + case .notRequested: + EmptyView() + case .loading: + ProgressView() + case .success(let items): + loadedView(items) + case .failure: + errorView() + } +} +``` + +--- + +## 공통 컴포넌트 사용 규칙 + +### 버튼 +```swift +Button("확인") { } + .buttonStyle(PrimaryButtonStyle()) // 주 CTA + +Button("취소") { } + .buttonStyle(SecondaryButtonStyle()) // 보조 + +Button("필터") { } + .buttonStyle(FilterButtonStyle(isSelected: isActive)) + +Button { } label: { Image.SRIconSet.xmark } + .srStyled(.iconButton) // 아이콘 버튼 + .srStyled(.borderedIconButton) // 테두리 있는 아이콘 버튼 +``` + +### 텍스트 필드 +```swift +TextField("플레이스홀더", text: $text) + .textFieldStyle(SRTextFieldStyle()) +``` + +### 바텀시트 / 팝업 / 토스트 +```swift +.srBottomSheet(isPresented: $showSheet) { SheetContentView() } +.srPopup(isPresented: $showPopup) { popupView } +.srToast(data: $toastData) +``` + +--- + +## 색상·폰트 사용 + +```swift +// 색상: Asset 이름으로 참조 (hex 하드코딩 금지) +.foregroundColor(.main) // CTA, 활성 상태 +.foregroundColor(.srGray) // 보조 텍스트 +.background(Color.srLightGray) // 보조 배경 + +// 폰트: SRFontSet 사용 (시스템 폰트 직접 사용 금지) +.font(.SRFontSet.body1) +.font(.SRFontSet.headline1) +.font(.SRFontSet.caption1) +``` + +--- + +## 뷰 분리 기준 + +- `body`가 50줄을 넘으면 `private var` 또는 `private func`로 서브뷰 분리 +- 반복 렌더링 되는 아이템(리스트 셀 등)은 별도 `struct`로 분리 +- 공통으로 재사용 가능한 컴포넌트는 `saerok/Sources/Common/Views/`에 작성 + +```swift +// 서브뷰 분리 예시 +var body: some View { + VStack { + navigationBar + headerSection + contentList + } +} + +private var headerSection: some View { ... } +private var contentList: some View { ... } +``` + +--- + +## Preview + +```swift +#Preview { + CollectionView( + viewModel: .init( + appState: .init(AppState()), + collectionInteractor: DIContainer.Interactors.stub.collection + ) + ) + .inject(.preview) // stub DI 주입 +} +``` + +--- + +## 금지 사항 +- View가 Interactor / Repository를 직접 참조 — ViewModel을 통해서만 접근 +- `NavigationLink(destination:)` 직접 사용 — `coordinator.push()` 사용 +- `@StateObject` / `ObservableObject` 사용 — `@Bindable` + `@Observable` 사용 +- `.navigationTitle()` / `.toolbar()` 사용 — 커스텀 `NavigationBar` 사용 +- `Color(hex:)` 하드코딩 — Asset Catalog 컬러 이름 사용 +- 시스템 폰트 직접 지정 — `SRFontSet` 사용 diff --git a/docs/VIEWMODEL-GUIDE.md b/docs/VIEWMODEL-GUIDE.md new file mode 100644 index 0000000..502f71e --- /dev/null +++ b/docs/VIEWMODEL-GUIDE.md @@ -0,0 +1,169 @@ +# ViewModel 작성 규칙 + +## 기본 구조 + +ViewModel은 반드시 **View의 extension 안에 중첩 클래스**로 선언한다. + +```swift +extension CollectionView { + @Observable + final class ViewModel { + // 1. 외부에서 읽기만 허용하는 상태 + private(set) var loadingState: LoadState<[Local.Collection]> = .notRequested + private(set) var output: Output? + + // 2. 내부 의존성 (Combine 구독 등 @Observable 추적 불필요한 것) + @ObservationIgnored private let cancelBag: CancelBag + + // 3. 주입받는 의존성 + private let appState: Store + private let collectionInteractor: CollectionInteractor + + init(appState: Store, collectionInteractor: CollectionInteractor) { + self.appState = appState + self.collectionInteractor = collectionInteractor + self.cancelBag = .init() + bindAppState() + } + } +} +``` + +**규칙:** +- `@Observable` + `final class` 조합 고정 +- 상태 프로퍼티는 `private(set)` — View에서 읽기만 허용 +- `CancelBag` 등 Combine 관련 객체는 `@ObservationIgnored` 필수 +- `init`에서 `bindAppState()` 호출로 구독 설정 + +--- + +## LoadState 패턴 + +비동기 데이터 로딩은 반드시 `LoadState`를 사용한다. + +```swift +// 최초 로딩 (로딩 인디케이터 표시) +func loadPosts() async { + guard loadingState == .notRequested || loadingState.inError else { return } + loadingState = await loadingState.load { + try await collectionInteractor.fetchMyCollections() + } +} + +// 새로고침 (로딩 인디케이터 없이 갱신) +func refresh() async { + guard let current = loadingState.value else { return } + do { + let updated = try await collectionInteractor.fetchMyCollections() + loadingState = .success(updated) + } catch { } +} +``` + +--- + +## Output 열거형 (ViewModel → View 이벤트) + +View로 보내는 이벤트는 `Output` enum을 통해서만 전달한다. 직접 coordinator를 ViewModel이 들고 있지 않는다. + +```swift +enum Output: Equatable { + case navigateToDetail(id: Int) + case scrollToTop(UUID) +} + +// ViewModel 내부 +private(set) var output: Output? + +func resetOutput() { + output = nil +} +``` + +--- + +## AppState 구독 (Combine + CancelBag) + +`init`에서 `cancelBag.collect {}` 블록으로 한꺼번에 구독한다. + +```swift +private func bindAppState() { + cancelBag.collect { + appState + .updates(for: \.routing.collectionView.collectionID) + .compactMap { $0 } + .weakSink(on: self) { viewModel, id in + viewModel.output = .navigateToDetail(id: id) + } + + appState + .updates(for: \.routing.collectionView.scrollToTop) + .compactMap { $0 } + .weakSink(on: self) { viewModel, _ in + viewModel.output = .scrollToTop(UUID()) + } + } +} +``` + +**규칙:** +- `weakSink(on: self)` 사용 — retain cycle 방지 +- 구독은 `cancelBag.collect {}` 블록 하나로 모은다 +- AppState 쓰기: `appState[\.routing.xxx] = value` +- 여러 상태를 한번에 바꿀 때: `appState.bulkUpdate { $0.routing.xxx = ...; $0.currentUser = ... }` + +--- + +## 비동기 작업 패턴 + +### 병렬 로딩 — `withTaskGroup` +상세 화면처럼 여러 데이터를 동시에 로드할 때 사용한다. + +```swift +func loadInitial() async { + loadState = .loading + await withTaskGroup(of: Void.self) { group in + group.addTask { await self.fetchDetail() } + group.addTask { await self.fetchComments() } + group.addTask { await self.fetchSuggestions() } + await group.waitForAll() + } + loadState = .success(()) +} +``` + +### 검색 디바운스 — `Task.debounce` +텍스트 입력 후 지연 실행이 필요한 경우에 사용한다. + +```swift +private var searchDebounceTask: Task? + +func updateSearchText(_ text: String) { + searchDebounceTask = Task.debounce(delay: 400_000_000, task: searchDebounceTask) { + await self.performSearch(text) + } +} +``` + +--- + +## 게스트 모드 처리 + +```swift +private var isGuestMode: Bool { + appState[\.authStatus] == .guest +} + +func loadPosts() async { + guard !isGuestMode else { return } + // ... +} +``` + +--- + +## 금지 사항 +- ViewModel이 `AppCoordinator`를 직접 참조하거나 주입받지 않는다 → Output으로 View에 위임 +- `@MainActor` 전체 클래스 지정 금지 — 필요한 메서드에만 개별 지정 +- UI 관련 import (`SwiftUI`) 최소화 — 불가피한 경우(`Color`, `Image` 타입)만 허용 +- 비즈니스 로직을 ViewModel에 직접 작성 금지 → Interactor에 위임 diff --git a/saerok.xcodeproj/project.pbxproj b/saerok.xcodeproj/project.pbxproj index 5e87547..692e967 100644 --- a/saerok.xcodeproj/project.pbxproj +++ b/saerok.xcodeproj/project.pbxproj @@ -15,18 +15,11 @@ 219841792DDC18CD00919B81 /* KakaoSDKCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 219841782DDC18CD00919B81 /* KakaoSDKCommon */; }; 2198417B2DDC18CD00919B81 /* KakaoSDKUser in Frameworks */ = {isa = PBXBuildFile; productRef = 2198417A2DDC18CD00919B81 /* KakaoSDKUser */; }; 21B1579E2F9A175A005DB0C6 /* PRD.md in Resources */ = {isa = PBXBuildFile; fileRef = 21B1579B2F9A175A005DB0C6 /* PRD.md */; }; - 21B1579F2F9A175A005DB0C6 /* UI_GUIDE.md in Resources */ = {isa = PBXBuildFile; fileRef = 21B1579C2F9A175A005DB0C6 /* UI_GUIDE.md */; }; + 21B1579F2F9A175A005DB0C6 /* UI-GUIDE.md in Resources */ = {isa = PBXBuildFile; fileRef = 21B1579C2F9A175A005DB0C6 /* UI-GUIDE.md */; }; 21B157A02F9A175A005DB0C6 /* ARCHITECTURE.md in Resources */ = {isa = PBXBuildFile; fileRef = 21B1579A2F9A175A005DB0C6 /* ARCHITECTURE.md */; }; 21B157A12F9A175A005DB0C6 /* ADR.md in Resources */ = {isa = PBXBuildFile; fileRef = 21B157992F9A175A005DB0C6 /* ADR.md */; }; 21D268922F63C19A00942051 /* CLAUDE.md in Resources */ = {isa = PBXBuildFile; fileRef = 21D2688D2F63C19A00942051 /* CLAUDE.md */; }; 21D268942F63C1A800942051 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 21D268932F63C1A800942051 /* README.md */; }; - 21DA2D492F95052B00A5A97C /* improvements.md in Resources */ = {isa = PBXBuildFile; fileRef = 21DA2D482F95052B00A5A97C /* improvements.md */; }; - 21E5E48B2F94EF500018A0F2 /* dependency-injection.md in Resources */ = {isa = PBXBuildFile; fileRef = 21E5E4842F94EF500018A0F2 /* dependency-injection.md */; }; - 21E5E48C2F94EF500018A0F2 /* state-management.md in Resources */ = {isa = PBXBuildFile; fileRef = 21E5E4872F94EF500018A0F2 /* state-management.md */; }; - 21E5E48D2F94EF500018A0F2 /* view-viewmodel.md in Resources */ = {isa = PBXBuildFile; fileRef = 21E5E4882F94EF500018A0F2 /* view-viewmodel.md */; }; - 21E5E48E2F94EF500018A0F2 /* design-system.md in Resources */ = {isa = PBXBuildFile; fileRef = 21E5E4852F94EF500018A0F2 /* design-system.md */; }; - 21E5E48F2F94EF500018A0F2 /* networking.md in Resources */ = {isa = PBXBuildFile; fileRef = 21E5E4862F94EF500018A0F2 /* networking.md */; }; - 21E5E4902F94EF500018A0F2 /* data-layer.md in Resources */ = {isa = PBXBuildFile; fileRef = 21E5E4832F94EF500018A0F2 /* data-layer.md */; }; 21F0CAE22DBE427E004F03F4 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 21F0CAE12DBE427E004F03F4 /* Lottie */; }; /* End PBXBuildFile section */ @@ -45,17 +38,10 @@ 21B157992F9A175A005DB0C6 /* ADR.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ADR.md; sourceTree = ""; }; 21B1579A2F9A175A005DB0C6 /* ARCHITECTURE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ARCHITECTURE.md; sourceTree = ""; }; 21B1579B2F9A175A005DB0C6 /* PRD.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = PRD.md; sourceTree = ""; }; - 21B1579C2F9A175A005DB0C6 /* UI_GUIDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = UI_GUIDE.md; sourceTree = ""; }; + 21B1579C2F9A175A005DB0C6 /* UI-GUIDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "UI-GUIDE.md"; sourceTree = ""; }; 21D2688D2F63C19A00942051 /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = ""; }; 21D268932F63C1A800942051 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; - 21DA2D482F95052B00A5A97C /* improvements.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = improvements.md; sourceTree = ""; }; 21DA2D502F9508EE00A5A97C /* saerokTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = saerokTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 21E5E4832F94EF500018A0F2 /* data-layer.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "data-layer.md"; sourceTree = ""; }; - 21E5E4842F94EF500018A0F2 /* dependency-injection.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "dependency-injection.md"; sourceTree = ""; }; - 21E5E4852F94EF500018A0F2 /* design-system.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "design-system.md"; sourceTree = ""; }; - 21E5E4862F94EF500018A0F2 /* networking.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = networking.md; sourceTree = ""; }; - 21E5E4872F94EF500018A0F2 /* state-management.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "state-management.md"; sourceTree = ""; }; - 21E5E4882F94EF500018A0F2 /* view-viewmodel.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "view-viewmodel.md"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -132,21 +118,7 @@ 21B157992F9A175A005DB0C6 /* ADR.md */, 21B1579A2F9A175A005DB0C6 /* ARCHITECTURE.md */, 21B1579B2F9A175A005DB0C6 /* PRD.md */, - 21B1579C2F9A175A005DB0C6 /* UI_GUIDE.md */, - ); - path = docs; - sourceTree = ""; - }; - 21E5E4892F94EF500018A0F2 /* docs */ = { - isa = PBXGroup; - children = ( - 21E5E4832F94EF500018A0F2 /* data-layer.md */, - 21E5E4842F94EF500018A0F2 /* dependency-injection.md */, - 21E5E4852F94EF500018A0F2 /* design-system.md */, - 21E5E4862F94EF500018A0F2 /* networking.md */, - 21E5E4872F94EF500018A0F2 /* state-management.md */, - 21E5E4882F94EF500018A0F2 /* view-viewmodel.md */, - 21DA2D482F95052B00A5A97C /* improvements.md */, + 21B1579C2F9A175A005DB0C6 /* UI-GUIDE.md */, ); path = docs; sourceTree = ""; @@ -154,7 +126,6 @@ 21E5E48A2F94EF500018A0F2 /* .claude */ = { isa = PBXGroup; children = ( - 21E5E4892F94EF500018A0F2 /* docs */, ); path = .claude; sourceTree = ""; @@ -264,17 +235,10 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 21E5E48B2F94EF500018A0F2 /* dependency-injection.md in Resources */, - 21E5E48C2F94EF500018A0F2 /* state-management.md in Resources */, - 21E5E48D2F94EF500018A0F2 /* view-viewmodel.md in Resources */, - 21E5E48E2F94EF500018A0F2 /* design-system.md in Resources */, - 21DA2D492F95052B00A5A97C /* improvements.md in Resources */, 21B1579E2F9A175A005DB0C6 /* PRD.md in Resources */, - 21B1579F2F9A175A005DB0C6 /* UI_GUIDE.md in Resources */, + 21B1579F2F9A175A005DB0C6 /* UI-GUIDE.md in Resources */, 21B157A02F9A175A005DB0C6 /* ARCHITECTURE.md in Resources */, 21B157A12F9A175A005DB0C6 /* ADR.md in Resources */, - 21E5E48F2F94EF500018A0F2 /* networking.md in Resources */, - 21E5E4902F94EF500018A0F2 /* data-layer.md in Resources */, 21D268922F63C19A00942051 /* CLAUDE.md in Resources */, 21D268942F63C1A800942051 /* README.md in Resources */, ); diff --git a/saerok.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/saerok.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 7477f79..919434a 100644 --- a/saerok.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/saerok.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -4,14 +4,4 @@ - - - - - - diff --git a/saerok.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/saerok.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 807951c..16176cf 100644 --- a/saerok.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/saerok.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -258,8 +258,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/navermaps/SPM-NMapsMap", "state" : { - "revision" : "d527147b0bf31260166a7063e3ed854a03f8239c", - "version" : "3.23.2" + "revision" : "ad89e53fdfec3b8d8994280fb0414b5a7b1c3e8e", + "version" : "3.21.0" } } ],