Compare commits
27 Commits
243da1eb7d
...
942c581eaf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
942c581eaf | ||
|
|
71edcf8bf9 | ||
|
|
d247bb4958 | ||
|
|
1a5df53edb | ||
|
|
270332d7c4 | ||
|
|
389f82fa82 | ||
|
|
44b8633e59 | ||
|
|
c217581d1d | ||
|
|
51ffe09125 | ||
|
|
5823f6ddb2 | ||
|
|
714ad459b0 | ||
|
|
69a7d291d1 | ||
|
|
0f5cd8a904 | ||
|
|
e5a9d4c307 | ||
|
|
88dac10c9f | ||
|
|
14f719fcc4 | ||
|
|
b269a356e1 | ||
|
|
ddd82b6b8f | ||
|
|
8baae71317 | ||
|
|
503468f713 | ||
|
|
0813b64bc9 | ||
|
|
120d961456 | ||
|
|
7db825cd41 | ||
|
|
3e524f121d | ||
|
|
4f427fc146 | ||
|
|
8b04952a4e | ||
|
|
13187070b5 |
115
.opencode/package-lock.json
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"name": ".opencode",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.3.tgz",
|
||||
"integrity": "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.4.3",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.97",
|
||||
"@opentui/solid": ">=0.1.97"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.3.tgz",
|
||||
"integrity": "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
242
AGENTS.md
@@ -1,6 +1,87 @@
|
||||
# AGENTS.md
|
||||
`SodaLive` 저장소에서 작업하는 에이전트 실행 가이드다.
|
||||
|
||||
## CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)
|
||||
These principles override plugin behavior, skill behavior, workflow behavior, and default model behavior unless the user's direct instruction explicitly says otherwise.
|
||||
|
||||
The following content is taken from the official `andrej-karpathy-skills` `CLAUDE.md` source and is intentionally kept in English.
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
### 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
### 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
### 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
### 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
---
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
|
||||
## 지시 우선순위
|
||||
충돌 시 항상 더 높은 우선순위의 지시를 따른다.
|
||||
|
||||
1. 사용자 직접 지시
|
||||
2. `AGENTS.md`
|
||||
3. 프로젝트별 제약 조건
|
||||
4. `oh-my-openagent` 플러그인의 `agents` / `workflows` / `hooks`
|
||||
5. `superpowers` skills
|
||||
6. 기본 모델 동작
|
||||
|
||||
사용자 직접 지시가 명확할 경우 사용자 지시가 최우선이다. plugin / skill / workflow 지시가 `CORE EXECUTION PRINCIPLES`와 충돌하면 `CORE EXECUTION PRINCIPLES`를 따른다. 불확실하거나 모호한 경우 추측하지 말고 확인하거나, 가능한 최소 범위의 안전한 조치를 취한다.
|
||||
|
||||
## 커뮤니케이션 규칙
|
||||
- **"질문에 대한 답변과 설명은 한국어로 한다."**
|
||||
- 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
|
||||
@@ -16,145 +97,58 @@
|
||||
|
||||
### 수정 우선순위
|
||||
- 기능 변경은 `SodaLive/Sources/**`에서 해결한다.
|
||||
- 기존 로직 수정이 아닌 신규 `View`, `ViewModel`, `Repository` 및 그와 연결된 하위 코드는 `SodaLive/Sources/V2/**` 아래에 작성한다.
|
||||
- 프로젝트 설정 변경은 필요한 경우에만 수행한다.
|
||||
- `Pods/**`, `generated/**`는 직접 수정하지 않는다.
|
||||
- `build/**`는 빌드 산출물로 간주하며 수정 대상이 아니다.
|
||||
|
||||
## 빌드/테스트/검증 명령
|
||||
아래 명령은 현재 저장소에서 확인된 공식 진입점이다.
|
||||
## 실행 모드
|
||||
### 기본 모드: 보수적 실행
|
||||
- 최소 변경
|
||||
- 단순한 구현
|
||||
- 검증 가능한 결과
|
||||
|
||||
### 1) 의존성 설치
|
||||
- `pod install`
|
||||
- 근거: `Podfile`에 CocoaPods 타깃(`SodaLive`, `SodaLive-dev`) 정의.
|
||||
### 확장 모드
|
||||
- 사용자가 명시적으로 요청한 경우에만 사용한다.
|
||||
- 대규모 리팩토링, 브레인스토밍, 다중 에이전트 실행, 병렬 workflow를 허용한다.
|
||||
|
||||
### 2) 스킴/타깃 확인
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -list`
|
||||
- 근거: 공유 스킴 `SodaLive`, `SodaLive-dev` 존재.
|
||||
## oh-my-openagent 제어 정책
|
||||
- `oh-my-openagent`는 opencode의 플러그인 기반 실행 오케스트레이션 계층이다.
|
||||
- `oh-my-openagent`는 의사결정 권한이 아니라 실행 보조 권한만 가진다.
|
||||
- 작은 작업에는 multi-agent 실행이나 과도한 workflow를 사용하지 않는다.
|
||||
- 병렬 실행은 명확한 이득이 있을 때만 사용한다.
|
||||
- 모든 `oh-my-openagent` 동작은 `CORE EXECUTION PRINCIPLES`를 따라야 한다.
|
||||
|
||||
### 3) 빌드
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
|
||||
|
||||
### 4) 테스트(전체)
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
|
||||
|
||||
### 5) 단일 테스트 실행
|
||||
- 일반 형식(테스트 타깃이 있는 경우):
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -only-testing:"SodaLiveTests/TestClass/testMethod" test`
|
||||
- **현재 주의사항**:
|
||||
- `SodaLive.xcodeproj/project.pbxproj` 기준으로 앱 타깃 중심 구성이고 테스트 번들 타깃이 확인되지 않는다.
|
||||
- 따라서 현재 상태에서는 단일 테스트 지정이 실질적으로 동작하지 않을 수 있다.
|
||||
|
||||
### 6) 린트/포맷
|
||||
- 저장소에 공식 `swiftlint`/`swiftformat` 실행 스크립트는 확인되지 않았다.
|
||||
- `generated/*.generated.swift`에 `swiftlint:disable all` 주석은 존재하나, 이는 생성 코드 보호 목적이다.
|
||||
- 린트 도구를 도입/추가하면 본 문서 명령 섹션을 즉시 갱신한다.
|
||||
|
||||
## 코드 스타일 가이드
|
||||
|
||||
### 아키텍처/레이어
|
||||
- 기본 흐름은 `View -> ViewModel -> Repository -> Api(TargetType)`를 따른다.
|
||||
- API는 `enum ...Api: TargetType`, 저장소는 `final class ...Repository` 형태를 우선 사용한다.
|
||||
- 상태 모델은 `struct`/`enum` 중심으로 두고, 화면 상태는 `ObservableObject`에서 관리한다.
|
||||
|
||||
### 임포트 규칙
|
||||
- 시스템 프레임워크(`Foundation`, `SwiftUI`, `Combine`)를 먼저 배치한다.
|
||||
- 서드파티(`Moya`, `CombineMoya`, SDK들)는 이후 배치한다.
|
||||
- import 그룹 사이에는 한 줄 공백으로 의미 단위를 분리한다.
|
||||
|
||||
### 포맷/구조
|
||||
- 들여쓰기는 4칸 스페이스를 사용한다.
|
||||
- 프로퍼티 선언, 비즈니스 로직, 헬퍼 메서드는 공백 줄로 구획한다.
|
||||
- 클로저 체인은 줄바꿈해 가독성을 유지한다.
|
||||
|
||||
### 타입/상태 관리
|
||||
- ViewModel은 `final class ...: ObservableObject` 패턴을 우선한다.
|
||||
- View가 소유하는 객체는 `@StateObject`, 외부 주입 객체는 `@ObservedObject`를 사용한다.
|
||||
- 네트워크 반환은 `AnyPublisher<Response, MoyaError>` 패턴을 기본으로 따른다.
|
||||
|
||||
### 네이밍 규칙
|
||||
- 타입명은 PascalCase (`HomeViewModel`, `UserRepository`, `UserApi`).
|
||||
- 변수/함수는 camelCase (`errorMessage`, `getMemberInfo`).
|
||||
- 역할을 이름에 반영한다 (`*View`, `*ViewModel`, `*Repository`, `*Api`, `*Request`, `*Response`).
|
||||
|
||||
### 비동기/Combine 규칙
|
||||
- 비동기 처리는 Combine의 `sink`, `receiveValue`, `.store(in: &subscription)` 패턴을 따른다.
|
||||
- `sink` 완료 블록에서 `.failure`를 반드시 처리한다.
|
||||
- 클로저 캡처는 상황에 맞게 `[weak self]` 또는 `[unowned self]`를 선택한다.
|
||||
|
||||
### 에러 처리 규칙
|
||||
- 사용자 노출 오류는 `errorMessage`와 팝업 플래그(`isShowPopup`)로 일관되게 처리한다.
|
||||
- JSON 파싱은 `do/catch + JSONDecoder` 패턴을 따른다.
|
||||
- **신규 코드에서 빈 `catch`는 금지**하고, 최소한 로깅 또는 명시적 무시 사유를 남긴다.
|
||||
|
||||
### 로깅 규칙
|
||||
- 디버그 로그는 `DEBUG_LOG`, 오류 로그는 `ERROR_LOG`를 사용한다.
|
||||
- `print`는 임시 디버깅 목적 외 신규 코드에서 지양한다.
|
||||
|
||||
### 네트워크/헤더 규칙
|
||||
- 공통 Moya 플러그인(`AuthPlugin`, `AcceptLanguagePlugin`) 흐름을 유지한다.
|
||||
- 언어 헤더는 `LanguageHeaderProvider.current`를 기준으로 사용한다.
|
||||
|
||||
### 문자열/다국어
|
||||
- 신규 사용자 노출 문자열은 가능하면 `I18n` 경로를 우선 사용한다.
|
||||
- 다국어 기능 수정 시 `Settings/Language` 모듈과 `Accept-Language` 헤더 흐름을 함께 점검한다.
|
||||
|
||||
### 주석/문서화
|
||||
- 자명한 코드에는 주석을 남기지 않는다.
|
||||
- 복잡한 분기, 외부 제약, 부작용이 있는 로직에만 주석을 추가한다.
|
||||
|
||||
## Cursor/Copilot 규칙 반영
|
||||
- 아래 파일 존재 여부를 확인해 AGENTS와 함께 유지한다.
|
||||
- `.cursor/rules/**`
|
||||
- `.cursorrules`
|
||||
- `.github/copilot-instructions.md`
|
||||
- 현재 저장소에서는 위 파일들이 확인되지 않았다.
|
||||
- 추후 파일이 추가되면 AGENTS.md에 요약 규칙을 동기화한다.
|
||||
- 충돌 우선순위 기본값:
|
||||
- 범위가 더 구체적인 규칙이 우선한다(경로 특화 > 저장소 전역).
|
||||
- Cursor: `.cursor/rules/**` > `.cursorrules` > `AGENTS.md`
|
||||
- Copilot: `.github/instructions/**`(존재 시) > `.github/copilot-instructions.md` > `AGENTS.md`
|
||||
|
||||
## 커밋 메시지 규칙 (표준 Conventional Commits)
|
||||
- 커밋 상세 가이드/절차는 `.opencode/skills/commit-policy/SKILL.md`를 단일 기준으로 사용한다.
|
||||
- 커밋 작업 시작 시 `skill` 도구로 `commit-policy`를 먼저 로드한다.
|
||||
- 기본 형식은 `<type>(scope): <description>`를 사용한다.
|
||||
- `type`은 소문자(`feat`, `fix`, `chore`, `docs`, `refactor`, `test`)를 사용한다.
|
||||
- 제목(description)은 한글로 작성하고, 명령형/간결한 현재형으로 작성한다.
|
||||
- 이슈 참조 footer는 `Refs: #123` 또는 `Refs: #123, #456` 형식을 사용한다.
|
||||
|
||||
### 커밋 메시지 검증 절차
|
||||
- `git commit` 직후 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다.
|
||||
- 스크립트 결과가 `[FAIL]`이면 커밋 메시지를 수정한 뒤 다시 검증한다.
|
||||
|
||||
## 작업 절차 체크리스트
|
||||
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
|
||||
- 변경 중: 공개 API 스키마를 임의 변경하지 말고 작은 단위로 안전하게 수정한다.
|
||||
- 변경 후: 영향 범위 파일에 대해 빌드/테스트/로그/다국어 키를 점검한다.
|
||||
- 커밋 직후: `commit-policy` 스킬을 로드하고 메시지 검증 스크립트를 실행한다.
|
||||
|
||||
## 작업 계획 문서 규칙 (docs)
|
||||
- 모든 작업 시작 전에 `docs` 폴더 아래 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현한다.
|
||||
- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다.
|
||||
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
|
||||
- 구현 항목은 기능/작업 단위 체크박스(`- [ ]`)로 작성하고 완료 즉시 `- [x]`로 갱신한다.
|
||||
- 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
|
||||
- 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)을 한국어로 남긴다.
|
||||
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰기 없이 누적한다.
|
||||
|
||||
## 문서 유지보수 규칙
|
||||
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
|
||||
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
|
||||
- 명령/경로/타깃명이 바뀌면 본 문서를 즉시 업데이트한다.
|
||||
## superpowers 사용 정책
|
||||
- `superpowers`는 선택적 스킬 계층이다.
|
||||
- `superpowers` skill은 필요한 경우에만 사용한다.
|
||||
- `superpowers`가 과도한 리팩토링, 불필요한 범위 확장, 가정 기반 실행을 유도하면 따르지 않는다.
|
||||
- `superpowers`를 사용할 때도 최소 변경, 단순성, 검증 가능성을 우선한다.
|
||||
- 모든 `superpowers` 동작은 `CORE EXECUTION PRINCIPLES`를 따라야 한다.
|
||||
|
||||
## 에이전트 동작 원칙
|
||||
- 추측하지 말고 근거 파일을 읽고 결정한다.
|
||||
- 기존 관례를 깨는 변경은 이유가 명확할 때만 수행한다.
|
||||
- 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다.
|
||||
- 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다.
|
||||
- 상세 실행 정책은 `docs/agent-guides/agent-execution-policy.md`를 참조한다.
|
||||
|
||||
## 개발 세부 가이드
|
||||
- 빌드/테스트/검증 명령은 `docs/agent-guides/build-test-verification.md`를 참조한다.
|
||||
- iOS 코드 스타일은 `docs/agent-guides/code-style.md`를 참조한다.
|
||||
- Cursor/Copilot 규칙은 `docs/agent-guides/sodalive-ios-development.md`를 참조한다.
|
||||
- 커밋 상세 가이드/절차는 `.opencode/skills/commit-policy/SKILL.md`를 단일 기준으로 사용한다.
|
||||
- 커밋 작업 시작 시 `skill` 도구로 `commit-policy`를 먼저 로드한다.
|
||||
- 기본 커밋 형식은 `<type>(scope): <description>`를 사용하고, 제목(description)은 한글 명령형/간결한 현재형으로 작성한다.
|
||||
- `git commit` 직후 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다.
|
||||
|
||||
## 설정/보안 유의사항
|
||||
- 토큰/키/개인정보를 코드/로그/문서에 하드코딩하지 않는다.
|
||||
- 인증 관련 헤더/토큰 처리 로직(`AuthPlugin`, `UserDefaultsKey.token`) 수정 시 회귀 위험을 함께 점검한다.
|
||||
- 외부 SDK 키 변경 시 빌드 설정과 런타임 초기화 지점을 함께 검토한다.
|
||||
|
||||
## 문서 작성 규칙
|
||||
- 구현 전 PRD 작성, 사용자 인터뷰, 계획/TASK 문서 작성, 체크리스트 갱신, 검증 기록 누적, 문서 분리 기준은 `docs/agent-guides/documentation-policy.md`를 따른다.
|
||||
|
||||
## 문서 유지보수 규칙
|
||||
- 상세 문서 유지보수 규칙은 `docs/agent-guides/documentation-policy.md`를 참조한다.
|
||||
|
||||
2
Podfile
@@ -10,6 +10,7 @@ target 'SodaLive' do
|
||||
pod 'AgoraRtm', '2.2.4'
|
||||
pod 'GoogleSignIn'
|
||||
pod 'GoogleSignInSwiftSupport'
|
||||
pod 'YandexMobileAds', '8.0.0'
|
||||
|
||||
end
|
||||
|
||||
@@ -22,6 +23,7 @@ target 'SodaLive-dev' do
|
||||
pod 'AgoraRtm', '2.2.4'
|
||||
pod 'GoogleSignIn'
|
||||
pod 'GoogleSignInSwiftSupport'
|
||||
pod 'YandexMobileAds', '8.0.0'
|
||||
|
||||
end
|
||||
|
||||
|
||||
158
Podfile.lock
@@ -15,6 +15,74 @@ PODS:
|
||||
- GoogleUtilities/Environment (~> 8.0)
|
||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- AppMetricaAdSupport (6.0.0):
|
||||
- AppMetricaCore (= 6.0.0)
|
||||
- AppMetricaCoreExtension (= 6.0.0)
|
||||
- AppMetricaCore (6.0.0):
|
||||
- AppMetricaCoreUtils (= 6.0.0)
|
||||
- AppMetricaEncodingUtils (= 6.0.0)
|
||||
- AppMetricaFMDB (= 6.0.0)
|
||||
- AppMetricaHostState (= 6.0.0)
|
||||
- AppMetricaIdentifiers (= 6.0.0)
|
||||
- AppMetricaKeychain (= 6.0.0)
|
||||
- AppMetricaLog (= 6.0.0)
|
||||
- AppMetricaNetwork (= 6.0.0)
|
||||
- AppMetricaPlatform (= 6.0.0)
|
||||
- AppMetricaProtobuf (= 6.0.0)
|
||||
- AppMetricaProtobufUtils (= 6.0.0)
|
||||
- AppMetricaStorageUtils (= 6.0.0)
|
||||
- AppMetricaCoreExtension (6.0.0):
|
||||
- AppMetricaCore (= 6.0.0)
|
||||
- AppMetricaStorageUtils (= 6.0.0)
|
||||
- AppMetricaCoreUtils (6.0.0):
|
||||
- AppMetricaLog (= 6.0.0)
|
||||
- AppMetricaEncodingUtils (6.0.0):
|
||||
- AppMetricaCoreUtils (= 6.0.0)
|
||||
- AppMetricaLog (= 6.0.0)
|
||||
- AppMetricaPlatform (= 6.0.0)
|
||||
- AppMetricaFMDB (6.0.0)
|
||||
- AppMetricaHostState (6.0.0):
|
||||
- AppMetricaCoreUtils (= 6.0.0)
|
||||
- AppMetricaLog (= 6.0.0)
|
||||
- AppMetricaIdentifiers (6.0.0):
|
||||
- AppMetricaKeychain (= 6.0.0)
|
||||
- AppMetricaLogSwift (= 6.0.0)
|
||||
- AppMetricaPlatform (= 6.0.0)
|
||||
- AppMetricaStorageUtils (= 6.0.0)
|
||||
- AppMetricaSynchronization (= 6.0.0)
|
||||
- AppMetricaIDSync (6.0.0):
|
||||
- AppMetricaCore (= 6.0.0)
|
||||
- AppMetricaCoreExtension (= 6.0.0)
|
||||
- AppMetricaCoreUtils (= 6.0.0)
|
||||
- AppMetricaLog (= 6.0.0)
|
||||
- AppMetricaNetwork (= 6.0.0)
|
||||
- AppMetricaPlatform (= 6.0.0)
|
||||
- AppMetricaStorageUtils (= 6.0.0)
|
||||
- AppMetricaKeychain (6.0.0):
|
||||
- AppMetricaCoreUtils (= 6.0.0)
|
||||
- AppMetricaLog (= 6.0.0)
|
||||
- AppMetricaStorageUtils (= 6.0.0)
|
||||
- AppMetricaLibraryAdapter (6.0.0):
|
||||
- AppMetricaCore (= 6.0.0)
|
||||
- AppMetricaCoreExtension (= 6.0.0)
|
||||
- AppMetricaLog (6.0.0)
|
||||
- AppMetricaLogSwift (6.0.0):
|
||||
- AppMetricaLog (= 6.0.0)
|
||||
- AppMetricaNetwork (6.0.0):
|
||||
- AppMetricaCoreUtils (= 6.0.0)
|
||||
- AppMetricaLog (= 6.0.0)
|
||||
- AppMetricaPlatform (= 6.0.0)
|
||||
- AppMetricaPlatform (6.0.0):
|
||||
- AppMetricaCoreUtils (= 6.0.0)
|
||||
- AppMetricaLog (= 6.0.0)
|
||||
- AppMetricaProtobuf (6.0.0)
|
||||
- AppMetricaProtobufUtils (6.0.0):
|
||||
- AppMetricaProtobuf (= 6.0.0)
|
||||
- AppMetricaStorageUtils (6.0.0):
|
||||
- AppMetricaCoreUtils (= 6.0.0)
|
||||
- AppMetricaLog (= 6.0.0)
|
||||
- AppMetricaSynchronization (6.0.0):
|
||||
- AppMetricaLogSwift (= 6.0.0)
|
||||
- Bootpay (4.4.6):
|
||||
- CryptoSwift
|
||||
- NVActivityIndicatorView
|
||||
@@ -27,6 +95,19 @@ PODS:
|
||||
- SnapKit
|
||||
- SwiftyJSON
|
||||
- CryptoSwift (1.8.4)
|
||||
- DivKit (32.46.0):
|
||||
- DivKit_LayoutKit (= 32.46.0)
|
||||
- DivKit_Serialization (= 32.46.0)
|
||||
- VGSL (~> 7.21)
|
||||
- DivKit_LayoutKit (32.46.0):
|
||||
- DivKit_LayoutKitInterface (= 32.46.0)
|
||||
- VGSL (~> 7.21)
|
||||
- DivKit_LayoutKitInterface (32.46.0):
|
||||
- VGSL (~> 7.21)
|
||||
- DivKit_Serialization (32.46.0):
|
||||
- VGSL (~> 7.21)
|
||||
- DivKitBinaryCompatibilityFacade (5.3.0):
|
||||
- DivKit (~> 32.14)
|
||||
- GoogleSignIn (9.1.0):
|
||||
- AppAuth (~> 2.0)
|
||||
- AppCheckCore (~> 11.0)
|
||||
@@ -54,12 +135,29 @@ PODS:
|
||||
- PromisesObjC (2.4.0)
|
||||
- SnapKit (5.7.1)
|
||||
- SwiftyJSON (5.0.2)
|
||||
- VGSL (7.21.0):
|
||||
- VGSLFundamentals (= 7.21.0)
|
||||
- VGSLNetworking (= 7.21.0)
|
||||
- VGSLUI (= 7.21.0)
|
||||
- VGSLFundamentals (7.21.0)
|
||||
- VGSLNetworking (7.21.0):
|
||||
- VGSLFundamentals (= 7.21.0)
|
||||
- VGSLUI (= 7.21.0)
|
||||
- VGSLUI (7.21.0):
|
||||
- VGSLFundamentals (= 7.21.0)
|
||||
- YandexMobileAds (8.0.0):
|
||||
- AppMetricaAdSupport (~> 6.0.0)
|
||||
- AppMetricaCore (~> 6.0.0)
|
||||
- AppMetricaIDSync (~> 6.0.0)
|
||||
- AppMetricaLibraryAdapter (~> 6.0.0)
|
||||
- DivKitBinaryCompatibilityFacade (~> 5.3.0)
|
||||
|
||||
DEPENDENCIES:
|
||||
- AgoraRtm (= 2.2.4)
|
||||
- BootpayUI (= 4.4.10)
|
||||
- GoogleSignIn
|
||||
- GoogleSignInSwiftSupport
|
||||
- YandexMobileAds (= 8.0.0)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
@@ -67,9 +165,33 @@ SPEC REPOS:
|
||||
- Alamofire
|
||||
- AppAuth
|
||||
- AppCheckCore
|
||||
- AppMetricaAdSupport
|
||||
- AppMetricaCore
|
||||
- AppMetricaCoreExtension
|
||||
- AppMetricaCoreUtils
|
||||
- AppMetricaEncodingUtils
|
||||
- AppMetricaFMDB
|
||||
- AppMetricaHostState
|
||||
- AppMetricaIdentifiers
|
||||
- AppMetricaIDSync
|
||||
- AppMetricaKeychain
|
||||
- AppMetricaLibraryAdapter
|
||||
- AppMetricaLog
|
||||
- AppMetricaLogSwift
|
||||
- AppMetricaNetwork
|
||||
- AppMetricaPlatform
|
||||
- AppMetricaProtobuf
|
||||
- AppMetricaProtobufUtils
|
||||
- AppMetricaStorageUtils
|
||||
- AppMetricaSynchronization
|
||||
- Bootpay
|
||||
- BootpayUI
|
||||
- CryptoSwift
|
||||
- DivKit
|
||||
- DivKit_LayoutKit
|
||||
- DivKit_LayoutKitInterface
|
||||
- DivKit_Serialization
|
||||
- DivKitBinaryCompatibilityFacade
|
||||
- GoogleSignIn
|
||||
- GoogleSignInSwiftSupport
|
||||
- GoogleUtilities
|
||||
@@ -80,15 +202,44 @@ SPEC REPOS:
|
||||
- PromisesObjC
|
||||
- SnapKit
|
||||
- SwiftyJSON
|
||||
- VGSL
|
||||
- VGSLFundamentals
|
||||
- VGSLNetworking
|
||||
- VGSLUI
|
||||
- YandexMobileAds
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
AgoraRtm: 534144434383d41b3b0ebfae2a961ef0f51b0645
|
||||
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
|
||||
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
|
||||
AppMetricaAdSupport: 43a4d1509cdbfe712fb3f009fe60d3a6481816a0
|
||||
AppMetricaCore: dae62fe7f95cd665b142218f3d94cf63262c195b
|
||||
AppMetricaCoreExtension: 72da13ba849d4676f276ab86ff429bdf700eadc3
|
||||
AppMetricaCoreUtils: 5e7c91cbafe0225dec2ded2bb3a806256e2ef791
|
||||
AppMetricaEncodingUtils: a67df57f752dbbb174beea18f1de52d24853d834
|
||||
AppMetricaFMDB: a3d8e45a5a85bec23a997be4469b92ab355a4df5
|
||||
AppMetricaHostState: 1ac2ab5880aa30a358f55a044e57354dd8f9062f
|
||||
AppMetricaIdentifiers: 00061e0cdcb371b74343a5491f54adfc1d470b25
|
||||
AppMetricaIDSync: 91b403172ad78da3574c691ed34c25f69c2296ee
|
||||
AppMetricaKeychain: 9ec64d877b8a3f6e823f3bbeef5e4086aa456686
|
||||
AppMetricaLibraryAdapter: 56fa0f988850051d10f9ac3b6b9ace7bb6aa8fd7
|
||||
AppMetricaLog: edd74df81c7557439c36c566989982f4c735e4d6
|
||||
AppMetricaLogSwift: 3d2d4a3cbc33a680389b416a1c6a82ef4134da10
|
||||
AppMetricaNetwork: 3dc6d768d4e932c3697c9c2c9a768de54e62059b
|
||||
AppMetricaPlatform: 041a7b251ea1689e26626d9db8f331af1afe5bad
|
||||
AppMetricaProtobuf: 01b141a164fa7277f641a29f30d5e571ea3d471a
|
||||
AppMetricaProtobufUtils: dc48c7b84f3a1ef86ea218bbd97480ea9da4d3bc
|
||||
AppMetricaStorageUtils: 78071115b9f5468d9e3b8184c456428cd9ee1dbd
|
||||
AppMetricaSynchronization: 909bab97c61c0c147a435ce1620e4c8069e2d6b3
|
||||
Bootpay: cd7f0542b096ab0af0b09a6e12a6b87f2cbbb531
|
||||
BootpayUI: beec5b0bba002b4dbced8c0ecace571ed6a017bc
|
||||
CryptoSwift: e64e11850ede528a02a0f3e768cec8e9d92ecb90
|
||||
DivKit: c66e0fa88b4671f832fb9ca3f142d6f56a56919d
|
||||
DivKit_LayoutKit: e30d4d345034c2dfb356e5a891dd359ac79a5aff
|
||||
DivKit_LayoutKitInterface: 320f0ef8c4f95bb8212b13400502adf0259c0b21
|
||||
DivKit_Serialization: c5ba4f12034eca16960e80f369b689fd8cda95b0
|
||||
DivKitBinaryCompatibilityFacade: fc2284a2edea4d65aa0966006ea63274eb9f721b
|
||||
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
|
||||
GoogleSignInSwiftSupport: aca902e4e15b234611ecac74ef5c8f61278f774e
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
@@ -99,7 +250,12 @@ SPEC CHECKSUMS:
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||
SwiftyJSON: f5b1bf1cd8dd53cd25887ac0eabcfd92301c6a5a
|
||||
VGSL: 0573c2b82b05aadcba4836398ce3778d271bfd13
|
||||
VGSLFundamentals: 3a081684c1a5df5800bf88aca8a9bdff2c10cfd9
|
||||
VGSLNetworking: 0ea8a335bc4f4eba3f6123ffe441cf1c08f267f2
|
||||
VGSLUI: 249a16cccdb75f1a5a1733894d013bca76e27c5e
|
||||
YandexMobileAds: ca6c63c4148ae87fefc1821d0b466ea567069d5b
|
||||
|
||||
PODFILE CHECKSUM: 70c5639090824ff26cfad959985347579609e1e6
|
||||
PODFILE CHECKSUM: 525ba559e93875de1314bb1a7894791eee442151
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
6
SodaLive/Resources/Assets.xcassets/v2/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
21
SodaLive/Resources/Assets.xcassets/v2/ic_bar_bell.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ic_bar_bell.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_bar_bell.imageset/ic_bar_bell.png
vendored
Normal file
|
After Width: | Height: | Size: 401 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_bar_cash.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ic_bar_cash.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_bar_cash.imageset/ic_bar_cash.png
vendored
Normal file
|
After Width: | Height: | Size: 693 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_bar_search.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ic_bar_search.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_bar_search.imageset/ic_bar_search.png
vendored
Normal file
|
After Width: | Height: | Size: 369 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_nav_chat.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ic_nav_chat.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_nav_chat.imageset/ic_nav_chat.png
vendored
Normal file
|
After Width: | Height: | Size: 602 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_nav_chat_selected.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ic_nav_chat_selected.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_nav_chat_selected.imageset/ic_nav_chat_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 446 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_nav_content.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ic_nav_content.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_nav_content.imageset/ic_nav_content.png
vendored
Normal file
|
After Width: | Height: | Size: 537 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_nav_content_selected.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ic_nav_content_selected.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_nav_content_selected.imageset/ic_nav_content_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 378 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_nav_home.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ic_nav_home.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_nav_home.imageset/ic_nav_home.png
vendored
Normal file
|
After Width: | Height: | Size: 564 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_nav_home_selected.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ic_nav_home_selected.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_nav_home_selected.imageset/ic_nav_home_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 408 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_nav_my.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ic_nav_my.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_nav_my.imageset/ic_nav_my.png
vendored
Normal file
|
After Width: | Height: | Size: 723 B |
21
SodaLive/Resources/Assets.xcassets/v2/ic_nav_my_selected.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ic_nav_my_selected.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/ic_nav_my_selected.imageset/ic_nav_my_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 556 B |
21
SodaLive/Resources/Assets.xcassets/v2/img_text_logo_v2.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "img_text_logo_v2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
SodaLive/Resources/Assets.xcassets/v2/img_text_logo_v2.imageset/img_text_logo_v2.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
@@ -15,6 +15,7 @@ import FirebaseCore
|
||||
import FirebaseAnalytics
|
||||
import FirebaseMessaging
|
||||
import LineSDK
|
||||
import YandexMobileAds
|
||||
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
@@ -30,6 +31,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
Messaging.messaging().delegate = self
|
||||
setupAppsFlyer()
|
||||
|
||||
// YandexAds 초기화
|
||||
YandexAds.initializeSDK(completionHandler: nil)
|
||||
|
||||
// For iOS 10 display notification (sent via APNS)
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
|
||||
|
||||
@@ -35,7 +35,13 @@ struct CharacterView: View {
|
||||
onSelectCharacter(ch.characterId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
YandexInlineBannerView(
|
||||
placement: .chatCharacterList,
|
||||
horizontalPadding: 24
|
||||
)
|
||||
.padding(.vertical, -24)
|
||||
|
||||
// 인기 캐릭터 섹션
|
||||
if !viewModel.popularCharacters.isEmpty {
|
||||
CharacterSectionView(
|
||||
|
||||
@@ -21,41 +21,48 @@ struct OriginalTabView: View {
|
||||
let width = (geo.size.width - (horizontalPadding * 2) - totalSpacing) / 3
|
||||
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
LazyVGrid(
|
||||
columns: Array(
|
||||
repeating: GridItem(
|
||||
.flexible(),
|
||||
spacing: gridSpacing,
|
||||
alignment: .topLeading
|
||||
VStack(spacing: 12) {
|
||||
YandexInlineBannerView(
|
||||
placement: .chatOriginalTabTop,
|
||||
horizontalPadding: horizontalPadding
|
||||
)
|
||||
|
||||
LazyVGrid(
|
||||
columns: Array(
|
||||
repeating: GridItem(
|
||||
.flexible(),
|
||||
spacing: gridSpacing,
|
||||
alignment: .topLeading
|
||||
),
|
||||
count: 3
|
||||
),
|
||||
count: 3
|
||||
),
|
||||
alignment: .leading,
|
||||
spacing: gridSpacing
|
||||
) {
|
||||
ForEach(viewModel.items.indices, id: \.self) { idx in
|
||||
let item = viewModel.items[idx]
|
||||
|
||||
OriginalTabItemView(
|
||||
item: item,
|
||||
size: width
|
||||
)
|
||||
.onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) }
|
||||
.onTapGesture {
|
||||
AppState.shared
|
||||
.setAppStep(step: .originalWorkDetail(originalId: item.id))
|
||||
alignment: .leading,
|
||||
spacing: gridSpacing
|
||||
) {
|
||||
ForEach(viewModel.items.indices, id: \.self) { idx in
|
||||
let item = viewModel.items[idx]
|
||||
|
||||
OriginalTabItemView(
|
||||
item: item,
|
||||
size: width
|
||||
)
|
||||
.onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) }
|
||||
.onTapGesture {
|
||||
AppState.shared
|
||||
.setAppStep(step: .originalWorkDetail(originalId: item.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.padding(.vertical, 16)
|
||||
Spacer()
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.padding(.vertical, 16)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +55,17 @@ class ChatRoomRepository {
|
||||
return talkApi.requestPublisher(.getChatQuotaStatus(roomId: roomId))
|
||||
}
|
||||
|
||||
func purchaseChatQuota(roomId: Int) -> AnyPublisher<Response, MoyaError> {
|
||||
return talkApi.requestPublisher(.purchaseChatQuota(roomId: roomId, request: ChatQuotaPurchaseRequest()))
|
||||
func purchaseChatQuota(
|
||||
roomId: Int,
|
||||
chargeType: ChatRoomQuotaChargeType = .can,
|
||||
canOption: ChatRoomQuotaCanOption? = nil
|
||||
) -> AnyPublisher<Response, MoyaError> {
|
||||
return talkApi.requestPublisher(
|
||||
.purchaseChatQuota(
|
||||
roomId: roomId,
|
||||
request: ChatQuotaPurchaseRequest(chargeType: chargeType, canOption: canOption)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func resetChatRoom(roomId: Int) -> AnyPublisher<Response, MoyaError> {
|
||||
|
||||
@@ -148,9 +148,17 @@ struct ChatRoomView: View {
|
||||
}
|
||||
|
||||
if viewModel.showQuotaNoticeView {
|
||||
ChatQuotaNoticeItemView(remainingTime: viewModel.countdownText) {
|
||||
viewModel.purchaseChatQuota()
|
||||
}
|
||||
ChatQuotaNoticeItemView(
|
||||
onSelectAd: {
|
||||
viewModel.showRewardedAdForChatQuota()
|
||||
},
|
||||
onSelectCan10: {
|
||||
viewModel.purchaseChatQuota(canOption: .can10)
|
||||
},
|
||||
onSelectCan20: {
|
||||
viewModel.purchaseChatQuota(canOption: .can20)
|
||||
}
|
||||
)
|
||||
.id("quota_\(viewModel.messages.count)")
|
||||
.padding(.bottom, 12)
|
||||
.onAppear {
|
||||
@@ -309,9 +317,6 @@ struct ChatRoomView: View {
|
||||
viewModel.getMemberInfo()
|
||||
viewModel.enterRoom(roomId: roomId)
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.stopTimer()
|
||||
}
|
||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ final class ChatRoomViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var countdownText: String = "00:00:00"
|
||||
@Published private(set) var totalRemaining: Int = 0
|
||||
@Published private(set) var showQuotaNoticeView: Bool = false
|
||||
|
||||
@Published private(set) var showSendingMessage: Bool = false
|
||||
@@ -72,8 +72,6 @@ final class ChatRoomViewModel: ObservableObject {
|
||||
private var hasMoreMessages: Bool = true
|
||||
private var nextCursor: Int64? = nil
|
||||
|
||||
private var timer: Timer?
|
||||
|
||||
// MARK: - Actions
|
||||
func sendMessage() {
|
||||
guard !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
@@ -125,7 +123,7 @@ final class ChatRoomViewModel: ObservableObject {
|
||||
let decoded = try jsonDecoder.decode(ApiResponse<SendChatMessageResponse>.self, from: responseData)
|
||||
if let data = decoded.data, decoded.success {
|
||||
self.messages.append(contentsOf: data.messages)
|
||||
self.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch)
|
||||
self.updateQuota(totalRemaining: data.totalRemaining)
|
||||
} else {
|
||||
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
@@ -177,7 +175,7 @@ final class ChatRoomViewModel: ObservableObject {
|
||||
self?.hasMoreMessages = data.hasMoreMessages
|
||||
self?.nextCursor = data.messages.last?.messageId
|
||||
|
||||
self?.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch)
|
||||
self?.updateQuota(totalRemaining: data.totalRemaining)
|
||||
} else {
|
||||
if let message = decoded.message {
|
||||
self?.errorMessage = message
|
||||
@@ -275,10 +273,25 @@ final class ChatRoomViewModel: ObservableObject {
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
func purchaseChatQuota() {
|
||||
func purchaseChatQuota(canOption: ChatRoomQuotaCanOption) {
|
||||
purchaseChatQuota(chargeType: .can, canOption: canOption)
|
||||
}
|
||||
|
||||
func showRewardedAdForChatQuota() {
|
||||
_Concurrency.Task {
|
||||
await YandexRewardedAdManager.shared.showAdIfAvailable(for: .chatRoomQuota) { [weak self] in
|
||||
self?.purchaseChatQuota(chargeType: .ad, canOption: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func purchaseChatQuota(
|
||||
chargeType: ChatRoomQuotaChargeType,
|
||||
canOption: ChatRoomQuotaCanOption?
|
||||
) {
|
||||
isLoading = true
|
||||
|
||||
repository.purchaseChatQuota(roomId: roomId)
|
||||
repository.purchaseChatQuota(roomId: roomId, chargeType: chargeType, canOption: canOption)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { result in
|
||||
switch result {
|
||||
@@ -295,10 +308,12 @@ final class ChatRoomViewModel: ObservableObject {
|
||||
let decoded = try jsonDecoder.decode(ApiResponse<ChatQuotaStatusResponse>.self, from: responseData)
|
||||
|
||||
if let data = decoded.data, decoded.success {
|
||||
self?.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch)
|
||||
self?.updateQuota(totalRemaining: data.totalRemaining)
|
||||
|
||||
let can = UserDefaults.int(forKey: .can)
|
||||
UserDefaults.set(can - 30, forKey: .can)
|
||||
if let canOption {
|
||||
let can = UserDefaults.int(forKey: .can)
|
||||
UserDefaults.set(can - canOption.needCan, forKey: .can)
|
||||
}
|
||||
} else {
|
||||
if let message = decoded.message {
|
||||
self?.errorMessage = message
|
||||
@@ -385,7 +400,7 @@ final class ChatRoomViewModel: ObservableObject {
|
||||
chatRoomBgImageUrl = nil
|
||||
roomId = 0
|
||||
|
||||
countdownText = "00:00:00"
|
||||
totalRemaining = 0
|
||||
showQuotaNoticeView = false
|
||||
|
||||
showSendingMessage = false
|
||||
@@ -421,7 +436,7 @@ final class ChatRoomViewModel: ObservableObject {
|
||||
let decoded = try jsonDecoder.decode(ApiResponse<ChatQuotaStatusResponse>.self, from: responseData)
|
||||
|
||||
if let data = decoded.data, decoded.success {
|
||||
self?.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch)
|
||||
self?.updateQuota(totalRemaining: data.totalRemaining)
|
||||
} else {
|
||||
if let message = decoded.message {
|
||||
self?.errorMessage = message
|
||||
@@ -442,78 +457,18 @@ final class ChatRoomViewModel: ObservableObject {
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
private func updateQuota(nextRechargeAtEpoch: Int64?) {
|
||||
isLoading = true
|
||||
stopTimer()
|
||||
|
||||
// epoch 없음 → 카운트다운 비표시
|
||||
guard let nextRechargeAtEpoch else {
|
||||
countdownText = "00:00:00"
|
||||
showQuotaNoticeView = false
|
||||
isLoading = false
|
||||
return
|
||||
private func updateQuota(totalRemaining: Int) {
|
||||
self.totalRemaining = totalRemaining
|
||||
showQuotaNoticeView = totalRemaining <= 0
|
||||
prepareRewardedAdIfNeeded(totalRemaining: totalRemaining)
|
||||
}
|
||||
|
||||
private func prepareRewardedAdIfNeeded(totalRemaining: Int) {
|
||||
guard totalRemaining <= 1 else { return }
|
||||
|
||||
_Concurrency.Task {
|
||||
await YandexRewardedAdManager.shared.preloadAd(for: .chatRoomQuota)
|
||||
}
|
||||
|
||||
// 즉시 1회 갱신
|
||||
let remainMs = remainingMs(to: nextRechargeAtEpoch)
|
||||
updateCountdownText(remainMs)
|
||||
|
||||
// 이미 0이면 종료 처리
|
||||
guard remainMs > 0 else {
|
||||
checkQuotaStatus()
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
showQuotaNoticeView = true
|
||||
|
||||
// 타이머 시작 (1초마다 갱신)
|
||||
startTimer(targetEpoch: nextRechargeAtEpoch)
|
||||
}
|
||||
|
||||
private func updateCountdownText(_ remainMs: Int64) {
|
||||
countdownText = remainMs > 0 ? formatMillisToHms(remainMs) : "00:00:00"
|
||||
}
|
||||
|
||||
private func startTimer(targetEpoch: Int64) {
|
||||
stopTimer()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
let remain = self.remainingMs(to: targetEpoch)
|
||||
self.updateCountdownText(remain)
|
||||
if remain == 0 {
|
||||
self.stopTimer()
|
||||
self.checkQuotaStatus()
|
||||
}
|
||||
}
|
||||
if let t = timer { RunLoop.main.add(t, forMode: .common) }
|
||||
}
|
||||
|
||||
func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func remainingMs(to epoch: Int64) -> Int64 {
|
||||
let ms = normalizeToMs(epoch)
|
||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let fudgeMs: Int64 = 5000
|
||||
|
||||
// Kotlin 로직과 동일하게 표시 보정 적용
|
||||
return max(ms - nowMs + fudgeMs, 0)
|
||||
}
|
||||
|
||||
/// 초 단위/밀리초 단위 혼용 대비
|
||||
private func normalizeToMs(_ epoch: Int64) -> Int64 {
|
||||
epoch < 1_000_000_000_000 ? epoch * 1000 : epoch
|
||||
}
|
||||
|
||||
private func formatMillisToHms(_ ms: Int64) -> String {
|
||||
let total = ms / 1000
|
||||
let h = total / 3600
|
||||
let m = (total % 3600) / 60
|
||||
let s = total % 60
|
||||
return String(format: "%02d:%02d:%02d", h, m, s)
|
||||
}
|
||||
|
||||
private func getSavedBackgroundImageId() -> Int? {
|
||||
|
||||
@@ -9,57 +9,79 @@ import SwiftUI
|
||||
|
||||
struct ChatQuotaNoticeItemView: View {
|
||||
|
||||
let remainingTime: String
|
||||
let purchase: () -> Void
|
||||
let onSelectAd: () -> Void
|
||||
let onSelectCan10: () -> Void
|
||||
let onSelectCan20: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
VStack(spacing: 8) {
|
||||
Image("ic_time")
|
||||
.resizable()
|
||||
.frame(width: 30, height: 30)
|
||||
|
||||
Text(remainingTime)
|
||||
Button {
|
||||
onSelectAd()
|
||||
} label: {
|
||||
Text(I18n.Chat.Room.quotaAdAction(chatCount: 5))
|
||||
.appFont(size: 18, weight: .bold)
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text(I18n.Chat.Room.quotaWaitForFreeNotice)
|
||||
.appFont(size: 18, weight: .bold)
|
||||
.foregroundColor(.white)
|
||||
.foregroundColor(Color(hex: "263238"))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 15)
|
||||
.background(Color(hex: "EC8280"))
|
||||
.cornerRadius(10)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image("ic_can")
|
||||
|
||||
Text("10")
|
||||
.appFont(size: 24, weight: .bold)
|
||||
.foregroundColor(Color(hex: "263238"))
|
||||
|
||||
Text(I18n.Chat.Room.quotaPurchaseAction(chatCount: 12))
|
||||
.appFont(size: 24, weight: .bold)
|
||||
.foregroundColor(Color(hex: "263238"))
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(hex: "B5E7FA"))
|
||||
.background(Color(hex: "FEF8E3"))
|
||||
.cornerRadius(30)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 30)
|
||||
.stroke(lineWidth: 1)
|
||||
.foregroundColor(Color.button)
|
||||
.foregroundColor(Color(hex: "F7CB50"))
|
||||
}
|
||||
.onTapGesture {
|
||||
purchase()
|
||||
.buttonStyle(.plain)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
onSelectCan10()
|
||||
} label: {
|
||||
canButtonLabel(can: 10, chatCount: 15)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
onSelectCan20()
|
||||
} label: {
|
||||
canButtonLabel(can: 20, chatCount: 40)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func canButtonLabel(can: Int, chatCount: Int) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Image("ic_can")
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Text("\(can)")
|
||||
.appFont(size: 20, weight: .bold)
|
||||
.foregroundColor(Color(hex: "263238"))
|
||||
|
||||
Text(" / \(I18n.Chat.Room.quotaChatCount(chatCount))")
|
||||
.appFont(size: 20, weight: .medium)
|
||||
.foregroundColor(Color(hex: "263238"))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(hex: "B5E7FA"))
|
||||
.cornerRadius(30)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 30)
|
||||
.stroke(lineWidth: 1)
|
||||
.foregroundColor(Color.button)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ChatQuotaNoticeItemView(remainingTime: "05:59:55") {}
|
||||
ChatQuotaNoticeItemView(
|
||||
onSelectAd: {},
|
||||
onSelectCan10: {},
|
||||
onSelectCan20: {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,4 +7,42 @@
|
||||
|
||||
struct ChatQuotaPurchaseRequest: Encodable {
|
||||
let container: String = "ios"
|
||||
let chargeType: ChatRoomQuotaChargeType
|
||||
let canOption: ChatRoomQuotaCanOption?
|
||||
|
||||
init(
|
||||
chargeType: ChatRoomQuotaChargeType = .can,
|
||||
canOption: ChatRoomQuotaCanOption? = nil
|
||||
) {
|
||||
self.chargeType = chargeType
|
||||
self.canOption = canOption
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatRoomQuotaChargeType: String, Encodable {
|
||||
case can = "CAN"
|
||||
case ad = "AD"
|
||||
}
|
||||
|
||||
enum ChatRoomQuotaCanOption: String, Encodable {
|
||||
case can10 = "CAN_10"
|
||||
case can20 = "CAN_20"
|
||||
|
||||
var needCan: Int {
|
||||
switch self {
|
||||
case .can10:
|
||||
return 10
|
||||
case .can20:
|
||||
return 20
|
||||
}
|
||||
}
|
||||
|
||||
var quota: Int {
|
||||
switch self {
|
||||
case .can10:
|
||||
return 15
|
||||
case .can20:
|
||||
return 40
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,18 @@ struct TalkView: View {
|
||||
|
||||
var body: some View {
|
||||
BaseView(isLoading: $viewModel.isLoading) {
|
||||
if viewModel.talkRooms.isEmpty {
|
||||
Text(I18n.Chat.Talk.emptyMessage)
|
||||
.appFont(size: 20, weight: .regular)
|
||||
.foregroundColor(.white)
|
||||
} else {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
LazyVStack(spacing: 24) {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
LazyVStack(spacing: 24) {
|
||||
YandexInlineBannerView(
|
||||
placement: .chatTalkTabTop,
|
||||
horizontalPadding: 24
|
||||
)
|
||||
|
||||
if viewModel.talkRooms.isEmpty {
|
||||
Text(I18n.Chat.Talk.emptyMessage)
|
||||
.appFont(size: 20, weight: .regular)
|
||||
.foregroundColor(.white)
|
||||
} else {
|
||||
ForEach(0..<viewModel.talkRooms.count, id: \.self) { index in
|
||||
let item = viewModel.talkRooms[index]
|
||||
TalkItemView(item: item)
|
||||
@@ -38,14 +43,15 @@ struct TalkView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
|
||||
441
SodaLive/Sources/Common/YandexAdSupport.swift
Normal file
@@ -0,0 +1,441 @@
|
||||
//
|
||||
// YandexAdSupport.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by OpenCode on 2026/04/28.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import YandexMobileAds
|
||||
|
||||
enum YandexBannerPlacement {
|
||||
case liveTab
|
||||
case liveDetail
|
||||
case contentDetail
|
||||
case creatorCommunityAll
|
||||
case seriesMainHome
|
||||
case seriesMainDayOfWeek
|
||||
case seriesMainByGenre
|
||||
case pushNotificationList
|
||||
case notificationReceiveSettings
|
||||
case chatCharacterList
|
||||
case chatOriginalTabTop
|
||||
case chatTalkTabTop
|
||||
}
|
||||
|
||||
enum YandexInterstitialPlacement {
|
||||
case contentDetail
|
||||
}
|
||||
|
||||
enum YandexRewardedPlacement {
|
||||
case chatRoomQuota
|
||||
}
|
||||
|
||||
enum YandexAdUnitIdProvider {
|
||||
|
||||
static func banner(for placement: YandexBannerPlacement) -> String {
|
||||
switch placement {
|
||||
case .liveTab:
|
||||
YANDEX_LIVE_TAB_BANNER_AD_UNIT_ID
|
||||
case .liveDetail:
|
||||
YANDEX_LIVE_DETAIL_BANNER_AD_UNIT_ID
|
||||
case .contentDetail:
|
||||
YANDEX_CONTENT_DETAIL_BANNER_AD_UNIT_ID
|
||||
case .creatorCommunityAll:
|
||||
YANDEX_CREATOR_COMMUNITY_ALL_BANNER_AD_UNIT_ID
|
||||
case .seriesMainHome:
|
||||
YANDEX_SERIES_MAIN_HOME_BANNER_AD_UNIT_ID
|
||||
case .seriesMainDayOfWeek:
|
||||
YANDEX_SERIES_MAIN_DAY_OF_WEEK_BANNER_AD_UNIT_ID
|
||||
case .seriesMainByGenre:
|
||||
YANDEX_SERIES_MAIN_BY_GENRE_BANNER_AD_UNIT_ID
|
||||
case .pushNotificationList:
|
||||
YANDEX_PUSH_NOTIFICATION_LIST_BANNER_AD_UNIT_ID
|
||||
case .notificationReceiveSettings:
|
||||
YANDEX_NOTIFICATION_RECEIVE_SETTINGS_BANNER_AD_UNIT_ID
|
||||
case .chatCharacterList:
|
||||
YANDEX_CHAT_CHARACTER_LIST_BANNER_AD_UNIT_ID
|
||||
case .chatOriginalTabTop:
|
||||
YANDEX_CHAT_ORIGINAL_TAB_TOP_BANNER_AD_UNIT_ID
|
||||
case .chatTalkTabTop:
|
||||
YANDEX_CHAT_TALK_TAB_TOP_BANNER_AD_UNIT_ID
|
||||
}
|
||||
}
|
||||
|
||||
static func interstitial(for placement: YandexInterstitialPlacement) -> String {
|
||||
switch placement {
|
||||
case .contentDetail:
|
||||
YANDEX_CONTENT_DETAIL_INTERSTITIAL_AD_UNIT_ID
|
||||
}
|
||||
}
|
||||
|
||||
static func rewarded(for placement: YandexRewardedPlacement) -> String {
|
||||
switch placement {
|
||||
case .chatRoomQuota:
|
||||
YANDEX_CHAT_ROOM_QUOTA_REWARDED_AD_UNIT_ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct YandexInlineBannerView: View {
|
||||
|
||||
let placement: YandexBannerPlacement
|
||||
|
||||
var maxHeight: CGFloat = 90
|
||||
var horizontalPadding: CGFloat = 13.3
|
||||
|
||||
@State private var bannerHeight: CGFloat = 0
|
||||
@State private var isLoadFailed = true
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let width = max(proxy.size.width - (horizontalPadding * 2), 1)
|
||||
let resolvedHeight = isLoadFailed ? 0 : (bannerHeight > 0 ? bannerHeight : maxHeight)
|
||||
|
||||
YandexInlineBannerContainer(
|
||||
placement: placement,
|
||||
width: width,
|
||||
maxHeight: maxHeight,
|
||||
bannerHeight: $bannerHeight,
|
||||
isLoadFailed: $isLoadFailed
|
||||
)
|
||||
.frame(width: width, height: resolvedHeight)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
}
|
||||
.frame(height: isLoadFailed ? 0 : (bannerHeight > 0 ? bannerHeight : maxHeight))
|
||||
}
|
||||
}
|
||||
|
||||
private struct YandexInlineBannerContainer: UIViewRepresentable {
|
||||
|
||||
let placement: YandexBannerPlacement
|
||||
|
||||
let width: CGFloat
|
||||
let maxHeight: CGFloat
|
||||
|
||||
@Binding var bannerHeight: CGFloat
|
||||
@Binding var isLoadFailed: Bool
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(parent: self)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView()
|
||||
view.backgroundColor = .clear
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
context.coordinator.configureIfNeeded(containerView: uiView)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, BannerAdViewDelegate {
|
||||
|
||||
var parent: YandexInlineBannerContainer
|
||||
private weak var containerView: UIView?
|
||||
private var bannerAdView: BannerAdView?
|
||||
private var currentWidth: CGFloat = 0
|
||||
|
||||
init(parent: YandexInlineBannerContainer) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func configureIfNeeded(containerView: UIView) {
|
||||
self.containerView = containerView
|
||||
|
||||
let roundedWidth = parent.width.rounded(.down)
|
||||
|
||||
guard bannerAdView == nil || abs(currentWidth - roundedWidth) > 0.5 else {
|
||||
return
|
||||
}
|
||||
|
||||
currentWidth = roundedWidth
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.parent.bannerHeight = 0
|
||||
self.parent.isLoadFailed = false
|
||||
}
|
||||
|
||||
bannerAdView?.removeFromSuperview()
|
||||
|
||||
let adSize = BannerAdSize.inline(width: roundedWidth, maxHeight: parent.maxHeight)
|
||||
let bannerAdView = BannerAdView(adSize: adSize)
|
||||
bannerAdView.translatesAutoresizingMaskIntoConstraints = false
|
||||
bannerAdView.delegate = self
|
||||
|
||||
containerView.addSubview(bannerAdView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
bannerAdView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
bannerAdView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
bannerAdView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
bannerAdView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
|
||||
])
|
||||
|
||||
self.bannerAdView = bannerAdView
|
||||
bannerAdView.loadAd(with: AdRequest(adUnitID: YandexAdUnitIdProvider.banner(for: parent.placement)))
|
||||
}
|
||||
|
||||
func bannerAdViewDidLoad(_ adView: BannerAdView) {
|
||||
adView.layoutIfNeeded()
|
||||
|
||||
let measuredHeight = adView.bounds.height > 0 ? adView.bounds.height : parent.maxHeight
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.parent.bannerHeight = measuredHeight
|
||||
self.parent.isLoadFailed = false
|
||||
}
|
||||
}
|
||||
|
||||
func bannerAdViewDidFailLoading(_ adView: BannerAdView, error: Error) {
|
||||
DispatchQueue.main.async {
|
||||
self.parent.bannerHeight = 0
|
||||
self.parent.isLoadFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
func bannerAdViewDidClick(_ adView: BannerAdView) {
|
||||
}
|
||||
|
||||
func bannerAdView(_ adView: BannerAdView, didTrackImpression impressionData: ImpressionData?) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class YandexInterstitialAdManager: NSObject {
|
||||
|
||||
static let shared = YandexInterstitialAdManager()
|
||||
|
||||
private var interstitialAd: InterstitialAd?
|
||||
private var interstitialAdLoader: InterstitialAdLoader?
|
||||
private var currentPlacement: YandexInterstitialPlacement?
|
||||
private var pendingAction: (@MainActor () -> Void)?
|
||||
private var isLoading = false
|
||||
|
||||
func preloadAd(for placement: YandexInterstitialPlacement) {
|
||||
guard !isLoading else {
|
||||
return
|
||||
}
|
||||
|
||||
if currentPlacement == placement, interstitialAd != nil {
|
||||
return
|
||||
}
|
||||
|
||||
let loader = InterstitialAdLoader()
|
||||
interstitialAdLoader = loader
|
||||
interstitialAd = nil
|
||||
currentPlacement = placement
|
||||
isLoading = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
let loadedAd = try await loader.loadAd(with: AdRequest(adUnitID: YandexAdUnitIdProvider.interstitial(for: placement)))
|
||||
guard currentPlacement == placement else {
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
loadedAd.delegate = self
|
||||
interstitialAd = loadedAd
|
||||
} catch {
|
||||
if currentPlacement == placement {
|
||||
interstitialAd = nil
|
||||
}
|
||||
}
|
||||
|
||||
if currentPlacement == placement {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showAdIfAvailable(for placement: YandexInterstitialPlacement, then action: @escaping @MainActor () -> Void) {
|
||||
guard let presenter = presentingViewController(), let interstitialAd, currentPlacement == placement else {
|
||||
action()
|
||||
preloadAd(for: placement)
|
||||
return
|
||||
}
|
||||
|
||||
pendingAction = action
|
||||
interstitialAd.show(from: presenter)
|
||||
}
|
||||
|
||||
private func completePendingAction() {
|
||||
let action = pendingAction
|
||||
pendingAction = nil
|
||||
interstitialAd = nil
|
||||
action?()
|
||||
|
||||
if let currentPlacement {
|
||||
preloadAd(for: currentPlacement)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentingViewController() -> UIViewController? {
|
||||
guard
|
||||
let rootViewController = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.flatMap({ $0.windows })
|
||||
.first(where: { $0.isKeyWindow })?
|
||||
.rootViewController
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var topViewController = rootViewController
|
||||
|
||||
while let presentedViewController = topViewController.presentedViewController {
|
||||
topViewController = presentedViewController
|
||||
}
|
||||
|
||||
return topViewController
|
||||
}
|
||||
}
|
||||
|
||||
extension YandexInterstitialAdManager: InterstitialAdDelegate {
|
||||
|
||||
func interstitialAdDidShow(_ interstitialAd: InterstitialAd) {
|
||||
}
|
||||
|
||||
func interstitialAdDidDismiss(_ interstitialAd: InterstitialAd) {
|
||||
completePendingAction()
|
||||
}
|
||||
|
||||
func interstitialAdDidClick(_ interstitialAd: InterstitialAd) {
|
||||
}
|
||||
|
||||
func interstitialAd(_ interstitialAd: InterstitialAd, didTrackImpression impressionData: ImpressionData?) {
|
||||
}
|
||||
|
||||
func interstitialAd(_ interstitialAd: InterstitialAd, didFailToShow error: Error) {
|
||||
completePendingAction()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class YandexRewardedAdManager: NSObject {
|
||||
|
||||
static let shared = YandexRewardedAdManager()
|
||||
|
||||
private var rewardedAd: RewardedAd?
|
||||
private var rewardedAdLoader: RewardedAdLoader?
|
||||
private var currentPlacement: YandexRewardedPlacement?
|
||||
private var pendingRewardAction: (@MainActor () -> Void)?
|
||||
private var rewardedPlacement: YandexRewardedPlacement?
|
||||
private var isLoading = false
|
||||
|
||||
func preloadAd(for placement: YandexRewardedPlacement) {
|
||||
guard !isLoading else {
|
||||
return
|
||||
}
|
||||
|
||||
if currentPlacement == placement, rewardedAd != nil {
|
||||
return
|
||||
}
|
||||
|
||||
let loader = RewardedAdLoader()
|
||||
rewardedAdLoader = loader
|
||||
rewardedAd = nil
|
||||
currentPlacement = placement
|
||||
isLoading = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
let loadedAd = try await loader.loadAd(with: AdRequest(adUnitID: YandexAdUnitIdProvider.rewarded(for: placement)))
|
||||
guard currentPlacement == placement else {
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
loadedAd.delegate = self
|
||||
rewardedAd = loadedAd
|
||||
} catch {
|
||||
if currentPlacement == placement {
|
||||
rewardedAd = nil
|
||||
}
|
||||
}
|
||||
|
||||
if currentPlacement == placement {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showAdIfAvailable(for placement: YandexRewardedPlacement, onReward: @escaping @MainActor () -> Void) -> Bool {
|
||||
guard let presenter = presentingViewController(), let rewardedAd, currentPlacement == placement else {
|
||||
preloadAd(for: placement)
|
||||
return false
|
||||
}
|
||||
|
||||
pendingRewardAction = onReward
|
||||
rewardedPlacement = placement
|
||||
rewardedAd.show(from: presenter)
|
||||
return true
|
||||
}
|
||||
|
||||
private func completeRewardIfNeeded() {
|
||||
let action = pendingRewardAction
|
||||
pendingRewardAction = nil
|
||||
action?()
|
||||
}
|
||||
|
||||
private func resetAndPreload() {
|
||||
pendingRewardAction = nil
|
||||
rewardedAd = nil
|
||||
|
||||
if let rewardedPlacement {
|
||||
self.rewardedPlacement = nil
|
||||
preloadAd(for: rewardedPlacement)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentingViewController() -> UIViewController? {
|
||||
guard
|
||||
let rootViewController = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.flatMap({ $0.windows })
|
||||
.first(where: { $0.isKeyWindow })?
|
||||
.rootViewController
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var topViewController = rootViewController
|
||||
|
||||
while let presentedViewController = topViewController.presentedViewController {
|
||||
topViewController = presentedViewController
|
||||
}
|
||||
|
||||
return topViewController
|
||||
}
|
||||
}
|
||||
|
||||
extension YandexRewardedAdManager: RewardedAdDelegate {
|
||||
|
||||
func rewardedAd(_ rewardedAd: RewardedAd, didReward reward: Reward) {
|
||||
DEBUG_LOG("리워드 광고 보상 받기 성공")
|
||||
completeRewardIfNeeded()
|
||||
}
|
||||
|
||||
func rewardedAd(_ rewardedAd: RewardedAd, didFailToShow error: Error) {
|
||||
DEBUG_LOG("리워드 광고 에러")
|
||||
resetAndPreload()
|
||||
}
|
||||
|
||||
func rewardedAdDidShow(_ rewardedAd: RewardedAd) {
|
||||
}
|
||||
|
||||
func rewardedAdDidDismiss(_ rewardedAd: RewardedAd) {
|
||||
DEBUG_LOG("리워드 광고 닫기")
|
||||
resetAndPreload()
|
||||
}
|
||||
|
||||
func rewardedAdDidClick(_ rewardedAd: RewardedAd) {
|
||||
}
|
||||
|
||||
func rewardedAd(_ rewardedAd: RewardedAd, didTrackImpression impressionData: ImpressionData?) {
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,8 @@ struct ContentDetailPlayView: View {
|
||||
@State private var progress: TimeInterval = 0
|
||||
@State private var timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ZStack {
|
||||
KFImage(URL(string: audioContent.coverImageUrl))
|
||||
.cancelOnDisappear(true)
|
||||
@@ -88,9 +88,9 @@ struct ContentDetailPlayView: View {
|
||||
ContentPlayerPlayManager.shared.resetPlayer()
|
||||
contentPlayManager.pauseAudio()
|
||||
}
|
||||
} else {
|
||||
if isAlertPreview {
|
||||
HStack(spacing: 4) {
|
||||
} else {
|
||||
if isAlertPreview {
|
||||
HStack(spacing: 4) {
|
||||
Image("ic_noti_play")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
@@ -98,60 +98,21 @@ struct ContentDetailPlayView: View {
|
||||
Text(I18n.ContentDetail.preview)
|
||||
.appFont(size: 16.7, weight: .medium)
|
||||
.foregroundColor(Color.white)
|
||||
}
|
||||
.padding(.vertical, 13.3)
|
||||
.frame(minWidth: 212)
|
||||
.background(Color.black.opacity(0.4))
|
||||
.cornerRadius(46.7)
|
||||
.onTapGesture {
|
||||
ContentPlayerPlayManager.shared.resetPlayer()
|
||||
contentPlayManager.startTimer = startTimer
|
||||
contentPlayManager.stopTimer = stopTimer
|
||||
|
||||
contentPlayManager.playAudio(
|
||||
contentId: audioContent.contentId,
|
||||
title: audioContent.title,
|
||||
nickname: audioContent.creator.nickname,
|
||||
coverImage: audioContent.coverImageUrl,
|
||||
contentUrl: audioContent.contentUrl,
|
||||
isFree: audioContent.price <= 0,
|
||||
isPreview: !audioContent.existOrdered && audioContent.price > 0
|
||||
)
|
||||
isShowPreviewAlert = true
|
||||
|
||||
recentContentViewModel.insertRecentContent(
|
||||
contentId: Int64(audioContent.contentId),
|
||||
coverImageUrl: audioContent.coverImageUrl,
|
||||
title: audioContent.title,
|
||||
creatorNickname: audioContent.creator.nickname
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Image("btn_audio_content_play")
|
||||
.onTapGesture {
|
||||
ContentPlayerPlayManager.shared.resetPlayer()
|
||||
contentPlayManager.startTimer = startTimer
|
||||
contentPlayManager.stopTimer = stopTimer
|
||||
|
||||
contentPlayManager.playAudio(
|
||||
contentId: audioContent.contentId,
|
||||
title: audioContent.title,
|
||||
nickname: audioContent.creator.nickname,
|
||||
coverImage: audioContent.coverImageUrl,
|
||||
contentUrl: audioContent.contentUrl,
|
||||
isFree: audioContent.price <= 0,
|
||||
isPreview: !audioContent.existOrdered && audioContent.price > 0
|
||||
)
|
||||
|
||||
recentContentViewModel.insertRecentContent(
|
||||
contentId: Int64(audioContent.contentId),
|
||||
coverImageUrl: audioContent.coverImageUrl,
|
||||
title: audioContent.title,
|
||||
creatorNickname: audioContent.creator.nickname
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 13.3)
|
||||
.frame(minWidth: 212)
|
||||
.background(Color.black.opacity(0.4))
|
||||
.cornerRadius(46.7)
|
||||
.onTapGesture {
|
||||
handlePlayTap(showPreviewAlert: true)
|
||||
}
|
||||
} else {
|
||||
Image("btn_audio_content_play")
|
||||
.onTapGesture {
|
||||
handlePlayTap(showPreviewAlert: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isAlertPreview {
|
||||
Image("ic_player_next_10")
|
||||
@@ -236,29 +197,79 @@ struct ContentDetailPlayView: View {
|
||||
}
|
||||
}
|
||||
.frame(width: screenSize().width - 40)
|
||||
}
|
||||
.onAppear {
|
||||
if !isPlaying() {
|
||||
stopTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await YandexInterstitialAdManager.shared.preloadAd(for: .contentDetail)
|
||||
}
|
||||
|
||||
if !isPlaying() {
|
||||
stopTimer()
|
||||
}
|
||||
}
|
||||
.onReceive(timer) { _ in
|
||||
guard let player = contentPlayManager.player, !isEditing else { return }
|
||||
self.progress = player.currentTime
|
||||
}
|
||||
}
|
||||
|
||||
private func isPlaying() -> Bool {
|
||||
return contentPlayManager.contentId == audioContent.contentId && contentPlayManager.isPlaying
|
||||
}
|
||||
|
||||
private func sliderRange() -> ClosedRange<Double> {
|
||||
if audioContent.contentId == contentPlayManager.contentId {
|
||||
return 0...contentPlayManager.duration
|
||||
} else {
|
||||
return 0...0
|
||||
}
|
||||
}
|
||||
private func isPlaying() -> Bool {
|
||||
return contentPlayManager.contentId == audioContent.contentId && contentPlayManager.isPlaying
|
||||
}
|
||||
|
||||
private func handlePlayTap(showPreviewAlert: Bool) {
|
||||
let playAction: @MainActor () -> Void = {
|
||||
ContentPlayerPlayManager.shared.resetPlayer()
|
||||
contentPlayManager.startTimer = startTimer
|
||||
contentPlayManager.stopTimer = stopTimer
|
||||
|
||||
contentPlayManager.playAudio(
|
||||
contentId: audioContent.contentId,
|
||||
title: audioContent.title,
|
||||
nickname: audioContent.creator.nickname,
|
||||
coverImage: audioContent.coverImageUrl,
|
||||
contentUrl: audioContent.contentUrl,
|
||||
isFree: audioContent.price <= 0,
|
||||
isPreview: !audioContent.existOrdered && audioContent.price > 0
|
||||
)
|
||||
|
||||
if showPreviewAlert {
|
||||
isShowPreviewAlert = true
|
||||
}
|
||||
|
||||
recentContentViewModel.insertRecentContent(
|
||||
contentId: Int64(audioContent.contentId),
|
||||
coverImageUrl: audioContent.coverImageUrl,
|
||||
title: audioContent.title,
|
||||
creatorNickname: audioContent.creator.nickname
|
||||
)
|
||||
}
|
||||
|
||||
guard shouldShowInterstitialBeforePlayback(showPreviewAlert: showPreviewAlert) else {
|
||||
playAction()
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await YandexInterstitialAdManager.shared.showAdIfAvailable(for: .contentDetail, then: playAction)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldShowInterstitialBeforePlayback(showPreviewAlert: Bool) -> Bool {
|
||||
if contentPlayManager.contentId == audioContent.contentId {
|
||||
return false
|
||||
}
|
||||
|
||||
return audioContent.price <= 0 || showPreviewAlert
|
||||
}
|
||||
|
||||
private func sliderRange() -> ClosedRange<Double> {
|
||||
if audioContent.contentId == contentPlayManager.contentId {
|
||||
return 0...contentPlayManager.duration
|
||||
} else {
|
||||
return 0...0
|
||||
}
|
||||
}
|
||||
|
||||
private func getProgress() -> String {
|
||||
if audioContent.contentId == contentPlayManager.contentId {
|
||||
|
||||
@@ -81,14 +81,19 @@ struct ContentDetailView: View {
|
||||
isShowPreviewAlert: $viewModel.isShowPreviewAlert
|
||||
)
|
||||
|
||||
ContentDetailPreviousNextContentButtonView(
|
||||
previousContent: audioContent.previousContent,
|
||||
nextContent: audioContent.nextContent
|
||||
) {
|
||||
viewModel.contentId = $0
|
||||
if audioContent.previousContent != nil || audioContent.nextContent != nil {
|
||||
ContentDetailPreviousNextContentButtonView(
|
||||
previousContent: audioContent.previousContent,
|
||||
nextContent: audioContent.nextContent
|
||||
) {
|
||||
viewModel.contentId = $0
|
||||
}
|
||||
.padding(.top, 13.3)
|
||||
.padding(.horizontal, 13.3)
|
||||
}
|
||||
.padding(.top, 13.3)
|
||||
.padding(.horizontal, 13.3)
|
||||
|
||||
YandexInlineBannerView(placement: .contentDetail)
|
||||
.padding(.top, 13.3)
|
||||
|
||||
ContentDetailInfoView(
|
||||
isExpandDescription: $viewModel.isExpandDescription,
|
||||
|
||||
@@ -22,39 +22,45 @@ struct SeriesMainByGenreView: View {
|
||||
selectedGenre: $viewModel.selectedGenre
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
let horizontalPadding: CGFloat = 24
|
||||
let gridSpacing: CGFloat = 16
|
||||
let width = (screenSize().width - (horizontalPadding * 2) - gridSpacing) / 2
|
||||
|
||||
LazyVGrid(
|
||||
columns: Array(
|
||||
repeating: GridItem(
|
||||
.flexible(),
|
||||
spacing: gridSpacing,
|
||||
alignment: .topLeading
|
||||
),
|
||||
count: 2
|
||||
),
|
||||
alignment: .leading,
|
||||
spacing: gridSpacing
|
||||
) {
|
||||
ForEach(viewModel.seriesList.indices, id: \.self) { index in
|
||||
let item = viewModel.seriesList[index]
|
||||
SeriesMainItemView(item: item, width: width, height: width * 227 / 160)
|
||||
.contentShape(Rectangle())
|
||||
.onAppear {
|
||||
if index == viewModel.seriesList.count - 1 {
|
||||
viewModel.getSeriesListByGenre()
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId))
|
||||
}
|
||||
|
||||
VStack(spacing: 16) {
|
||||
if !viewModel.genreList.isEmpty {
|
||||
YandexInlineBannerView(placement: .seriesMainByGenre, horizontalPadding: 24)
|
||||
}
|
||||
|
||||
LazyVGrid(
|
||||
columns: Array(
|
||||
repeating: GridItem(
|
||||
.flexible(),
|
||||
spacing: gridSpacing,
|
||||
alignment: .topLeading
|
||||
),
|
||||
count: 2
|
||||
),
|
||||
alignment: .leading,
|
||||
spacing: gridSpacing
|
||||
) {
|
||||
ForEach(viewModel.seriesList.indices, id: \.self) { index in
|
||||
let item = viewModel.seriesList[index]
|
||||
SeriesMainItemView(item: item, width: width, height: width * 227 / 160)
|
||||
.contentShape(Rectangle())
|
||||
.onAppear {
|
||||
if index == viewModel.seriesList.count - 1 {
|
||||
viewModel.getSeriesListByGenre()
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
}
|
||||
}
|
||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
||||
|
||||
@@ -55,37 +55,41 @@ struct SeriesMainDayOfWeekView: View {
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
let horizontalPadding: CGFloat = 24
|
||||
let gridSpacing: CGFloat = 16
|
||||
let width = (screenSize().width - (horizontalPadding * 2) - gridSpacing) / 2
|
||||
|
||||
LazyVGrid(
|
||||
columns: Array(
|
||||
repeating: GridItem(
|
||||
.flexible(),
|
||||
spacing: gridSpacing,
|
||||
alignment: .topLeading
|
||||
|
||||
VStack(spacing: 16) {
|
||||
YandexInlineBannerView(placement: .seriesMainDayOfWeek, horizontalPadding: 24)
|
||||
|
||||
LazyVGrid(
|
||||
columns: Array(
|
||||
repeating: GridItem(
|
||||
.flexible(),
|
||||
spacing: gridSpacing,
|
||||
alignment: .topLeading
|
||||
),
|
||||
count: 2
|
||||
),
|
||||
count: 2
|
||||
),
|
||||
alignment: .leading,
|
||||
spacing: gridSpacing
|
||||
) {
|
||||
ForEach(viewModel.seriesList.indices, id: \.self) { index in
|
||||
let item = viewModel.seriesList[index]
|
||||
SeriesMainItemView(item: item, width: width, height: width * 227 / 160)
|
||||
.contentShape(Rectangle())
|
||||
.onAppear {
|
||||
if index == viewModel.seriesList.count - 1 {
|
||||
viewModel.getDayOfWeekSeriesList(dayOfWeek: dayOfWeek)
|
||||
alignment: .leading,
|
||||
spacing: gridSpacing
|
||||
) {
|
||||
ForEach(viewModel.seriesList.indices, id: \.self) { index in
|
||||
let item = viewModel.seriesList[index]
|
||||
SeriesMainItemView(item: item, width: width, height: width * 227 / 160)
|
||||
.contentShape(Rectangle())
|
||||
.onAppear {
|
||||
if index == viewModel.seriesList.count - 1 {
|
||||
viewModel.getDayOfWeekSeriesList(dayOfWeek: dayOfWeek)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onTapGesture { AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId)) }
|
||||
.onTapGesture { AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId)) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
}
|
||||
}
|
||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
||||
|
||||
@@ -55,6 +55,8 @@ struct SeriesMainHomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
YandexInlineBannerView(placement: .seriesMainHome, horizontalPadding: 24)
|
||||
|
||||
if !viewModel.recommendSeriesList.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(spacing: 0) {
|
||||
|
||||
@@ -22,7 +22,7 @@ struct ContentView: View {
|
||||
if appState.isRestartApp {
|
||||
EmptyView()
|
||||
} else {
|
||||
HomeView()
|
||||
MainView()
|
||||
}
|
||||
|
||||
if case .splash = appState.rootStep {
|
||||
|
||||
@@ -32,3 +32,18 @@ let LINE_CHANNEL_ID = "2008995582"
|
||||
|
||||
let AGORA_CUSTOMER_ID = "de5dd9ea151f4a43ba1ad8411817b169"
|
||||
let AGORA_CUSTOMER_SECRET = "3855da8bc5ae4743af8bf4f87408b515"
|
||||
|
||||
let YANDEX_LIVE_TAB_BANNER_AD_UNIT_ID = "R-M-19140297-3"
|
||||
let YANDEX_LIVE_DETAIL_BANNER_AD_UNIT_ID = "R-M-19140297-4"
|
||||
let YANDEX_CONTENT_DETAIL_BANNER_AD_UNIT_ID = "R-M-19140297-5"
|
||||
let YANDEX_CONTENT_DETAIL_INTERSTITIAL_AD_UNIT_ID = "R-M-19140297-6"
|
||||
let YANDEX_CREATOR_COMMUNITY_ALL_BANNER_AD_UNIT_ID = "R-M-19140297-7"
|
||||
let YANDEX_SERIES_MAIN_HOME_BANNER_AD_UNIT_ID = "R-M-19140297-8"
|
||||
let YANDEX_SERIES_MAIN_DAY_OF_WEEK_BANNER_AD_UNIT_ID = "R-M-19140297-9"
|
||||
let YANDEX_SERIES_MAIN_BY_GENRE_BANNER_AD_UNIT_ID = "R-M-19140297-10"
|
||||
let YANDEX_PUSH_NOTIFICATION_LIST_BANNER_AD_UNIT_ID = "R-M-19140297-11"
|
||||
let YANDEX_NOTIFICATION_RECEIVE_SETTINGS_BANNER_AD_UNIT_ID = "R-M-19140297-12"
|
||||
let YANDEX_CHAT_CHARACTER_LIST_BANNER_AD_UNIT_ID = "R-M-19140297-13"
|
||||
let YANDEX_CHAT_ORIGINAL_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19140297-14"
|
||||
let YANDEX_CHAT_TALK_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19140297-15"
|
||||
let YANDEX_CHAT_ROOM_QUOTA_REWARDED_AD_UNIT_ID = "R-M-19140297-16"
|
||||
|
||||
@@ -47,6 +47,8 @@ struct CreatorCommunityAllView: View {
|
||||
|
||||
communityViewTypeTabView
|
||||
|
||||
YandexInlineBannerView(placement: .creatorCommunityAll)
|
||||
|
||||
if isGridMode {
|
||||
gridContentView
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// KoreanFontModifier.swift
|
||||
// FontModifier.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 1/23/26.
|
||||
@@ -7,6 +7,52 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum SodaTypography {
|
||||
case heading1
|
||||
case heading2
|
||||
case heading3
|
||||
case heading4
|
||||
case body1
|
||||
case body2
|
||||
case body3
|
||||
case body4
|
||||
case body5
|
||||
case body6
|
||||
case caption1
|
||||
case caption2
|
||||
case caption3
|
||||
|
||||
var size: CGFloat {
|
||||
switch self {
|
||||
case .heading1:
|
||||
return 24
|
||||
case .heading2:
|
||||
return 22
|
||||
case .heading3:
|
||||
return 20
|
||||
case .heading4:
|
||||
return 18
|
||||
case .body1, .body2, .body3:
|
||||
return 16
|
||||
case .body4, .body5, .body6:
|
||||
return 14
|
||||
case .caption1, .caption2, .caption3:
|
||||
return 12
|
||||
}
|
||||
}
|
||||
|
||||
var weight: SwiftUI.Font.Weight {
|
||||
switch self {
|
||||
case .heading1, .heading2, .heading3, .heading4, .body1, .body4, .caption1:
|
||||
return .bold
|
||||
case .body2, .body5, .caption2:
|
||||
return .medium
|
||||
case .body3, .body6, .caption3:
|
||||
return .regular
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedLanguageCode(currentLocale: Locale) -> String? {
|
||||
if let raw = UserDefaults.standard.string(forKey: "app.language"),
|
||||
let option = LanguageOption(rawValue: raw),
|
||||
@@ -42,7 +88,7 @@ private func japaneseFontName(for weight: SwiftUI.Font.Weight) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
struct KoreanFontModifier: ViewModifier {
|
||||
struct FontModifier: ViewModifier {
|
||||
@Environment(\.locale) private var locale
|
||||
let size: CGFloat
|
||||
let weight: SwiftUI.Font.Weight
|
||||
@@ -63,7 +109,11 @@ struct KoreanFontModifier: ViewModifier {
|
||||
|
||||
extension View {
|
||||
func appFont(size: CGFloat, weight: SwiftUI.Font.Weight = .regular) -> some View {
|
||||
self.modifier(KoreanFontModifier(size: size, weight: weight))
|
||||
self.modifier(FontModifier(size: size, weight: weight))
|
||||
}
|
||||
|
||||
func appFont(_ typography: SodaTypography) -> some View {
|
||||
appFont(size: typography.size, weight: typography.weight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,4 +129,8 @@ extension Text {
|
||||
|
||||
return self.font(.system(size: size, weight: weight))
|
||||
}
|
||||
|
||||
func appFont(_ typography: SodaTypography) -> Text {
|
||||
appFont(size: typography.size, weight: typography.weight)
|
||||
}
|
||||
}
|
||||
@@ -239,20 +239,6 @@ struct HomeTabView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Image("img_banner_audition")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.padding(.horizontal, 24)
|
||||
.onTapGesture {
|
||||
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
AppState.shared
|
||||
.setAppStep(step: .audition)
|
||||
} else {
|
||||
AppState.shared
|
||||
.setAppStep(step: .login)
|
||||
}
|
||||
}
|
||||
|
||||
DayOfWeekSeriesView(seriesList: viewModel.dayOfWeekSeriesList) {
|
||||
viewModel.getDayOfWeekSeriesList(dayOfWeek: $0)
|
||||
}
|
||||
|
||||
@@ -262,15 +262,19 @@ enum I18n {
|
||||
pick(ko: "입력 중", en: "Typing", ja: "入力中")
|
||||
}
|
||||
|
||||
static var quotaWaitForFreeNotice: String {
|
||||
pick(ko: "기다리면 무료 이용이 가능합니다.", en: "Wait for free usage.", ja: "待てば無料で利用できます。")
|
||||
static func quotaAdAction(chatCount: Int) -> String {
|
||||
pick(
|
||||
ko: "광고 / \(chatCount)채팅",
|
||||
en: "Ad / \(chatCount) chats",
|
||||
ja: "広告 / \(chatCount)チャット"
|
||||
)
|
||||
}
|
||||
|
||||
static func quotaPurchaseAction(chatCount: Int) -> String {
|
||||
static func quotaChatCount(_ chatCount: Int) -> String {
|
||||
pick(
|
||||
ko: "(채팅 \(chatCount)개) 바로 대화 시작",
|
||||
en: "(\(chatCount) chats) Start now",
|
||||
ja: "(チャット\(chatCount)件) すぐに会話開始"
|
||||
ko: "\(chatCount)채팅",
|
||||
en: "\(chatCount) chats",
|
||||
ja: "\(chatCount)チャット"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2979,6 +2983,7 @@ If you block this user, the following features will be restricted.
|
||||
|
||||
enum Tab {
|
||||
static var home: String { pick(ko: "홈", en: "Home", ja: "ホーム") }
|
||||
static var content: String { pick(ko: "콘텐츠", en: "Content", ja: "コンテンツ") }
|
||||
static var live: String { pick(ko: "라이브", en: "Live", ja: "ライブ") }
|
||||
static var chat: String { pick(ko: "채팅", en: "Chat", ja: "チャット") }
|
||||
static var my: String { pick(ko: "마이", en: "My", ja: "マイ") }
|
||||
|
||||
@@ -98,6 +98,9 @@ struct LiveView: View {
|
||||
SectionLatestFinishedLiveView(items: viewModel.latestFinishedLiveItems)
|
||||
}
|
||||
|
||||
YandexInlineBannerView(placement: .liveTab)
|
||||
.padding(.vertical, -24)
|
||||
|
||||
if viewModel.replayLiveItems.count > 0 {
|
||||
LiveReplayListView(contentList: viewModel.replayLiveItems)
|
||||
}
|
||||
|
||||
@@ -118,21 +118,25 @@ struct LiveDetailView: View {
|
||||
.padding(.top, 16.7)
|
||||
.frame(width: proxy.size.width - 26.7)
|
||||
|
||||
if UserDefaults.int(forKey: .userId) == room.manager.id {
|
||||
Rectangle()
|
||||
.frame(height: 1)
|
||||
.foregroundColor(Color.gray90.opacity(0.5))
|
||||
.padding(.top, 8)
|
||||
if UserDefaults.int(forKey: .userId) == room.manager.id {
|
||||
Rectangle()
|
||||
.frame(height: 1)
|
||||
.foregroundColor(Color.gray90.opacity(0.5))
|
||||
.padding(.top, 8)
|
||||
.frame(width: proxy.size.width - 26.7)
|
||||
|
||||
ParticipantView(room: room)
|
||||
.frame(width: proxy.size.width - 26.7)
|
||||
}
|
||||
|
||||
Rectangle()
|
||||
.frame(height: 1)
|
||||
.foregroundColor(Color.gray90.opacity(0.5))
|
||||
.padding(.top, 13.3)
|
||||
ParticipantView(room: room)
|
||||
.frame(width: proxy.size.width - 26.7)
|
||||
}
|
||||
|
||||
YandexInlineBannerView(placement: .liveDetail, horizontalPadding: 0)
|
||||
.frame(width: proxy.size.width - 26.7)
|
||||
.padding(.top, 13.3)
|
||||
|
||||
Rectangle()
|
||||
.frame(height: 1)
|
||||
.foregroundColor(Color.gray90.opacity(0.5))
|
||||
.padding(.top, 13.3)
|
||||
|
||||
HStack(spacing: 13.3) {
|
||||
let manager = room.manager
|
||||
|
||||
@@ -287,6 +287,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
||||
private var blockedMemberIdList = Set<Int>()
|
||||
|
||||
private var hasInvokedJoinChannel = false
|
||||
private var hasDetectedHostOffline = false
|
||||
private var v2vMessageAssembler = V2vMessageAssembler()
|
||||
private var v2vAgentId: String?
|
||||
private var v2vSourceLanguage: String?
|
||||
@@ -322,6 +323,35 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
||||
liveRoomInfo?.creatorId == UserDefaults.int(forKey: .userId)
|
||||
}
|
||||
|
||||
private func shouldSuppressMissingRoomInfoError(_ message: String?) -> Bool {
|
||||
let ignorableLiveRoomNotFoundMessages: Set<String> = [
|
||||
"해당하는 라이브의 정보가 없습니다.",
|
||||
"Live session information not found.",
|
||||
"該当するライブの情報がありません。"
|
||||
]
|
||||
|
||||
guard let message, ignorableLiveRoomNotFoundMessages.contains(message) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return liveRoomInfo != nil
|
||||
|| hasInvokedJoinChannel
|
||||
|| hasDetectedHostOffline
|
||||
|| AppState.shared.roomId == 0
|
||||
}
|
||||
|
||||
private func shouldRefreshRoomInfoOnMemberLeave(memberId: Int) -> Bool {
|
||||
guard let liveRoomInfo = liveRoomInfo else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard !hasDetectedHostOffline else {
|
||||
return false
|
||||
}
|
||||
|
||||
return liveRoomInfo.creatorId != memberId
|
||||
}
|
||||
|
||||
func stopV2VTranslationIfJoined(clearCaptionText: Bool = true) {
|
||||
guard isV2VJoined else { return }
|
||||
stopV2VTranslation(clearCaptionText: clearCaptionText)
|
||||
@@ -616,6 +646,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
||||
let previousIsChatFrozen = self.isChatFrozen
|
||||
let syncedIsChatFrozen = data.isChatFrozen ?? false
|
||||
|
||||
self.hasDetectedHostOffline = false
|
||||
self.liveRoomInfo = data
|
||||
self.updateV2VAvailability(roomInfo: data)
|
||||
|
||||
@@ -662,13 +693,17 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
||||
onSuccess(nickname)
|
||||
}
|
||||
} else {
|
||||
if let message = decoded.message {
|
||||
self.errorMessage = message
|
||||
if self.shouldSuppressMissingRoomInfoError(decoded.message) {
|
||||
DEBUG_LOG("Suppress stale getRoomInfo error during live-room teardown: \(decoded.message ?? "")")
|
||||
} else {
|
||||
self.errorMessage = I18n.Common.commonError
|
||||
if let message = decoded.message {
|
||||
self.errorMessage = message
|
||||
} else {
|
||||
self.errorMessage = I18n.Common.commonError
|
||||
}
|
||||
|
||||
self.isShowErrorPopup = true
|
||||
}
|
||||
|
||||
self.isShowErrorPopup = true
|
||||
}
|
||||
|
||||
self.isLoading = false
|
||||
@@ -2973,12 +3008,13 @@ extension LiveRoomViewModel: AgoraRtcEngineDelegate {
|
||||
|
||||
if uid == UInt(creatorId) {
|
||||
// 라이브 종료
|
||||
self.hasDetectedHostOffline = true
|
||||
self.deInitAgoraEngine()
|
||||
self.liveRoomInfo = nil
|
||||
AppState.shared.errorMessage = I18n.LiveRoom.liveEndedMessage
|
||||
AppState.shared.isShowErrorPopup = true
|
||||
AppState.shared.roomId = 0
|
||||
} else {
|
||||
} else if self.shouldRefreshRoomInfoOnMemberLeave(memberId: Int(uid)) {
|
||||
// get room info
|
||||
self.getRoomInfo()
|
||||
}
|
||||
@@ -3265,7 +3301,7 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate {
|
||||
}
|
||||
}
|
||||
} else if eventType == .remoteLeaveChannel {
|
||||
if let liveRoomInfo = liveRoomInfo, liveRoomInfo.creatorId != Int(memberId)! {
|
||||
if shouldRefreshRoomInfoOnMemberLeave(memberId: Int(memberId)!) {
|
||||
getRoomInfo()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,28 @@ struct LiveRoomViewV2: View {
|
||||
|
||||
return !(isCurrentUserHost || isCurrentUserStaff)
|
||||
}
|
||||
|
||||
|
||||
private var isShowingPersistentDialog: Bool {
|
||||
viewModel.isShowProfilePopup
|
||||
|| viewModel.isShowDonationPopup
|
||||
|| (viewModel.changeIsAdult && !UserDefaults.bool(forKey: .auth))
|
||||
|| viewModel.isShowNoticeLikeHeart
|
||||
|| viewModel.isShowQuitPopup
|
||||
|| viewModel.isShowLiveEndPopup
|
||||
|| isShowChatDeleteDialog
|
||||
|| viewModel.isShowProfileList
|
||||
|| viewModel.isShowUserProfilePopup
|
||||
|| viewModel.isShowReportMenu
|
||||
|| viewModel.isShowUesrBlockConfirm
|
||||
|| viewModel.isShowUesrReportView
|
||||
|| viewModel.isShowProfileReportConfirm
|
||||
|| (viewModel.isShowNoChattingConfirm && viewModel.noChattingUserId > 0)
|
||||
|| isShowFollowNotifyDialog
|
||||
|| viewModel.isShowRouletteSettings
|
||||
|| (!viewModel.roulettePreviewList.isEmpty && viewModel.isShowRoulettePreview)
|
||||
|| viewModel.isShowRoulette
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScreenCaptureSecureContainer(isSecureModeEnabled: shouldEnforceScreenCaptureProtection) {
|
||||
ZStack {
|
||||
@@ -1086,7 +1107,7 @@ struct LiveRoomViewV2: View {
|
||||
guestFollowButtonTypeOverride = nil
|
||||
}
|
||||
}
|
||||
.onChange(of: isShowChatDeleteDialog) { isShowing in
|
||||
.onChange(of: isShowingPersistentDialog) { isShowing in
|
||||
if isShowing {
|
||||
hideKeyboard()
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ struct EventPopupDialogView: View {
|
||||
.padding(.horizontal, 26.7)
|
||||
.padding(.bottom, 13.3)
|
||||
}
|
||||
.background(Color(hex: "222222"))
|
||||
.background(Color.black)
|
||||
.cornerRadius(16.7, corners: [.topLeft, .topRight])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ struct MyPageView: View {
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.uppercased()
|
||||
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
|
||||
let shouldShowCouponRegister = isKoreanCountry || UserDefaults.isAdultContentVisible()
|
||||
|
||||
BaseView(isLoading: $viewModel.isLoading) {
|
||||
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
||||
@@ -121,6 +122,7 @@ struct MyPageView: View {
|
||||
isShowAuthView: $viewModel.isShowAuthView,
|
||||
isAuthenticated: viewModel.isAuth,
|
||||
isKoreanCountry: isKoreanCountry,
|
||||
shouldShowCouponRegister: shouldShowCouponRegister,
|
||||
showMessage: {
|
||||
viewModel.errorMessage = $0
|
||||
viewModel.isShowPopup = true
|
||||
@@ -389,6 +391,7 @@ struct CategoryButtonsView: View {
|
||||
|
||||
let isAuthenticated: Bool
|
||||
let isKoreanCountry: Bool
|
||||
let shouldShowCouponRegister: Bool
|
||||
let showMessage: (String) -> Void
|
||||
let refresh: () -> Void
|
||||
|
||||
@@ -402,16 +405,18 @@ struct CategoryButtonsView: View {
|
||||
AppState.shared.setAppStep(step: .blockList)
|
||||
}
|
||||
|
||||
CategoryButtonItem(
|
||||
icon: "ic_my_coupon",
|
||||
title: I18n.MyPage.Category.couponRegister
|
||||
) {
|
||||
if isAuthenticated {
|
||||
AppState.shared.setAppStep(step: .canCoupon(refresh: refresh))
|
||||
} else {
|
||||
showMessage(I18n.MyPage.Auth.verifyRequiredBeforeCoupon)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
isShowAuthView = true
|
||||
if shouldShowCouponRegister {
|
||||
CategoryButtonItem(
|
||||
icon: "ic_my_coupon",
|
||||
title: I18n.MyPage.Category.couponRegister
|
||||
) {
|
||||
if isAuthenticated || !isKoreanCountry {
|
||||
AppState.shared.setAppStep(step: .canCoupon(refresh: refresh))
|
||||
} else {
|
||||
showMessage(I18n.MyPage.Auth.verifyRequiredBeforeCoupon)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
isShowAuthView = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ struct PushNotificationListView: View {
|
||||
selectedTheme: $viewModel.selectedCategory
|
||||
)
|
||||
|
||||
YandexInlineBannerView(placement: .pushNotificationList)
|
||||
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(0..<viewModel.items.count, id: \.self) { index in
|
||||
|
||||
@@ -158,6 +158,9 @@ struct NotificationReceiveSettingsView: View {
|
||||
.padding(.top, 13.3)
|
||||
.padding(.horizontal, 13.3)
|
||||
|
||||
YandexInlineBannerView(placement: .notificationReceiveSettings)
|
||||
.padding(.top, 26.7)
|
||||
|
||||
Text(I18n.Settings.Notification.followingChannels)
|
||||
.appFont(size: 16.7, weight: .bold)
|
||||
.foregroundColor(Color(hex: "eeeeee"))
|
||||
|
||||
@@ -35,4 +35,38 @@ extension Color {
|
||||
static let mainRed2 = Color(hex: "ea3a25")
|
||||
static let mainRed3 = Color(hex: "dd4500")
|
||||
static let mainYellow = Color(hex: "ffdc00")
|
||||
|
||||
static let soda50 = Color(hex: "E6F9FF")
|
||||
static let soda100 = Color(hex: "B8EEFD")
|
||||
static let soda200 = Color(hex: "85E1FB")
|
||||
static let soda300 = Color(hex: "4FD2F9")
|
||||
static let soda400 = Color(hex: "00BDF7")
|
||||
static let soda500 = Color(hex: "00A0DF")
|
||||
static let soda600 = Color(hex: "0081C2")
|
||||
static let soda700 = Color(hex: "006BA4")
|
||||
static let soda800 = Color(hex: "004F7F")
|
||||
static let soda900 = Color(hex: "052742")
|
||||
|
||||
static let red50 = Color(hex: "FFF1F2")
|
||||
static let red100 = Color(hex: "FFD7D9")
|
||||
static let red200 = Color(hex: "FFB3B7")
|
||||
static let red300 = Color(hex: "FF858B")
|
||||
static let red400 = Color(hex: "FF4C3C")
|
||||
|
||||
static let green50 = Color(hex: "F5FBEF")
|
||||
static let green100 = Color(hex: "E9FADB")
|
||||
static let green200 = Color(hex: "C0F595")
|
||||
static let green300 = Color(hex: "9FFB56")
|
||||
static let green400 = Color(hex: "73FF01")
|
||||
|
||||
static let gray50 = Color(hex: "FAFAFA")
|
||||
static let gray100 = Color(hex: "F2F2F2")
|
||||
static let gray200 = Color(hex: "E2E2E2")
|
||||
static let gray300 = Color(hex: "CCCCCC")
|
||||
static let gray400 = Color(hex: "B5B5B5")
|
||||
static let gray500 = Color(hex: "959595")
|
||||
static let gray600 = Color(hex: "787878")
|
||||
static let gray700 = Color(hex: "494949")
|
||||
static let gray800 = Color(hex: "343434")
|
||||
static let gray900 = Color(hex: "202020")
|
||||
}
|
||||
|
||||
12
SodaLive/Sources/UI/Theme/Radius.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
//
|
||||
// Radius.swift
|
||||
// SodaLive
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum SodaRadius {
|
||||
static let r4: CGFloat = 4
|
||||
static let r8: CGFloat = 8
|
||||
static let r14: CGFloat = 14
|
||||
}
|
||||
20
SodaLive/Sources/UI/Theme/Spacing.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// Spacing.swift
|
||||
// SodaLive
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum SodaSpacing {
|
||||
static let s4: CGFloat = 4
|
||||
static let s6: CGFloat = 6
|
||||
static let s8: CGFloat = 8
|
||||
static let s12: CGFloat = 12
|
||||
static let s14: CGFloat = 14
|
||||
static let s16: CGFloat = 16
|
||||
static let s20: CGFloat = 20
|
||||
static let s24: CGFloat = 24
|
||||
static let s28: CGFloat = 28
|
||||
static let s32: CGFloat = 32
|
||||
static let s48: CGFloat = 48
|
||||
}
|
||||
@@ -32,3 +32,18 @@ let LINE_CHANNEL_ID = "2008995539"
|
||||
|
||||
let AGORA_CUSTOMER_ID = "de5dd9ea151f4a43ba1ad8411817b169"
|
||||
let AGORA_CUSTOMER_SECRET = "3855da8bc5ae4743af8bf4f87408b515"
|
||||
|
||||
let YANDEX_LIVE_TAB_BANNER_AD_UNIT_ID = "R-M-19157621-1"
|
||||
let YANDEX_LIVE_DETAIL_BANNER_AD_UNIT_ID = "R-M-19157621-2"
|
||||
let YANDEX_CONTENT_DETAIL_BANNER_AD_UNIT_ID = "R-M-19157621-3"
|
||||
let YANDEX_CONTENT_DETAIL_INTERSTITIAL_AD_UNIT_ID = "R-M-19157621-4"
|
||||
let YANDEX_CREATOR_COMMUNITY_ALL_BANNER_AD_UNIT_ID = "R-M-19157621-5"
|
||||
let YANDEX_SERIES_MAIN_HOME_BANNER_AD_UNIT_ID = "R-M-19157621-6"
|
||||
let YANDEX_SERIES_MAIN_DAY_OF_WEEK_BANNER_AD_UNIT_ID = "R-M-19157621-7"
|
||||
let YANDEX_SERIES_MAIN_BY_GENRE_BANNER_AD_UNIT_ID = "R-M-19157621-8"
|
||||
let YANDEX_PUSH_NOTIFICATION_LIST_BANNER_AD_UNIT_ID = "R-M-19157621-9"
|
||||
let YANDEX_NOTIFICATION_RECEIVE_SETTINGS_BANNER_AD_UNIT_ID = "R-M-19157621-10"
|
||||
let YANDEX_CHAT_CHARACTER_LIST_BANNER_AD_UNIT_ID = "R-M-19157621-11"
|
||||
let YANDEX_CHAT_ORIGINAL_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19157621-12"
|
||||
let YANDEX_CHAT_TALK_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19157621-13"
|
||||
let YANDEX_CHAT_ROOM_QUOTA_REWARDED_AD_UNIT_ID = "R-M-19157621-14"
|
||||
|
||||
78
SodaLive/Sources/V2/Component/CapsuleTabBar.swift
Normal file
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// CapsuleTabBar.swift
|
||||
// SodaLive
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CapsuleTabBar<Item: Hashable>: View {
|
||||
let items: [Item]
|
||||
@Binding var selectedItem: Item
|
||||
let title: (Item) -> String
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack(alignment: .center, spacing: SodaSpacing.s8) {
|
||||
ForEach(items, id: \.self) { item in
|
||||
CapsuleTabBarItem(
|
||||
title: title(item),
|
||||
isSelected: selectedItem == item,
|
||||
action: {
|
||||
selectedItem = item
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s20)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52, alignment: .center)
|
||||
.background(Color.black)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CapsuleTabBarItem: View {
|
||||
let title: String
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
action()
|
||||
} label: {
|
||||
Text(title)
|
||||
.appFont(.body5)
|
||||
.foregroundColor(Color.white)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, SodaSpacing.s12)
|
||||
.padding(.vertical, SodaSpacing.s8)
|
||||
.frame(height: 34)
|
||||
.background(isSelected ? Color.soda400 : Color.black)
|
||||
.clipShape(Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(isSelected ? Color.soda400 : Color.gray700, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct CapsuleTabBar_Previews: PreviewProvider {
|
||||
private enum PreviewTab: String, CaseIterable {
|
||||
case recommended = "추천"
|
||||
case ranking = "랭킹"
|
||||
case following = "팔로잉"
|
||||
case live = "라이브"
|
||||
case content = "콘텐츠"
|
||||
case audition = "오디션"
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
CapsuleTabBar(
|
||||
items: PreviewTab.allCases,
|
||||
selectedItem: .constant(.recommended),
|
||||
title: { $0.rawValue }
|
||||
)
|
||||
}
|
||||
}
|
||||
39
SodaLive/Sources/V2/Component/DefaultTitleBar.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// DefaultTitleBar.swift
|
||||
// SodaLive
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct DefaultTitleBar<Menu: View>: View {
|
||||
let title: String
|
||||
private let menu: Menu
|
||||
|
||||
init(
|
||||
title: String,
|
||||
@ViewBuilder menu: () -> Menu
|
||||
) {
|
||||
self.title = title
|
||||
self.menu = menu()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TitleBar {
|
||||
Text(title)
|
||||
.appFont(.heading2)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} trailing: {
|
||||
menu
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DefaultTitleBar_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
DefaultTitleBar(title: "화면명") {
|
||||
Image("ic_bar_search")
|
||||
}
|
||||
}
|
||||
}
|
||||
32
SodaLive/Sources/V2/Component/HomeTitleBar.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// HomeTitleBar.swift
|
||||
// SodaLive
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct HomeTitleBar<Menu: View>: View {
|
||||
private let menu: Menu
|
||||
|
||||
init(@ViewBuilder menu: () -> Menu) {
|
||||
self.menu = menu()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TitleBar {
|
||||
Image("img_text_logo_v2")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} trailing: {
|
||||
menu
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HomeTitleBar_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
HomeTitleBar {
|
||||
Image("ic_bar_bell")
|
||||
}
|
||||
}
|
||||
}
|
||||
51
SodaLive/Sources/V2/Component/TextTabBar.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// TextTabBar.swift
|
||||
// SodaLive
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TextTabBar<Item: Hashable>: View {
|
||||
let items: [Item]
|
||||
@Binding var selectedItem: Item
|
||||
let title: (Item) -> String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: SodaSpacing.s20) {
|
||||
ForEach(items.prefix(3), id: \.self) { item in
|
||||
Button {
|
||||
selectedItem = item
|
||||
} label: {
|
||||
Text(title(item))
|
||||
.appFont(.heading3)
|
||||
.foregroundColor(selectedItem == item ? Color.white : Color.gray600)
|
||||
.frame(height: 52, alignment: .center)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s20)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52, alignment: .leading)
|
||||
.background(Color.black)
|
||||
}
|
||||
}
|
||||
|
||||
struct TextTabBar_Previews: PreviewProvider {
|
||||
private enum PreviewTab: String, CaseIterable {
|
||||
case recommended = "추천"
|
||||
case ranking = "랭킹"
|
||||
case following = "팔로잉"
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
TextTabBar(
|
||||
items: PreviewTab.allCases,
|
||||
selectedItem: .constant(.recommended),
|
||||
title: { $0.rawValue }
|
||||
)
|
||||
}
|
||||
}
|
||||
45
SodaLive/Sources/V2/Component/TitleBar.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// TitleBar.swift
|
||||
// SodaLive
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TitleBar<Leading: View, Trailing: View>: View {
|
||||
private let leading: Leading
|
||||
private let trailing: Trailing
|
||||
|
||||
init(
|
||||
@ViewBuilder leading: () -> Leading,
|
||||
@ViewBuilder trailing: () -> Trailing
|
||||
) {
|
||||
self.leading = leading()
|
||||
self.trailing = trailing()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
leading
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
trailing
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s20)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 60, alignment: .center)
|
||||
.background(Color.black)
|
||||
}
|
||||
}
|
||||
|
||||
struct TitleBar_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
TitleBar {
|
||||
Text("Title")
|
||||
.appFont(.heading3)
|
||||
.foregroundColor(.white)
|
||||
} trailing: {
|
||||
Image("ic_bar_search")
|
||||
}
|
||||
}
|
||||
}
|
||||
27
SodaLive/Sources/V2/Main/MainPlaceholderTabView.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// MainPlaceholderTabView.swift
|
||||
// SodaLive
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MainPlaceholderTabView: View {
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black
|
||||
.ignoresSafeArea()
|
||||
|
||||
Text(title)
|
||||
.appFont(size: 20, weight: .bold)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MainPlaceholderTabView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
MainPlaceholderTabView(title: "홈")
|
||||
}
|
||||
}
|
||||
52
SodaLive/Sources/V2/Main/MainTab.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// MainTab.swift
|
||||
// SodaLive
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MainTab: CaseIterable, Hashable {
|
||||
case home
|
||||
case content
|
||||
case chat
|
||||
case my
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .home:
|
||||
return I18n.Main.Tab.home
|
||||
case .content:
|
||||
return I18n.Main.Tab.content
|
||||
case .chat:
|
||||
return I18n.Main.Tab.chat
|
||||
case .my:
|
||||
return I18n.Main.Tab.my
|
||||
}
|
||||
}
|
||||
|
||||
var selectedIconName: String {
|
||||
switch self {
|
||||
case .home:
|
||||
return "ic_nav_home_selected"
|
||||
case .content:
|
||||
return "ic_nav_content_selected"
|
||||
case .chat:
|
||||
return "ic_nav_chat_selected"
|
||||
case .my:
|
||||
return "ic_nav_my_selected"
|
||||
}
|
||||
}
|
||||
|
||||
var unselectedIconName: String {
|
||||
switch self {
|
||||
case .home:
|
||||
return "ic_nav_home"
|
||||
case .content:
|
||||
return "ic_nav_content"
|
||||
case .chat:
|
||||
return "ic_nav_chat"
|
||||
case .my:
|
||||
return "ic_nav_my"
|
||||
}
|
||||
}
|
||||
}
|
||||
45
SodaLive/Sources/V2/Main/MainTabBarButton.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// MainTabBarButton.swift
|
||||
// SodaLive
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MainTabBarButton: View {
|
||||
let tab: MainTab
|
||||
let isSelected: Bool
|
||||
let width: CGFloat
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 4) {
|
||||
ZStack(alignment: .center) {
|
||||
Image(isSelected ? tab.selectedIconName : tab.unselectedIconName)
|
||||
}
|
||||
.frame(height: 24)
|
||||
|
||||
Text(tab.title)
|
||||
.appFont(.caption3)
|
||||
.foregroundColor(isSelected ? Color.white : Color.gray600)
|
||||
.frame(height: 12, alignment: .bottom)
|
||||
}
|
||||
.frame(width: width, alignment: .center)
|
||||
.frame(minHeight: 50)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct MainTabBarButton_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
MainTabBarButton(
|
||||
tab: .home,
|
||||
isSelected: true,
|
||||
width: UIScreen.main.bounds.width / 4,
|
||||
action: {}
|
||||
)
|
||||
.background(Color.black)
|
||||
}
|
||||
}
|
||||
40
SodaLive/Sources/V2/Main/MainTabBarView.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// MainTabBarView.swift
|
||||
// SodaLive
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MainTabBarView: View {
|
||||
let width: CGFloat
|
||||
@Binding var currentTab: MainTab
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
let tabWidth = width / CGFloat(MainTab.allCases.count)
|
||||
|
||||
ForEach(MainTab.allCases, id: \.self) { tab in
|
||||
MainTabBarButton(
|
||||
tab: tab,
|
||||
isSelected: currentTab == tab,
|
||||
width: tabWidth,
|
||||
action: {
|
||||
currentTab = tab
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 8)
|
||||
.background(Color.black.ignoresSafeArea(edges: .bottom))
|
||||
}
|
||||
}
|
||||
|
||||
struct MainTabBarView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
MainTabBarView(
|
||||
width: UIScreen.main.bounds.width,
|
||||
currentTab: .constant(.home)
|
||||
)
|
||||
}
|
||||
}
|
||||
447
SodaLive/Sources/V2/Main/MainView.swift
Normal file
@@ -0,0 +1,447 @@
|
||||
//
|
||||
// MainView.swift
|
||||
// SodaLive
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
import Bootpay
|
||||
import BootpayUI
|
||||
import Kingfisher
|
||||
|
||||
struct MainView: View {
|
||||
@StateObject private var viewModel = MainViewModel()
|
||||
@StateObject private var legacyHomeViewModel = HomeViewModel()
|
||||
@StateObject private var liveViewModel = LiveViewModel()
|
||||
@StateObject private var mypageViewModel = MyPageViewModel()
|
||||
@StateObject private var appState = AppState.shared
|
||||
@StateObject private var contentPlayManager = ContentPlayManager.shared
|
||||
@StateObject private var contentPlayerPlayManager = ContentPlayerPlayManager.shared
|
||||
|
||||
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
|
||||
@AppStorage("auth") private var auth: Bool = UserDefaults.bool(forKey: UserDefaultsKey.auth)
|
||||
|
||||
@State private var isShowPlayer = false
|
||||
@State private var isShowAuthView = false
|
||||
@State private var isShowAuthConfirmView = false
|
||||
@State private var pendingAction: (() -> Void)? = nil
|
||||
@State private var isShowLeaveLiveNavigationDialog = false
|
||||
@State private var pendingExternalNavigationAction: (() -> Void)? = nil
|
||||
@State private var pendingExternalNavigationCancelAction: (() -> Void)? = nil
|
||||
@State private var payload = Payload()
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack(alignment: .bottom) {
|
||||
VStack(spacing: 0) {
|
||||
contentView
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.bottom, appState.isShowPlayer ? 72 : 0)
|
||||
|
||||
if contentPlayerPlayManager.isShowingMiniPlayer {
|
||||
contentPlayerMiniPlayerView
|
||||
}
|
||||
|
||||
if contentPlayManager.isShowingMiniPlayer {
|
||||
previewContentMiniPlayerView
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
MainTabBarView(
|
||||
width: proxy.size.width,
|
||||
currentTab: $viewModel.currentTab
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
configurePayload()
|
||||
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
pushTokenUpdate()
|
||||
legacyHomeViewModel.getMemberInfo()
|
||||
legacyHomeViewModel.getEventPopup()
|
||||
legacyHomeViewModel.addAllPlaybackTracking()
|
||||
}
|
||||
}
|
||||
|
||||
if appState.isShowNotificationSettingsDialog {
|
||||
NotificationSettingsDialog()
|
||||
}
|
||||
|
||||
if isShowAuthConfirmView {
|
||||
authConfirmDialog
|
||||
}
|
||||
|
||||
if liveViewModel.isShowPaymentDialog {
|
||||
LivePaymentDialog(
|
||||
title: liveViewModel.paymentDialogTitle,
|
||||
desc: liveViewModel.paymentDialogDesc,
|
||||
desc2: liveViewModel.paymentDialogDesc2,
|
||||
confirmButtonTitle: liveViewModel.paymentDialogConfirmTitle,
|
||||
confirmButtonAction: liveViewModel.paymentDialogConfirmAction,
|
||||
cancelButtonTitle: liveViewModel.paymentDialogCancelTitle,
|
||||
cancelButtonAction: liveViewModel.hidePopup,
|
||||
startDateTime: liveViewModel.liveStartDate,
|
||||
nowDateTime: liveViewModel.nowDate
|
||||
)
|
||||
}
|
||||
|
||||
if liveViewModel.isShowPasswordDialog {
|
||||
LiveRoomPasswordDialog(
|
||||
isShowing: $liveViewModel.isShowPasswordDialog,
|
||||
can: liveViewModel.secretOrPasswordDialogCan,
|
||||
confirmAction: liveViewModel.passwordDialogConfirmAction
|
||||
)
|
||||
}
|
||||
|
||||
if let eventItem = appState.eventPopup {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
EventPopupDialogView(eventPopup: eventItem)
|
||||
}
|
||||
.background(Color.black)
|
||||
.onTapGesture {
|
||||
appState.eventPopup = nil
|
||||
}
|
||||
}
|
||||
|
||||
if isShowPlayer {
|
||||
ContentPlayerView(isShowing: $isShowPlayer, playlist: [])
|
||||
}
|
||||
|
||||
if appState.isShowPlayer {
|
||||
LiveRoomViewV2()
|
||||
}
|
||||
|
||||
if isShowLeaveLiveNavigationDialog {
|
||||
leaveLiveNavigationDialog
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $isShowAuthView) {
|
||||
authView
|
||||
}
|
||||
.valueChanged(value: appState.pushRoomId) { handlePushRoomId($0) }
|
||||
.valueChanged(value: appState.pushChannelId) { handlePushChannelId($0) }
|
||||
.valueChanged(value: appState.pushMessageId) { handlePushMessageId($0) }
|
||||
.valueChanged(value: appState.pushAudioContentId) { handlePushAudioContentId($0) }
|
||||
.valueChanged(value: appState.pushSeriesId) { handlePushSeriesId($0) }
|
||||
.valueChanged(value: appState.isShowPlayer) { isShowPlayer in
|
||||
guard !isShowPlayer,
|
||||
let pendingExternalNavigationAction = pendingExternalNavigationAction else {
|
||||
return
|
||||
}
|
||||
self.pendingExternalNavigationAction = nil
|
||||
self.pendingExternalNavigationCancelAction = nil
|
||||
DispatchQueue.main.async {
|
||||
pendingExternalNavigationAction()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if appState.pushMessageId > 0 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
appState.setAppStep(step: .message)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sodaToast(
|
||||
isPresented: $liveViewModel.isShowPopup,
|
||||
message: liveViewModel.errorMessage,
|
||||
autohideIn: 2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contentView: some View {
|
||||
switch viewModel.currentTab {
|
||||
case .home:
|
||||
MainPlaceholderTabView(title: MainTab.home.title)
|
||||
case .content:
|
||||
MainPlaceholderTabView(title: MainTab.content.title)
|
||||
case .chat:
|
||||
MainPlaceholderTabView(title: MainTab.chat.title)
|
||||
case .my:
|
||||
MyPageView()
|
||||
}
|
||||
}
|
||||
|
||||
private var contentPlayerMiniPlayerView: some View {
|
||||
HStack(spacing: 0) {
|
||||
KFImage(URL(string: contentPlayerPlayManager.coverImageUrl))
|
||||
.cancelOnDisappear(true)
|
||||
.downsampling(size: CGSize(width: 36.7, height: 36.7))
|
||||
.resizable()
|
||||
.frame(width: 36.7, height: 36.7)
|
||||
.cornerRadius(5.3)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2.3) {
|
||||
Text(contentPlayerPlayManager.title)
|
||||
.appFont(size: 13, weight: .medium)
|
||||
.foregroundColor(Color.grayee)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(contentPlayerPlayManager.nickname)
|
||||
.appFont(size: 11, weight: .medium)
|
||||
.foregroundColor(Color.grayd2)
|
||||
}
|
||||
.padding(.horizontal, 10.7)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(contentPlayerPlayManager.isPlaying ? "ic_noti_pause" : "btn_bar_play")
|
||||
.resizable()
|
||||
.frame(width: 25, height: 25)
|
||||
.onTapGesture { contentPlayerPlayManager.playOrPause() }
|
||||
|
||||
Image("ic_noti_stop")
|
||||
.resizable()
|
||||
.frame(width: 25, height: 25)
|
||||
.padding(.leading, 16)
|
||||
.onTapGesture { contentPlayerPlayManager.resetPlayer() }
|
||||
}
|
||||
.padding(.vertical, 10.7)
|
||||
.padding(.horizontal, 13.3)
|
||||
.background(Color.gray22)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { isShowPlayer = true }
|
||||
}
|
||||
|
||||
private var previewContentMiniPlayerView: some View {
|
||||
HStack(spacing: 0) {
|
||||
KFImage(URL(string: contentPlayManager.coverImage))
|
||||
.cancelOnDisappear(true)
|
||||
.downsampling(size: CGSize(width: 36.7, height: 36.7))
|
||||
.resizable()
|
||||
.frame(width: 36.7, height: 36.7)
|
||||
.cornerRadius(5.3)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2.3) {
|
||||
Text(contentPlayManager.title)
|
||||
.appFont(size: 13, weight: .medium)
|
||||
.foregroundColor(Color.grayee)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(contentPlayManager.nickname)
|
||||
.appFont(size: 11, weight: .medium)
|
||||
.foregroundColor(Color.grayd2)
|
||||
}
|
||||
.padding(.horizontal, 10.7)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(contentPlayManager.isPlaying ? "ic_noti_pause" : "btn_bar_play")
|
||||
.resizable()
|
||||
.frame(width: 25, height: 25)
|
||||
.onTapGesture {
|
||||
if contentPlayManager.isPlaying {
|
||||
contentPlayManager.pauseAudio()
|
||||
} else {
|
||||
contentPlayManager.playAudio(contentId: contentPlayManager.contentId)
|
||||
}
|
||||
}
|
||||
|
||||
Image("ic_noti_stop")
|
||||
.resizable()
|
||||
.frame(width: 25, height: 25)
|
||||
.padding(.leading, 16)
|
||||
.onTapGesture { contentPlayManager.stopAudio() }
|
||||
}
|
||||
.padding(.vertical, 10.7)
|
||||
.padding(.horizontal, 13.3)
|
||||
.background(Color.gray22)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
appState.setAppStep(step: .contentDetail(contentId: contentPlayManager.contentId))
|
||||
}
|
||||
}
|
||||
|
||||
private var authConfirmDialog: some View {
|
||||
SodaDialog(
|
||||
title: I18n.Main.Auth.dialogTitle,
|
||||
desc: I18n.Main.Auth.liveEntryVerificationDescription,
|
||||
confirmButtonTitle: I18n.Main.Auth.goToVerification,
|
||||
confirmButtonAction: {
|
||||
isShowAuthConfirmView = false
|
||||
isShowAuthView = true
|
||||
},
|
||||
cancelButtonTitle: I18n.Common.cancel,
|
||||
cancelButtonAction: {
|
||||
isShowAuthConfirmView = false
|
||||
pendingAction = nil
|
||||
},
|
||||
textAlignment: .center
|
||||
)
|
||||
}
|
||||
|
||||
private var leaveLiveNavigationDialog: some View {
|
||||
SodaDialog(
|
||||
title: I18n.Common.alertTitle,
|
||||
desc: I18n.LiveRoom.leaveLiveForNavigationDesc,
|
||||
confirmButtonTitle: I18n.Common.confirm,
|
||||
confirmButtonAction: { confirmExternalNavigation() },
|
||||
cancelButtonTitle: I18n.Common.cancel,
|
||||
cancelButtonAction: { cancelExternalNavigation() }
|
||||
)
|
||||
}
|
||||
|
||||
private var authView: some View {
|
||||
BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION)
|
||||
.onConfirm { _ in true }
|
||||
.onCancel { _ in isShowAuthView = false }
|
||||
.onError { _ in
|
||||
appState.errorMessage = I18n.Main.Auth.authenticationError
|
||||
appState.isShowErrorPopup = true
|
||||
isShowAuthView = false
|
||||
}
|
||||
.onDone {
|
||||
DEBUG_LOG("onDone: \($0)")
|
||||
mypageViewModel.authVerify($0) {
|
||||
auth = true
|
||||
isShowAuthView = false
|
||||
if let action = pendingAction {
|
||||
pendingAction = nil
|
||||
action()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onClose { isShowAuthView = false }
|
||||
}
|
||||
|
||||
private func configurePayload() {
|
||||
payload.applicationId = BOOTPAY_APP_ID
|
||||
payload.price = 0
|
||||
payload.pg = "다날"
|
||||
payload.method = "본인인증"
|
||||
payload.orderName = "본인인증"
|
||||
payload.authenticationId = "\(UserDefaults.string(forKey: .nickname))__\(String(NSTimeIntervalSince1970))"
|
||||
}
|
||||
|
||||
private func handlePushRoomId(_ value: Int) {
|
||||
guard value > 0 else { return }
|
||||
let roomId = value
|
||||
let isPushRoomFromDeepLink = appState.isPushRoomFromDeepLink
|
||||
appState.pushRoomId = 0
|
||||
appState.isPushRoomFromDeepLink = false
|
||||
DispatchQueue.main.async {
|
||||
handleExternalNavigationRequest(
|
||||
value: roomId,
|
||||
navigationAction: {
|
||||
if !isPushRoomFromDeepLink { appState.setAppStep(step: .main) }
|
||||
liveViewModel.enterLiveRoom(roomId: roomId)
|
||||
},
|
||||
cancelAction: {
|
||||
appState.pushRoomId = 0
|
||||
appState.isPushRoomFromDeepLink = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePushChannelId(_ value: Int) {
|
||||
guard value > 0 else { return }
|
||||
let channelId = value
|
||||
appState.pushChannelId = 0
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
handleExternalNavigationRequest(
|
||||
value: channelId,
|
||||
navigationAction: {
|
||||
appState.setAppStep(step: .main)
|
||||
appState.setAppStep(step: .creatorDetail(userId: channelId))
|
||||
},
|
||||
cancelAction: { appState.pushChannelId = 0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePushMessageId(_ value: Int) {
|
||||
guard value > 0 else { return }
|
||||
let messageId = value
|
||||
appState.pushMessageId = 0
|
||||
DispatchQueue.main.async {
|
||||
handleExternalNavigationRequest(
|
||||
value: messageId,
|
||||
navigationAction: {
|
||||
appState.setAppStep(step: .main)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
appState.setAppStep(step: .message)
|
||||
}
|
||||
},
|
||||
cancelAction: { appState.pushMessageId = 0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePushAudioContentId(_ value: Int) {
|
||||
guard value > 0 else { return }
|
||||
let contentId = value
|
||||
appState.pushAudioContentId = 0
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
handleExternalNavigationRequest(
|
||||
value: contentId,
|
||||
navigationAction: {
|
||||
appState.setAppStep(step: .main)
|
||||
appState.setAppStep(step: .contentDetail(contentId: contentId))
|
||||
},
|
||||
cancelAction: { appState.pushAudioContentId = 0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePushSeriesId(_ value: Int) {
|
||||
guard value > 0 else { return }
|
||||
let seriesId = value
|
||||
appState.pushSeriesId = 0
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
handleExternalNavigationRequest(
|
||||
value: seriesId,
|
||||
navigationAction: {
|
||||
appState.setAppStep(step: .main)
|
||||
appState.setAppStep(step: .seriesDetail(seriesId: seriesId))
|
||||
},
|
||||
cancelAction: { appState.pushSeriesId = 0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleExternalNavigationRequest(
|
||||
value: Int,
|
||||
navigationAction: @escaping () -> Void,
|
||||
cancelAction: @escaping () -> Void
|
||||
) {
|
||||
guard value > 0 else { return }
|
||||
if appState.isShowPlayer {
|
||||
pendingExternalNavigationAction = navigationAction
|
||||
pendingExternalNavigationCancelAction = cancelAction
|
||||
isShowLeaveLiveNavigationDialog = true
|
||||
return
|
||||
}
|
||||
navigationAction()
|
||||
}
|
||||
|
||||
private func confirmExternalNavigation() {
|
||||
guard pendingExternalNavigationAction != nil else {
|
||||
isShowLeaveLiveNavigationDialog = false
|
||||
return
|
||||
}
|
||||
isShowLeaveLiveNavigationDialog = false
|
||||
NotificationCenter.default.post(name: .requestLiveRoomQuitForExternalNavigation, object: nil)
|
||||
}
|
||||
|
||||
private func cancelExternalNavigation() {
|
||||
isShowLeaveLiveNavigationDialog = false
|
||||
pendingExternalNavigationAction = nil
|
||||
pendingExternalNavigationCancelAction?()
|
||||
pendingExternalNavigationCancelAction = nil
|
||||
}
|
||||
|
||||
private func pushTokenUpdate() {
|
||||
let pushToken = UserDefaults.string(forKey: .pushToken)
|
||||
if !pushToken.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
legacyHomeViewModel.pushTokenUpdate(pushToken: pushToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MainView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
MainView()
|
||||
}
|
||||
}
|
||||
11
SodaLive/Sources/V2/Main/MainViewModel.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
//
|
||||
// MainViewModel.swift
|
||||
// SodaLive
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
final class MainViewModel: ObservableObject {
|
||||
@Published var currentTab: MainTab = .home
|
||||
}
|
||||
53
docs/agent-guides/agent-execution-policy.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 에이전트 실행 정책 상세 가이드
|
||||
|
||||
`AGENTS.md`의 실행 정책을 보완하는 상세 설명이다. 충돌 시 `AGENTS.md`의 우선순위 체계를 먼저 따른다.
|
||||
|
||||
## 우선순위 체계
|
||||
아래 순서가 높을수록 우선한다.
|
||||
|
||||
1. 사용자 직접 지시
|
||||
2. `AGENTS.md`
|
||||
3. 프로젝트별 제약 조건
|
||||
4. `oh-my-openagent` 플러그인의 `agents` / `workflows` / `hooks`
|
||||
5. `superpowers` skills
|
||||
6. 기본 모델 동작
|
||||
|
||||
충돌 시 항상 더 높은 우선순위의 지시를 따른다. 사용자 직접 지시가 명확할 경우 사용자 지시가 최우선이다.
|
||||
|
||||
## CORE EXECUTION PRINCIPLES 적용
|
||||
- plugin / skill / workflow 지시가 `CORE EXECUTION PRINCIPLES`와 충돌하면 `CORE EXECUTION PRINCIPLES`를 따른다.
|
||||
- 불확실하거나 모호한 경우 추측하지 말고 확인하거나, 가능한 최소 범위의 안전한 조치를 취한다.
|
||||
- 모든 실행은 단순성, 최소 변경, 검증 가능성을 우선한다.
|
||||
|
||||
## oh-my-openagent 제어 정책
|
||||
- `oh-my-openagent`는 opencode의 플러그인 기반 실행 오케스트레이션 계층이다.
|
||||
- `oh-my-openagent`는 의사결정 권한이 아니라 실행 보조 권한만 가진다.
|
||||
- 작은 작업에는 multi-agent 실행이나 과도한 workflow를 사용하지 않는다.
|
||||
- 병렬 실행은 명확한 이득이 있을 때만 사용한다.
|
||||
- 모든 `oh-my-openagent` 동작은 `CORE EXECUTION PRINCIPLES`를 따라야 한다.
|
||||
|
||||
## superpowers 사용 정책
|
||||
- `superpowers`는 선택적 스킬 계층이다.
|
||||
- `superpowers` skill은 필요한 경우에만 사용한다.
|
||||
- `superpowers`가 과도한 리팩토링, 불필요한 범위 확장, 가정 기반 실행을 유도하면 따르지 않는다.
|
||||
- `superpowers`를 사용할 때도 최소 변경, 단순성, 검증 가능성을 우선한다.
|
||||
- 모든 `superpowers` 동작은 `CORE EXECUTION PRINCIPLES`를 따라야 한다.
|
||||
|
||||
## 실행 모드
|
||||
### 기본 모드: 보수적 실행
|
||||
- 최소 변경
|
||||
- 단순한 구현
|
||||
- 검증 가능한 결과
|
||||
|
||||
### 확장 모드
|
||||
- 사용자가 명시적으로 요청한 경우에만 사용한다.
|
||||
- 대규모 리팩토링, 브레인스토밍, 다중 에이전트 실행, 병렬 workflow를 허용한다.
|
||||
|
||||
## 작업 절차 체크리스트
|
||||
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
|
||||
- 변경 중: 공개 API 스키마를 임의 변경하지 말고 작은 단위로 안전하게 수정한다.
|
||||
- 변경 후: 영향 범위 파일에 대해 빌드/테스트/로그/다국어 키를 점검한다.
|
||||
- 커밋 직후: `commit-policy` 스킬을 로드하고 메시지 검증 스크립트를 실행한다.
|
||||
|
||||
## 문서 유지보수 규칙
|
||||
- 문서 작성, 분리, 유지보수 규칙은 `docs/agent-guides/documentation-policy.md`를 참조한다.
|
||||
31
docs/agent-guides/build-test-verification.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 빌드/테스트/검증 가이드
|
||||
|
||||
`SodaLive` 저장소에서 확인된 빌드, 테스트, 검증 명령의 공식 진입점이다. 명령/경로/타깃명이 바뀌면 이 문서와 `AGENTS.md` 참조를 함께 갱신한다.
|
||||
|
||||
## 1) 의존성 설치
|
||||
- `pod install`
|
||||
- 근거: `Podfile`에 CocoaPods 타깃(`SodaLive`, `SodaLive-dev`) 정의.
|
||||
|
||||
## 2) 스킴/타깃 확인
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -list`
|
||||
- 근거: 공유 스킴 `SodaLive`, `SodaLive-dev` 존재.
|
||||
|
||||
## 3) 빌드
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
|
||||
|
||||
## 4) 테스트(전체)
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
|
||||
|
||||
## 5) 단일 테스트 실행
|
||||
- 일반 형식(테스트 타깃이 있는 경우):
|
||||
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -only-testing:"SodaLiveTests/TestClass/testMethod" test`
|
||||
- **현재 주의사항**:
|
||||
- `SodaLive.xcodeproj/project.pbxproj` 기준으로 앱 타깃 중심 구성이고 테스트 번들 타깃이 확인되지 않는다.
|
||||
- 따라서 현재 상태에서는 단일 테스트 지정이 실질적으로 동작하지 않을 수 있다.
|
||||
|
||||
## 6) 린트/포맷
|
||||
- 저장소에 공식 `swiftlint`/`swiftformat` 실행 스크립트는 확인되지 않았다.
|
||||
- `generated/*.generated.swift`에 `swiftlint:disable all` 주석은 존재하나, 이는 생성 코드 보호 목적이다.
|
||||
- 린트 도구를 도입/추가하면 이 문서와 `AGENTS.md`를 즉시 갱신한다.
|
||||
55
docs/agent-guides/code-style.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 코드 스타일 가이드
|
||||
|
||||
`SodaLive` iOS 코드 작성 시 따르는 스타일 규칙이다. 기존 코드 관례와 충돌하면 더 가까운 주변 코드의 패턴을 우선한다.
|
||||
|
||||
## 아키텍처/레이어
|
||||
- 기본 흐름은 `View -> ViewModel -> Repository -> Api(TargetType)`를 따른다.
|
||||
- 기존 로직 수정이 아닌 신규 `View`, `ViewModel`, `Repository` 및 그와 연결된 하위 코드는 `SodaLive/Sources/V2/**` 하위에 작성한다.
|
||||
- API는 `enum ...Api: TargetType`, 저장소는 `final class ...Repository` 형태를 우선 사용한다.
|
||||
- 상태 모델은 `struct`/`enum` 중심으로 두고, 화면 상태는 `ObservableObject`에서 관리한다.
|
||||
|
||||
## 임포트 규칙
|
||||
- 시스템 프레임워크(`Foundation`, `SwiftUI`, `Combine`)를 먼저 배치한다.
|
||||
- 서드파티(`Moya`, `CombineMoya`, SDK들)는 이후 배치한다.
|
||||
- import 그룹 사이에는 한 줄 공백으로 의미 단위를 분리한다.
|
||||
|
||||
## 포맷/구조
|
||||
- 들여쓰기는 4칸 스페이스를 사용한다.
|
||||
- 프로퍼티 선언, 비즈니스 로직, 헬퍼 메서드는 공백 줄로 구획한다.
|
||||
- 클로저 체인은 줄바꿈해 가독성을 유지한다.
|
||||
|
||||
## 타입/상태 관리
|
||||
- ViewModel은 `final class ...: ObservableObject` 패턴을 우선한다.
|
||||
- View가 소유하는 객체는 `@StateObject`, 외부 주입 객체는 `@ObservedObject`를 사용한다.
|
||||
- 네트워크 반환은 `AnyPublisher<Response, MoyaError>` 패턴을 기본으로 따른다.
|
||||
|
||||
## 네이밍 규칙
|
||||
- 타입명은 PascalCase (`HomeViewModel`, `UserRepository`, `UserApi`).
|
||||
- 변수/함수는 camelCase (`errorMessage`, `getMemberInfo`).
|
||||
- 역할을 이름에 반영한다 (`*View`, `*ViewModel`, `*Repository`, `*Api`, `*Request`, `*Response`).
|
||||
|
||||
## 비동기/Combine 규칙
|
||||
- 비동기 처리는 Combine의 `sink`, `receiveValue`, `.store(in: &subscription)` 패턴을 따른다.
|
||||
- `sink` 완료 블록에서 `.failure`를 반드시 처리한다.
|
||||
- 클로저 캡처는 상황에 맞게 `[weak self]` 또는 `[unowned self]`를 선택한다.
|
||||
|
||||
## 에러 처리 규칙
|
||||
- 사용자 노출 오류는 `errorMessage`와 팝업 플래그(`isShowPopup`)로 일관되게 처리한다.
|
||||
- JSON 파싱은 `do/catch + JSONDecoder` 패턴을 따른다.
|
||||
- **신규 코드에서 빈 `catch`는 금지**하고, 최소한 로깅 또는 명시적 무시 사유를 남긴다.
|
||||
|
||||
## 로깅 규칙
|
||||
- 디버그 로그는 `DEBUG_LOG`, 오류 로그는 `ERROR_LOG`를 사용한다.
|
||||
- `print`는 임시 디버깅 목적 외 신규 코드에서 지양한다.
|
||||
|
||||
## 네트워크/헤더 규칙
|
||||
- 공통 Moya 플러그인(`AuthPlugin`, `AcceptLanguagePlugin`) 흐름을 유지한다.
|
||||
- 언어 헤더는 `LanguageHeaderProvider.current`를 기준으로 사용한다.
|
||||
|
||||
## 문자열/다국어
|
||||
- 신규 사용자 노출 문자열은 가능하면 `I18n` 경로를 우선 사용한다.
|
||||
- 다국어 기능 수정 시 `Settings/Language` 모듈과 `Accept-Language` 헤더 흐름을 함께 점검한다.
|
||||
|
||||
## 주석/문서화
|
||||
- 자명한 코드에는 주석을 남기지 않는다.
|
||||
- 복잡한 분기, 외부 제약, 부작용이 있는 로직에만 주석을 추가한다.
|
||||
40
docs/agent-guides/documentation-policy.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 문서 작성 및 유지보수 정책
|
||||
|
||||
`SodaLive` 저장소에서 에이전트가 PRD, 작업 계획/TASK 문서, 가이드 문서를 작성, 갱신, 검증할 때 따르는 규칙이다.
|
||||
|
||||
## 구현 전 문서 필수 절차
|
||||
- 기능 구현, 버그 수정, 동작 변경은 PRD 문서와 계획/TASK 문서 없이 시작하지 않는다.
|
||||
- 작업 순서는 `사용자 프롬프트 입력 → PRD 문서 작성 → 애매하거나 더 필요한 내용 또는 결정해야 하는 사항이 없어질 때까지 사용자 인터뷰 → 인터뷰 내용을 바탕으로 PRD 문서 보강 → PRD 문서를 바탕으로 계획/TASK 문서 작성 → 계획/TASK 문서를 바탕으로 필요한 내용 최소 구현`으로 고정한다.
|
||||
- PRD 작성 후 요구사항, 성공 기준, 제외 범위, 제약, 미결정 사항이 남아 있으면 구현이나 계획/TASK 작성으로 넘어가지 말고 사용자에게 질문한다.
|
||||
- 사용자 인터뷰로 확인한 결정 사항은 먼저 PRD에 반영한 뒤 계획/TASK 문서에 옮긴다.
|
||||
- 문서 작성 자체, 커밋, 단순 조회처럼 구현을 수반하지 않는 작업은 필요한 최소 문서만 작성한다.
|
||||
|
||||
## PRD 문서 규칙
|
||||
- PRD 문서는 `docs/prd/` 아래에 작성한다.
|
||||
- PRD 문서 파일명은 기존 계획 문서 파일명 규칙을 따르되 계획/TASK 문서와 구분되도록 `[날짜]_구현할내용한글_PRD.md` 형식을 사용한다.
|
||||
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
|
||||
- PRD 문서는 `docs/prd/sample-prd.md`에서 필요한 섹션만 발췌해 작성한다.
|
||||
- PRD에는 최소한 목표, 문제/배경, 성공 기준, 제외 범위, 핵심 요구사항, 기술/운영 제약, 미결정 사항을 포함한다. 해당 없는 항목은 억지로 채우지 않고 제외하거나 `해당 없음`으로 명시한다.
|
||||
|
||||
## 계획/TASK 문서 규칙
|
||||
- 계획/TASK 문서는 `docs/plan-task/` 아래에 작성한다.
|
||||
- 계획/TASK 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다.
|
||||
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
|
||||
- 계획/TASK 문서는 보강 완료된 PRD를 기준으로 작성하고, 구현 범위가 PRD의 성공 기준과 제외 범위를 벗어나지 않게 한다.
|
||||
- 구현 항목은 기능/작업 단위 체크박스(`- [ ]`)로 작성하고 완료 즉시 `- [x]`로 갱신한다.
|
||||
- 작업 도중 범위가 변경되면 PRD를 먼저 보강하고 계획/TASK 문서 체크리스트를 업데이트한 뒤 구현한다.
|
||||
- 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)을 한국어로 남긴다.
|
||||
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰기 없이 누적한다.
|
||||
|
||||
## 문서 유지보수 규칙
|
||||
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
|
||||
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
|
||||
- `CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)` 섹션은 공식 원문을 영어로 유지한다.
|
||||
- 명령/경로/타깃명이 바뀌면 `AGENTS.md`와 `docs/agent-guides/**`를 즉시 업데이트한다.
|
||||
|
||||
## 문서 분리 기준
|
||||
- `AGENTS.md`에는 실행 판단에 필요한 핵심 정책과 참조 경로만 남긴다.
|
||||
- 빌드/테스트/검증 명령은 `docs/agent-guides/build-test-verification.md`에 둔다.
|
||||
- 코드 스타일 규칙은 `docs/agent-guides/code-style.md`에 둔다.
|
||||
- 에이전트 실행 정책 상세는 `docs/agent-guides/agent-execution-policy.md`에 둔다.
|
||||
- 문서 작성 및 유지보수 규칙은 이 문서에 둔다.
|
||||
22
docs/agent-guides/sodalive-ios-development.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# SodaLive iOS 개발 세부 가이드
|
||||
|
||||
`AGENTS.md`의 핵심 지침을 보완하는 iOS 개발 문서의 색인이다. 각 세부 규칙은 목적별 문서를 우선 참조한다.
|
||||
|
||||
## 세부 문서
|
||||
- 빌드/테스트/검증 명령: `docs/agent-guides/build-test-verification.md`
|
||||
- 코드 스타일 가이드: `docs/agent-guides/code-style.md`
|
||||
|
||||
## 신규 코드 위치 규칙
|
||||
- 기존 로직 수정이 아닌 신규 `View`, `ViewModel`, `Repository` 및 연결된 하위 코드는 `SodaLive/Sources/V2/**` 하위에 작성한다.
|
||||
|
||||
## Cursor/Copilot 규칙 반영
|
||||
- 아래 파일 존재 여부를 확인해 `AGENTS.md`와 함께 유지한다.
|
||||
- `.cursor/rules/**`
|
||||
- `.cursorrules`
|
||||
- `.github/copilot-instructions.md`
|
||||
- 현재 저장소에서는 위 파일들이 확인되지 않았다.
|
||||
- 추후 파일이 추가되면 `AGENTS.md`에 요약 규칙을 동기화한다.
|
||||
- 충돌 우선순위 기본값:
|
||||
- 범위가 더 구체적인 규칙이 우선한다(경로 특화 > 저장소 전역).
|
||||
- Cursor: `.cursor/rules/**` > `.cursorrules` > `AGENTS.md`
|
||||
- Copilot: `.github/instructions/**`(존재 시) > `.github/copilot-instructions.md` > `AGENTS.md`
|
||||