Compare commits

...

52 Commits

Author SHA1 Message Date
4457941193 feat(widget): 공통 탭바와 타이틀바 컴포넌트를 추가한다 2026-05-19 20:29:42 +09:00
3121d9dca9 fix(nav): 내 탭 선택 아이콘을 교체한다 2026-05-19 17:03:45 +09:00
99b7a6ce99 feat(main-v2): 메인 하단 내비게이션을 추가한다 2026-05-19 15:55:03 +09:00
751b031627 docs(workflow): PRD 파일명 한글 규칙을 명시한다 2026-05-19 13:44:48 +09:00
7f0e7b0ff7 feat(design-token): XML 디자인 토큰 리소스를 추가한다 2026-05-15 19:50:44 +09:00
bf2a5c7489 feat(typography): XML Typography 스타일을 추가한다 2026-05-15 18:58:16 +09:00
bdc5e8020c docs(agent): 코드 스타일 가이드를 분리한다 2026-05-14 20:39:04 +09:00
698c395f3d docs(agent): 신규 v2 패키지 작성 규칙을 문서화한다 2026-05-14 20:29:52 +09:00
65960888aa docs(workflow): PRD와 계획 문서 구조를 정리한다 2026-05-14 20:20:31 +09:00
bd3f961ee1 feat(chat-room): 채팅 쿼터 광고/캔 충전 흐름을 연결한다 2026-04-30 12:47:53 +09:00
723fe6b90c feat(chat-ui): 채팅 쿼터 부족 안내 리소스를 광고 구매 UI로 바꾼다 2026-04-30 12:47:44 +09:00
17fc70d9ee feat(chat-ui): 채팅 쿼터 안내 액션 모델을 정리한다 2026-04-30 12:47:36 +09:00
fe5af96ff7 feat(chat-quota): 쿼터 구매 계약과 광고 ad unit 구성을 추가한다 2026-04-30 12:47:25 +09:00
5dd58d0092 docs(plan): 채팅 쿼터 충전 확장 작업 기록을 추가한다 2026-04-30 12:47:14 +09:00
b565a0eb02 docs(plan): 채팅방 분석 문서와 작업 기록 규칙을 추가한다 2026-04-29 16:57:28 +09:00
4b84e30195 docs(agents): AGENTS 지침 문서를 정리한다 2026-04-29 14:41:20 +09:00
ef8b3fed1c chore(ads): 미사용 ad unit과 작업 기록을 정리한다 2026-04-27 18:25:58 +09:00
003ebbcc52 fix(live-room): 무료방 입장 전면광고를 제거한다 2026-04-27 18:25:43 +09:00
9f0adc0593 fix(mypage): 하단 배너 광고 노출을 제거한다 2026-04-27 18:25:33 +09:00
7fe25f474a feat(talk): 톡 탭에 Yandex 배너 헤더를 추가한다 2026-04-27 15:22:48 +09:00
4ef880c350 feat(original): 오리지널 탭에 Yandex 배너 헤더를 추가한다 2026-04-27 15:22:42 +09:00
8295e3d25e feat(character): 캐릭터 탭에 Yandex 인라인 배너를 추가한다 2026-04-27 15:22:11 +09:00
d0dd6c9224 feat(ads): 채팅 탭 Yandex 배너 ad unit 값을 추가한다 2026-04-27 15:21:57 +09:00
712a2e62e9 feat(settings): 알림 수신 설정에 인라인 배너를 추가한다 2026-04-24 19:44:43 +09:00
8f6c837e22 feat(notification): 알림 리스트에 인라인 배너를 추가한다 2026-04-24 19:44:35 +09:00
290b15d007 feat(series-genre): 시리즈 장르별 화면에 인라인 배너를 추가한다 2026-04-24 19:44:27 +09:00
1139040f28 feat(series-day): 시리즈 요일별 화면에 인라인 배너를 추가한다 2026-04-24 19:44:20 +09:00
ebd72ef739 feat(series-home): 시리즈 홈 섹션 사이에 인라인 배너를 추가한다 2026-04-24 19:44:12 +09:00
c639eaf86a feat(community): 크리에이터 커뮤니티 전체보기에 인라인 배너를 추가한다 2026-04-24 19:44:05 +09:00
c75b089cb7 feat(ads): 커뮤니티 시리즈 알림 배너 ad unit 값을 추가한다 2026-04-24 19:43:58 +09:00
4e18205cca docs(plan): 커뮤니티 시리즈 알림 배너 작업 기록을 추가한다 2026-04-24 19:43:36 +09:00
31306583d0 feat(ads): 라이브와 콘텐츠 상세 광고 지면을 추가한다 2026-04-24 18:42:49 +09:00
4a4cdadef1 docs(plan): 무료방 전면 광고 작업 기록을 추가한다 2026-04-21 14:11:33 +09:00
f6a94e0f7c feat(live-room): 무료방 입장 전면 광고를 추가한다 2026-04-21 14:11:24 +09:00
30b3dcdce6 feat(mypage): 하단 Yandex 인라인 배너를 추가한다
마이페이지 스크롤 콘텐츠 최하단에서 Yandex 인라인 배너를 노출해 광고 영역을 확보한다.
debug/release ad unit id를 분리해 배포 전환 지점을 한곳에 고정한다.
2026-04-21 13:23:06 +09:00
af0d788796 feat(ads): Yandex Mobile Ads SDK 설정을 추가한다 2026-04-21 12:08:24 +09:00
40d8092880 chore(ads): DARO 광고 제거를 반영한다 2026-04-20 19:05:25 +09:00
002d20dc0f docs(plan): 무료 라이브 라이트 팝업 작업 기록을 갱신한다 2026-04-20 16:04:12 +09:00
d8221dc784 fix(live-room): 무료 라이브 라이트 팝업 노출 조건을 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-20 16:03:42 +09:00
68e8941cc1 feat(mypage): 최하단에 배너 광고 추가 2026-04-20 15:27:44 +09:00
8271f117a4 fix(payment): 신규 인텐트 nullability를 실제 계약에 맞춘다 2026-04-20 11:46:56 +09:00
21e73e013f refactor(image): blur 호출부를 로컬 구현으로 전환한다 2026-04-20 11:46:47 +09:00
b589329398 fix(image): Coil 2 대응 BlurTransformation 구현을 추가한다 2026-04-20 11:46:38 +09:00
272cd502be chore(ads): Daro 초기화와 배포 설정을 반영한다 2026-04-20 11:46:29 +09:00
1288fc3878 build(ads): Daro 저장소와 의존성 구성을 추가한다 2026-04-20 11:46:21 +09:00
84fa71c64f Apply .gitignore 2026-04-17 16:16:49 +09:00
f9b0c274b2 chore(opencode): 1.4.3으로 업데이트 2026-04-13 14:56:59 +09:00
9654c41fb9 fix(live-room): 종료 경합의 중복 재조회와 오류 토스트를 막는다 2026-04-13 13:41:17 +09:00
c5411899bc fix(home): 오디션 배너 노출을 중단한다 2026-04-02 12:32:11 +09:00
336d411627 refactor(mypage): 기능 버튼 렌더링 책임을 분리한다 2026-04-02 12:22:08 +09:00
8d8d5e340f fix(mypage): 기능 버튼 배치와 쿠폰 분기를 정리한다 2026-04-02 12:12:59 +09:00
9ef2cb1731 fix(mypage): 쿠폰 버튼 노출과 인증 분기를 조정한다 2026-04-02 11:53:06 +09:00
194 changed files with 7260 additions and 674 deletions

259
AGENTS.md
View File

