Compare commits

537 Commits

Author SHA1 Message Date
1e073e85a1 docs(main): 로그인 가드 후속 진행을 기록한다 2026-06-27 03:55:36 +09:00
4a45f9699c test(home): 팔로잉 테스트 상태를 격리한다 2026-06-27 03:55:10 +09:00
a91ec763e1 feat(content): 콘텐츠 이동 가드를 보강한다 2026-06-27 03:54:45 +09:00
a0cfa70551 feat(home): 팔로잉 탭 로그인 가드를 보강한다 2026-06-27 03:54:21 +09:00
39dd003b4e docs(main): 로그인 가드 진행을 기록한다 2026-06-27 02:35:45 +09:00
6299259f89 feat(main): 로그인 가드 헬퍼를 추가한다 2026-06-27 02:35:37 +09:00
12ea65f14f docs(main): 로그인 성인 콘텐츠 가드 문서를 갱신한다 2026-06-27 01:59:41 +09:00
cc1e41e7b4 docs(live): 온에어 라이브 최종 검증을 기록한다 2026-06-27 01:27:55 +09:00
0c1ad5c746 docs(live): 온에어 라이브 화면 구현을 기록한다 2026-06-27 01:11:05 +09:00
7c775efada feat(home): 온에어 라이브 더보기를 연결한다 2026-06-27 01:10:34 +09:00
ee2f5572fc feat(live): 온에어 라이브 화면을 연결한다 2026-06-27 01:10:27 +09:00
d16f0928bb feat(live): 온에어 라이브 목록 어댑터를 추가한다 2026-06-27 01:10:20 +09:00
1c2fcdd1b4 feat(live): 온에어 라이브 화면 리소스를 추가한다 2026-06-27 01:10:11 +09:00
541771d72c test(live): 온에어 입장 가능 정책을 검증한다 2026-06-27 01:10:02 +09:00
2ad59dfd96 docs(live): 온에어 라이브 구현 검증을 기록한다 2026-06-26 23:43:26 +09:00
d3454cc293 feat(live): 온에어 라이브 ViewModel을 연결한다 2026-06-26 23:43:16 +09:00
c3377e39e6 feat(live): 온에어 라이브 매핑을 추가한다 2026-06-26 23:43:05 +09:00
6e04a10a3b feat(live): 온에어 라이브 API를 추가한다 2026-06-26 23:42:55 +09:00
e13da00215 feat(live): 인증 헤더 헬퍼를 추가한다 2026-06-26 23:42:41 +09:00
0943237b5f docs(live): 현재 진행 라이브 리스트 문서를 추가한다 2026-06-26 22:54:34 +09:00
66116351c2 docs(home): 팔로잉 탭 후속 검증을 기록한다 2026-06-26 15:43:30 +09:00
68ba58a73c test(home): 팔로잉 탭 후속 UI 검증을 보강한다 2026-06-26 15:43:03 +09:00
595bc50cde fix(home): 팔로잉 탭 후속 UI를 보정한다 2026-06-26 15:42:38 +09:00
50b45d04e5 docs(home): 팔로잉 탭 UI 검증을 기록한다 2026-06-25 23:34:32 +09:00
01d0367f50 feat(home): 팔로잉 탭 화면을 연결한다 2026-06-25 23:34:24 +09:00
f009a2edb8 feat(home): 팔로잉 소식 카드를 추가한다 2026-06-25 23:34:11 +09:00
ccd2398a7c feat(home): 팔로잉 스케줄 카드를 추가한다 2026-06-25 23:34:02 +09:00
8f5012fe2a feat(home): 팔로잉 라이브 카드를 추가한다 2026-06-25 23:33:51 +09:00
34ac433247 feat(home): 팔로잉 크리에이터와 채팅 카드를 추가한다 2026-06-25 23:33:40 +09:00
11b06d8f9c feat(home): 팔로잉 탭 DI를 등록한다 2026-06-25 22:22:41 +09:00
2128bbf197 feat(home): 팔로잉 ViewModel을 추가한다 2026-06-25 22:22:32 +09:00
502bbd4f35 feat(home): 팔로잉 응답 UI 매핑을 추가한다 2026-06-25 22:22:22 +09:00
21d3ed4603 feat(home): 팔로잉 인증 헤더를 추가한다 2026-06-25 22:22:11 +09:00
2d9200ec4d feat(home): 팔로잉 탭 문자열을 추가한다 2026-06-25 22:21:58 +09:00
a088811b2f feat(home): 팔로잉 탭 API 계약을 추가한다 2026-06-25 22:21:46 +09:00
e331a7e072 docs(home): 팔로잉 탭 요구와 계획을 기록한다 2026-06-25 22:21:33 +09:00
bb00dd13b0 docs(content): original tag 이미지 교체를 기록한다 2026-06-25 19:11:30 +09:00
7748431330 fix(content): original tag 이미지 표시를 보정한다 2026-06-25 19:11:08 +09:00
8c5c1dce53 docs(content): 전체 탭 grid 검증을 기록한다 2026-06-25 18:31:46 +09:00
136fdced17 fix(content): 전체 탭 grid 폭을 주입한다 2026-06-25 18:31:39 +09:00
78e0a53018 fix(content): 콘텐츠 카드 동적 폭을 추가한다 2026-06-25 18:31:26 +09:00
15de359d4f docs(content): 전체 탭 grid 폭 요구를 기록한다 2026-06-25 18:31:15 +09:00
c030cbabd3 fix(content): 랭킹 카드 간격과 이미지를 보정한다 2026-06-25 18:30:55 +09:00
ab9d14598c docs(content): 랭킹 카드 후속 요구를 기록한다 2026-06-25 18:29:58 +09:00
71b0c64fd2 fix(creator): 소개 본문 타이포그래피를 보정한다 2026-06-25 18:29:40 +09:00
2feacd9416 docs(creator): 소개 타이포그래피 요구를 기록한다 2026-06-25 18:29:28 +09:00
f8a5e4c44f docs(content): 크리에이터 랭킹 rank 보정을 기록한다 2026-06-25 15:59:39 +09:00
c3f576dd5f docs(content): 콘텐츠 랭킹 rank 보정 PRD를 기록한다 2026-06-25 15:59:05 +09:00
102c454f9c docs(content): 콘텐츠 랭킹 rank 보정 계획을 기록한다 2026-06-25 15:58:42 +09:00
84b5ab4279 fix(content): 크리에이터 랭킹 rank 간격을 보정한다 2026-06-25 15:58:23 +09:00
11853761cf fix(content): 콘텐츠 랭킹 rank 간격을 보정한다 2026-06-25 15:57:53 +09:00
2e86e21cb7 docs(content): 전체 탭 spacing 검증을 기록한다 2026-06-25 12:23:29 +09:00
7436ac77d9 docs(content): 추천 탭 spacing 검증을 기록한다 2026-06-25 12:23:20 +09:00
00aac29d89 fix(content): 전체 탭 grid 간격을 보정한다 2026-06-25 12:23:12 +09:00
33e137bc22 docs(content): 전체 탭 Phase 4-6 검증을 기록한다 2026-06-25 11:54:58 +09:00
f4a32086b0 feat(content): 전체 탭 화면 연결을 추가한다 2026-06-25 11:54:43 +09:00
4df724ee56 feat(content): 전체 탭 화면 레이아웃을 추가한다 2026-06-25 11:54:32 +09:00
c1a21985fd feat(content): 전체 탭 카드 어댑터를 추가한다 2026-06-25 11:54:05 +09:00
0a97194b31 feat(content): 전체 탭 ViewModel을 추가한다 2026-06-25 11:53:51 +09:00
8150b680c1 docs(content): 전체 탭 Phase 1-3 검증을 기록한다 2026-06-25 01:55:45 +09:00
cdc847dcca feat(content): 시리즈 성인 배지를 추가한다 2026-06-25 01:55:16 +09:00
af895ed510 feat(content): 전체 탭 UI 매핑을 추가한다 2026-06-25 01:55:05 +09:00
ee74519f6f feat(content): 전체 탭 요일 매핑을 추가한다 2026-06-25 01:54:52 +09:00
d47e90d340 feat(content): 전체 탭 문자열을 추가한다 2026-06-25 01:54:09 +09:00
b53853f193 feat(content): 전체 탭 DI를 등록한다 2026-06-25 01:54:01 +09:00
b69a713641 feat(content): 전체 탭 API 계약을 추가한다 2026-06-25 01:53:53 +09:00
191b64d96e docs(content): 전체 탭 구현 문서를 추가한다 2026-06-25 01:09:20 +09:00
a747729c26 docs(content): 랭킹 Phase 6 검증을 기록한다 2026-06-24 15:41:03 +09:00
8ba3ed5fb5 fix(content): 랭킹 상세 이동 guard를 보강한다 2026-06-24 15:40:14 +09:00
a9446ef5cc docs(content): 랭킹 탭 Phase 4-5 검증을 기록한다 2026-06-24 14:45:53 +09:00
2818f8d4a4 feat(content): 랭킹 탭 화면 연결을 추가한다 2026-06-24 14:45:45 +09:00
cf89052806 feat(content): 랭킹 탭 레이아웃을 추가한다 2026-06-24 14:45:34 +09:00
fcd445666e feat(content): 랭킹 탭 문자열을 추가한다 2026-06-24 14:45:27 +09:00
3599197f01 feat(content): 랭킹 ViewModel DI를 등록한다 2026-06-24 14:45:19 +09:00
f4e46f9d20 feat(content): 랭킹 ViewModel을 추가한다 2026-06-24 14:45:10 +09:00
f2996f599a feat(content): 랭킹 응답 매핑을 추가한다 2026-06-24 13:35:28 +09:00
0d0bec1904 feat(content): 랭킹 API 계약을 추가한다 2026-06-24 13:35:20 +09:00
8bc1ec5830 feat(content): 랭킹 변동 숨김 처리를 추가한다 2026-06-24 13:35:03 +09:00
b858003b6d feat(content): 랭킹 변동 표시 옵션을 추가한다 2026-06-24 13:34:53 +09:00
37b70a956c docs(content): 콘텐츠 랭킹 탭 계획을 추가한다 2026-06-24 13:34:44 +09:00
6590cf8300 docs(content): 추천 Phase 7 검증을 기록한다 2026-06-23 17:33:49 +09:00
8d33b90e67 docs(content): 추천 Phase 4-6 검증을 기록한다 2026-06-23 17:18:51 +09:00
e4d650c3e7 feat(content): 추천 API 상태를 화면에 바인딩한다 2026-06-23 17:18:39 +09:00
1a45f42f9e feat(content): 추천 시리즈 어댑터를 추가한다 2026-06-23 17:18:20 +09:00
55255621e3 feat(content): 추천 댓글 오디오 어댑터를 추가한다 2026-06-23 17:18:05 +09:00
cd323c3f69 feat(content): 추천 NewAndHot 어댑터를 추가한다 2026-06-23 17:17:48 +09:00
27add8b244 feat(content): 추천 공통 오디오 카드를 추가한다 2026-06-23 17:17:33 +09:00
0ea2a10554 feat(content): 추천 배너 경로를 연결한다 2026-06-23 17:17:17 +09:00
bc4f565074 docs(content): 추천 탭 Phase 1-3 검증을 기록한다 2026-06-23 15:51:51 +09:00
de03d2ff76 feat(content): 추천 Fragment 연결을 추가한다 2026-06-23 15:51:39 +09:00
315df46b7e feat(content): 추천 화면 레이아웃을 추가한다 2026-06-23 15:51:23 +09:00
4117afa9e4 feat(content): 추천 화면 문자열을 추가한다 2026-06-23 15:50:57 +09:00
dd742ce245 feat(content): 추천 ViewModel DI를 추가한다 2026-06-23 15:50:34 +09:00
c02437797c feat(content): 추천 UI 모델 매핑을 추가한다 2026-06-23 15:50:24 +09:00
5746239873 feat(content): 추천 API 계약을 추가한다 2026-06-23 15:50:11 +09:00
62a11023fb feat(content): 보관함 바 아이콘을 추가한다 2026-06-23 14:37:23 +09:00
d98fdf2e98 docs(content): 메인 콘텐츠 추천 탭 문서를 추가한다 2026-06-23 14:36:58 +09:00
f8e916d280 docs(home): AI 캐릭터 이동 검증을 기록한다 2026-06-23 11:34:24 +09:00
b52e7b5430 test(creator): FanTalk empty 버튼 검증 줄바꿈을 정리한다 2026-06-23 11:34:05 +09:00
51b50eed75 feat(home): AI 캐릭터 크리에이터 이동을 연결한다 2026-06-23 11:33:35 +09:00
74efe45d05 docs(creator): FanTalk empty 보정 검증을 기록한다 2026-06-23 00:16:12 +09:00
6ebd59ff9c feat(creator): FanTalk empty 위치를 보정한다 2026-06-23 00:16:00 +09:00
7e1e453a6b docs(creator): 후원 empty 후속 검증을 기록한다 2026-06-22 23:55:56 +09:00
c25acea5d4 feat(creator): 후원 empty 표시 위치를 보정한다 2026-06-22 23:55:50 +09:00
7a705b4355 feat(creator): 후원 empty 아이템을 추가한다 2026-06-22 23:55:43 +09:00
933e118c36 feat(creator): 후원 empty 랭킹 상태를 유지한다 2026-06-22 23:55:36 +09:00
092dff2a9d docs(creator): 후원 탭 후속 검증을 기록한다 2026-06-22 23:22:45 +09:00
fe19552741 test(creator): 후원 성공 테스트 기대값을 보정한다 2026-06-22 23:22:40 +09:00
0122c8c5ed feat(creator): 후원 랭킹 레이아웃을 보정한다 2026-06-22 23:22:34 +09:00
9b19be7775 feat(creator): 후원 floating button 위치를 조정한다 2026-06-22 23:22:26 +09:00
4d79cb65cd docs(creator): 후원 탭 후속 검증을 기록한다 2026-06-22 22:07:17 +09:00
8db913812d feat(creator): 후원 탭 Activity 연결을 추가한다 2026-06-22 22:07:12 +09:00
a7ce991f7d feat(creator): 후원 탭 어댑터 연결을 추가한다 2026-06-22 22:07:06 +09:00
ba6616c81a fix(creator): 후원 empty 실패 메시지를 표시한다 2026-06-22 22:06:58 +09:00
4097181923 feat(creator): 후원 empty 액션을 연결한다 2026-06-22 22:06:52 +09:00
ecaa1f01e8 feat(creator): 후원 empty 레이아웃을 보정한다 2026-06-22 22:06:46 +09:00
a3ea2051cb docs(creator): 후원 탭 구현 검증을 기록한다 2026-06-22 21:24:50 +09:00
5ca5da45ba feat(creator): 후원 탭 화면을 구현한다 2026-06-22 21:24:43 +09:00
77d889d9ab feat(creator): 후원 탭 어댑터를 추가한다 2026-06-22 21:24:35 +09:00
6c8d3dfc76 feat(creator): 후원 탭 레이아웃을 추가한다 2026-06-22 21:24:09 +09:00
bb8442a32d feat(creator): 후원 탭 문자열을 추가한다 2026-06-22 21:24:00 +09:00
32504349cd feat(creator): 후원 탭 상태 관리를 추가한다 2026-06-22 21:23:52 +09:00
0344518130 feat(creator): 후원 탭 UI 모델 매핑을 추가한다 2026-06-22 21:23:42 +09:00
eb71034365 feat(creator): 후원 탭 API 계약을 추가한다 2026-06-22 21:21:59 +09:00
5a9449059b docs(creator): 후원 탭 Phase 1 검증을 기록한다 2026-06-22 19:16:33 +09:00
3a94878020 docs(creator): 크리에이터 채널 후원 탭 문서를 추가한다 2026-06-22 18:38:00 +09:00
f6190030a4 docs(creator): FanTalk 탭 Phase 6 검증을 기록한다 2026-06-22 18:02:22 +09:00
b04c01c930 docs(creator): FanTalk 탭 Phase 4와 5 검증을 기록한다 2026-06-22 17:46:41 +09:00
40ef5710fb feat(creator): FanTalk 탭 화면 연결을 추가한다 2026-06-22 17:46:25 +09:00
790e08f1b5 feat(creator): FanTalk 더보기 팝업을 추가한다 2026-06-22 17:45:36 +09:00
270fe32d94 feat(creator): FanTalk 목록 아이템을 추가한다 2026-06-22 17:45:20 +09:00
c87a6878b5 feat(creator): FanTalk 목록 레이아웃을 추가한다 2026-06-22 17:44:56 +09:00
c4906bd0b5 feat(creator): FanTalk 탭 문자열을 추가한다 2026-06-22 17:43:06 +09:00
4012b92357 docs(creator): FanTalk 탭 구현 검증을 기록한다 2026-06-22 16:38:15 +09:00
50449f43ac feat(creator): FanTalk 탭 상태 관리를 추가한다 2026-06-22 16:38:08 +09:00
b7eba4c99a feat(creator): FanTalk 탭 UI 모델 매핑을 추가한다 2026-06-22 16:38:00 +09:00
25320e283d feat(creator): FanTalk 탭 API 계약을 추가한다 2026-06-22 16:37:53 +09:00
6517e739b3 docs(creator): FanTalk 탭 후속 검증을 기록한다 2026-06-22 15:44:28 +09:00
4f7dd97be6 docs(creator): FanTalk 탭 문서를 추가한다 2026-06-22 15:18:58 +09:00
7ddfbb0a18 docs(creator): 커뮤니티 탭 검증 결과를 기록한다 2026-06-22 14:41:52 +09:00
2d41b07852 docs(creator): 커뮤니티 탭 후속 검증을 기록한다 2026-06-22 14:28:26 +09:00
7958547b28 docs(creator): 커뮤니티 탭 요구사항을 보정한다 2026-06-22 14:28:15 +09:00
b0346ae00c fix(creator): 커뮤니티 리스트 표시를 보정한다 2026-06-22 14:28:08 +09:00
8f5c55e0d1 fix(creator): 커뮤니티 썸네일 그리드를 보정한다 2026-06-22 14:28:02 +09:00
4288e7284b fix(creator): 커뮤니티 탭 하단 재평가를 보정한다 2026-06-22 14:27:53 +09:00
cb5d4f954d docs(creator): 커뮤니티 탭 Phase 5 검증을 기록한다 2026-06-22 01:44:34 +09:00
a36c3b74e8 feat(creator): 커뮤니티 탭 activity 동작을 연결한다 2026-06-22 01:44:29 +09:00
e29ae4fedb feat(creator): 커뮤니티 탭 pager 연결을 추가한다 2026-06-22 01:44:20 +09:00
74c11f2aa6 docs(repo): 레거시 코드 원칙을 추가한다 2026-06-22 01:44:14 +09:00
3e4c00fee8 test(creator): 커뮤니티 탭 레이아웃 검증을 추가한다 2026-06-22 00:36:49 +09:00
318944fbfe fix(creator): 커뮤니티 게시글 표시 정책을 보정한다 2026-06-22 00:36:42 +09:00
7ccc676192 feat(creator): 커뮤니티 탭 화면을 구현한다 2026-06-22 00:36:34 +09:00
2b21e07571 feat(creator): 커뮤니티 탭 어댑터를 추가한다 2026-06-22 00:36:27 +09:00
6ae28e4d84 feat(creator): 커뮤니티 탭 레이아웃을 추가한다 2026-06-22 00:36:21 +09:00
d6b49eb3e8 docs(creator): 커뮤니티 탭 Phase 3 검증을 기록한다 2026-06-21 22:32:47 +09:00
88fcbe49f4 feat(creator): 커뮤니티 상태에 UI 모델을 적용한다 2026-06-21 22:32:33 +09:00
f9501c156a feat(creator): 커뮤니티 게시글 UI 모델을 추가한다 2026-06-21 22:32:23 +09:00
efe12774f7 feat(creator): 커뮤니티 보기 방식 문자열을 추가한다 2026-06-21 22:31:54 +09:00
9b5bcbe41e docs(creator): 커뮤니티 탭 Phase 2 검증을 기록한다 2026-06-21 20:44:38 +09:00
d4448820d6 feat(creator): 커뮤니티 탭 상태 관리를 추가한다 2026-06-21 20:44:27 +09:00
744132fd7e feat(creator): 커뮤니티 탭 API 계약을 추가한다 2026-06-21 20:44:15 +09:00
a444dd8677 docs(creator): 채널 커뮤니티 탭 사전 확인을 기록한다 2026-06-21 19:35:09 +09:00
546a665ba3 feat(creator): 채널 커뮤니티 탭 아이콘을 추가한다 2026-06-21 19:28:36 +09:00
6bff74cd1e docs(creator): 채널 커뮤니티 탭 문서를 추가한다 2026-06-21 19:28:30 +09:00
1dc6aa5283 docs(creator): 시리즈 탭 최종 검증을 기록한다 2026-06-20 05:42:56 +09:00
a2dba0456b docs(creator): 시리즈 탭 구현 검증을 기록한다 2026-06-20 04:50:50 +09:00
015a6ac865 feat(creator): 시리즈 탭 activity 연동을 추가한다 2026-06-20 04:50:43 +09:00
fcf35e2513 feat(creator): 시리즈 탭 pager 연결을 추가한다 2026-06-20 04:50:37 +09:00
92cea6d3ee feat(creator): 시리즈 탭 UI를 구현한다 2026-06-20 04:50:30 +09:00
7ea06fda2f feat(creator): 시리즈 탭 문자열을 추가한다 2026-06-20 04:50:22 +09:00
a9456abfb0 refactor(creator): 시리즈 subtitle 모델을 분리한다 2026-06-20 04:49:36 +09:00
3dcc48c9d9 docs(creator): 시리즈 탭 Phase 2와 3 검증을 기록한다 2026-06-20 02:53:43 +09:00
c25d4cd161 feat(creator): 시리즈 탭 상태 관리를 추가한다 2026-06-20 02:53:24 +09:00
185c92e9af feat(creator): 시리즈 탭 API 계약을 추가한다 2026-06-20 02:53:16 +09:00
8fae9e6a96 docs(creator): 시리즈 탭 사전 확인을 기록한다 2026-06-20 02:13:54 +09:00
8a5fc48650 docs(creator): 시리즈 탭 구현 계획을 기록한다 2026-06-20 02:05:23 +09:00
688ba0a63d docs(creator): 라이브 오디오 후속 보정을 기록한다 2026-06-19 21:47:25 +09:00
a0f263c1fd fix(creator): 오디오 탭 표시를 보정한다 2026-06-19 21:47:19 +09:00
e3cea856b8 fix(creator): 라이브 empty 배치를 보정한다 2026-06-19 21:47:12 +09:00
df78b8a3f5 fix(creator): 탭 ViewPager 높이 계산을 보정한다 2026-06-19 21:47:06 +09:00
1a9a78ec70 docs(creator): 오디오 탭 Phase 5와 6 검증을 기록한다 2026-06-19 21:04:20 +09:00
1f855102ce fix(creator): 오디오 탭 owner reload를 보정한다 2026-06-19 21:04:15 +09:00
757f242285 feat(creator): 오디오 탭 activity 연동을 추가한다 2026-06-19 21:04:08 +09:00
bcbc48540e feat(creator): 오디오 탭 콘텐츠 UI를 연결한다 2026-06-19 21:04:01 +09:00
5b89d6c6d7 feat(creator): 오디오 탭 pager 연결을 추가한다 2026-06-19 21:03:55 +09:00
3a421d2a60 refactor(creator): 라이브 replay adapter를 공통화한다 2026-06-19 21:03:47 +09:00
9d7bc6969b refactor(creator): 오디오 콘텐츠 공통 모델을 추가한다 2026-06-19 21:03:33 +09:00
e12f00b5b4 feat(creator): 오디오 탭 fragment 골격을 추가한다 2026-06-19 19:13:00 +09:00
0b2faf2c6e feat(creator): 오디오 탭 레이아웃을 추가한다 2026-06-19 19:12:54 +09:00
763a86704c test(creator): 라이브 공통 UI 검증을 갱신한다 2026-06-19 19:12:47 +09:00
6df7666cec refactor(creator): 라이브 replay item을 공통화한다 2026-06-19 19:12:35 +09:00
d97c2792f5 refactor(creator): 라이브 sort popup을 공통 컴포넌트로 전환한다 2026-06-19 19:11:29 +09:00
f848f22029 feat(creator): 채널 sort popup을 공통 컴포넌트로 추가한다 2026-06-19 19:11:12 +09:00
011a762ecf feat(creator): 채널 sort popup 리소스를 공통화한다 2026-06-19 19:11:05 +09:00
241532f60e feat(creator): 채널 sort 공통 모델을 추가한다 2026-06-19 19:10:58 +09:00
6770dbd682 docs(creator): 오디오 탭 공통 UI 재사용 계획을 갱신한다 2026-06-19 17:59:24 +09:00
12a9d9f398 docs(creator): 오디오 탭 Phase 3 검증을 기록한다 2026-06-19 17:39:51 +09:00
c82513eaf0 fix(creator): 오디오 탭 theme 선택 정규화를 보정한다 2026-06-19 17:39:41 +09:00
845b36828b feat(creator): 오디오 탭 mapper를 추가한다 2026-06-19 17:39:31 +09:00
d0843d94ed docs(creator): 오디오 탭 Phase 2 검증을 기록한다 2026-06-19 15:41:42 +09:00
c9d911f339 feat(creator): 오디오 탭 상태 관리를 추가한다 2026-06-19 15:41:34 +09:00
4e4d13b4de feat(creator): 오디오 탭 API 계약을 추가한다 2026-06-19 15:41:25 +09:00
a0274518d2 docs(creator): 오디오 탭 Phase 1 검증을 기록한다 2026-06-19 14:48:01 +09:00
88dd7cc04c docs(creator): 오디오 탭 구현 계획을 기록한다 2026-06-19 14:26:11 +09:00
dacd3a67a1 docs(dm): DM push deep_link 계약을 기록한다 2026-06-19 05:03:37 +09:00
da64806d88 fix(dm): MainV2Activity chat path 라우팅을 보정한다 2026-06-19 05:03:32 +09:00
a87d2990b0 fix(dm): MainActivity chat path 라우팅을 보정한다 2026-06-19 05:03:25 +09:00
5e95aa4168 fix(dm): DeepLinkActivity chat path 라우팅을 보정한다 2026-06-19 05:03:20 +09:00
9c642fb3b7 fix(dm): FCM deep_link payload 알림 생성을 보정한다 2026-06-19 05:03:14 +09:00
8b515bba97 docs(dm): Phase 13 검증 기록을 갱신한다 2026-06-19 03:57:44 +09:00
1550014670 docs(dm): Phase 12 검증 기록을 갱신한다 2026-06-19 00:10:12 +09:00
a6862c51e4 test(dm): 제거 endpoint와 음성 범위 회귀를 고정한다 2026-06-19 00:10:05 +09:00
de90b34cd4 fix(dm): USER_CREATOR DM push 라우팅을 보정한다 2026-06-19 00:09:59 +09:00
bbb84d4ffa fix(dm): FCM DM payload 보존을 보정한다 2026-06-19 00:09:51 +09:00
f560adabfa docs(dm): WebSocket lifecycle 검증을 기록한다 2026-06-18 23:31:20 +09:00
8f69c1ab82 fix(dm): WebSocket heartbeat와 token 재연결을 보정한다 2026-06-18 23:31:15 +09:00
a6485292e4 docs(dm): MESSAGE race 검증을 기록한다 2026-06-18 22:57:48 +09:00
482d517145 fix(dm): MESSAGE 선도착 ACK 처리를 보정한다 2026-06-18 22:57:36 +09:00
2c1eb03e5f feat(dm): 메시지 전송 pending을 requestId로 관리한다 2026-06-18 19:09:40 +09:00
e640ee6c46 docs(dm): WebSocket 연결 완료 검증을 기록한다 2026-06-18 18:42:30 +09:00
e8ae5b9639 fix(dm): WebSocket 연결 완료 시점을 보정한다 2026-06-18 18:42:24 +09:00
32e33a243a docs(dm): WebSocket 후속 검증 계획을 보강한다 2026-06-18 18:26:56 +09:00
379284ca4f docs(dm): WebSocket 저장소 전환 검증을 기록한다 2026-06-18 18:26:16 +09:00
dd7a6465c1 refactor(dm): 채팅 화면 전송을 WebSocket으로 전환한다 2026-06-18 18:25:43 +09:00
deba733522 refactor(dm): 채팅 저장소를 WebSocket 기준으로 전환한다 2026-06-18 18:25:29 +09:00
3d71def880 docs(dm): WebSocket 검증 기록을 갱신한다 2026-06-18 17:41:56 +09:00
c5bcaf7329 feat(dm): WebSocket 클라이언트를 추가한다 2026-06-18 17:41:41 +09:00
e76562067f feat(dm): WebSocket 계약 모델을 추가한다 2026-06-18 17:41:31 +09:00
0e03a1a14a docs(dm): WebSocket 전환 계획을 기록한다 2026-06-18 17:03:07 +09:00
fa4e41589b docs(creator): 라이브 탭 후속 검증을 기록한다 2026-06-18 15:22:17 +09:00
b11cf53f67 fix(creator): 라이브 기본 조회 개수를 보정한다 2026-06-18 15:22:11 +09:00
7db7fdffdf fix(creator): 라이브 다시듣기 item 표시를 보정한다 2026-06-18 15:21:32 +09:00
efac753f83 fix(creator): 라이브 empty와 CTA 목록 여백을 보정한다 2026-06-18 15:21:26 +09:00
f4af9868e6 fix(creator): 라이브 탭 하단 CTA와 sticky 전환을 보정한다 2026-06-18 15:21:18 +09:00
a1e8f8edb3 docs(creator): 라이브 Phase 7 검증을 기록한다 2026-06-18 11:26:43 +09:00
7469172cfc docs(creator): 라이브 본인 CTA 검증을 기록한다 2026-06-18 11:13:17 +09:00
a49951c51f feat(creator): 라이브 탭 본인 CTA를 연결한다 2026-06-18 11:12:55 +09:00
0d11839a96 docs(creator): 라이브 정렬 팝업 검증을 기록한다 2026-06-18 00:12:14 +09:00
8213d2de42 feat(creator): 라이브 정렬 팝업을 연결한다 2026-06-18 00:11:49 +09:00
decc2cbb31 feat(creator): 라이브 정렬 팝업 UI를 추가한다 2026-06-18 00:11:28 +09:00
b639782e89 docs(creator): 라이브 탭 UI 검증을 기록한다 2026-06-17 23:24:45 +09:00
10004652e4 fix(creator): 라이브 더보기 상태 보존을 보정한다 2026-06-17 23:24:39 +09:00
e90fb04de9 feat(creator): 라이브 탭 화면을 연결한다 2026-06-17 23:24:34 +09:00
7fb52b3c85 feat(creator): 라이브 탭 레이아웃을 추가한다 2026-06-17 23:24:13 +09:00
dd13c619ac feat(creator): 라이브 탭 배경 리소스를 추가한다 2026-06-17 23:24:08 +09:00
41ef04b193 feat(creator): 라이브 탭 상태 문구를 추가한다 2026-06-17 23:23:55 +09:00
747569c1cc docs(creator): 라이브 다시듣기 mapper 검증을 기록한다 2026-06-17 19:14:03 +09:00
1f0adb21a7 feat(creator): 라이브 다시듣기 mapper를 추가한다 2026-06-17 19:13:58 +09:00
f015aea062 feat(creator): 라이브 탭 정렬 문구를 추가한다 2026-06-17 19:13:50 +09:00
3f16cc9312 docs(creator): 라이브 탭 API 계약을 기록한다 2026-06-17 18:47:12 +09:00
c1ac428ded test(creator): 채널 홈 source 검증을 갱신한다 2026-06-17 18:47:03 +09:00
4a38703873 feat(creator): 라이브 탭 ViewModel을 추가한다 2026-06-17 18:46:57 +09:00
e5d4f1d40d feat(creator): 라이브 탭 API 계약을 추가한다 2026-06-17 18:46:50 +09:00
d25f509118 docs(creator): 채널 공통 저장소 rename을 기록한다 2026-06-17 16:14:19 +09:00
ecaeea6262 refactor(creator): 채널 공통 저장소 이름을 정리한다 2026-06-17 16:13:56 +09:00
cff8469604 docs(creator): 라이브 탭 문서를 갱신한다 2026-06-17 15:54:53 +09:00
9f242f0201 feat(creator): 라이브 탭 아이콘을 추가한다 2026-06-17 15:44:45 +09:00
bb3c63e2fb docs(creator): 라이브 탭 구현 계획을 추가한다 2026-06-17 15:44:27 +09:00
ca0c586c0c docs(creator): 원본 태그 교체를 기록한다 2026-06-17 14:15:42 +09:00
bd60185db8 feat(creator): 원본 태그 이미지를 적용한다 2026-06-17 14:15:24 +09:00
01afed89f8 docs(dm): creatorId 진입 crash 수정을 기록한다 2026-06-17 13:52:49 +09:00
3dacc50e1b fix(creator): 채널 버튼 drawable compat을 적용한다 2026-06-17 13:52:44 +09:00
236b874e82 fix(dm): creatorId 방 열기 thread를 보정한다 2026-06-17 13:52:38 +09:00
26f44dd448 docs(creator): 채널 라이브 검증을 기록한다 2026-06-17 10:59:00 +09:00
f2f2a3143d fix(creator): 채널 라이브 진입을 보강한다 2026-06-17 10:58:49 +09:00
34876cf46f feat(creator): 라이브 생성 안내 문구를 추가한다 2026-06-17 10:58:31 +09:00
deda8322d5 feat(creator): 라이브 coordinator를 추가한다 2026-06-17 10:58:19 +09:00
ba0aef4349 docs(creator): 본인 FAB 검증을 기록한다 2026-06-16 22:24:42 +09:00
5d52787ea9 feat(creator): 본인 FAB 액션을 연결한다 2026-06-16 22:24:30 +09:00
6a6b1138a8 feat(creator): 본인 홈 FAB를 추가한다 2026-06-16 22:03:49 +09:00
722f84039f feat(creator): 본인 FAB 문구를 추가한다 2026-06-16 22:03:41 +09:00
710ce2602b feat(creator): 본인 FAB 배경을 추가한다 2026-06-16 22:03:35 +09:00
3e96a5435e docs(creator): 본인 FAB 기준을 갱신한다 2026-06-16 22:03:28 +09:00
433bf172ce fix(creator): 채널 후원 empty 레이아웃을 보정한다 2026-06-16 21:22:45 +09:00
5969f50888 fix(creator): 채널 홈 활동 표시를 보정한다 2026-06-16 21:19:03 +09:00
9900ac02f5 fix(creator): 채널 홈 카드 태그 크기를 보정한다 2026-06-16 21:18:38 +09:00
ee4de78c6c feat(creator): 시리즈 상세 이동을 연결한다 2026-06-16 21:18:26 +09:00
43c2f6f417 fix(creator): 채널 활동 dday 응답을 매핑한다 2026-06-16 21:18:15 +09:00
f7c1a5168f docs(creator): 채널 홈 후원 검증을 기록한다 2026-06-16 19:22:42 +09:00
28433c10df feat(creator): 채널 후원 버튼을 연결한다 2026-06-16 19:22:28 +09:00
de351d700c feat(creator): 채널 후원 요청을 연결한다 2026-06-16 19:22:21 +09:00
a01675b592 feat(creator): 채널 후원 empty 상태를 매핑한다 2026-06-16 19:21:54 +09:00
6ba5bf2cb1 feat(creator): 채널 후원 empty 리소스를 추가한다 2026-06-16 19:21:45 +09:00
f6395b5a3e test(creator): 채널 홈 후속 동작을 검증한다 2026-06-16 17:27:52 +09:00
1cd676bcb4 feat(creator): 채널 상단 액션을 연결한다 2026-06-16 17:27:38 +09:00
0bb5796da1 feat(main): 채팅 DM 필터 진입을 추가한다 2026-06-16 17:27:29 +09:00
5f4140ea68 feat(creator): 채널 본인 상태를 계산한다 2026-06-16 17:27:24 +09:00
b2878e42ea feat(creator): 채널 신고 차단 동작을 연결한다 2026-06-16 17:27:13 +09:00
e8bd31454e feat(creator): 채널 더보기 메뉴를 추가한다 2026-06-16 17:27:08 +09:00
59545ca82c feat(creator): 채널 홈 상단 UI 상태를 보정한다 2026-06-16 17:27:03 +09:00
cd1f9db8ae docs(creator): 채널 홈 후속 요구사항을 기록한다 2026-06-16 17:26:57 +09:00
d719470f8c docs(creator): 채널 홈 탭 전환 검증을 기록한다 2026-06-16 14:35:51 +09:00
f3c19ed8ba feat(creator): 채널 홈 탭 전환을 연결한다 2026-06-16 14:35:39 +09:00
984aa13edf feat(creator): 채널 탭 프래그먼트 골격을 추가한다 2026-06-16 14:35:23 +09:00
5ac900f3b7 docs(guide): 검증 기록 위치 규칙을 정리한다 2026-06-16 12:25:49 +09:00
a21427b549 docs(creator): 채널 홈 탭 전환 계획을 보강한다 2026-06-16 12:20:38 +09:00
f667bf1096 feat(creator): 본인 페이지 FAB 아이콘을 추가한다 2026-06-16 12:13:24 +09:00
458cdc3280 docs(creator): 채널 홈 후속 요구사항을 정리한다 2026-06-16 12:13:10 +09:00
2cf492d634 docs(creator): 채널 홈 검증 기록을 보강한다 2026-06-15 23:34:13 +09:00
e16bc306f7 refactor(creator): 채널 홈 액티비티 이름을 정리한다 2026-06-15 23:33:41 +09:00
fcb198c8a8 docs(creator): 채널 홈 최종 검증을 기록한다 2026-06-15 22:40:53 +09:00
40679a624b fix(creator): 오디션 지원자 프로필 이동을 복구한다 2026-06-15 22:40:42 +09:00
a2be1739a6 docs(creator): 채널 홈 Phase 7 검증을 기록한다 2026-06-15 21:01:58 +09:00
b529a83fe6 feat(profile): 내 채널 진입점을 전환한다 2026-06-15 21:01:40 +09:00
20491906fd feat(follow): 팔로잉 채널 진입점을 전환한다 2026-06-15 21:01:34 +09:00
9a8559af8b feat(search): 탐색 검색 채널 진입점을 전환한다 2026-06-15 21:01:28 +09:00
541333bc44 feat(live): 라이브 채널 진입점을 전환한다 2026-06-15 21:01:21 +09:00
9c4c0506af feat(home): 홈 채널 진입점을 전환한다 2026-06-15 21:01:11 +09:00
00b5a3687e feat(main): 메인 채널 진입점을 전환한다 2026-06-15 21:01:03 +09:00
b41e5c6074 feat(audio): 오디오 채널 진입점을 전환한다 2026-06-15 21:00:49 +09:00
19b8e4750f feat(creator): 채널 홈 팔로우와 탭 동작을 연결한다 2026-06-15 21:00:42 +09:00
bfb5440c9e fix(creator): 채널 홈 SNS xurl 파싱을 수정한다 2026-06-15 21:00:12 +09:00
1ce974938d docs(creator): 채널 홈 스크롤 구현 검증을 기록한다 2026-06-15 19:10:54 +09:00
dc217f97af feat(creator): 채널 홈 탭 고정 스크롤을 연결한다 2026-06-15 19:10:49 +09:00
d3bfc57294 feat(creator): 채널 홈 스크롤 상태 계산을 추가한다 2026-06-15 19:10:43 +09:00
cd5fbc0858 docs(creator): 채널 홈 소개 활동 SNS 검증을 기록한다 2026-06-15 18:43:53 +09:00
2d2844338a feat(creator): 채널 홈 소개 활동 SNS 섹션을 재구성한다 2026-06-15 18:43:47 +09:00
1f3abfde4f feat(creator): 채널 홈 SNS 아이콘 레이아웃을 추가한다 2026-06-15 18:43:39 +09:00
2d58a876da feat(creator): 채널 홈 시리즈, 커뮤니티, 팬 Talk 섹션을 재구성한다 2026-06-15 17:22:32 +09:00
c45c5a2a5c docs(creator): 채널 홈 오디오 섹션 검증을 기록한다 2026-06-15 15:01:46 +09:00
5fde0bc469 feat(creator): 채널 홈 오디오 섹션을 재구성한다 2026-06-15 15:01:41 +09:00
edb7d98de7 feat(creator): 채널 홈 오디오 콘텐츠 카드를 추가한다 2026-06-15 15:01:21 +09:00
573df4318b test(creator): 채널 홈 화면 계약을 검증한다 2026-06-15 13:21:40 +09:00
febd718796 fix(creator): 채널 홈 토스트 이벤트를 단발 처리한다 2026-06-15 13:21:34 +09:00
a20655badb test(creator): 채널 홈 매퍼 검증을 보강한다 2026-06-15 13:21:28 +09:00
11fd892310 feat(creator): 채널 홈 섹션 렌더링을 연결한다 2026-06-15 13:21:15 +09:00
2253dabfe9 feat(creator): 채널 홈 일정 카드를 추가한다 2026-06-15 13:21:07 +09:00
e2575e2b15 feat(creator): 채널 홈 공지 카드를 추가한다 2026-06-15 13:21:01 +09:00
05f724dbe1 feat(creator): 채널 홈 후원 카드를 추가한다 2026-06-15 13:20:54 +09:00
d4b3a9a8a4 feat(creator): 채널 홈 최신 오디오 카드를 추가한다 2026-06-15 13:20:47 +09:00
74687daf28 feat(creator): 채널 홈 라이브 카드를 추가한다 2026-06-15 13:20:42 +09:00
a8ba791349 feat(creator): 채널 홈 헤더 리소스를 추가한다 2026-06-15 13:20:35 +09:00
21383ced46 feat(creator): 채널 홈 문구 리소스를 추가한다 2026-06-15 13:20:25 +09:00
a631aa1b65 feat(creator): 채널 홈 Activity 진입점을 추가한다 2026-06-15 13:20:11 +09:00
402ea5e9c0 docs(creator): 채널 홈 세부 구현 계획을 갱신한다 2026-06-15 13:19:56 +09:00
654a74aacf docs(creator): 채널 홈 Phase 4 검증을 기록한다 2026-06-13 17:20:17 +09:00
3027934295 feat(creator): 채널 홈 ViewModel을 추가한다 2026-06-13 17:20:06 +09:00
a355838039 feat(creator): 채널 홈 UI 상태를 추가한다 2026-06-13 17:19:58 +09:00
0ae6596816 docs(creator): 채널 홈 탭 계획을 갱신한다 2026-06-13 16:22:05 +09:00
da23253a9c feat(creator): 채널 홈 저장소를 등록한다 2026-06-13 16:21:59 +09:00
0934a7ec51 feat(creator): 채널 홈 API 모델을 추가한다 2026-06-13 16:21:53 +09:00
80e8213f12 refactor(home): 추천 활동 타입을 공용 타입으로 교체한다 2026-06-13 16:21:46 +09:00
55b4d9bc8d feat(common): 크리에이터 활동 타입을 공용화한다 2026-06-13 16:21:34 +09:00
92fdd6ab54 docs(creator): 채널 홈 탭 채팅 계획을 보완한다 2026-06-12 23:47:29 +09:00
3514104e7c feat(creator): 채널 액션 아이콘을 추가한다 2026-06-12 16:42:47 +09:00
122c559e57 feat(creator): 채널 타이틀바 아이콘을 추가한다 2026-06-12 16:42:42 +09:00
b2cb08e96a docs(creator): 채널 홈 탭 계획을 기록한다 2026-06-12 16:42:22 +09:00
9e9533c7a6 fix(audio): 핀 아이콘 표시 크기를 고정한다 2026-06-11 13:28:01 +09:00
f6ab1bd4ef fix(audio): 핀 아이콘 리소스 밀도를 보정한다 2026-06-11 13:27:52 +09:00
f47a9d6d93 docs(chat): DM Phase 6 검증을 기록한다 2026-06-11 12:06:03 +09:00
ff728af7cc docs(test): DM 채팅 테스트 명령을 추가한다 2026-06-11 12:05:57 +09:00
1e8beb458c feat(chat): DM 채팅방 실행 구성을 등록한다 2026-06-11 12:05:51 +09:00
a6d8cd54f3 fix(chat): DM realtime 정리 누락을 보정한다 2026-06-11 12:05:45 +09:00
841ed5f6f8 fix(chat): DM SSE read timeout을 제거한다 2026-06-11 12:05:39 +09:00
0263e64f40 docs(chat): DM 채팅 Phase 5 검증을 기록한다 2026-06-11 11:17:46 +09:00
a0b95ea3bd feat(chat): 채팅 탭에서 DM 채팅방을 연다 2026-06-11 11:17:26 +09:00
590a52c605 feat(chat): DM 채팅방 Activity를 추가한다 2026-06-11 11:17:04 +09:00
f2687b8243 feat(chat): DM 채팅 실시간 수신을 연결한다 2026-06-11 11:16:44 +09:00
871f4e73e8 feat(chat): DM SSE 재연결 기반을 추가한다 2026-06-11 11:16:19 +09:00
e19442a61a docs(chat): DM 채팅 Phase 4 검증을 기록한다 2026-06-10 19:27:09 +09:00
3285958918 feat(chat): DM 재시도 아이콘을 추가한다 2026-06-10 19:26:46 +09:00
06088c578f feat(chat): DM 채팅방 레이아웃을 추가한다 2026-06-10 19:26:11 +09:00
63a4f5b4f7 docs(chat): DM 채팅 Phase 3 검증을 기록한다 2026-06-10 18:48:36 +09:00
56f110c548 feat(chat): DM 채팅방 ViewModel을 추가한다 2026-06-10 18:48:27 +09:00
406c377d13 docs(chat): DM 채팅 Phase 2 검증을 기록한다 2026-06-10 18:11:59 +09:00
fd0382ea65 feat(chat): DM 채팅 SSE 클라이언트를 추가한다 2026-06-10 18:11:53 +09:00
a289849a07 feat(chat): DM 채팅 저장소를 추가한다 2026-06-10 18:11:47 +09:00
630f84c3e5 feat(chat): DM 채팅 메시지 매퍼를 추가한다 2026-06-10 17:39:52 +09:00
e1ae7df0ee feat(chat): DM 채팅 API 모델을 추가한다 2026-06-10 17:39:41 +09:00
5f7fc68c8c docs(chat): DM 채팅화면 계획을 기록한다 2026-06-10 17:39:36 +09:00
f4f561b396 docs(chat): 통합 검증 결과를 기록한다 2026-06-10 15:28:56 +09:00
40f8355037 docs(chat): 타이틀바 액션 간격 검증을 기록한다 2026-06-10 15:20:33 +09:00
ccc4a822e3 fix(chat): 타이틀바 액션 간격을 보정한다 2026-06-10 15:20:09 +09:00
70177d8f81 docs(chat): 채팅 탭 스크롤 요구사항을 기록한다 2026-06-10 15:06:41 +09:00
b38e58af9a feat(chat): 채팅 탭 화면 동작을 연결한다 2026-06-10 15:06:35 +09:00
516e4a94bf fix(widget): CapsuleTab 여백과 선택 배경을 보정한다 2026-06-10 15:06:29 +09:00
cdb491b21b docs(chat): 채팅 탭 리뷰 개선을 기록한다 2026-06-10 14:27:40 +09:00
a769fc6a64 docs(chat): 채팅 탭 Phase 6 완료를 기록한다 2026-06-10 14:27:16 +09:00
2c30da8110 fix(widget): CapsuleTab 선택 색상을 보정한다 2026-06-10 14:26:45 +09:00
ee703eb13a feat(chat): 채팅 탭 기본 layout을 추가한다 2026-06-10 14:26:39 +09:00
55fb032b22 docs(chat): 채팅방 Adapter 검증을 기록한다 2026-06-10 13:26:03 +09:00
346671b3e2 feat(chat): 채팅방 목록 Adapter를 추가한다 2026-06-10 13:25:41 +09:00
5574e68b16 feat(chat): 채팅방 항목 레이아웃을 추가한다 2026-06-10 13:25:25 +09:00
445d91d594 feat(chat): 채팅방 필터 문자열을 추가한다 2026-06-10 13:25:09 +09:00
896935e19a docs(chat): 채팅방 ViewModel 검증을 기록한다 2026-06-10 12:00:34 +09:00
bb17f0014a feat(chat): 채팅방 ViewModel을 추가한다 2026-06-10 12:00:27 +09:00
ed8a0e9a09 refactor(chat): 채팅방 시간 표시 책임을 분리한다 2026-06-10 12:00:18 +09:00
10a74c2e5e docs(guide): 채팅방 테스트 명령을 추가한다 2026-06-10 11:30:14 +09:00
d66743ae1e docs(chat): 채팅방 모델 작업 검증을 기록한다 2026-06-10 11:30:07 +09:00
5a17d7d2f6 feat(chat): 채팅방 UI 모델 매핑을 추가한다 2026-06-10 11:30:00 +09:00
50f0fb3d15 feat(chat): 채팅방 시간 표시를 추가한다 2026-06-10 11:29:52 +09:00
4c351da60c feat(chat): 채팅방 필터 모델을 추가한다 2026-06-10 11:05:39 +09:00
89837877a2 docs(chat): 채팅방 목록 데이터 계층 작업을 기록한다 2026-06-09 23:30:19 +09:00
32c30132b9 feat(chat): 채팅방 목록 API와 저장소를 추가한다 2026-06-09 23:30:11 +09:00
06e4b5ad34 refactor(chat): 채팅방 모델 파일명을 정리한다 2026-06-09 23:29:50 +09:00
972d225a86 feat(chat): 채팅방 목록 응답 모델을 추가한다 2026-06-09 23:14:19 +09:00
7acc7b51b6 refactor(chat): 채팅 Fragment 패키지를 분리한다 2026-06-09 23:14:09 +09:00
86e18a1f7c docs(chat): 채팅 탭 구현 계획을 추가한다 2026-06-09 23:13:59 +09:00
bb4d290ca1 refactor(home): 홈 메인 Fragment 패키지를 분리한다 2026-06-09 18:17:57 +09:00
4e98a1dfea fix(home): 채팅 탭 문구를 대화로 변경한다 2026-06-09 18:06:21 +09:00
c3bbf9203d docs(home): 랭킹 순위 영역 보정 검증을 기록한다 2026-06-09 15:13:14 +09:00
64fb55db55 fix(widget): 랭킹 순위 영역 위치를 보정한다 2026-06-09 15:13:01 +09:00
ea076a5ac7 docs(home): 랭킹 순위 정렬 검증을 기록한다 2026-06-09 14:56:04 +09:00
6b0232b7e4 test(widget): 랭킹 순위 텍스트 정렬 검증을 추가한다 2026-06-09 14:55:58 +09:00
178397923b docs(home): 랭킹 페이지 최종 검증을 기록한다 2026-06-08 18:22:35 +09:00
de4e90b98e docs(home): 랭킹 탭 검증을 기록한다 2026-06-08 18:07:07 +09:00
5d66014044 feat(home): 랭킹 탭 목록을 연결한다 2026-06-08 18:07:00 +09:00
bb60f8bb9f docs(home): 크리에이터 랭킹 ViewModel 검증을 기록한다 2026-06-08 17:41:35 +09:00
b199804827 feat(home): 크리에이터 랭킹 의존성을 등록한다 2026-06-08 17:41:29 +09:00
37dc3cd24a feat(home): 크리에이터 랭킹 ViewModel을 추가한다 2026-06-08 17:41:25 +09:00
a4710ef6bb docs(guide): 테스트명 예외 규칙을 추가한다 2026-06-08 15:22:53 +09:00
28423d81bb docs(home): 크리에이터 랭킹 검증을 기록한다 2026-06-08 15:22:48 +09:00
21e94af8d1 feat(home): 크리에이터 랭킹 응답 매핑을 추가한다 2026-06-08 15:22:16 +09:00
6d980e319b feat(widget): 랭킹 카드 순위 변동 숨김을 적용한다 2026-06-08 15:22:10 +09:00
4a21827b47 feat(widget): 랭킹 순위 변동 표시 옵션을 추가한다 2026-06-08 15:22:02 +09:00
9600147240 docs(home): 크리에이터 랭킹 계획을 추가한다 2026-06-08 14:01:27 +09:00
ecc59376a3 docs(home): 최근 활동 이동 검증을 기록한다 2026-06-06 00:08:54 +09:00
4b4a23c92e feat(home): 최근 활동 카드 이동을 연결한다 2026-06-06 00:08:41 +09:00
c36eddb207 docs(home): 배너 이동 검증을 기록한다 2026-06-05 23:04:51 +09:00
e160a10708 feat(home): 추천 배너 이동을 연결한다 2026-06-05 23:04:40 +09:00
cdcd938cdf docs(home): 프로필과 상대 시간 검증을 기록한다 2026-06-05 22:01:23 +09:00
58e69be510 feat(home): 추천 시간과 프로필 표시를 보완한다 2026-06-05 22:01:16 +09:00
7f417c3a3f feat(common): UTC 상대 시간 포매터를 추가한다 2026-06-05 22:01:08 +09:00
9378f96b73 docs(home): 추천 안정화 검증을 기록한다 2026-06-05 20:54:01 +09:00
7e78ace304 docs(home): 추천 배너 요구사항을 갱신한다 2026-06-05 20:53:55 +09:00
a3aa406e13 test(home): 추천 응답 스키마 검증을 추가한다 2026-06-05 20:53:50 +09:00
8371dc7baf feat(home): 추천 UI 바인딩을 갱신한다 2026-06-05 20:53:44 +09:00
44457349aa feat(home): 추천 응답 모델을 갱신한다 2026-06-05 20:53:34 +09:00
51226cf5cc fix(widget): 배너 가상 목록 갱신을 안정화한다 2026-06-05 20:53:28 +09:00
7f307346f3 fix(common): 이미지 캐시 디렉터리를 분리한다 2026-06-05 20:53:20 +09:00
9de4493e89 docs(home): 추천 상태 연결 검증을 기록한다 2026-06-05 16:01:43 +09:00
c6680d0bd2 feat(home): 추천 상태 observe를 연결한다 2026-06-05 16:01:37 +09:00
6679808a18 feat(home): 콘텐츠 카드 클릭을 연결한다 2026-06-05 16:01:31 +09:00
7a4fa3c50f feat(home): 크리에이터 카드 클릭을 연결한다 2026-06-05 16:01:23 +09:00
7c0af85aaa docs(home): 사업자 정보 검증을 기록한다 2026-06-05 14:30:55 +09:00
8457194bb5 fix(home): 사업자 정보 문구를 공용 리소스로 정리한다 2026-06-05 14:30:44 +09:00
f07132c48b feat(home): 사업자 정보 inline 더보기를 추가한다 2026-06-05 14:30:34 +09:00
9c20b86373 docs(home): 인기 커뮤니티 검증을 기록한다 2026-06-05 13:17:14 +09:00
293f34ca13 test(home): 인기 커뮤니티 섹션 검증을 추가한다 2026-06-05 13:17:06 +09:00
dca06cdc3c feat(home): 인기 커뮤니티 섹션을 바인딩한다 2026-06-05 13:16:59 +09:00
1cbf989577 feat(home): 인기 커뮤니티 adapter를 추가한다 2026-06-05 13:16:53 +09:00
5b3b7c72d2 feat(widget): 커뮤니티 이미지와 유료 overlay를 추가한다 2026-06-05 13:16:47 +09:00
cd274a6d2f docs(home): 응원 크리에이터 검증을 기록한다 2026-06-04 20:18:12 +09:00
edf4a94494 feat(home): 응원 크리에이터 카드 바인딩을 추가한다 2026-06-04 20:18:06 +09:00
d5f46e6325 feat(home): 응원 크리에이터 카드 리소스를 추가한다 2026-06-04 20:18:00 +09:00
22ac5b0b54 docs(home): 장르 크리에이터 수정 검증을 기록한다 2026-06-04 19:38:14 +09:00
bb29fc8010 test(home): 장르 크리에이터 페이지 검증을 추가한다 2026-06-04 19:38:08 +09:00
c733796aeb feat(home): 장르 크리에이터 그룹 바인딩을 추가한다 2026-06-04 19:38:03 +09:00
c714a9d4c8 feat(home): 장르 크리에이터 페이지 리소스를 추가한다 2026-06-04 19:37:56 +09:00
c436866a51 docs(home): 모두 팔로우 버튼 검증을 기록한다 2026-06-04 17:59:36 +09:00
02480a96e9 fix(home): 모두 팔로우 완료 상태를 반영한다 2026-06-04 17:59:29 +09:00
3e8ea0473f feat(home): 모두 팔로우 버튼 배경을 추가한다 2026-06-04 17:59:23 +09:00
99c2082022 docs(agent): clipping 처리 가이드를 추가한다 2026-06-02 19:34:48 +09:00
4629ef00a9 fix(widget): 캐릭터 채팅 썸네일 clipping을 Kotlin에서 설정한다 2026-06-02 19:34:19 +09:00
a5b4d5046d docs(home): AI 캐릭터 섹션 검증 기록을 추가한다 2026-06-02 19:21:23 +09:00
29e64188c9 feat(home): AI 캐릭터 섹션 간격을 정리한다 2026-06-02 19:20:57 +09:00
8a73ea0472 docs(home): 첫 오디오 섹션 검증 기록을 추가한다 2026-06-02 19:00:30 +09:00
b402025ca5 feat(widget): 오디오 콘텐츠 카드 배지를 정리한다 2026-06-02 19:00:13 +09:00
bd475d1c87 feat(home): 첫 오디오 콘텐츠 카드를 정리한다 2026-06-02 18:59:57 +09:00
816641d7c5 docs(home): 최근 데뷔 섹션 검증 기록을 추가한다 2026-06-02 17:28:47 +09:00
3028288bb3 feat(home): 최근 데뷔 크리에이터 카드를 정리한다 2026-06-02 17:28:40 +09:00
089e980832 feat(home): 홈 추천 섹션 간격을 정리한다 2026-06-02 17:27:57 +09:00
f93236b2d0 docs(home): 라이브 섹션 검증 기록을 추가한다 2026-06-02 17:05:23 +09:00
9b29623f6f feat(home): 라이브 섹션 전체 아이템을 추가한다 2026-06-02 17:04:53 +09:00
0e50d7f8d5 docs(home): adapter 파일 분리 기록을 추가한다 2026-06-02 16:34:27 +09:00
9a8231c2b6 refactor(home): 홈 추천 adapter 파일을 분리한다 2026-06-02 16:34:16 +09:00
0a5be6467c docs(home): Phase 6 검증 기록을 추가한다 2026-06-02 16:23:45 +09:00
716f3c6880 test(home): 홈 추천 섹션 레이아웃 검증을 추가한다 2026-06-02 16:23:36 +09:00
b20866fedd feat(home): 홈 추천 섹션 바인딩을 추가한다 2026-06-02 16:23:28 +09:00
14e7b33b63 feat(home): 최근 활동 크리에이터 카드를 정리한다 2026-06-02 16:23:21 +09:00
cfa297ac3f feat(home): 홈 추천 크리에이터 카드 리소스를 추가한다 2026-06-02 16:23:11 +09:00
e0e64090cd docs(home): Phase 5 검증 기록을 추가한다 2026-06-02 14:51:28 +09:00
68f8d869cc feat(home): 홈 추천 상단 UI shell을 추가한다 2026-06-02 14:51:01 +09:00
d07a2837d9 feat(home): 홈 추천 섹션 문자열을 정리한다 2026-06-02 14:50:31 +09:00
caee72f9c3 docs(home): Phase 4 검증 기록을 추가한다 2026-06-02 13:29:36 +09:00
4870b7377a feat(home): 홈 추천 ViewModel을 추가한다 2026-06-02 13:29:01 +09:00
dd002e9f82 feat(home): 홈 추천 UI 상태 모델을 추가한다 2026-06-02 13:28:32 +09:00
4743c9cb8f feat(home): 홈 추천 UI 아이콘 리소스를 추가한다 2026-06-02 12:10:35 +09:00
3132269038 feat(home): 홈 추천 DI 등록을 추가한다 2026-06-02 12:10:18 +09:00
9eb1f7709d feat(home): 홈 추천 문자열 리소스를 추가한다 2026-06-02 12:10:01 +09:00
4817641155 feat(home): 홈 추천 activity type 매퍼를 추가한다 2026-06-02 12:09:46 +09:00
c74ceba495 feat(home): 홈 추천 API 데이터 계층을 추가한다 2026-06-02 12:09:25 +09:00
0d339f9565 docs(home): 메인 홈 추천 요구사항을 문서화한다 2026-06-02 12:09:04 +09:00
ee58794441 docs(workflow): 문서 폴더명 공백 대체 규칙 추가 2026-06-02 00:18:38 +09:00
4ab2490534 docs(agent): 계획 문서 규칙 수정 2026-06-01 19:34:16 +09:00
9a47c42958 docs(banner): Phase 9와 10 검증을 기록한다 2026-05-28 14:41:24 +09:00
a35310e536 feat(banner): 배너 시각 정렬을 보완한다 2026-05-28 14:41:17 +09:00
ecb9f5a260 docs(banner): Phase 8 검증을 기록한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-28 13:11:12 +09:00
8dd2371ce4 feat(banner): Phase 8 배너 동작을 보완한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-28 13:10:38 +09:00
df782d7968 docs(banner): Phase 7 검증을 기록한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-28 12:12:24 +09:00
7672a3bbe8 style(widget): 테스트 unused import를 제거한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-28 12:10:27 +09:00
9a56c124cc style(banner): 배너 ktlint 지적을 정리한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-28 12:10:02 +09:00
2fa9561a09 docs(banner): Phase 6 검증을 기록한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-28 11:51:20 +09:00
462d9c90b5 feat(banner): 배너 preview 회귀 테스트를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-28 11:51:14 +09:00
db72c4bf7d docs(banner): 배너 wrap content 요구를 기록한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-28 11:14:12 +09:00
49984cb651 feat(banner): 배너 wrap content 높이를 지원한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-28 11:14:02 +09:00
dcc76abf94 docs(banner): Phase 5 검증을 기록한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-28 10:50:30 +09:00
bc15a0997e feat(banner): 배너 자동 전환 동작을 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-28 10:50:23 +09:00
31b4e93bed docs(banner): 배너 Phase 3과 4 검증을 기록한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-28 00:08:56 +09:00
fe509365e2 feat(banner): 배너 어댑터와 뷰를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-28 00:08:41 +09:00
2e5af796e4 feat(banner): 배너 XML 레이아웃 속성을 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-28 00:08:25 +09:00
e2d848b7e5 feat(banner): 배너 디자인 리소스를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-28 00:08:10 +09:00
4d0d242162 docs(agent): 커밋 메시지 검증 규칙을 명시한다 2026-05-27 22:41:04 +09:00
1ffcd16efa docs(banner): Phase 2 검증과 테스트명 규칙을 기록한다 2026-05-27 22:28:19 +09:00
02f85f808d feat(banner): 배너 카운터와 상태 계약을 추가한다 2026-05-27 22:27:40 +09:00
91a7eb3f4c feat(banner): 배너 레이아웃 계산 계약을 추가한다 2026-05-27 22:26:50 +09:00
0c8c9f9b5f feat(banner): 배너 아이템 계약을 추가한다 2026-05-27 22:26:16 +09:00
8ae09d0514 docs(banner): 배너 컴포넌트 계획을 문서화한다 2026-05-27 21:10:57 +09:00
413a2f2fc1 docs(agent): 계획 문서 phase task 규칙을 추가한다 2026-05-27 20:56:17 +09:00
9ff4a8159a docs(agent): 실행 정책 중복을 정리한다 2026-05-27 20:20:13 +09:00
abc0cfe461 chore(opencode): 커밋 정책 스킬을 제거한다 2026-05-27 20:19:25 +09:00
9a67c45dab docs(agent): 분리된 규칙 문서 참조를 갱신한다 2026-05-27 20:14:15 +09:00
add6b66ea5 docs(agent): 작업 계획과 커밋 규칙 문서를 분리한다 2026-05-27 20:12:43 +09:00
11fdf89340 docs(workflow): 작업 계획 커밋 규칙 분리를 계획한다 2026-05-27 20:11:35 +09:00
9e37347877 chore(gitignore): antigravitycli 생성물을 제외한다 2026-05-27 19:40:08 +09:00
ef81cba7eb chore(gitignore): omo 생성물을 제외한다 2026-05-27 14:54:43 +09:00
799dd7fc92 feat(widget): 오디오 콘텐츠 태그 배지를 추가한다 2026-05-27 14:50:59 +09:00
a8e0f2377d feat(feed): 피드 어댑터와 뷰 테스트를 추가한다 2026-05-21 15:53:40 +09:00
59ea5de00a feat(feed): 커뮤니티 피드 뷰를 추가한다 2026-05-21 15:53:40 +09:00
77eef9609a feat(feed): 콘텐츠 피드 뷰를 추가한다 2026-05-21 15:53:40 +09:00
8e9ce634e3 feat(feed): 라이브 피드 뷰를 추가한다 2026-05-21 15:53:40 +09:00
4d0d330797 feat(feed): 랭킹 피드 뷰를 추가한다 2026-05-21 15:53:40 +09:00
a5728bcc4d feat(feed): 피드 크기 계산 계약을 추가한다 2026-05-21 15:53:40 +09:00
3444b1eeef feat(feed): 랭킹 강조 텍스트 계약을 추가한다 2026-05-21 15:53:39 +09:00
a2f3910e27 feat(feed): 피드 아이템 계약을 추가한다 2026-05-21 15:53:39 +09:00
01765f3e7f feat(feed): 피드 반응 아이콘 리소스를 추가한다 2026-05-21 15:53:39 +09:00
1baf62a2b7 feat(feed): 피드 공통 배경 리소스를 추가한다 2026-05-21 15:53:39 +09:00
e5f298eaa7 feat(feed): 피드 문자열 리소스를 추가한다 2026-05-21 15:53:39 +09:00
d312ce7cd8 docs(feed): 피드 컴포넌트 요구사항을 문서화한다 2026-05-21 15:53:39 +09:00
0764bced76 docs(agent): 코드 스타일 토큰 사용 원칙을 문서화한다 2026-05-21 11:32:35 +09:00
c32f9cdd9f feat(widget): 캐릭터 채팅 썸네일 컴포넌트를 추가한다 2026-05-21 11:22:23 +09:00
c58f03be08 feat(widget): 라이브 썸네일 컴포넌트를 추가한다 2026-05-20 17:55:19 +09:00
960e78afac feat(widget): 시리즈 콘텐츠 카드 컴포넌트를 추가한다 2026-05-20 14:06:33 +09:00
36ffbc6cdb feat(widget): 콘텐츠 랭킹 위젯을 추가한다 2026-05-20 12:00:23 +09:00
01fea58e4c feat(widget): 크리에이터 랭킹 위젯을 추가한다 2026-05-20 10:41:07 +09:00
6fda122091 feat(widget): 오디오 콘텐츠 카드 컴포넌트를 추가한다 2026-05-19 23:52:53 +09:00
30264935dc feat(widget): 섹션 타이틀 컴포넌트를 추가한다 2026-05-19 21:07:15 +09:00
646 changed files with 73480 additions and 297 deletions

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -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`를 참고한다.
## 핵심 금지사항

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.v2.common.data
enum class ContentSort {
LATEST,
POPULAR,
OWNED,
PRICE_HIGH,
PRICE_LOW
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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