Compare commits

27 Commits

Author SHA1 Message Date
Yu Sung
942c581eaf feat(component): 재사용 타이틀 탭 바를 추가한다 2026-05-19 20:32:26 +09:00
Yu Sung
71edcf8bf9 chore(asset): 네비게이션 아이콘을 v2로 이동한다 2026-05-19 17:29:13 +09:00
Yu Sung
d247bb4958 fix(main): 마이 탭 선택 아이콘을 수정한다 2026-05-19 17:03:47 +09:00
Yu Sung
1a5df53edb feat(main): 메인 탭 화면을 추가한다 2026-05-19 15:54:37 +09:00
Yu Sung
270332d7c4 feat(theme): 디자인 토큰을 추가한다 2026-05-15 19:50:30 +09:00
Yu Sung
389f82fa82 docs(agent): 신규 코드 위치 규칙을 명시한다 2026-05-15 11:30:23 +09:00
Yu Sung
44b8633e59 docs(agent): PRD 계획 문서 규칙을 보강한다 2026-05-15 11:11:52 +09:00
Yu Sung
c217581d1d docs(agent): 에이전트 가이드를 정리한다 2026-05-15 11:06:22 +09:00
Yu Sung
51ffe09125 fix(yandex-ads): 광고 후 재생 액션을 유지한다 2026-04-30 14:38:59 +09:00
Yu Sung
5823f6ddb2 feat(chat): 채팅 쿼터 광고 충전을 추가한다 2026-04-30 14:23:15 +09:00
Yu Sung
714ad459b0 docs(yandex-ads): 채팅 탭 배너 작업 기록을 추가한다
Ultraworked with [Sisyphus]

https://github.com/code-yeongyu/oh-my-openagent

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-28 14:29:20 +09:00
Yu Sung
69a7d291d1 feat(chat): 톡 탭 상단에 Yandex 배너를 추가한다
Ultraworked with [Sisyphus]

https://github.com/code-yeongyu/oh-my-openagent

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-28 14:29:09 +09:00
Yu Sung
0f5cd8a904 feat(chat): 오리지널 탭 상단에 Yandex 배너를 추가한다
Ultraworked with [Sisyphus]

https://github.com/code-yeongyu/oh-my-openagent

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-28 14:29:00 +09:00
Yu Sung
e5a9d4c307 feat(chat): 캐릭터 목록에 Yandex 배너를 추가한다
Ultraworked with [Sisyphus]

https://github.com/code-yeongyu/oh-my-openagent

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-28 14:28:50 +09:00
Yu Sung
88dac10c9f feat(yandex-ads): 채팅 탭 배너 placement와 ad unit을 추가한다
Ultraworked with [Sisyphus]

https://github.com/code-yeongyu/oh-my-openagent

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-28 14:26:47 +09:00
Yu Sung
14f719fcc4 feat(yandex-ads): 시리즈 장르별 배치와 작업 기록을 정리한다 2026-04-28 13:58:47 +09:00
Yu Sung
b269a356e1 feat(yandex-ads): 시리즈 홈과 요일별 화면에 배너를 추가한다 2026-04-28 13:58:39 +09:00
Yu Sung
ddd82b6b8f feat(yandex-ads): 커뮤니티와 알림 화면에 배너를 추가한다 2026-04-28 13:58:32 +09:00
Yu Sung
8baae71317 feat(yandex-ads): 신규 배너 placement와 ad unit을 추가한다 2026-04-28 13:58:25 +09:00
Yu Sung
503468f713 feat(yandex-ads): 화면별 Yandex 광고 배치를 추가한다 2026-04-28 11:59:46 +09:00
Yu Sung
0813b64bc9 chore(yandex-ads): Yandex SKAdNetwork 식별자 구성을 정리한다 2026-04-27 19:24:06 +09:00
Yu Sung
120d961456 feat(yandex-ads): Yandex 광고 SDK 의존성과 초기화를 추가한다 2026-04-27 19:23:44 +09:00
Yu Sung
7db825cd41 fix(live-room): 방장 종료 후 오래된 룸 정보 오류 노출을 막는다 2026-04-13 13:39:19 +09:00
Yu Sung
3e524f121d chore(opencode): 에이전트 플러그인 lockfile을 추가한다 2026-04-10 19:52:11 +09:00
Yu Sung
4f427fc146 fix(live-room): 유지형 다이얼로그 표시 시 키보드를 내린다 2026-04-10 19:46:18 +09:00
Yu Sung
8b04952a4e fix(home): 홈 오디션 배너 노출을 제거한다 2026-04-02 12:32:45 +09:00
Yu Sung
13187070b5 fix(my-page): 비한국 국가 쿠폰 등록 노출 조건을 조정한다 2026-04-02 11:53:49 +09:00
161 changed files with 6757 additions and 1067 deletions

115
.opencode/package-lock.json generated Normal file
View 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
View File

@@ -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`를 참조한다.

View File

@@ -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

View File

@@ -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

View File

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

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 B

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -35,7 +35,13 @@ struct CharacterView: View {
onSelectCharacter(ch.characterId)
}
}
YandexInlineBannerView(
placement: .chatCharacterList,
horizontalPadding: 24
)
.padding(.vertical, -24)
//
if !viewModel.popularCharacters.isEmpty {
CharacterSectionView(

View File

@@ -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()
}
}
}
}

View File

@@ -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> {

View File

@@ -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)
}
}

View File

@@ -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? {

View File

@@ -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: {}
)
}

View File

@@ -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
}
}
}

View File

@@ -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 {

View 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?) {
}
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)

View File

@@ -55,6 +55,8 @@ struct SeriesMainHomeView: View {
}
}
YandexInlineBannerView(placement: .seriesMainHome, horizontalPadding: 24)
if !viewModel.recommendSeriesList.isEmpty {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 0) {

View File

@@ -22,7 +22,7 @@ struct ContentView: View {
if appState.isRestartApp {
EmptyView()
} else {
HomeView()
MainView()
}
if case .splash = appState.rootStep {

View File

@@ -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"

View File

@@ -47,6 +47,8 @@ struct CreatorCommunityAllView: View {
communityViewTypeTabView
YandexInlineBannerView(placement: .creatorCommunityAll)
if isGridMode {
gridContentView
} else {

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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: "マイ") }

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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()
}

View File

@@ -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])
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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

View File

@@ -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"))

View File

@@ -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")
}

View 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
}

View 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
}

View File

@@ -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"

View 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 }
)
}
}

View 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")
}
}
}

View 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")
}
}
}

View 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 }
)
}
}

View 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")
}
}
}

View 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: "")
}
}

View 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"
}
}
}

View 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)
}
}

View 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)
)
}
}

View 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()
}
}

View File

@@ -0,0 +1,11 @@
//
// MainViewModel.swift
// SodaLive
//
import Combine
import Foundation
final class MainViewModel: ObservableObject {
@Published var currentTab: MainTab = .home
}

View 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`를 참조한다.

View 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`를 즉시 갱신한다.

View 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` 헤더 흐름을 함께 점검한다.
## 주석/문서화
- 자명한 코드에는 주석을 남기지 않는다.
- 복잡한 분기, 외부 제약, 부작용이 있는 로직에만 주석을 추가한다.

View 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`에 둔다.
- 문서 작성 및 유지보수 규칙은 이 문서에 둔다.

View 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`

Some files were not shown because too many files have changed in this diff Show More