Compare commits
537 Commits
4457941193
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e073e85a1 | |||
| 4a45f9699c | |||
| a91ec763e1 | |||
| a0cfa70551 | |||
| 39dd003b4e | |||
| 6299259f89 | |||
| 12ea65f14f | |||
| cc1e41e7b4 | |||
| 0c1ad5c746 | |||
| 7c775efada | |||
| ee2f5572fc | |||
| d16f0928bb | |||
| 1c2fcdd1b4 | |||
| 541771d72c | |||
| 2ad59dfd96 | |||
| d3454cc293 | |||
| c3377e39e6 | |||
| 6e04a10a3b | |||
| e13da00215 | |||
| 0943237b5f | |||
| 66116351c2 | |||
| 68ba58a73c | |||
| 595bc50cde | |||
| 50b45d04e5 | |||
| 01d0367f50 | |||
| f009a2edb8 | |||
| ccd2398a7c | |||
| 8f5012fe2a | |||
| 34ac433247 | |||
| 11b06d8f9c | |||
| 2128bbf197 | |||
| 502bbd4f35 | |||
| 21d3ed4603 | |||
| 2d9200ec4d | |||
| a088811b2f | |||
| e331a7e072 | |||
| bb00dd13b0 | |||
| 7748431330 | |||
| 8c5c1dce53 | |||
| 136fdced17 | |||
| 78e0a53018 | |||
| 15de359d4f | |||
| c030cbabd3 | |||
| ab9d14598c | |||
| 71b0c64fd2 | |||
| 2feacd9416 | |||
| f8a5e4c44f | |||
| c3f576dd5f | |||
| 102c454f9c | |||
| 84b5ab4279 | |||
| 11853761cf | |||
| 2e86e21cb7 | |||
| 7436ac77d9 | |||
| 00aac29d89 | |||
| 33e137bc22 | |||
| f4a32086b0 | |||
| 4df724ee56 | |||
| c1a21985fd | |||
| 0a97194b31 | |||
| 8150b680c1 | |||
| cdc847dcca | |||
| af895ed510 | |||
| ee74519f6f | |||
| d47e90d340 | |||
| b53853f193 | |||
| b69a713641 | |||
| 191b64d96e | |||
| a747729c26 | |||
| 8ba3ed5fb5 | |||
| a9446ef5cc | |||
| 2818f8d4a4 | |||
| cf89052806 | |||
| fcd445666e | |||
| 3599197f01 | |||
| f4e46f9d20 | |||
| f2996f599a | |||
| 0d0bec1904 | |||
| 8bc1ec5830 | |||
| b858003b6d | |||
| 37b70a956c | |||
| 6590cf8300 | |||
| 8d33b90e67 | |||
| e4d650c3e7 | |||
| 1a45f42f9e | |||
| 55255621e3 | |||
| cd323c3f69 | |||
| 27add8b244 | |||
| 0ea2a10554 | |||
| bc4f565074 | |||
| de03d2ff76 | |||
| 315df46b7e | |||
| 4117afa9e4 | |||
| dd742ce245 | |||
| c02437797c | |||
| 5746239873 | |||
| 62a11023fb | |||
| d98fdf2e98 | |||
| f8e916d280 | |||
| b52e7b5430 | |||
| 51b50eed75 | |||
| 74efe45d05 | |||
| 6ebd59ff9c | |||
| 7e1e453a6b | |||
| c25acea5d4 | |||
| 7a705b4355 | |||
| 933e118c36 | |||
| 092dff2a9d | |||
| fe19552741 | |||
| 0122c8c5ed | |||
| 9b19be7775 | |||
| 4d79cb65cd | |||
| 8db913812d | |||
| a7ce991f7d | |||
| ba6616c81a | |||
| 4097181923 | |||
| ecaa1f01e8 | |||
| a3ea2051cb | |||
| 5ca5da45ba | |||
| 77d889d9ab | |||
| 6c8d3dfc76 | |||
| bb8442a32d | |||
| 32504349cd | |||
| 0344518130 | |||
| eb71034365 | |||
| 5a9449059b | |||
| 3a94878020 | |||
| f6190030a4 | |||
| b04c01c930 | |||
| 40ef5710fb | |||
| 790e08f1b5 | |||
| 270fe32d94 | |||
| c87a6878b5 | |||
| c4906bd0b5 | |||
| 4012b92357 | |||
| 50449f43ac | |||
| b7eba4c99a | |||
| 25320e283d | |||
| 6517e739b3 | |||
| 4f7dd97be6 | |||
| 7ddfbb0a18 | |||
| 2d41b07852 | |||
| 7958547b28 | |||
| b0346ae00c | |||
| 8f5c55e0d1 | |||
| 4288e7284b | |||
| cb5d4f954d | |||
| a36c3b74e8 | |||
| e29ae4fedb | |||
| 74c11f2aa6 | |||
| 3e4c00fee8 | |||
| 318944fbfe | |||
| 7ccc676192 | |||
| 2b21e07571 | |||
| 6ae28e4d84 | |||
| d6b49eb3e8 | |||
| 88fcbe49f4 | |||
| f9501c156a | |||
| efe12774f7 | |||
| 9b5bcbe41e | |||
| d4448820d6 | |||
| 744132fd7e | |||
| a444dd8677 | |||
| 546a665ba3 | |||
| 6bff74cd1e | |||
| 1dc6aa5283 | |||
| a2dba0456b | |||
| 015a6ac865 | |||
| fcf35e2513 | |||
| 92cea6d3ee | |||
| 7ea06fda2f | |||
| a9456abfb0 | |||
| 3dcc48c9d9 | |||
| c25d4cd161 | |||
| 185c92e9af | |||
| 8fae9e6a96 | |||
| 8a5fc48650 | |||
| 688ba0a63d | |||
| a0f263c1fd | |||
| e3cea856b8 | |||
| df78b8a3f5 | |||
| 1a9a78ec70 | |||
| 1f855102ce | |||
| 757f242285 | |||
| bcbc48540e | |||
| 5b89d6c6d7 | |||
| 3a421d2a60 | |||
| 9d7bc6969b | |||
| e12f00b5b4 | |||
| 0b2faf2c6e | |||
| 763a86704c | |||
| 6df7666cec | |||
| d97c2792f5 | |||
| f848f22029 | |||
| 011a762ecf | |||
| 241532f60e | |||
| 6770dbd682 | |||
| 12a9d9f398 | |||
| c82513eaf0 | |||
| 845b36828b | |||
| d0843d94ed | |||
| c9d911f339 | |||
| 4e4d13b4de | |||
| a0274518d2 | |||
| 88dd7cc04c | |||
| dacd3a67a1 | |||
| da64806d88 | |||
| a87d2990b0 | |||
| 5e95aa4168 | |||
| 9c642fb3b7 | |||
| 8b515bba97 | |||
| 1550014670 | |||
| a6862c51e4 | |||
| de90b34cd4 | |||
| bbb84d4ffa | |||
| f560adabfa | |||
| 8f69c1ab82 | |||
| a6485292e4 | |||
| 482d517145 | |||
| 2c1eb03e5f | |||
| e640ee6c46 | |||
| e8ae5b9639 | |||
| 32e33a243a | |||
| 379284ca4f | |||
| dd7a6465c1 | |||
| deba733522 | |||
| 3d71def880 | |||
| c5bcaf7329 | |||
| e76562067f | |||
| 0e03a1a14a | |||
| fa4e41589b | |||
| b11cf53f67 | |||
| 7db7fdffdf | |||
| efac753f83 | |||
| f4af9868e6 | |||
| a1e8f8edb3 | |||
| 7469172cfc | |||
| a49951c51f | |||
| 0d11839a96 | |||
| 8213d2de42 | |||
| decc2cbb31 | |||
| b639782e89 | |||
| 10004652e4 | |||
| e90fb04de9 | |||
| 7fb52b3c85 | |||
| dd13c619ac | |||
| 41ef04b193 | |||
| 747569c1cc | |||
| 1f0adb21a7 | |||
| f015aea062 | |||
| 3f16cc9312 | |||
| c1ac428ded | |||
| 4a38703873 | |||
| e5d4f1d40d | |||
| d25f509118 | |||
| ecaeea6262 | |||
| cff8469604 | |||
| 9f242f0201 | |||
| bb3c63e2fb | |||
| ca0c586c0c | |||
| bd60185db8 | |||
| 01afed89f8 | |||
| 3dacc50e1b | |||
| 236b874e82 | |||
| 26f44dd448 | |||
| f2f2a3143d | |||
| 34876cf46f | |||
| deda8322d5 | |||
| ba0aef4349 | |||
| 5d52787ea9 | |||
| 6a6b1138a8 | |||
| 722f84039f | |||
| 710ce2602b | |||
| 3e96a5435e | |||
| 433bf172ce | |||
| 5969f50888 | |||
| 9900ac02f5 | |||
| ee4de78c6c | |||
| 43c2f6f417 | |||
| f7c1a5168f | |||
| 28433c10df | |||
| de351d700c | |||
| a01675b592 | |||
| 6ba5bf2cb1 | |||
| f6395b5a3e | |||
| 1cd676bcb4 | |||
| 0bb5796da1 | |||
| 5f4140ea68 | |||
| b2878e42ea | |||
| e8bd31454e | |||
| 59545ca82c | |||
| cd1f9db8ae | |||
| d719470f8c | |||
| f3c19ed8ba | |||
| 984aa13edf | |||
| 5ac900f3b7 | |||
| a21427b549 | |||
| f667bf1096 | |||
| 458cdc3280 | |||
| 2cf492d634 | |||
| e16bc306f7 | |||
| fcb198c8a8 | |||
| 40679a624b | |||
| a2be1739a6 | |||
| b529a83fe6 | |||
| 20491906fd | |||
| 9a8559af8b | |||
| 541333bc44 | |||
| 9c4c0506af | |||
| 00b5a3687e | |||
| b41e5c6074 | |||
| 19b8e4750f | |||
| bfb5440c9e | |||
| 1ce974938d | |||
| dc217f97af | |||
| d3bfc57294 | |||
| cd5fbc0858 | |||
| 2d2844338a | |||
| 1f3abfde4f | |||
| 2d58a876da | |||
| c45c5a2a5c | |||
| 5fde0bc469 | |||
| edb7d98de7 | |||
| 573df4318b | |||
| febd718796 | |||
| a20655badb | |||
| 11fd892310 | |||
| 2253dabfe9 | |||
| e2575e2b15 | |||
| 05f724dbe1 | |||
| d4b3a9a8a4 | |||
| 74687daf28 | |||
| a8ba791349 | |||
| 21383ced46 | |||
| a631aa1b65 | |||
| 402ea5e9c0 | |||
| 654a74aacf | |||
| 3027934295 | |||
| a355838039 | |||
| 0ae6596816 | |||
| da23253a9c | |||
| 0934a7ec51 | |||
| 80e8213f12 | |||
| 55b4d9bc8d | |||
| 92fdd6ab54 | |||
| 3514104e7c | |||
| 122c559e57 | |||
| b2cb08e96a | |||
| 9e9533c7a6 | |||
| f6ab1bd4ef | |||
| f47a9d6d93 | |||
| ff728af7cc | |||
| 1e8beb458c | |||
| a6d8cd54f3 | |||
| 841ed5f6f8 | |||
| 0263e64f40 | |||
| a0b95ea3bd | |||
| 590a52c605 | |||
| f2687b8243 | |||
| 871f4e73e8 | |||
| e19442a61a | |||
| 3285958918 | |||
| 06088c578f | |||
| 63a4f5b4f7 | |||
| 56f110c548 | |||
| 406c377d13 | |||
| fd0382ea65 | |||
| a289849a07 | |||
| 630f84c3e5 | |||
| e1ae7df0ee | |||
| 5f7fc68c8c | |||
| f4f561b396 | |||
| 40f8355037 | |||
| ccc4a822e3 | |||
| 70177d8f81 | |||
| b38e58af9a | |||
| 516e4a94bf | |||
| cdb491b21b | |||
| a769fc6a64 | |||
| 2c30da8110 | |||
| ee703eb13a | |||
| 55fb032b22 | |||
| 346671b3e2 | |||
| 5574e68b16 | |||
| 445d91d594 | |||
| 896935e19a | |||
| bb17f0014a | |||
| ed8a0e9a09 | |||
| 10a74c2e5e | |||
| d66743ae1e | |||
| 5a17d7d2f6 | |||
| 50f0fb3d15 | |||
| 4c351da60c | |||
| 89837877a2 | |||
| 32c30132b9 | |||
| 06e4b5ad34 | |||
| 972d225a86 | |||
| 7acc7b51b6 | |||
| 86e18a1f7c | |||
| bb4d290ca1 | |||
| 4e98a1dfea | |||
| c3bbf9203d | |||
| 64fb55db55 | |||
| ea076a5ac7 | |||
| 6b0232b7e4 | |||
| 178397923b | |||
| de4e90b98e | |||
| 5d66014044 | |||
| bb60f8bb9f | |||
| b199804827 | |||
| 37dc3cd24a | |||
| a4710ef6bb | |||
| 28423d81bb | |||
| 21e94af8d1 | |||
| 6d980e319b | |||
| 4a21827b47 | |||
| 9600147240 | |||
| ecc59376a3 | |||
| 4b4a23c92e | |||
| c36eddb207 | |||
| e160a10708 | |||
| cdcd938cdf | |||
| 58e69be510 | |||
| 7f417c3a3f | |||
| 9378f96b73 | |||
| 7e78ace304 | |||
| a3aa406e13 | |||
| 8371dc7baf | |||
| 44457349aa | |||
| 51226cf5cc | |||
| 7f307346f3 | |||
| 9de4493e89 | |||
| c6680d0bd2 | |||
| 6679808a18 | |||
| 7a4fa3c50f | |||
| 7c0af85aaa | |||
| 8457194bb5 | |||
| f07132c48b | |||
| 9c20b86373 | |||
| 293f34ca13 | |||
| dca06cdc3c | |||
| 1cbf989577 | |||
| 5b3b7c72d2 | |||
| cd274a6d2f | |||
| edf4a94494 | |||
| d5f46e6325 | |||
| 22ac5b0b54 | |||
| bb29fc8010 | |||
| c733796aeb | |||
| c714a9d4c8 | |||
| c436866a51 | |||
| 02480a96e9 | |||
| 3e8ea0473f | |||
| 99c2082022 | |||
| 4629ef00a9 | |||
| a5b4d5046d | |||
| 29e64188c9 | |||
| 8a73ea0472 | |||
| b402025ca5 | |||
| bd475d1c87 | |||
| 816641d7c5 | |||
| 3028288bb3 | |||
| 089e980832 | |||
| f93236b2d0 | |||
| 9b29623f6f | |||
| 0e50d7f8d5 | |||
| 9a8231c2b6 | |||
| 0a5be6467c | |||
| 716f3c6880 | |||
| b20866fedd | |||
| 14e7b33b63 | |||
| cfa297ac3f | |||
| e0e64090cd | |||
| 68f8d869cc | |||
| d07a2837d9 | |||
| caee72f9c3 | |||
| 4870b7377a | |||
| dd002e9f82 | |||
| 4743c9cb8f | |||
| 3132269038 | |||
| 9eb1f7709d | |||
| 4817641155 | |||
| c74ceba495 | |||
| 0d339f9565 | |||
| ee58794441 | |||
| 4ab2490534 | |||
| 9a47c42958 | |||
| a35310e536 | |||
| ecb9f5a260 | |||
| 8dd2371ce4 | |||
| df782d7968 | |||
| 7672a3bbe8 | |||
| 9a56c124cc | |||
| 2fa9561a09 | |||
| 462d9c90b5 | |||
| db72c4bf7d | |||
| 49984cb651 | |||
| dcc76abf94 | |||
| bc15a0997e | |||
| 31b4e93bed | |||
| fe509365e2 | |||
| 2e5af796e4 | |||
| e2d848b7e5 | |||
| 4d0d242162 | |||
| 1ffcd16efa | |||
| 02f85f808d | |||
| 91a7eb3f4c | |||
| 0c8c9f9b5f | |||
| 8ae09d0514 | |||
| 413a2f2fc1 | |||
| 9ff4a8159a | |||
| abc0cfe461 | |||
| 9a67c45dab | |||
| add6b66ea5 | |||
| 11fdf89340 | |||
| 9e37347877 | |||
| ef81cba7eb | |||
| 799dd7fc92 | |||
| a8e0f2377d | |||
| 59ea5de00a | |||
| 77eef9609a | |||
| 8e9ce634e3 | |||
| 4d0d330797 | |||
| a5728bcc4d | |||
| 3444b1eeef | |||
| a2f3910e27 | |||
| 01765f3e7f | |||
| 1baf62a2b7 | |||
| e5f298eaa7 | |||
| d312ce7cd8 | |||
| 0764bced76 | |||
| c32f9cdd9f | |||
| c58f03be08 | |||
| 960e78afac | |||
| 36ffbc6cdb | |||
| 01fea58e4c | |||
| 6fda122091 | |||
| 30264935dc |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -315,5 +315,7 @@ app/release/
|
||||
|
||||
.junie/
|
||||
.kiro/
|
||||
.omo/
|
||||
.antigravitycli/
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/macos,android,androidstudio,visualstudiocode,git,kotlin,java
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
description: commit-policy 스킬을 로드해 커밋 메시지 생성과 전후 검증을 수행한다
|
||||
agent: build
|
||||
subtask: true
|
||||
---
|
||||
|
||||
작업 목표:
|
||||
현재 변경사항을 안전하게 커밋한다.
|
||||
|
||||
필수 시작 단계:
|
||||
1. `skill` 도구로 `commit-policy` 스킬을 먼저 로드한다.
|
||||
- `skill({ name: "commit-policy" })`
|
||||
|
||||
실행 단계:
|
||||
1. 로드한 `commit-policy` 스킬의 Hard Requirements와 Execution Flow를 그대로 수행한다.
|
||||
2. `AGENTS.md`의 최소 정책(형식/한글 description/검증 스크립트)을 항상 만족한다.
|
||||
3. `$ARGUMENTS`가 있으면 scope 또는 description 의도에 반영하되, 스킬 규칙과 형식을 깨지 않는다.
|
||||
4. 마지막에 실행 명령과 pre-check/post-check PASS/FAIL 핵심 결과를 간단히 보고한다.
|
||||
|
||||
추가 사용자 의도:
|
||||
$ARGUMENTS
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
name: commit-policy
|
||||
description: Apply this skill for any git commit task in this repository. It enforces commit message format and validation flow defined in AGENTS.md and work/scripts/check-commit-message-rules.sh, including pre-commit and post-commit verification.
|
||||
---
|
||||
|
||||
# Commit Policy Skill
|
||||
|
||||
Use this workflow whenever the task includes creating a commit.
|
||||
|
||||
## Required References
|
||||
|
||||
- `@AGENTS.md`
|
||||
- `@work/scripts/check-commit-message-rules.sh`
|
||||
|
||||
## Hard Requirements
|
||||
|
||||
1. Use commit subject format: `<type>(scope): <description>`.
|
||||
2. `type` must be lowercase (for example `feat`, `fix`, `chore`, `docs`, `refactor`, `test`).
|
||||
3. `description` must include Korean text and stay concise in imperative present tone.
|
||||
4. Optional footer must use `Refs: #123` or `Refs: #123, #456` format.
|
||||
5. Never commit secret files (`.env`, key/token/secret credential files).
|
||||
6. Never bypass hooks with `--no-verify`.
|
||||
|
||||
## Execution Flow
|
||||
|
||||
1. Inspect context with:
|
||||
- `git status`
|
||||
- `git diff --cached`
|
||||
- `git diff`
|
||||
- `git log -5 --oneline`
|
||||
2. Stage commit target files only. Exclude suspicious secret-bearing files.
|
||||
3. Draft commit message from the change intent (focus on why, not only what).
|
||||
4. Run pre-commit validation with the full draft message:
|
||||
- `./work/scripts/check-commit-message-rules.sh --message "<full message>"`
|
||||
5. If validation fails, revise message and re-run until PASS.
|
||||
6. Commit using the validated message.
|
||||
7. Run post-commit validation:
|
||||
- `./work/scripts/check-commit-message-rules.sh`
|
||||
8. Report executed commands and PASS/FAIL summary.
|
||||
|
||||
## Output Checklist
|
||||
|
||||
- Final commit subject.
|
||||
- Whether pre-check passed.
|
||||
- Whether post-check passed.
|
||||
- Any excluded files and reason.
|
||||
59
AGENTS.md
59
AGENTS.md
@@ -1,29 +1,14 @@
|
||||
# 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)`를 따른다.
|
||||
- 사용자 직접 지시가 명확할 경우 사용자 지시가 최우선이다.
|
||||
|
||||
## 커뮤니케이션 규칙
|
||||
- **"질문에 대한 답변과 설명은 한국어로 한다."**
|
||||
- **"질문에 대한 답변과 설명과 Todo는 한국어로 한다."**
|
||||
- 이 저장소에서 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
|
||||
- 코드 식별자, 경로, 명령어는 원문(영문) 그대로 유지한다.
|
||||
|
||||
## 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.
|
||||
|
||||
# CLAUDE.md
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
@@ -100,20 +85,6 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
||||
- 사용자가 명시적으로 요청한 경우에만 사용한다.
|
||||
- 대규모 리팩토링, 브레인스토밍, 다중 에이전트 실행, 병렬 workflow를 허용한다.
|
||||
|
||||
### oh-my-openagent 사용 정책
|
||||
- oh-my-openagent는 opencode의 플러그인 기반 실행 오케스트레이션 계층이다.
|
||||
- oh-my-openagent는 의사결정 권한이 아니라 실행 보조 권한만 가진다.
|
||||
- 작은 작업에는 multi-agent 실행이나 과도한 workflow를 사용하지 않는다.
|
||||
- 병렬 실행은 명확한 이득이 있을 때만 사용한다.
|
||||
- 모든 oh-my-openagent 동작은 `CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)`를 따라야 한다.
|
||||
|
||||
### superpowers 사용 정책
|
||||
- superpowers는 선택적 스킬 계층이다.
|
||||
- superpowers skill은 필요한 경우에만 사용한다.
|
||||
- superpowers가 과도한 리팩토링, 불필요한 범위 확장, 가정 기반 실행을 유도하면 따르지 않는다.
|
||||
- superpowers를 사용할 때도 최소 변경, 단순성, 검증 가능성을 우선한다.
|
||||
- 모든 superpowers 동작은 `CORE EXECUTION PRINCIPLES (andrej-karpathy-skills)`를 따라야 한다.
|
||||
|
||||
### 에이전트 동작 원칙
|
||||
- 추측하지 말고 근거 파일을 읽고 결정한다.
|
||||
- 기존 관례를 깨는 변경은 이유가 명확할 때만 수행한다.
|
||||
@@ -125,16 +96,36 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
||||
- 요청 범위를 우선 충족하고, 변경은 작고 안전하게 유지한다.
|
||||
- 기존 로직 수정이 아닌 신규 `Activity`, `Fragment`, `ViewModel` 및 그와 연결된 하위 코드는 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다.
|
||||
|
||||
## 레거시 코드 사용 원칙
|
||||
- 레거시 코드는 직접 수정하지 않는다.
|
||||
- 레거시 기능이 필요하면 기존 코드를 호출해서 사용한다.
|
||||
- 레거시 코드를 약간 변경해야 사용할 수 있는 경우에도 레거시 파일을 고치지 말고, 신규 파일에 wrapper/adapter/확장 코드를 추가해 사용한다.
|
||||
- 레거시 코드 변경이 불가피해 보이면 구현 전에 사용자에게 확인한다.
|
||||
|
||||
## 작업 절차 핵심 규칙
|
||||
- PRD 문서와 구현 계획/TASK 문서 없이 구현하지 않는다.
|
||||
- 사용자의 프롬프트를 받으면 먼저 PRD 문서를 작성하고, 애매하거나 결정이 필요한 내용은 모호함이 사라질 때까지 사용자와 인터뷰한다.
|
||||
- 모든 구현 작업은 PRD 문서와 구현 계획/TASK 문서가 모두 준비된 뒤에 시작한다.
|
||||
- 사용자의 프롬프트를 받으면 먼저 PRD 문서를 작성한다.
|
||||
- PRD 작성 중 애매하거나 더 필요한 내용, 결정해야 하는 사항이 있으면 애매한 사항이 없어질 때까지 사용자와 인터뷰한다.
|
||||
- 인터뷰 내용을 PRD에 반영한 뒤, PRD를 기준으로 계획/TASK 문서를 작성하고 그 문서에 따라 필요한 내용만 최소 구현한다.
|
||||
- PRD 문서는 `docs/prd/`, 계획/TASK 문서는 `docs/plan-task/` 아래에 둔다.
|
||||
- 문서는 `docs/[날짜]_구현할내용한글/` 아래에 `prd.md`, `plan-task.md`로 만든다.
|
||||
- `docs/[날짜]_구현할내용한글/prd.md`
|
||||
- `docs/[날짜]_구현할내용한글/plan-task.md`
|
||||
- 문서 폴더명에서 원래 띄어쓰기가 들어갈 위치는 공백 대신 `_`를 사용한다.
|
||||
- 예: `docs/20260601_메인_홈_추천_UI와_API_연동/`
|
||||
- 기존에 생성된 `docs/prd/`, `docs/plan-task/` 문서는 유지하고, 신규 생성 문서부터 위 구조를 적용한다.
|
||||
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
|
||||
- PRD 문서는 `sample-prd.md` 파일에서 작업에 필요한 부분만 발췌해 작성한다. `sample-prd.md`가 없거나 위치가 불명확하면 추측하지 말고 사용자에게 확인한다.
|
||||
- 연속된 하나의 작업이라면 별도 새 문서를 만들지 말고 기존 PRD와 계획/TASK 문서에 추가 작업으로 이어서 기록한다.
|
||||
- 작업 도중 범위가 변경되면 계획/TASK 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
|
||||
- 특정 Phase 또는 Task에 직접 대응되는 검증 기록은 해당 Phase 또는 Task 아래에 한국어로 남긴다.
|
||||
- 여러 Phase에 걸치거나 문서 전체에 해당하는 통합 검증, 회귀 검증, 최종 수동 확인 기록은 문서 최하단 `Verification Log`에 한국어로 남긴다.
|
||||
- 후속 수정이 발생해도 기존 검증 기록은 삭제하거나 덮어쓰지 않고 위치별로 누적한다.
|
||||
|
||||
## 상세 참조 문서
|
||||
- 빌드/린트/테스트는 `docs/agent-guides/build-test-style.md`를 참고한다.
|
||||
- 코드 스타일/구조는 `docs/agent-guides/code-style.md`를 참고한다.
|
||||
- 작업 절차/docs/커밋 규칙은 `docs/agent-guides/workflow-docs-commits.md`를 참고한다.
|
||||
- 작업 절차/docs/계획 문서 규칙은 `docs/agent-guides/work-plan-docs.md`를 참고한다.
|
||||
- 커밋 메시지 규칙은 `docs/agent-guides/commit-message-rules.md`를 참고한다.
|
||||
- 저장소 세부 규칙/보안/Git 안전 수칙은 `docs/agent-guides/safety-repo-rules.md`를 참고한다.
|
||||
|
||||
## 핵심 금지사항
|
||||
|
||||
@@ -164,6 +164,12 @@ android {
|
||||
checkDependencies true
|
||||
checkReleaseBuilds false
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
includeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -287,6 +293,8 @@ dependencies {
|
||||
testImplementation 'org.mockito:mockito-inline:5.2.0'
|
||||
testImplementation 'org.mockito.kotlin:mockito-kotlin:6.1.0'
|
||||
testImplementation 'io.mockk:mockk:1.14.6'
|
||||
testImplementation 'androidx.test:core-ktx:1.6.1'
|
||||
testImplementation 'org.robolectric:robolectric:4.15.1'
|
||||
|
||||
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
|
||||
@@ -112,6 +112,11 @@
|
||||
</activity>
|
||||
<activity android:name=".main.MainActivity" />
|
||||
<activity android:name=".v2.main.MainV2Activity" />
|
||||
<activity android:name=".v2.creator.channel.CreatorChannelActivity" />
|
||||
<activity android:name=".v2.live.onair.HomeOnAirLiveActivity" />
|
||||
<activity
|
||||
android:name=".v2.main.chat.dm.DmChatRoomActivity"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
|
||||
<activity android:name=".user.login.LoginActivity" />
|
||||
<activity android:name=".audio_content.all.AudioContentAllActivity" />
|
||||
<activity android:name=".settings.language.LanguageSettingsActivity" />
|
||||
|
||||
@@ -18,9 +18,9 @@ import kr.co.vividnext.sodalive.common.Constants
|
||||
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
|
||||
import kr.co.vividnext.sodalive.common.LoadingDialog
|
||||
import kr.co.vividnext.sodalive.databinding.ActivityAudioContentNewAllBinding
|
||||
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import kr.co.vividnext.sodalive.home.HomeContentThemeAdapter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@@ -126,9 +126,7 @@ class AudioContentNewAllActivity : BaseActivity<ActivityAudioContentNewAllBindin
|
||||
},
|
||||
onClickCreator = {
|
||||
startActivity(
|
||||
Intent(this, UserProfileActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, it)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(this, it)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ import kr.co.vividnext.sodalive.common.Constants
|
||||
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
|
||||
import kr.co.vividnext.sodalive.common.LoadingDialog
|
||||
import kr.co.vividnext.sodalive.databinding.ActivityAudioContentAllByThemeBinding
|
||||
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
class AudioContentAllByThemeActivity : BaseActivity<ActivityAudioContentAllByThemeBinding>(
|
||||
@@ -69,9 +69,7 @@ class AudioContentAllByThemeActivity : BaseActivity<ActivityAudioContentAllByThe
|
||||
},
|
||||
onClickCreator = {
|
||||
startActivity(
|
||||
Intent(this, UserProfileActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, it)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(this, it)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -58,7 +58,6 @@ import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
|
||||
import kr.co.vividnext.sodalive.common.Utils
|
||||
import kr.co.vividnext.sodalive.databinding.ActivityAudioContentDetailBinding
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorFollowNotifyFragment
|
||||
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationDialog
|
||||
@@ -70,6 +69,7 @@ import kr.co.vividnext.sodalive.mypage.can.payment.CanPaymentTempActivity
|
||||
import kr.co.vividnext.sodalive.mypage.recent.RecentContentViewModel
|
||||
import kr.co.vividnext.sodalive.mypage.recent.db.RecentContent
|
||||
import kr.co.vividnext.sodalive.report.ReportType
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
||||
import org.koin.android.ext.android.inject
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToInt
|
||||
@@ -1207,9 +1207,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
|
||||
this.creatorId = creator.creatorId
|
||||
binding.rlProfile.setOnClickListener {
|
||||
startActivity(
|
||||
Intent(applicationContext, UserProfileActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, creator.creatorId)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(applicationContext, creator.creatorId)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.audio_content.series.detail
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
@@ -22,8 +21,8 @@ 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
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
class SeriesDetailActivity : BaseActivity<ActivitySeriesDetailBinding>(
|
||||
@@ -82,7 +81,6 @@ class SeriesDetailActivity : BaseActivity<ActivitySeriesDetailBinding>(
|
||||
|
||||
override fun onTabReselected(tab: TabLayout.Tab) {
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
@@ -194,9 +192,7 @@ class SeriesDetailActivity : BaseActivity<ActivitySeriesDetailBinding>(
|
||||
private fun setSeriesCreator(creator: GetSeriesDetailResponse.GetSeriesDetailCreator) {
|
||||
binding.llProfile.setOnClickListener {
|
||||
startActivity(
|
||||
Intent(applicationContext, UserProfileActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, creator.creatorId)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(applicationContext, creator.creatorId)
|
||||
)
|
||||
}
|
||||
binding.tvNickname.text = creator.nickname
|
||||
|
||||
@@ -29,7 +29,6 @@ import kr.co.vividnext.sodalive.common.Constants
|
||||
import kr.co.vividnext.sodalive.common.LoadingDialog
|
||||
import kr.co.vividnext.sodalive.common.RealPathUtil
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.common.ToastMessage
|
||||
import kr.co.vividnext.sodalive.databinding.ActivityAuditionRoleDetailBinding
|
||||
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.write.RecordingVoiceFragment
|
||||
@@ -37,9 +36,11 @@ import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.io.File
|
||||
|
||||
class AuditionRoleDetailActivity : BaseActivity<ActivityAuditionRoleDetailBinding>(
|
||||
ActivityAuditionRoleDetailBinding::inflate
|
||||
), RecordingVoiceFragment.OnAudioRecordedListener {
|
||||
class AuditionRoleDetailActivity :
|
||||
BaseActivity<ActivityAuditionRoleDetailBinding>(
|
||||
ActivityAuditionRoleDetailBinding::inflate
|
||||
),
|
||||
RecordingVoiceFragment.OnAudioRecordedListener {
|
||||
private val viewModel: AuditionRoleDetailViewModel by inject()
|
||||
|
||||
private lateinit var loadingDialog: LoadingDialog
|
||||
@@ -219,8 +220,8 @@ class AuditionRoleDetailActivity : BaseActivity<ActivityAuditionRoleDetailBindin
|
||||
confirmButtonClick = {
|
||||
isShowNotifyVote = false
|
||||
},
|
||||
descGravity = Gravity.CENTER
|
||||
).show(screenWidth)
|
||||
descGravity = Gravity.CENTER
|
||||
).show(screenWidth)
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
|
||||
@@ -31,6 +31,8 @@ abstract class BaseActivity<T : ViewBinding>(
|
||||
lateinit var binding: T
|
||||
private set
|
||||
|
||||
protected open val shouldApplySystemBarTopInset: Boolean = true
|
||||
|
||||
val screenWidth: Int by lazy {
|
||||
resources.displayMetrics.widthPixels
|
||||
}
|
||||
@@ -81,7 +83,7 @@ abstract class BaseActivity<T : ViewBinding>(
|
||||
|
||||
// 루트는 좌/우/하만 처리(상단은 Toolbar에 위임). IME가 등장하면 하단 패딩을 IME 높이까지 확장
|
||||
val left = max(systemBars.left, ime.left)
|
||||
val top = systemBars.top
|
||||
val top = if (shouldApplySystemBarTopInset) systemBars.top else 0
|
||||
val right = max(systemBars.right, ime.right)
|
||||
val bottom = max(systemBars.bottom, ime.bottom)
|
||||
v.setPadding(left, top, right, bottom)
|
||||
|
||||
@@ -7,6 +7,9 @@ import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
|
||||
object ImageLoaderProvider {
|
||||
const val LEGACY_OKHTTP_IMAGE_CACHE_DIRECTORY_NAME = "image_cache"
|
||||
const val COIL_IMAGE_CACHE_DIRECTORY_NAME = "coil_image_cache"
|
||||
|
||||
lateinit var imageLoader: ImageLoader
|
||||
private set
|
||||
|
||||
@@ -14,9 +17,10 @@ object ImageLoaderProvider {
|
||||
get() = ::imageLoader.isInitialized
|
||||
fun init(context: Context) {
|
||||
val cacheSize = 250L * 1024L * 1024L // 250 MB
|
||||
File(context.cacheDir, LEGACY_OKHTTP_IMAGE_CACHE_DIRECTORY_NAME).deleteRecursively()
|
||||
val cacheDirectory = File(
|
||||
context.cacheDir,
|
||||
"image_cache"
|
||||
COIL_IMAGE_CACHE_DIRECTORY_NAME
|
||||
).apply { mkdirs() }
|
||||
|
||||
val cache = Cache(cacheDirectory, cacheSize)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package kr.co.vividnext.sodalive.common
|
||||
|
||||
import android.content.Context
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
fun formatUtcRelativeTimeText(context: Context, utcText: String?): String {
|
||||
val pastMillis = parseServerUtcToMillis(utcText)
|
||||
?: return context.getString(R.string.character_comment_time_just_now)
|
||||
|
||||
val nowMillis = System.currentTimeMillis()
|
||||
var diff = nowMillis - pastMillis
|
||||
if (diff < 0) diff = 0
|
||||
|
||||
val minute = 60_000L
|
||||
val hour = 60 * minute
|
||||
val day = 24 * hour
|
||||
|
||||
if (diff < minute) {
|
||||
return context.getString(R.string.character_comment_time_just_now)
|
||||
}
|
||||
|
||||
if (diff < hour) {
|
||||
val minutes = (diff / minute).toInt()
|
||||
return context.getString(R.string.character_comment_time_minutes, minutes)
|
||||
}
|
||||
|
||||
if (diff < day) {
|
||||
val hours = (diff / hour).toInt()
|
||||
return context.getString(R.string.character_comment_time_hours, hours)
|
||||
}
|
||||
|
||||
if (diff < 30 * day) {
|
||||
val days = (diff / day).toInt()
|
||||
return context.getString(R.string.character_comment_time_days, days)
|
||||
}
|
||||
|
||||
val timeZone = TimeZone.getDefault()
|
||||
val nowCalendar = Calendar.getInstance(timeZone, Locale.getDefault())
|
||||
val pastCalendar = Calendar.getInstance(timeZone, Locale.getDefault())
|
||||
pastCalendar.timeInMillis = pastMillis
|
||||
|
||||
var years = nowCalendar.get(Calendar.YEAR) - pastCalendar.get(Calendar.YEAR)
|
||||
val nowMonth = nowCalendar.get(Calendar.MONTH)
|
||||
val pastMonth = pastCalendar.get(Calendar.MONTH)
|
||||
val nowDay = nowCalendar.get(Calendar.DAY_OF_MONTH)
|
||||
val pastDay = pastCalendar.get(Calendar.DAY_OF_MONTH)
|
||||
if (nowMonth < pastMonth || (nowMonth == pastMonth && nowDay < pastDay)) {
|
||||
years -= 1
|
||||
}
|
||||
|
||||
if (years < 1) {
|
||||
var months = (nowCalendar.get(Calendar.YEAR) - pastCalendar.get(Calendar.YEAR)) * 12 + (nowMonth - pastMonth)
|
||||
if (nowDay < pastDay) months -= 1
|
||||
if (months < 1) months = 1
|
||||
return context.getString(R.string.character_comment_time_months, months)
|
||||
}
|
||||
|
||||
return context.getString(R.string.character_comment_time_years, years)
|
||||
}
|
||||
|
||||
fun interface UtcRelativeTimeTextFormatter {
|
||||
fun format(utcText: String?): String
|
||||
}
|
||||
|
||||
class AndroidUtcRelativeTimeTextFormatter(context: Context) : UtcRelativeTimeTextFormatter {
|
||||
|
||||
private val applicationContext = context.applicationContext
|
||||
|
||||
override fun format(utcText: String?): String = formatUtcRelativeTimeText(applicationContext, utcText)
|
||||
}
|
||||
|
||||
private fun parseServerUtcToMillis(utcText: String?): Long? {
|
||||
if (utcText.isNullOrBlank()) return null
|
||||
|
||||
val value = utcText.trim()
|
||||
if (value.all { it.isDigit() }) {
|
||||
return try {
|
||||
value.toLong()
|
||||
} catch (_: NumberFormatException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val patterns = listOf(
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
|
||||
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
"yyyy/MM/dd HH:mm:ss"
|
||||
)
|
||||
|
||||
for (pattern in patterns) {
|
||||
try {
|
||||
val dateFormat = SimpleDateFormat(pattern, Locale.US)
|
||||
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
|
||||
val parsed: Date? = dateFormat.parse(value)
|
||||
if (parsed != null) return parsed.time
|
||||
} catch (_: ParseException) {
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -68,11 +68,13 @@ import kr.co.vividnext.sodalive.chat.talk.TalkTabRepository
|
||||
import kr.co.vividnext.sodalive.chat.talk.TalkTabViewModel
|
||||
import kr.co.vividnext.sodalive.chat.talk.room.chatTalkRoomModule
|
||||
import kr.co.vividnext.sodalive.common.ApiBuilder
|
||||
import kr.co.vividnext.sodalive.common.AndroidUtcRelativeTimeTextFormatter
|
||||
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
|
||||
import kr.co.vividnext.sodalive.explorer.ExplorerApi
|
||||
import kr.co.vividnext.sodalive.explorer.ExplorerRepository
|
||||
import kr.co.vividnext.sodalive.explorer.ExplorerViewModel
|
||||
import kr.co.vividnext.sodalive.explorer.profile.channel_donation.UserProfileChannelDonationAllViewModel
|
||||
import kr.co.vividnext.sodalive.explorer.profile.UserProfileViewModel
|
||||
import kr.co.vividnext.sodalive.explorer.profile.channel_donation.UserProfileChannelDonationAllViewModel
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityApi
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllViewModel
|
||||
@@ -145,8 +147,8 @@ import kr.co.vividnext.sodalive.mypage.recent.recentContentModule
|
||||
import kr.co.vividnext.sodalive.mypage.service_center.FaqApi
|
||||
import kr.co.vividnext.sodalive.mypage.service_center.FaqRepository
|
||||
import kr.co.vividnext.sodalive.mypage.service_center.ServiceCenterViewModel
|
||||
import kr.co.vividnext.sodalive.network.TokenAuthenticator
|
||||
import kr.co.vividnext.sodalive.network.AcceptLanguageInterceptor
|
||||
import kr.co.vividnext.sodalive.network.TokenAuthenticator
|
||||
import kr.co.vividnext.sodalive.report.ReportApi
|
||||
import kr.co.vividnext.sodalive.report.ReportRepository
|
||||
import kr.co.vividnext.sodalive.search.SearchApi
|
||||
@@ -176,7 +178,44 @@ 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.creator.channel.CreatorChannelHomeViewModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.CreatorChannelAudioViewModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityViewModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelApi
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.CreatorChannelDonationViewModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.CreatorChannelFanTalkViewModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.CreatorChannelLiveViewModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.CreatorChannelSeriesViewModel
|
||||
import kr.co.vividnext.sodalive.v2.live.onair.HomeOnAirLiveViewModel
|
||||
import kr.co.vividnext.sodalive.v2.live.onair.data.HomeOnAirLiveApi
|
||||
import kr.co.vividnext.sodalive.v2.live.onair.data.HomeOnAirLiveRepository
|
||||
import kr.co.vividnext.sodalive.v2.main.MainV2ViewModel
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModel
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomApi
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomRepository
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModel
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatApi
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketClient
|
||||
import kr.co.vividnext.sodalive.v2.main.content.ContentAllTabViewModel
|
||||
import kr.co.vividnext.sodalive.v2.main.content.ContentMainViewModel
|
||||
import kr.co.vividnext.sodalive.v2.main.content.ContentRankingViewModel
|
||||
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRecommendationsApi
|
||||
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRecommendationsRepository
|
||||
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingsApi
|
||||
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingsRepository
|
||||
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllTabApi
|
||||
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllTabRepository
|
||||
import kr.co.vividnext.sodalive.v2.main.home.HomeCreatorRankingViewModel
|
||||
import kr.co.vividnext.sodalive.v2.main.home.HomeFollowingViewModel
|
||||
import kr.co.vividnext.sodalive.v2.main.home.HomeRecommendationViewModel
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeCreatorRankingApi
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeCreatorRankingRepository
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeFollowingApi
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeFollowingRepository
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeRecommendationApi
|
||||
import kr.co.vividnext.sodalive.v2.main.home.data.HomeRecommendationRepository
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
@@ -195,6 +234,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
|
||||
|
||||
private val otherModule = module {
|
||||
single { GsonBuilder().create() }
|
||||
single<UtcRelativeTimeTextFormatter> { AndroidUtcRelativeTimeTextFormatter(get()) }
|
||||
single { PlaybackTrackingDatabase.getDatabase(get()) }
|
||||
single<PlaybackTrackingDao> { get<PlaybackTrackingDatabase>().playbackTrackingDao() }
|
||||
}
|
||||
@@ -208,6 +248,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
|
||||
} else {
|
||||
logging.setLevel(HttpLoggingInterceptor.Level.NONE)
|
||||
}
|
||||
logging.redactHeader("Authorization")
|
||||
|
||||
OkHttpClient().newBuilder()
|
||||
.addInterceptor(logging)
|
||||
@@ -285,11 +326,22 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
|
||||
single { ApiBuilder().build(get(), SearchApi::class.java) }
|
||||
single { ApiBuilder().build(get(), PointStatusApi::class.java) }
|
||||
single { ApiBuilder().build(get(), HomeApi::class.java) }
|
||||
single { ApiBuilder().build(get(), ChatRoomApi::class.java) }
|
||||
single { ApiBuilder().build(get(), DmChatApi::class.java) }
|
||||
single { ApiBuilder().build(get(), AudioRecommendationsApi::class.java) }
|
||||
single { ApiBuilder().build(get(), AudioRankingsApi::class.java) }
|
||||
single { ApiBuilder().build(get(), MainContentAllTabApi::class.java) }
|
||||
single { ApiBuilder().build(get(), HomeCreatorRankingApi::class.java) }
|
||||
single { ApiBuilder().build(get(), HomeFollowingApi::class.java) }
|
||||
single { ApiBuilder().build(get(), HomeRecommendationApi::class.java) }
|
||||
single { ApiBuilder().build(get(), CreatorChannelApi::class.java) }
|
||||
single { ApiBuilder().build(get(), HomeOnAirLiveApi::class.java) }
|
||||
single { ApiBuilder().build(get(), CharacterApi::class.java) }
|
||||
single { ApiBuilder().build(get(), TalkApi::class.java) }
|
||||
single { ApiBuilder().build(get(), CharacterCommentApi::class.java) }
|
||||
single { ApiBuilder().build(get(), OriginalWorkApi::class.java) }
|
||||
single { ApiBuilder().build(get<Retrofit>(named("agoraRetrofit")), V2vApi::class.java) }
|
||||
single { DmChatSocketClient(okHttpClient = get(), gson = get(), baseUrl = baseUrl) }
|
||||
}
|
||||
|
||||
private val viewModelModule = module {
|
||||
@@ -381,6 +433,22 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
|
||||
viewModel { SearchViewModel(get()) }
|
||||
viewModel { PointStatusViewModel(get()) }
|
||||
viewModel { HomeViewModel(get(), get()) }
|
||||
viewModel { ChatMainViewModel(get()) }
|
||||
viewModel { DmChatRoomViewModel(get()) }
|
||||
viewModel { ContentAllTabViewModel(get()) }
|
||||
viewModel { ContentMainViewModel(get()) }
|
||||
viewModel { ContentRankingViewModel(get()) }
|
||||
viewModel { HomeCreatorRankingViewModel(get()) }
|
||||
viewModel { HomeFollowingViewModel(get(), get()) }
|
||||
viewModel { HomeRecommendationViewModel(get()) }
|
||||
viewModel { HomeOnAirLiveViewModel(get()) }
|
||||
viewModel { CreatorChannelHomeViewModel(get()) }
|
||||
viewModel { CreatorChannelLiveViewModel(get()) }
|
||||
viewModel { CreatorChannelAudioViewModel(get()) }
|
||||
viewModel { CreatorChannelSeriesViewModel(get()) }
|
||||
viewModel { CreatorChannelCommunityViewModel(get(), get()) }
|
||||
viewModel { CreatorChannelFanTalkViewModel(get(), get()) }
|
||||
viewModel { CreatorChannelDonationViewModel(get(), get()) }
|
||||
viewModel { PushNotificationListViewModel(get()) }
|
||||
viewModel { CharacterTabViewModel(get()) }
|
||||
viewModel { CharacterDetailViewModel(get()) }
|
||||
@@ -431,6 +499,24 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
|
||||
factory { UserEventRepository(get()) }
|
||||
factory { PointStatusRepository(get()) }
|
||||
factory { HomeRepository(get()) }
|
||||
factory { ChatRoomRepository(get()) }
|
||||
factory { DmChatRepository(api = get(), socketClient = get()) }
|
||||
factory { AudioRecommendationsRepository(get()) }
|
||||
factory { AudioRankingsRepository(get()) }
|
||||
factory { MainContentAllTabRepository(get()) }
|
||||
factory { HomeCreatorRankingRepository(get()) }
|
||||
factory { HomeFollowingRepository(get()) }
|
||||
factory { HomeRecommendationRepository(get()) }
|
||||
factory { HomeOnAirLiveRepository(get()) }
|
||||
factory {
|
||||
CreatorChannelRepository(
|
||||
api = get(),
|
||||
userRepository = get(),
|
||||
talkApi = get(),
|
||||
reportRepository = get(),
|
||||
explorerRepository = get()
|
||||
)
|
||||
}
|
||||
factory { CharacterTabRepository(get()) }
|
||||
factory { CharacterDetailRepository(get(), get()) }
|
||||
factory { CharacterGalleryRepository(get()) }
|
||||
|
||||
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.explorer
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.graphics.Rect
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
@@ -16,12 +15,11 @@ import com.jakewharton.rxbinding4.widget.textChanges
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kr.co.vividnext.sodalive.base.BaseFragment
|
||||
import kr.co.vividnext.sodalive.common.Constants
|
||||
import kr.co.vividnext.sodalive.common.LoadingDialog
|
||||
import kr.co.vividnext.sodalive.databinding.FragmentExplorerBinding
|
||||
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import kr.co.vividnext.sodalive.message.SelectMessageRecipientAdapter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -62,9 +60,7 @@ class ExplorerFragment : BaseFragment<FragmentExplorerBinding>(
|
||||
|
||||
private fun setupView() {
|
||||
adapter = ExplorerAdapter {
|
||||
val intent = Intent(requireContext(), UserProfileActivity::class.java)
|
||||
intent.putExtra(Constants.EXTRA_USER_ID, it)
|
||||
startActivity(intent)
|
||||
startActivity(CreatorChannelActivity.newIntent(requireContext(), it))
|
||||
}
|
||||
|
||||
binding.rvExplorer.layoutManager = LinearLayoutManager(
|
||||
@@ -108,9 +104,7 @@ class ExplorerFragment : BaseFragment<FragmentExplorerBinding>(
|
||||
private fun setupSearchChannelView() {
|
||||
searchChannelAdapter = SelectMessageRecipientAdapter {
|
||||
hideKeyboard()
|
||||
val intent = Intent(requireContext(), UserProfileActivity::class.java)
|
||||
intent.putExtra(Constants.EXTRA_USER_ID, it.id)
|
||||
startActivity(intent)
|
||||
startActivity(CreatorChannelActivity.newIntent(requireContext(), it.id))
|
||||
}
|
||||
|
||||
binding.rvSearchChannel.layoutManager = LinearLayoutManager(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("ktlint:package-name", "ktlint:standard:package-name")
|
||||
|
||||
package kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player
|
||||
|
||||
import android.content.Context
|
||||
@@ -27,28 +29,36 @@ class CreatorCommunityMediaPlayerManager(
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
private var currentPlayingContentId: Long? = null
|
||||
private var isPaused: Boolean = false
|
||||
private var isPrepared: Boolean = false
|
||||
|
||||
fun pauseContent() {
|
||||
mediaPlayer?.pause()
|
||||
if (isPrepared) {
|
||||
mediaPlayer?.pause()
|
||||
}
|
||||
isPaused = true
|
||||
updateUI()
|
||||
}
|
||||
|
||||
private fun resumeContent() {
|
||||
pauseAudioContentService()
|
||||
mediaPlayer?.start()
|
||||
if (isPrepared) {
|
||||
mediaPlayer?.start()
|
||||
}
|
||||
isPaused = false
|
||||
updateUI()
|
||||
}
|
||||
|
||||
fun stopContent() {
|
||||
mediaPlayer?.let {
|
||||
it.stop()
|
||||
if (isPrepared) {
|
||||
it.stop()
|
||||
}
|
||||
it.release()
|
||||
mediaPlayer = null
|
||||
}
|
||||
currentPlayingContentId = null
|
||||
isPaused = false
|
||||
isPrepared = false
|
||||
updateUI()
|
||||
}
|
||||
|
||||
@@ -88,13 +98,15 @@ class CreatorCommunityMediaPlayerManager(
|
||||
|
||||
try {
|
||||
setDataSource(context, Uri.parse(creatorCommunityContentItem.url))
|
||||
prepareAsync() // 비동기적으로 준비
|
||||
setOnPreparedListener {
|
||||
start()
|
||||
isPrepared = true
|
||||
if (!isPaused) {
|
||||
start()
|
||||
}
|
||||
updateUI() // 준비 완료 후 UI 업데이트
|
||||
}
|
||||
prepareAsync() // 비동기적으로 준비
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
Toast.makeText(
|
||||
context,
|
||||
SodaLiveApplicationHolder.get()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.explorer.profile.creator_community.write
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -26,9 +27,11 @@ import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.io.File
|
||||
|
||||
class CreatorCommunityWriteActivity : BaseActivity<ActivityCreatorCommunityWriteBinding>(
|
||||
ActivityCreatorCommunityWriteBinding::inflate
|
||||
), RecordingVoiceFragment.OnAudioRecordedListener {
|
||||
class CreatorCommunityWriteActivity :
|
||||
BaseActivity<ActivityCreatorCommunityWriteBinding>(
|
||||
ActivityCreatorCommunityWriteBinding::inflate
|
||||
),
|
||||
RecordingVoiceFragment.OnAudioRecordedListener {
|
||||
|
||||
private val viewModel: CreatorCommunityWriteViewModel by inject()
|
||||
|
||||
@@ -62,7 +65,8 @@ class CreatorCommunityWriteActivity : BaseActivity<ActivityCreatorCommunityWrite
|
||||
context = this,
|
||||
isEnabledFreeStyleCrop = true,
|
||||
config = ImagePickerCropper.Config(
|
||||
aspectX = 1f, aspectY = 1f,
|
||||
aspectX = 1f,
|
||||
aspectY = 1f,
|
||||
compressFormat = Bitmap.CompressFormat.JPEG,
|
||||
compressQuality = 90
|
||||
),
|
||||
@@ -112,7 +116,10 @@ class CreatorCommunityWriteActivity : BaseActivity<ActivityCreatorCommunityWrite
|
||||
binding.llPriceFree.setOnClickListener { viewModel.setPriceFree(true) }
|
||||
binding.tvCancel.setOnClickListener { finish() }
|
||||
binding.tvUpload.setOnClickListener {
|
||||
viewModel.createCommunityPost { finish() }
|
||||
viewModel.createCommunityPost {
|
||||
setResult(Activity.RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ class SodaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
remoteMessage.notification != null
|
||||
) {
|
||||
sendNotification(remoteMessage.data, remoteMessage.notification)
|
||||
} else if (hasDeepLink(remoteMessage.data)) {
|
||||
sendNotification(remoteMessage.data, remoteMessage.notification)
|
||||
} else if (remoteMessage.data["message"]?.isNotBlank() == true) {
|
||||
sendNotification(remoteMessage.data, remoteMessage.notification)
|
||||
}
|
||||
@@ -43,6 +45,11 @@ class SodaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
SharedPreferenceManager.pushToken = token
|
||||
}
|
||||
|
||||
private fun hasDeepLink(messageData: Map<String, String>): Boolean {
|
||||
return messageData["deepLink"]?.isNotBlank() == true ||
|
||||
messageData["deep_link"]?.isNotBlank() == true
|
||||
}
|
||||
|
||||
private fun sendNotification(
|
||||
messageData: Map<String, String>,
|
||||
notification: RemoteMessage.Notification?
|
||||
@@ -78,6 +85,7 @@ class SodaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
val deepLinkExtras = if (!deepLinkUrl.isNullOrBlank()) {
|
||||
android.os.Bundle().apply {
|
||||
putString("deep_link", deepLinkUrl)
|
||||
messageData["room_id"]?.let { putString("room_id", it) }
|
||||
}
|
||||
} else {
|
||||
android.os.Bundle().apply {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.following
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.graphics.Rect
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
@@ -10,12 +9,11 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kr.co.vividnext.sodalive.R
|
||||
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.ActivityFollowingCreatorBinding
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorFollowNotifyFragment
|
||||
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
class FollowingCreatorActivity : BaseActivity<ActivityFollowingCreatorBinding>(
|
||||
@@ -41,9 +39,7 @@ class FollowingCreatorActivity : BaseActivity<ActivityFollowingCreatorBinding>(
|
||||
adapter = FollowingCreatorAdapter(
|
||||
onClickItem = { creatorId ->
|
||||
startActivity(
|
||||
Intent(applicationContext, UserProfileActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, creatorId)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(applicationContext, creatorId)
|
||||
)
|
||||
},
|
||||
onClickFollow = { creatorId, isFollow ->
|
||||
@@ -65,7 +61,7 @@ class FollowingCreatorActivity : BaseActivity<ActivityFollowingCreatorBinding>(
|
||||
} else {
|
||||
viewModel.follow(creatorId)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
binding.rvFollowingCreator.layoutManager = LinearLayoutManager(
|
||||
|
||||
@@ -45,7 +45,6 @@ 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.databinding.FragmentHomeBinding
|
||||
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
|
||||
import kr.co.vividnext.sodalive.home.pushnotification.PushNotificationListActivity
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import kr.co.vividnext.sodalive.live.LiveViewModel
|
||||
@@ -66,6 +65,7 @@ import kr.co.vividnext.sodalive.settings.language.LanguageManager
|
||||
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
|
||||
import kr.co.vividnext.sodalive.settings.notification.MemberRole
|
||||
import kr.co.vividnext.sodalive.splash.SplashActivity
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -271,12 +271,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
|
||||
onClickItem = {
|
||||
if (SharedPreferenceManager.token.isNotBlank()) {
|
||||
startActivity(
|
||||
Intent(
|
||||
requireActivity(),
|
||||
UserProfileActivity::class.java
|
||||
).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, it)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(requireActivity(), it)
|
||||
)
|
||||
} else {
|
||||
(requireActivity() as MainActivity).showLoginActivity()
|
||||
@@ -503,9 +498,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
|
||||
|
||||
AudioContentBannerType.CREATOR -> {
|
||||
startActivity(
|
||||
Intent(requireContext(), UserProfileActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, it.creatorId!!)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(requireContext(), it.creatorId!!)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -922,9 +915,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
|
||||
onClickCreatorProfile = {
|
||||
if (SharedPreferenceManager.token.isNotBlank()) {
|
||||
startActivity(
|
||||
Intent(requireContext(), UserProfileActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, it)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(requireContext(), it)
|
||||
)
|
||||
} else {
|
||||
(requireActivity() as MainActivity).showLoginActivity()
|
||||
|
||||
@@ -38,7 +38,6 @@ 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.databinding.FragmentLiveBinding
|
||||
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityAdapter
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
@@ -73,6 +72,7 @@ import kr.co.vividnext.sodalive.settings.language.LanguageManager
|
||||
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
|
||||
import kr.co.vividnext.sodalive.settings.notification.MemberRole
|
||||
import kr.co.vividnext.sodalive.splash.SplashActivity
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -265,9 +265,7 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
|
||||
adapter = RecommendLiveAdapter(requireContext(), pagerWidth.roundToInt(), pagerHeight) {
|
||||
if (SharedPreferenceManager.token.isNotBlank()) {
|
||||
startActivity(
|
||||
Intent(requireContext(), UserProfileActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, it)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(requireContext(), it)
|
||||
)
|
||||
} else {
|
||||
(requireActivity() as MainActivity).showLoginActivity()
|
||||
@@ -314,9 +312,7 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
|
||||
onClick = {
|
||||
if (SharedPreferenceManager.token.isNotBlank()) {
|
||||
startActivity(
|
||||
Intent(requireContext(), UserProfileActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, it)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(requireContext(), it)
|
||||
)
|
||||
} else {
|
||||
(requireActivity() as MainActivity).showLoginActivity()
|
||||
@@ -388,12 +384,7 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
|
||||
val adapter = LatestFinishedLiveAdapter {
|
||||
if (SharedPreferenceManager.token.isNotBlank()) {
|
||||
startActivity(
|
||||
Intent(
|
||||
requireContext(),
|
||||
UserProfileActivity::class.java
|
||||
).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, it)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(requireContext(), it)
|
||||
)
|
||||
} else {
|
||||
(requireActivity() as MainActivity).showLoginActivity()
|
||||
|
||||
@@ -23,16 +23,15 @@ 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
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.databinding.FragmentLiveRoomDetailBinding
|
||||
import kr.co.vividnext.sodalive.databinding.ItemLiveDetailUserSummaryBinding
|
||||
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
|
||||
import kr.co.vividnext.sodalive.extensions.convertDateFormat
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import kr.co.vividnext.sodalive.settings.language.LanguageManager
|
||||
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
@@ -315,9 +314,7 @@ class LiveRoomDetailFragment(
|
||||
if (manager.isCreator) {
|
||||
binding.tvManagerProfile.visibility = View.VISIBLE
|
||||
binding.tvManagerProfile.setOnClickListener {
|
||||
val intent = Intent(requireActivity(), UserProfileActivity::class.java)
|
||||
intent.putExtra(Constants.EXTRA_USER_ID, manager.id)
|
||||
startActivity(intent)
|
||||
startActivity(CreatorChannelActivity.newIntent(requireActivity(), manager.id))
|
||||
}
|
||||
} else {
|
||||
binding.tvManagerProfile.visibility = View.GONE
|
||||
|
||||
@@ -10,13 +10,14 @@ import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
|
||||
import kr.co.vividnext.sodalive.app.SodaLiveApp
|
||||
import kr.co.vividnext.sodalive.audition.AuditionActivity
|
||||
import kr.co.vividnext.sodalive.common.Constants
|
||||
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
|
||||
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.creator.channel.CreatorChannelActivity
|
||||
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
|
||||
import java.util.Locale
|
||||
|
||||
class DeepLinkActivity : AppCompatActivity() {
|
||||
@@ -43,6 +44,13 @@ class DeepLinkActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
if (SodaLiveApp.isAppInForeground && deepLinkExtras != null && isDmChatDeepLink(deepLinkExtras)) {
|
||||
if (routeForegroundDeepLink(deepLinkExtras)) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (SodaLiveApp.isAppInForeground && LiveRoomActivity.isForeground && deepLinkExtras != null) {
|
||||
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(
|
||||
Intent(Constants.ACTION_LIVE_ROOM_DEEPLINK_CONFIRM).apply {
|
||||
@@ -267,6 +275,11 @@ class DeepLinkActivity : AppCompatActivity() {
|
||||
val communityPostId = bundle.getString(Constants.EXTRA_COMMUNITY_POST_ID)?.toLongOrNull()
|
||||
?: bundle.getLong(Constants.EXTRA_COMMUNITY_POST_ID).takeIf { it > 0 }
|
||||
|
||||
if (isDmChatDeepLink(bundle) && roomId != null && roomId > 0) {
|
||||
startActivity(DmChatRoomActivity.newIntentByRoomId(applicationContext, roomId))
|
||||
return true
|
||||
}
|
||||
|
||||
when {
|
||||
roomId != null && roomId > 0 -> {
|
||||
routeLiveInMain(roomId)
|
||||
@@ -275,9 +288,7 @@ class DeepLinkActivity : AppCompatActivity() {
|
||||
|
||||
channelId != null && channelId > 0 -> {
|
||||
startActivity(
|
||||
Intent(applicationContext, UserProfileActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, channelId)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(applicationContext, channelId)
|
||||
)
|
||||
return true
|
||||
}
|
||||
@@ -319,6 +330,10 @@ class DeepLinkActivity : AppCompatActivity() {
|
||||
return routeByDeepLinkValue(deepLinkValue = deepLinkValue, deepLinkValueId = deepLinkValueId)
|
||||
}
|
||||
|
||||
private fun isDmChatDeepLink(bundle: Bundle): Boolean {
|
||||
return bundle.getString("deep_link_value") == "chat"
|
||||
}
|
||||
|
||||
private fun routeByDeepLinkValue(deepLinkValue: String?, deepLinkValueId: Long?): Boolean {
|
||||
if (deepLinkValue.isNullOrBlank()) {
|
||||
return false
|
||||
@@ -357,9 +372,7 @@ class DeepLinkActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
startActivity(
|
||||
Intent(applicationContext, UserProfileActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, deepLinkValueId)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(applicationContext, deepLinkValueId)
|
||||
)
|
||||
true
|
||||
}
|
||||
@@ -427,6 +440,12 @@ class DeepLinkActivity : AppCompatActivity() {
|
||||
putIfAbsent("deep_link_sub5", pathId)
|
||||
}
|
||||
|
||||
"chat" -> {
|
||||
putIfAbsent("room_id", pathId)
|
||||
putIfAbsent("deep_link_value", "chat")
|
||||
putIfAbsent("deep_link_sub5", pathId)
|
||||
}
|
||||
|
||||
"content" -> {
|
||||
putIfAbsent("content_id", pathId)
|
||||
putIfAbsent("deep_link_value", "content")
|
||||
|
||||
@@ -46,7 +46,6 @@ import kr.co.vividnext.sodalive.common.LoadingDialog
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.databinding.ActivityMainBinding
|
||||
import kr.co.vividnext.sodalive.databinding.ItemMainTabBinding
|
||||
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import kr.co.vividnext.sodalive.home.HomeFragment
|
||||
@@ -56,6 +55,8 @@ 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 kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -316,8 +317,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
|
||||
private fun executeBundleDeeplink(bundle: Bundle): Boolean {
|
||||
val deepLinkUrl = bundle.getString("deep_link")
|
||||
if (!deepLinkUrl.isNullOrBlank()) {
|
||||
val deepLinkBundle = buildBundleFromDeepLinkUrl(deepLinkUrl)
|
||||
return executeBundleRoute(deepLinkBundle ?: bundle)
|
||||
val deepLinkBundle = Bundle(bundle).apply {
|
||||
buildBundleFromDeepLinkUrl(deepLinkUrl)?.let { putAll(it) }
|
||||
}
|
||||
return executeBundleRoute(deepLinkBundle)
|
||||
}
|
||||
|
||||
return executeBundleRoute(bundle)
|
||||
@@ -339,6 +342,11 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
|
||||
val communityPostId = bundle.getString(Constants.EXTRA_COMMUNITY_POST_ID)?.toLongOrNull()
|
||||
?: bundle.getLong(Constants.EXTRA_COMMUNITY_POST_ID).takeIf { it > 0 }
|
||||
when {
|
||||
isDmChatDeepLink(bundle) && roomId != null && roomId > 0 -> {
|
||||
startActivity(DmChatRoomActivity.newIntentByRoomId(applicationContext, roomId))
|
||||
return true
|
||||
}
|
||||
|
||||
roomId != null && roomId > 0 -> {
|
||||
viewModel.clickTab(MainViewModel.CurrentTab.LIVE)
|
||||
|
||||
@@ -349,9 +357,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
|
||||
}
|
||||
|
||||
channelId != null && channelId > 0 -> {
|
||||
val nextIntent = Intent(applicationContext, UserProfileActivity::class.java)
|
||||
nextIntent.putExtra(Constants.EXTRA_USER_ID, channelId)
|
||||
startActivity(nextIntent)
|
||||
startActivity(CreatorChannelActivity.newIntent(applicationContext, channelId))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -495,6 +501,12 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
|
||||
putIfAbsent("deep_link_sub5", pathId)
|
||||
}
|
||||
|
||||
"chat" -> {
|
||||
putIfAbsent("room_id", pathId)
|
||||
putIfAbsent("deep_link_value", "chat")
|
||||
putIfAbsent("deep_link_sub5", pathId)
|
||||
}
|
||||
|
||||
"content" -> {
|
||||
putIfAbsent("content_id", pathId)
|
||||
putIfAbsent("deep_link_value", "content")
|
||||
@@ -577,9 +589,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
|
||||
}
|
||||
|
||||
startActivity(
|
||||
Intent(applicationContext, UserProfileActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, deepLinkValueId)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(applicationContext, deepLinkValueId)
|
||||
)
|
||||
true
|
||||
}
|
||||
@@ -624,6 +634,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDmChatDeepLink(bundle: Bundle): Boolean {
|
||||
return bundle.getString("deep_link_value") == "chat"
|
||||
}
|
||||
|
||||
private fun clearDeferredDeepLink() {
|
||||
SharedPreferenceManager.marketingUtmSource = ""
|
||||
SharedPreferenceManager.marketingUtmMedium = ""
|
||||
|
||||
@@ -25,7 +25,6 @@ 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.FragmentMyBinding
|
||||
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import kr.co.vividnext.sodalive.main.MainActivity
|
||||
@@ -50,6 +49,7 @@ 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.creator.channel.CreatorChannelActivity
|
||||
import kr.co.vividnext.sodalive.v2.main.MainV2Activity
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
@@ -253,15 +253,10 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
|
||||
binding.tvMyChannel.visibility = View.VISIBLE
|
||||
binding.tvMyChannel.setOnClickListener {
|
||||
startActivity(
|
||||
Intent(
|
||||
CreatorChannelActivity.newIntent(
|
||||
requireContext(),
|
||||
UserProfileActivity::class.java
|
||||
).apply {
|
||||
putExtra(
|
||||
Constants.EXTRA_USER_ID,
|
||||
SharedPreferenceManager.userId
|
||||
)
|
||||
}
|
||||
SharedPreferenceManager.userId
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -25,8 +25,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.ActivitySearchBinding
|
||||
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -298,9 +298,7 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(ActivitySearchBinding
|
||||
startActivity(
|
||||
when (item.type) {
|
||||
SearchResponseType.CREATOR -> {
|
||||
Intent(applicationContext, UserProfileActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, item.id)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(applicationContext, item.id)
|
||||
}
|
||||
|
||||
SearchResponseType.CONTENT -> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.settings.notification
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.graphics.Rect
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
@@ -13,13 +12,12 @@ 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
|
||||
import kr.co.vividnext.sodalive.common.LoadingDialog
|
||||
import kr.co.vividnext.sodalive.databinding.ActivityNotificationReceiveSettingsBinding
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorFollowNotifyFragment
|
||||
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import kr.co.vividnext.sodalive.following.FollowingCreatorAdapter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
|
||||
import org.koin.android.ext.android.inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -90,9 +88,7 @@ class NotificationReceiveSettingsActivity : BaseActivity<ActivityNotificationRec
|
||||
adapter = FollowingCreatorAdapter(
|
||||
onClickItem = { creatorId ->
|
||||
startActivity(
|
||||
Intent(applicationContext, UserProfileActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_USER_ID, creatorId)
|
||||
}
|
||||
CreatorChannelActivity.newIntent(applicationContext, creatorId)
|
||||
)
|
||||
},
|
||||
onClickFollow = { creatorId, isFollow ->
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package kr.co.vividnext.sodalive.v2.common
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kr.co.vividnext.sodalive.R
|
||||
|
||||
enum class CreatorActivityType(
|
||||
val code: String,
|
||||
@StringRes val labelResId: Int
|
||||
) {
|
||||
@SerializedName("LIVE")
|
||||
Live("LIVE", R.string.home_recommendation_activity_live),
|
||||
|
||||
@SerializedName("LIVE_REPLAY")
|
||||
LiveReplay("LIVE_REPLAY", R.string.home_recommendation_activity_live),
|
||||
|
||||
@SerializedName("AUDIO")
|
||||
Audio("AUDIO", R.string.home_recommendation_activity_audio),
|
||||
|
||||
@SerializedName("COMMUNITY")
|
||||
Community("COMMUNITY", R.string.home_recommendation_activity_community);
|
||||
|
||||
companion object {
|
||||
fun from(code: String): CreatorActivityType? = entries.firstOrNull { it.code.equals(code, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.v2.common.data
|
||||
|
||||
enum class ContentSort {
|
||||
LATEST,
|
||||
POPULAR,
|
||||
OWNED,
|
||||
PRICE_HIGH,
|
||||
PRICE_LOW
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import kr.co.vividnext.sodalive.base.BaseFragment
|
||||
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelHomeBinding
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHeaderUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeUiState
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelHomeSectionAdapter
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBinding>(
|
||||
FragmentCreatorChannelHomeBinding::inflate
|
||||
) {
|
||||
|
||||
private val viewModel: CreatorChannelHomeViewModel by viewModel()
|
||||
private val sectionAdapter = CreatorChannelHomeSectionAdapter(
|
||||
onLiveClick = ::onCurrentLiveClicked,
|
||||
onScheduleClick = ::onScheduleClicked,
|
||||
onAudioContentClick = ::onAudioContentClicked,
|
||||
onSeriesClick = ::onSeriesClicked,
|
||||
onDonationClick = ::onDonationClicked
|
||||
)
|
||||
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
|
||||
private val host: Host
|
||||
get() = requireActivity() as Host
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.rvHomeSections.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.rvHomeSections.adapter = sectionAdapter
|
||||
observeViewModel()
|
||||
host.onCreatorChannelHomeActionDelegateReady(
|
||||
object : HomeActionDelegate {
|
||||
override fun follow(follow: Boolean, notify: Boolean) {
|
||||
viewModel.follow(follow = follow, notify = notify)
|
||||
}
|
||||
|
||||
override fun createChatRoom(characterId: Long) {
|
||||
viewModel.createChatRoom(characterId)
|
||||
}
|
||||
|
||||
override fun blockUser() {
|
||||
viewModel.blockUser()
|
||||
}
|
||||
|
||||
override fun reportUser(reason: String) {
|
||||
viewModel.reportUser(reason)
|
||||
}
|
||||
|
||||
override fun reportProfile() {
|
||||
viewModel.reportProfile()
|
||||
}
|
||||
|
||||
override fun postChannelDonation(can: Int, isSecret: Boolean, message: String) {
|
||||
viewModel.postChannelDonation(can = can, isSecret = isSecret, message = message)
|
||||
}
|
||||
|
||||
override fun refreshHome() {
|
||||
if (creatorId > 0L) {
|
||||
viewModel.loadHome(creatorId)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if (creatorId > 0L) {
|
||||
viewModel.loadHome(creatorId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
binding.rvHomeSections.adapter = null
|
||||
host.onCreatorChannelHomeActionDelegateReady(null)
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
viewModel.homeStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
is CreatorChannelHomeUiState.Content -> {
|
||||
host.onCreatorChannelHeaderChanged(state.header)
|
||||
sectionAdapter.submitItems(state.sections)
|
||||
host.onCreatorChannelHomeContentChanged()
|
||||
}
|
||||
is CreatorChannelHomeUiState.Error -> Unit
|
||||
CreatorChannelHomeUiState.Empty -> Unit
|
||||
CreatorChannelHomeUiState.Loading -> Unit
|
||||
}
|
||||
}
|
||||
viewModel.chatRoomIdLiveData.observe(viewLifecycleOwner) { event ->
|
||||
event.consume()?.let(host::onCreatorChannelChatRoomCreated)
|
||||
}
|
||||
viewModel.toastLiveData.observe(viewLifecycleOwner) { event ->
|
||||
event.consume()?.let {
|
||||
val message = it.message ?: it.resId?.let(::getString)
|
||||
message?.let { text -> Toast.makeText(requireContext(), text, Toast.LENGTH_LONG).show() }
|
||||
}
|
||||
}
|
||||
viewModel.isFollowInProgressLiveData.observe(viewLifecycleOwner) {
|
||||
host.onCreatorChannelFollowProgressChanged(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse) {
|
||||
host.onCreatorChannelScheduleClicked(schedule)
|
||||
}
|
||||
|
||||
private fun onAudioContentClicked(audioContent: CreatorChannelAudioContentResponse) {
|
||||
host.onCreatorChannelAudioContentClicked(audioContent)
|
||||
}
|
||||
|
||||
private fun onSeriesClicked(series: CreatorChannelSeriesResponse) {
|
||||
host.onCreatorChannelSeriesClicked(series)
|
||||
}
|
||||
|
||||
private fun onDonationClicked() {
|
||||
host.onCreatorChannelDonationClicked()
|
||||
}
|
||||
|
||||
private fun onCurrentLiveClicked(live: CreatorChannelLiveResponse) {
|
||||
host.onCreatorChannelCurrentLiveClicked(live)
|
||||
}
|
||||
|
||||
interface Host {
|
||||
fun onCreatorChannelHeaderChanged(header: CreatorChannelHeaderUiModel)
|
||||
fun onCreatorChannelFollowProgressChanged(inProgress: Boolean)
|
||||
fun onCreatorChannelChatRoomCreated(chatRoomId: Long)
|
||||
fun onCreatorChannelScheduleClicked(schedule: CreatorChannelScheduleResponse)
|
||||
fun onCreatorChannelAudioContentClicked(audioContent: CreatorChannelAudioContentResponse)
|
||||
fun onCreatorChannelSeriesClicked(series: CreatorChannelSeriesResponse)
|
||||
fun onCreatorChannelHomeActionDelegateReady(delegate: HomeActionDelegate?)
|
||||
fun onCreatorChannelHomeContentChanged()
|
||||
fun onCreatorChannelDonationClicked()
|
||||
fun onCreatorChannelCurrentLiveClicked(live: CreatorChannelLiveResponse)
|
||||
}
|
||||
|
||||
interface HomeActionDelegate {
|
||||
fun follow(follow: Boolean, notify: Boolean)
|
||||
fun createChatRoom(characterId: Long)
|
||||
fun blockUser()
|
||||
fun reportUser(reason: String)
|
||||
fun reportProfile()
|
||||
fun postChannelDonation(can: Int, isSecret: Boolean, message: String)
|
||||
fun refreshHome()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_CREATOR_ID: String = "arg_creator_id"
|
||||
|
||||
fun newInstance(creatorId: Long): CreatorChannelHomeFragment {
|
||||
return CreatorChannelHomeFragment().apply {
|
||||
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.orhanobut.logger.Logger
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.base.BaseViewModel
|
||||
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.common.ToastMessage
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeUiState
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.toUiContent
|
||||
|
||||
class CreatorChannelHomeViewModel(
|
||||
private val repository: CreatorChannelRepository
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val _homeStateLiveData = MutableLiveData<CreatorChannelHomeUiState>()
|
||||
val homeStateLiveData: LiveData<CreatorChannelHomeUiState>
|
||||
get() = _homeStateLiveData
|
||||
|
||||
private val _toastLiveData = MutableLiveData<CreatorChannelEvent<ToastMessage>>()
|
||||
val toastLiveData: LiveData<CreatorChannelEvent<ToastMessage>>
|
||||
get() = _toastLiveData
|
||||
|
||||
private val _chatRoomIdLiveData = MutableLiveData<CreatorChannelEvent<Long>>()
|
||||
val chatRoomIdLiveData: LiveData<CreatorChannelEvent<Long>>
|
||||
get() = _chatRoomIdLiveData
|
||||
|
||||
private val _isFollowInProgressLiveData = MutableLiveData(false)
|
||||
val isFollowInProgressLiveData: LiveData<Boolean>
|
||||
get() = _isFollowInProgressLiveData
|
||||
|
||||
private var isFollowInProgress = false
|
||||
private var isCreateChatRoomInProgress = false
|
||||
private var isPostChannelDonationInProgress = false
|
||||
|
||||
fun loadHome(creatorId: Long) {
|
||||
if (creatorId <= 0) return
|
||||
|
||||
_homeStateLiveData.value = CreatorChannelHomeUiState.Loading
|
||||
compositeDisposable.add(
|
||||
repository.getHome(creatorId = creatorId, token = authToken())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
val data = it.data
|
||||
if (it.success && data != null) {
|
||||
_homeStateLiveData.value = data.toUiContent(currentMemberId = SharedPreferenceManager.userId)
|
||||
} else {
|
||||
showUnknownError(it.message)
|
||||
}
|
||||
},
|
||||
{
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
showUnknownError(it.message)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun follow(follow: Boolean, notify: Boolean) {
|
||||
val content = _homeStateLiveData.value as? CreatorChannelHomeUiState.Content ?: return
|
||||
if (isFollowInProgress) return
|
||||
|
||||
isFollowInProgress = true
|
||||
_isFollowInProgressLiveData.value = true
|
||||
compositeDisposable.add(
|
||||
repository.followCreator(
|
||||
creatorId = content.header.creatorId,
|
||||
follow = follow,
|
||||
notify = notify,
|
||||
token = authToken()
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
isFollowInProgress = false
|
||||
_isFollowInProgressLiveData.value = false
|
||||
if (it.success) {
|
||||
_homeStateLiveData.value = content.copy(
|
||||
header = content.header.copy(isFollow = follow, isNotify = notify)
|
||||
)
|
||||
if (!follow) {
|
||||
_toastLiveData.value = CreatorChannelEvent(
|
||||
ToastMessage(resId = R.string.creator_channel_unfollow_success)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
showUnknownErrorToast()
|
||||
}
|
||||
},
|
||||
{
|
||||
isFollowInProgress = false
|
||||
_isFollowInProgressLiveData.value = false
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
showUnknownErrorToast()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createChatRoom(characterId: Long) {
|
||||
if (characterId <= 0 || isCreateChatRoomInProgress) return
|
||||
|
||||
isCreateChatRoomInProgress = true
|
||||
compositeDisposable.add(
|
||||
repository.createChatRoom(characterId = characterId, token = authToken())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
isCreateChatRoomInProgress = false
|
||||
val data = it.data
|
||||
if (it.success && data != null) {
|
||||
_chatRoomIdLiveData.value = CreatorChannelEvent(data.chatRoomId)
|
||||
} else {
|
||||
showUnknownErrorToast()
|
||||
}
|
||||
},
|
||||
{
|
||||
isCreateChatRoomInProgress = false
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
showUnknownErrorToast()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun postChannelDonation(can: Int, isSecret: Boolean, message: String) {
|
||||
val content = _homeStateLiveData.value as? CreatorChannelHomeUiState.Content ?: return
|
||||
if (isPostChannelDonationInProgress) return
|
||||
|
||||
isPostChannelDonationInProgress = true
|
||||
compositeDisposable.add(
|
||||
repository.postChannelDonation(
|
||||
creatorId = content.header.creatorId,
|
||||
can = can,
|
||||
isSecret = isSecret,
|
||||
message = message,
|
||||
token = authToken()
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
isPostChannelDonationInProgress = false
|
||||
if (it.success) {
|
||||
SharedPreferenceManager.can = (SharedPreferenceManager.can - can).coerceAtLeast(0)
|
||||
loadHome(content.header.creatorId)
|
||||
} else {
|
||||
showUnknownErrorToast()
|
||||
}
|
||||
},
|
||||
{
|
||||
isPostChannelDonationInProgress = false
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
showUnknownErrorToast()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun blockUser() {
|
||||
val content = _homeStateLiveData.value as? CreatorChannelHomeUiState.Content ?: return
|
||||
|
||||
compositeDisposable.add(
|
||||
repository.blockUser(content.header.creatorId, authToken())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
if (it.success) {
|
||||
_toastLiveData.value = CreatorChannelEvent(
|
||||
ToastMessage(resId = R.string.creator_channel_block_success)
|
||||
)
|
||||
} else {
|
||||
showUnknownErrorToast()
|
||||
}
|
||||
},
|
||||
{
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
showUnknownErrorToast()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun reportUser(reason: String) {
|
||||
val content = _homeStateLiveData.value as? CreatorChannelHomeUiState.Content ?: return
|
||||
|
||||
compositeDisposable.add(
|
||||
repository.reportUser(content.header.creatorId, reason, authToken())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
if (it.success) {
|
||||
showReportSubmittedToast()
|
||||
} else {
|
||||
showUnknownErrorToast()
|
||||
}
|
||||
},
|
||||
{
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
showUnknownErrorToast()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun reportProfile() {
|
||||
val content = _homeStateLiveData.value as? CreatorChannelHomeUiState.Content ?: return
|
||||
val reason = SodaLiveApplicationHolder.get().getString(R.string.dialog_member_profile_report_profile)
|
||||
|
||||
compositeDisposable.add(
|
||||
repository.reportProfile(content.header.creatorId, reason, authToken())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
if (it.success) {
|
||||
showReportSubmittedToast()
|
||||
} else {
|
||||
showUnknownErrorToast()
|
||||
}
|
||||
},
|
||||
{
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
showUnknownErrorToast()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun showUnknownError(message: String?) {
|
||||
_homeStateLiveData.value = CreatorChannelHomeUiState.Error(message = message)
|
||||
showUnknownErrorToast()
|
||||
}
|
||||
|
||||
private fun showUnknownErrorToast() {
|
||||
_toastLiveData.value = CreatorChannelEvent(ToastMessage(resId = R.string.common_error_unknown))
|
||||
}
|
||||
|
||||
private fun showReportSubmittedToast() {
|
||||
_toastLiveData.value = CreatorChannelEvent(ToastMessage(resId = R.string.character_comment_report_submitted))
|
||||
}
|
||||
|
||||
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
|
||||
}
|
||||
|
||||
class CreatorChannelEvent<out T>(private val value: T) {
|
||||
private var consumed: Boolean = false
|
||||
|
||||
fun consume(): T? {
|
||||
if (consumed) return null
|
||||
consumed = true
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel
|
||||
|
||||
import android.content.Intent
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
|
||||
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService
|
||||
import kr.co.vividnext.sodalive.common.Constants
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import kr.co.vividnext.sodalive.live.LiveViewModel
|
||||
import kr.co.vividnext.sodalive.live.reservation.complete.LiveReservationCompleteActivity
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
|
||||
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse
|
||||
import kr.co.vividnext.sodalive.live.room.detail.LiveRoomDetailFragment
|
||||
import kr.co.vividnext.sodalive.live.room.dialog.LiveCancelDialog
|
||||
import kr.co.vividnext.sodalive.live.room.dialog.LivePaymentDialog
|
||||
import kr.co.vividnext.sodalive.live.room.dialog.LiveRoomPasswordDialog
|
||||
import kr.co.vividnext.sodalive.live.room.update.LiveRoomEditActivity
|
||||
import kr.co.vividnext.sodalive.settings.language.LanguageManager
|
||||
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
class CreatorChannelLiveCoordinator(
|
||||
private val activity: CreatorChannelActivity,
|
||||
private val layoutInflater: LayoutInflater,
|
||||
private val fragmentManager: FragmentManager,
|
||||
private val liveViewModel: LiveViewModel,
|
||||
private val screenWidthProvider: () -> Int,
|
||||
private val refreshHome: () -> Unit
|
||||
) {
|
||||
fun showLiveRoomDetail(roomId: Long) {
|
||||
val detailFragment = LiveRoomDetailFragment(
|
||||
roomId,
|
||||
onClickParticipant = {},
|
||||
onClickReservation = { reservationRoom(roomId) },
|
||||
onClickModify = { roomDetailResponse -> modifyLive(roomDetailResponse) },
|
||||
onClickStart = { startLive(roomId) },
|
||||
onClickCancel = { cancelLive(roomId) }
|
||||
)
|
||||
if (detailFragment.isAdded) return
|
||||
|
||||
detailFragment.show(fragmentManager, detailFragment.tag)
|
||||
}
|
||||
|
||||
fun enterLiveRoom(roomId: Long) {
|
||||
activity.startService(
|
||||
Intent(activity, AudioContentPlayService::class.java).apply {
|
||||
action = AudioContentPlayService.MusicAction.STOP.name
|
||||
}
|
||||
)
|
||||
activity.startService(
|
||||
Intent(activity, AudioContentPlayerService::class.java).apply {
|
||||
action = "STOP_SERVICE"
|
||||
}
|
||||
)
|
||||
|
||||
val onEnterRoomSuccess = {
|
||||
activity.runOnUiThread { openLiveRoom(roomId) }
|
||||
}
|
||||
|
||||
liveViewModel.getRoomDetail(roomId) {
|
||||
if (!it.channelName.isNullOrBlank()) {
|
||||
if (it.manager.id == SharedPreferenceManager.userId) {
|
||||
liveViewModel.enterRoom(roomId, onEnterRoomSuccess)
|
||||
} else if (it.price == 0 || it.isPaid) {
|
||||
if (it.isPrivateRoom) {
|
||||
LiveRoomPasswordDialog(
|
||||
activity = activity,
|
||||
layoutInflater = layoutInflater,
|
||||
can = 0,
|
||||
confirmButtonClick = { password ->
|
||||
liveViewModel.enterRoom(
|
||||
roomId = roomId,
|
||||
onSuccess = onEnterRoomSuccess,
|
||||
password = password
|
||||
)
|
||||
}
|
||||
).show(screenWidthProvider())
|
||||
} else {
|
||||
liveViewModel.enterRoom(roomId, onEnterRoomSuccess)
|
||||
}
|
||||
} else {
|
||||
showPaidLiveEntryDialog(
|
||||
roomId = roomId,
|
||||
beginDateTimeUtc = it.beginDateTimeUtc,
|
||||
price = it.price,
|
||||
isPrivateRoom = it.isPrivateRoom,
|
||||
onEnterRoomSuccess = onEnterRoomSuccess
|
||||
)
|
||||
}
|
||||
} else {
|
||||
showLiveRoomDetail(roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reservationRoom(roomId: Long) {
|
||||
liveViewModel.getRoomDetail(roomId) {
|
||||
if (it.manager.id == SharedPreferenceManager.userId) {
|
||||
Toast.makeText(
|
||||
activity,
|
||||
activity.getString(R.string.screen_live_reservation_self_block),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
return@getRoomDetail
|
||||
}
|
||||
|
||||
if (it.isPrivateRoom) {
|
||||
LiveRoomPasswordDialog(
|
||||
activity = activity,
|
||||
layoutInflater = layoutInflater,
|
||||
can = if (it.isPaid) 0 else it.price,
|
||||
confirmButtonClick = { password -> processLiveReservation(roomId, password) }
|
||||
).show(screenWidthProvider())
|
||||
} else if (it.price == 0 || it.isPaid) {
|
||||
processLiveReservation(roomId)
|
||||
} else {
|
||||
LivePaymentDialog(
|
||||
activity = activity,
|
||||
layoutInflater = layoutInflater,
|
||||
title = activity.getString(
|
||||
R.string.screen_live_reservation_pay_title,
|
||||
it.price.moneyFormat()
|
||||
),
|
||||
desc = activity.getString(R.string.screen_live_reservation_pay_desc, it.title),
|
||||
confirmButtonTitle = activity.getString(R.string.screen_live_reservation_confirm),
|
||||
confirmButtonClick = { processLiveReservation(roomId) },
|
||||
cancelButtonTitle = activity.getString(R.string.cancel),
|
||||
cancelButtonClick = {}
|
||||
).show(screenWidthProvider())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processLiveReservation(roomId: Long, password: String? = null) {
|
||||
liveViewModel.reservationRoom(roomId, password) {
|
||||
refreshHome()
|
||||
activity.startActivity(
|
||||
Intent(activity, LiveReservationCompleteActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_LIVE_RESERVATION_RESPONSE, it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun modifyLive(roomDetail: GetRoomDetailResponse) {
|
||||
activity.startActivity(
|
||||
Intent(activity, LiveRoomEditActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_ROOM_DETAIL, roomDetail)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun startLive(roomId: Long) {
|
||||
liveViewModel.startLive(roomId) {
|
||||
refreshHome()
|
||||
activity.runOnUiThread { openLiveRoom(roomId) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelLive(roomId: Long) {
|
||||
LiveCancelDialog(
|
||||
activity = activity,
|
||||
layoutInflater = layoutInflater,
|
||||
title = activity.getString(R.string.screen_live_cancel_title),
|
||||
hint = activity.getString(R.string.screen_live_cancel_hint),
|
||||
confirmButtonTitle = activity.getString(R.string.screen_live_cancel_confirm),
|
||||
confirmButtonClick = { reason ->
|
||||
liveViewModel.cancelLive(roomId, reason) {
|
||||
Toast.makeText(
|
||||
activity,
|
||||
activity.getString(R.string.screen_live_cancel_success),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
refreshHome()
|
||||
}
|
||||
},
|
||||
cancelButtonTitle = activity.getString(R.string.dialog_close),
|
||||
cancelButtonClick = {}
|
||||
).show(screenWidthProvider())
|
||||
}
|
||||
|
||||
private fun showPaidLiveEntryDialog(
|
||||
roomId: Long,
|
||||
beginDateTimeUtc: String,
|
||||
price: Int,
|
||||
isPrivateRoom: Boolean,
|
||||
onEnterRoomSuccess: () -> Unit
|
||||
) {
|
||||
val locale = Locale(LanguageManager.getEffectiveLanguage(activity))
|
||||
val wrappedContext = LocaleHelper.wrap(activity)
|
||||
val beginDate = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.parse(beginDateTimeUtc) ?: return
|
||||
val now = Date()
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd, HH:mm", locale)
|
||||
val diffTime = now.time - beginDate.time
|
||||
val hours = (diffTime / (1000 * 60 * 60)).toInt()
|
||||
val mins = (diffTime / (1000 * 60)).toInt() % 60
|
||||
|
||||
if (isPrivateRoom) {
|
||||
LiveRoomPasswordDialog(
|
||||
activity = activity,
|
||||
layoutInflater = layoutInflater,
|
||||
can = price,
|
||||
confirmButtonClick = { password ->
|
||||
liveViewModel.enterRoom(
|
||||
roomId = roomId,
|
||||
onSuccess = onEnterRoomSuccess,
|
||||
password = password
|
||||
)
|
||||
}
|
||||
).show(screenWidthProvider())
|
||||
return
|
||||
}
|
||||
|
||||
LivePaymentDialog(
|
||||
activity = activity,
|
||||
layoutInflater = layoutInflater,
|
||||
title = wrappedContext.getString(R.string.live_paid_title),
|
||||
startDateTime = if (hours >= 1) dateFormat.format(beginDate) else null,
|
||||
nowDateTime = if (hours >= 1) dateFormat.format(now) else null,
|
||||
desc = wrappedContext.getString(R.string.live_paid_desc, price),
|
||||
desc2 = if (hours >= 1) {
|
||||
wrappedContext.getString(R.string.live_paid_warning, hours, mins)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
confirmButtonTitle = wrappedContext.getString(R.string.live_paid_confirm),
|
||||
confirmButtonClick = { liveViewModel.enterRoom(roomId, onEnterRoomSuccess) },
|
||||
cancelButtonTitle = wrappedContext.getString(R.string.cancel),
|
||||
cancelButtonClick = {}
|
||||
).show(screenWidthProvider())
|
||||
}
|
||||
|
||||
private fun openLiveRoom(roomId: Long) {
|
||||
activity.startActivity(
|
||||
Intent(activity, LiveRoomActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_ROOM_ID, roomId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import kr.co.vividnext.sodalive.databinding.DialogCreatorChannelMoreBinding
|
||||
|
||||
class CreatorChannelMoreBottomSheet : BottomSheetDialogFragment() {
|
||||
|
||||
var onClickBlock: (() -> Unit)? = null
|
||||
var onClickUserReport: (() -> Unit)? = null
|
||||
var onClickProfileReport: (() -> Unit)? = null
|
||||
|
||||
private var binding: DialogCreatorChannelMoreBinding? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val viewBinding = DialogCreatorChannelMoreBinding.inflate(inflater, container, false)
|
||||
binding = viewBinding
|
||||
return viewBinding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val viewBinding = binding ?: return
|
||||
|
||||
viewBinding.tvUserBlock.setOnClickListener {
|
||||
dismiss()
|
||||
onClickBlock?.invoke()
|
||||
}
|
||||
viewBinding.tvUserReport.setOnClickListener {
|
||||
dismiss()
|
||||
onClickUserReport?.invoke()
|
||||
}
|
||||
viewBinding.tvProfileReport.setOnClickListener {
|
||||
dismiss()
|
||||
onClickProfileReport?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance(): CreatorChannelMoreBottomSheet = CreatorChannelMoreBottomSheet()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.CreatorChannelAudioFragment
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityFragment
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.CreatorChannelDonationFragment
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.CreatorChannelFanTalkFragment
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.CreatorChannelLiveFragment
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTab
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.CreatorChannelSeriesFragment
|
||||
|
||||
class CreatorChannelPagerAdapter(
|
||||
activity: FragmentActivity,
|
||||
private val creatorId: Long,
|
||||
private val tabs: List<CreatorChannelTab> = CreatorChannelTab.entries
|
||||
) : FragmentStateAdapter(activity) {
|
||||
|
||||
override fun getItemCount(): Int = tabs.size
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
val tab = tabs[position]
|
||||
return when (tab) {
|
||||
CreatorChannelTab.Home -> CreatorChannelHomeFragment.newInstance(creatorId)
|
||||
CreatorChannelTab.Live -> CreatorChannelLiveFragment.newInstance(creatorId)
|
||||
CreatorChannelTab.Audio -> CreatorChannelAudioFragment.newInstance(creatorId)
|
||||
CreatorChannelTab.Series -> CreatorChannelSeriesFragment.newInstance(creatorId)
|
||||
CreatorChannelTab.Community -> CreatorChannelCommunityFragment.newInstance(creatorId)
|
||||
CreatorChannelTab.FanTalk -> CreatorChannelFanTalkFragment.newInstance(creatorId)
|
||||
CreatorChannelTab.Donation -> CreatorChannelDonationFragment.newInstance(creatorId)
|
||||
else -> CreatorChannelPlaceholderFragment.newInstance(tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import kr.co.vividnext.sodalive.base.BaseFragment
|
||||
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelPlaceholderBinding
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTab
|
||||
|
||||
class CreatorChannelPlaceholderFragment : BaseFragment<FragmentCreatorChannelPlaceholderBinding>(
|
||||
FragmentCreatorChannelPlaceholderBinding::inflate
|
||||
) {
|
||||
|
||||
private val tabName: String by lazy { arguments?.getString(ARG_TAB_NAME).orEmpty() }
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.tvPlaceholder.text = tabName
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_TAB_NAME: String = "arg_tab_name"
|
||||
|
||||
fun newInstance(tab: CreatorChannelTab): CreatorChannelPlaceholderFragment {
|
||||
return CreatorChannelPlaceholderFragment().apply {
|
||||
arguments = Bundle().apply { putString(ARG_TAB_NAME, tab.name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.audio
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.base.BaseFragment
|
||||
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelAudioBinding
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.CreatorChannelAudioRateUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelAudioContentAdapter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelSortPopup
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.toLabelResId
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
class CreatorChannelAudioFragment : BaseFragment<FragmentCreatorChannelAudioBinding>(
|
||||
FragmentCreatorChannelAudioBinding::inflate
|
||||
) {
|
||||
|
||||
private val viewModel: CreatorChannelAudioViewModel by viewModel()
|
||||
private val audioContentAdapter = CreatorChannelAudioContentAdapter { item ->
|
||||
host.onCreatorChannelAudioContentClicked(item.audioContentId)
|
||||
}
|
||||
private var sortPopup: CreatorChannelSortPopup? = null
|
||||
private var currentContentState: CreatorChannelAudioUiState.Content? = null
|
||||
private var lastContentLayoutKey: CreatorChannelAudioContentLayoutKey? = null
|
||||
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
|
||||
private val host: Host
|
||||
get() = requireActivity() as Host
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
bindLoading()
|
||||
setupAudioList()
|
||||
setupClickListeners()
|
||||
observeViewModel()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
sortPopup?.dismiss()
|
||||
sortPopup = null
|
||||
currentContentState = null
|
||||
lastContentLayoutKey = null
|
||||
binding.rvCreatorChannelAudioContents.adapter = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun setupAudioList() = with(binding.rvCreatorChannelAudioContents) {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
adapter = audioContentAdapter
|
||||
}
|
||||
|
||||
private fun setupClickListeners() = with(binding) {
|
||||
ivCreatorChannelAudioSort.setImageResource(R.drawable.ic_new_sort)
|
||||
layoutCreatorChannelAudioSortButton.setOnClickListener {
|
||||
currentContentState?.let { state -> showSortPopup(state) }
|
||||
}
|
||||
viewCreatorChannelAudioThemeTabs.root.setOnTabSelectedListener { index ->
|
||||
currentContentState?.themes?.getOrNull(index)?.let { theme ->
|
||||
viewModel.changeTheme(theme.themeId)
|
||||
}
|
||||
}
|
||||
btnCreatorChannelAudioRetry.setOnClickListener {
|
||||
viewModel.retryAudio()
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
viewModel.audioStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
CreatorChannelAudioUiState.Loading -> bindLoading()
|
||||
CreatorChannelAudioUiState.Empty -> bindEmpty()
|
||||
is CreatorChannelAudioUiState.Error -> bindError(state)
|
||||
is CreatorChannelAudioUiState.Content -> bindContent(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onCreatorChannelAudioTabSelected() {
|
||||
if (creatorId > 0L) {
|
||||
viewModel.loadAudio(creatorId, isOwner = host.isCreatorChannelOwner())
|
||||
}
|
||||
}
|
||||
|
||||
fun onCreatorChannelAudioScrolledToBottom() {
|
||||
viewModel.loadMore()
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun onCreatorChannelAudioViewportHeightChanged(minHeight: Int) = Unit
|
||||
|
||||
fun onCreatorChannelAudioOwnerCtaVisibilityChanged(isVisible: Boolean) = with(binding) {
|
||||
val bottomPadding = if (isVisible) {
|
||||
OWNER_CTA_LIST_BOTTOM_PADDING_DP.dpToPx().toInt()
|
||||
} else {
|
||||
DEFAULT_LIST_BOTTOM_PADDING_DP.dpToPx().toInt()
|
||||
}
|
||||
rvCreatorChannelAudioContents.updatePadding(bottom = bottomPadding)
|
||||
layoutCreatorChannelAudioEmpty.updatePadding(bottom = bottomPadding)
|
||||
}
|
||||
|
||||
private fun bindLoading() = with(binding) {
|
||||
currentContentState = null
|
||||
lastContentLayoutKey = null
|
||||
viewCreatorChannelAudioThemeTabs.root.isVisible = false
|
||||
layoutCreatorChannelAudioSortBar.isVisible = false
|
||||
layoutCreatorChannelAudioRateCard.isVisible = false
|
||||
rvCreatorChannelAudioContents.isVisible = false
|
||||
layoutCreatorChannelAudioEmpty.isVisible = false
|
||||
tvCreatorChannelAudioErrorMessage.isVisible = false
|
||||
btnCreatorChannelAudioRetry.isVisible = false
|
||||
}
|
||||
|
||||
private fun bindEmpty() = with(binding) {
|
||||
currentContentState = null
|
||||
lastContentLayoutKey = null
|
||||
viewCreatorChannelAudioThemeTabs.root.isVisible = false
|
||||
layoutCreatorChannelAudioSortBar.isVisible = false
|
||||
layoutCreatorChannelAudioRateCard.isVisible = false
|
||||
rvCreatorChannelAudioContents.isVisible = false
|
||||
layoutCreatorChannelAudioEmpty.isVisible = true
|
||||
tvCreatorChannelAudioErrorMessage.isVisible = false
|
||||
btnCreatorChannelAudioRetry.isVisible = false
|
||||
host.onCreatorChannelAudioContentChanged()
|
||||
}
|
||||
|
||||
private fun bindError(state: CreatorChannelAudioUiState.Error) = with(binding) {
|
||||
currentContentState = null
|
||||
lastContentLayoutKey = null
|
||||
viewCreatorChannelAudioThemeTabs.root.isVisible = false
|
||||
layoutCreatorChannelAudioSortBar.isVisible = false
|
||||
layoutCreatorChannelAudioRateCard.isVisible = false
|
||||
rvCreatorChannelAudioContents.isVisible = false
|
||||
layoutCreatorChannelAudioEmpty.isVisible = false
|
||||
tvCreatorChannelAudioErrorMessage.isVisible = true
|
||||
tvCreatorChannelAudioErrorMessage.text = state.message ?: getString(R.string.creator_channel_audio_error_message)
|
||||
btnCreatorChannelAudioRetry.isVisible = true
|
||||
host.onCreatorChannelAudioContentChanged()
|
||||
}
|
||||
|
||||
private fun bindContent(state: CreatorChannelAudioUiState.Content) = with(binding) {
|
||||
currentContentState = state
|
||||
viewCreatorChannelAudioThemeTabs.root.isVisible = true
|
||||
viewCreatorChannelAudioThemeTabs.root.setMenus(
|
||||
menus = state.themes.map { it.title },
|
||||
selectedIndex = state.themes.indexOfFirst { it.isSelected }.coerceAtLeast(0)
|
||||
)
|
||||
layoutCreatorChannelAudioSortBar.isVisible = true
|
||||
tvCreatorChannelAudioTotalCount.text = state.audioContentCount.moneyFormat()
|
||||
tvCreatorChannelAudioSortLabel.setText(state.selectedSort.toLabelResId())
|
||||
bindRate(state.rate)
|
||||
rvCreatorChannelAudioContents.isVisible = true
|
||||
audioContentAdapter.submitItems(state.audioContents)
|
||||
layoutCreatorChannelAudioEmpty.isVisible = false
|
||||
tvCreatorChannelAudioErrorMessage.isVisible = false
|
||||
btnCreatorChannelAudioRetry.isVisible = false
|
||||
notifyContentChangedIfLayoutChanged(state)
|
||||
state.paginationErrorMessage?.let {
|
||||
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
|
||||
viewModel.consumePaginationErrorMessage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelAudioUiState.Content) {
|
||||
val contentLayoutKey = state.toContentLayoutKey()
|
||||
if (contentLayoutKey == lastContentLayoutKey) return
|
||||
|
||||
lastContentLayoutKey = contentLayoutKey
|
||||
host.onCreatorChannelAudioContentChanged()
|
||||
}
|
||||
|
||||
private fun showSortPopup(state: CreatorChannelAudioUiState.Content) {
|
||||
sortPopup?.dismiss()
|
||||
sortPopup = CreatorChannelSortPopup(
|
||||
anchor = binding.layoutCreatorChannelAudioSortButton,
|
||||
selectedSort = state.selectedSort,
|
||||
onSortSelected = { sort -> viewModel.changeSort(sort) }
|
||||
).also { it.show() }
|
||||
}
|
||||
|
||||
private fun bindRate(rate: CreatorChannelAudioRateUiModel?) = with(binding) {
|
||||
layoutCreatorChannelAudioRateCard.isVisible = rate != null
|
||||
if (rate == null) return@with
|
||||
|
||||
val ratePercentText = rate.ratePercent.toInt().toString()
|
||||
val rateMessage = getString(
|
||||
R.string.creator_channel_audio_owned_rate_message,
|
||||
ratePercentText
|
||||
)
|
||||
tvCreatorChannelAudioRateMessage.text = rateMessage.highlightRatePercent(ratePercentText)
|
||||
val purchasedCountText = rate.purchasedCount.moneyFormat()
|
||||
val rateCount = getString(
|
||||
R.string.creator_channel_audio_owned_rate_count,
|
||||
purchasedCountText,
|
||||
rate.paidCount.moneyFormat()
|
||||
)
|
||||
tvCreatorChannelAudioRateCount.text = rateCount.highlightPaidCount(purchasedCountText)
|
||||
viewCreatorChannelAudioRateFill.pivotX = 0f
|
||||
viewCreatorChannelAudioRateFill.scaleX = (rate.ratePercent / 100.0).toFloat().coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
private fun String.highlightRatePercent(ratePercentText: String): SpannableString {
|
||||
val spannable = SpannableString(this)
|
||||
val target = "$ratePercentText%"
|
||||
val start = indexOf(target)
|
||||
if (start < 0) return spannable
|
||||
|
||||
spannable.setSpan(
|
||||
ForegroundColorSpan(ContextCompat.getColor(requireContext(), R.color.soda_400)),
|
||||
start,
|
||||
start + target.length,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
return spannable
|
||||
}
|
||||
|
||||
private fun String.highlightPaidCount(purchasedCountText: String): SpannableString {
|
||||
val spannable = SpannableString(this)
|
||||
val start = purchasedCountText.length
|
||||
if (start >= length) return spannable
|
||||
|
||||
spannable.setSpan(
|
||||
ForegroundColorSpan(ContextCompat.getColor(requireContext(), R.color.gray_500)),
|
||||
start,
|
||||
length,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
return spannable
|
||||
}
|
||||
|
||||
interface Host {
|
||||
fun isCreatorChannelOwner(): Boolean
|
||||
fun onCreatorChannelAudioContentClicked(audioContentId: Long)
|
||||
fun onCreatorChannelAudioContentChanged()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_CREATOR_ID: String = "arg_creator_id"
|
||||
private const val DEFAULT_LIST_BOTTOM_PADDING_DP = 32
|
||||
private const val OWNER_CTA_LIST_BOTTOM_PADDING_DP = 132
|
||||
|
||||
fun newInstance(creatorId: Long): CreatorChannelAudioFragment {
|
||||
return CreatorChannelAudioFragment().apply {
|
||||
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class CreatorChannelAudioContentLayoutKey(
|
||||
val audioContentCount: Int,
|
||||
val selectedThemeId: Long?,
|
||||
val audioContentIds: List<Long>
|
||||
)
|
||||
|
||||
private fun CreatorChannelAudioUiState.Content.toContentLayoutKey(): CreatorChannelAudioContentLayoutKey {
|
||||
return CreatorChannelAudioContentLayoutKey(
|
||||
audioContentCount = audioContentCount,
|
||||
selectedThemeId = selectedThemeId,
|
||||
audioContentIds = audioContents.map { it.audioContentId }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.audio
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.orhanobut.logger.Logger
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kr.co.vividnext.sodalive.base.BaseViewModel
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.data.CreatorChannelAudioTabResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelAudioContentUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.CreatorChannelAudioRateUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.CreatorChannelAudioThemeUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.effectiveSelectedThemeId
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.toAudioContentUiModels
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.toRateUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.toThemeUiModels
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
|
||||
|
||||
class CreatorChannelAudioViewModel(
|
||||
private val repository: CreatorChannelRepository
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val _audioStateLiveData = MutableLiveData<CreatorChannelAudioUiState>()
|
||||
val audioStateLiveData: LiveData<CreatorChannelAudioUiState>
|
||||
get() = _audioStateLiveData
|
||||
|
||||
private var creatorId: Long = 0L
|
||||
private var isOwner: Boolean = false
|
||||
private var selectedSort: ContentSort = ContentSort.LATEST
|
||||
private var selectedThemeId: Long? = null
|
||||
private var requestGeneration: Int = 0
|
||||
|
||||
fun loadAudio(creatorId: Long, isOwner: Boolean) {
|
||||
if (creatorId <= 0) return
|
||||
val shouldSkipReload = this.creatorId == creatorId && this.isOwner == isOwner && _audioStateLiveData.value != null
|
||||
if (shouldSkipReload) return
|
||||
|
||||
this.creatorId = creatorId
|
||||
this.isOwner = isOwner
|
||||
loadFirstPage(selectedSort, selectedThemeId)
|
||||
}
|
||||
|
||||
fun changeSort(sort: ContentSort) {
|
||||
if (sort == selectedSort) return
|
||||
if (creatorId <= 0) return
|
||||
|
||||
selectedSort = sort
|
||||
loadFirstPage(sort, selectedThemeId)
|
||||
}
|
||||
|
||||
fun changeTheme(themeId: Long?) {
|
||||
if (themeId == selectedThemeId) return
|
||||
if (creatorId <= 0) return
|
||||
|
||||
selectedThemeId = themeId
|
||||
loadFirstPage(selectedSort, themeId)
|
||||
}
|
||||
|
||||
fun retryAudio() {
|
||||
if (creatorId <= 0) return
|
||||
|
||||
loadFirstPage(selectedSort, selectedThemeId)
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val content = _audioStateLiveData.value as? CreatorChannelAudioUiState.Content ?: return
|
||||
if (!content.hasNext || content.isLoadingMore || creatorId <= 0) return
|
||||
|
||||
val generation = requestGeneration
|
||||
_audioStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null)
|
||||
requestAudio(
|
||||
page = content.page + 1,
|
||||
sort = content.selectedSort,
|
||||
themeId = content.selectedThemeId,
|
||||
generation = generation
|
||||
) { response ->
|
||||
val data = response.data
|
||||
val current = _audioStateLiveData.value as? CreatorChannelAudioUiState.Content ?: content
|
||||
if (response.success && data != null) {
|
||||
_audioStateLiveData.value = current.copy(
|
||||
audioContents = current.audioContents + data.audioContents.toAudioContentUiModels(),
|
||||
page = data.page,
|
||||
size = data.size,
|
||||
hasNext = data.hasNext,
|
||||
isLoadingMore = false
|
||||
)
|
||||
} else {
|
||||
_audioStateLiveData.value = current.copy(
|
||||
isLoadingMore = false,
|
||||
paginationErrorMessage = response.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consumePaginationErrorMessage() {
|
||||
val content = _audioStateLiveData.value as? CreatorChannelAudioUiState.Content ?: return
|
||||
if (content.paginationErrorMessage == null) return
|
||||
|
||||
_audioStateLiveData.value = content.copy(paginationErrorMessage = null)
|
||||
}
|
||||
|
||||
private fun loadFirstPage(sort: ContentSort, themeId: Long?) {
|
||||
val generation = ++requestGeneration
|
||||
_audioStateLiveData.value = CreatorChannelAudioUiState.Loading
|
||||
requestAudio(page = FIRST_PAGE, sort = sort, themeId = themeId, generation = generation) { response ->
|
||||
val data = response.data
|
||||
if (response.success && data != null) {
|
||||
selectedThemeId = data.effectiveSelectedThemeId()
|
||||
val audioContents = data.audioContents.toAudioContentUiModels()
|
||||
_audioStateLiveData.value = if (audioContents.isEmpty() || data.audioContentCount == 0) {
|
||||
CreatorChannelAudioUiState.Empty
|
||||
} else {
|
||||
data.toContentState(audioContents = audioContents)
|
||||
}
|
||||
} else {
|
||||
_audioStateLiveData.value = CreatorChannelAudioUiState.Error(response.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestAudio(
|
||||
page: Int,
|
||||
sort: ContentSort,
|
||||
themeId: Long?,
|
||||
generation: Int,
|
||||
onSuccess: (ApiResponse<CreatorChannelAudioTabResponse>) -> Unit
|
||||
) {
|
||||
compositeDisposable.add(
|
||||
repository.getAudio(
|
||||
creatorId = creatorId,
|
||||
page = page,
|
||||
size = DEFAULT_PAGE_SIZE,
|
||||
sort = sort,
|
||||
themeId = themeId,
|
||||
token = authToken()
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
if (generation == requestGeneration) {
|
||||
onSuccess(it)
|
||||
}
|
||||
},
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
val current = _audioStateLiveData.value as? CreatorChannelAudioUiState.Content
|
||||
_audioStateLiveData.value = if (current != null && page > FIRST_PAGE) {
|
||||
current.copy(isLoadingMore = false, paginationErrorMessage = it.message)
|
||||
} else {
|
||||
CreatorChannelAudioUiState.Error(it.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CreatorChannelAudioTabResponse.toContentState(
|
||||
audioContents: List<CreatorChannelAudioContentUiModel>,
|
||||
isLoadingMore: Boolean = false
|
||||
) = CreatorChannelAudioUiState.Content(
|
||||
audioContentCount = audioContentCount,
|
||||
themes = toThemeUiModels(),
|
||||
selectedSort = sort,
|
||||
selectedThemeId = effectiveSelectedThemeId(),
|
||||
rate = toRateUiModel(isOwner),
|
||||
audioContents = audioContents,
|
||||
page = page,
|
||||
size = size,
|
||||
hasNext = hasNext,
|
||||
isLoadingMore = isLoadingMore
|
||||
)
|
||||
|
||||
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
|
||||
|
||||
companion object {
|
||||
val DEFAULT_PAGE_SIZE = 20
|
||||
private const val FIRST_PAGE = 0
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface CreatorChannelAudioUiState {
|
||||
data object Loading : CreatorChannelAudioUiState
|
||||
data object Empty : CreatorChannelAudioUiState
|
||||
data class Error(val message: String?) : CreatorChannelAudioUiState
|
||||
data class Content(
|
||||
val audioContentCount: Int,
|
||||
val themes: List<CreatorChannelAudioThemeUiModel>,
|
||||
val selectedSort: ContentSort,
|
||||
val selectedThemeId: Long?,
|
||||
val rate: CreatorChannelAudioRateUiModel?,
|
||||
val audioContents: List<CreatorChannelAudioContentUiModel>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val paginationErrorMessage: String? = null
|
||||
) : CreatorChannelAudioUiState
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.audio.data
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelAudioTabResponse(
|
||||
@SerializedName("audioContentCount") val audioContentCount: Int,
|
||||
@SerializedName("themes") val themes: List<CreatorChannelAudioThemeResponse>,
|
||||
@SerializedName("themeId") val themeId: Long?,
|
||||
@SerializedName("purchasedAudioContentRate") val purchasedAudioContentRate: Double,
|
||||
@SerializedName("purchasedAudioContentCount") val purchasedAudioContentCount: Int,
|
||||
@SerializedName("paidAudioContentCount") val paidAudioContentCount: Int,
|
||||
@SerializedName("audioContents") val audioContents: List<CreatorChannelAudioContentResponse>,
|
||||
@SerializedName("sort") val sort: ContentSort,
|
||||
@SerializedName("page") val page: Int,
|
||||
@SerializedName("size") val size: Int,
|
||||
@SerializedName("hasNext") val hasNext: Boolean
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelAudioThemeResponse(
|
||||
@SerializedName("themeId") val themeId: Long,
|
||||
@SerializedName("themeName") val themeName: String
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.audio.model
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.data.CreatorChannelAudioTabResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelAudioContentStatus
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelAudioContentUiModel
|
||||
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
|
||||
|
||||
private const val ALL_THEME_TITLE = "전체"
|
||||
|
||||
fun CreatorChannelAudioTabResponse.toThemeUiModels(): List<CreatorChannelAudioThemeUiModel> =
|
||||
listOf(
|
||||
CreatorChannelAudioThemeUiModel(
|
||||
themeId = null,
|
||||
title = ALL_THEME_TITLE,
|
||||
isSelected = effectiveSelectedThemeId() == null
|
||||
)
|
||||
) +
|
||||
themes.map { theme ->
|
||||
CreatorChannelAudioThemeUiModel(
|
||||
themeId = theme.themeId,
|
||||
title = theme.themeName,
|
||||
isSelected = theme.themeId == effectiveSelectedThemeId()
|
||||
)
|
||||
}
|
||||
|
||||
fun CreatorChannelAudioTabResponse.effectiveSelectedThemeId(): Long? =
|
||||
themeId?.takeIf { selectedThemeId -> themes.any { it.themeId == selectedThemeId } }
|
||||
|
||||
fun CreatorChannelAudioTabResponse.toRateUiModel(isOwner: Boolean): CreatorChannelAudioRateUiModel? =
|
||||
if (!isOwner && effectiveSelectedThemeId() == null) {
|
||||
CreatorChannelAudioRateUiModel(
|
||||
ratePercent = purchasedAudioContentRate,
|
||||
purchasedCount = purchasedAudioContentCount,
|
||||
paidCount = paidAudioContentCount
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
fun List<CreatorChannelAudioContentResponse>.toAudioContentUiModels(): List<CreatorChannelAudioContentUiModel> =
|
||||
mapNotNull { it.toAudioContentUiModel() }
|
||||
|
||||
private fun CreatorChannelAudioContentResponse.toAudioContentUiModel(): CreatorChannelAudioContentUiModel? {
|
||||
val duration = duration ?: return null
|
||||
return CreatorChannelAudioContentUiModel(
|
||||
audioContentId = audioContentId,
|
||||
title = title,
|
||||
secondaryText = secondaryText(duration),
|
||||
imageUrl = imageUrl,
|
||||
price = price,
|
||||
showAdultBadge = isAdult,
|
||||
tags = toAudioContentTags(),
|
||||
status = toAudioContentStatus()
|
||||
)
|
||||
}
|
||||
|
||||
private fun CreatorChannelAudioContentResponse.secondaryText(duration: String): String =
|
||||
if (seriesName.isNullOrBlank()) {
|
||||
duration
|
||||
} else {
|
||||
"$duration • $seriesName"
|
||||
}
|
||||
|
||||
private fun CreatorChannelAudioContentResponse.toAudioContentTags(): Set<AudioContentTag> = buildSet {
|
||||
if (isOriginalSeries == true) add(AudioContentTag.Original)
|
||||
if (isFirstContent) add(AudioContentTag.First)
|
||||
if (isPointAvailable) add(AudioContentTag.Point)
|
||||
if (price == 0) add(AudioContentTag.Free)
|
||||
}
|
||||
|
||||
private fun CreatorChannelAudioContentResponse.toAudioContentStatus(): CreatorChannelAudioContentStatus = when {
|
||||
isOwned -> CreatorChannelAudioContentStatus.Owned
|
||||
isRented -> CreatorChannelAudioContentStatus.Rented
|
||||
price == 0 -> CreatorChannelAudioContentStatus.Play
|
||||
else -> CreatorChannelAudioContentStatus.Price(price)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.audio.model
|
||||
|
||||
data class CreatorChannelAudioThemeUiModel(
|
||||
val themeId: Long?,
|
||||
val title: String,
|
||||
val isSelected: Boolean
|
||||
)
|
||||
|
||||
data class CreatorChannelAudioRateUiModel(
|
||||
val ratePercent: Double,
|
||||
val purchasedCount: Int,
|
||||
val paidCount: Int
|
||||
)
|
||||
@@ -0,0 +1,257 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.doOnLayout
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.base.BaseFragment
|
||||
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelCommunityBinding
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.CreatorCommunityContentItem
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.CreatorCommunityMediaPlayerManager
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityViewMode
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.ui.CreatorChannelCommunityGridAdapter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.ui.calculateCreatorChannelCommunityGridItemSize
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.ui.CreatorChannelCommunityListAdapter
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
class CreatorChannelCommunityFragment : BaseFragment<FragmentCreatorChannelCommunityBinding>(
|
||||
FragmentCreatorChannelCommunityBinding::inflate
|
||||
) {
|
||||
|
||||
private val viewModel: CreatorChannelCommunityViewModel by viewModel()
|
||||
private val listAdapter = CreatorChannelCommunityListAdapter(
|
||||
onPlayClick = { item -> toggleCommunityAudio(item) },
|
||||
onOwnerMoreClick = { item -> host.onCreatorChannelCommunityOwnerMoreClicked(item) },
|
||||
isPlayingContent = { postId -> mediaPlayerManager?.isPlayingContent(postId) == true }
|
||||
)
|
||||
private val gridAdapter = CreatorChannelCommunityGridAdapter()
|
||||
private var mediaPlayerManager: CreatorCommunityMediaPlayerManager? = null
|
||||
private var currentContentState: CreatorChannelCommunityUiState.Content? = null
|
||||
private var lastContentLayoutKey: CreatorChannelCommunityContentLayoutKey? = null
|
||||
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
|
||||
private val host: Host
|
||||
get() = requireActivity() as Host
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
mediaPlayerManager = CreatorCommunityMediaPlayerManager(requireContext()) { listAdapter.notifyDataSetChanged() }
|
||||
bindLoading()
|
||||
setupCommunityList()
|
||||
setupClickListeners()
|
||||
observeViewModel()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
mediaPlayerManager?.stopContent()
|
||||
mediaPlayerManager = null
|
||||
currentContentState = null
|
||||
lastContentLayoutKey = null
|
||||
binding.rvCreatorChannelCommunity.adapter = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
mediaPlayerManager?.pauseContent()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
private fun setupCommunityList() = with(binding.rvCreatorChannelCommunity) {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
adapter = listAdapter
|
||||
}
|
||||
|
||||
private fun setupClickListeners() = with(binding) {
|
||||
layoutCreatorChannelCommunityViewModeButton.setOnClickListener {
|
||||
viewModel.toggleViewMode()
|
||||
}
|
||||
btnCreatorChannelCommunityRetry.setOnClickListener {
|
||||
viewModel.retryCommunity()
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
viewModel.communityStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
CreatorChannelCommunityUiState.Loading -> bindLoading()
|
||||
CreatorChannelCommunityUiState.Empty -> bindEmpty()
|
||||
is CreatorChannelCommunityUiState.Error -> bindError(state)
|
||||
is CreatorChannelCommunityUiState.Content -> bindContent(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onCreatorChannelCommunityTabSelected() {
|
||||
if (creatorId > 0L) {
|
||||
viewModel.loadCommunity(creatorId, isOwner = host.isCreatorChannelOwner())
|
||||
}
|
||||
}
|
||||
|
||||
fun onCreatorChannelCommunityScrolledToBottom() {
|
||||
viewModel.loadMore()
|
||||
}
|
||||
|
||||
fun onCreatorChannelCommunityRefreshRequested() {
|
||||
viewModel.refreshCommunity()
|
||||
}
|
||||
|
||||
fun onCreatorChannelCommunityOwnerCtaVisibilityChanged(isVisible: Boolean) {
|
||||
applyOwnerCtaPadding(isVisible)
|
||||
}
|
||||
|
||||
private fun bindLoading() = with(binding) {
|
||||
currentContentState = null
|
||||
lastContentLayoutKey = null
|
||||
layoutCreatorChannelCommunitySortBar.isVisible = false
|
||||
rvCreatorChannelCommunity.isVisible = false
|
||||
layoutCreatorChannelCommunityEmpty.isVisible = false
|
||||
tvCreatorChannelCommunityErrorMessage.isVisible = false
|
||||
btnCreatorChannelCommunityRetry.isVisible = false
|
||||
}
|
||||
|
||||
private fun bindEmpty() = with(binding) {
|
||||
currentContentState = null
|
||||
lastContentLayoutKey = null
|
||||
layoutCreatorChannelCommunitySortBar.isVisible = false
|
||||
rvCreatorChannelCommunity.isVisible = false
|
||||
layoutCreatorChannelCommunityEmpty.isVisible = true
|
||||
tvCreatorChannelCommunityErrorMessage.isVisible = false
|
||||
btnCreatorChannelCommunityRetry.isVisible = false
|
||||
host.onCreatorChannelCommunityContentChanged()
|
||||
}
|
||||
|
||||
private fun bindError(state: CreatorChannelCommunityUiState.Error) = with(binding) {
|
||||
currentContentState = null
|
||||
lastContentLayoutKey = null
|
||||
layoutCreatorChannelCommunitySortBar.isVisible = false
|
||||
rvCreatorChannelCommunity.isVisible = false
|
||||
layoutCreatorChannelCommunityEmpty.isVisible = false
|
||||
tvCreatorChannelCommunityErrorMessage.isVisible = true
|
||||
tvCreatorChannelCommunityErrorMessage.text = state.message ?: getString(R.string.creator_channel_community_error_message)
|
||||
btnCreatorChannelCommunityRetry.isVisible = true
|
||||
host.onCreatorChannelCommunityContentChanged()
|
||||
}
|
||||
|
||||
private fun bindContent(state: CreatorChannelCommunityUiState.Content) = with(binding) {
|
||||
currentContentState = state
|
||||
layoutCreatorChannelCommunitySortBar.isVisible = true
|
||||
tvCreatorChannelCommunityTotalCount.text = state.communityPostCount.moneyFormat()
|
||||
tvCreatorChannelCommunityViewModeLabel.setText(state.viewMode.labelResId)
|
||||
ivCreatorChannelCommunityViewMode.setImageResource(state.viewMode.iconResId)
|
||||
rvCreatorChannelCommunity.isVisible = true
|
||||
bindCommunityAdapter(state)
|
||||
layoutCreatorChannelCommunityEmpty.isVisible = false
|
||||
tvCreatorChannelCommunityErrorMessage.isVisible = false
|
||||
btnCreatorChannelCommunityRetry.isVisible = false
|
||||
notifyContentChangedIfLayoutChanged(state)
|
||||
state.paginationErrorMessage?.let {
|
||||
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
|
||||
viewModel.consumePaginationErrorMessage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindCommunityAdapter(state: CreatorChannelCommunityUiState.Content) = with(binding.rvCreatorChannelCommunity) {
|
||||
when (state.viewMode) {
|
||||
CreatorChannelCommunityViewMode.List -> {
|
||||
if (adapter !== listAdapter) {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
adapter = listAdapter
|
||||
}
|
||||
applyCommunityListPadding()
|
||||
listAdapter.submitItems(state.communityPosts)
|
||||
}
|
||||
CreatorChannelCommunityViewMode.Grid -> {
|
||||
if (adapter !== gridAdapter) {
|
||||
layoutManager = GridLayoutManager(requireContext(), 3)
|
||||
adapter = gridAdapter
|
||||
}
|
||||
applyCommunityGridPadding()
|
||||
updateGridItemSize()
|
||||
doOnLayout { updateGridItemSize() }
|
||||
gridAdapter.submitItems(state.communityPosts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyCommunityListPadding() = with(binding.rvCreatorChannelCommunity) {
|
||||
updatePadding(
|
||||
left = DEFAULT_LIST_HORIZONTAL_PADDING_DP.dpToPx().toInt(),
|
||||
right = DEFAULT_LIST_HORIZONTAL_PADDING_DP.dpToPx().toInt()
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyCommunityGridPadding() = with(binding.rvCreatorChannelCommunity) {
|
||||
updatePadding(left = 0, right = 0)
|
||||
}
|
||||
|
||||
private fun calculateGridItemSize(): Int = with(binding.rvCreatorChannelCommunity) {
|
||||
return calculateCreatorChannelCommunityGridItemSize(width - paddingStart - paddingEnd)
|
||||
}
|
||||
|
||||
private fun updateGridItemSize() {
|
||||
gridAdapter.setItemSizePx(calculateGridItemSize())
|
||||
}
|
||||
|
||||
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelCommunityUiState.Content) {
|
||||
val contentLayoutKey = state.toContentLayoutKey()
|
||||
if (contentLayoutKey == lastContentLayoutKey) return
|
||||
|
||||
lastContentLayoutKey = contentLayoutKey
|
||||
host.onCreatorChannelCommunityContentChanged()
|
||||
}
|
||||
|
||||
private fun applyOwnerCtaPadding(isVisible: Boolean) = with(binding) {
|
||||
val bottomPadding = if (isVisible) {
|
||||
OWNER_CTA_LIST_BOTTOM_PADDING_DP.dpToPx().toInt()
|
||||
} else {
|
||||
DEFAULT_LIST_BOTTOM_PADDING_DP.dpToPx().toInt()
|
||||
}
|
||||
rvCreatorChannelCommunity.updatePadding(bottom = bottomPadding)
|
||||
layoutCreatorChannelCommunityEmpty.updatePadding(bottom = bottomPadding)
|
||||
}
|
||||
|
||||
private fun toggleCommunityAudio(item: CreatorChannelCommunityPostUiModel) {
|
||||
val audioUrl = item.audioUrl ?: return
|
||||
mediaPlayerManager?.toggleContent(CreatorCommunityContentItem(item.postId, audioUrl))
|
||||
}
|
||||
|
||||
interface Host {
|
||||
fun isCreatorChannelOwner(): Boolean
|
||||
fun onCreatorChannelCommunityContentChanged()
|
||||
fun onCreatorChannelCommunityOwnerMoreClicked(item: CreatorChannelCommunityPostUiModel)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_CREATOR_ID: String = "arg_creator_id"
|
||||
private const val DEFAULT_LIST_HORIZONTAL_PADDING_DP = 14
|
||||
private const val DEFAULT_LIST_BOTTOM_PADDING_DP = 32
|
||||
private const val OWNER_CTA_LIST_BOTTOM_PADDING_DP = 132
|
||||
|
||||
fun newInstance(creatorId: Long): CreatorChannelCommunityFragment {
|
||||
return CreatorChannelCommunityFragment().apply {
|
||||
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class CreatorChannelCommunityContentLayoutKey(
|
||||
val communityPostCount: Int,
|
||||
val viewMode: CreatorChannelCommunityViewMode,
|
||||
val communityPostIds: List<Long>
|
||||
)
|
||||
|
||||
private fun CreatorChannelCommunityUiState.Content.toContentLayoutKey(): CreatorChannelCommunityContentLayoutKey {
|
||||
return CreatorChannelCommunityContentLayoutKey(
|
||||
communityPostCount = communityPostCount,
|
||||
viewMode = viewMode,
|
||||
communityPostIds = communityPosts.map { it.postId }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community
|
||||
|
||||
typealias CreatorChannelCommunityViewMode =
|
||||
kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityViewMode
|
||||
@@ -0,0 +1,186 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kr.co.vividnext.sodalive.base.BaseViewModel
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityTabResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.toCommunityPostUiModels
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
|
||||
|
||||
class CreatorChannelCommunityViewModel(
|
||||
private val repository: CreatorChannelRepository,
|
||||
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val _communityStateLiveData = MutableLiveData<CreatorChannelCommunityUiState>()
|
||||
val communityStateLiveData: LiveData<CreatorChannelCommunityUiState>
|
||||
get() = _communityStateLiveData
|
||||
|
||||
private var creatorId: Long = 0L
|
||||
private var isOwner: Boolean = false
|
||||
private var viewMode: CreatorChannelCommunityViewMode = CreatorChannelCommunityViewMode.List
|
||||
private var requestGeneration: Int = 0
|
||||
|
||||
fun loadCommunity(creatorId: Long, isOwner: Boolean) {
|
||||
if (creatorId <= 0) return
|
||||
val shouldSkipReload = this.creatorId == creatorId && this.isOwner == isOwner && _communityStateLiveData.value != null
|
||||
if (shouldSkipReload) return
|
||||
|
||||
this.creatorId = creatorId
|
||||
this.isOwner = isOwner
|
||||
loadFirstPage()
|
||||
}
|
||||
|
||||
fun toggleViewMode() {
|
||||
val content = _communityStateLiveData.value as? CreatorChannelCommunityUiState.Content ?: return
|
||||
viewMode = when (content.viewMode) {
|
||||
CreatorChannelCommunityViewMode.List -> CreatorChannelCommunityViewMode.Grid
|
||||
CreatorChannelCommunityViewMode.Grid -> CreatorChannelCommunityViewMode.List
|
||||
}
|
||||
_communityStateLiveData.value = content.copy(viewMode = viewMode)
|
||||
}
|
||||
|
||||
fun retryCommunity() {
|
||||
if (creatorId <= 0) return
|
||||
|
||||
loadFirstPage()
|
||||
}
|
||||
|
||||
fun refreshCommunity() {
|
||||
if (creatorId <= 0) return
|
||||
|
||||
loadFirstPage()
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val content = _communityStateLiveData.value as? CreatorChannelCommunityUiState.Content ?: return
|
||||
if (!content.hasNext || content.isLoadingMore || creatorId <= 0) return
|
||||
|
||||
val generation = requestGeneration
|
||||
_communityStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null)
|
||||
requestCommunity(page = content.page + 1, generation = generation) { response ->
|
||||
val data = response.data
|
||||
val current = _communityStateLiveData.value as? CreatorChannelCommunityUiState.Content ?: content
|
||||
if (response.success && data != null) {
|
||||
_communityStateLiveData.value = current.copy(
|
||||
communityPosts = current.communityPosts + data.toCommunityPostUiModels(),
|
||||
page = data.page,
|
||||
size = data.size,
|
||||
hasNext = data.hasNext,
|
||||
isLoadingMore = false
|
||||
)
|
||||
} else {
|
||||
_communityStateLiveData.value = current.copy(
|
||||
isLoadingMore = false,
|
||||
paginationErrorMessage = response.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consumePaginationErrorMessage() {
|
||||
val content = _communityStateLiveData.value as? CreatorChannelCommunityUiState.Content ?: return
|
||||
if (content.paginationErrorMessage == null) return
|
||||
|
||||
_communityStateLiveData.value = content.copy(paginationErrorMessage = null)
|
||||
}
|
||||
|
||||
private fun loadFirstPage() {
|
||||
val generation = ++requestGeneration
|
||||
_communityStateLiveData.value = CreatorChannelCommunityUiState.Loading
|
||||
requestCommunity(page = FIRST_PAGE, generation = generation) { response ->
|
||||
val data = response.data
|
||||
if (response.success && data != null) {
|
||||
val communityPosts = data.toCommunityPostUiModels()
|
||||
_communityStateLiveData.value = if (communityPosts.isEmpty() || data.communityPostCount == 0) {
|
||||
CreatorChannelCommunityUiState.Empty
|
||||
} else {
|
||||
data.toContentState(communityPosts = communityPosts)
|
||||
}
|
||||
} else {
|
||||
_communityStateLiveData.value = CreatorChannelCommunityUiState.Error(response.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestCommunity(
|
||||
page: Int,
|
||||
generation: Int,
|
||||
onSuccess: (ApiResponse<CreatorChannelCommunityTabResponse>) -> Unit
|
||||
) {
|
||||
compositeDisposable.add(
|
||||
repository.getCommunity(
|
||||
creatorId = creatorId,
|
||||
page = page,
|
||||
size = DEFAULT_PAGE_SIZE,
|
||||
token = authToken()
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
if (generation == requestGeneration) {
|
||||
onSuccess(it)
|
||||
}
|
||||
},
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
|
||||
val current = _communityStateLiveData.value as? CreatorChannelCommunityUiState.Content
|
||||
_communityStateLiveData.value = if (current != null && page > FIRST_PAGE) {
|
||||
current.copy(isLoadingMore = false, paginationErrorMessage = it.message)
|
||||
} else {
|
||||
CreatorChannelCommunityUiState.Error(it.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CreatorChannelCommunityTabResponse.toContentState(
|
||||
communityPosts: List<CreatorChannelCommunityPostUiModel>
|
||||
) = CreatorChannelCommunityUiState.Content(
|
||||
communityPostCount = communityPostCount,
|
||||
communityPosts = communityPosts,
|
||||
viewMode = viewMode,
|
||||
page = page,
|
||||
size = size,
|
||||
hasNext = hasNext
|
||||
)
|
||||
|
||||
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
|
||||
|
||||
private fun CreatorChannelCommunityTabResponse.toCommunityPostUiModels(): List<CreatorChannelCommunityPostUiModel> =
|
||||
communityPosts.toCommunityPostUiModels(
|
||||
relativeTimeTextFormatter = relativeTimeTextFormatter,
|
||||
isOwner = isOwner,
|
||||
currentUserId = SharedPreferenceManager.userId
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_PAGE_SIZE = 20
|
||||
private const val FIRST_PAGE = 0
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface CreatorChannelCommunityUiState {
|
||||
data object Loading : CreatorChannelCommunityUiState
|
||||
data object Empty : CreatorChannelCommunityUiState
|
||||
data class Error(val message: String?) : CreatorChannelCommunityUiState
|
||||
data class Content(
|
||||
val communityPostCount: Int,
|
||||
val communityPosts: List<CreatorChannelCommunityPostUiModel>,
|
||||
val viewMode: CreatorChannelCommunityViewMode,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val paginationErrorMessage: String? = null
|
||||
) : CreatorChannelCommunityUiState
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.data
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelCommunityTabResponse(
|
||||
@SerializedName("communityPostCount") val communityPostCount: Int,
|
||||
@SerializedName("communityPosts") val communityPosts: List<CreatorChannelCommunityPostResponse>,
|
||||
@SerializedName("page") val page: Int,
|
||||
@SerializedName("size") val size: Int,
|
||||
@SerializedName("hasNext") val hasNext: Boolean
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelCommunityPostResponse(
|
||||
@SerializedName("postId") val postId: Long,
|
||||
@SerializedName("creatorId") val creatorId: Long,
|
||||
@SerializedName("creatorNickname") val creatorNickname: String,
|
||||
@SerializedName("creatorProfileUrl") val creatorProfileUrl: String,
|
||||
@SerializedName("createdAtUtc") val createdAtUtc: String,
|
||||
@SerializedName("content") val content: String,
|
||||
@SerializedName("imageUrl") val imageUrl: String?,
|
||||
@SerializedName("audioUrl") val audioUrl: String?,
|
||||
@SerializedName("price") val price: Int,
|
||||
@SerializedName("existOrdered") val existOrdered: Boolean,
|
||||
@SerializedName("isCommentAvailable") val isCommentAvailable: Boolean,
|
||||
@SerializedName("likeCount") val likeCount: Int,
|
||||
@SerializedName("commentCount") val commentCount: Int,
|
||||
@SerializedName("isPinned") val isPinned: Boolean
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.model
|
||||
|
||||
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityPostResponse
|
||||
|
||||
private const val GRID_PREVIEW_MAX_LENGTH = 24
|
||||
|
||||
fun List<CreatorChannelCommunityPostResponse>.toCommunityPostUiModels(
|
||||
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter,
|
||||
isOwner: Boolean,
|
||||
currentUserId: Long
|
||||
): List<CreatorChannelCommunityPostUiModel> = map {
|
||||
it.toCommunityPostUiModel(relativeTimeTextFormatter, isOwner, currentUserId)
|
||||
}
|
||||
|
||||
private fun CreatorChannelCommunityPostResponse.toCommunityPostUiModel(
|
||||
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter,
|
||||
isOwner: Boolean,
|
||||
currentUserId: Long
|
||||
): CreatorChannelCommunityPostUiModel {
|
||||
val isLocked = price > 0 && !existOrdered && !isOwner
|
||||
val showOwnerActions = isOwner && creatorId == currentUserId
|
||||
val visibleImageUrl = imageUrl.takeUnless { isLocked }
|
||||
val showPlayButton = !isLocked && !audioUrl.isNullOrBlank() && !visibleImageUrl.isNullOrBlank()
|
||||
return CreatorChannelCommunityPostUiModel(
|
||||
postId = postId,
|
||||
creatorId = creatorId,
|
||||
creatorNickname = creatorNickname,
|
||||
creatorProfileUrl = creatorProfileUrl,
|
||||
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
|
||||
content = content,
|
||||
imageUrl = visibleImageUrl,
|
||||
audioUrl = audioUrl,
|
||||
price = price,
|
||||
existOrdered = existOrdered,
|
||||
likeCount = likeCount,
|
||||
commentCount = commentCount,
|
||||
showComment = isCommentAvailable,
|
||||
showNotice = isPinned,
|
||||
isPinned = isPinned,
|
||||
isLocked = isLocked,
|
||||
showOwnerMore = showOwnerActions,
|
||||
showOwnerTopPrice = showOwnerActions && price > 0,
|
||||
showPlayButton = showPlayButton,
|
||||
gridPreviewText = content.toGridPreviewText(),
|
||||
imageMode = toImageMode(isLocked, visibleImageUrl)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CreatorChannelCommunityPostResponse.toImageMode(
|
||||
isLocked: Boolean,
|
||||
visibleImageUrl: String?
|
||||
): CreatorChannelCommunityImageMode = when {
|
||||
isLocked -> CreatorChannelCommunityImageMode.LockedGray
|
||||
visibleImageUrl.isNullOrBlank() -> CreatorChannelCommunityImageMode.TextPreview
|
||||
else -> CreatorChannelCommunityImageMode.Image
|
||||
}
|
||||
|
||||
private fun String.toGridPreviewText(): String = replace("\n", " ")
|
||||
.trim()
|
||||
.take(GRID_PREVIEW_MAX_LENGTH)
|
||||
@@ -0,0 +1,49 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.model
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import kr.co.vividnext.sodalive.R
|
||||
|
||||
enum class CreatorChannelCommunityViewMode(
|
||||
@StringRes val labelResId: Int,
|
||||
@DrawableRes val iconResId: Int
|
||||
) {
|
||||
List(
|
||||
labelResId = R.string.creator_channel_community_view_mode_list,
|
||||
iconResId = R.drawable.ic_new_list
|
||||
),
|
||||
Grid(
|
||||
labelResId = R.string.creator_channel_community_view_mode_grid,
|
||||
iconResId = R.drawable.ic_new_grid
|
||||
)
|
||||
}
|
||||
|
||||
enum class CreatorChannelCommunityImageMode {
|
||||
Image,
|
||||
TextPreview,
|
||||
LockedGray
|
||||
}
|
||||
|
||||
data class CreatorChannelCommunityPostUiModel(
|
||||
val postId: Long,
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileUrl: String,
|
||||
val createdAtText: String,
|
||||
val content: String,
|
||||
val imageUrl: String?,
|
||||
val audioUrl: String?,
|
||||
val price: Int,
|
||||
val existOrdered: Boolean,
|
||||
val likeCount: Int,
|
||||
val commentCount: Int,
|
||||
val showComment: Boolean,
|
||||
val showNotice: Boolean,
|
||||
val isPinned: Boolean,
|
||||
val isLocked: Boolean,
|
||||
val showOwnerMore: Boolean,
|
||||
val showOwnerTopPrice: Boolean,
|
||||
val showPlayButton: Boolean,
|
||||
val gridPreviewText: String,
|
||||
val imageMode: CreatorChannelCommunityImageMode
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.CenterCrop
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityGridBinding
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityImageMode
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
|
||||
|
||||
class CreatorChannelCommunityGridAdapter : RecyclerView.Adapter<CreatorChannelCommunityGridAdapter.ViewHolder>() {
|
||||
|
||||
private var items: List<CreatorChannelCommunityPostUiModel> = emptyList()
|
||||
private var itemSizePx: Int = 0
|
||||
|
||||
fun submitItems(items: List<CreatorChannelCommunityPostUiModel>) {
|
||||
this.items = items
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun setItemSizePx(itemSizePx: Int) {
|
||||
if (this.itemSizePx == itemSizePx) return
|
||||
|
||||
this.itemSizePx = itemSizePx
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return ViewHolder(ItemCreatorChannelCommunityGridBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(items[position], itemSizePx)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
class ViewHolder(
|
||||
private val binding: ItemCreatorChannelCommunityGridBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(item: CreatorChannelCommunityPostUiModel, itemSizePx: Int) = with(binding) {
|
||||
if (itemSizePx > 0) {
|
||||
root.layoutParams = root.layoutParams.apply {
|
||||
width = itemSizePx
|
||||
height = itemSizePx
|
||||
}
|
||||
}
|
||||
|
||||
val visibleImageUrl = item.imageUrl.takeIf { item.imageMode == CreatorChannelCommunityImageMode.Image }
|
||||
ivCreatorChannelCommunityGridImage.isVisible = visibleImageUrl != null
|
||||
if (visibleImageUrl != null) {
|
||||
Glide.with(ivCreatorChannelCommunityGridImage)
|
||||
.asBitmap()
|
||||
.load(visibleImageUrl)
|
||||
.placeholder(R.drawable.ic_place_holder)
|
||||
.apply(communityImageRequestOptions())
|
||||
.into(ivCreatorChannelCommunityGridImage)
|
||||
} else {
|
||||
Glide.with(ivCreatorChannelCommunityGridImage).clear(ivCreatorChannelCommunityGridImage)
|
||||
ivCreatorChannelCommunityGridImage.setImageDrawable(null)
|
||||
}
|
||||
tvCreatorChannelCommunityGridTextPreview.isVisible =
|
||||
!item.isLocked && item.imageMode != CreatorChannelCommunityImageMode.Image
|
||||
tvCreatorChannelCommunityGridTextPreview.text = item.gridPreviewText
|
||||
layoutCreatorChannelCommunityGridLockedOverlay.isVisible = item.isLocked
|
||||
ivCreatorChannelCommunityGridLock.isVisible = item.isLocked
|
||||
tvCreatorChannelCommunityGridLockPrice.isVisible = item.isLocked
|
||||
tvCreatorChannelCommunityGridLockPrice.text = item.price.moneyFormat()
|
||||
ivCreatorChannelCommunityGridNotice.isVisible = item.showNotice
|
||||
}
|
||||
|
||||
private fun communityImageRequestOptions(): RequestOptions {
|
||||
return RequestOptions().transform(CenterCrop())
|
||||
}
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
const val GRID_SPAN_COUNT = 3
|
||||
}
|
||||
}
|
||||
|
||||
internal fun calculateCreatorChannelCommunityGridItemSize(availableWidth: Int): Int {
|
||||
return availableWidth.coerceAtLeast(0) / CreatorChannelCommunityGridAdapter.GRID_SPAN_COUNT
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.transform.CircleCropTransformation
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.CenterCrop
|
||||
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityListBinding
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
|
||||
|
||||
class CreatorChannelCommunityListAdapter(
|
||||
private val onPlayClick: (CreatorChannelCommunityPostUiModel) -> Unit = {},
|
||||
private val onOwnerMoreClick: (CreatorChannelCommunityPostUiModel) -> Unit = {},
|
||||
private val isPlayingContent: (Long) -> Boolean = { false }
|
||||
) : RecyclerView.Adapter<CreatorChannelCommunityListAdapter.ViewHolder>() {
|
||||
|
||||
private var items: List<CreatorChannelCommunityPostUiModel> = emptyList()
|
||||
|
||||
fun submitItems(items: List<CreatorChannelCommunityPostUiModel>) {
|
||||
this.items = items
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return ViewHolder(
|
||||
ItemCreatorChannelCommunityListBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
onPlayClick,
|
||||
onOwnerMoreClick,
|
||||
isPlayingContent
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
class ViewHolder(
|
||||
private val binding: ItemCreatorChannelCommunityListBinding,
|
||||
private val onPlayClick: (CreatorChannelCommunityPostUiModel) -> Unit,
|
||||
private val onOwnerMoreClick: (CreatorChannelCommunityPostUiModel) -> Unit,
|
||||
private val isPlayingContent: (Long) -> Boolean
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(item: CreatorChannelCommunityPostUiModel) = with(binding) {
|
||||
ivCreatorChannelCommunityListProfile.loadUrl(item.creatorProfileUrl) {
|
||||
crossfade(true)
|
||||
placeholder(R.drawable.bg_placeholder)
|
||||
transformations(CircleCropTransformation())
|
||||
}
|
||||
tvCreatorChannelCommunityListNickname.text = item.creatorNickname
|
||||
tvCreatorChannelCommunityListTime.text = item.createdAtText
|
||||
layoutCreatorChannelCommunityListNotice.isVisible = item.showNotice
|
||||
tvCreatorChannelCommunityListBody.text = item.content
|
||||
tvCreatorChannelCommunityListCommentCount.text = item.commentCount.moneyFormat()
|
||||
tvCreatorChannelCommunityListCommentCount.isVisible = item.showComment
|
||||
ivCreatorChannelCommunityListComment.isVisible = item.showComment
|
||||
tvCreatorChannelCommunityListLikeCount.text = item.likeCount.moneyFormat()
|
||||
|
||||
val visibleImageUrl = item.imageUrl.takeUnless { item.isLocked }
|
||||
layoutCreatorChannelCommunityListImageContainer.isVisible =
|
||||
visibleImageUrl != null || item.isLocked || item.showPlayButton
|
||||
ivCreatorChannelCommunityListImage.isVisible = visibleImageUrl != null
|
||||
if (visibleImageUrl != null) {
|
||||
Glide.with(ivCreatorChannelCommunityListImage)
|
||||
.load(visibleImageUrl)
|
||||
.placeholder(R.drawable.ic_place_holder)
|
||||
.apply(communityImageRequestOptions())
|
||||
.into(ivCreatorChannelCommunityListImage)
|
||||
} else {
|
||||
Glide.with(ivCreatorChannelCommunityListImage).clear(ivCreatorChannelCommunityListImage)
|
||||
ivCreatorChannelCommunityListImage.setImageDrawable(null)
|
||||
}
|
||||
layoutCreatorChannelCommunityListLockedOverlay.isVisible = item.isLocked
|
||||
ivCreatorChannelCommunityListLock.isVisible = item.isLocked
|
||||
tvCreatorChannelCommunityListLockedPrice.isVisible = item.isLocked
|
||||
tvCreatorChannelCommunityListLockedPrice.text = item.price.moneyFormat()
|
||||
ivCreatorChannelCommunityListPlay.isVisible = item.showPlayButton
|
||||
ivCreatorChannelCommunityListPlay.setImageResource(
|
||||
if (isPlayingContent(item.postId)) R.drawable.ic_player_pause else R.drawable.ic_new_player_play
|
||||
)
|
||||
ivCreatorChannelCommunityListPlay.setOnClickListener { onPlayClick(item) }
|
||||
|
||||
layoutCreatorChannelCommunityListTopActions.isVisible = item.showOwnerMore || item.showOwnerTopPrice
|
||||
layoutCreatorChannelCommunityListTopPrice.isVisible = item.showOwnerTopPrice
|
||||
tvCreatorChannelCommunityListTopPrice.text = item.price.moneyFormat()
|
||||
ivCreatorChannelCommunityListOwnerMore.isVisible = item.showOwnerMore
|
||||
ivCreatorChannelCommunityListOwnerMore.setOnClickListener { onOwnerMoreClick(item) }
|
||||
}
|
||||
|
||||
private fun communityImageRequestOptions(): RequestOptions {
|
||||
return RequestOptions().transform(
|
||||
CenterCrop(),
|
||||
RoundedCorners(14f.dpToPx().toInt())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.data
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.audio.data.CreatorChannelAudioTabResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityTabResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.CreatorChannelDonationTabResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkTabResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.data.CreatorChannelLiveTabResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.data.CreatorChannelSeriesTabResponse
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface CreatorChannelApi {
|
||||
@GET("/api/v2/creator-channels/{creatorId}/home")
|
||||
fun getHome(
|
||||
@Path("creatorId") creatorId: Long,
|
||||
@Header("Authorization") authHeader: String
|
||||
): Single<ApiResponse<CreatorChannelHomeResponse>>
|
||||
|
||||
@GET("/api/v2/creator-channels/{creatorId}/live")
|
||||
fun getLive(
|
||||
@Path("creatorId") creatorId: Long,
|
||||
@Query("page") page: Int,
|
||||
@Query("size") size: Int,
|
||||
@Query("sort") sort: ContentSort,
|
||||
@Header("Authorization") authHeader: String
|
||||
): Single<ApiResponse<CreatorChannelLiveTabResponse>>
|
||||
|
||||
@GET("/api/v2/creator-channels/{creatorId}/audio")
|
||||
fun getAudio(
|
||||
@Path("creatorId") creatorId: Long,
|
||||
@Query("page") page: Int,
|
||||
@Query("size") size: Int,
|
||||
@Query("sort") sort: ContentSort,
|
||||
@Query("themeId") themeId: Long?,
|
||||
@Header("Authorization") authHeader: String
|
||||
): Single<ApiResponse<CreatorChannelAudioTabResponse>>
|
||||
|
||||
@GET("/api/v2/creator-channels/{creatorId}/series")
|
||||
fun getSeries(
|
||||
@Path("creatorId") creatorId: Long,
|
||||
@Query("page") page: Int,
|
||||
@Query("size") size: Int,
|
||||
@Query("sort") sort: ContentSort,
|
||||
@Header("Authorization") authHeader: String
|
||||
): Single<ApiResponse<CreatorChannelSeriesTabResponse>>
|
||||
|
||||
@GET("/api/v2/creator-channels/{creatorId}/community")
|
||||
fun getCommunity(
|
||||
@Path("creatorId") creatorId: Long,
|
||||
@Query("page") page: Int,
|
||||
@Query("size") size: Int,
|
||||
@Header("Authorization") authHeader: String
|
||||
): Single<ApiResponse<CreatorChannelCommunityTabResponse>>
|
||||
|
||||
@GET("/api/v2/creator-channels/{creatorId}/fan-talks")
|
||||
fun getFanTalks(
|
||||
@Path("creatorId") creatorId: Long,
|
||||
@Query("page") page: Int,
|
||||
@Query("size") size: Int,
|
||||
@Header("Authorization") authHeader: String
|
||||
): Single<ApiResponse<CreatorChannelFanTalkTabResponse>>
|
||||
|
||||
@GET("/api/v2/creator-channels/{creatorId}/donations")
|
||||
fun getDonations(
|
||||
@Path("creatorId") creatorId: Long,
|
||||
@Query("page") page: Int,
|
||||
@Query("size") size: Int,
|
||||
@Header("Authorization") authHeader: String
|
||||
): Single<ApiResponse<CreatorChannelDonationTabResponse>>
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.data
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelHomeResponse(
|
||||
@SerializedName("creator") val creator: CreatorChannelCreatorResponse,
|
||||
@SerializedName("currentLive") val currentLive: CreatorChannelLiveResponse?,
|
||||
@SerializedName("latestAudioContent") val latestAudioContent: CreatorChannelAudioContentResponse?,
|
||||
@SerializedName("channelDonations") val channelDonations: List<CreatorChannelDonationResponse>,
|
||||
@SerializedName("notices") val notices: List<CreatorChannelCommunityPostResponse>,
|
||||
@SerializedName("schedules") val schedules: List<CreatorChannelScheduleResponse>,
|
||||
@SerializedName("audioContents") val audioContents: List<CreatorChannelAudioContentResponse>,
|
||||
@SerializedName("series") val series: List<CreatorChannelSeriesResponse>,
|
||||
@SerializedName("communities") val communities: List<CreatorChannelCommunityPostResponse>,
|
||||
@SerializedName("fanTalk") val fanTalk: CreatorChannelFanTalkSummaryResponse,
|
||||
@SerializedName("introduce") val introduce: String,
|
||||
@SerializedName("activity") val activity: CreatorChannelActivityResponse,
|
||||
@SerializedName("sns") val sns: CreatorChannelSnsResponse
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelCreatorResponse(
|
||||
@SerializedName("creatorId") val creatorId: Long,
|
||||
@SerializedName("characterId") val characterId: Long?,
|
||||
@SerializedName("nickname") val nickname: String,
|
||||
@SerializedName("profileImageUrl") val profileImageUrl: String,
|
||||
@SerializedName("followerCount") val followerCount: Int,
|
||||
@SerializedName("isAiChatAvailable") val isAiChatAvailable: Boolean,
|
||||
@SerializedName("isDmAvailable") val isDmAvailable: Boolean,
|
||||
@SerializedName("isFollow") val isFollow: Boolean,
|
||||
@SerializedName("isNotify") val isNotify: Boolean
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelLiveResponse(
|
||||
@SerializedName("liveId") val liveId: Long,
|
||||
@SerializedName("title") val title: String,
|
||||
@SerializedName("coverImageUrl") val coverImageUrl: String?,
|
||||
@SerializedName("beginDateTimeUtc") val beginDateTimeUtc: String,
|
||||
@SerializedName("price") val price: Int,
|
||||
@SerializedName("isAdult") val isAdult: Boolean
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelAudioContentResponse(
|
||||
@SerializedName("audioContentId") val audioContentId: Long,
|
||||
@SerializedName("title") val title: String,
|
||||
@SerializedName("duration") val duration: String?,
|
||||
@SerializedName("imageUrl") val imageUrl: String?,
|
||||
@SerializedName("price") val price: Int,
|
||||
@SerializedName("isPointAvailable") val isPointAvailable: Boolean,
|
||||
@SerializedName("isFirstContent") val isFirstContent: Boolean,
|
||||
@SerializedName("seriesName") val seriesName: String?,
|
||||
@SerializedName("isOriginalSeries") val isOriginalSeries: Boolean?,
|
||||
@SerializedName("isAdult") val isAdult: Boolean = false,
|
||||
@SerializedName("isOwned") val isOwned: Boolean = false,
|
||||
@SerializedName("isRented") val isRented: Boolean = false
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelDonationResponse(
|
||||
@SerializedName("donationId") val donationId: Long,
|
||||
@SerializedName("memberId") val memberId: Long,
|
||||
@SerializedName("nickname") val nickname: String,
|
||||
@SerializedName("profileImageUrl") val profileImageUrl: String,
|
||||
@SerializedName("can") val can: Int,
|
||||
@SerializedName("isSecret") val isSecret: Boolean,
|
||||
@SerializedName("message") val message: String,
|
||||
@SerializedName("createdAtUtc") val createdAtUtc: String
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelScheduleResponse(
|
||||
@SerializedName("scheduledAtUtc") val scheduledAtUtc: String,
|
||||
@SerializedName("title") val title: String,
|
||||
@SerializedName("type") val type: CreatorActivityType,
|
||||
@SerializedName("targetId") val targetId: Long
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelSeriesResponse(
|
||||
@SerializedName("seriesId") val seriesId: Long,
|
||||
@SerializedName("title") val title: String,
|
||||
@SerializedName("coverImageUrl") val coverImageUrl: String,
|
||||
@SerializedName("numberOfContent") val numberOfContent: Int,
|
||||
@SerializedName("isNew") val isNew: Boolean,
|
||||
@SerializedName("isOriginal") val isOriginal: Boolean
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelCommunityPostResponse(
|
||||
@SerializedName("postId") val postId: Long,
|
||||
@SerializedName("creatorId") val creatorId: Long,
|
||||
@SerializedName("creatorNickname") val creatorNickname: String,
|
||||
@SerializedName("creatorProfileUrl") val creatorProfileUrl: String,
|
||||
@SerializedName("imageUrl") val imageUrl: String?,
|
||||
@SerializedName("audioUrl") val audioUrl: String?,
|
||||
@SerializedName("content") val content: String,
|
||||
@SerializedName("price") val price: Int,
|
||||
@SerializedName("dateUtc") val dateUtc: String,
|
||||
@SerializedName("existOrdered") val existOrdered: Boolean,
|
||||
@SerializedName("likeCount") val likeCount: Int,
|
||||
@SerializedName("commentCount") val commentCount: Int
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelFanTalkSummaryResponse(
|
||||
@SerializedName("totalCount") val totalCount: Int,
|
||||
@SerializedName("latestFanTalk") val latestFanTalk: CreatorChannelFanTalkResponse?
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelFanTalkResponse(
|
||||
@SerializedName("fanTalkId") val fanTalkId: Long,
|
||||
@SerializedName("memberId") val memberId: Long,
|
||||
@SerializedName("nickname") val nickname: String,
|
||||
@SerializedName("profileImageUrl") val profileImageUrl: String,
|
||||
@SerializedName("content") val content: String,
|
||||
@SerializedName("languageCode") val languageCode: String?,
|
||||
@SerializedName("createdAtUtc") val createdAtUtc: String
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelActivityResponse(
|
||||
@SerializedName("debutDateUtc") val debutDateUtc: String?,
|
||||
@SerializedName("dday") val dDay: String,
|
||||
@SerializedName("liveCount") val liveCount: Long,
|
||||
@SerializedName("liveDurationHours") val liveDurationHours: Long,
|
||||
@SerializedName("liveContributorCount") val liveContributorCount: Long,
|
||||
@SerializedName("audioContentCount") val audioContentCount: Long,
|
||||
@SerializedName("seriesCount") val seriesCount: Long
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelSnsResponse(
|
||||
@SerializedName("instagramUrl") val instagramUrl: String,
|
||||
@SerializedName("fancimmUrl") val fancimmUrl: String,
|
||||
@SerializedName("xurl") val xUrl: String,
|
||||
@SerializedName("youtubeUrl") val youtubeUrl: String,
|
||||
@SerializedName("kakaoOpenChatUrl") val kakaoOpenChatUrl: String
|
||||
)
|
||||
@@ -0,0 +1,163 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.data
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.talk.TalkApi
|
||||
import kr.co.vividnext.sodalive.chat.talk.room.CreateChatRoomRequest
|
||||
import kr.co.vividnext.sodalive.explorer.ExplorerRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.channel_donation.PostChannelDonationRequest
|
||||
import kr.co.vividnext.sodalive.explorer.profile.cheers.PutModifyCheersRequest
|
||||
import kr.co.vividnext.sodalive.report.ReportRepository
|
||||
import kr.co.vividnext.sodalive.report.ReportRequest
|
||||
import kr.co.vividnext.sodalive.report.ReportType
|
||||
import kr.co.vividnext.sodalive.user.UserRepository
|
||||
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
|
||||
|
||||
class CreatorChannelRepository(
|
||||
private val api: CreatorChannelApi,
|
||||
private val userRepository: UserRepository,
|
||||
private val talkApi: TalkApi,
|
||||
private val reportRepository: ReportRepository,
|
||||
private val explorerRepository: ExplorerRepository
|
||||
) {
|
||||
fun getHome(creatorId: Long, token: String) = api.getHome(
|
||||
creatorId = creatorId,
|
||||
authHeader = token
|
||||
)
|
||||
|
||||
fun getLive(
|
||||
creatorId: Long,
|
||||
page: Int,
|
||||
size: Int,
|
||||
sort: ContentSort,
|
||||
token: String
|
||||
) = api.getLive(
|
||||
creatorId = creatorId,
|
||||
page = page,
|
||||
size = size,
|
||||
sort = sort,
|
||||
authHeader = token
|
||||
)
|
||||
|
||||
fun getAudio(
|
||||
creatorId: Long,
|
||||
page: Int,
|
||||
size: Int,
|
||||
sort: ContentSort,
|
||||
themeId: Long?,
|
||||
token: String
|
||||
) = api.getAudio(
|
||||
creatorId = creatorId,
|
||||
page = page,
|
||||
size = size,
|
||||
sort = sort,
|
||||
themeId = themeId,
|
||||
authHeader = token
|
||||
)
|
||||
|
||||
fun getSeries(
|
||||
creatorId: Long,
|
||||
page: Int,
|
||||
size: Int,
|
||||
sort: ContentSort,
|
||||
token: String
|
||||
) = api.getSeries(
|
||||
creatorId = creatorId,
|
||||
page = page,
|
||||
size = size,
|
||||
sort = sort,
|
||||
authHeader = token
|
||||
)
|
||||
|
||||
fun getCommunity(
|
||||
creatorId: Long,
|
||||
page: Int,
|
||||
size: Int,
|
||||
token: String
|
||||
) = api.getCommunity(
|
||||
creatorId = creatorId,
|
||||
page = page,
|
||||
size = size,
|
||||
authHeader = token
|
||||
)
|
||||
|
||||
fun getFanTalks(
|
||||
creatorId: Long,
|
||||
page: Int,
|
||||
size: Int,
|
||||
token: String
|
||||
) = api.getFanTalks(
|
||||
creatorId = creatorId,
|
||||
page = page,
|
||||
size = size,
|
||||
authHeader = token
|
||||
)
|
||||
|
||||
fun getDonations(
|
||||
creatorId: Long,
|
||||
page: Int,
|
||||
size: Int,
|
||||
token: String
|
||||
) = api.getDonations(
|
||||
creatorId = creatorId,
|
||||
page = page,
|
||||
size = size,
|
||||
authHeader = token
|
||||
)
|
||||
|
||||
fun followCreator(
|
||||
creatorId: Long,
|
||||
follow: Boolean,
|
||||
notify: Boolean,
|
||||
token: String
|
||||
) = userRepository.creatorFollow(
|
||||
creatorId = creatorId,
|
||||
follow = follow,
|
||||
notify = notify,
|
||||
token = token
|
||||
)
|
||||
|
||||
fun createChatRoom(characterId: Long, token: String) = talkApi.createChatRoom(
|
||||
authHeader = token,
|
||||
request = CreateChatRoomRequest(characterId)
|
||||
)
|
||||
|
||||
fun postChannelDonation(
|
||||
creatorId: Long,
|
||||
can: Int,
|
||||
isSecret: Boolean,
|
||||
message: String,
|
||||
token: String
|
||||
) = explorerRepository.postChannelDonation(
|
||||
request = PostChannelDonationRequest(
|
||||
creatorId = creatorId,
|
||||
can = can,
|
||||
isSecret = isSecret,
|
||||
message = message
|
||||
),
|
||||
token = token
|
||||
)
|
||||
|
||||
fun blockUser(userId: Long, token: String) = userRepository.memberBlock(
|
||||
userId = userId,
|
||||
token = token
|
||||
)
|
||||
|
||||
fun reportUser(userId: Long, reason: String, token: String) = reportRepository.report(
|
||||
request = ReportRequest(ReportType.USER, reason, reportedMemberId = userId),
|
||||
token = token
|
||||
)
|
||||
|
||||
fun reportProfile(userId: Long, reason: String, token: String) = reportRepository.report(
|
||||
request = ReportRequest(ReportType.PROFILE, reason, reportedMemberId = userId),
|
||||
token = token
|
||||
)
|
||||
|
||||
fun reportFanTalk(fanTalkId: Long, reason: String, token: String) = reportRepository.report(
|
||||
request = ReportRequest(ReportType.CHEERS, reason, cheersId = fanTalkId),
|
||||
token = token
|
||||
)
|
||||
|
||||
fun deleteFanTalk(fanTalkId: Long, token: String) = explorerRepository.modifyCheers(
|
||||
request = PutModifyCheersRequest(cheersId = fanTalkId, isActive = false),
|
||||
token = token
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.donation
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.base.BaseFragment
|
||||
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelDonationBinding
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.ui.CreatorChannelDonationAdapter
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
class CreatorChannelDonationFragment : BaseFragment<FragmentCreatorChannelDonationBinding>(
|
||||
FragmentCreatorChannelDonationBinding::inflate
|
||||
) {
|
||||
|
||||
private val viewModel: CreatorChannelDonationViewModel by viewModel()
|
||||
private val donationAdapter = CreatorChannelDonationAdapter(
|
||||
onRankingAllClick = { host.onCreatorChannelDonationRankingAllClicked() },
|
||||
onEmptyDonationClick = {
|
||||
host.onCreatorChannelDonationRequested { can, isSecret, message ->
|
||||
viewModel.postChannelDonation(can, isSecret, message)
|
||||
}
|
||||
}
|
||||
)
|
||||
private var lastContentLayoutKey: CreatorChannelDonationContentLayoutKey? = null
|
||||
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
|
||||
private val host: Host
|
||||
get() = requireActivity() as Host
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
bindLoading()
|
||||
setupDonationList()
|
||||
setupClickListeners()
|
||||
observeViewModel()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
if (isAdded) {
|
||||
host.onCreatorChannelDonationFloatingButtonVisibilityChanged(false)
|
||||
}
|
||||
lastContentLayoutKey = null
|
||||
binding.rvCreatorChannelDonation.adapter = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
fun onCreatorChannelDonationTabSelected() {
|
||||
if (creatorId > 0L) {
|
||||
viewModel.loadDonations(creatorId, isOwner = host.isCreatorChannelOwner())
|
||||
}
|
||||
}
|
||||
|
||||
fun onCreatorChannelDonationScrolledToBottom() {
|
||||
viewModel.loadMore()
|
||||
}
|
||||
|
||||
fun onCreatorChannelDonationRefreshRequested() {
|
||||
viewModel.refreshDonations()
|
||||
}
|
||||
|
||||
fun onCreatorChannelDonationViewportHeightChanged(minHeight: Int) = Unit
|
||||
|
||||
fun onCreatorChannelDonationFloatingButtonClicked() {
|
||||
host.onCreatorChannelDonationRequested { can, isSecret, message ->
|
||||
viewModel.postChannelDonation(can, isSecret, message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupDonationList() = with(binding.rvCreatorChannelDonation) {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
adapter = donationAdapter
|
||||
}
|
||||
|
||||
private fun setupClickListeners() = with(binding) {
|
||||
btnCreatorChannelDonationRetry.setOnClickListener {
|
||||
viewModel.retryDonations()
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
viewModel.donationStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
CreatorChannelDonationUiState.Loading -> bindLoading()
|
||||
is CreatorChannelDonationUiState.Empty -> bindEmpty(state)
|
||||
is CreatorChannelDonationUiState.Error -> bindError(state)
|
||||
is CreatorChannelDonationUiState.Content -> bindContent(state)
|
||||
}
|
||||
handleActionToastMessage(state)
|
||||
handleDonationSuccessEvent()
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindLoading() = with(binding) {
|
||||
lastContentLayoutKey = null
|
||||
layoutCreatorChannelDonationCountBar.isVisible = false
|
||||
rvCreatorChannelDonation.isVisible = false
|
||||
tvCreatorChannelDonationErrorMessage.isVisible = false
|
||||
btnCreatorChannelDonationRetry.isVisible = false
|
||||
host.onCreatorChannelDonationFloatingButtonVisibilityChanged(false)
|
||||
}
|
||||
|
||||
private fun bindEmpty(state: CreatorChannelDonationUiState.Empty) = with(binding) {
|
||||
lastContentLayoutKey = null
|
||||
layoutCreatorChannelDonationCountBar.isVisible = false
|
||||
rvCreatorChannelDonation.isVisible = true
|
||||
donationAdapter.submitEmpty(state.rankings, state.isOwner)
|
||||
tvCreatorChannelDonationErrorMessage.isVisible = false
|
||||
btnCreatorChannelDonationRetry.isVisible = false
|
||||
host.onCreatorChannelDonationFloatingButtonVisibilityChanged(false)
|
||||
host.onCreatorChannelDonationContentChanged()
|
||||
}
|
||||
|
||||
private fun bindError(state: CreatorChannelDonationUiState.Error) = with(binding) {
|
||||
lastContentLayoutKey = null
|
||||
layoutCreatorChannelDonationCountBar.isVisible = false
|
||||
rvCreatorChannelDonation.isVisible = false
|
||||
tvCreatorChannelDonationErrorMessage.isVisible = true
|
||||
tvCreatorChannelDonationErrorMessage.text = state.message ?: getString(R.string.creator_channel_donation_error_message)
|
||||
btnCreatorChannelDonationRetry.isVisible = true
|
||||
host.onCreatorChannelDonationFloatingButtonVisibilityChanged(false)
|
||||
host.onCreatorChannelDonationContentChanged()
|
||||
}
|
||||
|
||||
private fun bindContent(state: CreatorChannelDonationUiState.Content) = with(binding) {
|
||||
layoutCreatorChannelDonationCountBar.isVisible = true
|
||||
tvCreatorChannelDonationTotalCount.text = state.donationCount.moneyFormat()
|
||||
rvCreatorChannelDonation.isVisible = true
|
||||
donationAdapter.submitItems(state.rankings, state.donations)
|
||||
tvCreatorChannelDonationErrorMessage.isVisible = false
|
||||
btnCreatorChannelDonationRetry.isVisible = false
|
||||
host.onCreatorChannelDonationFloatingButtonVisibilityChanged(!state.isOwner)
|
||||
notifyContentChangedIfLayoutChanged(state)
|
||||
state.paginationErrorMessage?.let {
|
||||
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
|
||||
viewModel.consumePaginationErrorMessage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleActionToastMessage(state: CreatorChannelDonationUiState) {
|
||||
val message = when (state) {
|
||||
is CreatorChannelDonationUiState.Empty -> state.actionToastMessage
|
||||
is CreatorChannelDonationUiState.Content -> state.actionToastMessage
|
||||
else -> null
|
||||
} ?: return
|
||||
|
||||
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
|
||||
viewModel.consumeActionToastMessage()
|
||||
}
|
||||
|
||||
private fun handleDonationSuccessEvent() {
|
||||
if (viewModel.consumeDonationSuccessEvent()) {
|
||||
host.onCreatorChannelDonationCompleted()
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelDonationUiState.Content) {
|
||||
val contentLayoutKey = state.toContentLayoutKey()
|
||||
if (contentLayoutKey == lastContentLayoutKey) return
|
||||
|
||||
lastContentLayoutKey = contentLayoutKey
|
||||
host.onCreatorChannelDonationContentChanged()
|
||||
}
|
||||
|
||||
interface Host {
|
||||
fun isCreatorChannelOwner(): Boolean
|
||||
fun onCreatorChannelDonationContentChanged()
|
||||
fun onCreatorChannelDonationFloatingButtonVisibilityChanged(isVisible: Boolean)
|
||||
fun onCreatorChannelDonationRequested(onSubmit: (can: Int, isSecret: Boolean, message: String) -> Unit)
|
||||
fun onCreatorChannelDonationRankingAllClicked()
|
||||
fun onCreatorChannelDonationCompleted()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_CREATOR_ID: String = "arg_creator_id"
|
||||
|
||||
fun newInstance(creatorId: Long): CreatorChannelDonationFragment {
|
||||
return CreatorChannelDonationFragment().apply {
|
||||
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class CreatorChannelDonationContentLayoutKey(
|
||||
val donationCount: Int,
|
||||
val rankingUserIds: List<Long>,
|
||||
val donationItems: List<CreatorChannelDonationItemLayoutKey>
|
||||
)
|
||||
|
||||
private data class CreatorChannelDonationItemLayoutKey(
|
||||
val nickname: String,
|
||||
val can: Int,
|
||||
val message: String,
|
||||
val createdAtText: String
|
||||
)
|
||||
|
||||
private fun CreatorChannelDonationUiState.Content.toContentLayoutKey(): CreatorChannelDonationContentLayoutKey {
|
||||
return CreatorChannelDonationContentLayoutKey(
|
||||
donationCount = donationCount,
|
||||
rankingUserIds = rankings.map { it.userId },
|
||||
donationItems = donations.map { donation ->
|
||||
CreatorChannelDonationItemLayoutKey(
|
||||
nickname = donation.nickname,
|
||||
can = donation.can,
|
||||
message = donation.message,
|
||||
createdAtText = donation.createdAtText
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.donation
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kr.co.vividnext.sodalive.base.BaseViewModel
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
|
||||
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.CreatorChannelDonationTabResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.CreatorChannelDonationRankingUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.CreatorChannelDonationUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.toDonationRankingUiModels
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.toDonationUiModels
|
||||
|
||||
class CreatorChannelDonationViewModel(
|
||||
private val repository: CreatorChannelRepository,
|
||||
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val _donationStateLiveData = MutableLiveData<CreatorChannelDonationUiState>()
|
||||
val donationStateLiveData: LiveData<CreatorChannelDonationUiState>
|
||||
get() = _donationStateLiveData
|
||||
|
||||
private val context = SodaLiveApplicationHolder.get()
|
||||
private var creatorId: Long = 0L
|
||||
private var isOwner: Boolean = false
|
||||
private var requestGeneration: Int = 0
|
||||
private var isPostChannelDonationInProgress: Boolean = false
|
||||
private var donationSuccessEvent: Boolean = false
|
||||
|
||||
fun loadDonations(creatorId: Long, isOwner: Boolean) {
|
||||
if (creatorId <= 0) return
|
||||
val shouldSkipReload = this.creatorId == creatorId && this.isOwner == isOwner && _donationStateLiveData.value != null
|
||||
if (shouldSkipReload) return
|
||||
|
||||
this.creatorId = creatorId
|
||||
this.isOwner = isOwner
|
||||
loadFirstPage()
|
||||
}
|
||||
|
||||
fun retryDonations() {
|
||||
if (creatorId <= 0) return
|
||||
|
||||
loadFirstPage()
|
||||
}
|
||||
|
||||
fun refreshDonations() {
|
||||
if (creatorId <= 0) return
|
||||
|
||||
loadFirstPage()
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val content = _donationStateLiveData.value as? CreatorChannelDonationUiState.Content ?: return
|
||||
if (!content.hasNext || content.isLoadingMore || creatorId <= 0) return
|
||||
|
||||
val generation = requestGeneration
|
||||
_donationStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null)
|
||||
requestDonations(page = content.page + 1, generation = generation) { response ->
|
||||
val data = response.data
|
||||
val current = _donationStateLiveData.value as? CreatorChannelDonationUiState.Content ?: content
|
||||
if (response.success && data != null) {
|
||||
_donationStateLiveData.value = current.copy(
|
||||
donationCount = data.donationCount,
|
||||
rankings = data.toDonationRankingUiModels(),
|
||||
donations = current.donations + data.toDonationUiModels(),
|
||||
page = data.page,
|
||||
size = data.size,
|
||||
hasNext = data.hasNext,
|
||||
isLoadingMore = false
|
||||
)
|
||||
} else {
|
||||
_donationStateLiveData.value = current.copy(
|
||||
isLoadingMore = false,
|
||||
paginationErrorMessage = response.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun postChannelDonation(can: Int, isSecret: Boolean, message: String) {
|
||||
if (creatorId <= 0 || isPostChannelDonationInProgress) return
|
||||
|
||||
isPostChannelDonationInProgress = true
|
||||
compositeDisposable.add(
|
||||
repository.postChannelDonation(
|
||||
creatorId = creatorId,
|
||||
can = can,
|
||||
isSecret = isSecret,
|
||||
message = message,
|
||||
token = authToken()
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
isPostChannelDonationInProgress = false
|
||||
if (it.success) {
|
||||
SharedPreferenceManager.can = (SharedPreferenceManager.can - can).coerceAtLeast(0)
|
||||
donationSuccessEvent = true
|
||||
loadFirstPage()
|
||||
} else {
|
||||
setActionToastMessage(it.message)
|
||||
}
|
||||
},
|
||||
{
|
||||
isPostChannelDonationInProgress = false
|
||||
setActionToastMessage(it.message)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun consumePaginationErrorMessage() {
|
||||
val content = _donationStateLiveData.value as? CreatorChannelDonationUiState.Content ?: return
|
||||
if (content.paginationErrorMessage == null) return
|
||||
|
||||
_donationStateLiveData.value = content.copy(paginationErrorMessage = null)
|
||||
}
|
||||
|
||||
fun consumeActionToastMessage() {
|
||||
when (val state = _donationStateLiveData.value) {
|
||||
is CreatorChannelDonationUiState.Empty -> {
|
||||
if (state.actionToastMessage != null) {
|
||||
_donationStateLiveData.value = state.copy(actionToastMessage = null)
|
||||
}
|
||||
}
|
||||
is CreatorChannelDonationUiState.Content -> {
|
||||
if (state.actionToastMessage != null) {
|
||||
_donationStateLiveData.value = state.copy(actionToastMessage = null)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeDonationSuccessEvent(): Boolean {
|
||||
val event = donationSuccessEvent
|
||||
donationSuccessEvent = false
|
||||
return event
|
||||
}
|
||||
|
||||
private fun loadFirstPage() {
|
||||
val generation = ++requestGeneration
|
||||
_donationStateLiveData.value = CreatorChannelDonationUiState.Loading
|
||||
requestDonations(page = FIRST_PAGE, generation = generation) { response ->
|
||||
val data = response.data
|
||||
if (response.success && data != null) {
|
||||
val donations = data.toDonationUiModels()
|
||||
val rankings = data.toDonationRankingUiModels()
|
||||
_donationStateLiveData.value = if (donations.isEmpty() || data.donationCount == 0) {
|
||||
CreatorChannelDonationUiState.Empty(data.donationCount, rankings, isOwner)
|
||||
} else {
|
||||
data.toContentState(rankings, donations)
|
||||
}
|
||||
} else {
|
||||
_donationStateLiveData.value = CreatorChannelDonationUiState.Error(response.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestDonations(
|
||||
page: Int,
|
||||
generation: Int,
|
||||
onSuccess: (ApiResponse<CreatorChannelDonationTabResponse>) -> Unit
|
||||
) {
|
||||
compositeDisposable.add(
|
||||
repository.getDonations(
|
||||
creatorId = creatorId,
|
||||
page = page,
|
||||
size = DEFAULT_PAGE_SIZE,
|
||||
token = authToken()
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
if (generation == requestGeneration) {
|
||||
onSuccess(it)
|
||||
}
|
||||
},
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
|
||||
val current = _donationStateLiveData.value as? CreatorChannelDonationUiState.Content
|
||||
_donationStateLiveData.value = if (current != null && page > FIRST_PAGE) {
|
||||
current.copy(isLoadingMore = false, paginationErrorMessage = it.message)
|
||||
} else {
|
||||
CreatorChannelDonationUiState.Error(it.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun setActionToastMessage(message: String?) {
|
||||
when (val state = _donationStateLiveData.value) {
|
||||
is CreatorChannelDonationUiState.Empty -> _donationStateLiveData.value = state.copy(actionToastMessage = message)
|
||||
is CreatorChannelDonationUiState.Content -> _donationStateLiveData.value = state.copy(actionToastMessage = message)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun CreatorChannelDonationTabResponse.toContentState(
|
||||
rankings: List<CreatorChannelDonationRankingUiModel>,
|
||||
donations: List<CreatorChannelDonationUiModel>
|
||||
) = CreatorChannelDonationUiState.Content(
|
||||
donationCount = donationCount,
|
||||
rankings = rankings,
|
||||
donations = donations,
|
||||
page = page,
|
||||
size = size,
|
||||
hasNext = hasNext,
|
||||
isOwner = isOwner
|
||||
)
|
||||
|
||||
private fun CreatorChannelDonationTabResponse.toDonationRankingUiModels(): List<CreatorChannelDonationRankingUiModel> =
|
||||
rankings.toDonationRankingUiModels()
|
||||
|
||||
private fun CreatorChannelDonationTabResponse.toDonationUiModels(): List<CreatorChannelDonationUiModel> =
|
||||
donations.toDonationUiModels(context, relativeTimeTextFormatter)
|
||||
|
||||
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_PAGE_SIZE = 20
|
||||
private const val FIRST_PAGE = 0
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface CreatorChannelDonationUiState {
|
||||
data object Loading : CreatorChannelDonationUiState
|
||||
data class Empty(
|
||||
val donationCount: Int,
|
||||
val rankings: List<CreatorChannelDonationRankingUiModel>,
|
||||
val isOwner: Boolean,
|
||||
val actionToastMessage: String? = null
|
||||
) : CreatorChannelDonationUiState
|
||||
data class Error(val message: String?) : CreatorChannelDonationUiState
|
||||
data class Content(
|
||||
val donationCount: Int,
|
||||
val rankings: List<CreatorChannelDonationRankingUiModel>,
|
||||
val donations: List<CreatorChannelDonationUiModel>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean,
|
||||
val isOwner: Boolean,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val paginationErrorMessage: String? = null,
|
||||
val actionToastMessage: String? = null
|
||||
) : CreatorChannelDonationUiState
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.donation.data
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelDonationTabResponse(
|
||||
@SerializedName("donationCount") val donationCount: Int,
|
||||
@SerializedName("rankings") val rankings: List<MemberDonationRankingResponse>,
|
||||
@SerializedName("donations") val donations: List<CreatorChannelDonationResponse>,
|
||||
@SerializedName("page") val page: Int,
|
||||
@SerializedName("size") val size: Int,
|
||||
@SerializedName("hasNext") val hasNext: Boolean
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class MemberDonationRankingResponse(
|
||||
@SerializedName("userId") val userId: Long,
|
||||
@SerializedName("nickname") val nickname: String,
|
||||
@SerializedName("profileImage") val profileImage: String,
|
||||
@SerializedName("donationCan") val donationCan: Int
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelDonationResponse(
|
||||
@SerializedName("nickname") val nickname: String,
|
||||
@SerializedName("profileImageUrl") val profileImageUrl: String,
|
||||
@SerializedName("can") val can: Int,
|
||||
@SerializedName("message") val message: String,
|
||||
@SerializedName("createdAtUtc") val createdAtUtc: String
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.donation.model
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorRes
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.CreatorChannelDonationResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.MemberDonationRankingResponse
|
||||
|
||||
fun List<MemberDonationRankingResponse>.toDonationRankingUiModels(): List<CreatorChannelDonationRankingUiModel> =
|
||||
mapIndexed { index, response -> response.toDonationRankingUiModel(rank = index + 1) }
|
||||
|
||||
fun List<CreatorChannelDonationResponse>.toDonationUiModels(
|
||||
context: Context,
|
||||
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
|
||||
): List<CreatorChannelDonationUiModel> = map { it.toDonationUiModel(context, relativeTimeTextFormatter) }
|
||||
|
||||
private fun MemberDonationRankingResponse.toDonationRankingUiModel(rank: Int) = CreatorChannelDonationRankingUiModel(
|
||||
rank = rank,
|
||||
userId = userId,
|
||||
nickname = nickname,
|
||||
profileImageUrl = profileImage,
|
||||
donationCan = donationCan
|
||||
)
|
||||
|
||||
private fun CreatorChannelDonationResponse.toDonationUiModel(
|
||||
context: Context,
|
||||
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
|
||||
) = CreatorChannelDonationUiModel(
|
||||
nickname = nickname,
|
||||
profileImageUrl = profileImageUrl,
|
||||
can = can,
|
||||
message = message.takeUnless { it.isBlank() } ?: context.getString(R.string.creator_channel_donation_fallback_message, can),
|
||||
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
|
||||
headerColorResId = calculateDonationHeaderColorRes(can)
|
||||
)
|
||||
|
||||
@ColorRes
|
||||
internal fun calculateDonationHeaderColorRes(can: Int): Int = when {
|
||||
can >= 500 -> R.color.red_400
|
||||
can >= 101 -> R.color.creator_channel_donation_cyan
|
||||
can >= 51 -> R.color.green_400
|
||||
else -> R.color.gray_200
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.donation.model
|
||||
|
||||
import androidx.annotation.ColorRes
|
||||
|
||||
data class CreatorChannelDonationRankingUiModel(
|
||||
val rank: Int,
|
||||
val userId: Long,
|
||||
val nickname: String,
|
||||
val profileImageUrl: String,
|
||||
val donationCan: Int
|
||||
)
|
||||
|
||||
data class CreatorChannelDonationUiModel(
|
||||
val nickname: String,
|
||||
val profileImageUrl: String,
|
||||
val can: Int,
|
||||
val message: String,
|
||||
val createdAtText: String,
|
||||
@param:ColorRes val headerColorResId: Int
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.donation.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.transform.CircleCropTransformation
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationBinding
|
||||
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationEmptyBinding
|
||||
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationRankingBinding
|
||||
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.CreatorChannelDonationRankingUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.CreatorChannelDonationUiModel
|
||||
|
||||
class CreatorChannelDonationAdapter(
|
||||
private val onRankingAllClick: () -> Unit = { },
|
||||
private val onEmptyDonationClick: () -> Unit = { }
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private var items: List<CreatorChannelDonationListItem> = emptyList()
|
||||
|
||||
fun submitItems(
|
||||
rankings: List<CreatorChannelDonationRankingUiModel>,
|
||||
donations: List<CreatorChannelDonationUiModel>
|
||||
) {
|
||||
items = buildList {
|
||||
if (rankings.isNotEmpty()) add(CreatorChannelDonationListItem.Ranking(rankings))
|
||||
donations.forEach { add(CreatorChannelDonationListItem.Donation(it)) }
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun submitEmpty(
|
||||
rankings: List<CreatorChannelDonationRankingUiModel>,
|
||||
isOwner: Boolean
|
||||
) {
|
||||
items = buildList {
|
||||
if (rankings.isNotEmpty()) add(CreatorChannelDonationListItem.Ranking(rankings))
|
||||
add(CreatorChannelDonationListItem.Empty(isOwner))
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int = when (items[position]) {
|
||||
is CreatorChannelDonationListItem.Ranking -> VIEW_TYPE_RANKING
|
||||
is CreatorChannelDonationListItem.Empty -> VIEW_TYPE_EMPTY
|
||||
is CreatorChannelDonationListItem.Donation -> VIEW_TYPE_DONATION
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return when (viewType) {
|
||||
VIEW_TYPE_RANKING -> RankingViewHolder(
|
||||
ItemCreatorChannelDonationRankingBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
onRankingAllClick
|
||||
)
|
||||
VIEW_TYPE_EMPTY -> EmptyViewHolder(
|
||||
ItemCreatorChannelDonationEmptyBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
onEmptyDonationClick
|
||||
)
|
||||
else -> DonationViewHolder(
|
||||
ItemCreatorChannelDonationBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (val item = items[position]) {
|
||||
is CreatorChannelDonationListItem.Ranking -> (holder as RankingViewHolder).bind(item.rankings)
|
||||
is CreatorChannelDonationListItem.Empty -> (holder as EmptyViewHolder).bind(item.isOwner)
|
||||
is CreatorChannelDonationListItem.Donation -> (holder as DonationViewHolder).bind(item.donation)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
class RankingViewHolder(
|
||||
private val binding: ItemCreatorChannelDonationRankingBinding,
|
||||
private val onRankingAllClick: () -> Unit
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
private val rankingAdapter = CreatorChannelDonationRankingAdapter()
|
||||
|
||||
init {
|
||||
binding.rvCreatorChannelDonationRankingMembers.layoutManager = GridLayoutManager(itemView.context, 4)
|
||||
binding.rvCreatorChannelDonationRankingMembers.addItemDecoration(
|
||||
CreatorChannelDonationRankingItemDecoration(itemView.context, spanCount = 4)
|
||||
)
|
||||
binding.rvCreatorChannelDonationRankingMembers.adapter = rankingAdapter
|
||||
binding.btnCreatorChannelDonationRankingAll.setOnClickListener { onRankingAllClick() }
|
||||
}
|
||||
|
||||
fun bind(rankings: List<CreatorChannelDonationRankingUiModel>) {
|
||||
rankingAdapter.submitItems(rankings)
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyViewHolder(
|
||||
private val binding: ItemCreatorChannelDonationEmptyBinding,
|
||||
private val onEmptyDonationClick: () -> Unit
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
init {
|
||||
binding.btnCreatorChannelDonationEmptyWrite.setOnClickListener { onEmptyDonationClick() }
|
||||
}
|
||||
|
||||
fun bind(isOwner: Boolean) = with(binding) {
|
||||
btnCreatorChannelDonationEmptyWrite.isVisible = !isOwner
|
||||
}
|
||||
}
|
||||
|
||||
class DonationViewHolder(
|
||||
private val binding: ItemCreatorChannelDonationBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(item: CreatorChannelDonationUiModel) = with(binding) {
|
||||
layoutCreatorChannelDonationHeader.setBackgroundColor(root.context.getColor(item.headerColorResId))
|
||||
ivCreatorChannelDonationProfile.loadUrl(item.profileImageUrl) {
|
||||
crossfade(true)
|
||||
placeholder(R.drawable.ic_placeholder_profile)
|
||||
error(R.drawable.ic_placeholder_profile)
|
||||
transformations(CircleCropTransformation())
|
||||
}
|
||||
tvCreatorChannelDonationNickname.text = item.nickname
|
||||
tvCreatorChannelDonationCreatedAt.text = item.createdAtText
|
||||
tvCreatorChannelDonationCan.text = root.context.getString(
|
||||
R.string.creator_channel_donation_can_format,
|
||||
item.can.moneyFormat()
|
||||
)
|
||||
tvCreatorChannelDonationMessage.text = item.message
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val VIEW_TYPE_RANKING = 0
|
||||
private const val VIEW_TYPE_EMPTY = 1
|
||||
private const val VIEW_TYPE_DONATION = 2
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface CreatorChannelDonationListItem {
|
||||
data class Ranking(val rankings: List<CreatorChannelDonationRankingUiModel>) : CreatorChannelDonationListItem
|
||||
data class Empty(val isOwner: Boolean) : CreatorChannelDonationListItem
|
||||
data class Donation(val donation: CreatorChannelDonationUiModel) : CreatorChannelDonationListItem
|
||||
}
|
||||
|
||||
private class CreatorChannelDonationRankingItemDecoration(
|
||||
context: Context,
|
||||
private val spanCount: Int
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val spacing: Int = (14 * context.resources.displayMetrics.density).toInt()
|
||||
|
||||
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||
val position = parent.getChildAdapterPosition(view)
|
||||
if (position == RecyclerView.NO_POSITION) return
|
||||
|
||||
val column = position % spanCount
|
||||
outRect.left = column * spacing / spanCount
|
||||
outRect.right = spacing - (column + 1) * spacing / spanCount
|
||||
outRect.top = if (position >= spanCount) spacing else 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.donation.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.transform.CircleCropTransformation
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationRankingMemberBinding
|
||||
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.CreatorChannelDonationRankingUiModel
|
||||
|
||||
class CreatorChannelDonationRankingAdapter : RecyclerView.Adapter<CreatorChannelDonationRankingAdapter.ViewHolder>() {
|
||||
|
||||
private var items: List<CreatorChannelDonationRankingUiModel> = emptyList()
|
||||
|
||||
fun submitItems(items: List<CreatorChannelDonationRankingUiModel>) {
|
||||
this.items = items.take(MAX_VISIBLE_RANKING_COUNT)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return ViewHolder(
|
||||
ItemCreatorChannelDonationRankingMemberBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
class ViewHolder(
|
||||
private val binding: ItemCreatorChannelDonationRankingMemberBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(item: CreatorChannelDonationRankingUiModel) = with(binding) {
|
||||
ivCreatorChannelDonationRankingProfile.loadUrl(item.profileImageUrl) {
|
||||
crossfade(true)
|
||||
placeholder(R.drawable.ic_placeholder_profile)
|
||||
error(R.drawable.ic_placeholder_profile)
|
||||
transformations(CircleCropTransformation())
|
||||
}
|
||||
tvCreatorChannelDonationRankingRank.text = item.rank.toString()
|
||||
tvCreatorChannelDonationRankingNickname.text = item.nickname
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_VISIBLE_RANKING_COUNT = 8
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.base.BaseFragment
|
||||
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelFantalkBinding
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import kr.co.vividnext.sodalive.report.CheersReportDialog
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkRightAction
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.ui.CreatorChannelFanTalkAdapter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.ui.CreatorChannelFanTalkMorePopup
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
class CreatorChannelFanTalkFragment : BaseFragment<FragmentCreatorChannelFantalkBinding>(
|
||||
FragmentCreatorChannelFantalkBinding::inflate
|
||||
) {
|
||||
|
||||
private val viewModel: CreatorChannelFanTalkViewModel by viewModel()
|
||||
private val fanTalkAdapter = CreatorChannelFanTalkAdapter(
|
||||
onOwnerMoreClick = ::showOwnerMorePopup,
|
||||
onReportClick = ::showReportDialog
|
||||
)
|
||||
private var morePopup: CreatorChannelFanTalkMorePopup? = null
|
||||
private var lastContentLayoutKey: CreatorChannelFanTalkContentLayoutKey? = null
|
||||
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
|
||||
private val host: Host
|
||||
get() = requireActivity() as Host
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
bindLoading()
|
||||
setupFanTalkList()
|
||||
setupClickListeners()
|
||||
observeViewModel()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
morePopup?.dismiss()
|
||||
morePopup = null
|
||||
lastContentLayoutKey = null
|
||||
binding.rvCreatorChannelFantalk.adapter = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
fun onCreatorChannelFanTalkTabSelected() {
|
||||
if (creatorId > 0L) {
|
||||
viewModel.loadFanTalks(creatorId, isOwner = host.isCreatorChannelOwner())
|
||||
}
|
||||
}
|
||||
|
||||
fun onCreatorChannelFanTalkScrolledToBottom() {
|
||||
viewModel.loadMore()
|
||||
}
|
||||
|
||||
fun onCreatorChannelFanTalkRefreshRequested() {
|
||||
viewModel.refreshFanTalks()
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun onCreatorChannelFanTalkViewportHeightChanged(minHeight: Int) = Unit
|
||||
|
||||
fun onCreatorChannelFanTalkDeleteConfirmed(fanTalkId: Long) {
|
||||
viewModel.deleteFanTalk(fanTalkId)
|
||||
}
|
||||
|
||||
private fun setupFanTalkList() = with(binding.rvCreatorChannelFantalk) {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
adapter = fanTalkAdapter
|
||||
}
|
||||
|
||||
private fun setupClickListeners() = with(binding) {
|
||||
btnCreatorChannelFantalkRetry.setOnClickListener {
|
||||
viewModel.retryFanTalks()
|
||||
}
|
||||
btnCreatorChannelFantalkWrite.setOnClickListener { }
|
||||
layoutCreatorChannelFantalkEmptyWriteButton.setOnClickListener { }
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
viewModel.fanTalkStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
CreatorChannelFanTalkUiState.Loading -> bindLoading()
|
||||
is CreatorChannelFanTalkUiState.Empty -> bindEmpty()
|
||||
is CreatorChannelFanTalkUiState.Error -> bindError(state)
|
||||
is CreatorChannelFanTalkUiState.Content -> bindContent(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindLoading() = with(binding) {
|
||||
lastContentLayoutKey = null
|
||||
layoutCreatorChannelFantalkCountBar.isVisible = false
|
||||
rvCreatorChannelFantalk.isVisible = false
|
||||
layoutCreatorChannelFantalkEmpty.isVisible = false
|
||||
tvCreatorChannelFantalkErrorMessage.isVisible = false
|
||||
btnCreatorChannelFantalkRetry.isVisible = false
|
||||
btnCreatorChannelFantalkWrite.isVisible = false
|
||||
}
|
||||
|
||||
private fun bindEmpty() = with(binding) {
|
||||
lastContentLayoutKey = null
|
||||
layoutCreatorChannelFantalkCountBar.isVisible = false
|
||||
rvCreatorChannelFantalk.isVisible = false
|
||||
layoutCreatorChannelFantalkEmpty.isVisible = true
|
||||
tvCreatorChannelFantalkErrorMessage.isVisible = false
|
||||
btnCreatorChannelFantalkRetry.isVisible = false
|
||||
btnCreatorChannelFantalkWrite.isVisible = false
|
||||
host.onCreatorChannelFanTalkContentChanged()
|
||||
}
|
||||
|
||||
private fun bindError(state: CreatorChannelFanTalkUiState.Error) = with(binding) {
|
||||
lastContentLayoutKey = null
|
||||
layoutCreatorChannelFantalkCountBar.isVisible = false
|
||||
rvCreatorChannelFantalk.isVisible = false
|
||||
layoutCreatorChannelFantalkEmpty.isVisible = false
|
||||
tvCreatorChannelFantalkErrorMessage.isVisible = true
|
||||
tvCreatorChannelFantalkErrorMessage.text = state.message ?: getString(R.string.creator_channel_fantalk_error_message)
|
||||
btnCreatorChannelFantalkRetry.isVisible = true
|
||||
btnCreatorChannelFantalkWrite.isVisible = false
|
||||
host.onCreatorChannelFanTalkContentChanged()
|
||||
}
|
||||
|
||||
private fun bindContent(state: CreatorChannelFanTalkUiState.Content) = with(binding) {
|
||||
layoutCreatorChannelFantalkCountBar.isVisible = true
|
||||
tvCreatorChannelFantalkTotalCount.text = state.fanTalkCount.moneyFormat()
|
||||
rvCreatorChannelFantalk.isVisible = true
|
||||
fanTalkAdapter.submitItems(state.fanTalks)
|
||||
layoutCreatorChannelFantalkEmpty.isVisible = false
|
||||
tvCreatorChannelFantalkErrorMessage.isVisible = false
|
||||
btnCreatorChannelFantalkRetry.isVisible = false
|
||||
btnCreatorChannelFantalkWrite.isVisible = true
|
||||
notifyContentChangedIfLayoutChanged(state)
|
||||
state.paginationErrorMessage?.let {
|
||||
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
|
||||
viewModel.consumePaginationErrorMessage()
|
||||
}
|
||||
state.actionToastMessage?.let {
|
||||
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
|
||||
viewModel.consumeActionToastMessage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelFanTalkUiState.Content) {
|
||||
val contentLayoutKey = state.toContentLayoutKey()
|
||||
if (contentLayoutKey == lastContentLayoutKey) return
|
||||
|
||||
lastContentLayoutKey = contentLayoutKey
|
||||
host.onCreatorChannelFanTalkContentChanged()
|
||||
}
|
||||
|
||||
private fun showReportDialog(item: CreatorChannelFanTalkUiModel) {
|
||||
CheersReportDialog(requireActivity(), layoutInflater) { reason ->
|
||||
if (reason.isBlank()) {
|
||||
showToast(getString(R.string.screen_user_profile_fantalk_report_reason_required))
|
||||
} else {
|
||||
viewModel.reportFanTalk(item.fanTalkId, reason)
|
||||
}
|
||||
}.show(screenWidth)
|
||||
}
|
||||
|
||||
private fun showOwnerMorePopup(anchor: View, item: CreatorChannelFanTalkUiModel) {
|
||||
val ownerMore = item.rightAction as? CreatorChannelFanTalkRightAction.OwnerMore ?: return
|
||||
morePopup?.dismiss()
|
||||
morePopup = CreatorChannelFanTalkMorePopup(
|
||||
anchor = anchor,
|
||||
fanTalkId = item.fanTalkId,
|
||||
showEdit = ownerMore.showEdit,
|
||||
showDelete = ownerMore.showDelete,
|
||||
onDeleteClick = host::onCreatorChannelFanTalkDeleteClicked
|
||||
).also { it.show() }
|
||||
}
|
||||
|
||||
interface Host {
|
||||
fun isCreatorChannelOwner(): Boolean
|
||||
fun onCreatorChannelFanTalkContentChanged()
|
||||
fun onCreatorChannelFanTalkDeleteClicked(fanTalkId: Long)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_CREATOR_ID: String = "arg_creator_id"
|
||||
|
||||
fun newInstance(creatorId: Long): CreatorChannelFanTalkFragment {
|
||||
return CreatorChannelFanTalkFragment().apply {
|
||||
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class CreatorChannelFanTalkContentLayoutKey(
|
||||
val fanTalkCount: Int,
|
||||
val fanTalkIds: List<Long>
|
||||
)
|
||||
|
||||
private fun CreatorChannelFanTalkUiState.Content.toContentLayoutKey(): CreatorChannelFanTalkContentLayoutKey {
|
||||
return CreatorChannelFanTalkContentLayoutKey(
|
||||
fanTalkCount = fanTalkCount,
|
||||
fanTalkIds = fanTalks.map { it.fanTalkId }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
|
||||
import kr.co.vividnext.sodalive.base.BaseViewModel
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkTabResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.toFanTalkUiModels
|
||||
|
||||
class CreatorChannelFanTalkViewModel(
|
||||
private val repository: CreatorChannelRepository,
|
||||
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val _fanTalkStateLiveData = MutableLiveData<CreatorChannelFanTalkUiState>()
|
||||
val fanTalkStateLiveData: LiveData<CreatorChannelFanTalkUiState>
|
||||
get() = _fanTalkStateLiveData
|
||||
|
||||
private var creatorId: Long = 0L
|
||||
private var isOwner: Boolean = false
|
||||
private var requestGeneration: Int = 0
|
||||
|
||||
fun loadFanTalks(creatorId: Long, isOwner: Boolean) {
|
||||
if (creatorId <= 0) return
|
||||
val shouldSkipReload = this.creatorId == creatorId && this.isOwner == isOwner && _fanTalkStateLiveData.value != null
|
||||
if (shouldSkipReload) return
|
||||
|
||||
this.creatorId = creatorId
|
||||
this.isOwner = isOwner
|
||||
loadFirstPage()
|
||||
}
|
||||
|
||||
fun retryFanTalks() {
|
||||
if (creatorId <= 0) return
|
||||
|
||||
loadFirstPage()
|
||||
}
|
||||
|
||||
fun refreshFanTalks() {
|
||||
if (creatorId <= 0) return
|
||||
|
||||
loadFirstPage()
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val content = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: return
|
||||
if (!content.hasNext || content.isLoadingMore || creatorId <= 0) return
|
||||
|
||||
val generation = requestGeneration
|
||||
_fanTalkStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null)
|
||||
requestFanTalks(page = content.page + 1, generation = generation) { response ->
|
||||
val data = response.data
|
||||
val current = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: content
|
||||
if (response.success && data != null) {
|
||||
_fanTalkStateLiveData.value = current.copy(
|
||||
fanTalks = current.fanTalks + data.toFanTalkUiModels(),
|
||||
page = data.page,
|
||||
size = data.size,
|
||||
hasNext = data.hasNext,
|
||||
isLoadingMore = false
|
||||
)
|
||||
} else {
|
||||
_fanTalkStateLiveData.value = current.copy(
|
||||
isLoadingMore = false,
|
||||
paginationErrorMessage = response.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consumePaginationErrorMessage() {
|
||||
val content = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: return
|
||||
if (content.paginationErrorMessage == null) return
|
||||
|
||||
_fanTalkStateLiveData.value = content.copy(paginationErrorMessage = null)
|
||||
}
|
||||
|
||||
fun consumeActionToastMessage() {
|
||||
val content = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: return
|
||||
if (content.actionToastMessage == null) return
|
||||
|
||||
_fanTalkStateLiveData.value = content.copy(actionToastMessage = null)
|
||||
}
|
||||
|
||||
fun reportFanTalk(fanTalkId: Long, reason: String) {
|
||||
val content = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: return
|
||||
compositeDisposable.add(
|
||||
repository.reportFanTalk(fanTalkId = fanTalkId, reason = reason, token = authToken())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
val current = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: content
|
||||
val message = it.message.takeUnless { message -> message.isNullOrBlank() }
|
||||
?: SodaLiveApplicationHolder.get().getString(R.string.character_comment_report_submitted)
|
||||
_fanTalkStateLiveData.value = current.copy(actionToastMessage = message)
|
||||
},
|
||||
{
|
||||
val current = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: content
|
||||
_fanTalkStateLiveData.value = current.copy(actionToastMessage = it.message)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteFanTalk(fanTalkId: Long) {
|
||||
val content = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: return
|
||||
compositeDisposable.add(
|
||||
repository.deleteFanTalk(fanTalkId = fanTalkId, token = authToken())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
if (it.success) {
|
||||
refreshFanTalks()
|
||||
} else {
|
||||
val current = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: content
|
||||
_fanTalkStateLiveData.value = current.copy(actionToastMessage = it.message)
|
||||
}
|
||||
},
|
||||
{
|
||||
val current = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: content
|
||||
_fanTalkStateLiveData.value = current.copy(actionToastMessage = it.message)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadFirstPage() {
|
||||
val generation = ++requestGeneration
|
||||
_fanTalkStateLiveData.value = CreatorChannelFanTalkUiState.Loading
|
||||
requestFanTalks(page = FIRST_PAGE, generation = generation) { response ->
|
||||
val data = response.data
|
||||
if (response.success && data != null) {
|
||||
val fanTalks = data.toFanTalkUiModels()
|
||||
_fanTalkStateLiveData.value = if (fanTalks.isEmpty() || data.fanTalkCount == 0) {
|
||||
CreatorChannelFanTalkUiState.Empty(data.fanTalkCount)
|
||||
} else {
|
||||
data.toContentState(fanTalks = fanTalks)
|
||||
}
|
||||
} else {
|
||||
_fanTalkStateLiveData.value = CreatorChannelFanTalkUiState.Error(response.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestFanTalks(
|
||||
page: Int,
|
||||
generation: Int,
|
||||
onSuccess: (ApiResponse<CreatorChannelFanTalkTabResponse>) -> Unit
|
||||
) {
|
||||
compositeDisposable.add(
|
||||
repository.getFanTalks(
|
||||
creatorId = creatorId,
|
||||
page = page,
|
||||
size = DEFAULT_PAGE_SIZE,
|
||||
token = authToken()
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
if (generation == requestGeneration) {
|
||||
onSuccess(it)
|
||||
}
|
||||
},
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
|
||||
val current = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content
|
||||
_fanTalkStateLiveData.value = if (current != null && page > FIRST_PAGE) {
|
||||
current.copy(isLoadingMore = false, paginationErrorMessage = it.message)
|
||||
} else {
|
||||
CreatorChannelFanTalkUiState.Error(it.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CreatorChannelFanTalkTabResponse.toContentState(
|
||||
fanTalks: List<CreatorChannelFanTalkUiModel>
|
||||
) = CreatorChannelFanTalkUiState.Content(
|
||||
fanTalkCount = fanTalkCount,
|
||||
fanTalks = fanTalks,
|
||||
page = page,
|
||||
size = size,
|
||||
hasNext = hasNext
|
||||
)
|
||||
|
||||
private fun CreatorChannelFanTalkTabResponse.toFanTalkUiModels(): List<CreatorChannelFanTalkUiModel> =
|
||||
fanTalks.toFanTalkUiModels(
|
||||
relativeTimeTextFormatter = relativeTimeTextFormatter,
|
||||
isOwner = isOwner,
|
||||
currentUserId = SharedPreferenceManager.userId
|
||||
)
|
||||
|
||||
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_PAGE_SIZE = 20
|
||||
private const val FIRST_PAGE = 0
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface CreatorChannelFanTalkUiState {
|
||||
data object Loading : CreatorChannelFanTalkUiState
|
||||
data class Empty(val fanTalkCount: Int) : CreatorChannelFanTalkUiState
|
||||
data class Error(val message: String?) : CreatorChannelFanTalkUiState
|
||||
data class Content(
|
||||
val fanTalkCount: Int,
|
||||
val fanTalks: List<CreatorChannelFanTalkUiModel>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val paginationErrorMessage: String? = null,
|
||||
val actionToastMessage: String? = null
|
||||
) : CreatorChannelFanTalkUiState
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelFanTalkTabResponse(
|
||||
@SerializedName("fanTalkCount") val fanTalkCount: Int,
|
||||
@SerializedName("fanTalks") val fanTalks: List<CreatorChannelFanTalkResponse>,
|
||||
@SerializedName("page") val page: Int,
|
||||
@SerializedName("size") val size: Int,
|
||||
@SerializedName("hasNext") val hasNext: Boolean
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelFanTalkResponse(
|
||||
@SerializedName("fanTalkId") val fanTalkId: Long,
|
||||
@SerializedName("writerId") val writerId: Long,
|
||||
@SerializedName("writerNickname") val writerNickname: String,
|
||||
@SerializedName("writerProfileImageUrl") val writerProfileImageUrl: String,
|
||||
@SerializedName("content") val content: String,
|
||||
@SerializedName("createdAtUtc") val createdAtUtc: String,
|
||||
@SerializedName("creatorReplies") val creatorReplies: List<CreatorChannelFanTalkReplyResponse>
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelFanTalkReplyResponse(
|
||||
@SerializedName("fanTalkId") val fanTalkId: Long,
|
||||
@SerializedName("writerId") val writerId: Long,
|
||||
@SerializedName("writerNickname") val writerNickname: String,
|
||||
@SerializedName("writerProfileImageUrl") val writerProfileImageUrl: String,
|
||||
@SerializedName("content") val content: String,
|
||||
@SerializedName("createdAtUtc") val createdAtUtc: String
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model
|
||||
|
||||
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkResponse
|
||||
|
||||
fun List<CreatorChannelFanTalkResponse>.toFanTalkUiModels(
|
||||
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter,
|
||||
isOwner: Boolean,
|
||||
currentUserId: Long
|
||||
): List<CreatorChannelFanTalkUiModel> = map {
|
||||
it.toFanTalkUiModel(relativeTimeTextFormatter, isOwner, currentUserId)
|
||||
}
|
||||
|
||||
private fun CreatorChannelFanTalkResponse.toFanTalkUiModel(
|
||||
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter,
|
||||
isOwner: Boolean,
|
||||
currentUserId: Long
|
||||
) = CreatorChannelFanTalkUiModel(
|
||||
fanTalkId = fanTalkId,
|
||||
writerId = writerId,
|
||||
writerNickname = writerNickname,
|
||||
writerProfileImageUrl = writerProfileImageUrl,
|
||||
content = content,
|
||||
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
|
||||
reply = creatorReplies.firstOrNull()?.toReplyUiModel(relativeTimeTextFormatter),
|
||||
rightAction = toRightAction(isOwner = isOwner, currentUserId = currentUserId)
|
||||
)
|
||||
|
||||
private fun CreatorChannelFanTalkReplyResponse.toReplyUiModel(
|
||||
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
|
||||
) = CreatorChannelFanTalkReplyUiModel(
|
||||
fanTalkId = fanTalkId,
|
||||
writerId = writerId,
|
||||
writerNickname = writerNickname,
|
||||
writerProfileImageUrl = writerProfileImageUrl,
|
||||
content = content,
|
||||
createdAtText = relativeTimeTextFormatter.format(createdAtUtc)
|
||||
)
|
||||
|
||||
private fun CreatorChannelFanTalkResponse.toRightAction(
|
||||
isOwner: Boolean,
|
||||
currentUserId: Long
|
||||
): CreatorChannelFanTalkRightAction = when {
|
||||
writerId == currentUserId -> CreatorChannelFanTalkRightAction.OwnerMore(showEdit = true, showDelete = true)
|
||||
isOwner -> CreatorChannelFanTalkRightAction.OwnerMore(showEdit = false, showDelete = true)
|
||||
else -> CreatorChannelFanTalkRightAction.Report
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model
|
||||
|
||||
data class CreatorChannelFanTalkUiModel(
|
||||
val fanTalkId: Long,
|
||||
val writerId: Long,
|
||||
val writerNickname: String,
|
||||
val writerProfileImageUrl: String,
|
||||
val content: String,
|
||||
val createdAtText: String,
|
||||
val reply: CreatorChannelFanTalkReplyUiModel?,
|
||||
val rightAction: CreatorChannelFanTalkRightAction
|
||||
)
|
||||
|
||||
data class CreatorChannelFanTalkReplyUiModel(
|
||||
val fanTalkId: Long,
|
||||
val writerId: Long,
|
||||
val writerNickname: String,
|
||||
val writerProfileImageUrl: String,
|
||||
val content: String,
|
||||
val createdAtText: String
|
||||
)
|
||||
|
||||
sealed interface CreatorChannelFanTalkRightAction {
|
||||
data object Report : CreatorChannelFanTalkRightAction
|
||||
data class OwnerMore(
|
||||
val showEdit: Boolean,
|
||||
val showDelete: Boolean
|
||||
) : CreatorChannelFanTalkRightAction
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.transform.CircleCropTransformation
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelFantalkBinding
|
||||
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkReplyUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkRightAction
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkUiModel
|
||||
|
||||
class CreatorChannelFanTalkAdapter(
|
||||
private val onOwnerMoreClick: (View, CreatorChannelFanTalkUiModel) -> Unit = { _, _ -> },
|
||||
private val onReportClick: (CreatorChannelFanTalkUiModel) -> Unit = { }
|
||||
) : RecyclerView.Adapter<CreatorChannelFanTalkAdapter.ViewHolder>() {
|
||||
|
||||
private var items: List<CreatorChannelFanTalkUiModel> = emptyList()
|
||||
|
||||
fun submitItems(items: List<CreatorChannelFanTalkUiModel>) {
|
||||
this.items = items
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return ViewHolder(
|
||||
ItemCreatorChannelFantalkBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
onOwnerMoreClick,
|
||||
onReportClick
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
class ViewHolder(
|
||||
private val binding: ItemCreatorChannelFantalkBinding,
|
||||
private val onOwnerMoreClick: (View, CreatorChannelFanTalkUiModel) -> Unit,
|
||||
private val onReportClick: (CreatorChannelFanTalkUiModel) -> Unit
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(item: CreatorChannelFanTalkUiModel) = with(binding) {
|
||||
ivCreatorChannelFantalkProfile.loadProfile(item.writerProfileImageUrl)
|
||||
tvCreatorChannelFantalkNickname.text = item.writerNickname
|
||||
tvCreatorChannelFantalkTime.text = item.createdAtText
|
||||
tvCreatorChannelFantalkContent.text = item.content
|
||||
bindRightAction(item)
|
||||
bindReply(item.reply)
|
||||
}
|
||||
|
||||
private fun ItemCreatorChannelFantalkBinding.bindRightAction(item: CreatorChannelFanTalkUiModel) {
|
||||
when (item.rightAction) {
|
||||
CreatorChannelFanTalkRightAction.Report -> {
|
||||
tvCreatorChannelFantalkReport.isVisible = true
|
||||
tvCreatorChannelFantalkReport.setOnClickListener { onReportClick(item) }
|
||||
ivCreatorChannelFantalkMore.isVisible = false
|
||||
ivCreatorChannelFantalkMore.setOnClickListener(null)
|
||||
}
|
||||
is CreatorChannelFanTalkRightAction.OwnerMore -> {
|
||||
tvCreatorChannelFantalkReport.isVisible = false
|
||||
tvCreatorChannelFantalkReport.setOnClickListener(null)
|
||||
ivCreatorChannelFantalkMore.isVisible = true
|
||||
ivCreatorChannelFantalkMore.setOnClickListener { onOwnerMoreClick(it, item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ItemCreatorChannelFantalkBinding.bindReply(reply: CreatorChannelFanTalkReplyUiModel?) {
|
||||
val hasReply = reply != null
|
||||
viewCreatorChannelFantalkReplyConnector.isVisible = hasReply
|
||||
layoutCreatorChannelFantalkReply.isVisible = hasReply
|
||||
if (reply == null) return
|
||||
|
||||
ivCreatorChannelFantalkReplyProfile.loadProfile(reply.writerProfileImageUrl)
|
||||
tvCreatorChannelFantalkReplyNickname.text = reply.writerNickname
|
||||
tvCreatorChannelFantalkReplyTime.text = reply.createdAtText
|
||||
tvCreatorChannelFantalkReplyContent.text = reply.content
|
||||
}
|
||||
|
||||
private fun android.widget.ImageView.loadProfile(url: String) {
|
||||
loadUrl(url) {
|
||||
crossfade(true)
|
||||
placeholder(R.drawable.ic_placeholder_profile)
|
||||
error(R.drawable.ic_placeholder_profile)
|
||||
transformations(CircleCropTransformation())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.ui
|
||||
|
||||
import android.graphics.Color
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.PopupWindow
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.view.isVisible
|
||||
import kr.co.vividnext.sodalive.databinding.ViewCreatorChannelFantalkMorePopupBinding
|
||||
|
||||
class CreatorChannelFanTalkMorePopup(
|
||||
private val anchor: View,
|
||||
private val fanTalkId: Long,
|
||||
private val showEdit: Boolean,
|
||||
private val showDelete: Boolean,
|
||||
private val onDeleteClick: (Long) -> Unit
|
||||
) {
|
||||
|
||||
private val binding = ViewCreatorChannelFantalkMorePopupBinding.inflate(LayoutInflater.from(anchor.context))
|
||||
private val popupWindow: PopupWindow = PopupWindow(anchor.context).apply {
|
||||
contentView = binding.root
|
||||
width = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
height = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
isOutsideTouchable = true
|
||||
isFocusable = true
|
||||
setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
|
||||
}
|
||||
|
||||
init {
|
||||
binding.tvCreatorChannelFantalkMoreEdit.isVisible = showEdit
|
||||
binding.tvCreatorChannelFantalkMoreDelete.isVisible = showDelete
|
||||
binding.tvCreatorChannelFantalkMoreEdit.setOnClickListener {
|
||||
dismiss()
|
||||
}
|
||||
binding.tvCreatorChannelFantalkMoreDelete.setOnClickListener {
|
||||
dismiss()
|
||||
onDeleteClick(fanTalkId)
|
||||
}
|
||||
}
|
||||
|
||||
fun show() {
|
||||
popupWindow.showAsDropDown(anchor)
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
popupWindow.dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.live
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.base.BaseFragment
|
||||
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelLiveBinding
|
||||
import kr.co.vividnext.sodalive.extensions.dpToPx
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.model.toReplayUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelAudioContentAdapter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.toLabelResId
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.ui.formatCreatorChannelLiveDateTime
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelSortPopup
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
class CreatorChannelLiveFragment : BaseFragment<FragmentCreatorChannelLiveBinding>(
|
||||
FragmentCreatorChannelLiveBinding::inflate
|
||||
) {
|
||||
|
||||
private val viewModel: CreatorChannelLiveViewModel by viewModel()
|
||||
private val replayAdapter = CreatorChannelAudioContentAdapter { item ->
|
||||
host.onCreatorChannelLiveReplayClicked(item.audioContentId)
|
||||
}
|
||||
private var lastContentLayoutKey: CreatorChannelLiveContentLayoutKey? = null
|
||||
private var sortPopup: CreatorChannelSortPopup? = null
|
||||
private var currentContentState: CreatorChannelLiveUiState.Content? = null
|
||||
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
|
||||
private val host: Host
|
||||
get() = requireActivity() as Host
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
bindLoading()
|
||||
setupReplayList()
|
||||
setupClickListeners()
|
||||
observeViewModel()
|
||||
}
|
||||
|
||||
fun onCreatorChannelLiveTabSelected() {
|
||||
if (creatorId > 0L) {
|
||||
viewModel.loadLive(creatorId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
sortPopup?.dismiss()
|
||||
sortPopup = null
|
||||
currentContentState = null
|
||||
binding.rvCreatorChannelLiveReplays.adapter = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun setupReplayList() = with(binding.rvCreatorChannelLiveReplays) {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
adapter = replayAdapter
|
||||
}
|
||||
|
||||
fun onCreatorChannelLiveScrolledToBottom() {
|
||||
viewModel.loadMore()
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun onCreatorChannelLiveViewportHeightChanged(minHeight: Int) = Unit
|
||||
|
||||
fun onCreatorChannelLiveOwnerCtaVisibilityChanged(isVisible: Boolean) = with(binding) {
|
||||
val bottomPadding = if (isVisible) {
|
||||
LIVE_OWNER_CTA_LIST_BOTTOM_PADDING_DP.dpToPx().toInt()
|
||||
} else {
|
||||
DEFAULT_LIST_BOTTOM_PADDING_DP.dpToPx().toInt()
|
||||
}
|
||||
rvCreatorChannelLiveReplays.updatePadding(bottom = bottomPadding)
|
||||
}
|
||||
|
||||
private fun setupClickListeners() {
|
||||
binding.ivCreatorChannelLiveSort.setImageResource(R.drawable.ic_new_sort)
|
||||
binding.layoutCreatorChannelLiveSortButton.setOnClickListener {
|
||||
currentContentState?.let { state -> showSortPopup(state) }
|
||||
}
|
||||
binding.btnCreatorChannelLiveRetry.setOnClickListener {
|
||||
viewModel.retryLive()
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
viewModel.liveStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
CreatorChannelLiveUiState.Loading -> bindLoading()
|
||||
CreatorChannelLiveUiState.Empty -> bindEmpty()
|
||||
is CreatorChannelLiveUiState.Error -> bindError(state)
|
||||
is CreatorChannelLiveUiState.Content -> bindContent(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindLoading() = with(binding) {
|
||||
currentContentState = null
|
||||
lastContentLayoutKey = null
|
||||
layoutCreatorChannelLiveSortBar.isVisible = false
|
||||
layoutCreatorChannelLiveCurrentCard.isVisible = false
|
||||
rvCreatorChannelLiveReplays.isVisible = false
|
||||
layoutCreatorChannelLiveEmpty.isVisible = false
|
||||
tvCreatorChannelLiveErrorMessage.isVisible = false
|
||||
btnCreatorChannelLiveRetry.isVisible = false
|
||||
}
|
||||
|
||||
private fun bindEmpty() = with(binding) {
|
||||
currentContentState = null
|
||||
lastContentLayoutKey = null
|
||||
layoutCreatorChannelLiveSortBar.isVisible = false
|
||||
layoutCreatorChannelLiveCurrentCard.isVisible = false
|
||||
rvCreatorChannelLiveReplays.isVisible = false
|
||||
layoutCreatorChannelLiveEmpty.isVisible = true
|
||||
tvCreatorChannelLiveErrorMessage.isVisible = false
|
||||
btnCreatorChannelLiveRetry.isVisible = false
|
||||
host.onCreatorChannelLiveContentChanged()
|
||||
}
|
||||
|
||||
private fun bindError(state: CreatorChannelLiveUiState.Error) = with(binding) {
|
||||
currentContentState = null
|
||||
lastContentLayoutKey = null
|
||||
layoutCreatorChannelLiveSortBar.isVisible = false
|
||||
layoutCreatorChannelLiveCurrentCard.isVisible = false
|
||||
rvCreatorChannelLiveReplays.isVisible = false
|
||||
layoutCreatorChannelLiveEmpty.isVisible = false
|
||||
tvCreatorChannelLiveErrorMessage.isVisible = true
|
||||
tvCreatorChannelLiveErrorMessage.text = state.message ?: getString(R.string.creator_channel_live_error_message)
|
||||
btnCreatorChannelLiveRetry.isVisible = true
|
||||
host.onCreatorChannelLiveContentChanged()
|
||||
}
|
||||
|
||||
private fun bindContent(state: CreatorChannelLiveUiState.Content) = with(binding) {
|
||||
currentContentState = state
|
||||
layoutCreatorChannelLiveEmpty.isVisible = false
|
||||
tvCreatorChannelLiveErrorMessage.isVisible = false
|
||||
btnCreatorChannelLiveRetry.isVisible = false
|
||||
layoutCreatorChannelLiveSortBar.isVisible = true
|
||||
tvCreatorChannelLiveTotalCount.text = state.liveReplayContentCount.moneyFormat()
|
||||
tvCreatorChannelLiveSortLabel.setText(state.selectedSort.toLabelResId())
|
||||
bindCurrentLive(state.currentLive)
|
||||
rvCreatorChannelLiveReplays.isVisible = true
|
||||
replayAdapter.submitItems(state.liveReplayContents.map { it.toReplayUiModel() })
|
||||
notifyContentChangedIfLayoutChanged(state)
|
||||
state.paginationErrorMessage?.let {
|
||||
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
|
||||
viewModel.consumePaginationErrorMessage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSortPopup(state: CreatorChannelLiveUiState.Content) {
|
||||
sortPopup?.dismiss()
|
||||
sortPopup = CreatorChannelSortPopup(
|
||||
anchor = binding.layoutCreatorChannelLiveSortButton,
|
||||
selectedSort = state.selectedSort,
|
||||
onSortSelected = { sort -> viewModel.changeSort(sort) }
|
||||
).also { it.show() }
|
||||
}
|
||||
|
||||
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelLiveUiState.Content) {
|
||||
val contentLayoutKey = state.toContentLayoutKey()
|
||||
if (contentLayoutKey == lastContentLayoutKey) return
|
||||
|
||||
lastContentLayoutKey = contentLayoutKey
|
||||
host.onCreatorChannelLiveContentChanged()
|
||||
}
|
||||
|
||||
private fun bindCurrentLive(live: CreatorChannelLiveResponse?) = with(binding) {
|
||||
layoutCreatorChannelLiveCurrentCard.isVisible = live != null
|
||||
if (live == null) return@with
|
||||
|
||||
tvCreatorChannelLiveCurrentTitle.text = live.title
|
||||
tvCreatorChannelLiveCurrentTime.text = formatCreatorChannelLiveDateTime(live.beginDateTimeUtc)
|
||||
tvCreatorChannelLiveCurrentPrice.text = if (live.price > 0) {
|
||||
live.price.moneyFormat()
|
||||
} else {
|
||||
getString(R.string.audio_content_tag_free)
|
||||
}
|
||||
layoutCreatorChannelLiveCurrentPrice.isVisible = true
|
||||
ivCreatorChannelLiveCurrentPriceCash.isVisible = live.price > 0
|
||||
layoutCreatorChannelLiveCurrentCard.setOnClickListener {
|
||||
host.onCreatorChannelCurrentLiveClicked(live)
|
||||
}
|
||||
}
|
||||
|
||||
interface Host {
|
||||
fun onCreatorChannelCurrentLiveClicked(live: CreatorChannelLiveResponse)
|
||||
fun onCreatorChannelLiveReplayClicked(audioContentId: Long)
|
||||
fun onCreatorChannelLiveContentChanged()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_CREATOR_ID: String = "arg_creator_id"
|
||||
private const val DEFAULT_LIST_BOTTOM_PADDING_DP = 32
|
||||
private const val LIVE_OWNER_CTA_LIST_BOTTOM_PADDING_DP = 132
|
||||
|
||||
fun newInstance(creatorId: Long): CreatorChannelLiveFragment {
|
||||
return CreatorChannelLiveFragment().apply {
|
||||
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class CreatorChannelLiveContentLayoutKey(
|
||||
val liveReplayContentCount: Int,
|
||||
val currentLive: CreatorChannelLiveResponse?,
|
||||
val liveReplayContentIds: List<Long>
|
||||
)
|
||||
|
||||
private fun CreatorChannelLiveUiState.Content.toContentLayoutKey(): CreatorChannelLiveContentLayoutKey {
|
||||
return CreatorChannelLiveContentLayoutKey(
|
||||
liveReplayContentCount = liveReplayContentCount,
|
||||
currentLive = currentLive,
|
||||
liveReplayContentIds = liveReplayContents.map { it.audioContentId }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.live
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.orhanobut.logger.Logger
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kr.co.vividnext.sodalive.base.BaseViewModel
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.data.CreatorChannelLiveTabResponse
|
||||
|
||||
class CreatorChannelLiveViewModel(
|
||||
private val repository: CreatorChannelRepository
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val _liveStateLiveData = MutableLiveData<CreatorChannelLiveUiState>()
|
||||
val liveStateLiveData: LiveData<CreatorChannelLiveUiState>
|
||||
get() = _liveStateLiveData
|
||||
|
||||
private var creatorId: Long = 0L
|
||||
private var selectedSort: ContentSort = ContentSort.LATEST
|
||||
private var requestGeneration: Int = 0
|
||||
|
||||
fun loadLive(creatorId: Long) {
|
||||
if (creatorId <= 0) return
|
||||
if (this.creatorId == creatorId && _liveStateLiveData.value != null) return
|
||||
|
||||
this.creatorId = creatorId
|
||||
loadFirstPage(selectedSort)
|
||||
}
|
||||
|
||||
fun changeSort(sort: ContentSort) {
|
||||
if (sort == selectedSort) return
|
||||
if (creatorId <= 0) return
|
||||
|
||||
selectedSort = sort
|
||||
loadFirstPage(sort)
|
||||
}
|
||||
|
||||
fun retryLive() {
|
||||
if (creatorId <= 0) return
|
||||
|
||||
loadFirstPage(selectedSort)
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val content = _liveStateLiveData.value as? CreatorChannelLiveUiState.Content ?: return
|
||||
if (!content.hasNext || content.isLoadingMore || creatorId <= 0) return
|
||||
|
||||
val generation = requestGeneration
|
||||
_liveStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null)
|
||||
requestLive(page = content.page + 1, sort = content.selectedSort, generation = generation) { response ->
|
||||
val data = response.data
|
||||
val current = _liveStateLiveData.value as? CreatorChannelLiveUiState.Content ?: content
|
||||
if (response.success && data != null) {
|
||||
_liveStateLiveData.value = current.copy(
|
||||
liveReplayContents = current.liveReplayContents + data.liveReplayContents,
|
||||
page = data.page,
|
||||
size = data.size,
|
||||
hasNext = data.hasNext,
|
||||
isLoadingMore = false
|
||||
)
|
||||
} else {
|
||||
_liveStateLiveData.value = current.copy(
|
||||
isLoadingMore = false,
|
||||
paginationErrorMessage = response.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consumePaginationErrorMessage() {
|
||||
val content = _liveStateLiveData.value as? CreatorChannelLiveUiState.Content ?: return
|
||||
if (content.paginationErrorMessage == null) return
|
||||
|
||||
_liveStateLiveData.value = content.copy(paginationErrorMessage = null)
|
||||
}
|
||||
|
||||
private fun loadFirstPage(sort: ContentSort) {
|
||||
val generation = ++requestGeneration
|
||||
_liveStateLiveData.value = CreatorChannelLiveUiState.Loading
|
||||
requestLive(page = FIRST_PAGE, sort = sort, generation = generation) { response ->
|
||||
val data = response.data
|
||||
if (response.success && data != null) {
|
||||
_liveStateLiveData.value = if (data.currentLive == null && data.liveReplayContents.isEmpty()) {
|
||||
CreatorChannelLiveUiState.Empty
|
||||
} else {
|
||||
data.toContentState(liveReplayContents = data.liveReplayContents)
|
||||
}
|
||||
} else {
|
||||
_liveStateLiveData.value = CreatorChannelLiveUiState.Error(response.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestLive(
|
||||
page: Int,
|
||||
sort: ContentSort,
|
||||
generation: Int,
|
||||
onSuccess: (ApiResponse<CreatorChannelLiveTabResponse>) -> Unit
|
||||
) {
|
||||
compositeDisposable.add(
|
||||
repository.getLive(
|
||||
creatorId = creatorId,
|
||||
page = page,
|
||||
size = DEFAULT_PAGE_SIZE,
|
||||
sort = sort,
|
||||
token = authToken()
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
if (generation == requestGeneration) {
|
||||
onSuccess(it)
|
||||
}
|
||||
},
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
val current = _liveStateLiveData.value as? CreatorChannelLiveUiState.Content
|
||||
_liveStateLiveData.value = if (current != null && page > FIRST_PAGE) {
|
||||
current.copy(isLoadingMore = false, paginationErrorMessage = it.message)
|
||||
} else {
|
||||
CreatorChannelLiveUiState.Error(it.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CreatorChannelLiveTabResponse.toContentState(
|
||||
liveReplayContents: List<CreatorChannelAudioContentResponse>,
|
||||
isLoadingMore: Boolean = false
|
||||
) = CreatorChannelLiveUiState.Content(
|
||||
liveReplayContentCount = liveReplayContentCount,
|
||||
currentLive = currentLive,
|
||||
liveReplayContents = liveReplayContents,
|
||||
selectedSort = sort,
|
||||
page = page,
|
||||
size = size,
|
||||
hasNext = hasNext,
|
||||
isLoadingMore = isLoadingMore
|
||||
)
|
||||
|
||||
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
|
||||
|
||||
companion object {
|
||||
val DEFAULT_PAGE_SIZE = 20
|
||||
private const val FIRST_PAGE = 0
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface CreatorChannelLiveUiState {
|
||||
data object Loading : CreatorChannelLiveUiState
|
||||
data object Empty : CreatorChannelLiveUiState
|
||||
data class Error(val message: String?) : CreatorChannelLiveUiState
|
||||
data class Content(
|
||||
val liveReplayContentCount: Int,
|
||||
val currentLive: CreatorChannelLiveResponse?,
|
||||
val liveReplayContents: List<CreatorChannelAudioContentResponse>,
|
||||
val selectedSort: ContentSort,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val paginationErrorMessage: String? = null
|
||||
) : CreatorChannelLiveUiState
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.live.data
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelLiveTabResponse(
|
||||
@SerializedName("liveReplayContentCount") val liveReplayContentCount: Int,
|
||||
@SerializedName("currentLive") val currentLive: CreatorChannelLiveResponse?,
|
||||
@SerializedName("liveReplayContents") val liveReplayContents: List<CreatorChannelAudioContentResponse>,
|
||||
@SerializedName("sort") val sort: ContentSort,
|
||||
@SerializedName("page") val page: Int,
|
||||
@SerializedName("size") val size: Int,
|
||||
@SerializedName("hasNext") val hasNext: Boolean
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.live.model
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelAudioContentStatus
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelAudioContentUiModel
|
||||
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
|
||||
|
||||
fun CreatorChannelAudioContentResponse.toReplayUiModel(): CreatorChannelAudioContentUiModel =
|
||||
CreatorChannelAudioContentUiModel(
|
||||
audioContentId = audioContentId,
|
||||
title = title,
|
||||
secondaryText = duration,
|
||||
imageUrl = imageUrl,
|
||||
price = price,
|
||||
showAdultBadge = isAdult,
|
||||
tags = toAudioContentTags(),
|
||||
status = toReplayStatus()
|
||||
)
|
||||
|
||||
private fun CreatorChannelAudioContentResponse.toAudioContentTags(): Set<AudioContentTag> = buildSet {
|
||||
if (isOriginalSeries == true) add(AudioContentTag.Original)
|
||||
if (isFirstContent) add(AudioContentTag.First)
|
||||
if (isPointAvailable) add(AudioContentTag.Point)
|
||||
if (price == 0) add(AudioContentTag.Free)
|
||||
}
|
||||
|
||||
private fun CreatorChannelAudioContentResponse.toReplayStatus(): CreatorChannelAudioContentStatus = when {
|
||||
isOwned -> CreatorChannelAudioContentStatus.Owned
|
||||
isRented -> CreatorChannelAudioContentStatus.Rented
|
||||
price == 0 -> CreatorChannelAudioContentStatus.Play
|
||||
else -> CreatorChannelAudioContentStatus.Price(price)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.model
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
|
||||
|
||||
data class CreatorChannelAudioContentUiModel(
|
||||
val audioContentId: Long,
|
||||
val title: String,
|
||||
val secondaryText: String?,
|
||||
val imageUrl: String?,
|
||||
val price: Int,
|
||||
val showAdultBadge: Boolean,
|
||||
val tags: Set<AudioContentTag>,
|
||||
val status: CreatorChannelAudioContentStatus
|
||||
)
|
||||
|
||||
sealed interface CreatorChannelAudioContentStatus {
|
||||
data object Play : CreatorChannelAudioContentStatus
|
||||
data object Owned : CreatorChannelAudioContentStatus
|
||||
data object Rented : CreatorChannelAudioContentStatus
|
||||
data class Price(val price: Int) : CreatorChannelAudioContentStatus
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.model
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelHomeResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSnsResponse
|
||||
import java.net.URI
|
||||
|
||||
fun CreatorChannelHomeResponse.toUiContent(currentMemberId: Long): CreatorChannelHomeUiState.Content {
|
||||
val isOwner = creator.creatorId == currentMemberId
|
||||
val sections = buildList {
|
||||
currentLive?.let { add(CreatorChannelHomeSection.CurrentLive(it)) }
|
||||
latestAudioContent?.let { add(CreatorChannelHomeSection.LatestAudioContent(it)) }
|
||||
add(CreatorChannelHomeSection.Donations(donations = channelDonations, isOwner = isOwner))
|
||||
notices.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Notices(it)) }
|
||||
schedules.sortedBy { it.scheduledAtUtc }.take(MAX_SCHEDULE_ITEM_COUNT)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { add(CreatorChannelHomeSection.Schedules(it)) }
|
||||
audioContents.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.AudioContents(it)) }
|
||||
series.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Series(it)) }
|
||||
communities.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Communities(it)) }
|
||||
add(CreatorChannelHomeSection.FanTalk(fanTalk))
|
||||
introduce.takeIf { it.isNotBlank() }?.let { add(CreatorChannelHomeSection.Introduce(it)) }
|
||||
add(CreatorChannelHomeSection.Activity(activity))
|
||||
sns.toUiItems().takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Sns(it)) }
|
||||
}
|
||||
|
||||
return CreatorChannelHomeUiState.Content(
|
||||
header = CreatorChannelHeaderUiModel(
|
||||
creatorId = creator.creatorId,
|
||||
characterId = creator.characterId,
|
||||
nickname = creator.nickname,
|
||||
profileImageUrl = creator.profileImageUrl,
|
||||
followerCount = creator.followerCount,
|
||||
isFollow = creator.isFollow,
|
||||
isNotify = creator.isNotify,
|
||||
isAiChatAvailable = creator.isAiChatAvailable,
|
||||
isDmAvailable = creator.isDmAvailable,
|
||||
isOwner = isOwner
|
||||
),
|
||||
tabs = CreatorChannelTab.entries,
|
||||
sections = sections
|
||||
)
|
||||
}
|
||||
|
||||
private fun CreatorChannelSnsResponse.toUiItems(): List<CreatorChannelSnsUiItem> = buildList {
|
||||
instagramUrl.toSnsItem(R.drawable.ic_sns_instagram, "Instagram")?.let(::add)
|
||||
youtubeUrl.toSnsItem(R.drawable.ic_sns_youtube, "YouTube")?.let(::add)
|
||||
xUrl.toSnsItem(R.drawable.ic_sns_x, "X")?.let(::add)
|
||||
kakaoOpenChatUrl.toSnsItem(R.drawable.ic_sns_kakao, "Kakao Open Chat")?.let(::add)
|
||||
fancimmUrl.toSnsItem(R.drawable.ic_sns_fancimm, "Fancimm")?.let(::add)
|
||||
}
|
||||
|
||||
private fun String.toSnsItem(
|
||||
@DrawableRes iconResId: Int,
|
||||
label: String
|
||||
): CreatorChannelSnsUiItem? = trim().takeIf(::isValidCreatorChannelSnsUrl)?.let {
|
||||
CreatorChannelSnsUiItem(iconResId = iconResId, label = label, url = it)
|
||||
}
|
||||
|
||||
internal fun isValidCreatorChannelSnsUrl(url: String): Boolean {
|
||||
val uri = runCatching { URI(url) }.getOrNull() ?: return false
|
||||
val scheme = uri.scheme?.lowercase() ?: return false
|
||||
return scheme in setOf("http", "https") && !uri.host.isNullOrBlank()
|
||||
}
|
||||
|
||||
private const val MAX_SCHEDULE_ITEM_COUNT = 3
|
||||
@@ -0,0 +1,71 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.model
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelActivityResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelCommunityPostResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelDonationResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelFanTalkSummaryResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse
|
||||
|
||||
sealed interface CreatorChannelHomeUiState {
|
||||
data object Loading : CreatorChannelHomeUiState
|
||||
data object Empty : CreatorChannelHomeUiState
|
||||
data class Error(val message: String?) : CreatorChannelHomeUiState
|
||||
data class Content(
|
||||
val header: CreatorChannelHeaderUiModel,
|
||||
val tabs: List<CreatorChannelTab>,
|
||||
val sections: List<CreatorChannelHomeSection>
|
||||
) : CreatorChannelHomeUiState
|
||||
}
|
||||
|
||||
enum class CreatorChannelTab(@StringRes val labelResId: Int) {
|
||||
Home(R.string.creator_channel_tab_home),
|
||||
Live(R.string.creator_channel_tab_live),
|
||||
Audio(R.string.creator_channel_tab_audio),
|
||||
Series(R.string.creator_channel_tab_series),
|
||||
Community(R.string.creator_channel_tab_community),
|
||||
FanTalk(R.string.creator_channel_tab_fantalk),
|
||||
Donation(R.string.creator_channel_tab_donation)
|
||||
}
|
||||
|
||||
data class CreatorChannelHeaderUiModel(
|
||||
val creatorId: Long,
|
||||
val characterId: Long?,
|
||||
val nickname: String,
|
||||
val profileImageUrl: String,
|
||||
val followerCount: Int,
|
||||
val isFollow: Boolean,
|
||||
val isNotify: Boolean,
|
||||
val isAiChatAvailable: Boolean,
|
||||
val isDmAvailable: Boolean,
|
||||
val isOwner: Boolean
|
||||
)
|
||||
|
||||
sealed interface CreatorChannelHomeSection {
|
||||
data class CurrentLive(val live: CreatorChannelLiveResponse) : CreatorChannelHomeSection
|
||||
data class LatestAudioContent(val audioContent: CreatorChannelAudioContentResponse) : CreatorChannelHomeSection
|
||||
data class Donations(
|
||||
val donations: List<CreatorChannelDonationResponse>,
|
||||
val isOwner: Boolean
|
||||
) : CreatorChannelHomeSection
|
||||
data class Notices(val notices: List<CreatorChannelCommunityPostResponse>) : CreatorChannelHomeSection
|
||||
data class Schedules(val schedules: List<CreatorChannelScheduleResponse>) : CreatorChannelHomeSection
|
||||
data class AudioContents(val audioContents: List<CreatorChannelAudioContentResponse>) : CreatorChannelHomeSection
|
||||
data class Series(val series: List<CreatorChannelSeriesResponse>) : CreatorChannelHomeSection
|
||||
data class Communities(val communities: List<CreatorChannelCommunityPostResponse>) : CreatorChannelHomeSection
|
||||
data class FanTalk(val fanTalk: CreatorChannelFanTalkSummaryResponse) : CreatorChannelHomeSection
|
||||
data class Introduce(val introduce: String) : CreatorChannelHomeSection
|
||||
data class Activity(val activity: CreatorChannelActivityResponse) : CreatorChannelHomeSection
|
||||
data class Sns(val items: List<CreatorChannelSnsUiItem>) : CreatorChannelHomeSection
|
||||
}
|
||||
|
||||
data class CreatorChannelSnsUiItem(
|
||||
@DrawableRes val iconResId: Int,
|
||||
val label: String,
|
||||
val url: String
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.model
|
||||
|
||||
object CreatorChannelScrollState {
|
||||
|
||||
fun shouldUseBlackTitleBar(
|
||||
titleBarBottom: Int,
|
||||
tabBarTop: Int,
|
||||
profileImageVisibleHeight: Int,
|
||||
profileImageTotalHeight: Int
|
||||
): Boolean {
|
||||
val isTabBarCloseToTitleBar = tabBarTop <= titleBarBottom
|
||||
val isProfileImageHalfHidden = profileImageVisibleHeight <= profileImageTotalHeight / 2
|
||||
return isTabBarCloseToTitleBar && isProfileImageHalfHidden
|
||||
}
|
||||
|
||||
fun calculateStickyTop(statusBarHeight: Int, titleBarHeight: Int): Int {
|
||||
return statusBarHeight + titleBarHeight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.model
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
|
||||
|
||||
data class CreatorChannelSortOptionUiModel(
|
||||
val sort: ContentSort,
|
||||
@param:StringRes val labelResId: Int,
|
||||
val isSelected: Boolean
|
||||
)
|
||||
|
||||
@StringRes
|
||||
fun ContentSort.toLabelResId(): Int = when (this) {
|
||||
ContentSort.LATEST -> R.string.screen_audio_content_sort_newest
|
||||
ContentSort.POPULAR -> R.string.screen_audio_content_sort_popularity
|
||||
ContentSort.OWNED -> R.string.creator_channel_live_sort_owned
|
||||
ContentSort.PRICE_HIGH -> R.string.screen_audio_content_sort_price_high
|
||||
ContentSort.PRICE_LOW -> R.string.screen_audio_content_sort_price_low
|
||||
}
|
||||
|
||||
fun ContentSort.toSortOptionUiModel(selectedSort: ContentSort): CreatorChannelSortOptionUiModel =
|
||||
CreatorChannelSortOptionUiModel(
|
||||
sort = this,
|
||||
labelResId = toLabelResId(),
|
||||
isSelected = this == selectedSort
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.model
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import kr.co.vividnext.sodalive.R
|
||||
|
||||
data class CreatorChannelTitleBarState(
|
||||
@DrawableRes val followIconResId: Int,
|
||||
@DrawableRes val bellIconResId: Int?,
|
||||
val isActionEnabled: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun from(
|
||||
isFollow: Boolean,
|
||||
isNotify: Boolean,
|
||||
isInProgress: Boolean
|
||||
): CreatorChannelTitleBarState = CreatorChannelTitleBarState(
|
||||
followIconResId = if (isFollow) R.drawable.ic_new_following else R.drawable.ic_new_follow,
|
||||
bellIconResId = when {
|
||||
!isFollow -> null
|
||||
isNotify -> R.drawable.ic_bar_bell_colored
|
||||
else -> R.drawable.ic_bar_bell
|
||||
},
|
||||
isActionEnabled = !isInProgress
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.series
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.base.BaseFragment
|
||||
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelSeriesBinding
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.toLabelResId
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.ui.CreatorChannelSeriesAdapter
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelSortPopup
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
class CreatorChannelSeriesFragment : BaseFragment<FragmentCreatorChannelSeriesBinding>(
|
||||
FragmentCreatorChannelSeriesBinding::inflate
|
||||
) {
|
||||
|
||||
private val viewModel: CreatorChannelSeriesViewModel by viewModel()
|
||||
private val seriesAdapter = CreatorChannelSeriesAdapter { seriesId ->
|
||||
host.onCreatorChannelSeriesClicked(seriesId)
|
||||
}
|
||||
private var sortPopup: CreatorChannelSortPopup? = null
|
||||
private var currentContentState: CreatorChannelSeriesUiState.Content? = null
|
||||
private var lastContentLayoutKey: CreatorChannelSeriesContentLayoutKey? = null
|
||||
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
|
||||
private val host: Host
|
||||
get() = requireActivity() as Host
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
bindLoading()
|
||||
setupSeriesList()
|
||||
setupClickListeners()
|
||||
observeViewModel()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
sortPopup?.dismiss()
|
||||
sortPopup = null
|
||||
currentContentState = null
|
||||
lastContentLayoutKey = null
|
||||
binding.rvCreatorChannelSeries.adapter = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun setupSeriesList() = with(binding.rvCreatorChannelSeries) {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
adapter = seriesAdapter
|
||||
}
|
||||
|
||||
private fun setupClickListeners() = with(binding) {
|
||||
ivCreatorChannelSeriesSort.setImageResource(R.drawable.ic_new_sort)
|
||||
layoutCreatorChannelSeriesSortButton.setOnClickListener {
|
||||
currentContentState?.let { state -> showSortPopup(state) }
|
||||
}
|
||||
btnCreatorChannelSeriesRetry.setOnClickListener {
|
||||
viewModel.retrySeries()
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
viewModel.seriesStateLiveData.observe(viewLifecycleOwner) { state ->
|
||||
when (state) {
|
||||
CreatorChannelSeriesUiState.Loading -> bindLoading()
|
||||
CreatorChannelSeriesUiState.Empty -> bindEmpty()
|
||||
is CreatorChannelSeriesUiState.Error -> bindError(state)
|
||||
is CreatorChannelSeriesUiState.Content -> bindContent(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onCreatorChannelSeriesTabSelected() {
|
||||
if (creatorId > 0L) {
|
||||
viewModel.loadSeries(creatorId, isOwner = host.isCreatorChannelOwner())
|
||||
}
|
||||
}
|
||||
|
||||
fun onCreatorChannelSeriesScrolledToBottom() {
|
||||
viewModel.loadMore()
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun onCreatorChannelSeriesViewportHeightChanged(minHeight: Int) = Unit
|
||||
|
||||
private fun bindLoading() = with(binding) {
|
||||
currentContentState = null
|
||||
lastContentLayoutKey = null
|
||||
layoutCreatorChannelSeriesSortBar.isVisible = false
|
||||
rvCreatorChannelSeries.isVisible = false
|
||||
layoutCreatorChannelSeriesEmpty.isVisible = false
|
||||
tvCreatorChannelSeriesErrorMessage.isVisible = false
|
||||
btnCreatorChannelSeriesRetry.isVisible = false
|
||||
}
|
||||
|
||||
private fun bindEmpty() = with(binding) {
|
||||
currentContentState = null
|
||||
lastContentLayoutKey = null
|
||||
layoutCreatorChannelSeriesSortBar.isVisible = false
|
||||
rvCreatorChannelSeries.isVisible = false
|
||||
layoutCreatorChannelSeriesEmpty.isVisible = true
|
||||
tvCreatorChannelSeriesErrorMessage.isVisible = false
|
||||
btnCreatorChannelSeriesRetry.isVisible = false
|
||||
host.onCreatorChannelSeriesContentChanged()
|
||||
}
|
||||
|
||||
private fun bindError(state: CreatorChannelSeriesUiState.Error) = with(binding) {
|
||||
currentContentState = null
|
||||
lastContentLayoutKey = null
|
||||
layoutCreatorChannelSeriesSortBar.isVisible = false
|
||||
rvCreatorChannelSeries.isVisible = false
|
||||
layoutCreatorChannelSeriesEmpty.isVisible = false
|
||||
tvCreatorChannelSeriesErrorMessage.isVisible = true
|
||||
tvCreatorChannelSeriesErrorMessage.text = state.message ?: getString(R.string.creator_channel_series_error_message)
|
||||
btnCreatorChannelSeriesRetry.isVisible = true
|
||||
host.onCreatorChannelSeriesContentChanged()
|
||||
}
|
||||
|
||||
private fun bindContent(state: CreatorChannelSeriesUiState.Content) = with(binding) {
|
||||
currentContentState = state
|
||||
layoutCreatorChannelSeriesSortBar.isVisible = true
|
||||
tvCreatorChannelSeriesTotalCount.text = state.seriesCount.moneyFormat()
|
||||
tvCreatorChannelSeriesSortLabel.setText(state.selectedSort.toLabelResId())
|
||||
rvCreatorChannelSeries.isVisible = true
|
||||
seriesAdapter.submitItems(state.series)
|
||||
layoutCreatorChannelSeriesEmpty.isVisible = false
|
||||
tvCreatorChannelSeriesErrorMessage.isVisible = false
|
||||
btnCreatorChannelSeriesRetry.isVisible = false
|
||||
notifyContentChangedIfLayoutChanged(state)
|
||||
state.paginationErrorMessage?.let {
|
||||
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
|
||||
viewModel.consumePaginationErrorMessage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelSeriesUiState.Content) {
|
||||
val contentLayoutKey = state.toContentLayoutKey()
|
||||
if (contentLayoutKey == lastContentLayoutKey) return
|
||||
|
||||
lastContentLayoutKey = contentLayoutKey
|
||||
host.onCreatorChannelSeriesContentChanged()
|
||||
}
|
||||
|
||||
private fun showSortPopup(state: CreatorChannelSeriesUiState.Content) {
|
||||
sortPopup?.dismiss()
|
||||
sortPopup = CreatorChannelSortPopup(
|
||||
anchor = binding.layoutCreatorChannelSeriesSortButton,
|
||||
selectedSort = state.selectedSort,
|
||||
onSortSelected = { sort -> viewModel.changeSort(sort) }
|
||||
).also { it.show() }
|
||||
}
|
||||
|
||||
interface Host {
|
||||
fun isCreatorChannelOwner(): Boolean
|
||||
fun onCreatorChannelSeriesClicked(seriesId: Long)
|
||||
fun onCreatorChannelSeriesContentChanged()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_CREATOR_ID: String = "arg_creator_id"
|
||||
|
||||
fun newInstance(creatorId: Long): CreatorChannelSeriesFragment {
|
||||
return CreatorChannelSeriesFragment().apply {
|
||||
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class CreatorChannelSeriesContentLayoutKey(
|
||||
val seriesCount: Int,
|
||||
val seriesIds: List<Long>
|
||||
)
|
||||
|
||||
private fun CreatorChannelSeriesUiState.Content.toContentLayoutKey(): CreatorChannelSeriesContentLayoutKey {
|
||||
return CreatorChannelSeriesContentLayoutKey(
|
||||
seriesCount = seriesCount,
|
||||
seriesIds = series.map { it.seriesId }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.series
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.orhanobut.logger.Logger
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kr.co.vividnext.sodalive.base.BaseViewModel
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.data.CreatorChannelSeriesTabResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.model.CreatorChannelSeriesItemUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.model.toSeriesItemUiModels
|
||||
|
||||
class CreatorChannelSeriesViewModel(
|
||||
private val repository: CreatorChannelRepository
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val _seriesStateLiveData = MutableLiveData<CreatorChannelSeriesUiState>()
|
||||
val seriesStateLiveData: LiveData<CreatorChannelSeriesUiState>
|
||||
get() = _seriesStateLiveData
|
||||
|
||||
private var creatorId: Long = 0L
|
||||
private var isOwner: Boolean = false
|
||||
private var selectedSort: ContentSort = ContentSort.LATEST
|
||||
private var requestGeneration: Int = 0
|
||||
|
||||
fun loadSeries(creatorId: Long, isOwner: Boolean) {
|
||||
if (creatorId <= 0) return
|
||||
val shouldSkipReload = this.creatorId == creatorId && this.isOwner == isOwner && _seriesStateLiveData.value != null
|
||||
if (shouldSkipReload) return
|
||||
|
||||
this.creatorId = creatorId
|
||||
this.isOwner = isOwner
|
||||
loadFirstPage(selectedSort)
|
||||
}
|
||||
|
||||
fun changeSort(sort: ContentSort) {
|
||||
if (sort == selectedSort) return
|
||||
if (creatorId <= 0) return
|
||||
|
||||
selectedSort = sort
|
||||
loadFirstPage(sort)
|
||||
}
|
||||
|
||||
fun retrySeries() {
|
||||
if (creatorId <= 0) return
|
||||
|
||||
loadFirstPage(selectedSort)
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val content = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content ?: return
|
||||
if (!content.hasNext || content.isLoadingMore || creatorId <= 0) return
|
||||
|
||||
val generation = requestGeneration
|
||||
_seriesStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null)
|
||||
requestSeries(page = content.page + 1, sort = content.selectedSort, generation = generation) { response ->
|
||||
val data = response.data
|
||||
val current = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content ?: content
|
||||
if (response.success && data != null) {
|
||||
_seriesStateLiveData.value = current.copy(
|
||||
series = current.series + data.series.toSeriesItemUiModels(isOwner),
|
||||
page = data.page,
|
||||
size = data.size,
|
||||
hasNext = data.hasNext,
|
||||
isLoadingMore = false
|
||||
)
|
||||
} else {
|
||||
_seriesStateLiveData.value = current.copy(
|
||||
isLoadingMore = false,
|
||||
paginationErrorMessage = response.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consumePaginationErrorMessage() {
|
||||
val content = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content ?: return
|
||||
if (content.paginationErrorMessage == null) return
|
||||
|
||||
_seriesStateLiveData.value = content.copy(paginationErrorMessage = null)
|
||||
}
|
||||
|
||||
private fun loadFirstPage(sort: ContentSort) {
|
||||
val generation = ++requestGeneration
|
||||
_seriesStateLiveData.value = CreatorChannelSeriesUiState.Loading
|
||||
requestSeries(page = FIRST_PAGE, sort = sort, generation = generation) { response ->
|
||||
val data = response.data
|
||||
if (response.success && data != null) {
|
||||
val series = data.series.toSeriesItemUiModels(isOwner)
|
||||
_seriesStateLiveData.value = if (series.isEmpty() || data.seriesCount == 0) {
|
||||
CreatorChannelSeriesUiState.Empty
|
||||
} else {
|
||||
data.toContentState(series = series)
|
||||
}
|
||||
} else {
|
||||
_seriesStateLiveData.value = CreatorChannelSeriesUiState.Error(response.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestSeries(
|
||||
page: Int,
|
||||
sort: ContentSort,
|
||||
generation: Int,
|
||||
onSuccess: (ApiResponse<CreatorChannelSeriesTabResponse>) -> Unit
|
||||
) {
|
||||
compositeDisposable.add(
|
||||
repository.getSeries(
|
||||
creatorId = creatorId,
|
||||
page = page,
|
||||
size = DEFAULT_PAGE_SIZE,
|
||||
sort = sort,
|
||||
token = authToken()
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
if (generation == requestGeneration) {
|
||||
onSuccess(it)
|
||||
}
|
||||
},
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
val current = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content
|
||||
_seriesStateLiveData.value = if (current != null && page > FIRST_PAGE) {
|
||||
current.copy(isLoadingMore = false, paginationErrorMessage = it.message)
|
||||
} else {
|
||||
CreatorChannelSeriesUiState.Error(it.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CreatorChannelSeriesTabResponse.toContentState(
|
||||
series: List<CreatorChannelSeriesItemUiModel>
|
||||
) = CreatorChannelSeriesUiState.Content(
|
||||
seriesCount = seriesCount,
|
||||
selectedSort = sort,
|
||||
series = series,
|
||||
page = page,
|
||||
size = size,
|
||||
hasNext = hasNext
|
||||
)
|
||||
|
||||
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
|
||||
|
||||
companion object {
|
||||
val DEFAULT_PAGE_SIZE = 20
|
||||
private const val FIRST_PAGE = 0
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface CreatorChannelSeriesUiState {
|
||||
data object Loading : CreatorChannelSeriesUiState
|
||||
data object Empty : CreatorChannelSeriesUiState
|
||||
data class Error(val message: String?) : CreatorChannelSeriesUiState
|
||||
data class Content(
|
||||
val seriesCount: Int,
|
||||
val selectedSort: ContentSort,
|
||||
val series: List<CreatorChannelSeriesItemUiModel>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val paginationErrorMessage: String? = null
|
||||
) : CreatorChannelSeriesUiState
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.series.data
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelSeriesTabResponse(
|
||||
@SerializedName("seriesCount") val seriesCount: Int,
|
||||
@SerializedName("series") val series: List<CreatorChannelSeriesResponse>,
|
||||
@SerializedName("sort") val sort: ContentSort,
|
||||
@SerializedName("page") val page: Int,
|
||||
@SerializedName("size") val size: Int,
|
||||
@SerializedName("hasNext") val hasNext: Boolean
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class CreatorChannelSeriesResponse(
|
||||
@SerializedName("seriesId") val seriesId: Long,
|
||||
@SerializedName("title") val title: String,
|
||||
@SerializedName("coverImageUrl") val coverImageUrl: String?,
|
||||
@SerializedName("publishedDaysOfWeek") val publishedDaysOfWeek: String?,
|
||||
@SerializedName("contentCount") val contentCount: Int,
|
||||
@SerializedName("isProceeding") val isProceeding: Boolean,
|
||||
@SerializedName("isOriginal") val isOriginal: Boolean,
|
||||
@SerializedName("isAdult") val isAdult: Boolean,
|
||||
@SerializedName("purchasedContentCount") val purchasedContentCount: Int?,
|
||||
@SerializedName("paidContentCount") val paidContentCount: Int?,
|
||||
@SerializedName("purchasedPaidContentRate") val purchasedPaidContentRate: Double?
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.series.model
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.data.CreatorChannelSeriesResponse
|
||||
|
||||
fun List<CreatorChannelSeriesResponse>.toSeriesItemUiModels(
|
||||
isOwner: Boolean
|
||||
): List<CreatorChannelSeriesItemUiModel> = mapNotNull { it.toSeriesItemUiModel(isOwner) }
|
||||
|
||||
private fun CreatorChannelSeriesResponse.toSeriesItemUiModel(isOwner: Boolean): CreatorChannelSeriesItemUiModel? {
|
||||
if (title.isBlank()) return null
|
||||
|
||||
return CreatorChannelSeriesItemUiModel(
|
||||
seriesId = seriesId,
|
||||
title = title,
|
||||
subtitle = toSubtitleUiModel(),
|
||||
coverImageUrl = coverImageUrl,
|
||||
showOriginalTag = isOriginal,
|
||||
showAdultBadge = isAdult,
|
||||
progress = toProgressUiModel(isOwner)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CreatorChannelSeriesResponse.toSubtitleUiModel(): CreatorChannelSeriesSubtitleUiModel {
|
||||
return CreatorChannelSeriesSubtitleUiModel(
|
||||
publishedDaysOfWeek = publishedDaysOfWeek?.takeIf { it.isNotBlank() },
|
||||
contentCount = contentCount,
|
||||
isProceeding = isProceeding
|
||||
)
|
||||
}
|
||||
|
||||
private fun CreatorChannelSeriesResponse.toProgressUiModel(isOwner: Boolean): CreatorChannelSeriesProgressUiModel? {
|
||||
if (isOwner) return null
|
||||
val purchasedCount = purchasedContentCount ?: return null
|
||||
val paidCount = paidContentCount ?: return null
|
||||
val ratePercent = purchasedPaidContentRate ?: return null
|
||||
|
||||
return CreatorChannelSeriesProgressUiModel(
|
||||
purchasedCount = purchasedCount,
|
||||
paidCount = paidCount,
|
||||
ratePercent = ratePercent,
|
||||
progressScale = (ratePercent / 100f).toFloat().coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.series.model
|
||||
|
||||
data class CreatorChannelSeriesItemUiModel(
|
||||
val seriesId: Long,
|
||||
val title: String,
|
||||
val subtitle: CreatorChannelSeriesSubtitleUiModel,
|
||||
val coverImageUrl: String?,
|
||||
val showOriginalTag: Boolean,
|
||||
val showAdultBadge: Boolean,
|
||||
val progress: CreatorChannelSeriesProgressUiModel?
|
||||
)
|
||||
|
||||
data class CreatorChannelSeriesSubtitleUiModel(
|
||||
val publishedDaysOfWeek: String?,
|
||||
val contentCount: Int,
|
||||
val isProceeding: Boolean
|
||||
)
|
||||
|
||||
data class CreatorChannelSeriesProgressUiModel(
|
||||
val purchasedCount: Int,
|
||||
val paidCount: Int,
|
||||
val ratePercent: Double,
|
||||
val progressScale: Float
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.series.ui
|
||||
|
||||
import android.graphics.Outline
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewOutlineProvider
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelSeriesBinding
|
||||
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.model.CreatorChannelSeriesItemUiModel
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.series.model.CreatorChannelSeriesSubtitleUiModel
|
||||
|
||||
class CreatorChannelSeriesAdapter(
|
||||
private val onSeriesClicked: (Long) -> Unit = {}
|
||||
) : RecyclerView.Adapter<CreatorChannelSeriesAdapter.ViewHolder>() {
|
||||
|
||||
private var items: List<CreatorChannelSeriesItemUiModel> = emptyList()
|
||||
|
||||
fun submitItems(items: List<CreatorChannelSeriesItemUiModel>) {
|
||||
this.items = items
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return ViewHolder(
|
||||
ItemCreatorChannelSeriesBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
onSeriesClicked
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
class ViewHolder(
|
||||
private val binding: ItemCreatorChannelSeriesBinding,
|
||||
private val onSeriesClicked: (Long) -> Unit
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
init {
|
||||
binding.layoutCreatorChannelSeriesThumbnail.clipToOutline = true
|
||||
binding.layoutCreatorChannelSeriesThumbnail.outlineProvider = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(
|
||||
0,
|
||||
0,
|
||||
view.width,
|
||||
view.height,
|
||||
view.resources.getDimension(R.dimen.radius_14)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(item: CreatorChannelSeriesItemUiModel) = with(binding) {
|
||||
ivCreatorChannelSeriesThumbnail.loadUrl(item.coverImageUrl)
|
||||
layoutCreatorChannelSeriesOriginalTag.isVisible = item.showOriginalTag
|
||||
ivCreatorChannelSeriesAdultBadge.isVisible = item.showAdultBadge
|
||||
tvCreatorChannelSeriesTitle.text = item.title
|
||||
tvCreatorChannelSeriesSubtitle.text = formatSubtitle(item.subtitle)
|
||||
bindProgress(item)
|
||||
root.setOnClickListener { onSeriesClicked(item.seriesId) }
|
||||
}
|
||||
|
||||
private fun formatSubtitle(subtitle: CreatorChannelSeriesSubtitleUiModel): String {
|
||||
return listOfNotNull(
|
||||
subtitle.publishedDaysOfWeek,
|
||||
binding.root.context.getString(
|
||||
R.string.creator_channel_series_subtitle_content_count,
|
||||
subtitle.contentCount.moneyFormat()
|
||||
),
|
||||
binding.root.context.getString(
|
||||
if (subtitle.isProceeding) {
|
||||
R.string.creator_channel_series_status_proceeding
|
||||
} else {
|
||||
R.string.creator_channel_series_status_completed
|
||||
}
|
||||
)
|
||||
).joinToString(BULLET_SEPARATOR)
|
||||
}
|
||||
|
||||
private fun bindProgress(item: CreatorChannelSeriesItemUiModel) = with(binding) {
|
||||
val progress = item.progress
|
||||
layoutCreatorChannelSeriesProgress.isVisible = progress != null
|
||||
if (progress == null) return@with
|
||||
|
||||
tvCreatorChannelSeriesProgressCount.text = root.context.getString(
|
||||
R.string.creator_channel_series_progress_count,
|
||||
progress.purchasedCount.moneyFormat(),
|
||||
progress.paidCount.moneyFormat()
|
||||
)
|
||||
tvCreatorChannelSeriesProgressPercent.text = root.context.getString(
|
||||
R.string.creator_channel_series_progress_percent,
|
||||
progress.ratePercent.toInt().moneyFormat()
|
||||
)
|
||||
viewCreatorChannelSeriesProgressFill.pivotX = 0f
|
||||
viewCreatorChannelSeriesProgressFill.scaleX = progress.progressScale
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BULLET_SEPARATOR = " • "
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.ui
|
||||
|
||||
import android.graphics.Outline
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewOutlineProvider
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelAudioContentBinding
|
||||
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelAudioContentStatus
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelAudioContentUiModel
|
||||
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
|
||||
|
||||
class CreatorChannelAudioContentAdapter(
|
||||
private val onAudioContentClick: (CreatorChannelAudioContentUiModel) -> Unit = {}
|
||||
) : RecyclerView.Adapter<CreatorChannelAudioContentAdapter.ViewHolder>() {
|
||||
|
||||
private var items: List<CreatorChannelAudioContentUiModel> = emptyList()
|
||||
|
||||
fun submitItems(items: List<CreatorChannelAudioContentUiModel>) {
|
||||
this.items = items
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
return ViewHolder(
|
||||
ItemCreatorChannelAudioContentBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
onAudioContentClick
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
class ViewHolder(
|
||||
private val binding: ItemCreatorChannelAudioContentBinding,
|
||||
private val onAudioContentClick: (CreatorChannelAudioContentUiModel) -> Unit
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
init {
|
||||
binding.layoutCreatorChannelAudioContentThumbnail.clipToOutline = true
|
||||
binding.layoutCreatorChannelAudioContentThumbnail.outlineProvider = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(
|
||||
0,
|
||||
0,
|
||||
view.width,
|
||||
view.height,
|
||||
view.resources.getDimension(R.dimen.radius_14)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(item: CreatorChannelAudioContentUiModel) = with(binding) {
|
||||
ivCreatorChannelAudioContentThumbnail.loadUrl(item.imageUrl)
|
||||
tvCreatorChannelAudioContentTitle.text = item.title
|
||||
tvCreatorChannelAudioContentSecondaryText.text = item.secondaryText.orEmpty()
|
||||
tvCreatorChannelAudioContentSecondaryText.isVisible = !item.secondaryText.isNullOrBlank()
|
||||
ivCreatorChannelAudioContentAdultBadge.setImageResource(R.drawable.ic_new_shield_small)
|
||||
ivCreatorChannelAudioContentAdultBadge.isVisible = item.showAdultBadge
|
||||
bindTag(ivCreatorChannelAudioContentOriginalTag, AudioContentTag.Original, item.tags)
|
||||
bindTag(ivCreatorChannelAudioContentFirstTag, AudioContentTag.First, item.tags)
|
||||
bindTag(ivCreatorChannelAudioContentPointTag, AudioContentTag.Point, item.tags)
|
||||
tvCreatorChannelAudioContentFreeTag.isVisible = AudioContentTag.Free in item.tags
|
||||
bindStatus(item.status)
|
||||
root.setOnClickListener { onAudioContentClick(item) }
|
||||
}
|
||||
|
||||
private fun bindTag(view: View, tag: AudioContentTag, tags: Set<AudioContentTag>) {
|
||||
view.isVisible = tag in tags
|
||||
}
|
||||
|
||||
private fun bindStatus(status: CreatorChannelAudioContentStatus) = with(binding) {
|
||||
ivCreatorChannelAudioContentPlay.setImageResource(R.drawable.ic_new_player_play)
|
||||
when (status) {
|
||||
CreatorChannelAudioContentStatus.Play -> {
|
||||
ivCreatorChannelAudioContentPlay.isVisible = true
|
||||
ivCreatorChannelAudioContentCan.isVisible = false
|
||||
layoutCreatorChannelAudioContentActionText.isVisible = false
|
||||
}
|
||||
CreatorChannelAudioContentStatus.Owned -> bindTextStatus(R.string.audio_content_badge_owned)
|
||||
CreatorChannelAudioContentStatus.Rented -> bindTextStatus(R.string.audio_content_badge_rented)
|
||||
is CreatorChannelAudioContentStatus.Price -> {
|
||||
ivCreatorChannelAudioContentPlay.isVisible = false
|
||||
layoutCreatorChannelAudioContentActionText.isVisible = true
|
||||
ivCreatorChannelAudioContentCan.isVisible = true
|
||||
tvCreatorChannelAudioContentActionText.text = status.price.moneyFormat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindTextStatus(textResId: Int) = with(binding) {
|
||||
ivCreatorChannelAudioContentPlay.isVisible = true
|
||||
layoutCreatorChannelAudioContentActionText.isVisible = true
|
||||
ivCreatorChannelAudioContentCan.isVisible = false
|
||||
tvCreatorChannelAudioContentActionText.setText(textResId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Outline
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewOutlineProvider
|
||||
import android.widget.LinearLayout
|
||||
import kr.co.vividnext.sodalive.R
|
||||
|
||||
class CreatorChannelDonationCardView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : LinearLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
init {
|
||||
clipToOutline = true
|
||||
outlineProvider = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Outline
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewOutlineProvider
|
||||
import android.widget.LinearLayout
|
||||
import kr.co.vividnext.sodalive.R
|
||||
|
||||
class CreatorChannelFanTalkCardView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : LinearLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
init {
|
||||
clipToOutline = true
|
||||
outlineProvider = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Outline
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewOutlineProvider
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
|
||||
|
||||
class CreatorChannelHomeAudioContentCardView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val thumbnailContainer: View by lazy { findViewById(R.id.layout_audio_content_thumbnail) }
|
||||
private val thumbnail: ImageView by lazy { findViewById(R.id.iv_audio_content_thumbnail) }
|
||||
private val originalTag: ImageView by lazy { findViewById(R.id.iv_audio_content_original_tag) }
|
||||
private val pointTag: ImageView by lazy { findViewById(R.id.iv_audio_content_point_tag) }
|
||||
private val firstTag: ImageView by lazy { findViewById(R.id.iv_audio_content_first_tag) }
|
||||
private val freeTag: TextView by lazy { findViewById(R.id.tv_audio_content_free_tag) }
|
||||
private val title: TextView by lazy { findViewById(R.id.tv_audio_content_title) }
|
||||
private val secondary: TextView by lazy { findViewById(R.id.tv_audio_content_secondary) }
|
||||
|
||||
override fun onFinishInflate() {
|
||||
super.onFinishInflate()
|
||||
thumbnailContainer.clipToOutline = true
|
||||
thumbnailContainer.outlineProvider = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(audioContent: CreatorChannelAudioContentResponse) {
|
||||
title.text = audioContent.title
|
||||
secondary.text = listOfNotNull(audioContent.duration, audioContent.seriesName).joinToCreatorChannelAudioText()
|
||||
thumbnail.loadUrl(audioContent.imageUrl)
|
||||
originalTag.isVisible = audioContent.isOriginalSeries == true
|
||||
pointTag.isVisible = audioContent.isPointAvailable
|
||||
firstTag.isVisible = audioContent.isFirstContent
|
||||
freeTag.isVisible = audioContent.price <= 0
|
||||
}
|
||||
|
||||
private fun List<String?>.joinToCreatorChannelAudioText(): String =
|
||||
filterNot { it.isNullOrBlank() }.joinToString(separator = " • ") { it.orEmpty() }
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import coil.transform.CircleCropTransformation
|
||||
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
|
||||
import kr.co.vividnext.sodalive.common.image.BlurTransformation
|
||||
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelCommunityPostResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeSection
|
||||
import kr.co.vividnext.sodalive.v2.widget.feed.FeedCommunityView
|
||||
import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem
|
||||
import kr.co.vividnext.sodalive.v2.widget.feed.FeedSize
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class CreatorChannelHomeSectionAdapter(
|
||||
private val onLiveClick: (CreatorChannelLiveResponse) -> Unit = {},
|
||||
private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit = {},
|
||||
private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit = {},
|
||||
private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit = {},
|
||||
private val onDonationClick: () -> Unit = {}
|
||||
) : RecyclerView.Adapter<CreatorChannelHomeSectionAdapter.SectionViewHolder>() {
|
||||
|
||||
private var items: List<CreatorChannelHomeSection> = emptyList()
|
||||
|
||||
fun submitItems(items: List<CreatorChannelHomeSection>) {
|
||||
this.items = items
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int = items[position].layoutResId
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionViewHolder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
|
||||
return SectionViewHolder(view, onLiveClick, onScheduleClick, onAudioContentClick, onSeriesClick, onDonationClick)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: SectionViewHolder, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
class SectionViewHolder(
|
||||
view: View,
|
||||
private val onLiveClick: (CreatorChannelLiveResponse) -> Unit,
|
||||
private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit,
|
||||
private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit,
|
||||
private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit,
|
||||
private val onDonationClick: () -> Unit
|
||||
) : RecyclerView.ViewHolder(view) {
|
||||
private val title: TextView? = view.findViewById(R.id.tv_section_title)
|
||||
private val currentLiveTitle: TextView? = view.findViewById(R.id.tv_current_live_title)
|
||||
private val currentLiveStartTime: TextView? = view.findViewById(R.id.tv_current_live_start_time)
|
||||
private val currentLivePrice: TextView? = view.findViewById(R.id.tv_current_live_price)
|
||||
private val currentLiveAdult: TextView? = view.findViewById(R.id.tv_current_live_adult)
|
||||
private val currentLivePriceLayout: View? = view.findViewById(R.id.layout_current_live_price)
|
||||
private val currentLiveCard: View? = view.findViewById(R.id.layout_current_live_card)
|
||||
private val latestAudioThumbnail: ImageView? = view.findViewById(R.id.iv_latest_audio_thumbnail)
|
||||
private val latestAudioPointTag: ImageView? = view.findViewById(R.id.iv_latest_audio_point_tag)
|
||||
private val latestAudioTitle: TextView? = view.findViewById(R.id.tv_latest_audio_title)
|
||||
private val latestAudioDuration: TextView? = view.findViewById(R.id.tv_latest_audio_duration)
|
||||
private val donationItemsScrollView: View? = view.findViewById(R.id.hsv_donation_items)
|
||||
private val donationItems: LinearLayout? = view.findViewById(R.id.ll_donation_items)
|
||||
private val donationEmpty: View? = view.findViewById(R.id.layout_donation_empty)
|
||||
private val donationEmptyButton: View? = view.findViewById(R.id.layout_donation_empty_button)
|
||||
private val donationButton: View? = view.findViewById(R.id.layout_donation_button)
|
||||
private val donationEmptyTitle: TextView? = view.findViewById(R.id.tv_donation_empty_title)
|
||||
private val noticeItems: LinearLayout? = view.findViewById(R.id.ll_notice_items)
|
||||
private val scheduleTimeline: LinearLayout? = view.findViewById(R.id.ll_schedule_timeline)
|
||||
private val scheduleItems: LinearLayout? = view.findViewById(R.id.ll_schedule_items)
|
||||
private val audioContentsRecyclerView: RecyclerView? = view.findViewById(R.id.rv_audio_contents)
|
||||
private val seriesItems: LinearLayout? = view.findViewById(R.id.ll_series_items)
|
||||
private val communityItems: LinearLayout? = view.findViewById(R.id.ll_community_items)
|
||||
private val fanTalkCard: View? = view.findViewById(R.id.layout_fantalk_card)
|
||||
private val fanTalkTotalRow: View? = view.findViewById(R.id.layout_fantalk_total_row)
|
||||
private val fanTalkLatestRow: View? = view.findViewById(R.id.layout_fantalk_latest_row)
|
||||
private val fanTalkEmpty: View? = view.findViewById(R.id.layout_fantalk_empty)
|
||||
private val fanTalkTotalCount: TextView? = view.findViewById(R.id.tv_fantalk_total_count)
|
||||
private val fanTalkProfile: ImageView? = view.findViewById(R.id.iv_fantalk_profile)
|
||||
private val fanTalkContent: TextView? = view.findViewById(R.id.tv_fantalk_content)
|
||||
private val introduceBody: TextView? = view.findViewById(R.id.tv_introduce_body)
|
||||
private val activityDebutValue: TextView? = view.findViewById(R.id.tv_activity_debut_value)
|
||||
private val activityLiveCountValue: TextView? = view.findViewById(R.id.tv_activity_live_count_value)
|
||||
private val activityLiveDurationValue: TextView? = view.findViewById(R.id.tv_activity_live_duration_value)
|
||||
private val activityLiveContributorValue: TextView? = view.findViewById(R.id.tv_activity_live_contributor_value)
|
||||
private val activityAudioCountValue: TextView? = view.findViewById(R.id.tv_activity_audio_count_value)
|
||||
private val activitySeriesCountValue: TextView? = view.findViewById(R.id.tv_activity_series_count_value)
|
||||
private val snsItems: LinearLayout? = view.findViewById(R.id.ll_sns_items)
|
||||
private val audioContentGridAdapter = AudioContentGridAdapter(
|
||||
itemWidth = calculateCreatorChannelAudioItemWidthDp(itemView.resources.configuration.screenWidthDp).dp(),
|
||||
onAudioContentClick = onAudioContentClick
|
||||
)
|
||||
|
||||
init {
|
||||
setupAudioContentsRecyclerView()
|
||||
}
|
||||
|
||||
fun bind(item: CreatorChannelHomeSection) {
|
||||
itemView.setOnClickListener(null)
|
||||
title?.setText(item.titleResId)
|
||||
donationItems?.removeAllViews()
|
||||
noticeItems?.removeAllViews()
|
||||
scheduleTimeline?.removeAllViews()
|
||||
scheduleItems?.removeAllViews()
|
||||
seriesItems?.removeAllViews()
|
||||
communityItems?.removeAllViews()
|
||||
snsItems?.removeAllViews()
|
||||
when (item) {
|
||||
is CreatorChannelHomeSection.CurrentLive -> bindCurrentLive(item)
|
||||
is CreatorChannelHomeSection.LatestAudioContent -> bindLatestAudioContent(item)
|
||||
is CreatorChannelHomeSection.Donations -> bindDonations(item)
|
||||
is CreatorChannelHomeSection.Notices -> bindNotices(item)
|
||||
is CreatorChannelHomeSection.Schedules -> bindSchedules(item)
|
||||
is CreatorChannelHomeSection.AudioContents -> bindAudioContents(item)
|
||||
is CreatorChannelHomeSection.Series -> bindSeries(item)
|
||||
is CreatorChannelHomeSection.Communities -> bindCommunities(item)
|
||||
is CreatorChannelHomeSection.FanTalk -> bindFanTalk(item)
|
||||
is CreatorChannelHomeSection.Introduce -> bindIntroduce(item)
|
||||
is CreatorChannelHomeSection.Activity -> bindActivity(item)
|
||||
is CreatorChannelHomeSection.Sns -> bindSns(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindCurrentLive(item: CreatorChannelHomeSection.CurrentLive) {
|
||||
currentLiveTitle?.text = item.live.title
|
||||
currentLiveStartTime?.text = formatCreatorChannelLiveDateTime(item.live.beginDateTimeUtc)
|
||||
currentLivePrice?.text = item.live.price.toString()
|
||||
currentLivePriceLayout?.isVisible = item.live.price > 0
|
||||
currentLiveAdult?.isVisible = item.live.isAdult
|
||||
currentLiveCard?.setOnClickListener { onLiveClick(item.live) }
|
||||
}
|
||||
|
||||
private fun bindLatestAudioContent(item: CreatorChannelHomeSection.LatestAudioContent) {
|
||||
latestAudioTitle?.text = item.audioContent.title
|
||||
latestAudioDuration?.text = item.audioContent.duration.orEmpty()
|
||||
latestAudioPointTag?.isVisible = item.audioContent.isPointAvailable
|
||||
latestAudioThumbnail?.loadUrl(item.audioContent.imageUrl)
|
||||
itemView.setOnClickListener { onAudioContentClick(item.audioContent) }
|
||||
}
|
||||
|
||||
private fun bindDonations(item: CreatorChannelHomeSection.Donations) {
|
||||
donationItems?.removeAllViews()
|
||||
donationItemsScrollView?.isVisible = item.donations.isNotEmpty()
|
||||
donationEmpty?.isVisible = item.donations.isEmpty()
|
||||
val isDonationButtonVisible = item.donations.isNotEmpty() && !item.isOwner
|
||||
val isDonationEmptyButtonVisible = !item.isOwner
|
||||
donationButton?.isVisible = isDonationButtonVisible
|
||||
donationEmptyButton?.isVisible = isDonationEmptyButtonVisible
|
||||
donationEmptyTitle?.setText(
|
||||
if (item.isOwner) {
|
||||
R.string.creator_channel_donation_empty_owner_title
|
||||
} else {
|
||||
R.string.creator_channel_donation_empty_title
|
||||
}
|
||||
)
|
||||
donationButton?.setOnClickListener(
|
||||
if (isDonationButtonVisible) View.OnClickListener { onDonationClick() } else null
|
||||
)
|
||||
donationEmptyButton?.setOnClickListener(
|
||||
if (isDonationEmptyButtonVisible) View.OnClickListener { onDonationClick() } else null
|
||||
)
|
||||
val visibleDonations = item.donations.take(MAX_DONATION_ITEM_COUNT)
|
||||
visibleDonations.forEachIndexed { index, donation ->
|
||||
val row = LayoutInflater.from(itemView.context).inflate(
|
||||
R.layout.item_creator_channel_home_donation_row,
|
||||
donationItems,
|
||||
false
|
||||
)
|
||||
row.layoutParams = LinearLayout.LayoutParams(
|
||||
calculateCreatorChannelDonationCardWidthDp(itemView.resources.configuration.screenWidthDp).dp(),
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
marginEnd = if (index == visibleDonations.lastIndex) 0 else 4.dp()
|
||||
}
|
||||
row.findViewById<View>(R.id.layout_donation_header)
|
||||
.setBackgroundColor(itemView.context.getColor(calculateCreatorChannelDonationHeaderColorRes(donation.can)))
|
||||
row.findViewById<ImageView>(R.id.iv_donation_profile).loadUrl(donation.profileImageUrl) {
|
||||
placeholder(R.drawable.ic_placeholder_profile)
|
||||
transformations(CircleCropTransformation())
|
||||
}
|
||||
row.findViewById<TextView>(R.id.tv_donation_nickname).text = donation.nickname
|
||||
row.findViewById<TextView>(R.id.tv_donation_created_at).text =
|
||||
formatUtcRelativeTimeText(itemView.context, donation.createdAtUtc)
|
||||
row.findViewById<TextView>(R.id.tv_donation_can).text = itemView.context.getString(
|
||||
R.string.creator_channel_donation_can_format,
|
||||
donation.can.moneyFormat()
|
||||
)
|
||||
row.findViewById<TextView>(R.id.tv_donation_message).text = donation.message.ifBlank {
|
||||
itemView.context.getString(R.string.creator_channel_donation_fallback_message, donation.can)
|
||||
}
|
||||
donationItems?.addView(row)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindNotices(item: CreatorChannelHomeSection.Notices) {
|
||||
val visibleNotices = item.notices.take(MAX_NOTICE_ITEM_COUNT)
|
||||
visibleNotices.forEachIndexed { index, notice ->
|
||||
val row = LayoutInflater.from(itemView.context).inflate(
|
||||
R.layout.item_creator_channel_home_notice_row,
|
||||
noticeItems,
|
||||
false
|
||||
)
|
||||
row.layoutParams = LinearLayout.LayoutParams(
|
||||
calculateCreatorChannelNoticeCardWidthDp(itemView.resources.configuration.screenWidthDp).dp(),
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
marginEnd = if (index == visibleNotices.lastIndex) 0 else 4.dp()
|
||||
}
|
||||
row.findViewById<ImageView>(R.id.iv_notice_profile).loadUrl(notice.creatorProfileUrl) {
|
||||
placeholder(R.drawable.ic_placeholder_profile)
|
||||
transformations(CircleCropTransformation())
|
||||
}
|
||||
row.findViewById<TextView>(R.id.tv_notice_creator_name).text = notice.creatorNickname
|
||||
row.findViewById<TextView>(R.id.tv_notice_created_at).text =
|
||||
formatUtcRelativeTimeText(itemView.context, notice.dateUtc)
|
||||
row.findViewById<TextView>(R.id.tv_notice_content).text = notice.content
|
||||
val noticeThumbnail = row.findViewById<ImageView>(R.id.iv_notice_thumbnail)
|
||||
noticeThumbnail.isVisible = !notice.imageUrl.isNullOrBlank()
|
||||
notice.imageUrl?.let(noticeThumbnail::loadUrl)
|
||||
noticeItems?.addView(row)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindSchedules(item: CreatorChannelHomeSection.Schedules) {
|
||||
val visibleSchedules = item.schedules.take(MAX_SCHEDULE_ITEM_COUNT)
|
||||
bindScheduleTimeline(visibleSchedules.size)
|
||||
visibleSchedules.forEachIndexed { index, schedule ->
|
||||
val row = LayoutInflater.from(itemView.context).inflate(
|
||||
R.layout.item_creator_channel_home_schedule_row,
|
||||
scheduleItems,
|
||||
false
|
||||
)
|
||||
row.layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
bottomMargin = if (index == visibleSchedules.lastIndex) 0 else 4.dp()
|
||||
}
|
||||
row.findViewById<TextView>(R.id.tv_schedule_date).text =
|
||||
formatCreatorChannelScheduleDate(schedule.scheduledAtUtc)
|
||||
row.findViewById<TextView>(R.id.tv_schedule_day_of_week).text =
|
||||
formatCreatorChannelScheduleDayOfWeek(schedule.scheduledAtUtc)
|
||||
row.findViewById<TextView>(R.id.tv_schedule_title).text = schedule.title
|
||||
row.findViewById<TextView>(R.id.tv_schedule_type).text = itemView.context.getString(schedule.type.labelResId)
|
||||
row.findViewById<TextView>(R.id.tv_schedule_time).text =
|
||||
formatCreatorChannelScheduleTime(schedule.scheduledAtUtc)
|
||||
row.setOnClickListener { onScheduleClick(schedule) }
|
||||
scheduleItems?.addView(row)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindScheduleTimeline(count: Int) {
|
||||
repeat(count) { index ->
|
||||
scheduleTimeline?.addView(createScheduleTimelineDot(index == 0))
|
||||
if (index < calculateCreatorChannelScheduleTimelineLineCount(count)) {
|
||||
scheduleTimeline?.addView(createScheduleTimelineLine())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createScheduleTimelineDot(isFirst: Boolean): View = View(itemView.context).apply {
|
||||
setBackgroundResource(R.drawable.bg_creator_channel_schedule_timeline_dot)
|
||||
if (isFirst) {
|
||||
background.setTint(itemView.context.getColor(R.color.soda_400))
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(8.dp(), 8.dp()).apply {
|
||||
topMargin = if (isFirst) 37.dp() else 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun createScheduleTimelineLine(): View = View(itemView.context).apply {
|
||||
setBackgroundResource(R.drawable.bg_creator_channel_schedule_timeline_line)
|
||||
layoutParams = LinearLayout.LayoutParams(2.dp(), 63.dp()).apply {
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindAudioContents(item: CreatorChannelHomeSection.AudioContents) {
|
||||
val visibleAudioContents = item.audioContents.take(MAX_AUDIO_ITEM_COUNT)
|
||||
updateAudioContentsGridSpan(visibleAudioContents.size)
|
||||
audioContentGridAdapter.submitItems(visibleAudioContents)
|
||||
}
|
||||
|
||||
private fun updateAudioContentsGridSpan(itemCount: Int) {
|
||||
(audioContentsRecyclerView?.layoutManager as? GridLayoutManager)?.spanCount = itemCount.coerceIn(
|
||||
1,
|
||||
AUDIO_GRID_SPAN_COUNT
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupAudioContentsRecyclerView() {
|
||||
audioContentsRecyclerView?.apply {
|
||||
if (layoutManager == null) {
|
||||
layoutManager = GridLayoutManager(itemView.context, AUDIO_GRID_SPAN_COUNT, RecyclerView.HORIZONTAL, false)
|
||||
}
|
||||
if (adapter == null) {
|
||||
adapter = audioContentGridAdapter
|
||||
}
|
||||
if (itemDecorationCount == 0) {
|
||||
addItemDecoration(AudioContentGridSpacingDecoration(horizontalSpacing = 8.dp(), verticalSpacing = 8.dp()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindSeries(item: CreatorChannelHomeSection.Series) {
|
||||
val visibleSeries = item.series.take(MAX_SERIES_ITEM_COUNT)
|
||||
visibleSeries.forEachIndexed { index, series ->
|
||||
val seriesWidthDp = calculateCreatorChannelSeriesCardWidthDp(itemView.resources.configuration.screenWidthDp)
|
||||
val row = LayoutInflater.from(itemView.context).inflate(
|
||||
R.layout.item_creator_channel_home_series_content,
|
||||
seriesItems,
|
||||
false
|
||||
)
|
||||
row.layoutParams = LinearLayout.LayoutParams(
|
||||
seriesWidthDp.dp(),
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
marginEnd = if (index == visibleSeries.lastIndex) 0 else 4.dp()
|
||||
}
|
||||
(row as CreatorChannelHomeSeriesCardView).apply {
|
||||
setThumbnailSize(seriesWidthDp, calculateCreatorChannelSeriesCardHeightDp(seriesWidthDp))
|
||||
bind(series)
|
||||
}
|
||||
row.setOnClickListener { onSeriesClick(series) }
|
||||
seriesItems?.addView(row)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindCommunities(item: CreatorChannelHomeSection.Communities) {
|
||||
val visibleCommunities = item.communities.take(MAX_COMMUNITY_ITEM_COUNT)
|
||||
visibleCommunities.forEachIndexed { index, community ->
|
||||
val communityWidthDp = calculateCreatorChannelCommunityCardWidthDp(
|
||||
itemView.resources.configuration.screenWidthDp
|
||||
)
|
||||
val row = LayoutInflater.from(itemView.context).inflate(
|
||||
R.layout.view_feed_community,
|
||||
communityItems,
|
||||
false
|
||||
)
|
||||
(row as FeedCommunityView).apply {
|
||||
setFeedSize(
|
||||
FeedSize(
|
||||
rootWidthDp = communityWidthDp
|
||||
)
|
||||
)
|
||||
setHideEmptyTextRows(true)
|
||||
bind(community.toFeedCommunityItem())
|
||||
}
|
||||
bindCommunityImages(row, community)
|
||||
row.layoutParams = LinearLayout.LayoutParams(
|
||||
communityWidthDp.dp(),
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
bottomMargin = if (index == visibleCommunities.lastIndex) 0 else 8.dp()
|
||||
}
|
||||
communityItems?.addView(row)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindFanTalk(item: CreatorChannelHomeSection.FanTalk) {
|
||||
fanTalkCard?.layoutParams = fanTalkCard.layoutParams?.apply {
|
||||
width = calculateCreatorChannelFanTalkCardWidthDp(itemView.resources.configuration.screenWidthDp).dp()
|
||||
}
|
||||
fanTalkTotalCount?.text = item.fanTalk.totalCount.toString()
|
||||
val fanTalk = item.fanTalk.latestFanTalk
|
||||
fanTalkTotalRow?.isVisible = fanTalk != null && item.fanTalk.totalCount > 0
|
||||
fanTalkLatestRow?.isVisible = fanTalk != null && item.fanTalk.totalCount > 0
|
||||
fanTalkEmpty?.isVisible = fanTalk == null || item.fanTalk.totalCount <= 0
|
||||
if (fanTalk != null) {
|
||||
fanTalkContent?.text = fanTalk.content
|
||||
fanTalkProfile?.loadUrl(fanTalk.profileImageUrl) {
|
||||
placeholder(R.drawable.ic_placeholder_profile)
|
||||
transformations(CircleCropTransformation())
|
||||
}
|
||||
} else {
|
||||
fanTalkContent?.text = ""
|
||||
fanTalkProfile?.setImageResource(R.drawable.ic_placeholder_profile)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindCommunityImages(row: View, community: CreatorChannelCommunityPostResponse) {
|
||||
val communityRow = row as FeedCommunityView
|
||||
communityRow.profileImageView().loadUrl(community.creatorProfileUrl) {
|
||||
placeholder(R.drawable.ic_placeholder_profile)
|
||||
transformations(CircleCropTransformation())
|
||||
}
|
||||
val isCommunityLocked = community.price > 0 && !community.existOrdered
|
||||
val communityImageUrl = community.imageUrl.takeIf { !it.isNullOrBlank() }
|
||||
if (communityImageUrl != null) {
|
||||
communityRow.communityImageView().loadUrl(communityImageUrl) {
|
||||
if (isCommunityLocked) {
|
||||
transformations(BlurTransformation(itemView.context, 25f, 2.5f))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
communityRow.communityImageView().setImageDrawable(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CreatorChannelCommunityPostResponse.toFeedCommunityItem(): FeedItem.Community = FeedItem.Community(
|
||||
feedId = postId.toString(),
|
||||
creatorId = creatorId.toString(),
|
||||
creatorName = creatorNickname,
|
||||
creatorImageUrl = creatorProfileUrl,
|
||||
postId = postId.toString(),
|
||||
bodyText = content,
|
||||
keywordText = "",
|
||||
createdAtText = formatUtcRelativeTimeText(itemView.context, dateUtc),
|
||||
commentCount = commentCount,
|
||||
likeCount = likeCount,
|
||||
imageUrl = imageUrl,
|
||||
audioUrl = audioUrl,
|
||||
price = price,
|
||||
existOrdered = existOrdered,
|
||||
showKeyword = false
|
||||
)
|
||||
|
||||
private fun bindIntroduce(item: CreatorChannelHomeSection.Introduce) {
|
||||
introduceBody?.text = item.introduce
|
||||
}
|
||||
|
||||
private fun bindActivity(item: CreatorChannelHomeSection.Activity) {
|
||||
val activity = item.activity
|
||||
activityDebutValue?.text = formatCreatorChannelDebutActivityValue(activity.debutDateUtc, activity.dDay)
|
||||
activityLiveCountValue?.text = itemView.context.getString(
|
||||
R.string.creator_channel_activity_live_count_format,
|
||||
activity.liveCount
|
||||
)
|
||||
activityLiveDurationValue?.text = itemView.context.getString(
|
||||
R.string.creator_channel_activity_live_duration_format,
|
||||
activity.liveDurationHours
|
||||
)
|
||||
activityLiveContributorValue?.text = itemView.context.getString(
|
||||
R.string.creator_channel_activity_live_contributor_format,
|
||||
activity.liveContributorCount
|
||||
)
|
||||
activityAudioCountValue?.text = itemView.context.getString(
|
||||
R.string.creator_channel_activity_audio_count_format,
|
||||
activity.audioContentCount
|
||||
)
|
||||
activitySeriesCountValue?.text = itemView.context.getString(
|
||||
R.string.creator_channel_activity_series_count_format,
|
||||
activity.seriesCount
|
||||
)
|
||||
}
|
||||
|
||||
private fun bindSns(item: CreatorChannelHomeSection.Sns) {
|
||||
item.items.forEachIndexed { index, sns ->
|
||||
val button = LayoutInflater.from(itemView.context).inflate(
|
||||
R.layout.item_creator_channel_home_sns_icon,
|
||||
snsItems,
|
||||
false
|
||||
) as ImageView
|
||||
val buttonSize = calculateCreatorChannelSnsButtonSizeDp(itemView.resources.configuration.screenWidthDp).dp()
|
||||
button.layoutParams = LinearLayout.LayoutParams(buttonSize, buttonSize).apply {
|
||||
marginEnd = if (index == item.items.lastIndex) 0 else 16.dp()
|
||||
}
|
||||
button.setImageResource(sns.iconResId)
|
||||
button.contentDescription = sns.label
|
||||
button.setOnClickListener {
|
||||
val intent = Intent(Intent.ACTION_VIEW, sns.url.toUri())
|
||||
if (intent.resolveActivity(itemView.context.packageManager) != null) {
|
||||
itemView.context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
snsItems?.addView(button)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.dp(): Int = (this * itemView.resources.displayMetrics.density).toInt()
|
||||
}
|
||||
|
||||
private class AudioContentGridAdapter(
|
||||
private val itemWidth: Int,
|
||||
private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit
|
||||
) : RecyclerView.Adapter<AudioContentGridAdapter.AudioContentViewHolder>() {
|
||||
|
||||
private var items: List<CreatorChannelAudioContentResponse> = emptyList()
|
||||
|
||||
fun submitItems(items: List<CreatorChannelAudioContentResponse>) {
|
||||
this.items = items
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AudioContentViewHolder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(
|
||||
R.layout.item_creator_channel_home_audio_content,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
view.layoutParams = RecyclerView.LayoutParams(itemWidth, RecyclerView.LayoutParams.WRAP_CONTENT)
|
||||
return AudioContentViewHolder(view, onAudioContentClick)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: AudioContentViewHolder, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
class AudioContentViewHolder(
|
||||
view: View,
|
||||
private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit
|
||||
) : RecyclerView.ViewHolder(view) {
|
||||
private val card: CreatorChannelHomeAudioContentCardView = view as CreatorChannelHomeAudioContentCardView
|
||||
|
||||
fun bind(audioContent: CreatorChannelAudioContentResponse) {
|
||||
card.bind(audioContent)
|
||||
card.setOnClickListener { onAudioContentClick(audioContent) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class AudioContentGridSpacingDecoration(
|
||||
private val horizontalSpacing: Int,
|
||||
private val verticalSpacing: Int
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
override fun getItemOffsets(
|
||||
outRect: android.graphics.Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
val position = parent.getChildAdapterPosition(view)
|
||||
if (position == RecyclerView.NO_POSITION) return
|
||||
val itemCount = parent.adapter?.itemCount ?: return
|
||||
val lastColumnStartPosition = ((itemCount - 1) / AUDIO_GRID_SPAN_COUNT) * AUDIO_GRID_SPAN_COUNT
|
||||
|
||||
outRect.right = if (position >= lastColumnStartPosition) 0 else horizontalSpacing
|
||||
outRect.bottom = if (position % AUDIO_GRID_SPAN_COUNT == AUDIO_GRID_SPAN_COUNT - 1) 0 else verticalSpacing
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
@get:LayoutRes
|
||||
val CreatorChannelHomeSection.layoutResId: Int
|
||||
get() = when (this) {
|
||||
is CreatorChannelHomeSection.CurrentLive -> R.layout.item_creator_channel_home_live
|
||||
is CreatorChannelHomeSection.LatestAudioContent -> R.layout.item_creator_channel_home_latest_audio
|
||||
is CreatorChannelHomeSection.Donations -> R.layout.item_creator_channel_home_donation
|
||||
is CreatorChannelHomeSection.Notices -> R.layout.item_creator_channel_home_notice
|
||||
is CreatorChannelHomeSection.Schedules -> R.layout.item_creator_channel_home_schedule
|
||||
is CreatorChannelHomeSection.AudioContents -> R.layout.item_creator_channel_home_audio
|
||||
is CreatorChannelHomeSection.Series -> R.layout.item_creator_channel_home_series
|
||||
is CreatorChannelHomeSection.Communities -> R.layout.item_creator_channel_home_community
|
||||
is CreatorChannelHomeSection.FanTalk -> R.layout.item_creator_channel_home_fantalk
|
||||
is CreatorChannelHomeSection.Introduce -> R.layout.item_creator_channel_home_introduce
|
||||
is CreatorChannelHomeSection.Activity -> R.layout.item_creator_channel_home_activity
|
||||
is CreatorChannelHomeSection.Sns -> R.layout.item_creator_channel_home_sns
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
val CreatorChannelHomeSection.titleResId: Int
|
||||
get() = when (this) {
|
||||
is CreatorChannelHomeSection.CurrentLive -> R.string.creator_channel_section_live
|
||||
is CreatorChannelHomeSection.LatestAudioContent -> R.string.creator_channel_section_latest_audio
|
||||
is CreatorChannelHomeSection.Donations -> R.string.creator_channel_section_donation
|
||||
is CreatorChannelHomeSection.Notices -> R.string.creator_channel_section_notice
|
||||
is CreatorChannelHomeSection.Schedules -> R.string.creator_channel_section_schedule
|
||||
is CreatorChannelHomeSection.AudioContents -> R.string.creator_channel_section_audio
|
||||
is CreatorChannelHomeSection.Series -> R.string.creator_channel_section_series
|
||||
is CreatorChannelHomeSection.Communities -> R.string.creator_channel_section_community
|
||||
is CreatorChannelHomeSection.FanTalk -> R.string.creator_channel_section_fantalk
|
||||
is CreatorChannelHomeSection.Introduce -> R.string.creator_channel_section_introduce
|
||||
is CreatorChannelHomeSection.Activity -> R.string.creator_channel_section_activity
|
||||
is CreatorChannelHomeSection.Sns -> R.string.creator_channel_section_sns
|
||||
}
|
||||
|
||||
private const val MAX_DONATION_ITEM_COUNT = 8
|
||||
private const val MAX_NOTICE_ITEM_COUNT = 3
|
||||
private const val MAX_SCHEDULE_ITEM_COUNT = 3
|
||||
private const val MAX_AUDIO_ITEM_COUNT = 9
|
||||
private const val MAX_SERIES_ITEM_COUNT = 10
|
||||
private const val MAX_COMMUNITY_ITEM_COUNT = 3
|
||||
private const val AUDIO_GRID_SPAN_COUNT = 3
|
||||
|
||||
fun List<String>.joinToText(): String = filter(String::isNotBlank).joinToString(separator = " · ")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun formatCreatorChannelScheduleDate(
|
||||
scheduledAtUtc: String,
|
||||
timeZone: TimeZone = TimeZone.getDefault(),
|
||||
locale: Locale = Locale.getDefault()
|
||||
): String = formatCreatorChannelScheduleUtc(scheduledAtUtc, "d", timeZone, locale)
|
||||
|
||||
internal fun formatCreatorChannelScheduleDayOfWeek(
|
||||
scheduledAtUtc: String,
|
||||
timeZone: TimeZone = TimeZone.getDefault(),
|
||||
locale: Locale = Locale.getDefault()
|
||||
): String = formatCreatorChannelScheduleUtc(scheduledAtUtc, "E", timeZone, locale)
|
||||
|
||||
internal fun formatCreatorChannelScheduleTime(
|
||||
scheduledAtUtc: String,
|
||||
timeZone: TimeZone = TimeZone.getDefault(),
|
||||
locale: Locale = Locale.getDefault()
|
||||
): String = formatCreatorChannelScheduleUtc(scheduledAtUtc, "a hh:mm", timeZone, locale)
|
||||
|
||||
internal fun formatCreatorChannelLiveDateTime(
|
||||
beginDateTimeUtc: String,
|
||||
timeZone: TimeZone = TimeZone.getDefault(),
|
||||
locale: Locale = Locale.getDefault()
|
||||
): String = formatCreatorChannelUtcOrNull(beginDateTimeUtc, "yyyy.MM.dd HH:mm:ss", timeZone, locale).orEmpty()
|
||||
|
||||
internal fun formatCreatorChannelDebutActivityValue(
|
||||
debutDateUtc: String?,
|
||||
dDay: String,
|
||||
timeZone: TimeZone = TimeZone.getDefault(),
|
||||
locale: Locale = Locale.getDefault()
|
||||
): String {
|
||||
val debutDate = debutDateUtc?.takeIf(String::isNotBlank)?.let { dateUtc ->
|
||||
formatCreatorChannelUtcOrNull(dateUtc, "yyyy.MM.dd", timeZone, locale)
|
||||
}
|
||||
return if (debutDate.isNullOrBlank()) {
|
||||
dDay
|
||||
} else {
|
||||
"$debutDate($dDay)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCreatorChannelScheduleUtc(
|
||||
scheduledAtUtc: String,
|
||||
pattern: String,
|
||||
timeZone: TimeZone,
|
||||
locale: Locale
|
||||
): String {
|
||||
return formatCreatorChannelUtcOrNull(scheduledAtUtc, pattern, timeZone, locale).orEmpty()
|
||||
}
|
||||
|
||||
private fun formatCreatorChannelUtcOrNull(
|
||||
utc: String,
|
||||
pattern: String,
|
||||
timeZone: TimeZone,
|
||||
locale: Locale
|
||||
): String? {
|
||||
val date = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||
this.timeZone = TimeZone.getTimeZone("UTC")
|
||||
isLenient = false
|
||||
}.runCatching { parse(utc) }.getOrNull() ?: return null
|
||||
return SimpleDateFormat(pattern, locale).apply { this.timeZone = timeZone }.format(date)
|
||||
}
|
||||
|
||||
internal fun calculateCreatorChannelSnsButtonSizeDp(screenWidthDp: Int): Int {
|
||||
val width = screenWidthDp.takeIf { it > 0 } ?: 402
|
||||
return minOf(
|
||||
SNS_MAX_ICON_SIZE_DP,
|
||||
(width - SNS_HORIZONTAL_PADDING_DP - SNS_TOTAL_GAP_DP).coerceAtLeast(0) / SNS_ICON_COUNT
|
||||
)
|
||||
}
|
||||
|
||||
private const val SNS_ICON_COUNT = 5
|
||||
private const val SNS_MAX_ICON_SIZE_DP = 52
|
||||
private const val SNS_HORIZONTAL_PADDING_DP = 40
|
||||
private const val SNS_TOTAL_GAP_DP = 64
|
||||
|
||||
@ColorRes
|
||||
internal fun calculateCreatorChannelDonationHeaderColorRes(can: Int): Int = when {
|
||||
can >= 500 -> R.color.red_400
|
||||
can >= 101 -> R.color.creator_channel_donation_cyan
|
||||
can >= 51 -> R.color.green_400
|
||||
else -> R.color.gray_200
|
||||
}
|
||||
|
||||
internal fun calculateCreatorChannelDonationCardWidthDp(screenWidthDp: Int): Int {
|
||||
val width = screenWidthDp.takeIf { it > 0 } ?: 402
|
||||
return if (width >= 402) {
|
||||
374
|
||||
} else {
|
||||
(374f * width / 402f).roundToInt()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun calculateCreatorChannelNoticeCardWidthDp(screenWidthDp: Int): Int {
|
||||
val width = screenWidthDp.takeIf { it > 0 } ?: 402
|
||||
return if (width >= 402) {
|
||||
346
|
||||
} else {
|
||||
(346f * width / 402f).roundToInt()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun calculateCreatorChannelAudioItemWidthDp(screenWidthDp: Int): Int {
|
||||
val width = screenWidthDp.takeIf { it > 0 } ?: 402
|
||||
return if (width >= 402) {
|
||||
346
|
||||
} else {
|
||||
(346f * width / 402f).roundToInt()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun calculateCreatorChannelSeriesCardWidthDp(screenWidthDp: Int): Int {
|
||||
val width = screenWidthDp.takeIf { it > 0 } ?: 402
|
||||
return if (width >= 402) {
|
||||
163
|
||||
} else {
|
||||
(163f * width / 402f).roundToInt()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun calculateCreatorChannelSeriesCardHeightDp(widthDp: Int): Int = (widthDp * 230f / 163f).roundToInt()
|
||||
|
||||
internal fun calculateCreatorChannelCommunityCardWidthDp(screenWidthDp: Int): Int {
|
||||
val width = screenWidthDp.takeIf { it > 0 } ?: 402
|
||||
return if (width >= 402) {
|
||||
374
|
||||
} else {
|
||||
(374f * width / 402f).roundToInt()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun calculateCreatorChannelFanTalkCardWidthDp(screenWidthDp: Int): Int {
|
||||
val width = screenWidthDp.takeIf { it > 0 } ?: 402
|
||||
return if (width >= 402) {
|
||||
374
|
||||
} else {
|
||||
(374f * width / 402f).roundToInt()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun calculateCreatorChannelScheduleTimelineLineCount(scheduleCount: Int): Int = (scheduleCount - 1).coerceAtLeast(0)
|
||||
@@ -0,0 +1,49 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Outline
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewOutlineProvider
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.core.view.isVisible
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse
|
||||
|
||||
class CreatorChannelHomeSeriesCardView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val thumbnailContainer: View by lazy { findViewById(R.id.layout_series_thumbnail) }
|
||||
private val thumbnail: ImageView by lazy { findViewById(R.id.iv_series_thumbnail) }
|
||||
private val originalTag: View by lazy { findViewById(R.id.layout_series_original_tag) }
|
||||
|
||||
override fun onFinishInflate() {
|
||||
super.onFinishInflate()
|
||||
thumbnailContainer.clipToOutline = true
|
||||
thumbnailContainer.outlineProvider = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(series: CreatorChannelSeriesResponse) {
|
||||
thumbnail.loadUrl(series.coverImageUrl)
|
||||
originalTag.isVisible = series.isOriginal
|
||||
}
|
||||
|
||||
fun setThumbnailSize(widthDp: Int, heightDp: Int) {
|
||||
thumbnailContainer.layoutParams = thumbnailContainer.layoutParams.apply {
|
||||
width = widthDp.dp()
|
||||
height = heightDp.dp()
|
||||
} ?: ViewGroup.LayoutParams(widthDp.dp(), heightDp.dp())
|
||||
}
|
||||
|
||||
private fun Int.dp(): Int = (this * resources.displayMetrics.density).toInt()
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Outline
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewOutlineProvider
|
||||
import android.widget.FrameLayout
|
||||
import kr.co.vividnext.sodalive.R
|
||||
|
||||
class CreatorChannelLatestAudioThumbnailView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
override fun onFinishInflate() {
|
||||
super.onFinishInflate()
|
||||
clipToOutline = true
|
||||
outlineProvider = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Outline
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewOutlineProvider
|
||||
import android.widget.LinearLayout
|
||||
import kr.co.vividnext.sodalive.R
|
||||
|
||||
class CreatorChannelNoticeCardView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : LinearLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
init {
|
||||
clipToOutline = true
|
||||
outlineProvider = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Outline
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewOutlineProvider
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import kr.co.vividnext.sodalive.R
|
||||
|
||||
class CreatorChannelNoticeThumbnailView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : AppCompatImageView(context, attrs, defStyleAttr) {
|
||||
|
||||
init {
|
||||
clipToOutline = true
|
||||
outlineProvider = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.ui
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.Outline
|
||||
import android.graphics.Rect
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewOutlineProvider
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.PopupWindow
|
||||
import android.widget.TextView
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.model.toSortOptionUiModel
|
||||
|
||||
class CreatorChannelSortPopup(
|
||||
private val anchor: View,
|
||||
private val selectedSort: ContentSort,
|
||||
private val onSortSelected: (ContentSort) -> Unit
|
||||
) {
|
||||
|
||||
private val popupWindow: PopupWindow = PopupWindow(anchor.context).apply {
|
||||
contentView = createContentView()
|
||||
width = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
height = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
isOutsideTouchable = true
|
||||
isFocusable = true
|
||||
setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
|
||||
}
|
||||
|
||||
fun show() {
|
||||
val content = popupWindow.contentView
|
||||
content.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(anchor.rootView.width, View.MeasureSpec.AT_MOST),
|
||||
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||
)
|
||||
popupWindow.showAsDropDown(anchor, calculateHorizontalOffset(content.measuredWidth), 0)
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
popupWindow.dismiss()
|
||||
}
|
||||
|
||||
private fun createContentView(): View {
|
||||
val root = FrameLayout(anchor.context)
|
||||
val view = LayoutInflater.from(anchor.context).inflate(
|
||||
R.layout.view_creator_channel_sort_menu,
|
||||
root,
|
||||
false
|
||||
)
|
||||
view.clipToOutline = true
|
||||
view.outlineProvider = object : ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width, view.height, view.resources.getDimension(R.dimen.radius_14))
|
||||
}
|
||||
}
|
||||
val container = view.findViewById<LinearLayout>(R.id.layout_creator_channel_sort_options)
|
||||
val sample = view.findViewById<TextView>(R.id.tv_creator_channel_sort_option_sample)
|
||||
container.removeView(sample)
|
||||
|
||||
ContentSort.entries.map { it.toSortOptionUiModel(selectedSort) }.forEach { option ->
|
||||
val row = TextView(anchor.context).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
typeface = sample.typeface
|
||||
includeFontPadding = false
|
||||
setPadding(sample.paddingStart, sample.paddingTop, sample.paddingEnd, sample.paddingBottom)
|
||||
setText(option.labelResId)
|
||||
setTextColor(sample.currentTextColor)
|
||||
textSize = 16f
|
||||
if (option.isSelected) {
|
||||
setBackgroundResource(R.drawable.bg_creator_channel_sort_selected)
|
||||
}
|
||||
setOnClickListener {
|
||||
if (option.sort != selectedSort) {
|
||||
onSortSelected(option.sort)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
container.addView(row)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
private fun calculateHorizontalOffset(popupWidth: Int): Int {
|
||||
val visibleDisplayFrame = Rect()
|
||||
anchor.rootView.getWindowVisibleDisplayFrame(visibleDisplayFrame)
|
||||
|
||||
val anchorLocation = IntArray(2)
|
||||
anchor.getLocationOnScreen(anchorLocation)
|
||||
val popupRight = anchorLocation[0] + popupWidth
|
||||
return if (popupRight > visibleDisplayFrame.right) {
|
||||
visibleDisplayFrame.right - popupRight
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package kr.co.vividnext.sodalive.v2.live.onair
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.gson.Gson
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
|
||||
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService
|
||||
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.common.LoadingDialog
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.common.ToastMessage
|
||||
import kr.co.vividnext.sodalive.databinding.ActivityHomeOnAirLiveBinding
|
||||
import kr.co.vividnext.sodalive.live.LiveViewModel
|
||||
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
|
||||
import kr.co.vividnext.sodalive.live.room.dialog.LivePaymentDialog
|
||||
import kr.co.vividnext.sodalive.live.room.dialog.LiveRoomPasswordDialog
|
||||
import kr.co.vividnext.sodalive.mypage.MyPageViewModel
|
||||
import kr.co.vividnext.sodalive.mypage.auth.Auth
|
||||
import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
|
||||
import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
|
||||
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
|
||||
import kr.co.vividnext.sodalive.settings.language.LanguageManager
|
||||
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
|
||||
import kr.co.vividnext.sodalive.splash.SplashActivity
|
||||
import kr.co.vividnext.sodalive.user.login.LoginActivity
|
||||
import kr.co.vividnext.sodalive.v2.live.onair.model.HomeOnAirLivePageUiState
|
||||
import kr.co.vividnext.sodalive.v2.live.onair.model.canEnterHomeOnAirLiveRoom
|
||||
import kr.co.vividnext.sodalive.v2.live.onair.ui.HomeOnAirLiveAdapter
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@UnstableApi
|
||||
class HomeOnAirLiveActivity : BaseActivity<ActivityHomeOnAirLiveBinding>(
|
||||
ActivityHomeOnAirLiveBinding::inflate
|
||||
) {
|
||||
private val viewModel: HomeOnAirLiveViewModel by viewModel()
|
||||
private val liveViewModel: LiveViewModel by inject()
|
||||
private val myPageViewModel: MyPageViewModel by inject()
|
||||
private val loadingDialog: LoadingDialog by lazy { LoadingDialog(this, layoutInflater) }
|
||||
private val adapter = HomeOnAirLiveAdapter { enterLiveRoom(it.roomId) }
|
||||
private var isPageLoading = false
|
||||
private var isLiveEntryLoading = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
bindData()
|
||||
viewModel.loadFirstPage()
|
||||
}
|
||||
|
||||
override fun setupView() {
|
||||
binding.toolbar.tvBack.setText(R.string.live_now)
|
||||
binding.toolbar.tvBack.setOnClickListener { finish() }
|
||||
binding.rvHomeOnAirLive.apply {
|
||||
layoutManager = LinearLayoutManager(this@HomeOnAirLiveActivity)
|
||||
adapter = this@HomeOnAirLiveActivity.adapter
|
||||
addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
super.onScrolled(recyclerView, dx, dy)
|
||||
if (dy > 0 && !recyclerView.canScrollVertically(1)) {
|
||||
viewModel.loadNextPage()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindData() {
|
||||
viewModel.onAirLiveStateLiveData.observe(this) { state ->
|
||||
when (state) {
|
||||
HomeOnAirLivePageUiState.Loading -> Unit
|
||||
HomeOnAirLivePageUiState.Empty -> showEmpty()
|
||||
is HomeOnAirLivePageUiState.Error -> showEmpty()
|
||||
is HomeOnAirLivePageUiState.Content -> {
|
||||
binding.rvHomeOnAirLive.isVisible = true
|
||||
binding.tvHomeOnAirLiveEmpty.isVisible = false
|
||||
adapter.submitItems(state.content.items)
|
||||
state.content.paginationErrorMessage?.let { message ->
|
||||
Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
|
||||
viewModel.consumePaginationErrorMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModel.isLoading.observe(this) { isLoading ->
|
||||
isPageLoading = isLoading
|
||||
updateLoadingDialog()
|
||||
}
|
||||
viewModel.toastLiveData.observe(this) { toastMessage ->
|
||||
toastMessage?.let(::showToast)
|
||||
}
|
||||
liveViewModel.isLoading.observe(this) { isLoading ->
|
||||
isLiveEntryLoading = isLoading
|
||||
updateLoadingDialog()
|
||||
}
|
||||
liveViewModel.toastLiveData.observe(this) { message ->
|
||||
message?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun showEmpty() {
|
||||
binding.rvHomeOnAirLive.isVisible = false
|
||||
binding.tvHomeOnAirLiveEmpty.isVisible = true
|
||||
adapter.submitItems(emptyList())
|
||||
}
|
||||
|
||||
private fun enterLiveRoom(roomId: Long) {
|
||||
ensureLoginAndAdultAuth(isAdult = false) {
|
||||
liveViewModel.getRoomDetail(roomId) { roomDetail ->
|
||||
if (!canEnterHomeOnAirLiveRoom(roomDetail)) {
|
||||
Toast.makeText(applicationContext, R.string.common_error_unknown, Toast.LENGTH_LONG).show()
|
||||
return@getRoomDetail
|
||||
}
|
||||
|
||||
ensureLoginAndAdultAuth(isAdult = roomDetail.isAdult) {
|
||||
enterLiveRoom(roomId, roomDetail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun enterLiveRoom(roomId: Long, roomDetail: GetRoomDetailResponse) {
|
||||
startService(
|
||||
Intent(applicationContext, AudioContentPlayService::class.java).apply {
|
||||
action = AudioContentPlayService.MusicAction.STOP.name
|
||||
}
|
||||
)
|
||||
startService(
|
||||
Intent(applicationContext, AudioContentPlayerService::class.java).apply {
|
||||
action = "STOP_SERVICE"
|
||||
}
|
||||
)
|
||||
|
||||
val onEnterRoomSuccess = {
|
||||
runOnUiThread {
|
||||
startActivity(
|
||||
Intent(applicationContext, LiveRoomActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_ROOM_ID, roomId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (roomDetail.manager.id == SharedPreferenceManager.userId) {
|
||||
liveViewModel.enterRoom(roomId, onEnterRoomSuccess)
|
||||
} else if (roomDetail.price == 0 || roomDetail.isPaid) {
|
||||
if (roomDetail.isPrivateRoom) {
|
||||
showPasswordDialog(roomId, can = 0, onEnterRoomSuccess = onEnterRoomSuccess)
|
||||
} else {
|
||||
liveViewModel.enterRoom(roomId, onEnterRoomSuccess)
|
||||
}
|
||||
} else {
|
||||
showPaidLiveEntryDialog(
|
||||
roomId = roomId,
|
||||
beginDateTimeUtc = roomDetail.beginDateTimeUtc,
|
||||
price = roomDetail.price,
|
||||
isPrivateRoom = roomDetail.isPrivateRoom,
|
||||
onEnterRoomSuccess = onEnterRoomSuccess
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureLoginAndAdultAuth(isAdult: Boolean, onAuthed: () -> Unit) {
|
||||
if (SharedPreferenceManager.token.isBlank()) {
|
||||
showLoginActivity()
|
||||
return
|
||||
}
|
||||
|
||||
if (isAdult) {
|
||||
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
|
||||
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
|
||||
SodaDialog(
|
||||
activity = this,
|
||||
layoutInflater = layoutInflater,
|
||||
title = getString(R.string.auth_title),
|
||||
desc = getString(R.string.auth_desc_live),
|
||||
confirmButtonTitle = getString(R.string.auth_go),
|
||||
confirmButtonClick = { startAuthFlow() },
|
||||
cancelButtonTitle = getString(R.string.cancel),
|
||||
cancelButtonClick = {},
|
||||
descGravity = Gravity.CENTER
|
||||
).show(screenWidth)
|
||||
return
|
||||
}
|
||||
|
||||
if (!SharedPreferenceManager.isAdultContentVisible) {
|
||||
startActivity(
|
||||
Intent(applicationContext, ContentSettingsActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
onAuthed()
|
||||
}
|
||||
|
||||
private fun showLoginActivity() {
|
||||
if (SharedPreferenceManager.token.isBlank()) {
|
||||
startActivity(
|
||||
Intent(applicationContext, LoginActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_DATA, intent.extras)
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAuthFlow() {
|
||||
Auth.auth(this, this) { json ->
|
||||
val bootpayResponse = Gson().fromJson(json, BootpayResponse::class.java)
|
||||
val request = AuthVerifyRequest(receiptId = bootpayResponse.data.receiptId)
|
||||
runOnUiThread {
|
||||
myPageViewModel.authVerify(request) {
|
||||
startActivity(
|
||||
Intent(applicationContext, SplashActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPasswordDialog(roomId: Long, can: Int, onEnterRoomSuccess: () -> Unit) {
|
||||
LiveRoomPasswordDialog(
|
||||
activity = this,
|
||||
layoutInflater = layoutInflater,
|
||||
can = can,
|
||||
confirmButtonClick = { password ->
|
||||
liveViewModel.enterRoom(
|
||||
roomId = roomId,
|
||||
onSuccess = onEnterRoomSuccess,
|
||||
password = password
|
||||
)
|
||||
}
|
||||
).show(screenWidth)
|
||||
}
|
||||
|
||||
private fun showPaidLiveEntryDialog(
|
||||
roomId: Long,
|
||||
beginDateTimeUtc: String,
|
||||
price: Int,
|
||||
isPrivateRoom: Boolean,
|
||||
onEnterRoomSuccess: () -> Unit
|
||||
) {
|
||||
if (isPrivateRoom) {
|
||||
showPasswordDialog(roomId, can = price, onEnterRoomSuccess = onEnterRoomSuccess)
|
||||
return
|
||||
}
|
||||
|
||||
val locale = Locale(LanguageManager.getEffectiveLanguage(this))
|
||||
val wrappedContext = LocaleHelper.wrap(this)
|
||||
val beginDate = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.parse(beginDateTimeUtc) ?: return
|
||||
val now = Date()
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd, HH:mm", locale)
|
||||
val diffTime = now.time - beginDate.time
|
||||
val hours = (diffTime / (1000 * 60 * 60)).toInt()
|
||||
val mins = (diffTime / (1000 * 60)).toInt() % 60
|
||||
|
||||
LivePaymentDialog(
|
||||
activity = this,
|
||||
layoutInflater = layoutInflater,
|
||||
title = wrappedContext.getString(R.string.live_paid_title),
|
||||
startDateTime = if (hours >= 1) dateFormat.format(beginDate) else null,
|
||||
nowDateTime = if (hours >= 1) dateFormat.format(now) else null,
|
||||
desc = wrappedContext.getString(R.string.live_paid_desc, price),
|
||||
desc2 = if (hours >= 1) wrappedContext.getString(R.string.live_paid_warning, hours, mins) else null,
|
||||
confirmButtonTitle = wrappedContext.getString(R.string.live_paid_confirm),
|
||||
confirmButtonClick = { liveViewModel.enterRoom(roomId, onEnterRoomSuccess) },
|
||||
cancelButtonTitle = wrappedContext.getString(R.string.cancel),
|
||||
cancelButtonClick = {}
|
||||
).show(screenWidth)
|
||||
}
|
||||
|
||||
private fun showToast(toastMessage: ToastMessage) {
|
||||
toastMessage.message?.let { message -> showToast(message) }
|
||||
?: toastMessage.resId?.let { resId -> showToast(getString(resId)) }
|
||||
}
|
||||
|
||||
private fun updateLoadingDialog() {
|
||||
if (isPageLoading || isLiveEntryLoading) {
|
||||
loadingDialog.show(screenWidth)
|
||||
} else {
|
||||
loadingDialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newIntent(context: Context): Intent = Intent(context, HomeOnAirLiveActivity::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package kr.co.vividnext.sodalive.v2.live.onair
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.orhanobut.logger.Logger
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kr.co.vividnext.sodalive.R
|
||||
import kr.co.vividnext.sodalive.base.BaseViewModel
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.common.ToastMessage
|
||||
import kr.co.vividnext.sodalive.v2.live.onair.data.HomeOnAirLivePageResponse
|
||||
import kr.co.vividnext.sodalive.v2.live.onair.data.HomeOnAirLiveRepository
|
||||
import kr.co.vividnext.sodalive.v2.live.onair.model.HomeOnAirLivePageUiState
|
||||
import kr.co.vividnext.sodalive.v2.live.onair.model.homeOnAirLiveAuthHeader
|
||||
import kr.co.vividnext.sodalive.v2.live.onair.model.toUiState
|
||||
|
||||
class HomeOnAirLiveViewModel(
|
||||
private val repository: HomeOnAirLiveRepository
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val _onAirLiveStateLiveData = MutableLiveData<HomeOnAirLivePageUiState>()
|
||||
val onAirLiveStateLiveData: LiveData<HomeOnAirLivePageUiState>
|
||||
get() = _onAirLiveStateLiveData
|
||||
|
||||
private val _isLoading = MutableLiveData(false)
|
||||
val isLoading: LiveData<Boolean>
|
||||
get() = _isLoading
|
||||
|
||||
private val _toastLiveData = MutableLiveData<ToastMessage?>()
|
||||
val toastLiveData: LiveData<ToastMessage?>
|
||||
get() = _toastLiveData
|
||||
|
||||
private var requestGeneration: Int = 0
|
||||
|
||||
fun loadFirstPage() {
|
||||
val generation = ++requestGeneration
|
||||
_isLoading.value = true
|
||||
_onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Loading
|
||||
requestOnAirLives(page = FIRST_PAGE, generation = generation) { response ->
|
||||
_isLoading.value = false
|
||||
val data = response.data
|
||||
if (response.success && data != null) {
|
||||
val state = data.toUiState()
|
||||
_onAirLiveStateLiveData.value = if (state.items.isEmpty()) {
|
||||
HomeOnAirLivePageUiState.Empty
|
||||
} else {
|
||||
HomeOnAirLivePageUiState.Content(state)
|
||||
}
|
||||
} else {
|
||||
showFirstPageError(response.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadNextPage() {
|
||||
val content = (_onAirLiveStateLiveData.value as? HomeOnAirLivePageUiState.Content)?.content ?: return
|
||||
if (!content.hasNext || content.isLoadingMore) return
|
||||
|
||||
val generation = requestGeneration
|
||||
_onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Content(
|
||||
content.copy(isLoadingMore = true, paginationErrorMessage = null)
|
||||
)
|
||||
requestOnAirLives(page = content.page + 1, generation = generation) { response ->
|
||||
val current = (_onAirLiveStateLiveData.value as? HomeOnAirLivePageUiState.Content)?.content ?: content
|
||||
val data = response.data
|
||||
if (response.success && data != null) {
|
||||
val next = data.toUiState()
|
||||
_onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Content(
|
||||
current.copy(
|
||||
items = current.items + next.items,
|
||||
page = next.page,
|
||||
size = next.size,
|
||||
hasNext = next.hasNext,
|
||||
isLoadingMore = false,
|
||||
paginationErrorMessage = null
|
||||
)
|
||||
)
|
||||
} else {
|
||||
_onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Content(
|
||||
current.copy(isLoadingMore = false, paginationErrorMessage = response.message)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consumePaginationErrorMessage() {
|
||||
val content = (_onAirLiveStateLiveData.value as? HomeOnAirLivePageUiState.Content)?.content ?: return
|
||||
if (content.paginationErrorMessage == null) return
|
||||
|
||||
_onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Content(
|
||||
content.copy(paginationErrorMessage = null)
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestOnAirLives(
|
||||
page: Int,
|
||||
generation: Int,
|
||||
onSuccess: (ApiResponse<HomeOnAirLivePageResponse>) -> Unit
|
||||
) {
|
||||
compositeDisposable.add(
|
||||
repository.getOnAirLives(authHeader = authHeader(), page = page)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
if (generation == requestGeneration) {
|
||||
onSuccess(it)
|
||||
}
|
||||
},
|
||||
{
|
||||
if (generation != requestGeneration) return@subscribe
|
||||
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
_isLoading.value = false
|
||||
val current = (_onAirLiveStateLiveData.value as? HomeOnAirLivePageUiState.Content)?.content
|
||||
if (current != null && page > FIRST_PAGE) {
|
||||
_onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Content(
|
||||
current.copy(isLoadingMore = false, paginationErrorMessage = it.message)
|
||||
)
|
||||
} else {
|
||||
showFirstPageError(it.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun showFirstPageError(message: String?) {
|
||||
_onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Error(message)
|
||||
_toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown)
|
||||
}
|
||||
|
||||
private fun authHeader(): String? = homeOnAirLiveAuthHeader(SharedPreferenceManager.token)
|
||||
|
||||
companion object {
|
||||
private const val FIRST_PAGE = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package kr.co.vividnext.sodalive.v2.live.onair.data
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface HomeOnAirLiveApi {
|
||||
@GET("/api/v2/home/on-air-lives")
|
||||
fun getOnAirLives(
|
||||
@Header("Authorization") authHeader: String?,
|
||||
@Query("page") page: Int
|
||||
): Single<ApiResponse<HomeOnAirLivePageResponse>>
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package kr.co.vividnext.sodalive.v2.live.onair.data
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
@Keep
|
||||
data class HomeOnAirLivePageResponse(
|
||||
@SerializedName("items") val items: List<HomeOnAirLiveResponse>,
|
||||
@SerializedName("page") val page: Int,
|
||||
@SerializedName("size") val size: Int,
|
||||
@SerializedName("hasNext") val hasNext: Boolean
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class HomeOnAirLiveResponse(
|
||||
@SerializedName("roomId") val roomId: Long,
|
||||
@SerializedName("creatorNickname") val creatorNickname: String,
|
||||
@SerializedName("creatorProfileImage") val creatorProfileImage: String,
|
||||
@SerializedName("title") val title: String,
|
||||
@SerializedName("price") val price: Int,
|
||||
@SerializedName("beginDateTimeUtc") val beginDateTimeUtc: String
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package kr.co.vividnext.sodalive.v2.live.onair.data
|
||||
|
||||
class HomeOnAirLiveRepository(
|
||||
private val api: HomeOnAirLiveApi
|
||||
) {
|
||||
fun getOnAirLives(authHeader: String?, page: Int) = api.getOnAirLives(
|
||||
authHeader = authHeader,
|
||||
page = page
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.v2.live.onair.model
|
||||
|
||||
fun homeOnAirLiveAuthHeader(token: String): String? = token
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { "Bearer $it" }
|
||||
@@ -0,0 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.live.onair.model
|
||||
|
||||
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse
|
||||
|
||||
fun canEnterHomeOnAirLiveRoom(roomDetail: GetRoomDetailResponse): Boolean {
|
||||
return roomDetail.channelName.isNullOrBlank().not()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user