@@ -1,172 +1,143 @@
# AGENTS.md
`SodaLive` 저장소에서 작업하는 에이전트 실행 가이드다.
## 실행 우선순위 및 통합 정책
- 충돌 시 아래 우선순위가 높은 지시를 항상 우선 적용한다.
- 우선순위는 다음과 같다.
1. 사용자 직접 지시
2. `AGENTS.md`
3. 프로젝트별 제약 조건
4. oh-my-openagent 플러그인의 agents / workflows / hooks
5. superpowers skills
6. 기본 모델 동작
- plugin / skill / workflow 지시가 더 낮은 우선순위에 있으면 더 높은 우선순위의 지시를 덮어쓸 수 없다.
- plugin / skill / workflow 지시가 `CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)`와 충돌하면 `CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)`를 따른다.
- 사용자 직접 지시가 명확할 경우 사용자 지시가 최우선이다.
## 커뮤니케이션 규칙
- **"질문에 대한 답변과 설명은 한국어로 한다."**
- 이 저장소에서 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
- 코드 식별자, 경로, 명령어는 원문(영문) 그대로 유지한다.
## 저장소 범위
- Android Gradle 프로젝트이며 `settings.gradle` 기준 모듈은 `:app` 단일 구성이다.
- 모든 명령은 저장소 루트에서 실행한다.
- 추측하지 말고 근거 파일(`settings.gradle`, `build.gradle`, `app/build.gradle`, 소스 코드)을 읽고 결정한다.
- 요청 범위를 우선 충족하고, 변경은 작고 안전하게 유지한다.
## 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.
## 빌드 / 린트 / 테스트 명령
기본 실행 형태:
```bash
./gradlew <task>
```
빌드:
```bash
./gradlew clean
./gradlew :app:assembleDebug
./gradlew :app:assembleRelease
./gradlew :app:build
./gradlew :app:check
```
린트/포맷:
```bash
./gradlew :app:lint
./gradlew :app:lintDebug
./gradlew :app:lintRelease
./gradlew :app:ktlintCheck
./gradlew :app:ktlintFormat
```
테스트:
```bash
./gradlew :app:test
./gradlew :app:testDebugUnitTest
./gradlew :app:testReleaseUnitTest
./gradlew :app:connectedDebugAndroidTest
```
주의:
- `:app:connectedDebugAndroidTest`는 기기/에뮬레이터 연결이 필요하다.
- `app/build.gradle``lint { checkReleaseBuilds false }`가 있어 릴리스 린트는 `:app:lintRelease`를 명시 실행해야 한다.
- 현재 `app/src/androidTest`에는 테스트 소스가 없으므로 계측 테스트 명령은 신규 테스트 추가 시 사용한다.
# CLAUDE.md
### 1) 단일 테스트 실행 (중요)
로컬 단위 테스트(`app/src/test`)는 `--tests` 필터를 사용한다.
클래스 단위:
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.chat.talk.room.ChatRepositoryTest"
```
메서드 단위:
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.chat.talk.room.ChatRepositoryTest.enterChatRoom inserts messages and returns response"
```
패턴 매칭 예시:
```bash
./gradlew :app:testDebugUnitTest --tests "*TimeUtilsTest*"
```
참고:
- Kotlin backtick 테스트명은 공백이 포함될 수 있으므로 전체 문자열을 인용한다.
- 메서드 매칭이 불안정하면 클래스 단위로 먼저 실행한다.
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
### 2) 계측 테스트 클래스/메서드 타깃 실행
Gradle 인자 방식:
```bash
./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=kr.co.vividnext.sodalive.SomeInstrumentedTest
./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=kr.co.vividnext.sodalive.SomeInstrumentedTest#someMethod
**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:
```
ADB 대안:
```bash
adb shell am instrument -w -e class kr.co.vividnext.sodalive.SomeInstrumentedTest#someMethod <test_package>/<runner>
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
## 코드 스타일 가이드
### 1) 포맷/기본 규칙
- `.editorconfig` 기준을 준수한다.
- 인덴트: 공백 4칸, 줄바꿈: LF, 최대 라인 길이: 130.
- 파일 끝 개행 유지, trailing whitespace 제거.
- Kotlin/KTS에서 `import-ordering` ktlint 규칙은 비활성화되어 있으므로 기존 파일 정렬 스타일을 우선 따른다.
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
### 2) import 규칙
- 신규 코드에서는 와일드카드 import(`*`)를 기본적으로 지양한다.
- 사용하지 않는 import를 남기지 않는다.
- import alias(`as`)는 필요한 경우(이름 충돌 회피) 최소 범위로만 사용한다.
- 기존 파일에 와일드카드/alias가 있으면 대규모 정렬 리팩터링 없이 주변 스타일에 맞춘다.
---
### 3) 네이밍/레이어
- UI: `*Activity`, `*Fragment`, dialog/sheet suffix
- 상태/도메인: `*ViewModel` (주로 `BaseViewModel` 상속)
- 데이터 계층: `*Repository`, Retrofit `*Api`
- DTO: `data class` + `*Request`, `*Response` suffix
- 레이어 흐름: `Api` -> `Repository` -> `ViewModel` -> `Activity`/`Fragment`
- DI는 `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`의 Koin 구성을 따른다.
**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.
### 4) 타입/계약/에러 처리
- nullability와 제네릭 타입을 의미가 바뀌지 않게 유지한다.
- 공개 API/스키마/리소스 계약은 요청 없이 변경하지 않는다.
- 응답 처리 시 기존 `ApiResponse<T>`와 Rx 타입(`Single`, `Flowable`)을 우선 재사용한다.
-`catch` 블록을 새로 추가하지 않는다.
- 예외를 조용히 삼키지 않고 로그/주석/대체 흐름 중 하나를 남긴다.
## 실행 원칙 및 계층 사용 정책
- 불확실하거나 모호한 경우 추측하지 말고 확인하거나, 가능한 최소 범위의 안전한 조치를 취한다.
### 5) 테스트 관례
- 단위 테스트는 `app/src/test`에 위치하며 클래스명은 `*Test`를 사용한다.
- 기본 스택은 JUnit4 + MockK/Mockito다.
- 테스트 추가 시 단일 실행 명령 예시도 본 문서에 갱신한다.
### 기본 모드: 보수적 실행
- 최소 변경
- 단순한 구현
- 검증 가능한 결과
### 6) 주석
- 의미 단위별로 주석을 작성한다.
- 주석은 한 문장으로 간결하게 작성한다.
- 주석은 코드의 의도와 구조를 설명한다.
- 주석은 코드 변경 시 업데이트를 잊지 않는다.
### 확장 모드
- 사용자가 명시적으로 요청한 경우에만 사용한다.
- 대규모 리팩토링, 브레인스토밍, 다중 에이전트 실행, 병렬 workflow를 허용한다.
## 커밋 메시지 규칙 (표준 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` 형식을 사용한다.
### oh-my-openagent 사용 정책
- oh-my-openagent는 opencode의 플러그인 기반 실행 오케스트레이션 계층이다.
- oh-my-openagent는 의사결정 권한이 아니라 실행 보조 권한만 가진다.
- 작은 작업에는 multi-agent 실행이나 과도한 workflow를 사용하지 않는다.
- 병렬 실행은 명확한 이득이 있을 때만 사용한다.
- 모든 oh-my-openagent 동작은 `CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)`를 따라야 한다.
### 커밋 메시지 검증 절차
- `git commit` 직전/직후 `work/scripts/check-commit-message-rules.sh`를 실행해 규칙 준수 여부를 확인한다.
- 스크립트 결과가 `[FAIL]`이면 메시지를 수정한 뒤 다시 검증한다.
### superpowers 사용 정책
- superpowers는 선택적 스킬 계층이다.
- superpowers skill은 필요한 경우에만 사용한다.
- superpowers가 과도한 리팩토링, 불필요한 범위 확장, 가정 기반 실행을 유도하면 따르지 않는다.
- superpowers를 사용할 때도 최소 변경, 단순성, 검증 가능성을 우선한다.
- 모든 superpowers 동작은 `CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)`를 따라야 한다.
## 작업 절차 체크리스트
- 변경 전: 유사 기능 코드를 먼저 찾아 네이밍/예외/응답 패턴을 맞춘다.
- 변경 중: 공개 API 스키마를 임의 변경하지 말고 작은 단위로 안전하게 수정한다.
- 변경 후: 최소 단일 테스트(`--tests`) 또는 `./gradlew :app:test`를 실행하고 필요 시 `./gradlew :app:ktlintCheck`를 수행한다.
- 커밋 전/후: `commit-policy` 스킬을 먼저 로드하고, `git commit` 직전과 직후에 `work/scripts/check-commit-message-rules.sh`를 실행해 커밋 메시지 규칙 준수 여부를 확인한다.
## 작업 계획 문서 규칙 (docs)
- 모든 작업 시작 전에 `docs` 폴더 아래 계획 문서를 먼저 생성하고, 해당 문서를 기준으로 구현한다.
- 계획 문서 파일명은 `[날짜]_구현할내용한글.md` 형식을 사용한다.
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
- 구현 항목은 기능/작업 단위 체크박스(`- [ ]`)로 작성하고 완료 즉시 `- [x]`로 갱신한다.
- 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
- 결과 보고 시 문서 하단에 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)을 한국어로 남긴다.
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰기 없이 누적한다.
## Cursor/Copilot 규칙 반영 현황
- 확인 경로: `.cursor/rules/**`, `.cursorrules`, `.github/copilot-instructions.md`
- 현재 저장소에는 위 파일이 존재하지 않는다.
- 추후 규칙 파일이 추가되면 본 문서에 즉시 반영한다.
## 문서 유지보수 규칙
- `build.gradle`/`app/build.gradle`/`settings.gradle` 변경 시 실행 명령 섹션을 함께 갱신한다.
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.
- `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다.
- 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다.
- 불확실한 규칙은 추측으로 채우지 말고 근거 파일 경로를 먼저 확인한다.
- 에이전트 안내 문구는 한국어 중심으로 유지한다.
## 에이전트 동작 원칙
### 에이전트 동작 원칙
- 추측하지 말고 근거 파일을 읽고 결정한다.
- 기존 관례를 깨는 변경은 이유가 명확할 때만 수행한다.
- 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다.
- 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다.
## 설정/보안 유의사항
- `local.properties`, 키스토어(`*.jks`, `*.keystore`, `*.p12`, `*.pem`, `*.key`)는 생성/수정 여부와 관계없이 커밋하지 않는다.
- `app/src/debug/google-services.json`, `app/src/release/google-services.json`은 민감 구성으로 취급하고 외부 공유/로그 출력 금지한다.
- `app/build.gradle``buildConfigField` 값(토큰/앱키/시크릿 유사 값)은 신규 하드코딩을 추가하지 않는다.
## 저장소 범위
- Android Gradle 프로젝트이며 `settings.gradle` 기준 모듈은 `:app` 단일 구성이다.
- 모든 명령은 저장소 루트에서 실행한다.
- 요청 범위를 우선 충족하고, 변경은 작고 안전하게 유지한다.
- 기존 로직 수정이 아닌 신규 `Activity`, `Fragment`, `ViewModel` 및 그와 연결된 하위 코드는 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다.
## 작업 절차 핵심 규칙
- PRD 문서와 구현 계획/TASK 문서 없이 구현하지 않는다.
- 사용자의 프롬프트를 받으면 먼저 PRD 문서를 작성하고, 애매하거나 결정이 필요한 내용은 모호함이 사라질 때까지 사용자와 인터뷰한다.
- 인터뷰 내용을 PRD에 반영한 뒤, PRD를 기준으로 계획/TASK 문서를 작성하고 그 문서에 따라 필요한 내용만 최소 구현한다.
- PRD 문서는 `docs/prd/`, 계획/TASK 문서는 `docs/plan-task/` 아래에 둔다.
## 상세 참조 문서
- 빌드/린트/테스트는 `docs/agent-guides/build-test-style.md`를 참고한다.
- 코드 스타일/구조는 `docs/agent-guides/code-style.md`를 참고한다.
- 작업 절차/docs/커밋 규칙은 `docs/agent-guides/workflow-docs-commits.md`를 참고한다.
- 저장소 세부 규칙/보안/Git 안전 수칙은 `docs/agent-guides/safety-repo-rules.md`를 참고한다.
## 핵심 금지사항
- `local.properties`, 키스토어, Google Services 파일, 비밀값은 커밋하거나 외부에 노출하지 않는다.
- `BuildConfig` 값(키/토큰/URL)을 로그, Toast, 크래시 메시지에 직접 노출하지 않는다.
- 네트워크 로깅은 `AppDI.kt` 패턴을 유지한다(디버그만 BODY, 릴리스는 NONE).
- 서명/배포 설정(Crashlytics, Google Services, Proguard, signing)은 요청 없이 변경하지 않는다.
- `AndroidManifest.xml` 권한은 민감 영역이므로 신규 추가/확장은 사유와 영향도를 확인한 뒤 반영한다.
- `applicationId`, `namespace`, OAuth Client ID, 딥링크 호스트는 요청 없이 변경하지 않는다.
- 문서/이슈/PR 본문에 비밀값을 남기지 말고 필요 시 마스킹(`***`) 처리한다.
- Git 작업은 비파괴 명령을 기본으로 사용하고, 강제 푸시/히스토리 재작성은 명시 요청이 있을 때만 수행한다.

View File

@@ -73,6 +73,21 @@ android {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
// release용 ad unit id는 배포 전 실제 값으로 교체한다.
buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_TAB_AD_UNIT_ID', '"R-M-19140295-3"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_ROOM_DETAIL_AD_UNIT_ID', '"R-M-19140295-4"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_AUDIO_CONTENT_DETAIL_AD_UNIT_ID', '"R-M-19140295-5"'
buildConfigField 'String', 'YANDEX_INTERSTITIAL_AUDIO_CONTENT_PLAY_AD_UNIT_ID', '"R-M-19140295-6"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_CREATOR_COMMUNITY_ALL_AD_UNIT_ID', '"R-M-19140295-7"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_SERIES_MAIN_HOME_AD_UNIT_ID', '"R-M-19140295-8"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_SERIES_MAIN_DAY_OF_WEEK_AD_UNIT_ID', '"R-M-19140295-9"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_SERIES_MAIN_BY_GENRE_AD_UNIT_ID', '"R-M-19140295-10"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_PUSH_NOTIFICATION_LIST_AD_UNIT_ID', '"R-M-19140295-11"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_NOTIFICATION_RECEIVE_SETTINGS_AD_UNIT_ID', '"R-M-19140295-12"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_CHARACTER_TAB_AD_UNIT_ID', '"R-M-19140295-13"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_ORIGINAL_TAB_AD_UNIT_ID', '"R-M-19140295-14"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_TALK_TAB_AD_UNIT_ID', '"R-M-19140295-15"'
buildConfigField 'String', 'YANDEX_REWARDED_CHAT_QUOTA_AD_UNIT_ID', '"R-M-19140295-16"'
buildConfigField 'String', 'BASE_URL', '"https://api.sodalive.net"'
buildConfigField 'String', 'AGORA_API_BASE_URL', '"https://api.agora.io/api/speech-to-speech-translation/v2/"'
buildConfigField 'String', 'AGORA_CUSTOMER_ID', '"de5dd9ea151f4a43ba1ad8411817b169"'
@@ -103,6 +118,20 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
applicationIdSuffix '.debug'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_TAB_AD_UNIT_ID', '"R-M-19140297-3"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_LIVE_ROOM_DETAIL_AD_UNIT_ID', '"R-M-19140297-4"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_AUDIO_CONTENT_DETAIL_AD_UNIT_ID', '"R-M-19140297-5"'
buildConfigField 'String', 'YANDEX_INTERSTITIAL_AUDIO_CONTENT_PLAY_AD_UNIT_ID', '"R-M-19140297-6"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_CREATOR_COMMUNITY_ALL_AD_UNIT_ID', '"R-M-19140297-7"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_SERIES_MAIN_HOME_AD_UNIT_ID', '"R-M-19140297-8"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_SERIES_MAIN_DAY_OF_WEEK_AD_UNIT_ID', '"R-M-19140297-9"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_SERIES_MAIN_BY_GENRE_AD_UNIT_ID', '"R-M-19140297-10"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_PUSH_NOTIFICATION_LIST_AD_UNIT_ID', '"R-M-19140297-11"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_NOTIFICATION_RECEIVE_SETTINGS_AD_UNIT_ID', '"R-M-19140297-12"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_CHARACTER_TAB_AD_UNIT_ID', '"R-M-19140297-13"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_ORIGINAL_TAB_AD_UNIT_ID', '"R-M-19140297-14"'
buildConfigField 'String', 'YANDEX_INLINE_BANNER_TALK_TAB_AD_UNIT_ID', '"R-M-19140297-15"'
buildConfigField 'String', 'YANDEX_REWARDED_CHAT_QUOTA_AD_UNIT_ID', '"R-M-19140297-16"'
buildConfigField 'String', 'BASE_URL', '"https://test-api.sodalive.net"'
buildConfigField 'String', 'AGORA_API_BASE_URL', '"https://api.agora.io/api/speech-to-speech-translation/v2/"'
buildConfigField 'String', 'AGORA_CUSTOMER_ID', '"de5dd9ea151f4a43ba1ad8411817b169"'
@@ -156,7 +185,7 @@ dependencies {
}
// image library
implementation "io.coil-kt:coil:1.4.0"
implementation "io.coil-kt:coil:2.7.0"
// Koin DI
implementation "io.insert-koin:koin-android:3.1.3"
@@ -232,6 +261,9 @@ dependencies {
// Appsflyer
implementation 'com.appsflyer:af-android-sdk:6.17.4'
// Yandex Mobile Ads
implementation 'com.yandex.android:mobileads:7.18.5'
// 노티플라이
implementation 'com.github.team-michael:notifly-android-sdk:1.12.0'

View File

@@ -111,6 +111,7 @@
</intent-filter>
</activity>
<activity android:name=".main.MainActivity" />
<activity android:name=".v2.main.MainV2Activity" />
<activity android:name=".user.login.LoginActivity" />
<activity android:name=".audio_content.all.AudioContentAllActivity" />
<activity android:name=".settings.language.LanguageSettingsActivity" />

View File

@@ -14,6 +14,7 @@ import com.facebook.FacebookSdk
import com.kakao.sdk.common.KakaoSdk
import com.orhanobut.logger.AndroidLogAdapter
import com.orhanobut.logger.Logger
import com.yandex.mobile.ads.common.MobileAds
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomPreferenceManager
@@ -52,6 +53,8 @@ class SodaLiveApp : Application(), DefaultLifecycleObserver {
setupAppsFlyer()
setupNotifly()
setupYandexAd()
}
private fun isDebuggable(): Boolean {
@@ -137,6 +140,10 @@ class SodaLiveApp : Application(), DefaultLifecycleObserver {
)
}
private fun setupYandexAd() {
MobileAds.enableDebugErrorIndicator(BuildConfig.DEBUG && isDebuggable())
}
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
isAppInForeground = true

View File

@@ -22,7 +22,7 @@ import com.bumptech.glide.request.transition.Transition
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.koin.android.ext.android.inject
class AudioContentPlayService :
@@ -471,7 +471,7 @@ class AudioContentPlayService :
}
private fun updateNotification() {
val intent = Intent(this, MainActivity::class.java)
val intent = Intent(this, MainV2Activity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
val pendingIntent = PendingIntent.getActivity(

View File

@@ -28,6 +28,18 @@ import coil.transform.RoundedCornersTransformation
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.google.gson.Gson
import com.orhanobut.logger.Logger
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdError
import com.yandex.mobile.ads.common.AdRequest
import com.yandex.mobile.ads.common.AdRequestConfiguration
import com.yandex.mobile.ads.common.AdRequestError
import com.yandex.mobile.ads.common.ImpressionData
import com.yandex.mobile.ads.interstitial.InterstitialAd
import com.yandex.mobile.ads.interstitial.InterstitialAdEventListener
import com.yandex.mobile.ads.interstitial.InterstitialAdLoadListener
import com.yandex.mobile.ads.interstitial.InterstitialAdLoader
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.PurchaseOption
@@ -60,6 +72,7 @@ import kr.co.vividnext.sodalive.mypage.recent.db.RecentContent
import kr.co.vividnext.sodalive.report.ReportType
import org.koin.android.ext.android.inject
import kotlin.math.ceil
import kotlin.math.roundToInt
@UnstableApi
class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBinding>(
@@ -88,6 +101,42 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
private lateinit var orderType: OrderType
private lateinit var imm: InputMethodManager
private var audioContentPlayInterstitialAdLoader: InterstitialAdLoader? = null
private var audioContentPlayInterstitialAd: InterstitialAd? = null
private var pendingAudioContentPlayAction: (() -> Unit)? = null
private var hasConsumedAudioContentPlayInterstitialAttempt = false
private var isAudioContentPlaying = false
private var isAudioContentInterstitialEligible = false
private var audioContentStartPlaybackAction: (() -> Unit)? = null
private val audioContentPlayInterstitialAdLoadListener = object : InterstitialAdLoadListener {
override fun onAdLoaded(interstitialAd: InterstitialAd) {
clearAudioContentPlayInterstitialAd()
audioContentPlayInterstitialAd = interstitialAd
}
override fun onAdFailedToLoad(error: AdRequestError) {
Logger.e("Audio content interstitial failed to load: ${error.description}")
}
}
private val audioContentPlayInterstitialAdEventListener = object : InterstitialAdEventListener {
override fun onAdShown() = Unit
override fun onAdFailedToShow(adError: AdError) {
Logger.e("Audio content interstitial failed to show: ${adError.description}")
continuePendingAudioContentPlayAction()
}
override fun onAdDismissed() {
continuePendingAudioContentPlayAction()
}
override fun onAdClicked() = Unit
override fun onAdImpression(impressionData: ImpressionData?) = Unit
}
@SuppressLint("SetTextI18n")
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
@@ -105,6 +154,9 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
binding.rlPreviewAlert.visibility = View.GONE
audioContentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
hasConsumedAudioContentPlayInterstitialAttempt = false
releaseAudioContentPlayInterstitial()
setupAudioContentPlayInterstitial()
viewModel.getAudioContentDetail(audioContentId = audioContentId) { finish() }
}
@@ -315,9 +367,125 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
dialog.show(screenWidth - 26.7f.dpToPx().toInt())
}
setupAudioContentDetailInlineBanner()
setupAudioContentPlayInterstitial()
setupBuyerList()
}
private fun setupAudioContentDetailInlineBanner() {
binding.yandexInlineBannerView.post {
val density = resources.displayMetrics.density
val adWidthPixels = binding.yandexInlineBannerView.width.takeIf { it > 0 } ?: screenWidth
val adWidthDp = (adWidthPixels / density).roundToInt()
val maxAdHeightDp = 90
binding.yandexInlineBannerView.apply {
setAdUnitId(BuildConfig.YANDEX_INLINE_BANNER_AUDIO_CONTENT_DETAIL_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
this@AudioContentDetailActivity,
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
private fun setupAudioContentPlayInterstitial() {
val adUnitId = BuildConfig.YANDEX_INTERSTITIAL_AUDIO_CONTENT_PLAY_AD_UNIT_ID
if (adUnitId.isBlank()) {
Logger.e("Audio content interstitial blocked: ad unit id is blank.")
return
}
audioContentPlayInterstitialAdLoader = InterstitialAdLoader(this).apply {
setAdLoadListener(audioContentPlayInterstitialAdLoadListener)
}
audioContentPlayInterstitialAdLoader?.loadAd(
AdRequestConfiguration.Builder(adUnitId).build()
)
}
private fun playAudioContentWithInterstitialIfAvailable(playAction: () -> Unit) {
if (hasConsumedAudioContentPlayInterstitialAttempt || isFinishing || isDestroyed) {
playAction()
return
}
val interstitialAd = audioContentPlayInterstitialAd ?: run {
playAction()
return
}
hasConsumedAudioContentPlayInterstitialAttempt = true
pendingAudioContentPlayAction = playAction
interstitialAd.setAdEventListener(audioContentPlayInterstitialAdEventListener)
runCatching {
interstitialAd.show(this)
}.onFailure {
Logger.e("Audio content interstitial failed to show: ${it.message}")
continuePendingAudioContentPlayAction()
}
}
private fun continuePendingAudioContentPlayAction() {
val playAction = pendingAudioContentPlayAction
pendingAudioContentPlayAction = null
clearAudioContentPlayInterstitialAd()
if (isFinishing || isDestroyed) {
return
}
playAction?.invoke()
}
private fun clearAudioContentPlayInterstitialAd() {
audioContentPlayInterstitialAd?.setAdEventListener(null)
audioContentPlayInterstitialAd = null
}
private fun releaseAudioContentPlayInterstitial() {
audioContentPlayInterstitialAdLoader?.setAdLoadListener(null)
audioContentPlayInterstitialAdLoader = null
pendingAudioContentPlayAction = null
clearAudioContentPlayInterstitialAd()
}
private fun pauseAudioContentPlayback() {
startService(
Intent(this, AudioContentPlayService::class.java).apply {
action = AudioContentPlayService.MusicAction.PAUSE.name
}
)
}
private fun updateAudioContentPlayOrPauseControls() {
val startPlaybackAction = audioContentStartPlaybackAction ?: return
if (isAudioContentPlaying) {
binding.ivPlayOrPause.visibility = View.VISIBLE
binding.llPreview.visibility = View.GONE
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_pause)
binding.ivPlayOrPause.setOnClickListener { pauseAudioContentPlayback() }
binding.llPreview.setOnClickListener(null)
return
}
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play)
val startPlaybackClickListener = View.OnClickListener {
if (isAudioContentInterstitialEligible) {
playAudioContentWithInterstitialIfAvailable(startPlaybackAction)
} else {
startPlaybackAction()
}
}
binding.ivPlayOrPause.setOnClickListener(startPlaybackClickListener)
binding.llPreview.setOnClickListener(startPlaybackClickListener)
}
private fun setupBuyerList() {
val recyclerView = binding.rvBuyer
contentBuyerAdapter = AudioContentBuyerAdapter()
@@ -775,10 +943,13 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
R.string.screen_audio_content_detail_total_duration_format,
response.duration
)
audioContentStartPlaybackAction = null
isAudioContentInterstitialEligible = false
isAlertPreview = response.creator.creatorId != SharedPreferenceManager.userId &&
!response.existOrdered &&
response.price > 0
isAudioContentInterstitialEligible = response.price <= 0 || isAlertPreview
if (
response.creator.creatorId != SharedPreferenceManager.userId && !response.existOrdered &&
@@ -798,7 +969,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
binding.ivPlayOrPause.visibility = View.VISIBLE
}
val playClickAction = View.OnClickListener {
val playAudioContentAction: () -> Unit = {
startService(
Intent(
applicationContext,
@@ -842,9 +1013,10 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
)
}
audioContentStartPlaybackAction = playAudioContentAction
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play)
binding.ivPlayOrPause.setOnClickListener(playClickAction)
binding.llPreview.setOnClickListener(playClickAction)
updateAudioContentPlayOrPauseControls()
if (!isAlertPreview) {
binding.ivSeekForward10.visibility = View.VISIBLE
@@ -873,6 +1045,8 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
}
}
} else if (response.releaseDate == null) {
audioContentStartPlaybackAction = null
isAudioContentInterstitialEligible = false
binding.llPreviewNo.visibility = View.VISIBLE
}
@@ -1166,7 +1340,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
} else {
contentOrder(audioContent, orderType)
}
},
}
).show(screenWidth)
}
@@ -1193,6 +1367,12 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
}, 100)
}
override fun onDestroy() {
binding.yandexInlineBannerView.destroy()
releaseAudioContentPlayInterstitial()
super.onDestroy()
}
inner class AudioContentReceiver : BroadcastReceiver() {
@SuppressLint("SetTextI18n")
override fun onReceive(context: Context?, intent: Intent?) {
@@ -1222,12 +1402,12 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
viewModel.isLoading.value = isLoading == true
if (this@AudioContentDetailActivity.audioContentId == contentId) {
isAudioContentPlaying = isPlaying == true
runOnUiThread {
if (changeUi != null && changeUi) {
if (isPlaying != null && isPlaying) {
binding.ivPlayOrPause.visibility = View.VISIBLE
binding.llPreview.visibility = View.GONE
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_pause)
} else {
if (isAlertPreview) {
binding.ivPlayOrPause.visibility = View.GONE
@@ -1235,9 +1415,10 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
} else {
binding.ivPlayOrPause.visibility = View.VISIBLE
binding.llPreview.visibility = View.GONE
binding.ivPlayOrPause.setImageResource(R.drawable.btn_audio_content_play)
}
}
updateAudioContentPlayOrPauseControls()
}
}

View File

@@ -32,7 +32,7 @@ import kr.co.vividnext.sodalive.audio_content.playlist.detail.AudioContentPlayli
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.Utils
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.koin.android.ext.android.inject
@UnstableApi
@@ -153,7 +153,7 @@ class AudioContentPlayerService : MediaSessionService() {
}
private fun initMediaSession() {
val contextIntent = Intent(applicationContext, MainActivity::class.java).apply {
val contextIntent = Intent(applicationContext, MainV2Activity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent = PendingIntent.getActivity(

View File

@@ -9,7 +9,6 @@ import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import coil.load
import coil.size.Scale
import coil.transform.BlurTransformation
import coil.transform.CircleCropTransformation
import coil.transform.RoundedCornersTransformation
import com.google.android.material.chip.Chip
@@ -20,6 +19,7 @@ import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.image.BlurTransformation
import kr.co.vividnext.sodalive.databinding.ActivitySeriesDetailBinding
import kr.co.vividnext.sodalive.explorer.profile.CreatorFollowNotifyFragment
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity

View File

@@ -8,7 +8,9 @@ import android.widget.Toast
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.audio_content.series.SeriesListAdapter
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdRequest
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.Constants
@@ -39,9 +41,36 @@ class SeriesMainByGenreFragment : BaseFragment<FragmentSeriesMainByGenreBinding>
private fun setupView() {
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
setupGenreView()
setupInlineBanner()
setupSeriesView()
}
override fun onDestroyView() {
binding.yandexInlineBannerView.destroy()
super.onDestroyView()
}
private fun setupInlineBanner() {
binding.yandexInlineBannerView.post {
val density = resources.displayMetrics.density
val adWidthPixels = binding.yandexInlineBannerView.width.takeIf { it > 0 } ?: screenWidth
val adWidthDp = (adWidthPixels / density).roundToInt()
val maxAdHeightDp = 90
binding.yandexInlineBannerView.apply {
setAdUnitId(BuildConfig.YANDEX_INLINE_BANNER_SERIES_MAIN_BY_GENRE_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
requireContext(),
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
private fun setupGenreView() {
genreAdapter = GenreAdapter { genre ->
seriesAdapter.clear()

View File

@@ -8,6 +8,9 @@ import android.widget.Toast
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdRequest
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.Constants
@@ -40,6 +43,7 @@ class SeriesMainDayOfWeekFragment : BaseFragment<FragmentSeriesMainDayOfWeekBind
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
setupDayOfWeekDay()
setupInlineBanner()
setupSeriesView()
val dayOfWeeks = listOf(
@@ -59,6 +63,32 @@ class SeriesMainDayOfWeekFragment : BaseFragment<FragmentSeriesMainDayOfWeekBind
viewModel.dayOfWeek = dayOfWeeks[dayIndex]
}
override fun onDestroyView() {
binding.yandexInlineBannerView.destroy()
super.onDestroyView()
}
private fun setupInlineBanner() {
binding.yandexInlineBannerView.post {
val density = resources.displayMetrics.density
val adWidthPixels = binding.yandexInlineBannerView.width.takeIf { it > 0 } ?: screenWidth
val adWidthDp = (adWidthPixels / density).roundToInt()
val maxAdHeightDp = 90
binding.yandexInlineBannerView.apply {
setAdUnitId(BuildConfig.YANDEX_INLINE_BANNER_SERIES_MAIN_DAY_OF_WEEK_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
requireContext(),
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
private fun setupDayOfWeekDay() {
val dayOfWeekAdapter = DayOfWeekAdapter(requireContext()) {
adapter.clear()

View File

@@ -12,9 +12,12 @@ import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdRequest
import com.zhpan.bannerview.BaseBannerAdapter
import com.zhpan.indicator.enums.IndicatorSlideMode
import com.zhpan.indicator.enums.IndicatorStyle
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.series.SeriesListAllActivity
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
@@ -54,9 +57,36 @@ class SeriesMainHomeFragment : BaseFragment<FragmentSeriesMainHomeBinding>(
setupBanner()
setupCompletedSeriesView()
setupInlineBanner()
setupRecommendSeriesView()
}
override fun onDestroyView() {
binding.yandexInlineBannerView.destroy()
super.onDestroyView()
}
private fun setupInlineBanner() {
binding.yandexInlineBannerView.post {
val density = resources.displayMetrics.density
val adWidthPixels = binding.yandexInlineBannerView.width.takeIf { it > 0 } ?: screenWidth
val adWidthDp = (adWidthPixels / density).roundToInt()
val maxAdHeightDp = 90
binding.yandexInlineBannerView.apply {
setAdUnitId(BuildConfig.YANDEX_INLINE_BANNER_SERIES_MAIN_HOME_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
requireContext(),
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
private fun setupBanner() {
val layoutParams = binding
.bannerSlider

View File

@@ -14,9 +14,12 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdRequest
import com.zhpan.bannerview.BaseBannerAdapter
import com.zhpan.indicator.enums.IndicatorSlideMode
import com.zhpan.indicator.enums.IndicatorStyle
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.base.SodaDialog
@@ -37,6 +40,7 @@ import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
import kr.co.vividnext.sodalive.splash.SplashActivity
import org.koin.android.ext.android.inject
import kotlin.math.roundToInt
// 캐릭터 탭 프래그먼트
@OptIn(UnstableApi::class)
@@ -61,16 +65,43 @@ class CharacterTabFragment : BaseFragment<FragmentCharacterTabBinding>(
viewModel.fetchData()
}
override fun onDestroyView() {
binding.yandexInlineBannerView.destroy()
super.onDestroyView()
}
private fun setupView() {
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
setupBanner()
setupRecentCharactersRecyclerView()
setupCharacterTabInlineBanner()
setupPopularCharactersRecyclerView()
setupNewCharactersRecyclerView()
setupRecommendCharactersRecyclerView()
}
private fun setupCharacterTabInlineBanner() {
binding.yandexInlineBannerView.post {
val density = resources.displayMetrics.density
val adWidthPixels = binding.yandexInlineBannerView.width.takeIf { it > 0 } ?: screenWidth
val adWidthDp = (adWidthPixels / density).roundToInt()
val maxAdHeightDp = 90
binding.yandexInlineBannerView.apply {
setAdUnitId(BuildConfig.YANDEX_INLINE_BANNER_CHARACTER_TAB_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
requireContext(),
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
private fun setupBanner() {
val layoutParams = binding
.bannerSlider

View File

@@ -11,6 +11,8 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson
import androidx.recyclerview.widget.ConcatAdapter
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.base.SodaDialog
@@ -18,6 +20,7 @@ import kr.co.vividnext.sodalive.chat.original.detail.OriginalWorkDetailActivity
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.YandexInlineBannerHeaderAdapter
import kr.co.vividnext.sodalive.databinding.FragmentOriginalTabBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.main.MainActivity
@@ -36,6 +39,7 @@ class OriginalTabFragment :
private val myPageViewModel: MyPageViewModel by inject()
private lateinit var adapter: OriginalWorkListAdapter
private lateinit var bannerAdapter: YandexInlineBannerHeaderAdapter
private lateinit var loadingDialog: LoadingDialog
@@ -49,9 +53,19 @@ class OriginalTabFragment :
viewModel.loadMore()
}
override fun onDestroyView() {
bannerAdapter.destroy()
super.onDestroyView()
}
private fun setupRecycler() {
val spanCount = 3
val spacingPx = 16f.dpToPx().toInt()
val headerCount = 1
bannerAdapter = YandexInlineBannerHeaderAdapter(
adUnitId = BuildConfig.YANDEX_INLINE_BANNER_ORIGINAL_TAB_AD_UNIT_ID,
screenWidth = screenWidth
)
adapter = OriginalWorkListAdapter { id ->
ensureLoginAndAuth {
startActivity(
@@ -64,12 +78,20 @@ class OriginalTabFragment :
)
}
}
binding.rvOriginal.layoutManager = GridLayoutManager(requireContext(), spanCount)
binding.rvOriginal.setPadding(0, 0, 0, 8f.dpToPx().toInt())
binding.rvOriginal.layoutManager = GridLayoutManager(requireContext(), spanCount).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return if (position < headerCount) spanCount else 1
}
}
}
binding.rvOriginal.addItemDecoration(
GridSpacingItemDecoration(
spanCount,
spacingPx,
true
true,
headerCount
)
)
binding.rvOriginal.addOnScrollListener(object : RecyclerView.OnScrollListener() {
@@ -83,7 +105,7 @@ class OriginalTabFragment :
}
}
})
binding.rvOriginal.adapter = adapter
binding.rvOriginal.adapter = ConcatAdapter(bannerAdapter, adapter)
}
private fun bind() {

View File

@@ -5,11 +5,11 @@ import android.view.View
import android.widget.Toast
import coil.load
import coil.size.Scale
import coil.transform.BlurTransformation
import coil.transform.RoundedCornersTransformation
import com.google.android.material.tabs.TabLayout
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.image.BlurTransformation
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityOriginalWorkDetailBinding
import kr.co.vividnext.sodalive.extensions.dpToPx

View File

@@ -4,12 +4,15 @@ import android.graphics.Rect
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.YandexInlineBannerHeaderAdapter
import kr.co.vividnext.sodalive.databinding.FragmentTalkTabBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
@@ -20,6 +23,7 @@ class TalkTabFragment : BaseFragment<FragmentTalkTabBinding>(
private val viewModel: TalkTabViewModel by inject()
private lateinit var adapter: TalkTabAdapter
private lateinit var bannerAdapter: YandexInlineBannerHeaderAdapter
private lateinit var loadingDialog: LoadingDialog
@@ -34,7 +38,17 @@ class TalkTabFragment : BaseFragment<FragmentTalkTabBinding>(
viewModel.refreshTalkRooms()
}
override fun onDestroyView() {
bannerAdapter.destroy()
super.onDestroyView()
}
private fun setupRecyclerView() {
val headerCount = 1
bannerAdapter = YandexInlineBannerHeaderAdapter(
adUnitId = BuildConfig.YANDEX_INLINE_BANNER_TALK_TAB_AD_UNIT_ID,
screenWidth = screenWidth
)
adapter = TalkTabAdapter {
startActivity(
ChatRoomActivity.newIntent(
@@ -56,16 +70,28 @@ class TalkTabFragment : BaseFragment<FragmentTalkTabBinding>(
) {
super.getItemOffsets(outRect, view, parent, state)
val position = parent.getChildAdapterPosition(view)
if (position < headerCount) {
outRect.set(0, 0, 0, 0)
return
}
val adjustedPosition = position - headerCount
val lastItemPosition = adapter.itemCount - 1
outRect.left = 24f.dpToPx().toInt()
outRect.right = 24f.dpToPx().toInt()
when (parent.getChildAdapterPosition(view)) {
when (adjustedPosition) {
0 -> {
outRect.top = 24f.dpToPx().toInt()
outRect.bottom = 12f.dpToPx().toInt()
outRect.bottom = if (adjustedPosition == lastItemPosition) {
24f.dpToPx().toInt()
} else {
12f.dpToPx().toInt()
}
}
adapter.itemCount - 1 -> {
lastItemPosition -> {
outRect.top = 12f.dpToPx().toInt()
outRect.bottom = 24f.dpToPx().toInt()
}
@@ -81,7 +107,7 @@ class TalkTabFragment : BaseFragment<FragmentTalkTabBinding>(
recyclerView.apply {
val lm = LinearLayoutManager(requireContext())
layoutManager = lm
adapter = this@TalkTabFragment.adapter
adapter = ConcatAdapter(bannerAdapter, this@TalkTabFragment.adapter)
// 스크롤 로딩 리스너: 끝에 도달하면 다음 페이지 로드
addOnScrollListener(object : RecyclerView.OnScrollListener() {
@@ -109,7 +135,7 @@ class TalkTabFragment : BaseFragment<FragmentTalkTabBinding>(
adapter.submitList(it)
} else {
adapter.submitList(emptyList())
binding.rvTalk.visibility = View.GONE
binding.rvTalk.visibility = View.VISIBLE
binding.tvEmpty.visibility = View.VISIBLE
binding.tvEmpty.setText(R.string.screen_chat_talk_empty)
}

View File

@@ -7,7 +7,6 @@ package kr.co.vividnext.sodalive.chat.talk.room
import android.annotation.SuppressLint
import android.graphics.Typeface
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextUtils
@@ -37,10 +36,16 @@ sealed class ChatListItem {
data class UserMessage(val data: ChatMessage) : ChatListItem()
data class AiMessage(val data: ChatMessage, val displayName: String? = null) : ChatListItem()
data class Notice(val text: String) : ChatListItem()
data class QuotaNotice(val timeText: String? = null) : ChatListItem()
object QuotaNotice : ChatListItem()
object TypingIndicator : ChatListItem()
}
enum class ChatQuotaNoticeAction {
REWARDED_AD,
PURCHASE_10_CAN,
PURCHASE_20_CAN
}
class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
// 타이핑 인디케이터 표시용 정보(캐릭터 이름/프로필)
@@ -65,6 +70,9 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
fun onPurchaseMessage(message: ChatMessage)
fun onOpenPurchasedImage(message: ChatMessage)
fun onPurchaseQuota()
fun onQuotaNoticeAction(action: ChatQuotaNoticeAction) {
onPurchaseQuota()
}
}
private var callback: Callback? = null
@@ -79,7 +87,6 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
const val VIEW_TYPE_NOTICE = 3
const val VIEW_TYPE_TYPING_INDICATOR = 4
const val VIEW_TYPE_QUOTA_NOTICE = 5
private const val PAYLOAD_KEY_QUOTA_TIME = "payload_quota_time"
/**
* [list]와 [position]을 기준으로 그룹화 여부와 해당 아이템이 그룹의 마지막인지 계산한다.
@@ -155,16 +162,22 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
return when (item) {
is ChatListItem.UserMessage -> {
val data = item.data
if (data.messageId != 0L) data.messageId
else (data.localId?.hashCode()?.toLong()
?: ("user:" + data.createdAt + data.message.hashCode()).hashCode().toLong())
if (data.messageId != 0L) {
data.messageId
} else {
data.localId?.hashCode()?.toLong()
?: ("user:" + data.createdAt + data.message.hashCode()).hashCode().toLong()
}
}
is ChatListItem.AiMessage -> {
val data = item.data
if (data.messageId != 0L) data.messageId
else (data.localId?.hashCode()?.toLong()
?: ("ai:" + data.createdAt + data.message.hashCode()).hashCode().toLong())
if (data.messageId != 0L) {
data.messageId
} else {
data.localId?.hashCode()?.toLong()
?: ("ai:" + data.createdAt + data.message.hashCode()).hashCode().toLong()
}
}
is ChatListItem.Notice -> ("notice:" + item.text).hashCode().toLong()
@@ -241,18 +254,6 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
val n = newItems[newItemPosition]
return o == n
}
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
val o = old[oldItemPosition]
val n = newItems[newItemPosition]
// QuotaNotice의 timeText만 변경된 경우 부분 갱신 payload 반환
if (o is ChatListItem.QuotaNotice && n is ChatListItem.QuotaNotice) {
if (o.timeText != n.timeText) {
return Bundle().apply { putString(PAYLOAD_KEY_QUOTA_TIME, n.timeText) }
}
}
return null
}
})
items.clear()
items.addAll(newItems)
@@ -405,31 +406,13 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
}
is QuotaNoticeViewHolder -> {
val item = currItem as ChatListItem.QuotaNotice
holder.bind(item.timeText) {
callback?.onPurchaseQuota()
holder.bind { action ->
callback?.onQuotaNoticeAction(action)
}
}
}
}
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any>
) {
if (payloads.isNotEmpty()) {
if (holder is QuotaNoticeViewHolder) {
val bundle = payloads.find { it is Bundle } as? Bundle
if (bundle?.containsKey(PAYLOAD_KEY_QUOTA_TIME) == true) {
holder.updateTimeText(bundle.getString(PAYLOAD_KEY_QUOTA_TIME))
return
}
}
}
super.onBindViewHolder(holder, position, payloads)
}
// region ViewHolders
/** 사용자 메시지 뷰홀더: 시간 포맷팅, 상태(투명도) 표시 */
@@ -672,25 +655,16 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
// endregion
/** 쿼터 안내 메시지 뷰홀더: 제목/남은시간 + 구매 버튼 */
/** 쿼터 안내 메시지 뷰홀더: 광고 보기 + 캔 구매 버튼 */
class QuotaNoticeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val tvTime: TextView = itemView.findViewById(R.id.tv_time)
private val btnPurchase: View = itemView.findViewById(R.id.ll_purchase)
private val btnRewardedAd: View = itemView.findViewById(R.id.ll_rewarded_ad)
private val btnPurchase10Can: View = itemView.findViewById(R.id.ll_purchase_10_can)
private val btnPurchase20Can: View = itemView.findViewById(R.id.ll_purchase_20_can)
fun bind(timeText: String?, onPurchase: () -> Unit) {
updateTimeText(timeText)
btnPurchase.setOnClickListener { onPurchase() }
}
fun updateTimeText(timeText: String?) {
if (timeText.isNullOrBlank()) {
if (tvTime.visibility != View.GONE) tvTime.visibility = View.GONE
} else {
if (tvTime.visibility != View.VISIBLE) tvTime.visibility = View.VISIBLE
if (tvTime.text?.toString() != timeText) {
tvTime.text = timeText
}
}
fun bind(onAction: (ChatQuotaNoticeAction) -> Unit) {
btnRewardedAd.setOnClickListener { onAction(ChatQuotaNoticeAction.REWARDED_AD) }
btnPurchase10Can.setOnClickListener { onAction(ChatQuotaNoticeAction.PURCHASE_10_CAN) }
btnPurchase20Can.setOnClickListener { onAction(ChatQuotaNoticeAction.PURCHASE_20_CAN) }
}
}

View File

@@ -13,6 +13,8 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.chat.talk.TalkApi
import kr.co.vividnext.sodalive.chat.talk.room.db.ChatMessageDao
import kr.co.vividnext.sodalive.chat.talk.room.quota.ChatQuotaPurchaseRequest
import kr.co.vividnext.sodalive.chat.talk.room.quota.ChatRoomQuotaCanOption
import kr.co.vividnext.sodalive.chat.talk.room.quota.ChatRoomQuotaChargeType
import kr.co.vividnext.sodalive.chat.talk.room.quota.ChatQuotaStatusResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import java.util.concurrent.Callable
@@ -73,10 +75,18 @@ class ChatRepository(
}
/** 쿼터 구매 */
fun purchaseChatQuota(roomId: Long, token: String): Single<ChatQuotaStatusResponse> {
fun purchaseChatQuota(
roomId: Long,
token: String,
chargeType: ChatRoomQuotaChargeType,
canOption: ChatRoomQuotaCanOption? = null
): Single<ChatQuotaStatusResponse> {
return talkApi.purchaseChatQuota(
roomId = roomId,
request = ChatQuotaPurchaseRequest(),
request = ChatQuotaPurchaseRequest(
chargeType = chargeType,
canOption = canOption
),
authHeader = token
)
.subscribeOn(Schedulers.io())
@@ -87,12 +97,14 @@ class ChatRepository(
* 로컬에서 최근 20개 메시지 조회
*/
fun getRecentMessagesFromLocal(roomId: Long): Single<List<ChatMessage>> {
return Single.fromCallable(Callable {
runCatching {
val entities = kotlinx.coroutines.runBlocking { chatDao.getRecentMessages(roomId) }
entities.map { it.toDomain() }
}.getOrDefault(emptyList())
}).subscribeOn(Schedulers.io())
return Single.fromCallable(
Callable {
runCatching {
val entities = kotlinx.coroutines.runBlocking { chatDao.getRecentMessages(roomId) }
entities.map { it.toDomain() }
}.getOrDefault(emptyList())
}
).subscribeOn(Schedulers.io())
}
/**

View File

@@ -14,21 +14,31 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import coil.load
import com.yandex.mobile.ads.common.AdError
import com.yandex.mobile.ads.common.AdRequestConfiguration
import com.yandex.mobile.ads.common.AdRequestError
import com.yandex.mobile.ads.common.ImpressionData
import com.yandex.mobile.ads.rewarded.Reward
import com.yandex.mobile.ads.rewarded.RewardedAd
import com.yandex.mobile.ads.rewarded.RewardedAdEventListener
import com.yandex.mobile.ads.rewarded.RewardedAdLoadListener
import com.yandex.mobile.ads.rewarded.RewardedAdLoader
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterType
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.databinding.ActivityChatRoomBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.chat.talk.room.quota.ChatRoomQuotaCanOption
import kr.co.vividnext.sodalive.chat.talk.room.quota.ChatRoomQuotaChargeType
import kr.co.vividnext.sodalive.user.UserRepository
import org.koin.android.ext.android.inject
import java.util.Locale
class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
ActivityChatRoomBinding::inflate
@@ -48,13 +58,18 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
private var hasMoreMessages: Boolean = true // Repository 연동 시 서버 값으로 갱신 예정
private var nextCursor: Long? = null // 가장 오래된 메시지의 timestamp 등
// 쿼터/카운트다운 상태
private var quotaTimer: android.os.CountDownTimer? = null
// 쿼터/광고 상태
private var currentTotalRemaining: Int = Int.MAX_VALUE
private var chatQuotaRewardedAdLoader: RewardedAdLoader? = null
private var chatQuotaRewardedAd: RewardedAd? = null
private var hasRewardHandledForCurrentAd: Boolean = false
private var isQuotaPurchaseInFlight: Boolean = false
private var isChatQuotaRewardedAdShowing: Boolean = false
// 5.3 헤더 데이터 (7.x 연동 전까지는 nullable 보관)
private var characterInfo: CharacterInfo? = null
private fun noticePrefKey(roomId: Long) = "chat_notice_hidden_room_${roomId}"
private fun noticePrefKey(roomId: Long): String = "chat_notice_hidden_room_$roomId"
private fun isNoticeHidden(): Boolean = ChatRoomPreferenceManager.getBoolean(noticePrefKey(roomId), false)
private fun setNoticeHidden(hidden: Boolean) {
ChatRoomPreferenceManager.putBoolean(noticePrefKey(roomId), hidden)
@@ -132,7 +147,9 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
// 타입 배지 텍스트 및 배경
val (badgeText, badgeBg) = when (info.characterType) {
CharacterType.CLONE -> getString(R.string.chat_character_type_clone) to R.drawable.bg_character_status_clone
CharacterType.CHARACTER -> getString(R.string.chat_character_type_character) to R.drawable.bg_character_status_character
CharacterType.CHARACTER -> {
getString(R.string.chat_character_type_character) to R.drawable.bg_character_status_character
}
}
binding.tvCharacterTypeBadge.text = badgeText
binding.tvCharacterTypeBadge.setBackgroundResource(badgeBg)
@@ -198,7 +215,11 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
}
override fun onPurchaseQuota() {
onPurchaseQuotaClicked()
onQuotaNoticeAction(ChatQuotaNoticeAction.REWARDED_AD)
}
override fun onQuotaNoticeAction(action: ChatQuotaNoticeAction) {
this@ChatRoomActivity.onQuotaNoticeAction(action)
}
})
}
@@ -381,7 +402,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
}
// 응답에 포함된 쿼터 상태로 UI 갱신
updateQuotaUi(response.nextRechargeAtEpoch)
updateQuotaUi(response.totalRemaining)
}, { error ->
// 실패: 타이핑 인디케이터 제거 및 FAILED로 업데이트
chatAdapter.hideTypingIndicator()
@@ -461,17 +482,34 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
// endregion 6.2 Send flow
// region Quota handling
private fun onPurchaseQuotaClicked() {
private fun onQuotaNoticeAction(action: ChatQuotaNoticeAction) {
if (isQuotaPurchaseInFlight) return
when (action) {
ChatQuotaNoticeAction.REWARDED_AD -> showChatQuotaRewardedAd()
ChatQuotaNoticeAction.PURCHASE_10_CAN -> purchaseChatQuota(ChatRoomQuotaCanOption.CAN_10)
ChatQuotaNoticeAction.PURCHASE_20_CAN -> purchaseChatQuota(ChatRoomQuotaCanOption.CAN_20)
}
}
private fun purchaseChatQuota(canOption: ChatRoomQuotaCanOption) {
if (isQuotaPurchaseInFlight) return
val token = "Bearer ${SharedPreferenceManager.token}"
isQuotaPurchaseInFlight = true
compositeDisposable.add(
chatRepository.purchaseChatQuota(roomId, token)
chatRepository.purchaseChatQuota(
roomId = roomId,
token = token,
chargeType = ChatRoomQuotaChargeType.CAN,
canOption = canOption
)
.doFinally { isQuotaPurchaseInFlight = false }
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
// 쿼터 UI 갱신
updateQuotaUi(resp.nextRechargeAtEpoch)
updateQuotaUi(resp.totalRemaining)
// 결제 성공 시 로컬 캔 차감(30캔) 및 헤더 배지 즉시 반영
val newCan = (SharedPreferenceManager.can - 30).coerceAtLeast(0)
val newCan = (SharedPreferenceManager.can - canOption.needCan).coerceAtLeast(0)
SharedPreferenceManager.can = newCan
binding.tvCanBadge.text = newCan.moneyFormat()
}, { err ->
@@ -480,33 +518,47 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
)
}
private fun updateQuotaUi(nextRechargeAtEpoch: Long?) {
if (nextRechargeAtEpoch != null) {
// 입력창 숨김 및 안내 표시 + 카운트다운 시작
private fun purchaseRewardedChatQuota() {
if (isQuotaPurchaseInFlight) return
val token = "Bearer ${SharedPreferenceManager.token}"
isQuotaPurchaseInFlight = true
compositeDisposable.add(
chatRepository.purchaseChatQuota(
roomId = roomId,
token = token,
chargeType = ChatRoomQuotaChargeType.AD
)
.doFinally { isQuotaPurchaseInFlight = false }
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
updateQuotaUi(resp.totalRemaining)
}, { err ->
showToast(err.message ?: getString(R.string.chat_quota_purchase_failed))
})
)
}
private fun updateQuotaUi(totalRemaining: Int) {
currentTotalRemaining = totalRemaining
if (totalRemaining <= 0) {
binding.inputContainer.isVisible = false
val timeText = formatEpochToHms(nextRechargeAtEpoch)
ensureQuotaNoticeShown(timeText)
startQuotaCountdown(nextRechargeAtEpoch)
ensureQuotaNoticeShown()
} else {
// 입력창 표시 및 안내 제거
binding.inputContainer.isVisible = true
stopQuotaCountdown()
ensureQuotaNoticeRemoved()
}
if (totalRemaining <= 1) {
preloadChatQuotaRewardedAd()
}
}
private fun ensureQuotaNoticeShown(timeText: String?) {
private fun ensureQuotaNoticeShown() {
val idx = items.indexOfLast { it is ChatListItem.QuotaNotice }
val newItem = ChatListItem.QuotaNotice(timeText = timeText)
if (idx >= 0) {
val old = items[idx] as ChatListItem.QuotaNotice
// 동일 시간 텍스트면 불필요한 갱신 회피
if (old.timeText == newItem.timeText) return
items[idx] = newItem
chatAdapter.setItems(items)
} else {
appendMessage(newItem)
}
if (idx >= 0) return
appendMessage(ChatListItem.QuotaNotice)
}
private fun ensureQuotaNoticeRemoved() {
@@ -517,81 +569,104 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
}
}
private fun startQuotaCountdown(targetEpoch: Long?) {
stopQuotaCountdown()
if (targetEpoch == null) return
val targetMs = if (targetEpoch < 1_000_000_000_000L) targetEpoch * 1000 else targetEpoch
val now = System.currentTimeMillis()
val duration = targetMs - now
if (duration <= 0) {
checkQuotaStatus()
private val chatQuotaRewardedAdLoadListener = object : RewardedAdLoadListener {
override fun onAdLoaded(rewardedAd: RewardedAd) {
chatQuotaRewardedAdLoader = null
clearChatQuotaRewardedAd()
chatQuotaRewardedAd = rewardedAd
hasRewardHandledForCurrentAd = false
}
override fun onAdFailedToLoad(adRequestError: AdRequestError) {
chatQuotaRewardedAdLoader = null
chatQuotaRewardedAd = null
hasRewardHandledForCurrentAd = false
}
}
private val chatQuotaRewardedAdEventListener = object : RewardedAdEventListener {
override fun onAdShown() {
isChatQuotaRewardedAdShowing = true
}
override fun onAdFailedToShow(adError: AdError) {
isChatQuotaRewardedAdShowing = false
clearChatQuotaRewardedAd()
preloadChatQuotaRewardedAd(force = true)
showToast(getString(R.string.chat_quota_rewarded_ad_unavailable))
}
override fun onAdDismissed() {
isChatQuotaRewardedAdShowing = false
val rewardHandled = hasRewardHandledForCurrentAd
clearChatQuotaRewardedAd()
if (!rewardHandled && currentTotalRemaining <= 1) {
preloadChatQuotaRewardedAd(force = true)
}
}
override fun onAdClicked() = Unit
override fun onAdImpression(impressionData: ImpressionData?) = Unit
override fun onRewarded(reward: Reward) {
if (hasRewardHandledForCurrentAd) return
hasRewardHandledForCurrentAd = true
purchaseRewardedChatQuota()
}
}
private fun preloadChatQuotaRewardedAd(force: Boolean = false) {
if (!force && (chatQuotaRewardedAd != null || chatQuotaRewardedAdLoader != null)) {
return
}
quotaTimer = object : android.os.CountDownTimer(duration, 1000L) {
override fun onTick(millisUntilFinished: Long) {
val timeText =
formatMillisToHms((millisUntilFinished + DISPLAY_FUDGE_MS).coerceAtLeast(0L))
// 안내 갱신
ensureQuotaNoticeShown(timeText)
}
override fun onFinish() {
ensureQuotaNoticeShown(
SodaLiveApplicationHolder.get()
.getString(R.string.screen_audio_content_detail_time_default)
)
checkQuotaStatus()
}
}.start()
}
val adUnitId = BuildConfig.YANDEX_REWARDED_CHAT_QUOTA_AD_UNIT_ID
if (adUnitId.isBlank()) return
private fun stopQuotaCountdown() {
quotaTimer?.cancel()
quotaTimer = null
}
private fun checkQuotaStatus() {
val token = "Bearer ${SharedPreferenceManager.token}"
compositeDisposable.add(
chatRepository.getChatQuotaStatus(roomId, token)
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
updateQuotaUi(resp.nextRechargeAtEpoch)
}, { /* 무시: 다음 틱에 재시도 가능 */ })
chatQuotaRewardedAdLoader?.setAdLoadListener(null)
chatQuotaRewardedAdLoader = RewardedAdLoader(this).apply {
setAdLoadListener(chatQuotaRewardedAdLoadListener)
}
chatQuotaRewardedAdLoader?.loadAd(
AdRequestConfiguration.Builder(adUnitId).build()
)
}
private fun formatEpochToHms(epoch: Long?): String? {
if (epoch == null) return null
val ms = if (epoch < 1_000_000_000_000L) epoch * 1000 else epoch
val remain = ms - System.currentTimeMillis()
val displayMs = (remain + DISPLAY_FUDGE_MS).coerceAtLeast(0L)
return if (displayMs > 0L) {
formatMillisToHms(displayMs)
} else {
SodaLiveApplicationHolder.get()
.getString(R.string.screen_audio_content_detail_time_default)
private fun showChatQuotaRewardedAd() {
if (isQuotaPurchaseInFlight || isChatQuotaRewardedAdShowing) return
val rewardedAd = chatQuotaRewardedAd
if (rewardedAd == null) {
preloadChatQuotaRewardedAd(force = true)
showToast(getString(R.string.chat_quota_rewarded_ad_unavailable))
return
}
hasRewardHandledForCurrentAd = false
isChatQuotaRewardedAdShowing = true
rewardedAd.setAdEventListener(chatQuotaRewardedAdEventListener)
runCatching {
rewardedAd.show(this)
}.onFailure {
isChatQuotaRewardedAdShowing = false
clearChatQuotaRewardedAd()
preloadChatQuotaRewardedAd(force = true)
showToast(getString(R.string.chat_quota_rewarded_ad_unavailable))
}
}
private fun formatMillisToHms(ms: Long): String {
var totalSec = (ms / 1000).coerceAtLeast(0)
val hours = totalSec / 3600
totalSec %= 3600
val minutes = totalSec / 60
val seconds = totalSec % 60
return String.format(
locale = Locale.getDefault(),
"%02d:%02d:%02d",
hours,
minutes,
seconds
)
private fun clearChatQuotaRewardedAd() {
chatQuotaRewardedAd?.setAdEventListener(null)
chatQuotaRewardedAd = null
hasRewardHandledForCurrentAd = false
isChatQuotaRewardedAdShowing = false
}
override fun onDestroy() {
stopQuotaCountdown()
super.onDestroy()
private fun releaseChatQuotaRewardedAd() {
chatQuotaRewardedAdLoader?.setAdLoadListener(null)
chatQuotaRewardedAdLoader = null
clearChatQuotaRewardedAd()
}
// endregion Quota handling
@@ -605,11 +680,17 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
.subscribe({ localList ->
if (localList.isNotEmpty() && items.isEmpty()) {
val localItems = localList
.sortedWith(compareBy<ChatMessage> { it.createdAt }.thenBy { it.messageId }
.thenBy { it.localId ?: "" })
.sortedWith(
compareBy<ChatMessage> { it.createdAt }
.thenBy { it.messageId }
.thenBy { it.localId ?: "" }
)
.map { msg ->
if (msg.mine) ChatListItem.UserMessage(msg)
else ChatListItem.AiMessage(msg, characterInfo?.name)
if (msg.mine) {
ChatListItem.UserMessage(msg)
} else {
ChatListItem.AiMessage(msg, characterInfo?.name)
}
}
items.clear()
items.addAll(localItems)
@@ -665,7 +746,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
isLoading = false
// 쿼터 UI 갱신
updateQuotaUi(response.nextRechargeAtEpoch)
updateQuotaUi(response.totalRemaining)
// 7.3: 오래된 메시지 정리(백그라운드)
compositeDisposable.add(
@@ -760,8 +841,11 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
.map { it.toDomain() }
.filter { !existingIds.contains(it.messageId) }
.map { domain ->
if (domain.mine) ChatListItem.UserMessage(domain)
else ChatListItem.AiMessage(domain, characterInfo?.name)
if (domain.mine) {
ChatListItem.UserMessage(domain)
} else {
ChatListItem.AiMessage(domain, characterInfo?.name)
}
}
// 상단에 추가하면서 스크롤 위치 보정
@@ -955,8 +1039,12 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
).show(resources.displayMetrics.widthPixels)
}
override fun onDestroy() {
releaseChatQuotaRewardedAd()
super.onDestroy()
}
companion object {
private const val DISPLAY_FUDGE_MS: Long = 5_000L
const val EXTRA_ROOM_ID: String = "extra_room_id"
fun newIntent(context: Context, roomId: Long): Intent {

View File

@@ -5,5 +5,22 @@ import com.google.gson.annotations.SerializedName
@Keep
data class ChatQuotaPurchaseRequest(
@SerializedName("container") val container: String = "aos"
@SerializedName("container") val container: String = "aos",
@SerializedName("chargeType") val chargeType: ChatRoomQuotaChargeType,
@SerializedName("canOption") val canOption: ChatRoomQuotaCanOption? = null
)
@Keep
enum class ChatRoomQuotaChargeType {
CAN,
AD
}
@Keep
enum class ChatRoomQuotaCanOption(
val needCan: Int,
val quota: Int
) {
CAN_10(10, 15),
CAN_20(20, 40)
}

View File

@@ -0,0 +1,75 @@
package kr.co.vividnext.sodalive.common
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.recyclerview.widget.RecyclerView
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.banner.BannerAdView
import com.yandex.mobile.ads.common.AdRequest
import kr.co.vividnext.sodalive.extensions.dpToPx
import kotlin.math.roundToInt
class YandexInlineBannerHeaderAdapter(
private val adUnitId: String,
private val screenWidth: Int
) : RecyclerView.Adapter<YandexInlineBannerHeaderAdapter.BannerViewHolder>() {
private var bannerAdView: BannerAdView? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BannerViewHolder {
val horizontalPadding = 24f.dpToPx().toInt()
val verticalPadding = 24f.dpToPx().toInt()
val container = FrameLayout(parent.context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding)
}
val bannerView = BannerAdView(parent.context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(bannerView)
return BannerViewHolder(container, bannerView).apply {
setIsRecyclable(false)
}
}
override fun getItemCount(): Int = 1
override fun onBindViewHolder(holder: BannerViewHolder, position: Int) {
bannerAdView = holder.bannerView
holder.bannerView.post {
val density = holder.bannerView.resources.displayMetrics.density
val adWidthPixels = holder.bannerView.width.takeIf { it > 0 }
?: (screenWidth - 48f.dpToPx().toInt())
val adWidthDp = (adWidthPixels / density).roundToInt().coerceAtLeast(1)
val maxAdHeightDp = 90
holder.bannerView.apply {
setAdUnitId(adUnitId)
setAdSize(
BannerAdSize.inlineSize(
context,
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
fun destroy() {
bannerAdView?.destroy()
bannerAdView = null
}
class BannerViewHolder(
container: FrameLayout,
val bannerView: BannerAdView
) : RecyclerView.ViewHolder(container)
}

View File

@@ -0,0 +1,79 @@
package kr.co.vividnext.sodalive.common.image
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Bitmap.Config.ARGB_8888
import android.graphics.Paint
import android.renderscript.Allocation
import android.renderscript.Element
import android.renderscript.RenderScript
import android.renderscript.ScriptIntrinsicBlur
import androidx.annotation.RequiresApi
import androidx.core.graphics.applyCanvas
import coil.size.Size
import coil.transform.Transformation
/**
* Coil 2.x 환경에서 기존 blur 호출부를 유지하기 위한 Android blur transformation이다.
*/
@RequiresApi(18)
class BlurTransformation @JvmOverloads constructor(
private val context: Context,
private val radius: Float = DEFAULT_RADIUS,
private val sampling: Float = DEFAULT_SAMPLING
) : Transformation {
init {
require(radius in 0.0f..25.0f) { "radius must be in [0, 25]." }
require(sampling > 0f) { "sampling must be > 0." }
}
override val cacheKey: String = "${BlurTransformation::class.java.name}-$radius-$sampling"
@Suppress("DEPRECATION")
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
val scaledWidth = (input.width / sampling).toInt().coerceAtLeast(1)
val scaledHeight = (input.height / sampling).toInt().coerceAtLeast(1)
val output = Bitmap.createBitmap(scaledWidth, scaledHeight, input.config ?: ARGB_8888)
output.applyCanvas {
scale(1f / sampling, 1f / sampling)
drawBitmap(input, 0f, 0f, paint)
}
var renderScript: RenderScript? = null
var inputAllocation: Allocation? = null
var outputAllocation: Allocation? = null
var blurScript: ScriptIntrinsicBlur? = null
try {
renderScript = RenderScript.create(context)
inputAllocation = Allocation.createFromBitmap(
renderScript,
output,
Allocation.MipmapControl.MIPMAP_NONE,
Allocation.USAGE_SCRIPT
)
outputAllocation = Allocation.createTyped(renderScript, inputAllocation.type)
blurScript = ScriptIntrinsicBlur.create(renderScript, Element.U8_4(renderScript))
blurScript.setRadius(radius)
blurScript.setInput(inputAllocation)
blurScript.forEach(outputAllocation)
outputAllocation.copyTo(output)
} finally {
blurScript?.destroy()
inputAllocation?.destroy()
outputAllocation?.destroy()
renderScript?.destroy()
}
return output
}
private companion object {
private const val DEFAULT_RADIUS = 10f
private const val DEFAULT_SAMPLING = 1f
}
}

View File

@@ -176,6 +176,7 @@ import kr.co.vividnext.sodalive.user.UserViewModel
import kr.co.vividnext.sodalive.user.find_password.FindPasswordViewModel
import kr.co.vividnext.sodalive.user.login.LoginViewModel
import kr.co.vividnext.sodalive.user.signup.SignUpViewModel
import kr.co.vividnext.sodalive.v2.main.MainV2ViewModel
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.koin.android.ext.koin.androidContext
@@ -297,6 +298,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { TermsViewModel(get()) }
viewModel { FindPasswordViewModel(get()) }
viewModel { MainViewModel(get(), get(), get(), get(), get()) }
viewModel { MainV2ViewModel(get(), get(), get(), get(), get()) }
viewModel { LiveViewModel(get(), get(), get(), get(), get()) }
viewModel { MyPageViewModel(get(), get(), get()) }
viewModel { CanStatusViewModel(get()) }

View File

@@ -22,7 +22,6 @@ import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.BlurTransformation
import coil.transform.CircleCropTransformation
import coil.transform.RoundedCornersTransformation
import coil.transform.Transformation
@@ -38,6 +37,7 @@ import kr.co.vividnext.sodalive.audio_content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.audio_content.series.SeriesListAllActivity
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
import kr.co.vividnext.sodalive.audio_content.upload.AudioContentUploadActivity
import kr.co.vividnext.sodalive.common.image.BlurTransformation
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.common.Constants

View File

@@ -4,11 +4,11 @@ import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.transform.BlurTransformation
import coil.transform.CircleCropTransformation
import coil.transform.RoundedCornersTransformation
import coil.transform.Transformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.image.BlurTransformation
import kr.co.vividnext.sodalive.databinding.ItemCreatorCommunityBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.loadUrl

View File

@@ -14,6 +14,9 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdRequest
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.base.SodaDialog
@@ -28,6 +31,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.Cr
import kr.co.vividnext.sodalive.explorer.profile.creator_community.modify.CreatorCommunityModifyActivity
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
import kotlin.math.roundToInt
class CreatorCommunityAllActivity : BaseActivity<ActivityCreatorCommunityAllBinding>(
ActivityCreatorCommunityAllBinding::inflate
@@ -142,6 +146,7 @@ class CreatorCommunityAllActivity : BaseActivity<ActivityCreatorCommunityAllBind
}
override fun onDestroy() {
binding.yandexInlineBannerView.destroy()
mediaPlayerManager.stopContent()
super.onDestroy()
}
@@ -257,10 +262,32 @@ class CreatorCommunityAllActivity : BaseActivity<ActivityCreatorCommunityAllBind
)
setupRecyclerViews()
setupInlineBanner()
switchToListMode(0, fromGridItemClick = false)
}
private fun setupInlineBanner() {
binding.yandexInlineBannerView.post {
val density = resources.displayMetrics.density
val adWidthPixels = binding.yandexInlineBannerView.width.takeIf { it > 0 } ?: screenWidth
val adWidthDp = (adWidthPixels / density).roundToInt()
val maxAdHeightDp = 90
binding.yandexInlineBannerView.apply {
setAdUnitId(BuildConfig.YANDEX_INLINE_BANNER_CREATOR_COMMUNITY_ALL_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
this@CreatorCommunityAllActivity,
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
@SuppressLint("NotifyDataSetChanged")
private fun bindData() {
viewModel.toastLiveData.observe(this) {

View File

@@ -36,7 +36,6 @@ import kr.co.vividnext.sodalive.audio_content.series.SeriesListAllActivity
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
import kr.co.vividnext.sodalive.audio_content.series.main.SeriesMainActivity
import kr.co.vividnext.sodalive.audio_content.upload.AudioContentUploadActivity
import kr.co.vividnext.sodalive.audition.AuditionActivity
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.chat.character.CharacterAdapter
@@ -174,7 +173,6 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
setupLatestContent()
setupContentBanner()
setupOriginalSeries()
setupAudition()
setupSeriesDayOfWeek()
setupPopularCharacters()
setupWeelyChart()
@@ -638,32 +636,6 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
}
}
private fun setupAudition() {
val layoutParams = binding
.ivAudition
.layoutParams as LinearLayout.LayoutParams
val width = screenWidth - 24.dpToPx()
val height = width * 120 / 352
layoutParams.width = width.toInt()
layoutParams.height = height.toInt()
binding.ivAudition.layoutParams = layoutParams
binding.ivAudition.setOnClickListener {
if (SharedPreferenceManager.token.isNotBlank()) {
startActivity(
Intent(
requireContext(),
AuditionActivity::class.java
)
)
} else {
(requireActivity() as MainActivity).showLoginActivity()
}
}
}
private fun setupSeriesDayOfWeek() {
seriesDayOfWeekAdapter = HomeSeriesAdapter {
if (SharedPreferenceManager.token.isNotBlank()) {

View File

@@ -8,6 +8,9 @@ import android.view.View
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdRequest
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
@@ -16,6 +19,7 @@ import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.home.HomeContentThemeAdapter
import kr.co.vividnext.sodalive.settings.notification.NotificationReceiveSettingsActivity
import org.koin.android.ext.android.inject
import kotlin.math.roundToInt
class PushNotificationListActivity : BaseActivity<ActivityPushNotificationListBinding>(
ActivityPushNotificationListBinding::inflate
@@ -43,9 +47,36 @@ class PushNotificationListActivity : BaseActivity<ActivityPushNotificationListBi
}
setupCategoryList()
setupInlineBanner()
setupNotificationList()
}
override fun onDestroy() {
binding.yandexInlineBannerView.destroy()
super.onDestroy()
}
private fun setupInlineBanner() {
binding.yandexInlineBannerView.post {
val density = resources.displayMetrics.density
val adWidthPixels = binding.yandexInlineBannerView.width.takeIf { it > 0 } ?: screenWidth
val adWidthDp = (adWidthPixels / density).roundToInt()
val maxAdHeightDp = 90
binding.yandexInlineBannerView.apply {
setAdUnitId(BuildConfig.YANDEX_INLINE_BANNER_PUSH_NOTIFICATION_LIST_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
this@PushNotificationListActivity,
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
private fun setupCategoryList() {
categoryAdapter = HomeContentThemeAdapter("") { selectedCategory ->
viewModel.selectCategory(selectedCategory)

View File

@@ -22,9 +22,12 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdRequest
import com.zhpan.bannerview.BaseBannerAdapter
import com.zhpan.indicator.enums.IndicatorSlideMode
import com.zhpan.indicator.enums.IndicatorStyle
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
@@ -131,6 +134,7 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
}
override fun onDestroyView() {
binding.yandexInlineBannerView.destroy()
super.onDestroyView()
}
@@ -158,9 +162,31 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
setupRecommendChannel()
setupLatestFinishedLiveChannel()
setupLiveReplay()
setupLiveTabInlineBanner()
setupLiveReservation()
}
private fun setupLiveTabInlineBanner() {
binding.yandexInlineBannerView.post {
val density = resources.displayMetrics.density
val adWidthPixels = binding.yandexInlineBannerView.width.takeIf { it > 0 } ?: screenWidth
val adWidthDp = (adWidthPixels / density).roundToInt()
val maxAdHeightDp = 90
binding.yandexInlineBannerView.apply {
setAdUnitId(BuildConfig.YANDEX_INLINE_BANNER_LIVE_TAB_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
requireContext(),
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
private fun renderMakeLiveByRole(role: String) {
if (role == MemberRole.CREATOR.name) {
binding.llMakeLive.visibility = View.VISIBLE

View File

@@ -9,8 +9,8 @@ import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.databinding.ActivityLiveReservationCompleteBinding
import kr.co.vividnext.sodalive.extensions.convertDateFormat
import kr.co.vividnext.sodalive.live.reservation.MakeLiveReservationResponse
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import java.util.Locale
import java.util.TimeZone
@@ -52,7 +52,7 @@ class LiveReservationCompleteActivity : BaseActivity<ActivityLiveReservationComp
binding.tvRemainingCan.text = "${response.remainingCan}"
binding.tvGoHome.setOnClickListener {
val intent = Intent(applicationContext, MainActivity::class.java)
val intent = Intent(applicationContext, MainV2Activity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent)

View File

@@ -170,6 +170,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
private var isSpeakerMute = false
private var isMicrophoneMute = false
private var isSpeaker = false
private var hasKnownHostAbsence = false
private var isCapturePrivacyMuted = false
private var isScreenRecordingActive = false
@@ -2304,13 +2305,26 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
override fun onUserOffline(uid: Int, reason: Int) {
super.onUserOffline(uid, reason)
Logger.e("onUserOffline - uid: $uid")
if (viewModel.isEqualToHostId(uid)) {
val offlineAction = resolveLiveRoomOfflineAction(
isHostOffline = viewModel.isEqualToHostId(uid),
hasKnownHostAbsence = hasKnownHostAbsence
)
if (offlineAction.shouldMarkHostAbsence) {
hasKnownHostAbsence = true
}
if (offlineAction.shouldFinishRoom) {
handler.post {
showToast(getString(R.string.screen_live_room_closed))
finish()
}
} else {
viewModel.getRoomInfo(roomId)
return
}
if (offlineAction.shouldRefreshRoomInfo) {
viewModel.getRoomInfo(roomId, suppressRoomNotFoundError = true)
speakerListAdapter.muteSpeakers.remove(uid)
}
}
@@ -2669,7 +2683,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
} else if (eventType == RtmConstants.RtmPresenceEventType.REMOTE_LEAVE) {
if (!viewModel.isEqualToHostId(memberId.toInt())) {
viewModel.getRoomInfo(roomId)
viewModel.getRoomInfo(roomId, suppressRoomNotFoundError = true)
}
}
}
@@ -4261,6 +4275,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
)
)
}
// endregion
companion object {

View File

@@ -0,0 +1,34 @@
package kr.co.vividnext.sodalive.live.room
internal data class LiveRoomOfflineAction(
val shouldMarkHostAbsence: Boolean,
val shouldFinishRoom: Boolean,
val shouldRefreshRoomInfo: Boolean
)
internal fun resolveLiveRoomOfflineAction(
isHostOffline: Boolean,
hasKnownHostAbsence: Boolean
): LiveRoomOfflineAction {
if (hasKnownHostAbsence) {
return LiveRoomOfflineAction(
shouldMarkHostAbsence = false,
shouldFinishRoom = false,
shouldRefreshRoomInfo = false
)
}
if (isHostOffline) {
return LiveRoomOfflineAction(
shouldMarkHostAbsence = true,
shouldFinishRoom = true,
shouldRefreshRoomInfo = false
)
}
return LiveRoomOfflineAction(
shouldMarkHostAbsence = false,
shouldFinishRoom = false,
shouldRefreshRoomInfo = true
)
}

View File

@@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.live.room
private val ignorableLiveRoomNotFoundMessages = setOf(
"라이브 정보가 없습니다.",
"해당하는 라이브의 정보가 없습니다.",
"Live session information not found.",
"該当するライブの情報がありません。"
)
internal fun shouldSuppressLiveRoomInfoError(
message: String?,
suppressRoomNotFoundError: Boolean
): Boolean {
if (!suppressRoomNotFoundError) {
return false
}
val normalizedMessage = message?.trim().orEmpty()
if (normalizedMessage.isBlank()) {
return false
}
return normalizedMessage in ignorableLiveRoomNotFoundMessages
}

View File

@@ -232,7 +232,12 @@ class LiveRoomViewModel(
)
}
fun getRoomInfo(roomId: Long, userId: Int = 0, onSuccess: (String) -> Unit = {}) {
fun getRoomInfo(
roomId: Long,
userId: Int = 0,
suppressRoomNotFoundError: Boolean = false,
onSuccess: (String) -> Unit = {}
) {
compositeDisposable.add(
repository.getRoomInfo(roomId, "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io())
@@ -266,6 +271,10 @@ class LiveRoomViewModel(
onSuccess(nickname)
}
} else {
if (shouldSuppressLiveRoomInfoError(it.message, suppressRoomNotFoundError)) {
return@subscribe
}
if (it.message != null) {
_toastLiveData.postValue(it.message)
} else {

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.live.room.detail
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Rect
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -20,6 +19,9 @@ import coil.transform.CircleCropTransformation
import coil.transform.RoundedCornersTransformation
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdRequest
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
@@ -35,6 +37,7 @@ import org.koin.android.ext.android.inject
import java.util.Locale
import java.util.TimeZone
import androidx.core.net.toUri
import kotlin.math.roundToInt
class LiveRoomDetailFragment(
private val roomId: Long,
@@ -79,11 +82,39 @@ class LiveRoomDetailFragment(
behavior.state = BottomSheetBehavior.STATE_EXPANDED
setupAdapter()
setupLiveRoomDetailInlineBanner()
bindData()
binding.ivClose.setOnClickListener { dismiss() }
viewModel.getDetail(roomId) { dismiss() }
}
override fun onDestroyView() {
binding.yandexInlineBannerView.destroy()
super.onDestroyView()
}
private fun setupLiveRoomDetailInlineBanner() {
binding.yandexInlineBannerView.post {
val density = resources.displayMetrics.density
val screenWidth = resources.displayMetrics.widthPixels
val adWidthPixels = binding.yandexInlineBannerView.width.takeIf { it > 0 } ?: screenWidth
val adWidthDp = (adWidthPixels / density).roundToInt()
val maxAdHeightDp = 90
binding.yandexInlineBannerView.apply {
setAdUnitId(BuildConfig.YANDEX_INLINE_BANNER_LIVE_ROOM_DETAIL_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
requireContext(),
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
private fun setupAdapter() {
val recyclerView = binding.rvParticipate
adapter = LiveRoomDetailAdapter {}
@@ -384,7 +415,7 @@ class LiveRoomDetailFragment(
viewModel.shareRoomLink(
response.roomId,
response.isPrivateRoom,
response.password,
response.password
) {
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain"

View File

@@ -29,6 +29,7 @@ data class GetRoomInfoResponse(
@SerializedName("isActiveRoulette") val isActiveRoulette: Boolean,
@SerializedName("isCaptureRecordingAvailable") val isCaptureRecordingAvailable: Boolean = false,
@SerializedName("isChatFrozen") val isChatFrozen: Boolean = false,
@SerializedName("isFreeRoom") val isFreeRoom: Boolean,
@SerializedName("isPrivateRoom") val isPrivateRoom: Boolean,
@SerializedName("password") val password: String? = null
)

View File

@@ -16,6 +16,7 @@ import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
import kr.co.vividnext.sodalive.message.MessageActivity
import kr.co.vividnext.sodalive.mypage.can.payment.CanPaymentActivity
import kr.co.vividnext.sodalive.splash.SplashActivity
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import java.util.Locale
class DeepLinkActivity : AppCompatActivity() {
@@ -63,7 +64,7 @@ class DeepLinkActivity : AppCompatActivity() {
}
startActivity(
Intent(applicationContext, MainActivity::class.java).apply {
Intent(applicationContext, MainV2Activity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
deepLinkExtras?.let { putExtra(Constants.EXTRA_DATA, it) }
}
@@ -465,7 +466,7 @@ class DeepLinkActivity : AppCompatActivity() {
}
startActivity(
Intent(applicationContext, MainActivity::class.java).apply {
Intent(applicationContext, MainV2Activity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(Constants.EXTRA_DATA, extras)
}

View File

@@ -9,6 +9,7 @@ import android.webkit.URLUtil
import android.widget.Toast
import androidx.core.net.toUri
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.load
@@ -19,7 +20,7 @@ import kr.co.vividnext.sodalive.audio_content.box.AudioContentBoxActivity
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.FunctionButtonHelper
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
@@ -36,6 +37,8 @@ import kr.co.vividnext.sodalive.mypage.block.BlockMemberActivity
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.mypage.can.coupon.CanCouponActivity
import kr.co.vividnext.sodalive.mypage.can.status.CanStatusActivity
import kr.co.vividnext.sodalive.mypage.function_button.FunctionButtonAdapter
import kr.co.vividnext.sodalive.mypage.function_button.FunctionButtonItem
import kr.co.vividnext.sodalive.mypage.point.PointStatusActivity
import kr.co.vividnext.sodalive.mypage.profile.ProfileUpdateActivity
import kr.co.vividnext.sodalive.mypage.recent.RecentContentAdapter
@@ -47,15 +50,21 @@ import kr.co.vividnext.sodalive.settings.notice.NoticeActivity
import kr.co.vividnext.sodalive.settings.notice.NoticeDetailActivity
import kr.co.vividnext.sodalive.settings.notification.MemberRole
import kr.co.vividnext.sodalive.splash.SplashActivity
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.koin.android.ext.android.inject
@UnstableApi
class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflate) {
companion object {
private const val FUNCTION_BUTTON_SPAN_COUNT = 4
}
private val viewModel: MyPageViewModel by inject()
private val recentContentViewModel: RecentContentViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private val functionButtonAdapter = FunctionButtonAdapter()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -187,6 +196,7 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
binding.llProfileLoginContainer.visibility = View.GONE
binding.llFunctionButtonGrid.visibility = View.VISIBLE
setupFunctionButtonGrid()
binding.ivSettings.setOnClickListener {
startActivity(
@@ -239,84 +249,6 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
}
}
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnStorage.root,
iconRes = R.drawable.ic_my_storage,
title = getString(R.string.screen_my_storage)
) {
startActivity(
Intent(
requireContext(),
AudioContentBoxActivity::class.java
)
)
}
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnBlockList.root,
iconRes = R.drawable.ic_my_block,
title = getString(R.string.screen_my_block_list)
) {
startActivity(
Intent(
requireContext(),
BlockMemberActivity::class.java
)
)
}
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnMorningCall.root,
iconRes = R.drawable.ic_my_alarm,
title = getString(R.string.screen_my_morning_call)
) {
startActivity(
Intent(
requireActivity(),
AlarmListActivity::class.java
)
)
}
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnNotice.root,
iconRes = R.drawable.ic_my_notice,
title = getString(R.string.screen_my_notice)
) {
startActivity(
Intent(
requireActivity(),
NoticeActivity::class.java
)
)
}
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnEvent.root,
iconRes = R.drawable.ic_my_event,
title = getString(R.string.screen_my_event)
) {
startActivity(
Intent(
requireActivity(),
EventActivity::class.java
)
)
}
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnCustomerService.root,
iconRes = R.drawable.ic_my_service_center,
title = getString(R.string.screen_my_customer_service)
) {
startActivity(
Intent(
requireActivity(),
ServiceCenterActivity::class.java
)
)
}
if (SharedPreferenceManager.role == MemberRole.CREATOR.name) {
binding.tvMyChannel.visibility = View.VISIBLE
binding.tvMyChannel.setOnClickListener {
@@ -335,6 +267,8 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
} else {
binding.tvMyChannel.visibility = View.GONE
}
updateFunctionButtons()
} else {
binding.ivSettings.visibility = View.GONE
binding.llFunctionButtonGrid.visibility = View.GONE
@@ -342,19 +276,19 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
binding.rlProfileContainer.visibility = View.GONE
binding.llProfileLoginContainer.visibility = View.VISIBLE
binding.llProfileLoginContainer.setOnClickListener {
(requireActivity() as MainActivity).showLoginActivity()
showLoginActivity()
}
binding.tvCanAmount.text =
SodaLiveApplicationHolder.get().getString(R.string.common_zero)
binding.tvCanAmount.setOnClickListener {
(requireActivity() as MainActivity).showLoginActivity()
showLoginActivity()
}
binding.tvPointAmount.text =
SodaLiveApplicationHolder.get().getString(R.string.common_zero)
binding.tvPointAmount.setOnClickListener {
(requireActivity() as MainActivity).showLoginActivity()
showLoginActivity()
}
binding.tvChargeCan.visibility = View.INVISIBLE
@@ -380,58 +314,6 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
}
viewModel.myPageLiveData.observe(viewLifecycleOwner) {
val isKoreanUser = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanUser) {
binding.btnIdentityVerification.root.visibility = View.VISIBLE
if (it.isAuth) {
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnIdentityVerification.root,
iconRes = R.drawable.ic_my_auth,
title = getString(R.string.screen_my_identity_verified)
)
} else {
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnIdentityVerification.root,
iconRes = R.drawable.ic_my_auth,
title = getString(R.string.screen_my_identity_verification)
) {
showAuthDialog()
}
}
} else {
binding.btnIdentityVerification.root.visibility = View.INVISIBLE
}
if (it.isAuth) {
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnCoupon.root,
iconRes = R.drawable.ic_my_coupon,
title = getString(R.string.screen_my_coupon_register)
) {
startActivity(
Intent(
requireActivity(),
CanCouponActivity::class.java
)
)
}
} else {
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnCoupon.root,
iconRes = R.drawable.ic_my_coupon,
title = getString(R.string.screen_my_coupon_register)
) {
Toast.makeText(
requireContext(),
getString(R.string.screen_my_auth_required),
Toast.LENGTH_LONG
).show()
showAuthDialog()
}
}
binding.ivProfile.load(it.profileUrl) {
crossfade(true)
placeholder(R.drawable.ic_place_holder)
@@ -441,9 +323,157 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
binding.tvCanAmount.text = (it.chargeCan + it.rewardCan).moneyFormat()
binding.tvPointAmount.text = it.point.moneyFormat()
updateFunctionButtons(it.isAuth)
}
}
private fun setupFunctionButtonGrid() {
binding.rvFunctionButtons.layoutManager = GridLayoutManager(
requireContext(),
FUNCTION_BUTTON_SPAN_COUNT
)
if (binding.rvFunctionButtons.itemDecorationCount == 0) {
binding.rvFunctionButtons.addItemDecoration(
GridSpacingItemDecoration(
spanCount = FUNCTION_BUTTON_SPAN_COUNT,
spacing = 16f.dpToPx().toInt(),
includeEdge = false
)
)
}
binding.rvFunctionButtons.adapter = functionButtonAdapter
}
private fun updateFunctionButtons(isAuth: Boolean? = null) {
functionButtonAdapter.submitList(buildFunctionButtonItems(isAuth))
}
private fun buildFunctionButtonItems(isAuth: Boolean?): List<FunctionButtonItem> {
val isKoreanUser = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
val items = mutableListOf(
FunctionButtonItem(
iconRes = R.drawable.ic_my_storage,
title = getString(R.string.screen_my_storage)
) {
startActivity(
Intent(
requireContext(),
AudioContentBoxActivity::class.java
)
)
},
FunctionButtonItem(
iconRes = R.drawable.ic_my_block,
title = getString(R.string.screen_my_block_list)
) {
startActivity(
Intent(
requireContext(),
BlockMemberActivity::class.java
)
)
}
)
val shouldShowCouponButton = if (isKoreanUser) {
isAuth != null
} else {
SharedPreferenceManager.isAdultContentVisible
}
if (shouldShowCouponButton) {
items += FunctionButtonItem(
iconRes = R.drawable.ic_my_coupon,
title = getString(R.string.screen_my_coupon_register)
) {
if ((isAuth == true) || !isKoreanUser) {
startActivity(
Intent(
requireActivity(),
CanCouponActivity::class.java
)
)
} else {
Toast.makeText(
requireContext(),
getString(R.string.screen_my_auth_required),
Toast.LENGTH_LONG
).show()
showAuthDialog()
}
}
}
items += listOf(
FunctionButtonItem(
iconRes = R.drawable.ic_my_alarm,
title = getString(R.string.screen_my_morning_call)
) {
startActivity(
Intent(
requireActivity(),
AlarmListActivity::class.java
)
)
},
FunctionButtonItem(
iconRes = R.drawable.ic_my_notice,
title = getString(R.string.screen_my_notice)
) {
startActivity(
Intent(
requireActivity(),
NoticeActivity::class.java
)
)
},
FunctionButtonItem(
iconRes = R.drawable.ic_my_event,
title = getString(R.string.screen_my_event)
) {
startActivity(
Intent(
requireActivity(),
EventActivity::class.java
)
)
},
FunctionButtonItem(
iconRes = R.drawable.ic_my_service_center,
title = getString(R.string.screen_my_customer_service)
) {
startActivity(
Intent(
requireActivity(),
ServiceCenterActivity::class.java
)
)
}
)
if (isKoreanUser && isAuth != null) {
items += FunctionButtonItem(
iconRes = R.drawable.ic_my_auth,
title = if (isAuth) {
getString(R.string.screen_my_identity_verified)
} else {
getString(R.string.screen_my_identity_verification)
}
) {
if (!isAuth) {
showAuthDialog()
}
}
}
return items
}
private fun showAuthDialog() {
Auth.auth(requireActivity(), requireContext()) { json ->
val bootpayResponse = Gson().fromJson(
@@ -469,4 +499,11 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
}
}
}
private fun showLoginActivity() {
when (val activity = requireActivity()) {
is MainActivity -> activity.showLoginActivity()
is MainV2Activity -> activity.showLoginActivity()
}
}
}

View File

@@ -417,9 +417,9 @@ class CanPaymentActivity : BaseActivity<ActivityCanPaymentBinding>(
}
}
override fun onNewIntent(intent: Intent?) {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent?.data?.let { handlePayverseDeeplink(it) }
intent.data?.let { handlePayverseDeeplink(it) }
}
private fun handleUrl(view: WebView, url: String): Boolean {

View File

@@ -11,10 +11,10 @@ import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityCanStatusBinding
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.mypage.can.status.charge.CanChargeStatusFragment
import kr.co.vividnext.sodalive.mypage.can.status.use.CanUseStatusFragment
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.koin.android.ext.android.inject
class CanStatusActivity : BaseActivity<ActivityCanStatusBinding>(
@@ -137,7 +137,7 @@ class CanStatusActivity : BaseActivity<ActivityCanStatusBinding>(
}
private fun onClickBackButton() {
val intent = Intent(applicationContext, MainActivity::class.java)
val intent = Intent(applicationContext, MainV2Activity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent)

View File

@@ -0,0 +1,47 @@
package kr.co.vividnext.sodalive.mypage.function_button
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.common.FunctionButtonHelper
import kr.co.vividnext.sodalive.databinding.ItemFunctionButtonBinding
class FunctionButtonAdapter : RecyclerView.Adapter<FunctionButtonAdapter.ViewHolder>() {
private val items = mutableListOf<FunctionButtonItem>()
inner class ViewHolder(
private val binding: ItemFunctionButtonBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: FunctionButtonItem) {
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.root,
iconRes = item.iconRes,
title = item.title,
clickListener = View.OnClickListener { item.onClick() }
)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
ItemFunctionButtonBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount() = items.size
@SuppressLint("NotifyDataSetChanged")
fun submitList(newItems: List<FunctionButtonItem>) {
items.clear()
items.addAll(newItems)
notifyDataSetChanged()
}
}

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.mypage.function_button
import androidx.annotation.DrawableRes
data class FunctionButtonItem(
@param:DrawableRes val iconRes: Int,
val title: String,
val onClick: () -> Unit
)

View File

@@ -11,9 +11,9 @@ import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityPointStatusBinding
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.mypage.point.reward.PointRewardStatusFragment
import kr.co.vividnext.sodalive.mypage.point.use.PointUseStatusFragment
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.koin.android.ext.android.inject
class PointStatusActivity : BaseActivity<ActivityPointStatusBinding>(
@@ -120,7 +120,7 @@ class PointStatusActivity : BaseActivity<ActivityPointStatusBinding>(
}
private fun onClickBackButton() {
val intent = Intent(applicationContext, MainActivity::class.java)
val intent = Intent(applicationContext, MainV2Activity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent)

View File

@@ -8,6 +8,9 @@ import android.view.View
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdRequest
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
@@ -18,6 +21,7 @@ import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.following.FollowingCreatorAdapter
import org.koin.android.ext.android.inject
import kotlin.math.roundToInt
class NotificationReceiveSettingsActivity : BaseActivity<ActivityNotificationReceiveSettingsBinding>(
ActivityNotificationReceiveSettingsBinding::inflate
@@ -53,6 +57,33 @@ class NotificationReceiveSettingsActivity : BaseActivity<ActivityNotificationRec
binding.ivMessage.setOnClickListener { viewModel.toggleMessage() }
setupFollowingChannels()
setupInlineBanner()
}
override fun onDestroy() {
binding.yandexInlineBannerView.destroy()
super.onDestroy()
}
private fun setupInlineBanner() {
binding.yandexInlineBannerView.post {
val density = resources.displayMetrics.density
val adWidthPixels = binding.yandexInlineBannerView.width.takeIf { it > 0 } ?: screenWidth
val adWidthDp = (adWidthPixels / density).roundToInt()
val maxAdHeightDp = 90
binding.yandexInlineBannerView.apply {
setAdUnitId(BuildConfig.YANDEX_INLINE_BANNER_NOTIFICATION_RECEIVE_SETTINGS_AD_UNIT_ID)
setAdSize(
BannerAdSize.inlineSize(
this@NotificationReceiveSettingsActivity,
adWidthDp,
maxAdHeightDp
)
)
loadAd(AdRequest.Builder().build())
}
}
}
private fun setupFollowingChannels() {

View File

@@ -20,7 +20,7 @@ import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.databinding.ActivitySplashBinding
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
@SuppressLint("CustomSplashScreen")
class SplashActivity : BaseActivity<ActivitySplashBinding>(ActivitySplashBinding::inflate) {
@@ -174,7 +174,7 @@ class SplashActivity : BaseActivity<ActivitySplashBinding>(ActivitySplashBinding
private fun showMainActivity(extras: Bundle?) {
handler.postDelayed({
startActivity(
Intent(applicationContext, MainActivity::class.java).apply {
Intent(applicationContext, MainV2Activity::class.java).apply {
putExtra(Constants.EXTRA_DATA, extras)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)

View File

@@ -1,7 +1,6 @@
package kr.co.vividnext.sodalive.user.login
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
@@ -45,9 +44,9 @@ import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityLoginBinding
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.user.find_password.FindPasswordActivity
import kr.co.vividnext.sodalive.user.signup.SignUpActivity
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.koin.android.ext.android.inject
import java.util.UUID
import androidx.core.net.toUri
@@ -434,7 +433,7 @@ class LoginActivity : BaseActivity<ActivityLoginBinding>(ActivityLoginBinding::i
}
private fun navigateToMain() {
val nextIntent = Intent(this@LoginActivity, MainActivity::class.java)
val nextIntent = Intent(this@LoginActivity, MainV2Activity::class.java)
val extras = intent.getBundleExtra(Constants.EXTRA_DATA)
?: if (intent.extras != null) {
intent.extras

View File

@@ -17,8 +17,8 @@ import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivitySignupBinding
import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.settings.terms.TermsActivity
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
import org.koin.android.ext.android.inject
@OptIn(UnstableApi::class)
@@ -152,7 +152,7 @@ class SignUpActivity : BaseActivity<ActivitySignupBinding>(ActivitySignupBinding
}
private fun navigateToMain() {
val nextIntent = Intent(this@SignUpActivity, MainActivity::class.java)
val nextIntent = Intent(this@SignUpActivity, MainV2Activity::class.java)
val extras = intent.getBundleExtra(Constants.EXTRA_DATA)
?: if (intent.extras != null) {
intent.extras

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.main
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentV2MainChatBinding
class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
FragmentV2MainChatBinding::inflate
)

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.main
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentV2MainContentBinding
class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
FragmentV2MainContentBinding::inflate
)

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.main
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentV2MainHomeBinding
class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
FragmentV2MainHomeBinding::inflate
)

View File

@@ -0,0 +1,654 @@
package kr.co.vividnext.sodalive.v2.main
import android.Manifest
import android.content.ComponentName
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import coil.load
import coil.transform.RoundedCornersTransformation
import com.google.common.util.concurrent.ListenableFuture
import com.google.firebase.messaging.FirebaseMessaging
import com.gun0912.tedpermission.PermissionListener
import com.gun0912.tedpermission.normal.TedPermission
import com.orhanobut.logger.Logger
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerFragment
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
import kr.co.vividnext.sodalive.audition.AuditionActivity
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityMainV2Binding
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
import kr.co.vividnext.sodalive.main.EventPopupDialogFragment
import kr.co.vividnext.sodalive.message.MessageActivity
import kr.co.vividnext.sodalive.mypage.MyPageFragment
import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
import kr.co.vividnext.sodalive.settings.notification.NotificationSettingsDialog
import kr.co.vividnext.sodalive.user.login.LoginActivity
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import java.util.Locale
import kotlin.math.max
@UnstableApi
class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding::inflate) {
private val viewModel: MainV2ViewModel by inject()
private lateinit var notificationSettingsDialog: NotificationSettingsDialog
private var mediaController: MediaController? = null
private var mediaControllerFuture: ListenableFuture<MediaController>? = null
private val handler = Handler(Looper.getMainLooper())
private val showMiniPlayerRunnable = Runnable { initAndVisibleMiniPlayer() }
private var playerStateJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
overrideRootWindowInsets()
checkPermissions()
trackAppLaunchIfNeeded()
pushTokenUpdate()
if (isLoggedIn()) {
updatePidAndGaid()
getEventPopup()
observePlayerState()
handler.postDelayed({ executeDeeplink(intent) }, 1000)
}
}
private fun overrideRootWindowInsets() {
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
val left = max(systemBars.left, ime.left)
val top = systemBars.top
val right = max(systemBars.right, ime.right)
v.setPadding(left, top, right, 0)
insets
}
ViewCompat.requestApplyInsets(binding.root)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (isLoggedIn()) {
executeDeeplink(intent)
}
}
override fun onResume() {
super.onResume()
getMemberInfo()
startService(
Intent(this, AudioContentPlayService::class.java).apply {
action = AudioContentPlayService.MusicAction.INIT.name
}
)
}
override fun onDestroy() {
deInitMiniPlayer()
playerStateJob?.cancel()
super.onDestroy()
}
override fun setupView() {
notificationSettingsDialog = NotificationSettingsDialog(
this,
layoutInflater
) { isNotifiedLive, isNotifiedUploadContent, isNotifiedMessage ->
viewModel.updateNotificationSettings(
isNotifiedLive,
isNotifiedUploadContent,
isNotifiedMessage
)
}
setupBottomNavigation()
}
fun showLoginActivity() {
if (SharedPreferenceManager.token.isBlank()) {
val extras = intent.extras
startActivity(
Intent(applicationContext, LoginActivity::class.java).apply {
putExtra(Constants.EXTRA_DATA, extras)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
)
}
}
fun openChatTab() {
viewModel.clickTab(MainV2Tab.CHAT)
}
private fun setupBottomNavigation() {
binding.bottomNavigation.setOnItemSelectedListener { item ->
when (item.itemId) {
R.id.menu_main_v2_home -> viewModel.clickTab(MainV2Tab.HOME)
R.id.menu_main_v2_content -> viewModel.clickTab(MainV2Tab.CONTENT)
R.id.menu_main_v2_chat -> viewModel.clickTab(MainV2Tab.CHAT)
R.id.menu_main_v2_my -> viewModel.clickTab(MainV2Tab.MY)
}
true
}
binding.bottomNavigation.apply {
itemIconTintList = null
}
viewModel.currentTab.observe(this) { tab ->
val itemId = when (tab) {
MainV2Tab.HOME -> R.id.menu_main_v2_home
MainV2Tab.CONTENT -> R.id.menu_main_v2_content
MainV2Tab.CHAT -> R.id.menu_main_v2_chat
MainV2Tab.MY -> R.id.menu_main_v2_my
}
if (binding.bottomNavigation.selectedItemId != itemId) {
binding.bottomNavigation.selectedItemId = itemId
}
changeFragment(tab)
}
}
private fun changeFragment(currentTab: MainV2Tab) {
val tag = currentTab.toString()
val fragmentManager = supportFragmentManager
val fragmentTransaction = fragmentManager.beginTransaction()
fragmentManager.primaryNavigationFragment?.let {
fragmentTransaction.hide(it)
}
var fragment = fragmentManager.findFragmentByTag(tag)
if (fragment == null) {
fragment = when (currentTab) {
MainV2Tab.HOME -> HomeMainFragment()
MainV2Tab.CONTENT -> ContentMainFragment()
MainV2Tab.CHAT -> ChatMainFragment()
MainV2Tab.MY -> MyPageFragment()
}
fragmentTransaction.add(R.id.fl_container, fragment, tag)
} else {
fragmentTransaction.show(fragment)
}
fragmentTransaction.setPrimaryNavigationFragment(fragment)
fragmentTransaction.setReorderingAllowed(true)
fragmentTransaction.commitNow()
}
private fun observePlayerState() {
playerStateJob = lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
SharedPreferenceManager.isPlayerServiceRunningFlow.collect { isRunning ->
if (isRunning) {
handler.removeCallbacks(showMiniPlayerRunnable)
handler.postDelayed(showMiniPlayerRunnable, 1500)
} else {
deInitMiniPlayer()
}
}
}
}
}
private fun initAndVisibleMiniPlayer() {
binding.clMiniPlayer.visibility = View.VISIBLE
binding.clMiniPlayer.setOnClickListener { showPlayerFragment() }
binding.ivPlayerStop.setOnClickListener {
startService(
Intent(applicationContext, AudioContentPlayerService::class.java).apply {
action = "STOP_SERVICE"
}
)
}
connectPlayerService()
}
private fun connectPlayerService() {
if (mediaController != null || mediaControllerFuture != null) {
return
}
val componentName = ComponentName(applicationContext, AudioContentPlayerService::class.java)
val sessionToken = SessionToken(applicationContext, componentName)
val controllerFuture =
MediaController.Builder(applicationContext, sessionToken).buildAsync()
mediaControllerFuture = controllerFuture
controllerFuture.addListener(
{
try {
if (mediaController != null) {
controllerFuture.get().release()
return@addListener
}
mediaController = controllerFuture.get()
setupMediaController()
updateMediaMetadata(mediaController?.mediaMetadata)
binding.ivPlayerPlayOrPause.setImageResource(
if (mediaController?.isPlaying == true) {
R.drawable.ic_player_pause
} else {
R.drawable.ic_player_play
}
)
binding.ivPlayerPlayOrPause.setOnClickListener {
mediaController?.let {
if (it.playWhenReady) {
it.pause()
} else {
it.play()
}
}
}
} catch (throwable: Throwable) {
Logger.e(throwable, "Failed to connect player service")
} finally {
mediaControllerFuture = null
}
},
ContextCompat.getMainExecutor(applicationContext)
)
}
private fun updateMediaMetadata(metadata: MediaMetadata?) {
metadata?.let {
binding.tvPlayerTitle.text = it.title
binding.tvPlayerNickname.text = it.artist
binding.ivPlayerCover.load(it.artworkUri) {
crossfade(true)
placeholder(R.drawable.ic_place_holder)
transformations(RoundedCornersTransformation(4f))
}
}
}
private fun setupMediaController() {
if (mediaController == null) {
deInitMiniPlayer()
return
}
mediaController!!.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
updateMediaMetadata(mediaItem?.mediaMetadata)
}
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
binding.ivPlayerPlayOrPause.setImageResource(
if (playWhenReady) {
R.drawable.ic_player_pause
} else {
R.drawable.ic_player_play
}
)
}
})
}
private fun deInitMiniPlayer() {
handler.removeCallbacks(showMiniPlayerRunnable)
binding.clMiniPlayer.visibility = View.GONE
mediaControllerFuture?.cancel(true)
mediaControllerFuture = null
mediaController?.release()
mediaController = null
}
private fun showPlayerFragment() {
val playerFragment = AudioContentPlayerFragment(screenWidth, arrayListOf())
playerFragment.show(supportFragmentManager, playerFragment.tag)
}
private fun checkPermissions() {
val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
TedPermission.create()
.setPermissionListener(object : PermissionListener {
override fun onPermissionGranted() {
}
override fun onPermissionDenied(deniedPermissions: MutableList<String>?) {
}
})
.setDeniedMessage(R.string.record_audio_permission_denied_message)
.setPermissions(*permissions.toTypedArray())
.check()
}
private fun trackAppLaunchIfNeeded() {
handler.postDelayed({
val alreadyTrackingAppLaunch = SharedPreferenceManager.alreadyTrackingAppLaunch
val pid = SharedPreferenceManager.marketingPid
if (!alreadyTrackingAppLaunch && pid.isNotBlank()) {
SharedPreferenceManager.alreadyTrackingAppLaunch = true
viewModel.adTrackingAppLaunch(pid = pid)
}
}, 1000)
}
private fun pushTokenUpdate() {
FirebaseMessaging.getInstance().token.addOnCompleteListener {
if (!it.isSuccessful) {
Logger.v("Fetching FCM registration token failed", it.exception)
return@addOnCompleteListener
}
val pushToken = it.result
if (pushToken != null) {
SharedPreferenceManager.pushToken = pushToken
if (isLoggedIn()) {
viewModel.pushTokenUpdate(pushToken)
}
}
}
}
private fun updatePidAndGaid() {
handler.postDelayed({
viewModel.fetchAndUpdateGaidAndPid(context = applicationContext)
}, 3000)
}
private fun getMemberInfo() {
if (isLoggedIn()) {
viewModel.getMemberInfo(context = applicationContext) {
notificationSettingsDialog.show(screenWidth)
}
}
}
private fun getEventPopup() {
viewModel.getEventPopup {
if (SharedPreferenceManager.notShowingEventPopupId != it.id) {
EventPopupDialogFragment(
screenWidth = screenWidth,
eventItem = it
) {
startActivity(
Intent(applicationContext, EventDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_EVENT, it)
}
)
}.show(supportFragmentManager, EventPopupDialogFragment::class.java.simpleName)
}
}
}
private fun executeDeeplink(intent: Intent) {
val bundle = intent.getBundleExtra(Constants.EXTRA_DATA) ?: return
val deepLinkUrl = bundle.getString("deep_link")
val routeBundle = if (!deepLinkUrl.isNullOrBlank()) {
buildBundleFromDeepLinkUrl(deepLinkUrl) ?: bundle
} else {
bundle
}
if (executeBundleRoute(routeBundle)) {
clearDeferredDeepLink()
}
}
private fun buildBundleFromDeepLinkUrl(deepLinkUrl: String): Bundle? {
val data = runCatching { deepLinkUrl.toUri() }.getOrNull() ?: return null
val extras = Bundle().apply {
putString("deep_link", deepLinkUrl)
}
fun putQuery(key: String) {
val value = data.getQueryParameter(key)
if (!value.isNullOrBlank()) {
extras.putString(key, value)
}
}
putQuery("channel_id")
putQuery("message_id")
putQuery("audition_id")
putQuery("content_id")
putQuery("deep_link_value")
putQuery("deep_link_sub5")
putQuery(Constants.EXTRA_COMMUNITY_CREATOR_ID)
putQuery(Constants.EXTRA_COMMUNITY_POST_ID)
applyPathDeepLink(data = data) { key, value ->
if (!value.isNullOrBlank() && !extras.containsKey(key)) {
extras.putString(key, value)
}
}
return extras
}
private fun applyPathDeepLink(
data: android.net.Uri,
putIfAbsent: (key: String, value: String?) -> Unit
) {
val host = data.host?.lowercase(Locale.ROOT).orEmpty()
val pathSegments = data.pathSegments.filter { it.isNotBlank() }
val pathType: String
val pathId: String?
if (host.isNotBlank() && host != "payverse") {
pathType = host
pathId = pathSegments.firstOrNull()
} else if (pathSegments.isNotEmpty()) {
pathType = pathSegments[0].lowercase(Locale.ROOT)
pathId = pathSegments.getOrNull(1)
} else {
return
}
when (pathType) {
"content" -> {
putIfAbsent("content_id", pathId)
putIfAbsent("deep_link_value", "content")
putIfAbsent("deep_link_sub5", pathId)
}
"series" -> {
putIfAbsent("deep_link_value", "series")
putIfAbsent("deep_link_sub5", pathId)
}
"community" -> {
putIfAbsent("deep_link_value", "community")
putIfAbsent(Constants.EXTRA_COMMUNITY_CREATOR_ID, pathId)
putIfAbsent("deep_link_sub5", pathId)
}
"message" -> {
putIfAbsent("deep_link_value", "message")
putIfAbsent("message_id", pathId)
putIfAbsent("deep_link_sub5", pathId)
}
"audition" -> {
putIfAbsent("deep_link_value", "audition")
putIfAbsent("audition_id", pathId)
putIfAbsent("deep_link_sub5", pathId)
}
}
}
private fun executeBundleRoute(bundle: Bundle): Boolean {
val channelId = bundle.getString("channel_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_USER_ID).takeIf { it > 0 }
val messageId = bundle.getString("message_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_MESSAGE_ID).takeIf { it > 0 }
val contentId = bundle.getString("content_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_AUDIO_CONTENT_ID).takeIf { it > 0 }
val auditionId = bundle.getString("audition_id")?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_AUDITION_ID).takeIf { it > 0 }
val communityCreatorId = bundle.getString(Constants.EXTRA_COMMUNITY_CREATOR_ID)?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_COMMUNITY_CREATOR_ID).takeIf { it > 0 }
val communityPostId = bundle.getString(Constants.EXTRA_COMMUNITY_POST_ID)?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_COMMUNITY_POST_ID).takeIf { it > 0 }
when {
channelId != null && channelId > 0 -> {
startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply {
putExtra(Constants.EXTRA_USER_ID, channelId)
}
)
return true
}
contentId != null && contentId > 0 -> {
startActivity(
Intent(applicationContext, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)
}
)
return true
}
messageId != null && messageId > 0 -> {
startActivity(Intent(applicationContext, MessageActivity::class.java))
return true
}
communityCreatorId != null && communityCreatorId > 0 -> {
startActivity(
Intent(applicationContext, CreatorCommunityAllActivity::class.java).apply {
putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, communityCreatorId)
if (communityPostId != null && communityPostId > 0) {
putExtra(Constants.EXTRA_COMMUNITY_POST_ID, communityPostId)
}
}
)
return true
}
auditionId != null && auditionId > 0 -> {
startActivity(Intent(applicationContext, AuditionActivity::class.java))
return true
}
}
val deepLinkValue = bundle.getString("deep_link_value")
val deepLinkValueId = bundle.getString("deep_link_sub5")?.toLongOrNull()
return !deepLinkValue.isNullOrBlank() && routeByDeepLinkValue(deepLinkValue, deepLinkValueId)
}
private fun routeByDeepLinkValue(deepLinkValue: String, deepLinkValueId: Long?): Boolean {
return when (deepLinkValue.lowercase(Locale.ROOT)) {
"series" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, SeriesDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_SERIES_ID, deepLinkValueId)
}
)
true
}
"content" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, AudioContentDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, deepLinkValueId)
}
)
true
}
"channel" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply {
putExtra(Constants.EXTRA_USER_ID, deepLinkValueId)
}
)
true
}
"community" -> {
if (deepLinkValueId == null || deepLinkValueId <= 0) {
return false
}
startActivity(
Intent(applicationContext, CreatorCommunityAllActivity::class.java).apply {
putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, deepLinkValueId)
}
)
true
}
"message" -> {
startActivity(Intent(applicationContext, MessageActivity::class.java))
true
}
"audition" -> {
startActivity(Intent(applicationContext, AuditionActivity::class.java))
true
}
else -> false
}
}
private fun clearDeferredDeepLink() {
SharedPreferenceManager.marketingUtmSource = ""
SharedPreferenceManager.marketingUtmMedium = ""
SharedPreferenceManager.marketingUtmCampaign = ""
SharedPreferenceManager.marketingLinkValue = ""
SharedPreferenceManager.marketingLinkValueId = 0
}
private fun isLoggedIn(): Boolean {
return SharedPreferenceManager.token.isNotBlank() && SharedPreferenceManager.token.length > 10
}
}

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.main
enum class MainV2Tab {
HOME,
CONTENT,
CHAT,
MY
}

View File

@@ -0,0 +1,231 @@
package kr.co.vividnext.sodalive.v2.main
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.android.gms.ads.identifier.AdvertisingIdClient
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.audio_content.AddAllPlaybackTrackingRequest
import kr.co.vividnext.sodalive.audio_content.AudioContentRepository
import kr.co.vividnext.sodalive.audio_content.PlaybackTrackingData
import kr.co.vividnext.sodalive.audio_content.PlaybackTrackingRepository
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.main.MarketingInfoUpdateRequest
import kr.co.vividnext.sodalive.main.PushTokenUpdateRequest
import kr.co.vividnext.sodalive.settings.ContentType
import kr.co.vividnext.sodalive.settings.event.EventItem
import kr.co.vividnext.sodalive.settings.event.EventRepository
import kr.co.vividnext.sodalive.settings.notification.UpdateNotificationSettingRequest
import kr.co.vividnext.sodalive.tracking.AdTrackingRepository
import kr.co.vividnext.sodalive.tracking.FirebaseTracking
import kr.co.vividnext.sodalive.tracking.NotiflyClient
import kr.co.vividnext.sodalive.user.UserRepository
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
class MainV2ViewModel(
private val userRepository: UserRepository,
private val eventRepository: EventRepository,
private val adTrackingRepository: AdTrackingRepository,
private val audioContentRepository: AudioContentRepository,
private val playbackTrackingRepository: PlaybackTrackingRepository
) : BaseViewModel() {
private val _currentTab = MutableLiveData(MainV2Tab.HOME)
val currentTab: LiveData<MainV2Tab>
get() = _currentTab
fun clickTab(tab: MainV2Tab) {
if (_currentTab.value != tab) {
_currentTab.postValue(tab)
}
}
fun updateNotificationSettings(
isNotifiedLive: Boolean,
isNotifiedUploadContent: Boolean,
isNotifiedMessage: Boolean
) {
compositeDisposable.add(
userRepository.updateNotificationSettings(
request = UpdateNotificationSettingRequest(
isNotifiedLive,
isNotifiedUploadContent,
isNotifiedMessage
),
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({}, {})
)
}
fun pushTokenUpdate(pushToken: String) {
compositeDisposable.add(
userRepository
.updatePushToken(
PushTokenUpdateRequest(pushToken = pushToken),
"Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({}, {})
)
}
fun getMemberInfo(context: Context, showNotificationSettingsDialog: () -> Unit) {
compositeDisposable.add(
userRepository.getMemberInfo(token = "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
val data = it.data
SharedPreferenceManager.can = data.can
SharedPreferenceManager.point = data.point
SharedPreferenceManager.role = data.role.name
SharedPreferenceManager.isAuth = data.isAuth
val localCountryCode = SharedPreferenceManager.countryCode.ifBlank { "KR" }
val resolvedCountryCode = data.countryCode?.ifBlank { "KR" } ?: localCountryCode
val resolvedIsAdultContentVisible =
data.isAdultContentVisible ?: SharedPreferenceManager.isAdultContentVisible
val resolvedContentType =
data.contentType
?: ContentType.entries.getOrNull(SharedPreferenceManager.contentPreference)
?: ContentType.ALL
SharedPreferenceManager.countryCode = resolvedCountryCode
SharedPreferenceManager.isAdultContentVisible = resolvedIsAdultContentVisible
SharedPreferenceManager.contentPreference = resolvedContentType.ordinal
SharedPreferenceManager.isAuditionNotification =
data.auditionNotice ?: false
if (
data.followingChannelUploadContentNotice == null &&
data.followingChannelLiveNotice == null &&
data.messageNotice == null
) {
showNotificationSettingsDialog()
}
val dateFormat = SimpleDateFormat(
"yyyy-MM-dd, HH:mm:ss",
Locale.getDefault()
)
val lastActiveDate = dateFormat.format(Date())
val params = mutableMapOf(
"nickname" to SharedPreferenceManager.nickname,
"last_active_date" to lastActiveDate,
"charge_count" to data.chargeCount,
"signup_date" to data.signupDate,
"is_auth" to data.isAuth,
"gender" to data.gender,
"can" to data.can
)
NotiflyClient.setUser(
context = context,
userId = SharedPreferenceManager.userId,
params = params
)
FirebaseTracking.login("email")
}
},
{}
)
)
}
fun addAllPlaybackTracking() {
val trackingDataList = playbackTrackingRepository.getAllPlaybackTracking()
.filter { it.endPosition != null }
.filter { it.endPosition!! - it.startPosition >= 4000 }
.map {
PlaybackTrackingData(it.contentId, it.playDateTime, it.isPreview)
}
if (trackingDataList.isNotEmpty()) {
compositeDisposable.add(
audioContentRepository.addAllPlaybackTracking(
request = AddAllPlaybackTrackingRequest(trackingDataList = trackingDataList),
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success) {
playbackTrackingRepository.removeAllPlaybackTracking()
}
},
{}
)
)
}
}
fun getEventPopup(onSuccess: (EventItem) -> Unit) {
compositeDisposable.add(
eventRepository.getEventPopup(token = "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success && it.data != null) {
onSuccess(it.data)
}
},
{}
)
)
}
fun fetchAndUpdateGaidAndPid(context: Context) {
Executors.newSingleThreadExecutor().execute {
try {
val adInfo = AdvertisingIdClient.getAdvertisingIdInfo(context)
val request = MarketingInfoUpdateRequest(
adid = adInfo.id.orEmpty(),
pid = SharedPreferenceManager.marketingPid
)
updateMarketingInfo(request)
} catch (e: Exception) {
e.printStackTrace()
updateMarketingInfo(
MarketingInfoUpdateRequest(
adid = "",
pid = SharedPreferenceManager.marketingPid
)
)
}
}
}
fun adTrackingAppLaunch(pid: String) {
compositeDisposable.add(
adTrackingRepository.appLaunch(pid)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({}, {})
)
}
private fun updateMarketingInfo(request: MarketingInfoUpdateRequest) {
compositeDisposable.add(
userRepository.updateMarketingInfo(
request,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({}, {})
)
}
}

View File

@@ -0,0 +1,97 @@
package kr.co.vividnext.sodalive.v2.widget
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import kr.co.vividnext.sodalive.R
class CapsuleTabBarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var container: LinearLayout? = null
private var selectionState: CapsuleTabSelectionState? = null
private var onTabSelected: ((index: Int) -> Unit)? = null
override fun onFinishInflate() {
super.onFinishInflate()
container = findViewById(R.id.ll_capsule_tab_container)
}
fun setMenus(
menus: List<String>,
selectedIndex: Int = 0
) {
applyState(CapsuleTabSelectionState.create(menus, selectedIndex), notifySelection = false)
}
fun selectTab(index: Int) {
val currentState = selectionState ?: return
if (index !in currentState.menus.indices) return
if (index == currentState.selectedIndex) return
applyState(currentState.select(index), notifySelection = true)
}
fun setOnTabSelectedListener(listener: ((index: Int) -> Unit)?) {
onTabSelected = listener
}
private fun applyState(
state: CapsuleTabSelectionState,
notifySelection: Boolean
) {
selectionState = state
val tabContainer = requireNotNull(container) { "Capsule tab container is not inflated." }
tabContainer.removeAllViews()
state.menus.forEachIndexed { index, label ->
tabContainer.addView(createTabView(label, state.isSelected(index), index))
}
if (notifySelection) {
onTabSelected?.invoke(state.selectedIndex)
}
}
private fun createTabView(
label: String,
selected: Boolean,
index: Int
): TextView {
return TextView(context).apply {
text = label
isSelected = selected
setTextAppearance(R.style.Typography_Body5)
setTextColor(ContextCompat.getColor(context, R.color.white))
background = ContextCompat.getDrawable(
context,
if (selected) R.drawable.bg_capsule_tab_selected else R.drawable.bg_capsule_tab_normal
)
gravity = Gravity.CENTER
minHeight = resources.getDimensionPixelSize(R.dimen.spacing_32)
setPadding(
resources.getDimensionPixelSize(R.dimen.spacing_12),
resources.getDimensionPixelSize(R.dimen.spacing_8),
resources.getDimensionPixelSize(R.dimen.spacing_12),
resources.getDimensionPixelSize(R.dimen.spacing_8)
)
setOnClickListener { selectTab(index) }
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
resources.getDimensionPixelSize(R.dimen.spacing_32)
).apply {
if (index > 0) {
marginStart = resources.getDimensionPixelSize(R.dimen.spacing_8)
}
}
}
}
}

View File

@@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.v2.widget
class CapsuleTabSelectionState private constructor(
val menus: List<String>,
val selectedIndex: Int
) {
fun isSelected(index: Int): Boolean = selectedIndex == index
fun select(index: Int): CapsuleTabSelectionState = create(menus, index)
companion object {
fun create(
menus: List<String>,
selectedIndex: Int = 0
): CapsuleTabSelectionState {
require(menus.isNotEmpty()) { "Capsule tab bar requires at least one menu." }
val normalizedSelectedIndex = if (selectedIndex in menus.indices) selectedIndex else 0
return CapsuleTabSelectionState(
menus = menus.toList(),
selectedIndex = normalizedSelectedIndex
)
}
}
}

View File

@@ -0,0 +1,70 @@
package kr.co.vividnext.sodalive.v2.widget
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import kr.co.vividnext.sodalive.R
class TextTabBarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
private val tabViews: List<TextView>
get() = listOf(
findViewById(R.id.tv_text_tab_first),
findViewById(R.id.tv_text_tab_second),
findViewById(R.id.tv_text_tab_third)
)
private var selectionState: TextTabSelectionState? = null
private var onTabSelected: ((index: Int) -> Unit)? = null
override fun onFinishInflate() {
super.onFinishInflate()
tabViews.forEachIndexed { index, textView ->
textView.setOnClickListener { selectTab(index) }
}
}
fun setMenus(
menus: List<String>,
selectedIndex: Int = 0
) {
applyState(TextTabSelectionState.create(menus, selectedIndex), notifySelection = false)
}
fun selectTab(index: Int) {
val currentState = selectionState ?: return
if (index !in currentState.menus.indices) return
if (index == currentState.selectedIndex) return
applyState(currentState.select(index), notifySelection = true)
}
fun setOnTabSelectedListener(listener: ((index: Int) -> Unit)?) {
onTabSelected = listener
}
private fun applyState(
state: TextTabSelectionState,
notifySelection: Boolean
) {
selectionState = state
tabViews.forEachIndexed { index, textView ->
val hasMenu = index in state.menus.indices
textView.visibility = if (hasMenu) View.VISIBLE else View.GONE
textView.isSelected = hasMenu && state.isSelected(index)
if (hasMenu) {
textView.text = state.menus[index]
}
}
if (notifySelection) {
onTabSelected?.invoke(state.selectedIndex)
}
}
}

View File

@@ -0,0 +1,27 @@
package kr.co.vividnext.sodalive.v2.widget
class TextTabSelectionState private constructor(
val menus: List<String>,
val selectedIndex: Int
) {
fun isSelected(index: Int): Boolean = selectedIndex == index
fun select(index: Int): TextTabSelectionState = create(menus, index)
companion object {
private const val MAX_MENU_COUNT = 3
fun create(
menus: List<String>,
selectedIndex: Int = 0
): TextTabSelectionState {
require(menus.size in 1..MAX_MENU_COUNT) { "Text tab bar requires one to three menus." }
val normalizedSelectedIndex = if (selectedIndex in menus.indices) selectedIndex else 0
return TextTabSelectionState(
menus = menus.toList(),
selectedIndex = normalizedSelectedIndex
)
}
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/white" android:state_checked="true" />
<item android:color="@color/white" android:state_selected="true" />
<item android:color="@color/gray_600" />
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/white" android:state_selected="true" />
<item android:color="@color/gray_600" />
</selector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/black" />
<stroke
android:width="1dp"
android:color="@color/gray_700" />
<corners android:radius="100dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/soda_400" />
<corners android:radius="100dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FEF8E3" />
<corners android:radius="30dp" />
<stroke android:width="1dp" android:color="#F7CB50" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_nav_chat_selected" android:state_checked="true" />
<item android:drawable="@drawable/ic_nav_chat" />
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_nav_content_selected" android:state_checked="true" />
<item android:drawable="@drawable/ic_nav_content" />
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_nav_home_selected" android:state_checked="true" />
<item android:drawable="@drawable/ic_nav_home" />
</selector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_nav_my_selected" android:state_checked="true" />
<item android:drawable="@drawable/ic_nav_my" />
</selector>

View File

@@ -396,6 +396,14 @@
android:visibility="gone" />
</LinearLayout>
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxHeight="90dp"
android:layout_marginHorizontal="13.3dp"
android:layout_marginTop="13.3dp" />
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@@ -80,6 +80,15 @@
android:layout_height="1dp"
android:background="#909090" />
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="13.3dp"
android:layout_marginTop="13.3dp"
android:layout_marginBottom="13.3dp"
android:maxHeight="90dp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black">
<FrameLayout
android:id="@+id/fl_container"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/cl_mini_player"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cl_mini_player"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/color_222222"
android:paddingHorizontal="13.3dp"
android:paddingVertical="10.7dp"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/bottom_navigation"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible">
<ImageView
android:id="@+id/iv_player_cover"
android:layout_width="36.7dp"
android:layout_height="36.7dp"
android:contentDescription="@null"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@mipmap/ic_launcher" />
<TextView
android:id="@+id/tv_player_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="10.7dp"
android:ellipsize="end"
android:fontFamily="@font/medium"
android:maxLines="2"
android:textColor="@color/color_eeeeee"
android:textSize="13sp"
app:layout_constraintEnd_toStartOf="@+id/iv_player_play_or_pause"
app:layout_constraintStart_toEndOf="@+id/iv_player_cover"
app:layout_constraintTop_toTopOf="@+id/iv_player_cover"
tools:text="JFLA 커버곡 Avicii for your self" />
<TextView
android:id="@+id/tv_player_nickname"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2.3dp"
android:fontFamily="@font/medium"
android:textColor="@color/color_d2d2d2"
android:textSize="11sp"
app:layout_constraintEnd_toEndOf="@+id/tv_player_title"
app:layout_constraintStart_toStartOf="@+id/tv_player_title"
app:layout_constraintTop_toBottomOf="@+id/tv_player_title"
tools:ignore="SmallSp"
tools:text="JFLA 커버곡 Avicii for your self" />
<ImageView
android:id="@+id/iv_player_play_or_pause"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginEnd="16dp"
android:contentDescription="@null"
app:layout_constraintBottom_toBottomOf="@+id/iv_player_stop"
app:layout_constraintEnd_toStartOf="@+id/iv_player_stop"
app:layout_constraintTop_toTopOf="@+id/iv_player_stop"
tools:src="@drawable/btn_bar_play" />
<ImageView
android:id="@+id/iv_player_stop"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@null"
android:src="@drawable/ic_noti_stop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/black"
app:itemActiveIndicatorStyle="@null"
app:itemIconTint="@null"
app:itemTextAppearanceActive="@style/Typography.Caption3"
app:itemTextAppearanceActiveBoldEnabled="false"
app:itemTextAppearanceInactive="@style/Typography.Caption3"
app:itemTextColor="@color/color_main_v2_bottom_navigation_label"
app:labelVisibilityMode="labeled"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/menu_main_v2_bottom_navigation" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -133,6 +133,14 @@
</RelativeLayout>
</LinearLayout>
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="13.3dp"
android:layout_marginTop="26.7dp"
android:maxHeight="90dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View File

@@ -49,6 +49,14 @@
android:clipToPadding="false"
android:paddingHorizontal="13.3dp" />
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="13.3dp"
android:layout_marginTop="8dp"
android:maxHeight="90dp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"

View File

@@ -82,6 +82,14 @@
</LinearLayout>
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp"
android:maxHeight="90dp" />
<!-- 인기 캐릭터 섹션 -->
<LinearLayout
android:id="@+id/ll_popular_characters"

View File

@@ -2,6 +2,7 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/black"
android:orientation="vertical">
<ImageView

View File

@@ -253,7 +253,8 @@
android:layout_marginBottom="48dp"
android:contentDescription="@null"
android:scaleType="centerCrop"
android:src="@drawable/img_banner_audition" />
android:src="@drawable/img_banner_audition"
android:visibility="gone" />
<LinearLayout
android:id="@+id/ll_series_day_of_week"

View File

@@ -143,11 +143,19 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="48dp"
android:layout_marginBottom="24dp"
android:clipToPadding="false"
android:paddingHorizontal="24dp"
android:visibility="gone" />
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="24dp"
android:maxHeight="90dp" />
<LinearLayout
android:id="@+id/ll_replay_live"
android:layout_width="match_parent"

View File

@@ -187,6 +187,14 @@
app:drawableStartCompat="@drawable/ic_live_detail_bottom" />
</LinearLayout>
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxHeight="90dp"
android:layout_marginHorizontal="13.3dp"
android:layout_marginTop="13.3dp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"

View File

@@ -276,86 +276,13 @@
android:orientation="vertical"
android:visibility="gone">
<!-- First Row -->
<LinearLayout
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_function_buttons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:baselineAligned="false"
android:orientation="horizontal">
<include
android:id="@+id/btn_storage"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_weight="1" />
<include
android:id="@+id/btn_block_list"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_weight="1" />
<include
android:id="@+id/btn_coupon"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_weight="1" />
<include
android:id="@+id/btn_morning_call"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1" />
</LinearLayout>
<!-- Second Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
android:orientation="horizontal">
<include
android:id="@+id/btn_notice"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_weight="1" />
<include
android:id="@+id/btn_event"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_weight="1" />
<include
android:id="@+id/btn_customer_service"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_weight="1" />
<include
android:id="@+id/btn_identity_verification"
layout="@layout/item_function_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1" />
</LinearLayout>
android:nestedScrollingEnabled="false"
tools:itemCount="8"
tools:listitem="@layout/item_function_button" />
</LinearLayout>
<ImageView
@@ -419,6 +346,7 @@
android:clipToPadding="false"
android:paddingHorizontal="24dp" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -9,10 +9,11 @@
android:id="@+id/rv_original"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
android:clipToPadding="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:listitem="@layout/item_original_work" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -13,11 +13,20 @@
android:nestedScrollingEnabled="false"
android:paddingHorizontal="24dp" />
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="24dp"
android:maxHeight="90dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_series_by_genre"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="0dp"
android:layout_marginTop="24dp"
android:layout_weight="1"
android:clipToPadding="false"
android:paddingHorizontal="24dp" />
</LinearLayout>

View File

@@ -13,11 +13,20 @@
android:nestedScrollingEnabled="false"
android:paddingHorizontal="24dp" />
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="24dp"
android:maxHeight="90dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_series_day_of_week"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="0dp"
android:layout_marginTop="24dp"
android:layout_weight="1"
android:clipToPadding="false"
android:paddingHorizontal="24dp" />
</LinearLayout>

View File

@@ -79,6 +79,14 @@
android:paddingHorizontal="24dp" />
</LinearLayout>
<com.yandex.mobile.ads.banner.BannerAdView
android:id="@+id/yandex_inline_banner_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="48dp"
android:maxHeight="90dp" />
<!-- 추천 시리즈 섹션 -->
<LinearLayout
android:id="@+id/ll_recommend_series"

View File

@@ -24,8 +24,8 @@
android:textSize="20sp"
android:fontFamily="@font/regular"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black" />

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black" />

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black" />

View File

@@ -1,81 +1,149 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingVertical="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="120dp"
android:background="@drawable/bg_chat_notice_quota"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/iv_icon"
android:layout_width="30dp"
android:layout_height="30dp"
android:contentDescription="@null"
android:src="@drawable/ic_time" />
<TextView
android:id="@+id/tv_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:fontFamily="@font/bold"
android:textColor="@color/white"
android:textSize="18sp"
tools:text="--:--:--" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:fontFamily="@font/bold"
android:text="@string/chat_quota_notice_message"
android:textColor="@color/white"
android:textSize="18sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_purchase"
android:id="@+id/ll_rewarded_ad"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:background="@drawable/bg_buy_button"
android:background="@drawable/bg_chat_quota_rewarded_ad_button"
android:gravity="center"
android:orientation="horizontal"
android:paddingVertical="12dp">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:contentDescription="@null"
android:src="@drawable/ic_can" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/bold"
android:includeFontPadding="false"
android:text="@string/chat_quota_price"
android:textColor="#263238"
android:text="@string/chat_quota_rewarded_ad_label"
android:textColor="@color/color_37474f"
android:textSize="24sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:fontFamily="@font/bold"
android:layout_marginHorizontal="4dp"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:text="@string/chat_quota_purchase_cta"
android:textColor="#263238"
android:text="@string/chat_quota_separator"
android:textColor="@color/color_37474f"
android:textSize="18sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:text="@string/chat_quota_rewarded_ad_chat_count"
android:textColor="@color/color_37474f"
android:textSize="18sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:baselineAligned="false"
android:gravity="center"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/ll_purchase_10_can"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:layout_weight="1"
android:background="@drawable/bg_buy_button"
android:gravity="center"
android:orientation="horizontal"
android:paddingVertical="12dp">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:contentDescription="@null"
android:src="@drawable/ic_can" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/bold"
android:includeFontPadding="false"
android:text="@string/chat_quota_10_can"
android:textColor="@color/color_37474f"
android:textSize="24sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:text="@string/chat_quota_separator"
android:textColor="@color/color_37474f"
android:textSize="18sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:text="@string/chat_quota_10_can_chat_count"
android:textColor="@color/color_37474f"
android:textSize="18sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_purchase_20_can"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_weight="1"
android:background="@drawable/bg_buy_button"
android:gravity="center"
android:orientation="horizontal"
android:paddingVertical="12dp">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:contentDescription="@null"
android:src="@drawable/ic_can" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/bold"
android:includeFontPadding="false"
android:text="@string/chat_quota_20_can"
android:textColor="@color/color_37474f"
android:textSize="24sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:text="@string/chat_quota_separator"
android:textColor="@color/color_37474f"
android:textSize="18sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:text="@string/chat_quota_20_can_chat_count"
android:textColor="@color/color_37474f"
android:textSize="18sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.CapsuleTabBarView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="52dp"
android:background="@color/black">
<HorizontalScrollView
android:id="@+id/hsv_capsule_tab_bar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="false"
android:overScrollMode="never"
android:scrollbars="none">
<LinearLayout
android:id="@+id/ll_capsule_tab_container"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_20" />
</HorizontalScrollView>
</kr.co.vividnext.sodalive.v2.widget.CapsuleTabBarView>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.TextTabBarView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="52dp"
android:background="@color/black"
android:gravity="start|center_vertical"
android:paddingHorizontal="@dimen/spacing_20"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_text_tab_first"
style="@style/Typography.Heading3"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginEnd="@dimen/spacing_20"
android:gravity="center_vertical"
android:textColor="@color/color_text_tab_bar"
tools:text="추천" />
<TextView
android:id="@+id/tv_text_tab_second"
style="@style/Typography.Heading3"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginEnd="@dimen/spacing_20"
android:gravity="center_vertical"
android:textColor="@color/color_text_tab_bar"
android:visibility="gone"
tools:text="랭킹"
tools:visibility="visible" />
<TextView
android:id="@+id/tv_text_tab_third"
style="@style/Typography.Heading3"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:textColor="@color/color_text_tab_bar"
android:visibility="gone"
tools:text="팔로잉"
tools:visibility="visible" />
</kr.co.vividnext.sodalive.v2.widget.TextTabBarView>

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