50 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
Yu Sung
243da1eb7d fix(i18n): 예약 날짜와 시간 표시 언어를 앱 설정에 맞춘다 2026-04-01 18:44:05 +09:00
Yu Sung
39700d3b39 chore(i18n): 미사용 String Catalog를 정리한다 2026-04-01 18:09:29 +09:00
Yu Sung
43c86a627b feat(i18n): 온보딩 시작하기 문구를 I18n 키로 통일한다 2026-04-01 17:53:03 +09:00
Yu Sung
1ec56a1f15 feat(i18n): 시리즈/재생목록 하드코딩 문구를 I18n 키로 통일한다 2026-04-01 17:33:13 +09:00
Yu Sung
c039931f34 feat(i18n): 콘텐츠 상세/댓글 하드코딩 문구를 I18n 키로 통일한다 2026-04-01 16:50:56 +09:00
Yu Sung
a90996603b feat(i18n): 콘텐츠 모듈 그룹2 하드코딩 문구를 I18n 키로 통일한다 2026-04-01 16:08:26 +09:00
Yu Sung
49e2487617 feat(i18n): 콘텐츠 모듈 그룹1 하드코딩 문구를 I18n 키로 통일한다 2026-04-01 15:36:39 +09:00
Yu Sung
038d66e363 feat(i18n): 탐색 프로필 하드코딩 문구를 I18n 키로 통일한다 2026-04-01 14:40:37 +09:00
Yu Sung
bceec46ebc feat(i18n): 라이브 룸 하드코딩 문구를 I18n 키로 통일한다 2026-04-01 13:52:02 +09:00
Yu Sung
540238eb48 feat(i18n): 라이브 모듈 하드코딩 문구를 I18n 키로 통일한다 2026-04-01 11:58:41 +09:00
Yu Sung
201f4c8139 feat(i18n): 홈 화면 하드코딩 문구를 I18n 키로 통일한다 2026-04-01 11:33:26 +09:00
Yu Sung
7285c5367d feat(i18n): 마이페이지 그룹 3~5 하드코딩 문구를 I18n 키로 통일한다 2026-03-31 23:32:05 +09:00
Yu Sung
b53614836f feat(i18n): 마이페이지 하드코딩 문구를 I18n 키로 통일한다 2026-03-31 23:04:33 +09:00
Yu Sung
25fccbaa07 feat(i18n): 음성 메시지 하드코딩 문구를 I18n 키로 통일한다 2026-03-31 22:21:41 +09:00
Yu Sung
9369a52ba2 feat(i18n): 메시지 모듈 하드코딩 문구를 I18n 키로 통일한다 2026-03-31 22:01:30 +09:00
Yu Sung
4c170e0f97 feat(i18n): 설정 화면 하드코딩 문구를 I18n 키로 통일한다 2026-03-31 19:46:21 +09:00
Yu Sung
3f61a08a04 feat(i18n): 시리즈 리스트 하드코딩 문구를 I18n 키로 통일한다 2026-03-31 18:50:22 +09:00
Yu Sung
9216db51da docs(i18n): 미완료 체크리스트를 10개 단위로 재배치한다 2026-03-31 18:35:04 +09:00
Yu Sung
8e4fe7a534 feat(i18n): 사용자 화면 문구를 I18n 키로 통일한다 2026-03-31 17:37:29 +09:00
Yu Sung
b2f66cf408 feat(i18n): 주요 UI 하드코딩 문구를 I18n 키로 통일한다 2026-03-31 17:09:01 +09:00
Yu Sung
47085dc1ca feat(chat): 채팅 모듈 하드코딩 문구를 I18n 키로 통일한다 2026-03-31 16:30:48 +09:00
Yu Sung
222520d5e9 feat(i18n): 오디션 화면 하드코딩 문구를 I18n 키로 통일한다 2026-03-31 15:39:57 +09:00
Yu Sung
136bfc8eee docs(i18n): 하드코딩 텍스트 I18n 통일 계획을 추가한다 2026-03-31 14:48:11 +09:00
439 changed files with 13010 additions and 12758 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 # AGENTS.md
`SodaLive` 저장소에서 작업하는 에이전트 실행 가이드다. `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/**`에서 해결한다. - 기능 변경은 `SodaLive/Sources/**`에서 해결한다.
- 기존 로직 수정이 아닌 신규 `View`, `ViewModel`, `Repository` 및 그와 연결된 하위 코드는 `SodaLive/Sources/V2/**` 아래에 작성한다.
- 프로젝트 설정 변경은 필요한 경우에만 수행한다. - 프로젝트 설정 변경은 필요한 경우에만 수행한다.
- `Pods/**`, `generated/**`는 직접 수정하지 않는다. - `Pods/**`, `generated/**`는 직접 수정하지 않는다.
- `build/**`는 빌드 산출물로 간주하며 수정 대상이 아니다. - `build/**`는 빌드 산출물로 간주하며 수정 대상이 아니다.
## 빌드/테스트/검증 명령 ## 실행 모드
아래 명령은 현재 저장소에서 확인된 공식 진입점이다. ### 기본 모드: 보수적 실행
- 최소 변경
- 단순한 구현
- 검증 가능한 결과
### 1) 의존성 설치 ### 확장 모드
- `pod install` - 사용자가 명시적으로 요청한 경우에만 사용한다.
- 근거: `Podfile`에 CocoaPods 타깃(`SodaLive`, `SodaLive-dev`) 정의. - 대규모 리팩토링, 브레인스토밍, 다중 에이전트 실행, 병렬 workflow를 허용한다.
### 2) 스킴/타깃 확인 ## oh-my-openagent 제어 정책
- `xcodebuild -workspace "SodaLive.xcworkspace" -list` - `oh-my-openagent`는 opencode의 플러그인 기반 실행 오케스트레이션 계층이다.
- 근거: 공유 스킴 `SodaLive`, `SodaLive-dev` 존재. - `oh-my-openagent`는 의사결정 권한이 아니라 실행 보조 권한만 가진다.
- 작은 작업에는 multi-agent 실행이나 과도한 workflow를 사용하지 않는다.
- 병렬 실행은 명확한 이득이 있을 때만 사용한다.
- 모든 `oh-my-openagent` 동작은 `CORE EXECUTION PRINCIPLES`를 따라야 한다.
### 3) 빌드 ## superpowers 사용 정책
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` - `superpowers`는 선택적 스킬 계층이다.
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` - `superpowers` skill은 필요한 경우에만 사용한다.
- `superpowers`가 과도한 리팩토링, 불필요한 범위 확장, 가정 기반 실행을 유도하면 따르지 않는다.
### 4) 테스트(전체) - `superpowers`를 사용할 때도 최소 변경, 단순성, 검증 가능성을 우선한다.
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` - 모든 `superpowers` 동작은 `CORE EXECUTION PRINCIPLES`를 따라야 한다.
- `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]`로 갱신한다.
- 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
- 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)을 한국어로 남긴다.
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰기 없이 누적한다.
## 문서 유지보수 규칙
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
- 명령/경로/타깃명이 바뀌면 본 문서를 즉시 업데이트한다.
## 에이전트 동작 원칙 ## 에이전트 동작 원칙
- 추측하지 말고 근거 파일을 읽고 결정한다. - 추측하지 말고 근거 파일을 읽고 결정한다.
- 기존 관례를 깨는 변경은 이유가 명확할 때만 수행한다. - 기존 관례를 깨는 변경은 이유가 명확할 때만 수행한다.
- 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다. - 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다.
- 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다. - 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다.
- 상세 실행 정책은 `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`) 수정 시 회귀 위험을 함께 점검한다. - 인증 관련 헤더/토큰 처리 로직(`AuthPlugin`, `UserDefaultsKey.token`) 수정 시 회귀 위험을 함께 점검한다.
- 외부 SDK 키 변경 시 빌드 설정과 런타임 초기화 지점을 함께 검토한다. - 외부 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 'AgoraRtm', '2.2.4'
pod 'GoogleSignIn' pod 'GoogleSignIn'
pod 'GoogleSignInSwiftSupport' pod 'GoogleSignInSwiftSupport'
pod 'YandexMobileAds', '8.0.0'
end end
@@ -22,6 +23,7 @@ target 'SodaLive-dev' do
pod 'AgoraRtm', '2.2.4' pod 'AgoraRtm', '2.2.4'
pod 'GoogleSignIn' pod 'GoogleSignIn'
pod 'GoogleSignInSwiftSupport' pod 'GoogleSignInSwiftSupport'
pod 'YandexMobileAds', '8.0.0'
end end

View File

@@ -15,6 +15,74 @@ PODS:
- GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4) - 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): - Bootpay (4.4.6):
- CryptoSwift - CryptoSwift
- NVActivityIndicatorView - NVActivityIndicatorView
@@ -27,6 +95,19 @@ PODS:
- SnapKit - SnapKit
- SwiftyJSON - SwiftyJSON
- CryptoSwift (1.8.4) - 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): - GoogleSignIn (9.1.0):
- AppAuth (~> 2.0) - AppAuth (~> 2.0)
- AppCheckCore (~> 11.0) - AppCheckCore (~> 11.0)
@@ -54,12 +135,29 @@ PODS:
- PromisesObjC (2.4.0) - PromisesObjC (2.4.0)
- SnapKit (5.7.1) - SnapKit (5.7.1)
- SwiftyJSON (5.0.2) - 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: DEPENDENCIES:
- AgoraRtm (= 2.2.4) - AgoraRtm (= 2.2.4)
- BootpayUI (= 4.4.10) - BootpayUI (= 4.4.10)
- GoogleSignIn - GoogleSignIn
- GoogleSignInSwiftSupport - GoogleSignInSwiftSupport
- YandexMobileAds (= 8.0.0)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
@@ -67,9 +165,33 @@ SPEC REPOS:
- Alamofire - Alamofire
- AppAuth - AppAuth
- AppCheckCore - AppCheckCore
- AppMetricaAdSupport
- AppMetricaCore
- AppMetricaCoreExtension
- AppMetricaCoreUtils
- AppMetricaEncodingUtils
- AppMetricaFMDB
- AppMetricaHostState
- AppMetricaIdentifiers
- AppMetricaIDSync
- AppMetricaKeychain
- AppMetricaLibraryAdapter
- AppMetricaLog
- AppMetricaLogSwift
- AppMetricaNetwork
- AppMetricaPlatform
- AppMetricaProtobuf
- AppMetricaProtobufUtils
- AppMetricaStorageUtils
- AppMetricaSynchronization
- Bootpay - Bootpay
- BootpayUI - BootpayUI
- CryptoSwift - CryptoSwift
- DivKit
- DivKit_LayoutKit
- DivKit_LayoutKitInterface
- DivKit_Serialization
- DivKitBinaryCompatibilityFacade
- GoogleSignIn - GoogleSignIn
- GoogleSignInSwiftSupport - GoogleSignInSwiftSupport
- GoogleUtilities - GoogleUtilities
@@ -80,15 +202,44 @@ SPEC REPOS:
- PromisesObjC - PromisesObjC
- SnapKit - SnapKit
- SwiftyJSON - SwiftyJSON
- VGSL
- VGSLFundamentals
- VGSLNetworking
- VGSLUI
- YandexMobileAds
SPEC CHECKSUMS: SPEC CHECKSUMS:
AgoraRtm: 534144434383d41b3b0ebfae2a961ef0f51b0645 AgoraRtm: 534144434383d41b3b0ebfae2a961ef0f51b0645
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f 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 Bootpay: cd7f0542b096ab0af0b09a6e12a6b87f2cbbb531
BootpayUI: beec5b0bba002b4dbced8c0ecace571ed6a017bc BootpayUI: beec5b0bba002b4dbced8c0ecace571ed6a017bc
CryptoSwift: e64e11850ede528a02a0f3e768cec8e9d92ecb90 CryptoSwift: e64e11850ede528a02a0f3e768cec8e9d92ecb90
DivKit: c66e0fa88b4671f832fb9ca3f142d6f56a56919d
DivKit_LayoutKit: e30d4d345034c2dfb356e5a891dd359ac79a5aff
DivKit_LayoutKitInterface: 320f0ef8c4f95bb8212b13400502adf0259c0b21
DivKit_Serialization: c5ba4f12034eca16960e80f369b689fd8cda95b0
DivKitBinaryCompatibilityFacade: fc2284a2edea4d65aa0966006ea63274eb9f721b
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
GoogleSignInSwiftSupport: aca902e4e15b234611ecac74ef5c8f61278f774e GoogleSignInSwiftSupport: aca902e4e15b234611ecac74ef5c8f61278f774e
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
@@ -99,7 +250,12 @@ SPEC CHECKSUMS:
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SwiftyJSON: f5b1bf1cd8dd53cd25887ac0eabcfd92301c6a5a SwiftyJSON: f5b1bf1cd8dd53cd25887ac0eabcfd92301c6a5a
VGSL: 0573c2b82b05aadcba4836398ce3778d271bfd13
VGSLFundamentals: 3a081684c1a5df5800bf88aca8a9bdff2c10cfd9
VGSLNetworking: 0ea8a335bc4f4eba3f6123ffe441cf1c08f267f2
VGSLUI: 249a16cccdb75f1a5a1733894d013bca76e27c5e
YandexMobileAds: ca6c63c4148ae87fefc1821d0b466ea567069d5b
PODFILE CHECKSUM: 70c5639090824ff26cfad959985347579609e1e6 PODFILE CHECKSUM: 525ba559e93875de1314bb1a7894791eee442151
COCOAPODS: 1.16.2 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

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,7 @@ import FirebaseCore
import FirebaseAnalytics import FirebaseAnalytics
import FirebaseMessaging import FirebaseMessaging
import LineSDK import LineSDK
import YandexMobileAds
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
@@ -30,6 +31,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
Messaging.messaging().delegate = self Messaging.messaging().delegate = self
setupAppsFlyer() setupAppsFlyer()
// YandexAds
YandexAds.initializeSDK(completionHandler: nil)
// For iOS 10 display notification (sent via APNS) // For iOS 10 display notification (sent via APNS)
UNUserNotificationCenter.current().delegate = self UNUserNotificationCenter.current().delegate = self

View File

@@ -30,7 +30,7 @@ struct ApplyMethodView: View {
} }
} }
Text("오디션 지원방식") Text(I18n.Audition.ApplyMethod.title)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color.graybb) .foregroundColor(Color.graybb)
.padding(.top, 33.3) .padding(.top, 33.3)
@@ -39,7 +39,7 @@ struct ApplyMethodView: View {
HStack(spacing: 3) { HStack(spacing: 3) {
Image("ic_upload") Image("ic_upload")
Text("파일 업로드") Text(I18n.Audition.ApplyMethod.fileUpload)
.appFont(size: 14.7, weight: .medium) .appFont(size: 14.7, weight: .medium)
.foregroundColor(Color.button) .foregroundColor(Color.button)
} }
@@ -60,7 +60,7 @@ struct ApplyMethodView: View {
HStack(spacing: 3) { HStack(spacing: 3) {
Image("ic_mic_color_button") Image("ic_mic_color_button")
Text("바로 녹음") Text(I18n.Audition.ApplyMethod.recordNow)
.appFont(size: 14.7, weight: .medium) .appFont(size: 14.7, weight: .medium)
.foregroundColor(Color.button) .foregroundColor(Color.button)
} }
@@ -81,7 +81,7 @@ struct ApplyMethodView: View {
.padding(.top, 21.3) .padding(.top, 21.3)
HStack(spacing: 0) { HStack(spacing: 0) {
Text("※ 파일은 mp3, aac만 업로드 가능") Text(I18n.Audition.ApplyMethod.fileFormatNotice)
.appFont(size: 12, weight: .medium) .appFont(size: 12, weight: .medium)
.foregroundColor(Color.gray77) .foregroundColor(Color.gray77)
.padding(.top, 13.3) .padding(.top, 13.3)

View File

@@ -30,7 +30,7 @@ struct AuditionApplicantRecordingView: View {
VStack { VStack {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(spacing: 0) { HStack(spacing: 0) {
Text("오디션 녹음") Text(I18n.Audition.Recording.title)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
@@ -76,7 +76,7 @@ struct AuditionApplicantRecordingView: View {
HStack(spacing: 0) { HStack(spacing: 0) {
Spacer() Spacer()
Text("삭제") Text(I18n.Common.delete)
.appFont(size: 15.3, weight: .medium) .appFont(size: 15.3, weight: .medium)
.foregroundColor(Color.graybb.opacity(0)) .foregroundColor(Color.graybb.opacity(0))
@@ -99,7 +99,7 @@ struct AuditionApplicantRecordingView: View {
Spacer() Spacer()
Text("삭제") Text(I18n.Common.delete)
.appFont(size: 15.3, weight: .medium) .appFont(size: 15.3, weight: .medium)
.foregroundColor(Color.graybb) .foregroundColor(Color.graybb)
.onTapGesture { .onTapGesture {
@@ -113,7 +113,7 @@ struct AuditionApplicantRecordingView: View {
.padding(.vertical, 52.3) .padding(.vertical, 52.3)
HStack(spacing: 13.3) { HStack(spacing: 13.3) {
Text("다시 녹음") Text(I18n.Audition.Recording.recordAgain)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color.button) .foregroundColor(Color.button)
.frame(width: (proxy.size.width - 40) / 3, height: 50) .frame(width: (proxy.size.width - 40) / 3, height: 50)
@@ -129,7 +129,7 @@ struct AuditionApplicantRecordingView: View {
soundManager.recordMode = .RECORD soundManager.recordMode = .RECORD
} }
Text("녹음완료") Text(I18n.Audition.Recording.recordComplete)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
.frame(width: (proxy.size.width - 40) * 2 / 3, height: 50) .frame(width: (proxy.size.width - 40) * 2 / 3, height: 50)
@@ -140,7 +140,7 @@ struct AuditionApplicantRecordingView: View {
let soundData = try Data(contentsOf: soundManager.getAudioFileURL()) let soundData = try Data(contentsOf: soundManager.getAudioFileURL())
onClickCompleteRecording(tempFileName, soundData) onClickCompleteRecording(tempFileName, soundData)
} catch { } catch {
errorMessage = "녹음파일을 생성하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." errorMessage = I18n.Audition.Recording.createFileFailed
isShowPopup = true isShowPopup = true
} }
} }

View File

@@ -33,7 +33,7 @@ struct AuditionApplyView: View {
if isShow { if isShow {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 0) { HStack(spacing: 0) {
Text("오디션 지원") Text(I18n.Audition.Apply.title)
.appFont(size: 18.3, weight: .medium) .appFont(size: 18.3, weight: .medium)
.foregroundColor(.white) .foregroundColor(.white)
@@ -45,7 +45,7 @@ struct AuditionApplyView: View {
} }
} }
Text("녹음파일") Text(I18n.Audition.Apply.recordingFile)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(.grayee) .foregroundColor(.grayee)
.padding(.top, 20) .padding(.top, 20)
@@ -53,7 +53,7 @@ struct AuditionApplyView: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image("ic_note_square") Image("ic_note_square")
Text(filename) Text(displayFileName)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(.grayd2) .foregroundColor(.grayd2)
@@ -66,12 +66,12 @@ struct AuditionApplyView: View {
.cornerRadius(5.3) .cornerRadius(5.3)
.padding(.top, 10) .padding(.top, 10)
Text("연락처") Text(I18n.Audition.Apply.contact)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(.grayee) .foregroundColor(.grayee)
.padding(.top, 15) .padding(.top, 15)
TextField("합격시 받을 연락처를 남겨주세요", text: $phoneNumber) TextField(I18n.Audition.Apply.contactPlaceholder, text: $phoneNumber)
.autocapitalization(.none) .autocapitalization(.none)
.disableAutocorrection(true) .disableAutocorrection(true)
.keyboardType(.decimalPad) .keyboardType(.decimalPad)
@@ -89,7 +89,7 @@ struct AuditionApplyView: View {
.resizable() .resizable()
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
Text("보이스온 오디오 드라마 오디션 합격시 개인 연락을 위한 개인 정보(연락처) 수집 및 활용에 동의합니다.\n오디션 지원자는 개인정보 수집 및 활용 동의에 거부할 권리가 있으며 비동의시 오디션 지원은 취소 됩니다.") Text(I18n.Audition.Apply.privacyAgreement)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.lineSpacing(3) .lineSpacing(3)
@@ -100,7 +100,7 @@ struct AuditionApplyView: View {
isAgree.toggle() isAgree.toggle()
} }
Text("오디션 지원하기") Text(I18n.Audition.Apply.submit)
.appFont(size: 13.3, weight: .bold) .appFont(size: 13.3, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.padding(.vertical, 13.3) .padding(.vertical, 13.3)
@@ -110,7 +110,7 @@ struct AuditionApplyView: View {
.padding(.top, 35) .padding(.top, 35)
.onTapGesture { .onTapGesture {
if !isAgree { if !isAgree {
errorMessage = "연락처 수집 및 활용에 동의하셔야 오디션 지원이 가능합니다." errorMessage = I18n.Audition.Apply.requireAgreement
isShowPopup = true isShowPopup = true
return return
} }
@@ -137,6 +137,14 @@ struct AuditionApplyView: View {
} }
} }
} }
private var displayFileName: String {
if filename.hasPrefix("voiceon_now_voice_") {
return I18n.Audition.Apply.recordedVoiceFileName
}
return filename
}
} }
#Preview { #Preview {

View File

@@ -24,7 +24,7 @@ struct AuditionView: View {
.resizable() .resizable()
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
Text("오디션") Text(I18n.Audition.title)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: "eeeeee")) .foregroundColor(Color(hex: "eeeeee"))
} }
@@ -43,13 +43,13 @@ struct AuditionView: View {
.background(Color.black) .background(Color.black)
HStack(spacing: 0) { HStack(spacing: 0) {
Text("보이스온 오디션 이용방법") Text(I18n.Audition.List.usageGuide)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(.white) .foregroundColor(.white)
Spacer() Spacer()
Text("자세히>") Text(I18n.Audition.List.detail)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(.white) .foregroundColor(.white)
} }
@@ -73,17 +73,17 @@ struct AuditionView: View {
if $0 == 0 && !item.isOff { if $0 == 0 && !item.isOff {
VStack(alignment: .leading, spacing: 25) { VStack(alignment: .leading, spacing: 25) {
HStack(spacing: 0) { HStack(spacing: 0) {
Text("오디션") Text(I18n.Audition.title)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
Text(" ON") Text(I18n.Audition.List.onStatus)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color.mainRed) .foregroundColor(Color.mainRed)
Spacer() Spacer()
Text("\(viewModel.inProgressCount)") Text(I18n.Audition.List.totalCount(viewModel.inProgressCount))
.appFont(size: 11.3, weight: .medium) .appFont(size: 11.3, weight: .medium)
.foregroundColor(Color.graybb) .foregroundColor(Color.graybb)
} }
@@ -111,17 +111,17 @@ struct AuditionView: View {
.padding(.top, 5) .padding(.top, 5)
HStack(spacing: 0) { HStack(spacing: 0) {
Text("오디션") Text(I18n.Audition.title)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
Text(" OFF") Text(I18n.Audition.List.offStatus)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color.graybb) .foregroundColor(Color.graybb)
Spacer() Spacer()
Text("\(viewModel.completedCount)") Text(I18n.Audition.List.totalCount(viewModel.completedCount))
.appFont(size: 11.3, weight: .medium) .appFont(size: 11.3, weight: .medium)
.foregroundColor(Color.graybb) .foregroundColor(Color.graybb)
} }

View File

@@ -66,13 +66,13 @@ final class AuditionViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }

View File

@@ -30,7 +30,7 @@ struct AuditionDetailView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.cornerRadius(6.7) .cornerRadius(6.7)
Text("오디션 정보") Text(I18n.Audition.Detail.informationTitle)
.appFont(size: 14.7, weight: .bold) .appFont(size: 14.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.padding(.top, 15) .padding(.top, 15)
@@ -38,7 +38,7 @@ struct AuditionDetailView: View {
ExpandableTextView(text: response.information) ExpandableTextView(text: response.information)
.padding(.top, 13.3) .padding(.top, 13.3)
Text("오디션 캐릭터") Text(I18n.Audition.Detail.characterTitle)
.appFont(size: 14.7, weight: .bold) .appFont(size: 14.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.padding(.top, 15) .padding(.top, 15)

View File

@@ -18,7 +18,7 @@ final class AuditionDetailViewModel: ObservableObject {
@Published var isLoading = false @Published var isLoading = false
@Published var response: GetAuditionDetailResponse? = nil @Published var response: GetAuditionDetailResponse? = nil
@Published var title: String = "보이스온" @Published var title: String = I18n.Audition.defaultTitle
func getAuditionDetail(auditionId: Int, onFailure: @escaping () -> Void) { func getAuditionDetail(auditionId: Int, onFailure: @escaping () -> Void) {
isLoading = true isLoading = true
@@ -45,7 +45,7 @@ final class AuditionDetailViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
@@ -54,7 +54,7 @@ final class AuditionDetailViewModel: ObservableObject {
} }
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
onFailure() onFailure()

View File

@@ -64,7 +64,7 @@ class AuditionSoundManager: NSObject, ObservableObject {
private func setupPlayer(with url: String) { private func setupPlayer(with url: String) {
guard let url = URL(string: url) else { guard let url = URL(string: url) else {
self.errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." self.errorMessage = I18n.Audition.Sound.playbackFailed
self.isShowPopup = true self.isShowPopup = true
return return
} }
@@ -92,7 +92,7 @@ class AuditionSoundManager: NSObject, ObservableObject {
} }
} catch { } catch {
DispatchQueue.main.async { DispatchQueue.main.async {
self.errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." self.errorMessage = I18n.Audition.Sound.playbackFailed
self.isShowPopup = true self.isShowPopup = true
self.isLoading = false self.isLoading = false
} }

View File

@@ -28,7 +28,7 @@ struct AuditionDetailRoleItemView: View {
.opacity(item.isComplete ? 0.7 : 0.0) .opacity(item.isComplete ? 0.7 : 0.0)
) )
Text(item.isComplete ? "모집완료" : "모집중") Text(item.isComplete ? I18n.Audition.Detail.recruitmentClosed : I18n.Audition.Detail.recruiting)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.white) .foregroundColor(Color.white)
.padding(.horizontal, 9) .padding(.horizontal, 9)

View File

@@ -45,7 +45,7 @@ struct AuditionRoleDetailView: View {
HStack(spacing: 14) { HStack(spacing: 14) {
if let url = URL(string: roleDetail.originalWorkUrl), UIApplication.shared.canOpenURL(url) { if let url = URL(string: roleDetail.originalWorkUrl), UIApplication.shared.canOpenURL(url) {
Text("원작 보러가기") Text(I18n.Audition.Detail.viewOriginalWork)
.appFont(size: 16, weight: .bold) .appFont(size: 16, weight: .bold)
.foregroundColor(Color.button) .foregroundColor(Color.button)
.padding(.vertical, 12) .padding(.vertical, 12)
@@ -59,7 +59,7 @@ struct AuditionRoleDetailView: View {
} }
if let url = URL(string: roleDetail.auditionScriptUrl), UIApplication.shared.canOpenURL(url) { if let url = URL(string: roleDetail.auditionScriptUrl), UIApplication.shared.canOpenURL(url) {
Text("오디션 대본 확인") Text(I18n.Audition.Detail.checkScript)
.appFont(size: 16, weight: .bold) .appFont(size: 16, weight: .bold)
.foregroundColor(Color.button) .foregroundColor(Color.button)
.padding(.vertical, 12) .padding(.vertical, 12)
@@ -74,7 +74,7 @@ struct AuditionRoleDetailView: View {
} }
VStack(alignment: .leading, spacing: 13.3) { VStack(alignment: .leading, spacing: 13.3) {
Text("오디션 캐릭터 정보") Text(I18n.Audition.Detail.characterInfo)
.appFont(size: 14.7, weight: .bold) .appFont(size: 14.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
@@ -83,13 +83,13 @@ struct AuditionRoleDetailView: View {
} }
if viewModel.applicantList.isEmpty { if viewModel.applicantList.isEmpty {
Text("지원자가 없습니다.") Text(I18n.Audition.Detail.noApplicants)
.appFont(size: 13, weight: .medium) .appFont(size: 13, weight: .medium)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.padding(.top, 15) .padding(.top, 15)
} else { } else {
HStack(spacing: 0) { HStack(spacing: 0) {
Text("참여자") Text(I18n.Audition.Detail.participants)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.graybb) .foregroundColor(Color.graybb)
@@ -98,13 +98,13 @@ struct AuditionRoleDetailView: View {
.foregroundColor(Color.button) .foregroundColor(Color.button)
.padding(.leading, 2.3) .padding(.leading, 2.3)
Text("") Text(I18n.Audition.Detail.personUnit)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.graybb) .foregroundColor(Color.graybb)
Spacer() Spacer()
Text("최신순") Text(I18n.Audition.Detail.sortNewest)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor( .foregroundColor(
viewModel.sortType == .NEWEST ? Color.button : Color.graybb viewModel.sortType == .NEWEST ? Color.button : Color.graybb
@@ -113,7 +113,7 @@ struct AuditionRoleDetailView: View {
viewModel.setSortType(sortType: .NEWEST) viewModel.setSortType(sortType: .NEWEST)
} }
Text("좋아요순") Text(I18n.Audition.Detail.sortLikes)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor( .foregroundColor(
viewModel.sortType == .LIKES ? Color.button : Color.graybb viewModel.sortType == .LIKES ? Color.button : Color.graybb
@@ -161,7 +161,7 @@ struct AuditionRoleDetailView: View {
} }
if let roleDetail = viewModel.auditionRoleDetail { if let roleDetail = viewModel.auditionRoleDetail {
Text(roleDetail.isAlreadyApplicant ? "오디션 재지원" : "오디션 지원") Text(roleDetail.isAlreadyApplicant ? I18n.Audition.Apply.reapply : I18n.Audition.Apply.apply)
.appFont(size: 15.3, weight: .bold) .appFont(size: 15.3, weight: .bold)
.foregroundColor(Color.white) .foregroundColor(Color.white)
.padding(14) .padding(14)
@@ -241,9 +241,9 @@ struct AuditionRoleDetailView: View {
if isShowNoticeReapply { if isShowNoticeReapply {
SodaDialog( SodaDialog(
title: "재지원 안내", title: I18n.Audition.Apply.reapplyNoticeTitle,
desc: "재지원 시 이전 지원 내역은 삭제되며 받은 투표수는 무효 처리됩니다.", desc: I18n.Audition.Apply.reapplyNoticeDesc,
confirmButtonTitle: "확인" confirmButtonTitle: I18n.Common.confirm
) { ) {
isShowNoticeReapply = false isShowNoticeReapply = false
isShowApplyMethodView = true isShowApplyMethodView = true
@@ -252,9 +252,9 @@ struct AuditionRoleDetailView: View {
if isShowNoticeAuthView { if isShowNoticeAuthView {
SodaDialog( SodaDialog(
title: "- 본인인증 -", title: I18n.Audition.Apply.authRequiredTitle,
desc: "마이페이지에서 '본인인증'을 하고 다시 오디션에 지원해 주세요.", desc: I18n.Audition.Apply.authRequiredDesc,
confirmButtonTitle: "확인" confirmButtonTitle: I18n.Common.confirm
) { ) {
isShowNoticeAuthView = false isShowNoticeAuthView = false
} }
@@ -264,7 +264,7 @@ struct AuditionRoleDetailView: View {
SodaDialog( SodaDialog(
title: viewModel.dialogTitle, title: viewModel.dialogTitle,
desc: viewModel.dialogDesc, desc: viewModel.dialogDesc,
confirmButtonTitle: "확인" confirmButtonTitle: I18n.Common.confirm
) { ) {
viewModel.isShowVoteCompleteView = false viewModel.isShowVoteCompleteView = false
viewModel.isShowNotifyVote = false viewModel.isShowNotifyVote = false
@@ -300,17 +300,17 @@ struct AuditionRoleDetailView: View {
viewModel.fileName = fileUrl.lastPathComponent viewModel.fileName = fileUrl.lastPathComponent
isShowApplyView = true isShowApplyView = true
} else { } else {
viewModel.errorMessage = "콘텐츠 파일을 불러오지 못했습니다.\n다시 선택해 주세요" viewModel.errorMessage = I18n.Audition.Apply.fileLoadFailed
viewModel.isShowPopup = true viewModel.isShowPopup = true
} }
} else { } else {
viewModel.errorMessage = "콘텐츠 파일을 불러오지 못했습니다.\n다시 선택해 주세요" viewModel.errorMessage = I18n.Audition.Apply.fileLoadFailed
viewModel.isShowPopup = true viewModel.isShowPopup = true
} }
case .failure(let error): case .failure(let error):
DEBUG_LOG("error: \(error.localizedDescription)") DEBUG_LOG("error: \(error.localizedDescription)")
viewModel.errorMessage = "콘텐츠 파일을 불러오지 못했습니다.\n다시 선택해 주세요" viewModel.errorMessage = I18n.Audition.Apply.fileLoadFailed
viewModel.isShowPopup = true viewModel.isShowPopup = true
} }
} }

View File

@@ -21,7 +21,7 @@ final class AuditionRoleDetailViewModel: ObservableObject {
@Published var totalCount = 0 @Published var totalCount = 0
@Published var applicantList = [GetAuditionRoleApplicantItem]() @Published var applicantList = [GetAuditionRoleApplicantItem]()
@Published var name = "보이스온" @Published var name = I18n.Audition.defaultTitle
@Published var auditionRoleDetail: GetAuditionRoleDetailResponse? = nil @Published var auditionRoleDetail: GetAuditionRoleDetailResponse? = nil
@Published private(set) var sortType = AuditionApplicantSortType.NEWEST { @Published private(set) var sortType = AuditionApplicantSortType.NEWEST {
@@ -93,13 +93,13 @@ final class AuditionRoleDetailViewModel: ObservableObject {
if let message = roleDetailDecoded.message { if let message = roleDetailDecoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
@@ -118,7 +118,7 @@ final class AuditionRoleDetailViewModel: ObservableObject {
if let message = applicantListDecoded.message { if let message = applicantListDecoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
@@ -127,7 +127,7 @@ final class AuditionRoleDetailViewModel: ObservableObject {
} }
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.onFailure() self.onFailure()
@@ -172,13 +172,13 @@ final class AuditionRoleDetailViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }
@@ -188,13 +188,13 @@ final class AuditionRoleDetailViewModel: ObservableObject {
func applyAudition(onSuccess: @escaping () -> Void) { func applyAudition(onSuccess: @escaping () -> Void) {
if phoneNumber.count != 11 { if phoneNumber.count != 11 {
errorMessage = "잘못된 연락처 입니다.\n다시 입력해 주세요." errorMessage = I18n.Audition.Apply.invalidContact
isShowPopup = true isShowPopup = true
return return
} }
guard let soundData = soundData else { guard let soundData = soundData else {
errorMessage = "잘못된 녹음 파일 입니다.\n다시 선택해 주세요." errorMessage = I18n.Audition.Apply.invalidRecordingFile
isShowPopup = true isShowPopup = true
return return
} }
@@ -248,19 +248,19 @@ final class AuditionRoleDetailViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "오디션 지원을 완료하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Audition.Apply.applyFailed
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "오디션 지원을 완료하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Audition.Apply.applyFailed
self.isShowPopup = true self.isShowPopup = true
} }
} }
.store(in: &subscription) .store(in: &subscription)
} else { } else {
self.errorMessage = "오디션 지원을 완료하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Audition.Apply.applyFailed
self.isShowPopup = true self.isShowPopup = true
self.isLoading = false self.isLoading = false
} }
@@ -287,8 +287,8 @@ final class AuditionRoleDetailViewModel: ObservableObject {
if decoded.success { if decoded.success {
if self.isShowNotifyVote { if self.isShowNotifyVote {
self.dialogTitle = "[오디션 응원]" self.dialogTitle = I18n.Audition.Vote.cheerTitle
self.dialogDesc = "오디션을 응원하셨습니다\n(무료응원 : 1계정당 1일 1회)\n1캔으로 추가 응원을 해보세요." self.dialogDesc = I18n.Audition.Vote.cheerDescription
self.isShowVoteCompleteView = true self.isShowVoteCompleteView = true
} }
@@ -302,20 +302,20 @@ final class AuditionRoleDetailViewModel: ObservableObject {
} else { } else {
if let message = decoded.message { if let message = decoded.message {
if message.contains("오늘 응원은 여기까지") { if message.contains("오늘 응원은 여기까지") {
self.dialogTitle = "[오늘 응원 제한]" self.dialogTitle = I18n.Audition.Vote.limitTitle
self.dialogDesc = "오늘 응원은 여기까지!\n하루 최대 10회까지 이용이 가능합니다.\n내일 다시 이용해주세요." self.dialogDesc = I18n.Audition.Vote.limitDescription
self.isShowVoteCompleteView = true self.isShowVoteCompleteView = true
} else { } else {
self.errorMessage = message self.errorMessage = message
self.isShowPopup = true self.isShowPopup = true
} }
} else { } else {
self.errorMessage = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." self.errorMessage = I18n.Audition.Vote.unknownError
self.isShowPopup = true self.isShowPopup = true
} }
} }
} catch { } catch {
self.errorMessage = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요." self.errorMessage = I18n.Audition.Vote.unknownError
self.isShowPopup = true self.isShowPopup = true
} }
} }

View File

@@ -33,7 +33,7 @@ struct CharacterItemView: View {
HStack { HStack {
Spacer() Spacer()
Text("N") Text(I18n.Chat.Character.newBadge)
.appFont(size: 18, weight: .regular) .appFont(size: 18, weight: .regular)
.foregroundColor(.white) .foregroundColor(.white)
.frame(width: 30, height: 30) .frame(width: 30, height: 30)

View File

@@ -8,7 +8,7 @@
import SwiftUI import SwiftUI
struct CharacterSectionView: View { struct CharacterSectionView: View {
let title: LocalizedStringResource let title: String
let items: [Character] let items: [Character]
let isShowRank: Bool let isShowRank: Bool
var trailingTitle: String? = nil var trailingTitle: String? = nil
@@ -52,7 +52,7 @@ struct CharacterSectionView: View {
#Preview { #Preview {
CharacterSectionView( CharacterSectionView(
title: "신규 캐릭터", title: I18n.Chat.Character.newSectionTitle,
items: [ items: [
Character(characterId: 1, name: "찰리", description: "새로운 친구", imageUrl: "https://picsum.photos/300", isNew: true), Character(characterId: 1, name: "찰리", description: "새로운 친구", imageUrl: "https://picsum.photos/300", isNew: true),
Character(characterId: 2, name: "데이지", description: "", imageUrl: "https://picsum.photos/300", isNew: false) Character(characterId: 2, name: "데이지", description: "", imageUrl: "https://picsum.photos/300", isNew: false)

View File

@@ -35,11 +35,17 @@ struct CharacterView: View {
onSelectCharacter(ch.characterId) onSelectCharacter(ch.characterId)
} }
} }
YandexInlineBannerView(
placement: .chatCharacterList,
horizontalPadding: 24
)
.padding(.vertical, -24)
// //
if !viewModel.popularCharacters.isEmpty { if !viewModel.popularCharacters.isEmpty {
CharacterSectionView( CharacterSectionView(
title: "인기 캐릭터", title: I18n.Chat.Character.popularSectionTitle,
items: viewModel.popularCharacters, items: viewModel.popularCharacters,
isShowRank: true, isShowRank: true,
onTap: { ch in onTap: { ch in
@@ -51,7 +57,7 @@ struct CharacterView: View {
// //
if !viewModel.newCharacters.isEmpty { if !viewModel.newCharacters.isEmpty {
CharacterSectionView( CharacterSectionView(
title: "신규 캐릭터", title: I18n.Chat.Character.newSectionTitle,
items: viewModel.newCharacters, items: viewModel.newCharacters,
isShowRank: false, isShowRank: false,
trailingTitle: I18n.Common.viewAll, trailingTitle: I18n.Common.viewAll,
@@ -67,7 +73,7 @@ struct CharacterView: View {
if !viewModel.recommendCharacters.isEmpty { if !viewModel.recommendCharacters.isEmpty {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack { HStack {
Text("추천 캐릭터") Text(I18n.Chat.Character.recommendSectionTitle)
.appFont(size: 24, weight: .bold) .appFont(size: 24, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)

View File

@@ -54,7 +54,7 @@ final class CharacterViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
@@ -62,7 +62,7 @@ final class CharacterViewModel: ObservableObject {
self.isLoading = false self.isLoading = false
} catch { } catch {
self.isLoading = false self.isLoading = false
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }
@@ -93,7 +93,7 @@ final class CharacterViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
@@ -101,11 +101,10 @@ final class CharacterViewModel: ObservableObject {
self.isLoading = false self.isLoading = false
} catch { } catch {
self.isLoading = false self.isLoading = false
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }
.store(in: &subscription) .store(in: &subscription)
} }
} }

View File

@@ -34,7 +34,7 @@ struct CharacterDetailView: View {
var body: some View { var body: some View {
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 0) { VStack(spacing: 0) {
DetailNavigationBar(title: String(localized: "캐릭터 정보")) { DetailNavigationBar(title: I18n.Chat.Character.detailTitle) {
if presentationMode.wrappedValue.isPresented { if presentationMode.wrappedValue.isPresented {
presentationMode.wrappedValue.dismiss() presentationMode.wrappedValue.dismiss()
} else { } else {
@@ -77,7 +77,7 @@ struct CharacterDetailView: View {
if let others = viewModel.characterDetail?.others, !others.isEmpty { if let others = viewModel.characterDetail?.others, !others.isEmpty {
VStack(spacing: 16) { VStack(spacing: 16) {
HStack { HStack {
Text("장르의 다른 캐릭터") Text(I18n.Chat.Character.detailOtherCharactersTitle)
.appFont(size: 26, weight: .bold) .appFont(size: 26, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
@@ -178,6 +178,13 @@ extension CharacterDetailView {
// MARK: - Profile Section // MARK: - Profile Section
extension CharacterDetailView { extension CharacterDetailView {
private func isMaleGender(_ gender: String) -> Bool {
let normalizedGender = gender
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return normalizedGender == "남성" || normalizedGender == "male"
}
private var profileSection: some View { private var profileSection: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
if viewModel.characterDetail?.mbti != nil || if viewModel.characterDetail?.mbti != nil ||
@@ -189,7 +196,7 @@ extension CharacterDetailView {
Text(viewModel.characterDetail?.translated?.gender ?? gender) Text(viewModel.characterDetail?.translated?.gender ?? gender)
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor( .foregroundColor(
gender == "남성" ? isMaleGender(gender) ?
Color.button : Color.button :
Color.mainRed Color.mainRed
) )
@@ -201,7 +208,7 @@ extension CharacterDetailView {
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.stroke(lineWidth: 1) .stroke(lineWidth: 1)
.foregroundColor( .foregroundColor(
gender == "남성" ? isMaleGender(gender) ?
Color.button : Color.button :
Color.mainRed Color.mainRed
) )
@@ -209,7 +216,7 @@ extension CharacterDetailView {
} }
if let age = viewModel.characterDetail?.age { if let age = viewModel.characterDetail?.age {
Text("\(age)") Text(I18n.Chat.Character.age(age))
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
.padding(.horizontal, 7) .padding(.horizontal, 7)
@@ -252,7 +259,7 @@ extension CharacterDetailView {
if let characterType = viewModel.characterDetail?.characterType { if let characterType = viewModel.characterDetail?.characterType {
HStack(spacing: 8) { HStack(spacing: 8) {
Text(characterType.rawValue) Text(characterType == .Clone ? I18n.Chat.Character.typeClone : I18n.Chat.Character.typeCharacter)
.appFont(size: 12, weight: .regular) .appFont(size: 12, weight: .regular)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 5) .padding(.horizontal, 5)
@@ -282,7 +289,7 @@ extension CharacterDetailView {
private func worldViewSection(backgrounds: String) -> some View { private func worldViewSection(backgrounds: String) -> some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
Text("[세계관 및 작품 소개]") Text(I18n.Chat.Character.detailWorldViewTitle)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
@@ -300,7 +307,7 @@ extension CharacterDetailView {
private func originalWorkSection(title: String, link: String) -> some View { private func originalWorkSection(title: String, link: String) -> some View {
VStack(spacing: 8) { VStack(spacing: 8) {
HStack { HStack {
Text("원작") Text(I18n.Chat.Character.detailOriginalTitle)
.appFont(size: 16, weight: .bold) .appFont(size: 16, weight: .bold)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(.white) .foregroundColor(.white)
@@ -321,7 +328,7 @@ extension CharacterDetailView {
UIApplication.shared.open(url) UIApplication.shared.open(url)
} }
}) { }) {
Text("원작 보러가기") Text(I18n.Chat.Character.detailOriginalLinkButton)
.appFont(size: 16, weight: .bold) .appFont(size: 16, weight: .bold)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(Color(hex: "3BB9F1")) .foregroundColor(Color(hex: "3BB9F1"))
@@ -342,7 +349,7 @@ extension CharacterDetailView {
private func personalitySection(personalities: String) -> some View { private func personalitySection(personalities: String) -> some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
Text("[성격 및 특징]") Text(I18n.Chat.Character.detailPersonalityTitle)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
@@ -354,24 +361,19 @@ extension CharacterDetailView {
// //
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack { HStack {
Text("⚠️ 캐릭터톡 대화 가이드") Text(I18n.Chat.Character.detailConversationGuideTitle)
.appFont(size: 16, weight: .bold) .appFont(size: 16, weight: .bold)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
Spacer() Spacer()
} }
Text(""" Text(I18n.Chat.Character.detailConversationGuideDescription1)
보이스온의 오픈월드 캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다. 세계관 속 연관 캐릭터가 되어 대화를 하거나 완전히 새로운 인물이 되어 캐릭터와 당신만의 스토리를 만들어 갈 수 있습니다.
""")
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(Color(hex: "AEAEB2")) .foregroundColor(Color(hex: "AEAEB2"))
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
Text(""" Text(I18n.Chat.Character.detailConversationGuideDescription2)
오픈월드 캐릭터톡은 캐릭터를 정교하게 설계하였지만, 대화가 어색하거나 불완전할 수도 있습니다.
대화 도중 캐릭터의 대화가 이상하거나 새로운 캐릭터로 대화를 나누고 싶다면 대화를 초기화 하고 새롭게 캐릭터와 대화를 나눠보세요.
""")
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(Color(hex: "AEAEB2")) .foregroundColor(Color(hex: "AEAEB2"))
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
@@ -393,7 +395,7 @@ extension CharacterDetailView {
// MARK: - Chat Button // MARK: - Chat Button
extension CharacterDetailView { extension CharacterDetailView {
private var chatButton: some View { private var chatButton: some View {
Text("대화하기") Text(I18n.Chat.Character.detailChatButton)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -450,7 +452,7 @@ struct CharacterExpandableTextView: View {
.foregroundColor(Color(hex: "607D8B")) .foregroundColor(Color(hex: "607D8B"))
.rotationEffect(.degrees(isExpanded ? 180 : 0)) .rotationEffect(.degrees(isExpanded ? 180 : 0))
Text(isExpanded ? "간략히" : "더보기") Text(isExpanded ? I18n.Chat.Character.detailCollapse : I18n.Chat.Character.detailExpand)
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(Color(hex: "607D8B")) .foregroundColor(Color(hex: "607D8B"))
} }

View File

@@ -76,7 +76,7 @@ struct CharacterDetailGalleryView: View {
VStack(spacing: 8) { VStack(spacing: 8) {
// ( % , , ) // ( % , , )
HStack { HStack {
Text("\(viewModel.ownershipPercentage)% 보유중") Text(I18n.Chat.Character.DetailGallery.ownership(viewModel.ownershipPercentage))
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
@@ -91,7 +91,7 @@ struct CharacterDetailGalleryView: View {
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(.white) .foregroundColor(.white)
Text("\(viewModel.totalCount)") Text(I18n.Chat.Character.DetailGallery.totalCount(viewModel.totalCount))
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(.white) .foregroundColor(.white)
} }

View File

@@ -68,7 +68,7 @@ final class NewCharacterListViewModel: ObservableObject {
} else { } else {
self?.isLoading = false self?.isLoading = false
} }
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} receiveValue: { [weak self] response in } receiveValue: { [weak self] response in
@@ -93,7 +93,7 @@ final class NewCharacterListViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
if isLoadMore { if isLoadMore {
@@ -108,7 +108,7 @@ final class NewCharacterListViewModel: ObservableObject {
} else { } else {
self.isLoading = false self.isLoading = false
} }
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }

View File

@@ -17,18 +17,18 @@ struct NewCharacterListView: View {
Group { BaseView(isLoading: $viewModel.isLoading) { Group { BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 8) { VStack(spacing: 8) {
// Toolbar // Toolbar
DetailNavigationBar(title: String(localized: "신규 캐릭터 전체보기")) DetailNavigationBar(title: I18n.Chat.Character.NewList.title)
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
// n // n
HStack(spacing: 0) { HStack(spacing: 0) {
Text("전체") Text(I18n.Chat.Character.NewList.totalPrefix)
.appFont(size: 12, weight: .regular) .appFont(size: 12, weight: .regular)
.foregroundColor(Color(hex: "e2e2e2")) .foregroundColor(Color(hex: "e2e2e2"))
Text(" \(viewModel.totalCount)") Text(" \(viewModel.totalCount)")
.appFont(size: 12, weight: .regular) .appFont(size: 12, weight: .regular)
.foregroundColor(Color(hex: "ff5c49")) .foregroundColor(Color(hex: "ff5c49"))
Text("") Text(I18n.Chat.Character.NewList.countUnit)
.appFont(size: 12, weight: .regular) .appFont(size: 12, weight: .regular)
.foregroundColor(Color(hex: "e2e2e2")) .foregroundColor(Color(hex: "e2e2e2"))
Spacer() Spacer()

View File

@@ -14,8 +14,8 @@ struct RecentCharacterSectionView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 0) { HStack(spacing: 4) {
Text("최근 대화한 캐릭터 ") Text(I18n.Chat.Character.recentSectionTitle)
.appFont(size: 20, weight: .bold) .appFont(size: 20, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)

View File

@@ -168,7 +168,7 @@ struct ChatTabView: View {
isShowAuthView = false isShowAuthView = false
} }
.onError { _ in .onError { _ in
AppState.shared.errorMessage = "본인인증 중 오류가 발생했습니다." AppState.shared.errorMessage = I18n.Chat.Auth.authenticationError
AppState.shared.isShowErrorPopup = true AppState.shared.isShowErrorPopup = true
isShowAuthView = false isShowAuthView = false
} }
@@ -190,15 +190,14 @@ struct ChatTabView: View {
if isShowAuthConfirmView { if isShowAuthConfirmView {
SodaDialog( SodaDialog(
title: "본인인증", title: I18n.Chat.Auth.dialogTitle,
desc: "보이스온의 오픈월드 캐릭터톡은\n청소년 보호를 위해 본인인증한\n성인만 이용이 가능합니다.\n" + desc: I18n.Chat.Auth.dialogDescription,
"캐릭터톡 서비스를 이용하시려면\n본인인증을 하고 이용해주세요.", confirmButtonTitle: I18n.Chat.Auth.goToVerification,
confirmButtonTitle: "본인인증 하러가기",
confirmButtonAction: { confirmButtonAction: {
isShowAuthConfirmView = false isShowAuthConfirmView = false
isShowAuthView = true isShowAuthView = true
}, },
cancelButtonTitle: "취소", cancelButtonTitle: I18n.Common.cancel,
cancelButtonAction: { cancelButtonAction: {
isShowAuthConfirmView = false isShowAuthConfirmView = false
pendingAction = nil pendingAction = nil

View File

@@ -58,7 +58,7 @@ struct OriginalWorkDetailHeaderView: View {
} }
if item.isAdult { if item.isAdult {
Text("19+") Text(I18n.Chat.Original.adultBadge)
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "ff5c49")) .foregroundColor(Color(hex: "ff5c49"))
.padding(.horizontal, 7) .padding(.horizontal, 7)

View File

@@ -151,7 +151,7 @@ struct OriginalWorkInfoView: View {
ZStack { ZStack {
VStack(spacing: 16) { VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("작품 소개") Text(I18n.Chat.Original.workIntroductionTitle)
.appFont(size: 16, weight: .bold) .appFont(size: 16, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
@@ -170,7 +170,7 @@ struct OriginalWorkInfoView: View {
.cornerRadius(16) .cornerRadius(16)
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("원작 보러 가기") Text(I18n.Chat.Original.viewOriginalLinksTitle)
.appFont(size: 16, weight: .bold) .appFont(size: 16, weight: .bold)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
@@ -197,26 +197,26 @@ struct OriginalWorkInfoView: View {
.cornerRadius(16) .cornerRadius(16)
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("상세 정보") Text(I18n.Chat.Original.detailInfoTitle)
.appFont(size: 16, weight: .bold) .appFont(size: 16, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
HStack(spacing: 16) { HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
if let _ = response.writer { if let _ = response.writer {
Text("작가") Text(I18n.Chat.Original.writerLabel)
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
} }
if let _ = response.studio { if let _ = response.studio {
Text("제작사") Text(I18n.Chat.Original.studioLabel)
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
} }
if let _ = response.originalWork { if let _ = response.originalWork {
Text("원작") Text(I18n.Chat.Original.originalLabel)
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
} }

View File

@@ -45,7 +45,7 @@ final class OriginalWorkDetailViewModel: ObservableObject {
case .failure(let error): case .failure(let error):
ERROR_LOG(error.localizedDescription) ERROR_LOG(error.localizedDescription)
self?.isLoading = false self?.isLoading = false
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} receiveValue: { [weak self] response in } receiveValue: { [weak self] response in
@@ -61,14 +61,14 @@ final class OriginalWorkDetailViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
self.isLoading = false self.isLoading = false
} }
} catch { } catch {
self.isLoading = false self.isLoading = false
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }

View File

@@ -21,41 +21,48 @@ struct OriginalTabView: View {
let width = (geo.size.width - (horizontalPadding * 2) - totalSpacing) / 3 let width = (geo.size.width - (horizontalPadding * 2) - totalSpacing) / 3
ScrollView(.vertical, showsIndicators: false) { ScrollView(.vertical, showsIndicators: false) {
LazyVGrid( VStack(spacing: 12) {
columns: Array( YandexInlineBannerView(
repeating: GridItem( placement: .chatOriginalTabTop,
.flexible(), horizontalPadding: horizontalPadding
spacing: gridSpacing, )
alignment: .topLeading
LazyVGrid(
columns: Array(
repeating: GridItem(
.flexible(),
spacing: gridSpacing,
alignment: .topLeading
),
count: 3
), ),
count: 3 alignment: .leading,
), spacing: gridSpacing
alignment: .leading, ) {
spacing: gridSpacing ForEach(viewModel.items.indices, id: \.self) { idx in
) { let item = viewModel.items[idx]
ForEach(viewModel.items.indices, id: \.self) { idx in
let item = viewModel.items[idx] OriginalTabItemView(
item: item,
OriginalTabItemView( size: width
item: item, )
size: width .onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) }
) .onTapGesture {
.onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) } AppState.shared
.onTapGesture { .setAppStep(step: .originalWorkDetail(originalId: item.id))
AppState.shared }
.setAppStep(step: .originalWorkDetail(originalId: item.id))
} }
} }
} .padding(.horizontal, horizontalPadding)
.padding(.horizontal, horizontalPadding)
if viewModel.isLoadingMore {
if viewModel.isLoadingMore { HStack {
HStack { Spacer()
Spacer() ProgressView()
ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white))
.progressViewStyle(CircularProgressViewStyle(tint: .white)) .padding(.vertical, 16)
.padding(.vertical, 16) Spacer()
Spacer() }
} }
} }
} }

View File

@@ -67,7 +67,7 @@ final class OriginalWorkViewModel: ObservableObject {
} else { } else {
self?.isLoading = false self?.isLoading = false
} }
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} receiveValue: { [weak self] response in } receiveValue: { [weak self] response in
@@ -92,7 +92,7 @@ final class OriginalWorkViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
if isLoadMore { if isLoadMore {
@@ -107,7 +107,7 @@ final class OriginalWorkViewModel: ObservableObject {
} else { } else {
self.isLoading = false self.isLoading = false
} }
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }

View File

@@ -55,8 +55,17 @@ class ChatRoomRepository {
return talkApi.requestPublisher(.getChatQuotaStatus(roomId: roomId)) return talkApi.requestPublisher(.getChatQuotaStatus(roomId: roomId))
} }
func purchaseChatQuota(roomId: Int) -> AnyPublisher<Response, MoyaError> { func purchaseChatQuota(
return talkApi.requestPublisher(.purchaseChatQuota(roomId: roomId, request: ChatQuotaPurchaseRequest())) 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> { func resetChatRoom(roomId: Int) -> AnyPublisher<Response, MoyaError> {

View File

@@ -51,7 +51,11 @@ struct ChatRoomView: View {
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
Text(viewModel.characterType.rawValue) Text(
viewModel.characterType == .Clone
? I18n.Chat.Character.typeClone
: I18n.Chat.Character.typeCharacter
)
.appFont(size: 10, weight: .bold) .appFont(size: 10, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 4) .padding(.horizontal, 4)
@@ -100,8 +104,8 @@ struct ChatRoomView: View {
Text( Text(
viewModel.characterType == .Character viewModel.characterType == .Character
? "보이스온 AI캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다.\n세계관 속 캐릭터로 대화를 하거나 새로운 인물로 캐릭터와 당신만의 스토리를 만들어보세요.\n※ AI캐릭터톡은 오픈베타 서비스 중이며, 캐릭터의 대화가 어색하거나 불완전할 수 있습니다." ? I18n.Chat.Room.noticeForCharacter
: "AI Clone은 크리에이터의 정보를 기반으로 대화하지만, 모든 정보를 완벽하게 반영하거나 실제 대화와 일치하지 않을 수 있습니다." : I18n.Chat.Room.noticeForClone
) )
.appFont(size: 12, weight: .regular) .appFont(size: 12, weight: .regular)
.foregroundColor(.white) .foregroundColor(.white)
@@ -144,9 +148,17 @@ struct ChatRoomView: View {
} }
if viewModel.showQuotaNoticeView { if viewModel.showQuotaNoticeView {
ChatQuotaNoticeItemView(remainingTime: viewModel.countdownText) { ChatQuotaNoticeItemView(
viewModel.purchaseChatQuota() onSelectAd: {
} viewModel.showRewardedAdForChatQuota()
},
onSelectCan10: {
viewModel.purchaseChatQuota(canOption: .can10)
},
onSelectCan20: {
viewModel.purchaseChatQuota(canOption: .can20)
}
)
.id("quota_\(viewModel.messages.count)") .id("quota_\(viewModel.messages.count)")
.padding(.bottom, 12) .padding(.bottom, 12)
.onAppear { .onAppear {
@@ -186,7 +198,7 @@ struct ChatRoomView: View {
HStack(spacing: 0) { HStack(spacing: 0) {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
if viewModel.messageText.isEmpty { if viewModel.messageText.isEmpty {
Text("메시지를 입력하세요.") Text(I18n.Chat.Room.messagePlaceholder)
.appFont(size: 14, weight: .regular) .appFont(size: 14, weight: .regular)
.foregroundColor(Color(hex: "78909C")) .foregroundColor(Color(hex: "78909C"))
} }
@@ -289,7 +301,7 @@ struct ChatRoomView: View {
ActivityIndicatorView() ActivityIndicatorView()
.frame(width: 100, height: 100) .frame(width: 100, height: 100)
Text("대화 초기화 중...") Text(I18n.Chat.Room.resettingMessage)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -305,9 +317,6 @@ struct ChatRoomView: View {
viewModel.getMemberInfo() viewModel.getMemberInfo()
viewModel.enterRoom(roomId: roomId) viewModel.enterRoom(roomId: roomId)
} }
.onDisappear {
viewModel.stopTimer()
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
} }
} }

View File

@@ -19,7 +19,7 @@ final class ChatRoomViewModel: ObservableObject {
@Published var chatRoomBgImageId: Int = 0 @Published var chatRoomBgImageId: Int = 0
@Published private(set) var characterId: Int64 = 0 @Published private(set) var characterId: Int64 = 0
@Published private(set) var characterProfileUrl: String = "" @Published private(set) var characterProfileUrl: String = ""
@Published private(set) var characterName: String = "Character Name" @Published private(set) var characterName: String = I18n.Chat.Room.defaultCharacterName
@Published private(set) var characterType: CharacterType = .Character @Published private(set) var characterType: CharacterType = .Character
@Published private(set) var chatRoomBgImageUrl: String? = nil @Published private(set) var chatRoomBgImageUrl: String? = nil
@Published private(set) var roomId: Int = 0 { @Published private(set) var roomId: Int = 0 {
@@ -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 showQuotaNoticeView: Bool = false
@Published private(set) var showSendingMessage: Bool = false @Published private(set) var showSendingMessage: Bool = false
@@ -72,8 +72,6 @@ final class ChatRoomViewModel: ObservableObject {
private var hasMoreMessages: Bool = true private var hasMoreMessages: Bool = true
private var nextCursor: Int64? = nil private var nextCursor: Int64? = nil
private var timer: Timer?
// MARK: - Actions // MARK: - Actions
func sendMessage() { func sendMessage() {
guard !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { guard !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
@@ -113,7 +111,7 @@ final class ChatRoomViewModel: ObservableObject {
DEBUG_LOG("finish") DEBUG_LOG("finish")
case .failure(let error): case .failure(let error):
self.showSendingMessage = false // self.showSendingMessage = false //
self.errorMessage = error.localizedDescription self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
ERROR_LOG(error.localizedDescription) ERROR_LOG(error.localizedDescription)
} }
@@ -125,16 +123,15 @@ final class ChatRoomViewModel: ObservableObject {
let decoded = try jsonDecoder.decode(ApiResponse<SendChatMessageResponse>.self, from: responseData) let decoded = try jsonDecoder.decode(ApiResponse<SendChatMessageResponse>.self, from: responseData)
if let data = decoded.data, decoded.success { if let data = decoded.data, decoded.success {
self.messages.append(contentsOf: data.messages) self.messages.append(contentsOf: data.messages)
self.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch) self.updateQuota(totalRemaining: data.totalRemaining)
} else { } else {
self.errorMessage = decoded.message ?? self.errorMessage = decoded.message ?? I18n.Common.commonError
"다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true self.isShowPopup = true
} }
self.showSendingMessage = false // self.showSendingMessage = false //
} catch { } catch {
self.showSendingMessage = false self.showSendingMessage = false
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }
@@ -178,12 +175,12 @@ final class ChatRoomViewModel: ObservableObject {
self?.hasMoreMessages = data.hasMoreMessages self?.hasMoreMessages = data.hasMoreMessages
self?.nextCursor = data.messages.last?.messageId self?.nextCursor = data.messages.last?.messageId
self?.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch) self?.updateQuota(totalRemaining: data.totalRemaining)
} else { } else {
if let message = decoded.message { if let message = decoded.message {
self?.errorMessage = message self?.errorMessage = message
} else { } else {
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
} }
self?.isShowPopup = true self?.isShowPopup = true
@@ -192,7 +189,7 @@ final class ChatRoomViewModel: ObservableObject {
self?.isLoading = false self?.isLoading = false
} catch { } catch {
self?.isLoading = false self?.isLoading = false
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} }
@@ -260,7 +257,7 @@ final class ChatRoomViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self?.errorMessage = message self?.errorMessage = message
} else { } else {
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
} }
self?.isShowPopup = true self?.isShowPopup = true
@@ -269,17 +266,32 @@ final class ChatRoomViewModel: ObservableObject {
self?.isLoading = false self?.isLoading = false
} catch { } catch {
self?.isLoading = false self?.isLoading = false
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} }
.store(in: &subscription) .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 isLoading = true
repository.purchaseChatQuota(roomId: roomId) repository.purchaseChatQuota(roomId: roomId, chargeType: chargeType, canOption: canOption)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { result in .sink { result in
switch result { switch result {
@@ -296,15 +308,17 @@ final class ChatRoomViewModel: ObservableObject {
let decoded = try jsonDecoder.decode(ApiResponse<ChatQuotaStatusResponse>.self, from: responseData) let decoded = try jsonDecoder.decode(ApiResponse<ChatQuotaStatusResponse>.self, from: responseData)
if let data = decoded.data, decoded.success { if let data = decoded.data, decoded.success {
self?.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch) self?.updateQuota(totalRemaining: data.totalRemaining)
let can = UserDefaults.int(forKey: .can) if let canOption {
UserDefaults.set(can - 30, forKey: .can) let can = UserDefaults.int(forKey: .can)
UserDefaults.set(can - canOption.needCan, forKey: .can)
}
} else { } else {
if let message = decoded.message { if let message = decoded.message {
self?.errorMessage = message self?.errorMessage = message
} else { } else {
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
} }
self?.isShowPopup = true self?.isShowPopup = true
@@ -313,7 +327,7 @@ final class ChatRoomViewModel: ObservableObject {
self?.isLoading = false self?.isLoading = false
} catch { } catch {
self?.isLoading = false self?.isLoading = false
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} }
@@ -348,14 +362,14 @@ final class ChatRoomViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self?.errorMessage = message self?.errorMessage = message
} else { } else {
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
} }
self?.isShowPopup = true self?.isShowPopup = true
} }
} catch { } catch {
ERROR_LOG(String(describing: error)) ERROR_LOG(String(describing: error))
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} }
@@ -381,12 +395,12 @@ final class ChatRoomViewModel: ObservableObject {
private func resetData() { private func resetData() {
characterProfileUrl = "" characterProfileUrl = ""
characterName = "Character Name" characterName = I18n.Chat.Room.defaultCharacterName
characterType = .Character characterType = .Character
chatRoomBgImageUrl = nil chatRoomBgImageUrl = nil
roomId = 0 roomId = 0
countdownText = "00:00:00" totalRemaining = 0
showQuotaNoticeView = false showQuotaNoticeView = false
showSendingMessage = false showSendingMessage = false
@@ -422,12 +436,12 @@ final class ChatRoomViewModel: ObservableObject {
let decoded = try jsonDecoder.decode(ApiResponse<ChatQuotaStatusResponse>.self, from: responseData) let decoded = try jsonDecoder.decode(ApiResponse<ChatQuotaStatusResponse>.self, from: responseData)
if let data = decoded.data, decoded.success { if let data = decoded.data, decoded.success {
self?.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch) self?.updateQuota(totalRemaining: data.totalRemaining)
} else { } else {
if let message = decoded.message { if let message = decoded.message {
self?.errorMessage = message self?.errorMessage = message
} else { } else {
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
} }
self?.isShowPopup = true self?.isShowPopup = true
@@ -436,85 +450,25 @@ final class ChatRoomViewModel: ObservableObject {
self?.isLoading = false self?.isLoading = false
} catch { } catch {
self?.isLoading = false self?.isLoading = false
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} }
.store(in: &subscription) .store(in: &subscription)
} }
private func updateQuota(nextRechargeAtEpoch: Int64?) { private func updateQuota(totalRemaining: Int) {
isLoading = true self.totalRemaining = totalRemaining
stopTimer() showQuotaNoticeView = totalRemaining <= 0
prepareRewardedAdIfNeeded(totalRemaining: totalRemaining)
// epoch }
guard let nextRechargeAtEpoch else {
countdownText = "00:00:00" private func prepareRewardedAdIfNeeded(totalRemaining: Int) {
showQuotaNoticeView = false guard totalRemaining <= 1 else { return }
isLoading = false
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? { private func getSavedBackgroundImageId() -> Int? {

View File

@@ -130,7 +130,7 @@ struct AiMessageItemView: View {
.foregroundColor(.button) .foregroundColor(.button)
} }
Text("눌러서 잠금해제") Text(I18n.Chat.Room.unlockImagePrompt)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)
} }

View File

@@ -59,7 +59,7 @@ struct TypingIndicatorItemView: View {
} }
} }
} }
.accessibilityLabel(Text("입력 중")) .accessibilityLabel(Text(I18n.Chat.Room.typingAccessibilityLabel))
} }
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 8) .padding(.vertical, 8)

View File

@@ -9,57 +9,79 @@ import SwiftUI
struct ChatQuotaNoticeItemView: View { struct ChatQuotaNoticeItemView: View {
let remainingTime: String let onSelectAd: () -> Void
let purchase: () -> Void let onSelectCan10: () -> Void
let onSelectCan20: () -> Void
var body: some View { var body: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
VStack(spacing: 8) { Button {
Image("ic_time") onSelectAd()
.resizable() } label: {
.frame(width: 30, height: 30) Text(I18n.Chat.Room.quotaAdAction(chatCount: 5))
Text(remainingTime)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(.white) .foregroundColor(Color(hex: "263238"))
.frame(maxWidth: .infinity)
Text("기다리면 무료 이용이 가능합니다.") .padding(.vertical, 14)
.appFont(size: 18, weight: .bold)
.foregroundColor(.white)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 15) .background(Color(hex: "FEF8E3"))
.background(Color(hex: "EC8280"))
.cornerRadius(10)
HStack(spacing: 4) {
Image("ic_can")
Text("10")
.appFont(size: 24, weight: .bold)
.foregroundColor(Color(hex: "263238"))
Text("(채팅 12개) 바로 대화 시작")
.appFont(size: 24, weight: .bold)
.foregroundColor(Color(hex: "263238"))
.padding(.leading, 4)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color(hex: "B5E7FA"))
.cornerRadius(30) .cornerRadius(30)
.overlay { .overlay {
RoundedRectangle(cornerRadius: 30) RoundedRectangle(cornerRadius: 30)
.stroke(lineWidth: 1) .stroke(lineWidth: 1)
.foregroundColor(Color.button) .foregroundColor(Color(hex: "F7CB50"))
} }
.onTapGesture { .buttonStyle(.plain)
purchase()
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 { #Preview {
ChatQuotaNoticeItemView(remainingTime: "05:59:55") {} ChatQuotaNoticeItemView(
onSelectAd: {},
onSelectCan10: {},
onSelectCan20: {}
)
} }

View File

@@ -7,4 +7,42 @@
struct ChatQuotaPurchaseRequest: Encodable { struct ChatQuotaPurchaseRequest: Encodable {
let container: String = "ios" 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

@@ -26,7 +26,7 @@ struct ChatBgSelectionView: View {
var body: some View { var body: some View {
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 0) { VStack(spacing: 0) {
DetailNavigationBar(title: String(localized: "배경 이미지 선택")) { DetailNavigationBar(title: I18n.Chat.Room.backgroundSelectionTitle) {
isShowing = false isShowing = false
} }
// //
@@ -79,7 +79,7 @@ struct ChatBgSelectionView: View {
} }
if selectedBgImageId == item.id { if selectedBgImageId == item.id {
Text("현재 배경") Text(I18n.Chat.Room.currentBackground)
.appFont(size: 12, weight: .regular) .appFont(size: 12, weight: .regular)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 6) .padding(.horizontal, 6)

View File

@@ -75,14 +75,14 @@ final class ChatBgSelectionViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self?.errorMessage = message self?.errorMessage = message
} else { } else {
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
} }
self?.isShowPopup = true self?.isShowPopup = true
} }
} catch { } catch {
ERROR_LOG(String(describing: error)) ERROR_LOG(String(describing: error))
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self?.errorMessage = I18n.Common.commonError
self?.isShowPopup = true self?.isShowPopup = true
} }
} }

View File

@@ -17,7 +17,7 @@ struct ChatSettingsView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
DetailNavigationBar(title: String(localized: "대화 설정")) { DetailNavigationBar(title: I18n.Chat.Room.settingsTitle) {
isShowing = false isShowing = false
} }
@@ -25,7 +25,7 @@ struct ChatSettingsView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
VStack(spacing: 0) { VStack(spacing: 0) {
Toggle(isOn: $isHideBg) { Toggle(isOn: $isHideBg) {
Text("배경 이미지 끄기") Text(I18n.Chat.Room.hideBackgroundImage)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
} }
@@ -42,7 +42,7 @@ struct ChatSettingsView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { HStack {
Text("배경 이미지 변경") Text(I18n.Chat.Room.changeBackgroundImage)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
.padding(.horizontal, 24) .padding(.horizontal, 24)
@@ -61,16 +61,16 @@ struct ChatSettingsView: View {
HStack(spacing: 0) { HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("대화 초기화") Text(I18n.Chat.Room.resetConversationTitle)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(Color(hex: "B0BEC5")) .foregroundColor(Color(hex: "B0BEC5"))
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
Text("⚠️ ") Text(I18n.Chat.Room.resetWarningPrefix)
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(.white.opacity(0.7)) .foregroundColor(.white.opacity(0.7))
Text("지금까지의 대화가 모두 초기화 되고, 이용자가 새로운 캐릭터가 되어 새롭게 대화를 시작합니다.") Text(I18n.Chat.Room.resetWarningDescription)
.appFont(size: 16, weight: .regular) .appFont(size: 16, weight: .regular)
.foregroundColor(.white.opacity(0.7)) .foregroundColor(.white.opacity(0.7))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)

View File

@@ -13,13 +13,18 @@ struct TalkView: View {
var body: some View { var body: some View {
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
if viewModel.talkRooms.isEmpty { ScrollView(.vertical, showsIndicators: false) {
Text("대화 중인 톡이 없습니다") LazyVStack(spacing: 24) {
.appFont(size: 20, weight: .regular) YandexInlineBannerView(
.foregroundColor(.white) placement: .chatTalkTabTop,
} else { horizontalPadding: 24
ScrollView(.vertical, showsIndicators: false) { )
LazyVStack(spacing: 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 ForEach(0..<viewModel.talkRooms.count, id: \.self) { index in
let item = viewModel.talkRooms[index] let item = viewModel.talkRooms[index]
TalkItemView(item: item) TalkItemView(item: item)
@@ -38,14 +43,15 @@ struct TalkView: View {
} }
} }
} }
if viewModel.isLoadingMore { if viewModel.isLoadingMore {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white)) .progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.vertical, 12) .padding(.vertical, 12)
} }
} }
.padding(.vertical, 24)
} }
.padding(.vertical, 12)
} }
} }
.onAppear { .onAppear {

View File

@@ -61,7 +61,7 @@ final class TalkViewModel: ObservableObject {
if case let .failure(error) = completion { if case let .failure(error) = completion {
ERROR_LOG(error.localizedDescription) ERROR_LOG(error.localizedDescription)
DispatchQueue.main.async { DispatchQueue.main.async {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} else { } else {
@@ -90,16 +90,15 @@ final class TalkViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }
.store(in: &subscription) .store(in: &subscription)
} }
} }

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

@@ -22,7 +22,7 @@ struct ContentAllByThemeView: View {
HStack(spacing: 13.3) { HStack(spacing: 13.3) {
Spacer() Spacer()
Text("최신순") Text(I18n.Content.Sort.newest)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor( .foregroundColor(
Color(hex: "e2e2e2") Color(hex: "e2e2e2")
@@ -34,7 +34,7 @@ struct ContentAllByThemeView: View {
} }
} }
Text("높은 가격순") Text(I18n.Content.Sort.priceHigh)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor( .foregroundColor(
Color(hex: "e2e2e2") Color(hex: "e2e2e2")
@@ -46,7 +46,7 @@ struct ContentAllByThemeView: View {
} }
} }
Text("낮은 가격순") Text(I18n.Content.Sort.priceLow)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor( .foregroundColor(
Color(hex: "e2e2e2") Color(hex: "e2e2e2")
@@ -64,7 +64,7 @@ struct ContentAllByThemeView: View {
.padding(.top, 13.3) .padding(.top, 13.3)
HStack(spacing: 0) { HStack(spacing: 0) {
Text("전체") Text(I18n.Content.Count.totalPrefix)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2")) .foregroundColor(Color(hex: "e2e2e2"))
@@ -73,7 +73,7 @@ struct ContentAllByThemeView: View {
.foregroundColor(Color(hex: "ff5c49")) .foregroundColor(Color(hex: "ff5c49"))
.padding(.leading, 8) .padding(.leading, 8)
Text("") Text(I18n.Content.Count.countUnit)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2")) .foregroundColor(Color(hex: "e2e2e2"))
.padding(.leading, 2) .padding(.leading, 2)

View File

@@ -78,13 +78,13 @@ final class ContentAllByThemeViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }

View File

@@ -19,7 +19,11 @@ struct ContentAllView: View {
Group { Group {
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 0) { VStack(spacing: 0) {
DetailNavigationBar(title: isFree ? String(localized: "무료 콘텐츠 전체") : isPointAvailableOnly ? String(localized: "포인트 대여 전체") : String(localized: "콘텐츠 전체")) DetailNavigationBar(
title: isFree ?
I18n.Content.All.freeTitle :
isPointAvailableOnly ? I18n.Content.All.pointRentalTitle : I18n.Content.All.title
)
if !viewModel.themeList.isEmpty { if !viewModel.themeList.isEmpty {
ContentMainContentThemeView( ContentMainContentThemeView(
@@ -32,7 +36,7 @@ struct ContentAllView: View {
HStack(spacing: 12) { HStack(spacing: 12) {
Spacer() Spacer()
Text("최신순") Text(I18n.Content.Sort.newest)
.appFont(size: 16, weight: .medium) .appFont(size: 16, weight: .medium)
.foregroundColor( .foregroundColor(
Color(hex: "e2e2e2") Color(hex: "e2e2e2")
@@ -44,7 +48,7 @@ struct ContentAllView: View {
} }
} }
Text("인기순") Text(I18n.Content.Sort.popularity)
.appFont(size: 16, weight: .medium) .appFont(size: 16, weight: .medium)
.foregroundColor( .foregroundColor(
Color(hex: "e2e2e2") Color(hex: "e2e2e2")

View File

@@ -41,7 +41,7 @@ struct ContentNewAllItemView: View {
.appFont(size: 8.5, weight: .medium) .appFont(size: 8.5, weight: .medium)
.foregroundColor(Color.white) .foregroundColor(Color.white)
} else { } else {
Text("무료") Text(I18n.CreateContent.free)
.appFont(size: 8.5, weight: .medium) .appFont(size: 8.5, weight: .medium)
.foregroundColor(Color.white) .foregroundColor(Color.white)
} }

View File

@@ -18,9 +18,9 @@ struct ContentNewAllView: View {
Group { Group {
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
VStack(alignment: .leading, spacing: 13.3) { VStack(alignment: .leading, spacing: 13.3) {
DetailNavigationBar(title: isFree ? "최신 무료 콘텐츠" : "최신 콘텐츠") DetailNavigationBar(title: isFree ? I18n.Content.New.freeTitle : I18n.Content.New.title)
Text("※ 최근 2주간 등록된 새로운 콘텐츠 입니다.") Text(I18n.Content.New.recentTwoWeeksNotice)
.appFont(size: 14.7, weight: .medium) .appFont(size: 14.7, weight: .medium)
.foregroundColor(.graybb) .foregroundColor(.graybb)
.padding(.horizontal, 13.3) .padding(.horizontal, 13.3)
@@ -37,7 +37,7 @@ struct ContentNewAllView: View {
) )
HStack(spacing: 0) { HStack(spacing: 0) {
Text("전체") Text(I18n.Content.Count.totalPrefix)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2")) .foregroundColor(Color(hex: "e2e2e2"))
@@ -46,7 +46,7 @@ struct ContentNewAllView: View {
.foregroundColor(Color(hex: "ff5c49")) .foregroundColor(Color(hex: "ff5c49"))
.padding(.leading, 8) .padding(.leading, 8)
Text("") Text(I18n.Content.Count.countUnit)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2")) .foregroundColor(Color(hex: "e2e2e2"))
.padding(.leading, 2) .padding(.leading, 2)

View File

@@ -17,14 +17,14 @@ struct ContentRankingAllView: View {
Group { Group {
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 0) { VStack(spacing: 0) {
DetailNavigationBar(title: "인기 콘텐츠") DetailNavigationBar(title: I18n.Content.Ranking.title)
VStack(spacing: 8) { VStack(spacing: 8) {
Text("\(viewModel.dateString)") Text("\(viewModel.dateString)")
.appFont(size: 14.7, weight: .bold) .appFont(size: 14.7, weight: .bold)
.foregroundColor(Color(hex: "eeeeee")) .foregroundColor(Color(hex: "eeeeee"))
Text("※ 인기 콘텐츠의 순위는 매주 업데이트됩니다.") Text(I18n.Content.Ranking.weeklyUpdateNotice)
.appFont(size: 13.3, weight: .light) .appFont(size: 13.3, weight: .light)
.foregroundColor(Color(hex: "bbbbbb")) .foregroundColor(Color(hex: "bbbbbb"))
} }
@@ -82,7 +82,7 @@ struct ContentRankingAllView: View {
.cornerRadius(2.6) .cornerRadius(2.6)
if item.isPointAvailable { if item.isPointAvailable {
Text("포인트") Text(I18n.Common.points)
.appFont(size: 8, weight: .medium) .appFont(size: 8, weight: .medium)
.foregroundColor(.white) .foregroundColor(.white)
.padding(2.6) .padding(2.6)
@@ -116,7 +116,7 @@ struct ContentRankingAllView: View {
.foregroundColor(Color(hex: "909090")) .foregroundColor(Color(hex: "909090"))
} }
} else { } else {
Text("무료") Text(I18n.CreateContent.free)
.appFont(size: 12, weight: .medium) .appFont(size: 12, weight: .medium)
.foregroundColor(Color(hex: "ffffff")) .foregroundColor(Color(hex: "ffffff"))
.padding(.horizontal, 5.3) .padding(.horizontal, 5.3)

View File

@@ -71,13 +71,13 @@ final class ContentRankingAllViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
self.isLoading = false self.isLoading = false
} }
@@ -109,13 +109,13 @@ final class ContentRankingAllViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }

View File

@@ -49,11 +49,11 @@ struct ContentListCategoryView: View {
ContentListCategoryView( ContentListCategoryView(
categoryList: [ categoryList: [
GetCategoryListResponse(categoryId: 0, category: "전체"), GetCategoryListResponse(categoryId: 0, category: I18n.Category.all),
GetCategoryListResponse(categoryId: 1, category: "test"), GetCategoryListResponse(categoryId: 1, category: "test"),
GetCategoryListResponse(categoryId: 0, category: "test2") GetCategoryListResponse(categoryId: 0, category: "test2")
], ],
selectCategory: { _ in }, selectCategory: { _ in },
selectedCategory: .constant("전체") selectedCategory: .constant(I18n.Category.all)
) )
} }

View File

@@ -29,7 +29,7 @@ struct ContentListItemView: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) { HStack(spacing: 8) {
if item.isScheduledToOpen { if item.isScheduledToOpen {
Text("오픈예정") Text(I18n.Common.openScheduled)
.appFont(size: 11, weight: .medium) .appFont(size: 11, weight: .medium)
.foregroundColor(Color(hex: "3bb9f1")) .foregroundColor(Color(hex: "3bb9f1"))
.padding(2.6) .padding(2.6)
@@ -52,7 +52,7 @@ struct ContentListItemView: View {
.cornerRadius(2.6) .cornerRadius(2.6)
if item.isPointAvailable { if item.isPointAvailable {
Text("포인트") Text(I18n.Common.points)
.appFont(size: 11, weight: .medium) .appFont(size: 11, weight: .medium)
.foregroundColor(.white) .foregroundColor(.white)
.padding(2.6) .padding(2.6)
@@ -98,7 +98,7 @@ struct ContentListItemView: View {
Spacer() Spacer()
if item.isOwned { if item.isOwned {
Text("소장중") Text(I18n.Content.Status.owned)
.appFont(size: 14, weight: .medium) .appFont(size: 14, weight: .medium)
.foregroundColor(Color.gray11) .foregroundColor(Color.gray11)
.padding(.horizontal, 5.3) .padding(.horizontal, 5.3)
@@ -106,7 +106,7 @@ struct ContentListItemView: View {
.background(Color(hex: "b1ef2c")) .background(Color(hex: "b1ef2c"))
.cornerRadius(2.6) .cornerRadius(2.6)
} else if item.isRented { } else if item.isRented {
Text("대여중") Text(I18n.Content.Status.rented)
.appFont(size: 14, weight: .medium) .appFont(size: 14, weight: .medium)
.foregroundColor(Color.white) .foregroundColor(Color.white)
.padding(.horizontal, 5.3) .padding(.horizontal, 5.3)
@@ -114,7 +114,7 @@ struct ContentListItemView: View {
.background(Color(hex: "660fd4")) .background(Color(hex: "660fd4"))
.cornerRadius(2.6) .cornerRadius(2.6)
} else if item.isSoldOut { } else if item.isSoldOut {
Text("Sold Out") Text(I18n.Content.Status.soldOut)
.appFont(size: 14, weight: .medium) .appFont(size: 14, weight: .medium)
.foregroundColor(Color.grayd2) .foregroundColor(Color.grayd2)
.padding(.horizontal, 5.3) .padding(.horizontal, 5.3)
@@ -135,7 +135,7 @@ struct ContentListItemView: View {
.foregroundColor(.white) .foregroundColor(.white)
} }
} else { } else {
Text("무료") Text(I18n.CreateContent.free)
.appFont(size: 14, weight: .medium) .appFont(size: 14, weight: .medium)
.foregroundColor(.white) .foregroundColor(.white)
} }

View File

@@ -25,7 +25,7 @@ struct ContentListView: View {
.resizable() .resizable()
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
Text("콘텐츠 전체보기") Text(I18n.Content.List.title)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: "eeeeee")) .foregroundColor(Color(hex: "eeeeee"))
} }
@@ -46,7 +46,7 @@ struct ContentListView: View {
} }
if userId == UserDefaults.int(forKey: .userId) { if userId == UserDefaults.int(forKey: .userId) {
Text("새로운 콘텐츠 등록하기") Text(I18n.Content.List.createNewContentAction)
.appFont(size: 15, weight: .bold) .appFont(size: 15, weight: .bold)
.foregroundColor(Color(hex: "eeeeee")) .foregroundColor(Color(hex: "eeeeee"))
.padding(.vertical, 17) .padding(.vertical, 17)
@@ -61,7 +61,7 @@ struct ContentListView: View {
HStack(spacing: 13.3) { HStack(spacing: 13.3) {
Spacer() Spacer()
Text("최신순") Text(I18n.Content.Sort.newest)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor( .foregroundColor(
Color(hex: "e2e2e2") Color(hex: "e2e2e2")
@@ -73,7 +73,7 @@ struct ContentListView: View {
} }
} }
Text("높은 가격순") Text(I18n.Content.Sort.priceHigh)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor( .foregroundColor(
Color(hex: "e2e2e2") Color(hex: "e2e2e2")
@@ -85,7 +85,7 @@ struct ContentListView: View {
} }
} }
Text("낮은 가격순") Text(I18n.Content.Sort.priceLow)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor( .foregroundColor(
Color(hex: "e2e2e2") Color(hex: "e2e2e2")
@@ -103,7 +103,7 @@ struct ContentListView: View {
.padding(.top, 13.3) .padding(.top, 13.3)
HStack(spacing: 0) { HStack(spacing: 0) {
Text("전체") Text(I18n.Content.Count.totalPrefix)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2")) .foregroundColor(Color(hex: "e2e2e2"))
@@ -112,7 +112,7 @@ struct ContentListView: View {
.foregroundColor(Color(hex: "ff5c49")) .foregroundColor(Color(hex: "ff5c49"))
.padding(.leading, 8) .padding(.leading, 8)
Text("") Text(I18n.Content.Count.countUnit)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2")) .foregroundColor(Color(hex: "e2e2e2"))
.padding(.leading, 2) .padding(.leading, 2)

View File

@@ -200,7 +200,7 @@ extension ContentPlayManager {
} }
private func showError() { private func showError() {
self.errorMessage = "오류가 발생했습니다. 다시 시도해 주세요." self.errorMessage = I18n.Content.Playback.playFailed
self.isShowPopup = true self.isShowPopup = true
self.resetAudioData() self.resetAudioData()
} }

View File

@@ -28,7 +28,7 @@ struct ContentCreateSelectThemeView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
Text("테마 선택") Text(I18n.CreateContent.selectTheme)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(.white) .foregroundColor(.white)

View File

@@ -44,13 +44,13 @@ final class ContentCreateSelectThemeViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }

View File

@@ -9,6 +9,7 @@ import SwiftUI
import Kingfisher import Kingfisher
struct ContentCreateView: View { struct ContentCreateView: View {
@Environment(\.locale) private var locale
@StateObject var keyboardHandler = KeyboardHandler() @StateObject var keyboardHandler = KeyboardHandler()
@StateObject private var viewModel = ContentCreateViewModel() @StateObject private var viewModel = ContentCreateViewModel()
@@ -36,11 +37,11 @@ struct ContentCreateView: View {
GeometryReader { proxy in GeometryReader { proxy in
ZStack { ZStack {
VStack(spacing: 0) { VStack(spacing: 0) {
DetailNavigationBar(title: String(localized: "콘텐츠 등록")) DetailNavigationBar(title: I18n.CreateContent.registerTitle)
ScrollView(.vertical, showsIndicators: false) { ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) { VStack(spacing: 0) {
Text("썸네일") Text(I18n.CreateContent.thumbnail)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -73,7 +74,7 @@ struct ContentCreateView: View {
.frame(alignment: .bottomTrailing) .frame(alignment: .bottomTrailing)
.onTapGesture { isShowPhotoPicker = true } .onTapGesture { isShowPhotoPicker = true }
Text("등록") Text(I18n.CreateContent.registerSectionTitle)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -103,12 +104,12 @@ struct ContentCreateView: View {
.padding(.top, 26.7) .padding(.top, 26.7)
VStack(spacing: 0) { VStack(spacing: 0) {
Text("제목") Text(I18n.CreateContent.titleLabel)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
TextField("제목을 입력하세요", text: $viewModel.title) TextField(I18n.CreateContent.titlePlaceholder, text: $viewModel.title)
.autocapitalization(.none) .autocapitalization(.none)
.disableAutocorrection(true) .disableAutocorrection(true)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
@@ -121,16 +122,16 @@ struct ContentCreateView: View {
.padding(.top, 13.3) .padding(.top, 13.3)
HStack(spacing: 0) { HStack(spacing: 0) {
Text("내용") Text(I18n.CreateContent.contentLabel)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
Spacer() Spacer()
Text("\(viewModel.detail.count)") Text(I18n.CreateContent.characterCount(viewModel.detail.count))
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.mainRed) .foregroundColor(Color.mainRed)
Text(" / 최대 500자") Text(I18n.CreateContent.max500CharactersSuffix)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.gray77) .foregroundColor(Color.gray77)
} }
@@ -146,7 +147,7 @@ struct ContentCreateView: View {
.cornerRadius(6.7) .cornerRadius(6.7)
.padding(.top, 13.3) .padding(.top, 13.3)
Text("테마") Text(I18n.CreateContent.themeLabel)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -188,13 +189,13 @@ struct ContentCreateView: View {
hideKeyboard() hideKeyboard()
} }
Text("태그") Text(I18n.CreateContent.tagLabel)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 26.7) .padding(.top, 26.7)
TextField("예: #연애 #커버곡", text: $viewModel.hashtags) TextField(I18n.CreateContent.tagPlaceholderExample, text: $viewModel.hashtags)
.autocapitalization(.none) .autocapitalization(.none)
.disableAutocorrection(true) .disableAutocorrection(true)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
@@ -215,7 +216,7 @@ struct ContentCreateView: View {
.padding(.top, 26.7) .padding(.top, 26.7)
VStack(spacing: 13.3) { VStack(spacing: 13.3) {
Text("가격 설정") Text(I18n.CreateContent.priceSettingsTitle)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -236,7 +237,7 @@ struct ContentCreateView: View {
if !viewModel.isFree { if !viewModel.isFree {
VStack(spacing: 13.3) { VStack(spacing: 13.3) {
Text("소장 설정") Text(I18n.CreateContent.ownershipSettingsTitle)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -264,13 +265,13 @@ struct ContentCreateView: View {
.padding(.top, 13.3) .padding(.top, 13.3)
VStack(spacing: 0) { VStack(spacing: 0) {
Text(viewModel.purchaseOption == .RENT_ONLY ? "대여 가격" : "소장 가격") Text(viewModel.purchaseOption == .RENT_ONLY ? I18n.CreateContent.rentPriceLabel : I18n.CreateContent.purchasePriceLabel)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayd2) .foregroundColor(Color.grayd2)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 0) { HStack(spacing: 0) {
TextField("가격을 입력하세요(5캔 이상)", text: $viewModel.priceString) TextField(I18n.CreateContent.priceInputPlaceholder, text: $viewModel.priceString)
.autocapitalization(.none) .autocapitalization(.none)
.disableAutocorrection(true) .disableAutocorrection(true)
.appFont(size: 14.7, weight: .bold) .appFont(size: 14.7, weight: .bold)
@@ -281,7 +282,7 @@ struct ContentCreateView: View {
Spacer() Spacer()
Text("") Text(I18n.CreateContent.canUnit)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.gray77) .foregroundColor(Color.gray77)
} }
@@ -296,18 +297,18 @@ struct ContentCreateView: View {
.frame(height: 1) .frame(height: 1)
.padding(.top, 11) .padding(.top, 11)
Text("※ 이용기간 대여 (5일) | 소장 (서비스종료시까지)") Text(I18n.CreateContent.rentalPeriodNotice)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.gray77) .foregroundColor(Color.gray77)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 13.3) .padding(.top, 13.3)
Text("※ 대여가격은 소장가격의 70%로 자동 반영") Text(I18n.CreateContent.rentalPriceAutoNotice)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.gray77) .foregroundColor(Color.gray77)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
Text("※ 콘텐츠의 최소금액은 5캔 입니다") Text(I18n.CreateContent.minimumPriceNotice)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.gray77) .foregroundColor(Color.gray77)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -316,7 +317,7 @@ struct ContentCreateView: View {
if viewModel.price > 0 && viewModel.purchaseOption != .RENT_ONLY { if viewModel.price > 0 && viewModel.purchaseOption != .RENT_ONLY {
VStack(spacing: 13.3) { VStack(spacing: 13.3) {
Text("한정판 설정") Text(I18n.CreateContent.limitedEditionSettingsTitle)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -336,7 +337,7 @@ struct ContentCreateView: View {
} }
if viewModel.isLimited { if viewModel.isLimited {
TextField("한정판 개수를 입력하세요", text: $viewModel.limitedString) TextField(I18n.CreateContent.limitedCountPlaceholder, text: $viewModel.limitedString)
.autocapitalization(.none) .autocapitalization(.none)
.disableAutocorrection(true) .disableAutocorrection(true)
.appFont(size: 14.7, weight: .bold) .appFont(size: 14.7, weight: .bold)
@@ -353,7 +354,7 @@ struct ContentCreateView: View {
} }
VStack(spacing: 13.3) { VStack(spacing: 13.3) {
Text("포인트 사용") Text(I18n.CreateContent.pointUsageTitle)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -375,7 +376,7 @@ struct ContentCreateView: View {
.padding(.top, 26.7) .padding(.top, 26.7)
VStack(spacing: 13.3) { VStack(spacing: 13.3) {
Text("미리듣기") Text(I18n.CreateContent.previewTitle)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -398,19 +399,19 @@ struct ContentCreateView: View {
if viewModel.isGeneratePreview { if viewModel.isGeneratePreview {
VStack(spacing: 10) { VStack(spacing: 10) {
Text("미리듣기 시간 설정") Text(I18n.CreateContent.previewTimeSettingsTitle)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
Text("미리듣기 시간을 직접 설정하지 않으면 콘텐츠 앞부분 15초가 자동으로 설정됩니다. 미리듣기의 시간제한은 없습니다.") Text(I18n.CreateContent.previewTimeGuide)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.gray77) .foregroundColor(Color.gray77)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 13.3) { HStack(spacing: 13.3) {
VStack(spacing: 5.3) { VStack(spacing: 5.3) {
Text("시작 시간") Text(I18n.CreateContent.previewStartTimeLabel)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayd2) .foregroundColor(Color.grayd2)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -429,7 +430,7 @@ struct ContentCreateView: View {
} }
VStack(spacing: 5.3) { VStack(spacing: 5.3) {
Text("종료 시간") Text(I18n.CreateContent.previewEndTimeLabel)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayd2) .foregroundColor(Color.grayd2)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -458,7 +459,7 @@ struct ContentCreateView: View {
if shouldShowAdultSetting { if shouldShowAdultSetting {
VStack(spacing: 13.3) { VStack(spacing: 13.3) {
Text("연령 제한") Text(I18n.CreateContent.ageRestrictionTitle)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -477,7 +478,7 @@ struct ContentCreateView: View {
} }
} }
Text("성인콘텐츠를 전체관람가로 등록할 시 발생하는 법적 책임은 회사와 상관없이 콘텐츠를 등록한 본인에게 있습니다.\n콘텐츠 내용은 물론 제목도 19금 여부를 체크해 주시기 바랍니다.") Text(I18n.CreateContent.adultLegalNotice)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.mainRed3) .foregroundColor(Color.mainRed3)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -488,7 +489,7 @@ struct ContentCreateView: View {
} }
VStack(spacing: 13.3) { VStack(spacing: 13.3) {
Text("댓글 가능 여부") Text(I18n.CreateContent.commentAvailabilityTitle)
.appFont(size: 16.7, weight: .bold) .appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -533,7 +534,7 @@ struct ContentCreateView: View {
if viewModel.isActiveReservation { if viewModel.isActiveReservation {
HStack(spacing: 13.3) { HStack(spacing: 13.3) {
VStack(alignment: .leading, spacing: 6.7) { VStack(alignment: .leading, spacing: 6.7) {
Text("예약 날짜") Text(I18n.CreateContent.reservationDateLabel)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
@@ -541,7 +542,7 @@ struct ContentCreateView: View {
hideKeyboard() hideKeyboard()
self.isShowSelectDateView = true self.isShowSelectDateView = true
}) { }) {
Text(viewModel.releaseDateString) Text(viewModel.releaseDate.convertDateFormat(dateFormat: "yyyy.MM.dd", locale: locale))
.appFont(size: 14.7, weight: .medium) .appFont(size: 14.7, weight: .medium)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -554,7 +555,7 @@ struct ContentCreateView: View {
} }
VStack(alignment: .leading, spacing: 6.7) { VStack(alignment: .leading, spacing: 6.7) {
Text("예약 시간") Text(I18n.CreateContent.reservationTimeLabel)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
@@ -562,7 +563,7 @@ struct ContentCreateView: View {
hideKeyboard() hideKeyboard()
self.isShowSelectTimeView = true self.isShowSelectTimeView = true
}) { }) {
Text(viewModel.releaseTimeString) Text(viewModel.releaseTime.convertDateFormat(dateFormat: "a hh:mm", locale: locale))
.appFont(size: 14.7, weight: .medium) .appFont(size: 14.7, weight: .medium)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -586,7 +587,7 @@ struct ContentCreateView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
Text("등록") Text(I18n.CreateContent.registerButton)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color.white) .foregroundColor(Color.white)
.frame(height: 50) .frame(height: 50)

View File

@@ -154,7 +154,7 @@ final class ContentCreateViewModel: ObservableObject {
mimeType: "image/*") mimeType: "image/*")
) )
} else { } else {
errorMessage = "커버이미지를 업로드 하지 못했습니다.\n다시 선택해 주세요" errorMessage = I18n.CreateContent.coverImageUploadFailed
isShowPopup = true isShowPopup = true
isLoading = false isLoading = false
return return
@@ -176,19 +176,19 @@ final class ContentCreateViewModel: ObservableObject {
) )
) )
} else { } else {
errorMessage = "콘텐츠 파일을 업로드 하지 못했습니다.\n다시 선택해 주세요" errorMessage = I18n.CreateContent.contentFileUploadFailed
isShowPopup = true isShowPopup = true
isLoading = false isLoading = false
return return
} }
} else { } else {
errorMessage = "콘텐츠 파일을 업로드 하지 못했습니다.\n다시 선택해 주세요" errorMessage = I18n.CreateContent.contentFileUploadFailed
isShowPopup = true isShowPopup = true
isLoading = false isLoading = false
return return
} }
} else { } else {
errorMessage = "콘텐츠 파일을 업로드 하지 못했습니다.\n다시 선택해 주세요" errorMessage = I18n.CreateContent.contentFileUploadFailed
isShowPopup = true isShowPopup = true
isLoading = false isLoading = false
return return
@@ -219,19 +219,19 @@ final class ContentCreateViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }
.store(in: &subscription) .store(in: &subscription)
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
self.isLoading = false self.isLoading = false
} }
@@ -240,37 +240,37 @@ final class ContentCreateViewModel: ObservableObject {
private func validateData() -> Bool { private func validateData() -> Bool {
if title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
errorMessage = "제목을 입력해 주세요." errorMessage = I18n.CreateContent.titleRequired
isShowPopup = true isShowPopup = true
return false return false
} }
if detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || detail.count < 5 { if detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || detail.count < 5 {
errorMessage = "내용을 5자 이상 입력해 주세요." errorMessage = I18n.CreateContent.detailMinLengthRequired
isShowPopup = true isShowPopup = true
return false return false
} }
if theme == nil { if theme == nil {
errorMessage = "테마를 선택해 주세요." errorMessage = I18n.CreateContent.themeRequired
isShowPopup = true isShowPopup = true
return false return false
} }
if coverImage == nil { if coverImage == nil {
errorMessage = "커버이미지를 선택해 주세요." errorMessage = I18n.CreateContent.coverImageRequired
isShowPopup = true isShowPopup = true
return false return false
} }
if selectedFileUrl == nil { if selectedFileUrl == nil {
errorMessage = "오디오 콘텐츠를 선택해 주세요." errorMessage = I18n.CreateContent.audioContentRequired
isShowPopup = true isShowPopup = true
return false return false
} }
if !isFree && price < 5 { if !isFree && price < 5 {
errorMessage = "콘텐츠의 최소금액은 5캔 입니다." errorMessage = I18n.CreateContent.minimumPriceRequired
isShowPopup = true isShowPopup = true
return false return false
} }
@@ -278,14 +278,14 @@ final class ContentCreateViewModel: ObservableObject {
if previewStartTime.count > 0 && previewEndTime.count > 0 { if previewStartTime.count > 0 && previewEndTime.count > 0 {
let startTimeArray = previewStartTime.split(separator: ":") let startTimeArray = previewStartTime.split(separator: ":")
if startTimeArray.count != 3 { if startTimeArray.count != 3 {
errorMessage = "미리 듣기 시간 형식은 00:30:00 과 같아야 합니다" errorMessage = I18n.CreateContent.previewTimeFormatInvalid
isShowPopup = true isShowPopup = true
return false return false
} }
for time in startTimeArray { for time in startTimeArray {
if time.count != 2 { if time.count != 2 {
errorMessage = "미리 듣기 시간 형식은 00:30:00 과 같아야 합니다" errorMessage = I18n.CreateContent.previewTimeFormatInvalid
isShowPopup = true isShowPopup = true
return false return false
} }
@@ -293,14 +293,14 @@ final class ContentCreateViewModel: ObservableObject {
let endTimeArray = previewStartTime.split(separator: ":") let endTimeArray = previewStartTime.split(separator: ":")
if endTimeArray.count != 3 { if endTimeArray.count != 3 {
errorMessage = "미리 듣기 시간 형식은 00:30:00 과 같아야 합니다" errorMessage = I18n.CreateContent.previewTimeFormatInvalid
isShowPopup = true isShowPopup = true
return false return false
} }
for time in endTimeArray { for time in endTimeArray {
if time.count != 2 { if time.count != 2 {
errorMessage = "미리 듣기 시간 형식은 00:30:00 과 같아야 합니다" errorMessage = I18n.CreateContent.previewTimeFormatInvalid
isShowPopup = true isShowPopup = true
return false return false
} }
@@ -308,13 +308,13 @@ final class ContentCreateViewModel: ObservableObject {
let timeDifference = timeDifference(startTime: previewStartTime, endTime: previewEndTime) let timeDifference = timeDifference(startTime: previewStartTime, endTime: previewEndTime)
if timeDifference < 15.0 { if timeDifference < 15.0 {
errorMessage = "미리 듣기의 최소 시간은 15초 입니다" errorMessage = I18n.CreateContent.previewMinimumDurationError
isShowPopup = true isShowPopup = true
return false return false
} }
} else { } else {
if previewStartTime.count > 0 || previewEndTime.count > 0 { if previewStartTime.count > 0 || previewEndTime.count > 0 {
errorMessage = "미리 듣기 시작 시간과 종료 시간 둘 다 입력을 하거나 둘 다 입력 하지 않아야 합니다." errorMessage = I18n.CreateContent.previewStartEndBothOrNone
isShowPopup = true isShowPopup = true
return false return false
} }

View File

@@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
struct QuarterTimePickerView: View { struct QuarterTimePickerView: View {
@Environment(\.locale) private var locale
@Binding var selectedTime: Date @Binding var selectedTime: Date
@Binding var isShowing: Bool @Binding var isShowing: Bool
@@ -28,7 +29,7 @@ struct QuarterTimePickerView: View {
) )
.datePickerStyle(WheelDatePickerStyle()) .datePickerStyle(WheelDatePickerStyle())
.labelsHidden() .labelsHidden()
.environment(\.locale, Locale.init(identifier: "ko")) .environment(\.locale, locale)
.frame(width: proxy.size.width - 53.4) .frame(width: proxy.size.width - 53.4)
.onAppear { .onAppear {
UIDatePicker.appearance().minuteInterval = 15 UIDatePicker.appearance().minuteInterval = 15
@@ -38,7 +39,7 @@ struct QuarterTimePickerView: View {
} }
Button(action: { self.isShowing = false }) { Button(action: { self.isShowing = false }) {
Text("확인") Text(I18n.Common.confirm)
.appFont(size: 16) .appFont(size: 16)
.foregroundColor(Color(hex: "eeeeee")) .foregroundColor(Color(hex: "eeeeee"))
.padding(.vertical, 10) .padding(.vertical, 10)

View File

@@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
struct SelectDatePicker: View { struct SelectDatePicker: View {
@Environment(\.locale) private var locale
@Binding var selectedDate: Date @Binding var selectedDate: Date
@Binding var isShowing: Bool @Binding var isShowing: Bool
@@ -24,11 +25,11 @@ struct SelectDatePicker: View {
DatePicker("", selection: $selectedDate, in: Date()..., displayedComponents: .date) DatePicker("", selection: $selectedDate, in: Date()..., displayedComponents: .date)
.datePickerStyle(WheelDatePickerStyle()) .datePickerStyle(WheelDatePickerStyle())
.labelsHidden() .labelsHidden()
.environment(\.locale, Locale.init(identifier: "ko")) .environment(\.locale, locale)
.frame(width: proxy.size.width) .frame(width: proxy.size.width)
Button(action: { self.isShowing = false }) { Button(action: { self.isShowing = false }) {
Text("확인") Text(I18n.Common.confirm)
.appFont(size: 16) .appFont(size: 16)
.foregroundColor(Color(hex: "eeeeee")) .foregroundColor(Color(hex: "eeeeee"))
.padding(.vertical, 10) .padding(.vertical, 10)

View File

@@ -30,7 +30,7 @@ struct ContentCurationView: View {
HStack(spacing: 13.3) { HStack(spacing: 13.3) {
Spacer() Spacer()
Text("최신순") Text(I18n.Content.Sort.newest)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor( .foregroundColor(
Color(hex: "e2e2e2") Color(hex: "e2e2e2")
@@ -42,7 +42,7 @@ struct ContentCurationView: View {
} }
} }
Text("높은 가격순") Text(I18n.Content.Sort.priceHigh)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor( .foregroundColor(
Color(hex: "e2e2e2") Color(hex: "e2e2e2")
@@ -54,7 +54,7 @@ struct ContentCurationView: View {
} }
} }
Text("낮은 가격순") Text(I18n.Content.Sort.priceLow)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor( .foregroundColor(
Color(hex: "e2e2e2") Color(hex: "e2e2e2")
@@ -72,7 +72,7 @@ struct ContentCurationView: View {
.padding(.top, 13.3) .padding(.top, 13.3)
HStack(spacing: 0) { HStack(spacing: 0) {
Text("전체") Text(I18n.Content.Count.totalPrefix)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2")) .foregroundColor(Color(hex: "e2e2e2"))
@@ -81,7 +81,7 @@ struct ContentCurationView: View {
.foregroundColor(Color(hex: "ff5c49")) .foregroundColor(Color(hex: "ff5c49"))
.padding(.leading, 8) .padding(.leading, 8)
Text("") Text(I18n.Content.Count.countUnit)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2")) .foregroundColor(Color(hex: "e2e2e2"))
.padding(.leading, 2) .padding(.leading, 2)

View File

@@ -77,13 +77,13 @@ final class ContentCurationViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }

View File

@@ -19,11 +19,11 @@ struct AudioContentDeleteDialogView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
Text("콘텐츠 삭제") Text(I18n.ContentDetail.DeleteDialog.title)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: "eeeeee")) .foregroundColor(Color(hex: "eeeeee"))
Text("[\(title)]을 삭제하시겠습니까?") Text(I18n.ContentDetail.DeleteDialog.confirmQuestion(title))
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "eeeeee")) .foregroundColor(Color(hex: "eeeeee"))
.padding(.top, 21.3) .padding(.top, 21.3)
@@ -36,7 +36,7 @@ struct AudioContentDeleteDialogView: View {
isAgree.toggle() isAgree.toggle()
} }
Text("삭제된 콘텐츠는 되돌릴 수 없음을 알고 있습니다.") Text(I18n.ContentDetail.DeleteDialog.irreversibleAcknowledgement)
.appFont(size: 12, weight: .medium) .appFont(size: 12, weight: .medium)
.foregroundColor(Color(hex: "eeeeee")) .foregroundColor(Color(hex: "eeeeee"))
.onTapGesture { .onTapGesture {
@@ -48,7 +48,7 @@ struct AudioContentDeleteDialogView: View {
.cornerRadius(6.7) .cornerRadius(6.7)
.padding(.top, 13.3) .padding(.top, 13.3)
Text("콘텐츠를 삭제하더라도 이미 구매한\n사용자는 콘텐츠를 이용할 수 있습니다.") Text(I18n.ContentDetail.DeleteDialog.purchasedUserNotice)
.appFont(size: 12, weight: .medium) .appFont(size: 12, weight: .medium)
.foregroundColor(Color(hex: "dd4500")) .foregroundColor(Color(hex: "dd4500"))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -56,7 +56,7 @@ struct AudioContentDeleteDialogView: View {
.padding(.top, 13.3) .padding(.top, 13.3)
HStack(spacing: 12) { HStack(spacing: 12) {
Text("취소") Text(I18n.Common.cancel)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: "9970ff")) .foregroundColor(Color(hex: "9970ff"))
.padding(.horizontal, 55) .padding(.horizontal, 55)
@@ -70,7 +70,7 @@ struct AudioContentDeleteDialogView: View {
isShowing = false isShowing = false
} }
Text("확인") Text(I18n.Common.confirm)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: "eeeeee")) .foregroundColor(Color(hex: "eeeeee"))
.padding(.horizontal, 55) .padding(.horizontal, 55)

View File

@@ -13,15 +13,7 @@ struct AudioContentReportDialogView: View {
let confirmAction: (String) -> Void let confirmAction: (String) -> Void
@State private var selectedIndex: Int? = nil @State private var selectedIndex: Int? = nil
let reasons = [ let reasons = I18n.ContentDetail.ReportDialog.reasons
"괴롭힘 및 사이버 폭력",
"개인정보 침해",
"명의도용",
"폭력적 위협",
"아동학대",
"보호대상 집단에 대한 증오심 표현",
"스팸 및 사기"
]
var body: some View { var body: some View {
ZStack { ZStack {
@@ -31,7 +23,7 @@ struct AudioContentReportDialogView: View {
.onTapGesture { isShowing = false } .onTapGesture { isShowing = false }
VStack(spacing: 13.3) { VStack(spacing: 13.3) {
Text("콘텐츠 신고") Text(I18n.ContentDetail.ReportDialog.title)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: "eeeeee")) .foregroundColor(Color(hex: "eeeeee"))
@@ -59,13 +51,13 @@ struct AudioContentReportDialogView: View {
.cornerRadius(6.7) .cornerRadius(6.7)
.padding(.vertical, 21.3) .padding(.vertical, 21.3)
Text("신고한 콘텐츠를 관리자가 확인 후, 서비스정책을\n위반한 경우 삭제 조치할 예정입니다.") Text(I18n.ContentDetail.ReportDialog.notice)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "dd4500")) .foregroundColor(Color(hex: "dd4500"))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
HStack(spacing: 12) { HStack(spacing: 12) {
Text("취소") Text(I18n.Common.cancel)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: "9970ff")) .foregroundColor(Color(hex: "9970ff"))
.padding(.vertical, 16) .padding(.vertical, 16)
@@ -79,7 +71,7 @@ struct AudioContentReportDialogView: View {
isShowing = false isShowing = false
} }
Text("신고") Text(I18n.ContentDetail.ReportDialog.reportAction)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: "eeeeee")) .foregroundColor(Color(hex: "eeeeee"))
.padding(.vertical, 16) .padding(.vertical, 16)

View File

@@ -51,7 +51,7 @@ struct AudioContentCommentItemView: View {
.foregroundColor(Color.gray90) .foregroundColor(Color.gray90)
if commentItem.isSecret { if commentItem.isSecret {
Text("비밀댓글") Text(I18n.ContentDetail.Comment.secretComment)
.appFont(size: 11, weight: .medium) .appFont(size: 11, weight: .medium)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)
.padding(.horizontal, 4) .padding(.horizontal, 4)
@@ -104,7 +104,7 @@ struct AudioContentCommentItemView: View {
HStack(spacing: 0) { HStack(spacing: 0) {
if isModeModify { if isModeModify {
HStack(spacing: 0) { HStack(spacing: 0) {
TextField("댓글을 입력해 보세요.", text: $comment) TextField(I18n.ContentDetail.Comment.inputPlaceholder, text: $comment)
.autocapitalization(.none) .autocapitalization(.none)
.disableAutocorrection(true) .disableAutocorrection(true)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
@@ -150,8 +150,12 @@ struct AudioContentCommentItemView: View {
audioContentId: audioContentId, audioContentId: audioContentId,
parentComment: commentItem parentComment: commentItem
) )
) { ) {
Text(commentItem.replyCount > 0 ? "답글 \(commentItem.replyCount)" : "답글 쓰기") Text(
commentItem.replyCount > 0 ?
I18n.ContentDetail.Comment.replyCount(commentItem.replyCount) :
I18n.ContentDetail.Comment.writeReply
)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.button) .foregroundColor(Color.button)
} }
@@ -172,7 +176,7 @@ struct AudioContentCommentItemView: View {
if isShowPopupMenu { if isShowPopupMenu {
VStack(spacing: 10) { VStack(spacing: 10) {
if commentItem.writerId == UserDefaults.int(forKey: .userId) { if commentItem.writerId == UserDefaults.int(forKey: .userId) {
Text("수정") Text(I18n.ContentDetail.Comment.edit)
.appFont(size: 14, weight: .medium) .appFont(size: 14, weight: .medium)
.foregroundColor(Color.gray77) .foregroundColor(Color.gray77)
.onTapGesture { .onTapGesture {
@@ -184,7 +188,7 @@ struct AudioContentCommentItemView: View {
if contentCreatorId == UserDefaults.int(forKey: .userId) || if contentCreatorId == UserDefaults.int(forKey: .userId) ||
commentItem.writerId == UserDefaults.int(forKey: .userId) commentItem.writerId == UserDefaults.int(forKey: .userId)
{ {
Text("삭제") Text(I18n.Common.delete)
.appFont(size: 14, weight: .medium) .appFont(size: 14, weight: .medium)
.foregroundColor(Color.gray77) .foregroundColor(Color.gray77)
.onTapGesture { .onTapGesture {

View File

@@ -29,7 +29,7 @@ struct AudioContentCommentListView: View {
ZStack { ZStack {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(spacing: 0) { HStack(spacing: 0) {
Text("댓글") Text(I18n.ContentDetail.Comment.title)
.appFont(size: 14.7, weight: .medium) .appFont(size: 14.7, weight: .medium)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.leading, 13.3) .padding(.leading, 13.3)
@@ -65,7 +65,7 @@ struct AudioContentCommentListView: View {
viewModel.isSecret.toggle() viewModel.isSecret.toggle()
} }
Text("비밀댓글") Text(I18n.ContentDetail.Comment.secretComment)
.appFont(size: 12, weight: .medium) .appFont(size: 12, weight: .medium)
.foregroundColor(viewModel.isSecret ? Color.button : Color.grayee) .foregroundColor(viewModel.isSecret ? Color.button : Color.grayee)
.onTapGesture { .onTapGesture {
@@ -85,7 +85,7 @@ struct AudioContentCommentListView: View {
.clipShape(Circle()) .clipShape(Circle())
HStack(spacing: 0) { HStack(spacing: 0) {
TextField("댓글을 입력해 보세요.", text: $viewModel.comment) TextField(I18n.ContentDetail.Comment.inputPlaceholder, text: $viewModel.comment)
.autocapitalization(.none) .autocapitalization(.none)
.disableAutocorrection(true) .disableAutocorrection(true)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)

View File

@@ -64,13 +64,13 @@ class AudioContentCommentListViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
@@ -115,13 +115,13 @@ class AudioContentCommentListViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }
@@ -135,13 +135,13 @@ class AudioContentCommentListViewModel: ObservableObject {
isActive: Bool? = nil isActive: Bool? = nil
) { ) {
if comment == nil && isActive == nil { if comment == nil && isActive == nil {
errorMessage = "변경사항이 없습니다." errorMessage = I18n.ContentDetail.Comment.noChanges
isShowPopup = true isShowPopup = true
return return
} }
if let comment = comment, comment.trimmingCharacters(in: .whitespaces).isEmpty { if let comment = comment, comment.trimmingCharacters(in: .whitespaces).isEmpty {
errorMessage = "내용을 입력하세요." errorMessage = I18n.ContentDetail.Comment.inputContent
isShowPopup = true isShowPopup = true
return return
} }
@@ -187,14 +187,14 @@ class AudioContentCommentListViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.isLoading = false self.isLoading = false
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }

View File

@@ -29,7 +29,7 @@ struct AudioContentListReplyView: View {
HStack(spacing: 6.7) { HStack(spacing: 6.7) {
Image("ic_back") Image("ic_back")
Text("답글") Text(I18n.ContentDetail.Comment.replyTitle)
.appFont(size: 14.7, weight: .medium) .appFont(size: 14.7, weight: .medium)
.foregroundColor(.white) .foregroundColor(.white)
@@ -55,7 +55,7 @@ struct AudioContentListReplyView: View {
.clipShape(Circle()) .clipShape(Circle())
HStack(spacing: 0) { HStack(spacing: 0) {
TextField("댓글을 입력해 보세요.", text: $viewModel.comment) TextField(I18n.ContentDetail.Comment.inputPlaceholder, text: $viewModel.comment)
.autocapitalization(.none) .autocapitalization(.none)
.disableAutocorrection(true) .disableAutocorrection(true)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)

View File

@@ -62,13 +62,13 @@ final class AudioContentListReplyViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
@@ -113,13 +113,13 @@ final class AudioContentListReplyViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }
@@ -133,13 +133,13 @@ final class AudioContentListReplyViewModel: ObservableObject {
isActive: Bool? = nil isActive: Bool? = nil
) { ) {
if comment == nil && isActive == nil { if comment == nil && isActive == nil {
errorMessage = "변경사항이 없습니다." errorMessage = I18n.ContentDetail.Comment.noChanges
isShowPopup = true isShowPopup = true
return return
} }
if let comment = comment, comment.trimmingCharacters(in: .whitespaces).isEmpty { if let comment = comment, comment.trimmingCharacters(in: .whitespaces).isEmpty {
errorMessage = "내용을 입력하세요." errorMessage = I18n.ContentDetail.Comment.inputContent
isShowPopup = true isShowPopup = true
return return
} }
@@ -185,14 +185,14 @@ final class AudioContentListReplyViewModel: ObservableObject {
if let message = decoded.message { if let message = decoded.message {
self.errorMessage = message self.errorMessage = message
} else { } else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
} }
self.isShowPopup = true self.isShowPopup = true
} }
} catch { } catch {
self.isLoading = false self.isLoading = false
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.errorMessage = I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
} }
} }

View File

@@ -22,7 +22,7 @@ struct ContentDetailCommentView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10.3) { VStack(alignment: .leading, spacing: 10.3) {
HStack(spacing: 5.3) { HStack(spacing: 5.3) {
Text("댓글") Text(I18n.ContentDetail.Comment.title)
.appFont(size: 12, weight: .medium) .appFont(size: 12, weight: .medium)
.foregroundColor(.white) .foregroundColor(.white)
@@ -38,7 +38,7 @@ struct ContentDetailCommentView: View {
.resizable() .resizable()
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
Text("비밀댓글") Text(I18n.ContentDetail.Comment.secretComment)
.appFont(size: 12, weight: .medium) .appFont(size: 12, weight: .medium)
.foregroundColor(isSecret ? Color.button : Color.grayee) .foregroundColor(isSecret ? Color.button : Color.grayee)
} }
@@ -71,7 +71,7 @@ struct ContentDetailCommentView: View {
.padding(.leading, 3) .padding(.leading, 3)
} else { } else {
HStack(spacing: 0) { HStack(spacing: 0) {
TextField("댓글을 입력해 보세요.", text: $comment) TextField(I18n.ContentDetail.Comment.inputPlaceholder, text: $comment)
.autocapitalization(.none) .autocapitalization(.none)
.disableAutocorrection(true) .disableAutocorrection(true)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)

View File

@@ -18,7 +18,7 @@ struct ContentDetailInfoLimitedEditionView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 13.3) { VStack(alignment: .leading, spacing: 13.3) {
HStack(spacing: 0) { HStack(spacing: 0) {
Text("한정판") Text(I18n.ContentDetail.LimitedEdition.title)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.button) .foregroundColor(Color.button)
@@ -40,7 +40,7 @@ struct ContentDetailInfoLimitedEditionView: View {
.foregroundColor(Color.grayd2) .foregroundColor(Color.grayd2)
.padding(.leading, 2.3) .padding(.leading, 2.3)
} else if (remainingContentCount <= 0) { } else if (remainingContentCount <= 0) {
Text("Sold Out") Text(I18n.Content.Status.soldOut)
.appFont(size: 12, weight: .medium) .appFont(size: 12, weight: .medium)
.foregroundColor(Color.grayd2) .foregroundColor(Color.grayd2)
.padding(.horizontal, 5.3) .padding(.horizontal, 5.3)
@@ -51,7 +51,7 @@ struct ContentDetailInfoLimitedEditionView: View {
.foregroundColor(Color.grayd2) .foregroundColor(Color.grayd2)
) )
} else { } else {
Text("잔여수량") Text(I18n.ContentDetail.LimitedEdition.remainingCount)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayd2) .foregroundColor(Color.grayd2)
@@ -69,7 +69,7 @@ struct ContentDetailInfoLimitedEditionView: View {
.padding(.top, 13.3) .padding(.top, 13.3)
if !buyerList.isEmpty { if !buyerList.isEmpty {
Text("구매자") Text(I18n.ContentDetail.LimitedEdition.buyers)
.appFont(size: 18.3, weight: .bold) .appFont(size: 18.3, weight: .bold)
.foregroundColor(Color.grayee) .foregroundColor(Color.grayee)

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