537 Commits
Author SHA1 Message Date
klaus 1e073e85a1 docs(main): 로그인 가드 후속 진행을 기록한다 2026-06-27 03:55:36 +09:00
klaus 4a45f9699c test(home): 팔로잉 테스트 상태를 격리한다 2026-06-27 03:55:10 +09:00
klaus a91ec763e1 feat(content): 콘텐츠 이동 가드를 보강한다 2026-06-27 03:54:45 +09:00
klaus a0cfa70551 feat(home): 팔로잉 탭 로그인 가드를 보강한다 2026-06-27 03:54:21 +09:00
klaus 39dd003b4e docs(main): 로그인 가드 진행을 기록한다 2026-06-27 02:35:45 +09:00
klaus 6299259f89 feat(main): 로그인 가드 헬퍼를 추가한다 2026-06-27 02:35:37 +09:00
klaus 12ea65f14f docs(main): 로그인 성인 콘텐츠 가드 문서를 갱신한다 2026-06-27 01:59:41 +09:00
klaus cc1e41e7b4 docs(live): 온에어 라이브 최종 검증을 기록한다 2026-06-27 01:27:55 +09:00
klaus 0c1ad5c746 docs(live): 온에어 라이브 화면 구현을 기록한다 2026-06-27 01:11:05 +09:00
klaus 7c775efada feat(home): 온에어 라이브 더보기를 연결한다 2026-06-27 01:10:34 +09:00
klaus ee2f5572fc feat(live): 온에어 라이브 화면을 연결한다 2026-06-27 01:10:27 +09:00
klaus d16f0928bb feat(live): 온에어 라이브 목록 어댑터를 추가한다 2026-06-27 01:10:20 +09:00
klaus 1c2fcdd1b4 feat(live): 온에어 라이브 화면 리소스를 추가한다 2026-06-27 01:10:11 +09:00
klaus 541771d72c test(live): 온에어 입장 가능 정책을 검증한다 2026-06-27 01:10:02 +09:00
klaus 2ad59dfd96 docs(live): 온에어 라이브 구현 검증을 기록한다 2026-06-26 23:43:26 +09:00
klaus d3454cc293 feat(live): 온에어 라이브 ViewModel을 연결한다 2026-06-26 23:43:16 +09:00
klaus c3377e39e6 feat(live): 온에어 라이브 매핑을 추가한다 2026-06-26 23:43:05 +09:00
klaus 6e04a10a3b feat(live): 온에어 라이브 API를 추가한다 2026-06-26 23:42:55 +09:00
klaus e13da00215 feat(live): 인증 헤더 헬퍼를 추가한다 2026-06-26 23:42:41 +09:00
klaus 0943237b5f docs(live): 현재 진행 라이브 리스트 문서를 추가한다 2026-06-26 22:54:34 +09:00
klaus 66116351c2 docs(home): 팔로잉 탭 후속 검증을 기록한다 2026-06-26 15:43:30 +09:00
klaus 68ba58a73c test(home): 팔로잉 탭 후속 UI 검증을 보강한다 2026-06-26 15:43:03 +09:00
klaus 595bc50cde fix(home): 팔로잉 탭 후속 UI를 보정한다 2026-06-26 15:42:38 +09:00
klaus 50b45d04e5 docs(home): 팔로잉 탭 UI 검증을 기록한다 2026-06-25 23:34:32 +09:00
klaus 01d0367f50 feat(home): 팔로잉 탭 화면을 연결한다 2026-06-25 23:34:24 +09:00
klaus f009a2edb8 feat(home): 팔로잉 소식 카드를 추가한다 2026-06-25 23:34:11 +09:00
klaus ccd2398a7c feat(home): 팔로잉 스케줄 카드를 추가한다 2026-06-25 23:34:02 +09:00
klaus 8f5012fe2a feat(home): 팔로잉 라이브 카드를 추가한다 2026-06-25 23:33:51 +09:00
klaus 34ac433247 feat(home): 팔로잉 크리에이터와 채팅 카드를 추가한다 2026-06-25 23:33:40 +09:00
klaus 11b06d8f9c feat(home): 팔로잉 탭 DI를 등록한다 2026-06-25 22:22:41 +09:00
klaus 2128bbf197 feat(home): 팔로잉 ViewModel을 추가한다 2026-06-25 22:22:32 +09:00
klaus 502bbd4f35 feat(home): 팔로잉 응답 UI 매핑을 추가한다 2026-06-25 22:22:22 +09:00
klaus 21d3ed4603 feat(home): 팔로잉 인증 헤더를 추가한다 2026-06-25 22:22:11 +09:00
klaus 2d9200ec4d feat(home): 팔로잉 탭 문자열을 추가한다 2026-06-25 22:21:58 +09:00
klaus a088811b2f feat(home): 팔로잉 탭 API 계약을 추가한다 2026-06-25 22:21:46 +09:00
klaus e331a7e072 docs(home): 팔로잉 탭 요구와 계획을 기록한다 2026-06-25 22:21:33 +09:00
klaus bb00dd13b0 docs(content): original tag 이미지 교체를 기록한다 2026-06-25 19:11:30 +09:00
klaus 7748431330 fix(content): original tag 이미지 표시를 보정한다 2026-06-25 19:11:08 +09:00
klaus 8c5c1dce53 docs(content): 전체 탭 grid 검증을 기록한다 2026-06-25 18:31:46 +09:00
klaus 136fdced17 fix(content): 전체 탭 grid 폭을 주입한다 2026-06-25 18:31:39 +09:00
klaus 78e0a53018 fix(content): 콘텐츠 카드 동적 폭을 추가한다 2026-06-25 18:31:26 +09:00
klaus 15de359d4f docs(content): 전체 탭 grid 폭 요구를 기록한다 2026-06-25 18:31:15 +09:00
klaus c030cbabd3 fix(content): 랭킹 카드 간격과 이미지를 보정한다 2026-06-25 18:30:55 +09:00
klaus ab9d14598c docs(content): 랭킹 카드 후속 요구를 기록한다 2026-06-25 18:29:58 +09:00
klaus 71b0c64fd2 fix(creator): 소개 본문 타이포그래피를 보정한다 2026-06-25 18:29:40 +09:00
klaus 2feacd9416 docs(creator): 소개 타이포그래피 요구를 기록한다 2026-06-25 18:29:28 +09:00
klaus f8a5e4c44f docs(content): 크리에이터 랭킹 rank 보정을 기록한다 2026-06-25 15:59:39 +09:00
klaus c3f576dd5f docs(content): 콘텐츠 랭킹 rank 보정 PRD를 기록한다 2026-06-25 15:59:05 +09:00
klaus 102c454f9c docs(content): 콘텐츠 랭킹 rank 보정 계획을 기록한다 2026-06-25 15:58:42 +09:00
klaus 84b5ab4279 fix(content): 크리에이터 랭킹 rank 간격을 보정한다 2026-06-25 15:58:23 +09:00
klaus 11853761cf fix(content): 콘텐츠 랭킹 rank 간격을 보정한다 2026-06-25 15:57:53 +09:00
klaus 2e86e21cb7 docs(content): 전체 탭 spacing 검증을 기록한다 2026-06-25 12:23:29 +09:00
klaus 7436ac77d9 docs(content): 추천 탭 spacing 검증을 기록한다 2026-06-25 12:23:20 +09:00
klaus 00aac29d89 fix(content): 전체 탭 grid 간격을 보정한다 2026-06-25 12:23:12 +09:00
klaus 33e137bc22 docs(content): 전체 탭 Phase 4-6 검증을 기록한다 2026-06-25 11:54:58 +09:00
klaus f4a32086b0 feat(content): 전체 탭 화면 연결을 추가한다 2026-06-25 11:54:43 +09:00
klaus 4df724ee56 feat(content): 전체 탭 화면 레이아웃을 추가한다 2026-06-25 11:54:32 +09:00
klaus c1a21985fd feat(content): 전체 탭 카드 어댑터를 추가한다 2026-06-25 11:54:05 +09:00
klaus 0a97194b31 feat(content): 전체 탭 ViewModel을 추가한다 2026-06-25 11:53:51 +09:00
klaus 8150b680c1 docs(content): 전체 탭 Phase 1-3 검증을 기록한다 2026-06-25 01:55:45 +09:00
klaus cdc847dcca feat(content): 시리즈 성인 배지를 추가한다 2026-06-25 01:55:16 +09:00
klaus af895ed510 feat(content): 전체 탭 UI 매핑을 추가한다 2026-06-25 01:55:05 +09:00
klaus ee74519f6f feat(content): 전체 탭 요일 매핑을 추가한다 2026-06-25 01:54:52 +09:00
klaus d47e90d340 feat(content): 전체 탭 문자열을 추가한다 2026-06-25 01:54:09 +09:00
klaus b53853f193 feat(content): 전체 탭 DI를 등록한다 2026-06-25 01:54:01 +09:00
klaus b69a713641 feat(content): 전체 탭 API 계약을 추가한다 2026-06-25 01:53:53 +09:00
klaus 191b64d96e docs(content): 전체 탭 구현 문서를 추가한다 2026-06-25 01:09:20 +09:00
klaus a747729c26 docs(content): 랭킹 Phase 6 검증을 기록한다 2026-06-24 15:41:03 +09:00
klaus 8ba3ed5fb5 fix(content): 랭킹 상세 이동 guard를 보강한다 2026-06-24 15:40:14 +09:00
klaus a9446ef5cc docs(content): 랭킹 탭 Phase 4-5 검증을 기록한다 2026-06-24 14:45:53 +09:00
klaus 2818f8d4a4 feat(content): 랭킹 탭 화면 연결을 추가한다 2026-06-24 14:45:45 +09:00
klaus cf89052806 feat(content): 랭킹 탭 레이아웃을 추가한다 2026-06-24 14:45:34 +09:00
klaus fcd445666e feat(content): 랭킹 탭 문자열을 추가한다 2026-06-24 14:45:27 +09:00
klaus 3599197f01 feat(content): 랭킹 ViewModel DI를 등록한다 2026-06-24 14:45:19 +09:00
klaus f4e46f9d20 feat(content): 랭킹 ViewModel을 추가한다 2026-06-24 14:45:10 +09:00
klaus f2996f599a feat(content): 랭킹 응답 매핑을 추가한다 2026-06-24 13:35:28 +09:00
klaus 0d0bec1904 feat(content): 랭킹 API 계약을 추가한다 2026-06-24 13:35:20 +09:00
klaus 8bc1ec5830 feat(content): 랭킹 변동 숨김 처리를 추가한다 2026-06-24 13:35:03 +09:00
klaus b858003b6d feat(content): 랭킹 변동 표시 옵션을 추가한다 2026-06-24 13:34:53 +09:00
klaus 37b70a956c docs(content): 콘텐츠 랭킹 탭 계획을 추가한다 2026-06-24 13:34:44 +09:00
klaus 6590cf8300 docs(content): 추천 Phase 7 검증을 기록한다 2026-06-23 17:33:49 +09:00
klaus 8d33b90e67 docs(content): 추천 Phase 4-6 검증을 기록한다 2026-06-23 17:18:51 +09:00
klaus e4d650c3e7 feat(content): 추천 API 상태를 화면에 바인딩한다 2026-06-23 17:18:39 +09:00
klaus 1a45f42f9e feat(content): 추천 시리즈 어댑터를 추가한다 2026-06-23 17:18:20 +09:00
klaus 55255621e3 feat(content): 추천 댓글 오디오 어댑터를 추가한다 2026-06-23 17:18:05 +09:00
klaus cd323c3f69 feat(content): 추천 NewAndHot 어댑터를 추가한다 2026-06-23 17:17:48 +09:00
klaus 27add8b244 feat(content): 추천 공통 오디오 카드를 추가한다 2026-06-23 17:17:33 +09:00
klaus 0ea2a10554 feat(content): 추천 배너 경로를 연결한다 2026-06-23 17:17:17 +09:00
klaus bc4f565074 docs(content): 추천 탭 Phase 1-3 검증을 기록한다 2026-06-23 15:51:51 +09:00
klaus de03d2ff76 feat(content): 추천 Fragment 연결을 추가한다 2026-06-23 15:51:39 +09:00
klaus 315df46b7e feat(content): 추천 화면 레이아웃을 추가한다 2026-06-23 15:51:23 +09:00
klaus 4117afa9e4 feat(content): 추천 화면 문자열을 추가한다 2026-06-23 15:50:57 +09:00
klaus dd742ce245 feat(content): 추천 ViewModel DI를 추가한다 2026-06-23 15:50:34 +09:00
klaus c02437797c feat(content): 추천 UI 모델 매핑을 추가한다 2026-06-23 15:50:24 +09:00
klaus 5746239873 feat(content): 추천 API 계약을 추가한다 2026-06-23 15:50:11 +09:00
klaus 62a11023fb feat(content): 보관함 바 아이콘을 추가한다 2026-06-23 14:37:23 +09:00
klaus d98fdf2e98 docs(content): 메인 콘텐츠 추천 탭 문서를 추가한다 2026-06-23 14:36:58 +09:00
klaus f8e916d280 docs(home): AI 캐릭터 이동 검증을 기록한다 2026-06-23 11:34:24 +09:00
klaus b52e7b5430 test(creator): FanTalk empty 버튼 검증 줄바꿈을 정리한다 2026-06-23 11:34:05 +09:00
klaus 51b50eed75 feat(home): AI 캐릭터 크리에이터 이동을 연결한다 2026-06-23 11:33:35 +09:00
klaus 74efe45d05 docs(creator): FanTalk empty 보정 검증을 기록한다 2026-06-23 00:16:12 +09:00
klaus 6ebd59ff9c feat(creator): FanTalk empty 위치를 보정한다 2026-06-23 00:16:00 +09:00
klaus 7e1e453a6b docs(creator): 후원 empty 후속 검증을 기록한다 2026-06-22 23:55:56 +09:00
klaus c25acea5d4 feat(creator): 후원 empty 표시 위치를 보정한다 2026-06-22 23:55:50 +09:00
klaus 7a705b4355 feat(creator): 후원 empty 아이템을 추가한다 2026-06-22 23:55:43 +09:00
klaus 933e118c36 feat(creator): 후원 empty 랭킹 상태를 유지한다 2026-06-22 23:55:36 +09:00
klaus 092dff2a9d docs(creator): 후원 탭 후속 검증을 기록한다 2026-06-22 23:22:45 +09:00
klaus fe19552741 test(creator): 후원 성공 테스트 기대값을 보정한다 2026-06-22 23:22:40 +09:00
klaus 0122c8c5ed feat(creator): 후원 랭킹 레이아웃을 보정한다 2026-06-22 23:22:34 +09:00
klaus 9b19be7775 feat(creator): 후원 floating button 위치를 조정한다 2026-06-22 23:22:26 +09:00
klaus 4d79cb65cd docs(creator): 후원 탭 후속 검증을 기록한다 2026-06-22 22:07:17 +09:00
klaus 8db913812d feat(creator): 후원 탭 Activity 연결을 추가한다 2026-06-22 22:07:12 +09:00
klaus a7ce991f7d feat(creator): 후원 탭 어댑터 연결을 추가한다 2026-06-22 22:07:06 +09:00
klaus ba6616c81a fix(creator): 후원 empty 실패 메시지를 표시한다 2026-06-22 22:06:58 +09:00
klaus 4097181923 feat(creator): 후원 empty 액션을 연결한다 2026-06-22 22:06:52 +09:00
klaus ecaa1f01e8 feat(creator): 후원 empty 레이아웃을 보정한다 2026-06-22 22:06:46 +09:00
klaus a3ea2051cb docs(creator): 후원 탭 구현 검증을 기록한다 2026-06-22 21:24:50 +09:00
klaus 5ca5da45ba feat(creator): 후원 탭 화면을 구현한다 2026-06-22 21:24:43 +09:00
klaus 77d889d9ab feat(creator): 후원 탭 어댑터를 추가한다 2026-06-22 21:24:35 +09:00
klaus 6c8d3dfc76 feat(creator): 후원 탭 레이아웃을 추가한다 2026-06-22 21:24:09 +09:00
klaus bb8442a32d feat(creator): 후원 탭 문자열을 추가한다 2026-06-22 21:24:00 +09:00
klaus 32504349cd feat(creator): 후원 탭 상태 관리를 추가한다 2026-06-22 21:23:52 +09:00
klaus 0344518130 feat(creator): 후원 탭 UI 모델 매핑을 추가한다 2026-06-22 21:23:42 +09:00
klaus eb71034365 feat(creator): 후원 탭 API 계약을 추가한다 2026-06-22 21:21:59 +09:00
klaus 5a9449059b docs(creator): 후원 탭 Phase 1 검증을 기록한다 2026-06-22 19:16:33 +09:00
klaus 3a94878020 docs(creator): 크리에이터 채널 후원 탭 문서를 추가한다 2026-06-22 18:38:00 +09:00
klaus f6190030a4 docs(creator): FanTalk 탭 Phase 6 검증을 기록한다 2026-06-22 18:02:22 +09:00
klaus b04c01c930 docs(creator): FanTalk 탭 Phase 4와 5 검증을 기록한다 2026-06-22 17:46:41 +09:00
klaus 40ef5710fb feat(creator): FanTalk 탭 화면 연결을 추가한다 2026-06-22 17:46:25 +09:00
klaus 790e08f1b5 feat(creator): FanTalk 더보기 팝업을 추가한다 2026-06-22 17:45:36 +09:00
klaus 270fe32d94 feat(creator): FanTalk 목록 아이템을 추가한다 2026-06-22 17:45:20 +09:00
klaus c87a6878b5 feat(creator): FanTalk 목록 레이아웃을 추가한다 2026-06-22 17:44:56 +09:00
klaus c4906bd0b5 feat(creator): FanTalk 탭 문자열을 추가한다 2026-06-22 17:43:06 +09:00
klaus 4012b92357 docs(creator): FanTalk 탭 구현 검증을 기록한다 2026-06-22 16:38:15 +09:00
klaus 50449f43ac feat(creator): FanTalk 탭 상태 관리를 추가한다 2026-06-22 16:38:08 +09:00
klaus b7eba4c99a feat(creator): FanTalk 탭 UI 모델 매핑을 추가한다 2026-06-22 16:38:00 +09:00
klaus 25320e283d feat(creator): FanTalk 탭 API 계약을 추가한다 2026-06-22 16:37:53 +09:00
klaus 6517e739b3 docs(creator): FanTalk 탭 후속 검증을 기록한다 2026-06-22 15:44:28 +09:00
klaus 4f7dd97be6 docs(creator): FanTalk 탭 문서를 추가한다 2026-06-22 15:18:58 +09:00
klaus 7ddfbb0a18 docs(creator): 커뮤니티 탭 검증 결과를 기록한다 2026-06-22 14:41:52 +09:00
klaus 2d41b07852 docs(creator): 커뮤니티 탭 후속 검증을 기록한다 2026-06-22 14:28:26 +09:00
klaus 7958547b28 docs(creator): 커뮤니티 탭 요구사항을 보정한다 2026-06-22 14:28:15 +09:00
klaus b0346ae00c fix(creator): 커뮤니티 리스트 표시를 보정한다 2026-06-22 14:28:08 +09:00
klaus 8f5c55e0d1 fix(creator): 커뮤니티 썸네일 그리드를 보정한다 2026-06-22 14:28:02 +09:00
klaus 4288e7284b fix(creator): 커뮤니티 탭 하단 재평가를 보정한다 2026-06-22 14:27:53 +09:00
klaus cb5d4f954d docs(creator): 커뮤니티 탭 Phase 5 검증을 기록한다 2026-06-22 01:44:34 +09:00
klaus a36c3b74e8 feat(creator): 커뮤니티 탭 activity 동작을 연결한다 2026-06-22 01:44:29 +09:00
klaus e29ae4fedb feat(creator): 커뮤니티 탭 pager 연결을 추가한다 2026-06-22 01:44:20 +09:00
klaus 74c11f2aa6 docs(repo): 레거시 코드 원칙을 추가한다 2026-06-22 01:44:14 +09:00
klaus 3e4c00fee8 test(creator): 커뮤니티 탭 레이아웃 검증을 추가한다 2026-06-22 00:36:49 +09:00
klaus 318944fbfe fix(creator): 커뮤니티 게시글 표시 정책을 보정한다 2026-06-22 00:36:42 +09:00
klaus 7ccc676192 feat(creator): 커뮤니티 탭 화면을 구현한다 2026-06-22 00:36:34 +09:00
klaus 2b21e07571 feat(creator): 커뮤니티 탭 어댑터를 추가한다 2026-06-22 00:36:27 +09:00
klaus 6ae28e4d84 feat(creator): 커뮤니티 탭 레이아웃을 추가한다 2026-06-22 00:36:21 +09:00
klaus d6b49eb3e8 docs(creator): 커뮤니티 탭 Phase 3 검증을 기록한다 2026-06-21 22:32:47 +09:00
klaus 88fcbe49f4 feat(creator): 커뮤니티 상태에 UI 모델을 적용한다 2026-06-21 22:32:33 +09:00
klaus f9501c156a feat(creator): 커뮤니티 게시글 UI 모델을 추가한다 2026-06-21 22:32:23 +09:00
klaus efe12774f7 feat(creator): 커뮤니티 보기 방식 문자열을 추가한다 2026-06-21 22:31:54 +09:00
klaus 9b5bcbe41e docs(creator): 커뮤니티 탭 Phase 2 검증을 기록한다 2026-06-21 20:44:38 +09:00
klaus d4448820d6 feat(creator): 커뮤니티 탭 상태 관리를 추가한다 2026-06-21 20:44:27 +09:00
klaus 744132fd7e feat(creator): 커뮤니티 탭 API 계약을 추가한다 2026-06-21 20:44:15 +09:00
klaus a444dd8677 docs(creator): 채널 커뮤니티 탭 사전 확인을 기록한다 2026-06-21 19:35:09 +09:00
klaus 546a665ba3 feat(creator): 채널 커뮤니티 탭 아이콘을 추가한다 2026-06-21 19:28:36 +09:00
klaus 6bff74cd1e docs(creator): 채널 커뮤니티 탭 문서를 추가한다 2026-06-21 19:28:30 +09:00
klaus 1dc6aa5283 docs(creator): 시리즈 탭 최종 검증을 기록한다 2026-06-20 05:42:56 +09:00
klaus a2dba0456b docs(creator): 시리즈 탭 구현 검증을 기록한다 2026-06-20 04:50:50 +09:00
klaus 015a6ac865 feat(creator): 시리즈 탭 activity 연동을 추가한다 2026-06-20 04:50:43 +09:00
klaus fcf35e2513 feat(creator): 시리즈 탭 pager 연결을 추가한다 2026-06-20 04:50:37 +09:00
klaus 92cea6d3ee feat(creator): 시리즈 탭 UI를 구현한다 2026-06-20 04:50:30 +09:00
klaus 7ea06fda2f feat(creator): 시리즈 탭 문자열을 추가한다 2026-06-20 04:50:22 +09:00
klaus a9456abfb0 refactor(creator): 시리즈 subtitle 모델을 분리한다 2026-06-20 04:49:36 +09:00
klaus 3dcc48c9d9 docs(creator): 시리즈 탭 Phase 2와 3 검증을 기록한다 2026-06-20 02:53:43 +09:00
klaus c25d4cd161 feat(creator): 시리즈 탭 상태 관리를 추가한다 2026-06-20 02:53:24 +09:00
klaus 185c92e9af feat(creator): 시리즈 탭 API 계약을 추가한다 2026-06-20 02:53:16 +09:00
klaus 8fae9e6a96 docs(creator): 시리즈 탭 사전 확인을 기록한다 2026-06-20 02:13:54 +09:00
klaus 8a5fc48650 docs(creator): 시리즈 탭 구현 계획을 기록한다 2026-06-20 02:05:23 +09:00
klaus 688ba0a63d docs(creator): 라이브 오디오 후속 보정을 기록한다 2026-06-19 21:47:25 +09:00
klaus a0f263c1fd fix(creator): 오디오 탭 표시를 보정한다 2026-06-19 21:47:19 +09:00
klaus e3cea856b8 fix(creator): 라이브 empty 배치를 보정한다 2026-06-19 21:47:12 +09:00
klaus df78b8a3f5 fix(creator): 탭 ViewPager 높이 계산을 보정한다 2026-06-19 21:47:06 +09:00
klaus 1a9a78ec70 docs(creator): 오디오 탭 Phase 5와 6 검증을 기록한다 2026-06-19 21:04:20 +09:00
klaus 1f855102ce fix(creator): 오디오 탭 owner reload를 보정한다 2026-06-19 21:04:15 +09:00
klaus 757f242285 feat(creator): 오디오 탭 activity 연동을 추가한다 2026-06-19 21:04:08 +09:00
klaus bcbc48540e feat(creator): 오디오 탭 콘텐츠 UI를 연결한다 2026-06-19 21:04:01 +09:00
klaus 5b89d6c6d7 feat(creator): 오디오 탭 pager 연결을 추가한다 2026-06-19 21:03:55 +09:00
klaus 3a421d2a60 refactor(creator): 라이브 replay adapter를 공통화한다 2026-06-19 21:03:47 +09:00
klaus 9d7bc6969b refactor(creator): 오디오 콘텐츠 공통 모델을 추가한다 2026-06-19 21:03:33 +09:00
klaus e12f00b5b4 feat(creator): 오디오 탭 fragment 골격을 추가한다 2026-06-19 19:13:00 +09:00
klaus 0b2faf2c6e feat(creator): 오디오 탭 레이아웃을 추가한다 2026-06-19 19:12:54 +09:00
klaus 763a86704c test(creator): 라이브 공통 UI 검증을 갱신한다 2026-06-19 19:12:47 +09:00
klaus 6df7666cec refactor(creator): 라이브 replay item을 공통화한다 2026-06-19 19:12:35 +09:00
klaus d97c2792f5 refactor(creator): 라이브 sort popup을 공통 컴포넌트로 전환한다 2026-06-19 19:11:29 +09:00
klaus f848f22029 feat(creator): 채널 sort popup을 공통 컴포넌트로 추가한다 2026-06-19 19:11:12 +09:00
klaus 011a762ecf feat(creator): 채널 sort popup 리소스를 공통화한다 2026-06-19 19:11:05 +09:00
klaus 241532f60e feat(creator): 채널 sort 공통 모델을 추가한다 2026-06-19 19:10:58 +09:00
klaus 6770dbd682 docs(creator): 오디오 탭 공통 UI 재사용 계획을 갱신한다 2026-06-19 17:59:24 +09:00
klaus 12a9d9f398 docs(creator): 오디오 탭 Phase 3 검증을 기록한다 2026-06-19 17:39:51 +09:00
klaus c82513eaf0 fix(creator): 오디오 탭 theme 선택 정규화를 보정한다 2026-06-19 17:39:41 +09:00
klaus 845b36828b feat(creator): 오디오 탭 mapper를 추가한다 2026-06-19 17:39:31 +09:00
klaus d0843d94ed docs(creator): 오디오 탭 Phase 2 검증을 기록한다 2026-06-19 15:41:42 +09:00
klaus c9d911f339 feat(creator): 오디오 탭 상태 관리를 추가한다 2026-06-19 15:41:34 +09:00
klaus 4e4d13b4de feat(creator): 오디오 탭 API 계약을 추가한다 2026-06-19 15:41:25 +09:00
klaus a0274518d2 docs(creator): 오디오 탭 Phase 1 검증을 기록한다 2026-06-19 14:48:01 +09:00
klaus 88dd7cc04c docs(creator): 오디오 탭 구현 계획을 기록한다 2026-06-19 14:26:11 +09:00
klaus dacd3a67a1 docs(dm): DM push deep_link 계약을 기록한다 2026-06-19 05:03:37 +09:00
klaus da64806d88 fix(dm): MainV2Activity chat path 라우팅을 보정한다 2026-06-19 05:03:32 +09:00
klaus a87d2990b0 fix(dm): MainActivity chat path 라우팅을 보정한다 2026-06-19 05:03:25 +09:00
klaus 5e95aa4168 fix(dm): DeepLinkActivity chat path 라우팅을 보정한다 2026-06-19 05:03:20 +09:00
klaus 9c642fb3b7 fix(dm): FCM deep_link payload 알림 생성을 보정한다 2026-06-19 05:03:14 +09:00
klaus 8b515bba97 docs(dm): Phase 13 검증 기록을 갱신한다 2026-06-19 03:57:44 +09:00
klaus 1550014670 docs(dm): Phase 12 검증 기록을 갱신한다 2026-06-19 00:10:12 +09:00
klaus a6862c51e4 test(dm): 제거 endpoint와 음성 범위 회귀를 고정한다 2026-06-19 00:10:05 +09:00
klaus de90b34cd4 fix(dm): USER_CREATOR DM push 라우팅을 보정한다 2026-06-19 00:09:59 +09:00
klaus bbb84d4ffa fix(dm): FCM DM payload 보존을 보정한다 2026-06-19 00:09:51 +09:00
klaus f560adabfa docs(dm): WebSocket lifecycle 검증을 기록한다 2026-06-18 23:31:20 +09:00
klaus 8f69c1ab82 fix(dm): WebSocket heartbeat와 token 재연결을 보정한다 2026-06-18 23:31:15 +09:00
klaus a6485292e4 docs(dm): MESSAGE race 검증을 기록한다 2026-06-18 22:57:48 +09:00
klaus 482d517145 fix(dm): MESSAGE 선도착 ACK 처리를 보정한다 2026-06-18 22:57:36 +09:00
klaus 2c1eb03e5f feat(dm): 메시지 전송 pending을 requestId로 관리한다 2026-06-18 19:09:40 +09:00
klaus e640ee6c46 docs(dm): WebSocket 연결 완료 검증을 기록한다 2026-06-18 18:42:30 +09:00
klaus e8ae5b9639 fix(dm): WebSocket 연결 완료 시점을 보정한다 2026-06-18 18:42:24 +09:00
klaus 32e33a243a docs(dm): WebSocket 후속 검증 계획을 보강한다 2026-06-18 18:26:56 +09:00
klaus 379284ca4f docs(dm): WebSocket 저장소 전환 검증을 기록한다 2026-06-18 18:26:16 +09:00
klaus dd7a6465c1 refactor(dm): 채팅 화면 전송을 WebSocket으로 전환한다 2026-06-18 18:25:43 +09:00
klaus deba733522 refactor(dm): 채팅 저장소를 WebSocket 기준으로 전환한다 2026-06-18 18:25:29 +09:00
klaus 3d71def880 docs(dm): WebSocket 검증 기록을 갱신한다 2026-06-18 17:41:56 +09:00
klaus c5bcaf7329 feat(dm): WebSocket 클라이언트를 추가한다 2026-06-18 17:41:41 +09:00
klaus e76562067f feat(dm): WebSocket 계약 모델을 추가한다 2026-06-18 17:41:31 +09:00
klaus 0e03a1a14a docs(dm): WebSocket 전환 계획을 기록한다 2026-06-18 17:03:07 +09:00
klaus fa4e41589b docs(creator): 라이브 탭 후속 검증을 기록한다 2026-06-18 15:22:17 +09:00
klaus b11cf53f67 fix(creator): 라이브 기본 조회 개수를 보정한다 2026-06-18 15:22:11 +09:00
klaus 7db7fdffdf fix(creator): 라이브 다시듣기 item 표시를 보정한다 2026-06-18 15:21:32 +09:00
klaus efac753f83 fix(creator): 라이브 empty와 CTA 목록 여백을 보정한다 2026-06-18 15:21:26 +09:00
klaus f4af9868e6 fix(creator): 라이브 탭 하단 CTA와 sticky 전환을 보정한다 2026-06-18 15:21:18 +09:00
klaus a1e8f8edb3 docs(creator): 라이브 Phase 7 검증을 기록한다 2026-06-18 11:26:43 +09:00
klaus 7469172cfc docs(creator): 라이브 본인 CTA 검증을 기록한다 2026-06-18 11:13:17 +09:00
klaus a49951c51f feat(creator): 라이브 탭 본인 CTA를 연결한다 2026-06-18 11:12:55 +09:00
klaus 0d11839a96 docs(creator): 라이브 정렬 팝업 검증을 기록한다 2026-06-18 00:12:14 +09:00
klaus 8213d2de42 feat(creator): 라이브 정렬 팝업을 연결한다 2026-06-18 00:11:49 +09:00
klaus decc2cbb31 feat(creator): 라이브 정렬 팝업 UI를 추가한다 2026-06-18 00:11:28 +09:00
klaus b639782e89 docs(creator): 라이브 탭 UI 검증을 기록한다 2026-06-17 23:24:45 +09:00
klaus 10004652e4 fix(creator): 라이브 더보기 상태 보존을 보정한다 2026-06-17 23:24:39 +09:00
klaus e90fb04de9 feat(creator): 라이브 탭 화면을 연결한다 2026-06-17 23:24:34 +09:00
klaus 7fb52b3c85 feat(creator): 라이브 탭 레이아웃을 추가한다 2026-06-17 23:24:13 +09:00
klaus dd13c619ac feat(creator): 라이브 탭 배경 리소스를 추가한다 2026-06-17 23:24:08 +09:00
klaus 41ef04b193 feat(creator): 라이브 탭 상태 문구를 추가한다 2026-06-17 23:23:55 +09:00
klaus 747569c1cc docs(creator): 라이브 다시듣기 mapper 검증을 기록한다 2026-06-17 19:14:03 +09:00
klaus 1f0adb21a7 feat(creator): 라이브 다시듣기 mapper를 추가한다 2026-06-17 19:13:58 +09:00
klaus f015aea062 feat(creator): 라이브 탭 정렬 문구를 추가한다 2026-06-17 19:13:50 +09:00
klaus 3f16cc9312 docs(creator): 라이브 탭 API 계약을 기록한다 2026-06-17 18:47:12 +09:00
klaus c1ac428ded test(creator): 채널 홈 source 검증을 갱신한다 2026-06-17 18:47:03 +09:00
klaus 4a38703873 feat(creator): 라이브 탭 ViewModel을 추가한다 2026-06-17 18:46:57 +09:00
klaus e5d4f1d40d feat(creator): 라이브 탭 API 계약을 추가한다 2026-06-17 18:46:50 +09:00
klaus d25f509118 docs(creator): 채널 공통 저장소 rename을 기록한다 2026-06-17 16:14:19 +09:00
klaus ecaeea6262 refactor(creator): 채널 공통 저장소 이름을 정리한다 2026-06-17 16:13:56 +09:00
klaus cff8469604 docs(creator): 라이브 탭 문서를 갱신한다 2026-06-17 15:54:53 +09:00
klaus 9f242f0201 feat(creator): 라이브 탭 아이콘을 추가한다 2026-06-17 15:44:45 +09:00
klaus bb3c63e2fb docs(creator): 라이브 탭 구현 계획을 추가한다 2026-06-17 15:44:27 +09:00
klaus ca0c586c0c docs(creator): 원본 태그 교체를 기록한다 2026-06-17 14:15:42 +09:00
klaus bd60185db8 feat(creator): 원본 태그 이미지를 적용한다 2026-06-17 14:15:24 +09:00
klaus 01afed89f8 docs(dm): creatorId 진입 crash 수정을 기록한다 2026-06-17 13:52:49 +09:00
klaus 3dacc50e1b fix(creator): 채널 버튼 drawable compat을 적용한다 2026-06-17 13:52:44 +09:00
klaus 236b874e82 fix(dm): creatorId 방 열기 thread를 보정한다 2026-06-17 13:52:38 +09:00
klaus 26f44dd448 docs(creator): 채널 라이브 검증을 기록한다 2026-06-17 10:59:00 +09:00
klaus f2f2a3143d fix(creator): 채널 라이브 진입을 보강한다 2026-06-17 10:58:49 +09:00
klaus 34876cf46f feat(creator): 라이브 생성 안내 문구를 추가한다 2026-06-17 10:58:31 +09:00
klaus deda8322d5 feat(creator): 라이브 coordinator를 추가한다 2026-06-17 10:58:19 +09:00
klaus ba0aef4349 docs(creator): 본인 FAB 검증을 기록한다 2026-06-16 22:24:42 +09:00
klaus 5d52787ea9 feat(creator): 본인 FAB 액션을 연결한다 2026-06-16 22:24:30 +09:00
klaus 6a6b1138a8 feat(creator): 본인 홈 FAB를 추가한다 2026-06-16 22:03:49 +09:00
klaus 722f84039f feat(creator): 본인 FAB 문구를 추가한다 2026-06-16 22:03:41 +09:00
klaus 710ce2602b feat(creator): 본인 FAB 배경을 추가한다 2026-06-16 22:03:35 +09:00
klaus 3e96a5435e docs(creator): 본인 FAB 기준을 갱신한다 2026-06-16 22:03:28 +09:00
klaus 433bf172ce fix(creator): 채널 후원 empty 레이아웃을 보정한다 2026-06-16 21:22:45 +09:00
klaus 5969f50888 fix(creator): 채널 홈 활동 표시를 보정한다 2026-06-16 21:19:03 +09:00
klaus 9900ac02f5 fix(creator): 채널 홈 카드 태그 크기를 보정한다 2026-06-16 21:18:38 +09:00
klaus ee4de78c6c feat(creator): 시리즈 상세 이동을 연결한다 2026-06-16 21:18:26 +09:00
klaus 43c2f6f417 fix(creator): 채널 활동 dday 응답을 매핑한다 2026-06-16 21:18:15 +09:00
klaus f7c1a5168f docs(creator): 채널 홈 후원 검증을 기록한다 2026-06-16 19:22:42 +09:00
klaus 28433c10df feat(creator): 채널 후원 버튼을 연결한다 2026-06-16 19:22:28 +09:00
klaus de351d700c feat(creator): 채널 후원 요청을 연결한다 2026-06-16 19:22:21 +09:00
klaus a01675b592 feat(creator): 채널 후원 empty 상태를 매핑한다 2026-06-16 19:21:54 +09:00
klaus 6ba5bf2cb1 feat(creator): 채널 후원 empty 리소스를 추가한다 2026-06-16 19:21:45 +09:00
klaus f6395b5a3e test(creator): 채널 홈 후속 동작을 검증한다 2026-06-16 17:27:52 +09:00
klaus 1cd676bcb4 feat(creator): 채널 상단 액션을 연결한다 2026-06-16 17:27:38 +09:00
klaus 0bb5796da1 feat(main): 채팅 DM 필터 진입을 추가한다 2026-06-16 17:27:29 +09:00
klaus 5f4140ea68 feat(creator): 채널 본인 상태를 계산한다 2026-06-16 17:27:24 +09:00
klaus b2878e42ea feat(creator): 채널 신고 차단 동작을 연결한다 2026-06-16 17:27:13 +09:00
klaus e8bd31454e feat(creator): 채널 더보기 메뉴를 추가한다 2026-06-16 17:27:08 +09:00
klaus 59545ca82c feat(creator): 채널 홈 상단 UI 상태를 보정한다 2026-06-16 17:27:03 +09:00
klaus cd1f9db8ae docs(creator): 채널 홈 후속 요구사항을 기록한다 2026-06-16 17:26:57 +09:00
klaus d719470f8c docs(creator): 채널 홈 탭 전환 검증을 기록한다 2026-06-16 14:35:51 +09:00
klaus f3c19ed8ba feat(creator): 채널 홈 탭 전환을 연결한다 2026-06-16 14:35:39 +09:00
klaus 984aa13edf feat(creator): 채널 탭 프래그먼트 골격을 추가한다 2026-06-16 14:35:23 +09:00
klaus 5ac900f3b7 docs(guide): 검증 기록 위치 규칙을 정리한다 2026-06-16 12:25:49 +09:00
klaus a21427b549 docs(creator): 채널 홈 탭 전환 계획을 보강한다 2026-06-16 12:20:38 +09:00
klaus f667bf1096 feat(creator): 본인 페이지 FAB 아이콘을 추가한다 2026-06-16 12:13:24 +09:00
klaus 458cdc3280 docs(creator): 채널 홈 후속 요구사항을 정리한다 2026-06-16 12:13:10 +09:00
klaus 2cf492d634 docs(creator): 채널 홈 검증 기록을 보강한다 2026-06-15 23:34:13 +09:00
klaus e16bc306f7 refactor(creator): 채널 홈 액티비티 이름을 정리한다 2026-06-15 23:33:41 +09:00
klaus fcb198c8a8 docs(creator): 채널 홈 최종 검증을 기록한다 2026-06-15 22:40:53 +09:00
klaus 40679a624b fix(creator): 오디션 지원자 프로필 이동을 복구한다 2026-06-15 22:40:42 +09:00
klaus a2be1739a6 docs(creator): 채널 홈 Phase 7 검증을 기록한다 2026-06-15 21:01:58 +09:00
klaus b529a83fe6 feat(profile): 내 채널 진입점을 전환한다 2026-06-15 21:01:40 +09:00
klaus 20491906fd feat(follow): 팔로잉 채널 진입점을 전환한다 2026-06-15 21:01:34 +09:00
klaus 9a8559af8b feat(search): 탐색 검색 채널 진입점을 전환한다 2026-06-15 21:01:28 +09:00
klaus 541333bc44 feat(live): 라이브 채널 진입점을 전환한다 2026-06-15 21:01:21 +09:00
klaus 9c4c0506af feat(home): 홈 채널 진입점을 전환한다 2026-06-15 21:01:11 +09:00
klaus 00b5a3687e feat(main): 메인 채널 진입점을 전환한다 2026-06-15 21:01:03 +09:00
klaus b41e5c6074 feat(audio): 오디오 채널 진입점을 전환한다 2026-06-15 21:00:49 +09:00
klaus 19b8e4750f feat(creator): 채널 홈 팔로우와 탭 동작을 연결한다 2026-06-15 21:00:42 +09:00
klaus bfb5440c9e fix(creator): 채널 홈 SNS xurl 파싱을 수정한다 2026-06-15 21:00:12 +09:00
klaus 1ce974938d docs(creator): 채널 홈 스크롤 구현 검증을 기록한다 2026-06-15 19:10:54 +09:00
klaus dc217f97af feat(creator): 채널 홈 탭 고정 스크롤을 연결한다 2026-06-15 19:10:49 +09:00
klaus d3bfc57294 feat(creator): 채널 홈 스크롤 상태 계산을 추가한다 2026-06-15 19:10:43 +09:00
klaus cd5fbc0858 docs(creator): 채널 홈 소개 활동 SNS 검증을 기록한다 2026-06-15 18:43:53 +09:00
klaus 2d2844338a feat(creator): 채널 홈 소개 활동 SNS 섹션을 재구성한다 2026-06-15 18:43:47 +09:00
klaus 1f3abfde4f feat(creator): 채널 홈 SNS 아이콘 레이아웃을 추가한다 2026-06-15 18:43:39 +09:00
klaus 2d58a876da feat(creator): 채널 홈 시리즈, 커뮤니티, 팬 Talk 섹션을 재구성한다 2026-06-15 17:22:32 +09:00
klaus c45c5a2a5c docs(creator): 채널 홈 오디오 섹션 검증을 기록한다 2026-06-15 15:01:46 +09:00
klaus 5fde0bc469 feat(creator): 채널 홈 오디오 섹션을 재구성한다 2026-06-15 15:01:41 +09:00
klaus edb7d98de7 feat(creator): 채널 홈 오디오 콘텐츠 카드를 추가한다 2026-06-15 15:01:21 +09:00
klaus 573df4318b test(creator): 채널 홈 화면 계약을 검증한다 2026-06-15 13:21:40 +09:00
klaus febd718796 fix(creator): 채널 홈 토스트 이벤트를 단발 처리한다 2026-06-15 13:21:34 +09:00
klaus a20655badb test(creator): 채널 홈 매퍼 검증을 보강한다 2026-06-15 13:21:28 +09:00
klaus 11fd892310 feat(creator): 채널 홈 섹션 렌더링을 연결한다 2026-06-15 13:21:15 +09:00
klaus 2253dabfe9 feat(creator): 채널 홈 일정 카드를 추가한다 2026-06-15 13:21:07 +09:00
klaus e2575e2b15 feat(creator): 채널 홈 공지 카드를 추가한다 2026-06-15 13:21:01 +09:00
klaus 05f724dbe1 feat(creator): 채널 홈 후원 카드를 추가한다 2026-06-15 13:20:54 +09:00
klaus d4b3a9a8a4 feat(creator): 채널 홈 최신 오디오 카드를 추가한다 2026-06-15 13:20:47 +09:00
klaus 74687daf28 feat(creator): 채널 홈 라이브 카드를 추가한다 2026-06-15 13:20:42 +09:00
klaus a8ba791349 feat(creator): 채널 홈 헤더 리소스를 추가한다 2026-06-15 13:20:35 +09:00
klaus 21383ced46 feat(creator): 채널 홈 문구 리소스를 추가한다 2026-06-15 13:20:25 +09:00
klaus a631aa1b65 feat(creator): 채널 홈 Activity 진입점을 추가한다 2026-06-15 13:20:11 +09:00
klaus 402ea5e9c0 docs(creator): 채널 홈 세부 구현 계획을 갱신한다 2026-06-15 13:19:56 +09:00
klaus 654a74aacf docs(creator): 채널 홈 Phase 4 검증을 기록한다 2026-06-13 17:20:17 +09:00
klaus 3027934295 feat(creator): 채널 홈 ViewModel을 추가한다 2026-06-13 17:20:06 +09:00
klaus a355838039 feat(creator): 채널 홈 UI 상태를 추가한다 2026-06-13 17:19:58 +09:00
klaus 0ae6596816 docs(creator): 채널 홈 탭 계획을 갱신한다 2026-06-13 16:22:05 +09:00
klaus da23253a9c feat(creator): 채널 홈 저장소를 등록한다 2026-06-13 16:21:59 +09:00
klaus 0934a7ec51 feat(creator): 채널 홈 API 모델을 추가한다 2026-06-13 16:21:53 +09:00
klaus 80e8213f12 refactor(home): 추천 활동 타입을 공용 타입으로 교체한다 2026-06-13 16:21:46 +09:00
klaus 55b4d9bc8d feat(common): 크리에이터 활동 타입을 공용화한다 2026-06-13 16:21:34 +09:00
klaus 92fdd6ab54 docs(creator): 채널 홈 탭 채팅 계획을 보완한다 2026-06-12 23:47:29 +09:00
klaus 3514104e7c feat(creator): 채널 액션 아이콘을 추가한다 2026-06-12 16:42:47 +09:00
klaus 122c559e57 feat(creator): 채널 타이틀바 아이콘을 추가한다 2026-06-12 16:42:42 +09:00
klaus b2cb08e96a docs(creator): 채널 홈 탭 계획을 기록한다 2026-06-12 16:42:22 +09:00
klaus 9e9533c7a6 fix(audio): 핀 아이콘 표시 크기를 고정한다 2026-06-11 13:28:01 +09:00
klaus f6ab1bd4ef fix(audio): 핀 아이콘 리소스 밀도를 보정한다 2026-06-11 13:27:52 +09:00
klaus f47a9d6d93 docs(chat): DM Phase 6 검증을 기록한다 2026-06-11 12:06:03 +09:00
klaus ff728af7cc docs(test): DM 채팅 테스트 명령을 추가한다 2026-06-11 12:05:57 +09:00
klaus 1e8beb458c feat(chat): DM 채팅방 실행 구성을 등록한다 2026-06-11 12:05:51 +09:00
klaus a6d8cd54f3 fix(chat): DM realtime 정리 누락을 보정한다 2026-06-11 12:05:45 +09:00
klaus 841ed5f6f8 fix(chat): DM SSE read timeout을 제거한다 2026-06-11 12:05:39 +09:00
klaus 0263e64f40 docs(chat): DM 채팅 Phase 5 검증을 기록한다 2026-06-11 11:17:46 +09:00
klaus a0b95ea3bd feat(chat): 채팅 탭에서 DM 채팅방을 연다 2026-06-11 11:17:26 +09:00
klaus 590a52c605 feat(chat): DM 채팅방 Activity를 추가한다 2026-06-11 11:17:04 +09:00
klaus f2687b8243 feat(chat): DM 채팅 실시간 수신을 연결한다 2026-06-11 11:16:44 +09:00
klaus 871f4e73e8 feat(chat): DM SSE 재연결 기반을 추가한다 2026-06-11 11:16:19 +09:00
klaus e19442a61a docs(chat): DM 채팅 Phase 4 검증을 기록한다 2026-06-10 19:27:09 +09:00
klaus 3285958918 feat(chat): DM 재시도 아이콘을 추가한다 2026-06-10 19:26:46 +09:00
klaus 06088c578f feat(chat): DM 채팅방 레이아웃을 추가한다 2026-06-10 19:26:11 +09:00
klaus 63a4f5b4f7 docs(chat): DM 채팅 Phase 3 검증을 기록한다 2026-06-10 18:48:36 +09:00
klaus 56f110c548 feat(chat): DM 채팅방 ViewModel을 추가한다 2026-06-10 18:48:27 +09:00
klaus 406c377d13 docs(chat): DM 채팅 Phase 2 검증을 기록한다 2026-06-10 18:11:59 +09:00
klaus fd0382ea65 feat(chat): DM 채팅 SSE 클라이언트를 추가한다 2026-06-10 18:11:53 +09:00
klaus a289849a07 feat(chat): DM 채팅 저장소를 추가한다 2026-06-10 18:11:47 +09:00
klaus 630f84c3e5 feat(chat): DM 채팅 메시지 매퍼를 추가한다 2026-06-10 17:39:52 +09:00
klaus e1ae7df0ee feat(chat): DM 채팅 API 모델을 추가한다 2026-06-10 17:39:41 +09:00
klaus 5f7fc68c8c docs(chat): DM 채팅화면 계획을 기록한다 2026-06-10 17:39:36 +09:00
klaus f4f561b396 docs(chat): 통합 검증 결과를 기록한다 2026-06-10 15:28:56 +09:00
klaus 40f8355037 docs(chat): 타이틀바 액션 간격 검증을 기록한다 2026-06-10 15:20:33 +09:00
klaus ccc4a822e3 fix(chat): 타이틀바 액션 간격을 보정한다 2026-06-10 15:20:09 +09:00
klaus 70177d8f81 docs(chat): 채팅 탭 스크롤 요구사항을 기록한다 2026-06-10 15:06:41 +09:00
klaus b38e58af9a feat(chat): 채팅 탭 화면 동작을 연결한다 2026-06-10 15:06:35 +09:00
klaus 516e4a94bf fix(widget): CapsuleTab 여백과 선택 배경을 보정한다 2026-06-10 15:06:29 +09:00
klaus cdb491b21b docs(chat): 채팅 탭 리뷰 개선을 기록한다 2026-06-10 14:27:40 +09:00
klaus a769fc6a64 docs(chat): 채팅 탭 Phase 6 완료를 기록한다 2026-06-10 14:27:16 +09:00
klaus 2c30da8110 fix(widget): CapsuleTab 선택 색상을 보정한다 2026-06-10 14:26:45 +09:00
klaus ee703eb13a feat(chat): 채팅 탭 기본 layout을 추가한다 2026-06-10 14:26:39 +09:00
klaus 55fb032b22 docs(chat): 채팅방 Adapter 검증을 기록한다 2026-06-10 13:26:03 +09:00
klaus 346671b3e2 feat(chat): 채팅방 목록 Adapter를 추가한다 2026-06-10 13:25:41 +09:00
klaus 5574e68b16 feat(chat): 채팅방 항목 레이아웃을 추가한다 2026-06-10 13:25:25 +09:00
klaus 445d91d594 feat(chat): 채팅방 필터 문자열을 추가한다 2026-06-10 13:25:09 +09:00
klaus 896935e19a docs(chat): 채팅방 ViewModel 검증을 기록한다 2026-06-10 12:00:34 +09:00
klaus bb17f0014a feat(chat): 채팅방 ViewModel을 추가한다 2026-06-10 12:00:27 +09:00
klaus ed8a0e9a09 refactor(chat): 채팅방 시간 표시 책임을 분리한다 2026-06-10 12:00:18 +09:00
klaus 10a74c2e5e docs(guide): 채팅방 테스트 명령을 추가한다 2026-06-10 11:30:14 +09:00
klaus d66743ae1e docs(chat): 채팅방 모델 작업 검증을 기록한다 2026-06-10 11:30:07 +09:00
klaus 5a17d7d2f6 feat(chat): 채팅방 UI 모델 매핑을 추가한다 2026-06-10 11:30:00 +09:00
klaus 50f0fb3d15 feat(chat): 채팅방 시간 표시를 추가한다 2026-06-10 11:29:52 +09:00
klaus 4c351da60c feat(chat): 채팅방 필터 모델을 추가한다 2026-06-10 11:05:39 +09:00
klaus 89837877a2 docs(chat): 채팅방 목록 데이터 계층 작업을 기록한다 2026-06-09 23:30:19 +09:00
klaus 32c30132b9 feat(chat): 채팅방 목록 API와 저장소를 추가한다 2026-06-09 23:30:11 +09:00
klaus 06e4b5ad34 refactor(chat): 채팅방 모델 파일명을 정리한다 2026-06-09 23:29:50 +09:00
klaus 972d225a86 feat(chat): 채팅방 목록 응답 모델을 추가한다 2026-06-09 23:14:19 +09:00
klaus 7acc7b51b6 refactor(chat): 채팅 Fragment 패키지를 분리한다 2026-06-09 23:14:09 +09:00
klaus 86e18a1f7c docs(chat): 채팅 탭 구현 계획을 추가한다 2026-06-09 23:13:59 +09:00
klaus bb4d290ca1 refactor(home): 홈 메인 Fragment 패키지를 분리한다 2026-06-09 18:17:57 +09:00
klaus 4e98a1dfea fix(home): 채팅 탭 문구를 대화로 변경한다 2026-06-09 18:06:21 +09:00
klaus c3bbf9203d docs(home): 랭킹 순위 영역 보정 검증을 기록한다 2026-06-09 15:13:14 +09:00
klaus 64fb55db55 fix(widget): 랭킹 순위 영역 위치를 보정한다 2026-06-09 15:13:01 +09:00
klaus ea076a5ac7 docs(home): 랭킹 순위 정렬 검증을 기록한다 2026-06-09 14:56:04 +09:00
klaus 6b0232b7e4 test(widget): 랭킹 순위 텍스트 정렬 검증을 추가한다 2026-06-09 14:55:58 +09:00
klaus 178397923b docs(home): 랭킹 페이지 최종 검증을 기록한다 2026-06-08 18:22:35 +09:00
klaus de4e90b98e docs(home): 랭킹 탭 검증을 기록한다 2026-06-08 18:07:07 +09:00
klaus 5d66014044 feat(home): 랭킹 탭 목록을 연결한다 2026-06-08 18:07:00 +09:00
klaus bb60f8bb9f docs(home): 크리에이터 랭킹 ViewModel 검증을 기록한다 2026-06-08 17:41:35 +09:00
klaus b199804827 feat(home): 크리에이터 랭킹 의존성을 등록한다 2026-06-08 17:41:29 +09:00
klaus 37dc3cd24a feat(home): 크리에이터 랭킹 ViewModel을 추가한다 2026-06-08 17:41:25 +09:00
klaus a4710ef6bb docs(guide): 테스트명 예외 규칙을 추가한다 2026-06-08 15:22:53 +09:00
klaus 28423d81bb docs(home): 크리에이터 랭킹 검증을 기록한다 2026-06-08 15:22:48 +09:00
klaus 21e94af8d1 feat(home): 크리에이터 랭킹 응답 매핑을 추가한다 2026-06-08 15:22:16 +09:00
klaus 6d980e319b feat(widget): 랭킹 카드 순위 변동 숨김을 적용한다 2026-06-08 15:22:10 +09:00
klaus 4a21827b47 feat(widget): 랭킹 순위 변동 표시 옵션을 추가한다 2026-06-08 15:22:02 +09:00
klaus 9600147240 docs(home): 크리에이터 랭킹 계획을 추가한다 2026-06-08 14:01:27 +09:00
klaus ecc59376a3 docs(home): 최근 활동 이동 검증을 기록한다 2026-06-06 00:08:54 +09:00
klaus 4b4a23c92e feat(home): 최근 활동 카드 이동을 연결한다 2026-06-06 00:08:41 +09:00
klaus c36eddb207 docs(home): 배너 이동 검증을 기록한다 2026-06-05 23:04:51 +09:00
klaus e160a10708 feat(home): 추천 배너 이동을 연결한다 2026-06-05 23:04:40 +09:00
klaus cdcd938cdf docs(home): 프로필과 상대 시간 검증을 기록한다 2026-06-05 22:01:23 +09:00
klaus 58e69be510 feat(home): 추천 시간과 프로필 표시를 보완한다 2026-06-05 22:01:16 +09:00
klaus 7f417c3a3f feat(common): UTC 상대 시간 포매터를 추가한다 2026-06-05 22:01:08 +09:00
klaus 9378f96b73 docs(home): 추천 안정화 검증을 기록한다 2026-06-05 20:54:01 +09:00
klaus 7e78ace304 docs(home): 추천 배너 요구사항을 갱신한다 2026-06-05 20:53:55 +09:00
klaus a3aa406e13 test(home): 추천 응답 스키마 검증을 추가한다 2026-06-05 20:53:50 +09:00
klaus 8371dc7baf feat(home): 추천 UI 바인딩을 갱신한다 2026-06-05 20:53:44 +09:00
klaus 44457349aa feat(home): 추천 응답 모델을 갱신한다 2026-06-05 20:53:34 +09:00
klaus 51226cf5cc fix(widget): 배너 가상 목록 갱신을 안정화한다 2026-06-05 20:53:28 +09:00
klaus 7f307346f3 fix(common): 이미지 캐시 디렉터리를 분리한다 2026-06-05 20:53:20 +09:00
klaus 9de4493e89 docs(home): 추천 상태 연결 검증을 기록한다 2026-06-05 16:01:43 +09:00
klaus c6680d0bd2 feat(home): 추천 상태 observe를 연결한다 2026-06-05 16:01:37 +09:00
klaus 6679808a18 feat(home): 콘텐츠 카드 클릭을 연결한다 2026-06-05 16:01:31 +09:00
klaus 7a4fa3c50f feat(home): 크리에이터 카드 클릭을 연결한다 2026-06-05 16:01:23 +09:00
klaus 7c0af85aaa docs(home): 사업자 정보 검증을 기록한다 2026-06-05 14:30:55 +09:00
klaus 8457194bb5 fix(home): 사업자 정보 문구를 공용 리소스로 정리한다 2026-06-05 14:30:44 +09:00
klaus f07132c48b feat(home): 사업자 정보 inline 더보기를 추가한다 2026-06-05 14:30:34 +09:00
klaus 9c20b86373 docs(home): 인기 커뮤니티 검증을 기록한다 2026-06-05 13:17:14 +09:00
klaus 293f34ca13 test(home): 인기 커뮤니티 섹션 검증을 추가한다 2026-06-05 13:17:06 +09:00
klaus dca06cdc3c feat(home): 인기 커뮤니티 섹션을 바인딩한다 2026-06-05 13:16:59 +09:00
klaus 1cbf989577 feat(home): 인기 커뮤니티 adapter를 추가한다 2026-06-05 13:16:53 +09:00
klaus 5b3b7c72d2 feat(widget): 커뮤니티 이미지와 유료 overlay를 추가한다 2026-06-05 13:16:47 +09:00
klaus cd274a6d2f docs(home): 응원 크리에이터 검증을 기록한다 2026-06-04 20:18:12 +09:00
klaus edf4a94494 feat(home): 응원 크리에이터 카드 바인딩을 추가한다 2026-06-04 20:18:06 +09:00
klaus d5f46e6325 feat(home): 응원 크리에이터 카드 리소스를 추가한다 2026-06-04 20:18:00 +09:00
klaus 22ac5b0b54 docs(home): 장르 크리에이터 수정 검증을 기록한다 2026-06-04 19:38:14 +09:00
klaus bb29fc8010 test(home): 장르 크리에이터 페이지 검증을 추가한다 2026-06-04 19:38:08 +09:00
klaus c733796aeb feat(home): 장르 크리에이터 그룹 바인딩을 추가한다 2026-06-04 19:38:03 +09:00
klaus c714a9d4c8 feat(home): 장르 크리에이터 페이지 리소스를 추가한다 2026-06-04 19:37:56 +09:00
klaus c436866a51 docs(home): 모두 팔로우 버튼 검증을 기록한다 2026-06-04 17:59:36 +09:00
klaus 02480a96e9 fix(home): 모두 팔로우 완료 상태를 반영한다 2026-06-04 17:59:29 +09:00
klaus 3e8ea0473f feat(home): 모두 팔로우 버튼 배경을 추가한다 2026-06-04 17:59:23 +09:00
klaus 99c2082022 docs(agent): clipping 처리 가이드를 추가한다 2026-06-02 19:34:48 +09:00
klaus 4629ef00a9 fix(widget): 캐릭터 채팅 썸네일 clipping을 Kotlin에서 설정한다 2026-06-02 19:34:19 +09:00
klaus a5b4d5046d docs(home): AI 캐릭터 섹션 검증 기록을 추가한다 2026-06-02 19:21:23 +09:00
klaus 29e64188c9 feat(home): AI 캐릭터 섹션 간격을 정리한다 2026-06-02 19:20:57 +09:00
klaus 8a73ea0472 docs(home): 첫 오디오 섹션 검증 기록을 추가한다 2026-06-02 19:00:30 +09:00
klaus b402025ca5 feat(widget): 오디오 콘텐츠 카드 배지를 정리한다 2026-06-02 19:00:13 +09:00
klaus bd475d1c87 feat(home): 첫 오디오 콘텐츠 카드를 정리한다 2026-06-02 18:59:57 +09:00
klaus 816641d7c5 docs(home): 최근 데뷔 섹션 검증 기록을 추가한다 2026-06-02 17:28:47 +09:00
klaus 3028288bb3 feat(home): 최근 데뷔 크리에이터 카드를 정리한다 2026-06-02 17:28:40 +09:00
klaus 089e980832 feat(home): 홈 추천 섹션 간격을 정리한다 2026-06-02 17:27:57 +09:00
klaus f93236b2d0 docs(home): 라이브 섹션 검증 기록을 추가한다 2026-06-02 17:05:23 +09:00
klaus 9b29623f6f feat(home): 라이브 섹션 전체 아이템을 추가한다 2026-06-02 17:04:53 +09:00
klaus 0e50d7f8d5 docs(home): adapter 파일 분리 기록을 추가한다 2026-06-02 16:34:27 +09:00
klaus 9a8231c2b6 refactor(home): 홈 추천 adapter 파일을 분리한다 2026-06-02 16:34:16 +09:00
klaus 0a5be6467c docs(home): Phase 6 검증 기록을 추가한다 2026-06-02 16:23:45 +09:00
klaus 716f3c6880 test(home): 홈 추천 섹션 레이아웃 검증을 추가한다 2026-06-02 16:23:36 +09:00
klaus b20866fedd feat(home): 홈 추천 섹션 바인딩을 추가한다 2026-06-02 16:23:28 +09:00
klaus 14e7b33b63 feat(home): 최근 활동 크리에이터 카드를 정리한다 2026-06-02 16:23:21 +09:00
klaus cfa297ac3f feat(home): 홈 추천 크리에이터 카드 리소스를 추가한다 2026-06-02 16:23:11 +09:00
klaus e0e64090cd docs(home): Phase 5 검증 기록을 추가한다 2026-06-02 14:51:28 +09:00
klaus 68f8d869cc feat(home): 홈 추천 상단 UI shell을 추가한다 2026-06-02 14:51:01 +09:00
klaus d07a2837d9 feat(home): 홈 추천 섹션 문자열을 정리한다 2026-06-02 14:50:31 +09:00
klaus caee72f9c3 docs(home): Phase 4 검증 기록을 추가한다 2026-06-02 13:29:36 +09:00
klaus 4870b7377a feat(home): 홈 추천 ViewModel을 추가한다 2026-06-02 13:29:01 +09:00
klaus dd002e9f82 feat(home): 홈 추천 UI 상태 모델을 추가한다 2026-06-02 13:28:32 +09:00
klaus 4743c9cb8f feat(home): 홈 추천 UI 아이콘 리소스를 추가한다 2026-06-02 12:10:35 +09:00
klaus 3132269038 feat(home): 홈 추천 DI 등록을 추가한다 2026-06-02 12:10:18 +09:00
klaus 9eb1f7709d feat(home): 홈 추천 문자열 리소스를 추가한다 2026-06-02 12:10:01 +09:00
klaus 4817641155 feat(home): 홈 추천 activity type 매퍼를 추가한다 2026-06-02 12:09:46 +09:00
klaus c74ceba495 feat(home): 홈 추천 API 데이터 계층을 추가한다 2026-06-02 12:09:25 +09:00
klaus 0d339f9565 docs(home): 메인 홈 추천 요구사항을 문서화한다 2026-06-02 12:09:04 +09:00
klaus ee58794441 docs(workflow): 문서 폴더명 공백 대체 규칙 추가 2026-06-02 00:18:38 +09:00
klaus 4ab2490534 docs(agent): 계획 문서 규칙 수정 2026-06-01 19:34:16 +09:00
klaus 9a47c42958 docs(banner): Phase 9와 10 검증을 기록한다 2026-05-28 14:41:24 +09:00
klaus a35310e536 feat(banner): 배너 시각 정렬을 보완한다 2026-05-28 14:41:17 +09:00
klausandSisyphus 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
klausandSisyphus 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
klausandSisyphus 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
klausandSisyphus 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
klausandSisyphus 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
klausandSisyphus 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
klausandSisyphus 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
klausandSisyphus 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
klausandSisyphus 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
klausandSisyphus 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
klausandSisyphus 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
klausandSisyphus 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
klausandSisyphus 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
klausandSisyphus 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
klausandSisyphus 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
klaus 4d0d242162 docs(agent): 커밋 메시지 검증 규칙을 명시한다 2026-05-27 22:41:04 +09:00
klaus 1ffcd16efa docs(banner): Phase 2 검증과 테스트명 규칙을 기록한다 2026-05-27 22:28:19 +09:00
klaus 02f85f808d feat(banner): 배너 카운터와 상태 계약을 추가한다 2026-05-27 22:27:40 +09:00
klaus 91a7eb3f4c feat(banner): 배너 레이아웃 계산 계약을 추가한다 2026-05-27 22:26:50 +09:00
klaus 0c8c9f9b5f feat(banner): 배너 아이템 계약을 추가한다 2026-05-27 22:26:16 +09:00
klaus 8ae09d0514 docs(banner): 배너 컴포넌트 계획을 문서화한다 2026-05-27 21:10:57 +09:00
klaus 413a2f2fc1 docs(agent): 계획 문서 phase task 규칙을 추가한다 2026-05-27 20:56:17 +09:00
klaus 9ff4a8159a docs(agent): 실행 정책 중복을 정리한다 2026-05-27 20:20:13 +09:00
klaus abc0cfe461 chore(opencode): 커밋 정책 스킬을 제거한다 2026-05-27 20:19:25 +09:00
klaus 9a67c45dab docs(agent): 분리된 규칙 문서 참조를 갱신한다 2026-05-27 20:14:15 +09:00
klaus add6b66ea5 docs(agent): 작업 계획과 커밋 규칙 문서를 분리한다 2026-05-27 20:12:43 +09:00
klaus 11fdf89340 docs(workflow): 작업 계획 커밋 규칙 분리를 계획한다 2026-05-27 20:11:35 +09:00
klaus 9e37347877 chore(gitignore): antigravitycli 생성물을 제외한다 2026-05-27 19:40:08 +09:00
klaus ef81cba7eb chore(gitignore): omo 생성물을 제외한다 2026-05-27 14:54:43 +09:00
klaus 799dd7fc92 feat(widget): 오디오 콘텐츠 태그 배지를 추가한다 2026-05-27 14:50:59 +09:00
klaus a8e0f2377d feat(feed): 피드 어댑터와 뷰 테스트를 추가한다 2026-05-21 15:53:40 +09:00
klaus 59ea5de00a feat(feed): 커뮤니티 피드 뷰를 추가한다 2026-05-21 15:53:40 +09:00
klaus 77eef9609a feat(feed): 콘텐츠 피드 뷰를 추가한다 2026-05-21 15:53:40 +09:00
klaus 8e9ce634e3 feat(feed): 라이브 피드 뷰를 추가한다 2026-05-21 15:53:40 +09:00
klaus 4d0d330797 feat(feed): 랭킹 피드 뷰를 추가한다 2026-05-21 15:53:40 +09:00
klaus a5728bcc4d feat(feed): 피드 크기 계산 계약을 추가한다 2026-05-21 15:53:40 +09:00
klaus 3444b1eeef feat(feed): 랭킹 강조 텍스트 계약을 추가한다 2026-05-21 15:53:39 +09:00
klaus a2f3910e27 feat(feed): 피드 아이템 계약을 추가한다 2026-05-21 15:53:39 +09:00
klaus 01765f3e7f feat(feed): 피드 반응 아이콘 리소스를 추가한다 2026-05-21 15:53:39 +09:00
klaus 1baf62a2b7 feat(feed): 피드 공통 배경 리소스를 추가한다 2026-05-21 15:53:39 +09:00
klaus e5f298eaa7 feat(feed): 피드 문자열 리소스를 추가한다 2026-05-21 15:53:39 +09:00
klaus d312ce7cd8 docs(feed): 피드 컴포넌트 요구사항을 문서화한다 2026-05-21 15:53:39 +09:00
klaus 0764bced76 docs(agent): 코드 스타일 토큰 사용 원칙을 문서화한다 2026-05-21 11:32:35 +09:00
klaus c32f9cdd9f feat(widget): 캐릭터 채팅 썸네일 컴포넌트를 추가한다 2026-05-21 11:22:23 +09:00
klaus c58f03be08 feat(widget): 라이브 썸네일 컴포넌트를 추가한다 2026-05-20 17:55:19 +09:00
klaus 960e78afac feat(widget): 시리즈 콘텐츠 카드 컴포넌트를 추가한다 2026-05-20 14:06:33 +09:00
klaus 36ffbc6cdb feat(widget): 콘텐츠 랭킹 위젯을 추가한다 2026-05-20 12:00:23 +09:00
klaus 01fea58e4c feat(widget): 크리에이터 랭킹 위젯을 추가한다 2026-05-20 10:41:07 +09:00
klaus 6fda122091 feat(widget): 오디오 콘텐츠 카드 컴포넌트를 추가한다 2026-05-19 23:52:53 +09:00
klaus 30264935dc feat(widget): 섹션 타이틀 컴포넌트를 추가한다 2026-05-19 21:07:15 +09:00
646 changed files with 73480 additions and 297 deletions
+2
View File
@@ -315,5 +315,7 @@ app/release/
.junie/ .junie/
.kiro/ .kiro/
.omo/
.antigravitycli/
# End of https://www.toptal.com/developers/gitignore/api/macos,android,androidstudio,visualstudiocode,git,kotlin,java # End of https://www.toptal.com/developers/gitignore/api/macos,android,androidstudio,visualstudiocode,git,kotlin,java
-21
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
-46
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.
+25 -34
View File
@@ -1,29 +1,14 @@
# AGENTS.md # AGENTS.md
`SodaLive` 저장소에서 작업하는 에이전트 실행 가이드다. `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) ## 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. 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. 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. **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를 허용한다. - 대규모 리팩토링, 브레인스토밍, 다중 에이전트 실행, 병렬 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` 패키지 하위에 작성한다. - 기존 로직 수정이 아닌 신규 `Activity`, `Fragment`, `ViewModel` 및 그와 연결된 하위 코드는 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다.
## 레거시 코드 사용 원칙
- 레거시 코드는 직접 수정하지 않는다.
- 레거시 기능이 필요하면 기존 코드를 호출해서 사용한다.
- 레거시 코드를 약간 변경해야 사용할 수 있는 경우에도 레거시 파일을 고치지 말고, 신규 파일에 wrapper/adapter/확장 코드를 추가해 사용한다.
- 레거시 코드 변경이 불가피해 보이면 구현 전에 사용자에게 확인한다.
## 작업 절차 핵심 규칙 ## 작업 절차 핵심 규칙
- PRD 문서와 구현 계획/TASK 문서 없이 구현하지 않는다. - 모든 구현 작업은 PRD 문서와 구현 계획/TASK 문서가 모두 준비된 뒤에 시작한다.
- 사용자의 프롬프트를 받으면 먼저 PRD 문서를 작성하고, 애매하거나 결정이 필요한 내용은 모호함이 사라질 때까지 사용자와 인터뷰한다. - 사용자의 프롬프트를 받으면 먼저 PRD 문서를 작성한다.
- PRD 작성 중 애매하거나 더 필요한 내용, 결정해야 하는 사항이 있으면 애매한 사항이 없어질 때까지 사용자와 인터뷰한다.
- 인터뷰 내용을 PRD에 반영한 뒤, PRD를 기준으로 계획/TASK 문서를 작성하고 그 문서에 따라 필요한 내용만 최소 구현한다. - 인터뷰 내용을 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/build-test-style.md`를 참고한다.
- 코드 스타일/구조는 `docs/agent-guides/code-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`를 참고한다. - 저장소 세부 규칙/보안/Git 안전 수칙은 `docs/agent-guides/safety-repo-rules.md`를 참고한다.
## 핵심 금지사항 ## 핵심 금지사항
+8
View File
@@ -164,6 +164,12 @@ android {
checkDependencies true checkDependencies true
checkReleaseBuilds false checkReleaseBuilds false
} }
testOptions {
unitTests {
includeAndroidResources = true
}
}
} }
dependencies { dependencies {
@@ -287,6 +293,8 @@ dependencies {
testImplementation 'org.mockito:mockito-inline:5.2.0' testImplementation 'org.mockito:mockito-inline:5.2.0'
testImplementation 'org.mockito.kotlin:mockito-kotlin:6.1.0' testImplementation 'org.mockito.kotlin:mockito-kotlin:6.1.0'
testImplementation 'io.mockk:mockk:1.14.6' 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:core-ktx:1.6.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.ext:junit:1.2.1'
+5
View File
@@ -112,6 +112,11 @@
</activity> </activity>
<activity android:name=".main.MainActivity" /> <activity android:name=".main.MainActivity" />
<activity android:name=".v2.main.MainV2Activity" /> <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=".user.login.LoginActivity" />
<activity android:name=".audio_content.all.AudioContentAllActivity" /> <activity android:name=".audio_content.all.AudioContentAllActivity" />
<activity android:name=".settings.language.LanguageSettingsActivity" /> <activity android:name=".settings.language.LanguageSettingsActivity" />
@@ -18,9 +18,9 @@ import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityAudioContentNewAllBinding 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.extensions.dpToPx
import kr.co.vividnext.sodalive.home.HomeContentThemeAdapter import kr.co.vividnext.sodalive.home.HomeContentThemeAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@@ -126,9 +126,7 @@ class AudioContentNewAllActivity : BaseActivity<ActivityAudioContentNewAllBindin
}, },
onClickCreator = { onClickCreator = {
startActivity( startActivity(
Intent(this, UserProfileActivity::class.java).apply { CreatorChannelActivity.newIntent(this, it)
putExtra(Constants.EXTRA_USER_ID, it)
}
) )
} }
) )
@@ -19,7 +19,7 @@ import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityAudioContentAllByThemeBinding 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 import org.koin.android.ext.android.inject
class AudioContentAllByThemeActivity : BaseActivity<ActivityAudioContentAllByThemeBinding>( class AudioContentAllByThemeActivity : BaseActivity<ActivityAudioContentAllByThemeBinding>(
@@ -69,9 +69,7 @@ class AudioContentAllByThemeActivity : BaseActivity<ActivityAudioContentAllByThe
}, },
onClickCreator = { onClickCreator = {
startActivity( startActivity(
Intent(this, UserProfileActivity::class.java).apply { CreatorChannelActivity.newIntent(this, it)
putExtra(Constants.EXTRA_USER_ID, it)
}
) )
} }
) )
@@ -58,7 +58,6 @@ import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.common.Utils import kr.co.vividnext.sodalive.common.Utils
import kr.co.vividnext.sodalive.databinding.ActivityAudioContentDetailBinding import kr.co.vividnext.sodalive.databinding.ActivityAudioContentDetailBinding
import kr.co.vividnext.sodalive.explorer.profile.CreatorFollowNotifyFragment 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.dpToPx
import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationDialog 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.RecentContentViewModel
import kr.co.vividnext.sodalive.mypage.recent.db.RecentContent import kr.co.vividnext.sodalive.mypage.recent.db.RecentContent
import kr.co.vividnext.sodalive.report.ReportType import kr.co.vividnext.sodalive.report.ReportType
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -1207,9 +1207,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
this.creatorId = creator.creatorId this.creatorId = creator.creatorId
binding.rlProfile.setOnClickListener { binding.rlProfile.setOnClickListener {
startActivity( startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply { CreatorChannelActivity.newIntent(applicationContext, creator.creatorId)
putExtra(Constants.EXTRA_USER_ID, creator.creatorId)
}
) )
} }
@@ -1,7 +1,6 @@
package kr.co.vividnext.sodalive.audio_content.series.detail package kr.co.vividnext.sodalive.audio_content.series.detail
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.widget.Toast 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.common.image.BlurTransformation
import kr.co.vividnext.sodalive.databinding.ActivitySeriesDetailBinding import kr.co.vividnext.sodalive.databinding.ActivitySeriesDetailBinding
import kr.co.vividnext.sodalive.explorer.profile.CreatorFollowNotifyFragment 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.dpToPx
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
class SeriesDetailActivity : BaseActivity<ActivitySeriesDetailBinding>( class SeriesDetailActivity : BaseActivity<ActivitySeriesDetailBinding>(
@@ -82,7 +81,6 @@ class SeriesDetailActivity : BaseActivity<ActivitySeriesDetailBinding>(
override fun onTabReselected(tab: TabLayout.Tab) { override fun onTabReselected(tab: TabLayout.Tab) {
} }
}) })
} }
@@ -194,9 +192,7 @@ class SeriesDetailActivity : BaseActivity<ActivitySeriesDetailBinding>(
private fun setSeriesCreator(creator: GetSeriesDetailResponse.GetSeriesDetailCreator) { private fun setSeriesCreator(creator: GetSeriesDetailResponse.GetSeriesDetailCreator) {
binding.llProfile.setOnClickListener { binding.llProfile.setOnClickListener {
startActivity( startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply { CreatorChannelActivity.newIntent(applicationContext, creator.creatorId)
putExtra(Constants.EXTRA_USER_ID, creator.creatorId)
}
) )
} }
binding.tvNickname.text = creator.nickname binding.tvNickname.text = creator.nickname
@@ -29,7 +29,6 @@ import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.RealPathUtil import kr.co.vividnext.sodalive.common.RealPathUtil
import kr.co.vividnext.sodalive.common.SharedPreferenceManager 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.databinding.ActivityAuditionRoleDetailBinding
import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity
import kr.co.vividnext.sodalive.explorer.profile.creator_community.write.RecordingVoiceFragment 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 org.koin.android.ext.android.inject
import java.io.File import java.io.File
class AuditionRoleDetailActivity : BaseActivity<ActivityAuditionRoleDetailBinding>( class AuditionRoleDetailActivity :
BaseActivity<ActivityAuditionRoleDetailBinding>(
ActivityAuditionRoleDetailBinding::inflate ActivityAuditionRoleDetailBinding::inflate
), RecordingVoiceFragment.OnAudioRecordedListener { ),
RecordingVoiceFragment.OnAudioRecordedListener {
private val viewModel: AuditionRoleDetailViewModel by inject() private val viewModel: AuditionRoleDetailViewModel by inject()
private lateinit var loadingDialog: LoadingDialog private lateinit var loadingDialog: LoadingDialog
@@ -31,6 +31,8 @@ abstract class BaseActivity<T : ViewBinding>(
lateinit var binding: T lateinit var binding: T
private set private set
protected open val shouldApplySystemBarTopInset: Boolean = true
val screenWidth: Int by lazy { val screenWidth: Int by lazy {
resources.displayMetrics.widthPixels resources.displayMetrics.widthPixels
} }
@@ -81,7 +83,7 @@ abstract class BaseActivity<T : ViewBinding>(
// 루트는 좌/우/하만 처리(상단은 Toolbar에 위임). IME가 등장하면 하단 패딩을 IME 높이까지 확장 // 루트는 좌/우/하만 처리(상단은 Toolbar에 위임). IME가 등장하면 하단 패딩을 IME 높이까지 확장
val left = max(systemBars.left, ime.left) 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 right = max(systemBars.right, ime.right)
val bottom = max(systemBars.bottom, ime.bottom) val bottom = max(systemBars.bottom, ime.bottom)
v.setPadding(left, top, right, bottom) v.setPadding(left, top, right, bottom)
@@ -7,6 +7,9 @@ import okhttp3.OkHttpClient
import java.io.File import java.io.File
object ImageLoaderProvider { 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 lateinit var imageLoader: ImageLoader
private set private set
@@ -14,9 +17,10 @@ object ImageLoaderProvider {
get() = ::imageLoader.isInitialized get() = ::imageLoader.isInitialized
fun init(context: Context) { fun init(context: Context) {
val cacheSize = 250L * 1024L * 1024L // 250 MB val cacheSize = 250L * 1024L * 1024L // 250 MB
File(context.cacheDir, LEGACY_OKHTTP_IMAGE_CACHE_DIRECTORY_NAME).deleteRecursively()
val cacheDirectory = File( val cacheDirectory = File(
context.cacheDir, context.cacheDir,
"image_cache" COIL_IMAGE_CACHE_DIRECTORY_NAME
).apply { mkdirs() } ).apply { mkdirs() }
val cache = Cache(cacheDirectory, cacheSize) val cache = Cache(cacheDirectory, cacheSize)
@@ -0,0 +1,109 @@
package kr.co.vividnext.sodalive.common
import android.content.Context
import kr.co.vividnext.sodalive.R
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
fun formatUtcRelativeTimeText(context: Context, utcText: String?): String {
val pastMillis = parseServerUtcToMillis(utcText)
?: return context.getString(R.string.character_comment_time_just_now)
val nowMillis = System.currentTimeMillis()
var diff = nowMillis - pastMillis
if (diff < 0) diff = 0
val minute = 60_000L
val hour = 60 * minute
val day = 24 * hour
if (diff < minute) {
return context.getString(R.string.character_comment_time_just_now)
}
if (diff < hour) {
val minutes = (diff / minute).toInt()
return context.getString(R.string.character_comment_time_minutes, minutes)
}
if (diff < day) {
val hours = (diff / hour).toInt()
return context.getString(R.string.character_comment_time_hours, hours)
}
if (diff < 30 * day) {
val days = (diff / day).toInt()
return context.getString(R.string.character_comment_time_days, days)
}
val timeZone = TimeZone.getDefault()
val nowCalendar = Calendar.getInstance(timeZone, Locale.getDefault())
val pastCalendar = Calendar.getInstance(timeZone, Locale.getDefault())
pastCalendar.timeInMillis = pastMillis
var years = nowCalendar.get(Calendar.YEAR) - pastCalendar.get(Calendar.YEAR)
val nowMonth = nowCalendar.get(Calendar.MONTH)
val pastMonth = pastCalendar.get(Calendar.MONTH)
val nowDay = nowCalendar.get(Calendar.DAY_OF_MONTH)
val pastDay = pastCalendar.get(Calendar.DAY_OF_MONTH)
if (nowMonth < pastMonth || (nowMonth == pastMonth && nowDay < pastDay)) {
years -= 1
}
if (years < 1) {
var months = (nowCalendar.get(Calendar.YEAR) - pastCalendar.get(Calendar.YEAR)) * 12 + (nowMonth - pastMonth)
if (nowDay < pastDay) months -= 1
if (months < 1) months = 1
return context.getString(R.string.character_comment_time_months, months)
}
return context.getString(R.string.character_comment_time_years, years)
}
fun interface UtcRelativeTimeTextFormatter {
fun format(utcText: String?): String
}
class AndroidUtcRelativeTimeTextFormatter(context: Context) : UtcRelativeTimeTextFormatter {
private val applicationContext = context.applicationContext
override fun format(utcText: String?): String = formatUtcRelativeTimeText(applicationContext, utcText)
}
private fun parseServerUtcToMillis(utcText: String?): Long? {
if (utcText.isNullOrBlank()) return null
val value = utcText.trim()
if (value.all { it.isDigit() }) {
return try {
value.toLong()
} catch (_: NumberFormatException) {
null
}
}
val patterns = listOf(
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
"yyyy-MM-dd'T'HH:mm:ss'Z'",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd HH:mm:ss",
"yyyy/MM/dd HH:mm:ss"
)
for (pattern in patterns) {
try {
val dateFormat = SimpleDateFormat(pattern, Locale.US)
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
val parsed: Date? = dateFormat.parse(value)
if (parsed != null) return parsed.time
} catch (_: ParseException) {
}
}
return null
}
@@ -68,11 +68,13 @@ import kr.co.vividnext.sodalive.chat.talk.TalkTabRepository
import kr.co.vividnext.sodalive.chat.talk.TalkTabViewModel import kr.co.vividnext.sodalive.chat.talk.TalkTabViewModel
import kr.co.vividnext.sodalive.chat.talk.room.chatTalkRoomModule import kr.co.vividnext.sodalive.chat.talk.room.chatTalkRoomModule
import kr.co.vividnext.sodalive.common.ApiBuilder 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.ExplorerApi
import kr.co.vividnext.sodalive.explorer.ExplorerRepository import kr.co.vividnext.sodalive.explorer.ExplorerRepository
import kr.co.vividnext.sodalive.explorer.ExplorerViewModel 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.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.CreatorCommunityApi
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllViewModel 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.FaqApi
import kr.co.vividnext.sodalive.mypage.service_center.FaqRepository import kr.co.vividnext.sodalive.mypage.service_center.FaqRepository
import kr.co.vividnext.sodalive.mypage.service_center.ServiceCenterViewModel 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.AcceptLanguageInterceptor
import kr.co.vividnext.sodalive.network.TokenAuthenticator
import kr.co.vividnext.sodalive.report.ReportApi import kr.co.vividnext.sodalive.report.ReportApi
import kr.co.vividnext.sodalive.report.ReportRepository import kr.co.vividnext.sodalive.report.ReportRepository
import kr.co.vividnext.sodalive.search.SearchApi 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.find_password.FindPasswordViewModel
import kr.co.vividnext.sodalive.user.login.LoginViewModel import kr.co.vividnext.sodalive.user.login.LoginViewModel
import kr.co.vividnext.sodalive.user.signup.SignUpViewModel 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.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.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
@@ -195,6 +234,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
private val otherModule = module { private val otherModule = module {
single { GsonBuilder().create() } single { GsonBuilder().create() }
single<UtcRelativeTimeTextFormatter> { AndroidUtcRelativeTimeTextFormatter(get()) }
single { PlaybackTrackingDatabase.getDatabase(get()) } single { PlaybackTrackingDatabase.getDatabase(get()) }
single<PlaybackTrackingDao> { get<PlaybackTrackingDatabase>().playbackTrackingDao() } single<PlaybackTrackingDao> { get<PlaybackTrackingDatabase>().playbackTrackingDao() }
} }
@@ -208,6 +248,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
} else { } else {
logging.setLevel(HttpLoggingInterceptor.Level.NONE) logging.setLevel(HttpLoggingInterceptor.Level.NONE)
} }
logging.redactHeader("Authorization")
OkHttpClient().newBuilder() OkHttpClient().newBuilder()
.addInterceptor(logging) .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(), SearchApi::class.java) }
single { ApiBuilder().build(get(), PointStatusApi::class.java) } single { ApiBuilder().build(get(), PointStatusApi::class.java) }
single { ApiBuilder().build(get(), HomeApi::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(), CharacterApi::class.java) }
single { ApiBuilder().build(get(), TalkApi::class.java) } single { ApiBuilder().build(get(), TalkApi::class.java) }
single { ApiBuilder().build(get(), CharacterCommentApi::class.java) } single { ApiBuilder().build(get(), CharacterCommentApi::class.java) }
single { ApiBuilder().build(get(), OriginalWorkApi::class.java) } single { ApiBuilder().build(get(), OriginalWorkApi::class.java) }
single { ApiBuilder().build(get<Retrofit>(named("agoraRetrofit")), V2vApi::class.java) } single { ApiBuilder().build(get<Retrofit>(named("agoraRetrofit")), V2vApi::class.java) }
single { DmChatSocketClient(okHttpClient = get(), gson = get(), baseUrl = baseUrl) }
} }
private val viewModelModule = module { private val viewModelModule = module {
@@ -381,6 +433,22 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { SearchViewModel(get()) } viewModel { SearchViewModel(get()) }
viewModel { PointStatusViewModel(get()) } viewModel { PointStatusViewModel(get()) }
viewModel { HomeViewModel(get(), 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 { PushNotificationListViewModel(get()) }
viewModel { CharacterTabViewModel(get()) } viewModel { CharacterTabViewModel(get()) }
viewModel { CharacterDetailViewModel(get()) } viewModel { CharacterDetailViewModel(get()) }
@@ -431,6 +499,24 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
factory { UserEventRepository(get()) } factory { UserEventRepository(get()) }
factory { PointStatusRepository(get()) } factory { PointStatusRepository(get()) }
factory { HomeRepository(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 { CharacterTabRepository(get()) }
factory { CharacterDetailRepository(get(), get()) } factory { CharacterDetailRepository(get(), get()) }
factory { CharacterGalleryRepository(get()) } factory { CharacterGalleryRepository(get()) }
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.explorer
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Service import android.app.Service
import android.content.Intent
import android.graphics.Rect import android.graphics.Rect
import android.os.Bundle import android.os.Bundle
import android.os.Handler 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.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseFragment 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.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.FragmentExplorerBinding 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.extensions.dpToPx
import kr.co.vividnext.sodalive.message.SelectMessageRecipientAdapter import kr.co.vividnext.sodalive.message.SelectMessageRecipientAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -62,9 +60,7 @@ class ExplorerFragment : BaseFragment<FragmentExplorerBinding>(
private fun setupView() { private fun setupView() {
adapter = ExplorerAdapter { adapter = ExplorerAdapter {
val intent = Intent(requireContext(), UserProfileActivity::class.java) startActivity(CreatorChannelActivity.newIntent(requireContext(), it))
intent.putExtra(Constants.EXTRA_USER_ID, it)
startActivity(intent)
} }
binding.rvExplorer.layoutManager = LinearLayoutManager( binding.rvExplorer.layoutManager = LinearLayoutManager(
@@ -108,9 +104,7 @@ class ExplorerFragment : BaseFragment<FragmentExplorerBinding>(
private fun setupSearchChannelView() { private fun setupSearchChannelView() {
searchChannelAdapter = SelectMessageRecipientAdapter { searchChannelAdapter = SelectMessageRecipientAdapter {
hideKeyboard() hideKeyboard()
val intent = Intent(requireContext(), UserProfileActivity::class.java) startActivity(CreatorChannelActivity.newIntent(requireContext(), it.id))
intent.putExtra(Constants.EXTRA_USER_ID, it.id)
startActivity(intent)
} }
binding.rvSearchChannel.layoutManager = LinearLayoutManager( binding.rvSearchChannel.layoutManager = LinearLayoutManager(
@@ -1,3 +1,5 @@
@file:Suppress("ktlint:package-name", "ktlint:standard:package-name")
package kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player package kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player
import android.content.Context import android.content.Context
@@ -27,28 +29,36 @@ class CreatorCommunityMediaPlayerManager(
private var mediaPlayer: MediaPlayer? = null private var mediaPlayer: MediaPlayer? = null
private var currentPlayingContentId: Long? = null private var currentPlayingContentId: Long? = null
private var isPaused: Boolean = false private var isPaused: Boolean = false
private var isPrepared: Boolean = false
fun pauseContent() { fun pauseContent() {
if (isPrepared) {
mediaPlayer?.pause() mediaPlayer?.pause()
}
isPaused = true isPaused = true
updateUI() updateUI()
} }
private fun resumeContent() { private fun resumeContent() {
pauseAudioContentService() pauseAudioContentService()
if (isPrepared) {
mediaPlayer?.start() mediaPlayer?.start()
}
isPaused = false isPaused = false
updateUI() updateUI()
} }
fun stopContent() { fun stopContent() {
mediaPlayer?.let { mediaPlayer?.let {
if (isPrepared) {
it.stop() it.stop()
}
it.release() it.release()
mediaPlayer = null mediaPlayer = null
} }
currentPlayingContentId = null currentPlayingContentId = null
isPaused = false isPaused = false
isPrepared = false
updateUI() updateUI()
} }
@@ -88,13 +98,15 @@ class CreatorCommunityMediaPlayerManager(
try { try {
setDataSource(context, Uri.parse(creatorCommunityContentItem.url)) setDataSource(context, Uri.parse(creatorCommunityContentItem.url))
prepareAsync() // 비동기적으로 준비
setOnPreparedListener { setOnPreparedListener {
isPrepared = true
if (!isPaused) {
start() start()
}
updateUI() // 준비 완료 후 UI 업데이트 updateUI() // 준비 완료 후 UI 업데이트
} }
prepareAsync() // 비동기적으로 준비
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace()
Toast.makeText( Toast.makeText(
context, context,
SodaLiveApplicationHolder.get() SodaLiveApplicationHolder.get()
@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.explorer.profile.creator_community.write package kr.co.vividnext.sodalive.explorer.profile.creator_community.write
import android.Manifest import android.Manifest
import android.app.Activity
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -26,9 +27,11 @@ import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import java.io.File import java.io.File
class CreatorCommunityWriteActivity : BaseActivity<ActivityCreatorCommunityWriteBinding>( class CreatorCommunityWriteActivity :
BaseActivity<ActivityCreatorCommunityWriteBinding>(
ActivityCreatorCommunityWriteBinding::inflate ActivityCreatorCommunityWriteBinding::inflate
), RecordingVoiceFragment.OnAudioRecordedListener { ),
RecordingVoiceFragment.OnAudioRecordedListener {
private val viewModel: CreatorCommunityWriteViewModel by inject() private val viewModel: CreatorCommunityWriteViewModel by inject()
@@ -62,7 +65,8 @@ class CreatorCommunityWriteActivity : BaseActivity<ActivityCreatorCommunityWrite
context = this, context = this,
isEnabledFreeStyleCrop = true, isEnabledFreeStyleCrop = true,
config = ImagePickerCropper.Config( config = ImagePickerCropper.Config(
aspectX = 1f, aspectY = 1f, aspectX = 1f,
aspectY = 1f,
compressFormat = Bitmap.CompressFormat.JPEG, compressFormat = Bitmap.CompressFormat.JPEG,
compressQuality = 90 compressQuality = 90
), ),
@@ -112,7 +116,10 @@ class CreatorCommunityWriteActivity : BaseActivity<ActivityCreatorCommunityWrite
binding.llPriceFree.setOnClickListener { viewModel.setPriceFree(true) } binding.llPriceFree.setOnClickListener { viewModel.setPriceFree(true) }
binding.tvCancel.setOnClickListener { finish() } binding.tvCancel.setOnClickListener { finish() }
binding.tvUpload.setOnClickListener { binding.tvUpload.setOnClickListener {
viewModel.createCommunityPost { finish() } viewModel.createCommunityPost {
setResult(Activity.RESULT_OK)
finish()
}
} }
} }
@@ -31,6 +31,8 @@ class SodaFirebaseMessagingService : FirebaseMessagingService() {
remoteMessage.notification != null remoteMessage.notification != null
) { ) {
sendNotification(remoteMessage.data, remoteMessage.notification) sendNotification(remoteMessage.data, remoteMessage.notification)
} else if (hasDeepLink(remoteMessage.data)) {
sendNotification(remoteMessage.data, remoteMessage.notification)
} else if (remoteMessage.data["message"]?.isNotBlank() == true) { } else if (remoteMessage.data["message"]?.isNotBlank() == true) {
sendNotification(remoteMessage.data, remoteMessage.notification) sendNotification(remoteMessage.data, remoteMessage.notification)
} }
@@ -43,6 +45,11 @@ class SodaFirebaseMessagingService : FirebaseMessagingService() {
SharedPreferenceManager.pushToken = token SharedPreferenceManager.pushToken = token
} }
private fun hasDeepLink(messageData: Map<String, String>): Boolean {
return messageData["deepLink"]?.isNotBlank() == true ||
messageData["deep_link"]?.isNotBlank() == true
}
private fun sendNotification( private fun sendNotification(
messageData: Map<String, String>, messageData: Map<String, String>,
notification: RemoteMessage.Notification? notification: RemoteMessage.Notification?
@@ -78,6 +85,7 @@ class SodaFirebaseMessagingService : FirebaseMessagingService() {
val deepLinkExtras = if (!deepLinkUrl.isNullOrBlank()) { val deepLinkExtras = if (!deepLinkUrl.isNullOrBlank()) {
android.os.Bundle().apply { android.os.Bundle().apply {
putString("deep_link", deepLinkUrl) putString("deep_link", deepLinkUrl)
messageData["room_id"]?.let { putString("room_id", it) }
} }
} else { } else {
android.os.Bundle().apply { android.os.Bundle().apply {
@@ -1,7 +1,6 @@
package kr.co.vividnext.sodalive.following package kr.co.vividnext.sodalive.following
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Rect import android.graphics.Rect
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
@@ -10,12 +9,11 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityFollowingCreatorBinding import kr.co.vividnext.sodalive.databinding.ActivityFollowingCreatorBinding
import kr.co.vividnext.sodalive.explorer.profile.CreatorFollowNotifyFragment 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.dpToPx
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
class FollowingCreatorActivity : BaseActivity<ActivityFollowingCreatorBinding>( class FollowingCreatorActivity : BaseActivity<ActivityFollowingCreatorBinding>(
@@ -41,9 +39,7 @@ class FollowingCreatorActivity : BaseActivity<ActivityFollowingCreatorBinding>(
adapter = FollowingCreatorAdapter( adapter = FollowingCreatorAdapter(
onClickItem = { creatorId -> onClickItem = { creatorId ->
startActivity( startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply { CreatorChannelActivity.newIntent(applicationContext, creatorId)
putExtra(Constants.EXTRA_USER_ID, creatorId)
}
) )
}, },
onClickFollow = { creatorId, isFollow -> onClickFollow = { creatorId, isFollow ->
@@ -65,7 +61,7 @@ class FollowingCreatorActivity : BaseActivity<ActivityFollowingCreatorBinding>(
} else { } else {
viewModel.follow(creatorId) viewModel.follow(creatorId)
} }
}, }
) )
binding.rvFollowingCreator.layoutManager = LinearLayoutManager( binding.rvFollowingCreator.layoutManager = LinearLayoutManager(
@@ -45,7 +45,6 @@ import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentHomeBinding 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.home.pushnotification.PushNotificationListActivity
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.live.LiveViewModel 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.language.LocaleHelper
import kr.co.vividnext.sodalive.settings.notification.MemberRole import kr.co.vividnext.sodalive.settings.notification.MemberRole
import kr.co.vividnext.sodalive.splash.SplashActivity import kr.co.vividnext.sodalive.splash.SplashActivity
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -271,12 +271,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
onClickItem = { onClickItem = {
if (SharedPreferenceManager.token.isNotBlank()) { if (SharedPreferenceManager.token.isNotBlank()) {
startActivity( startActivity(
Intent( CreatorChannelActivity.newIntent(requireActivity(), it)
requireActivity(),
UserProfileActivity::class.java
).apply {
putExtra(Constants.EXTRA_USER_ID, it)
}
) )
} else { } else {
(requireActivity() as MainActivity).showLoginActivity() (requireActivity() as MainActivity).showLoginActivity()
@@ -503,9 +498,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
AudioContentBannerType.CREATOR -> { AudioContentBannerType.CREATOR -> {
startActivity( startActivity(
Intent(requireContext(), UserProfileActivity::class.java).apply { CreatorChannelActivity.newIntent(requireContext(), it.creatorId!!)
putExtra(Constants.EXTRA_USER_ID, it.creatorId!!)
}
) )
} }
@@ -922,9 +915,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
onClickCreatorProfile = { onClickCreatorProfile = {
if (SharedPreferenceManager.token.isNotBlank()) { if (SharedPreferenceManager.token.isNotBlank()) {
startActivity( startActivity(
Intent(requireContext(), UserProfileActivity::class.java).apply { CreatorChannelActivity.newIntent(requireContext(), it)
putExtra(Constants.EXTRA_USER_ID, it)
}
) )
} else { } else {
(requireActivity() as MainActivity).showLoginActivity() (requireActivity() as MainActivity).showLoginActivity()
@@ -38,7 +38,6 @@ import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentLiveBinding 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.CreatorCommunityAdapter
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity
import kr.co.vividnext.sodalive.extensions.dpToPx 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.language.LocaleHelper
import kr.co.vividnext.sodalive.settings.notification.MemberRole import kr.co.vividnext.sodalive.settings.notification.MemberRole
import kr.co.vividnext.sodalive.splash.SplashActivity import kr.co.vividnext.sodalive.splash.SplashActivity
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -265,9 +265,7 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
adapter = RecommendLiveAdapter(requireContext(), pagerWidth.roundToInt(), pagerHeight) { adapter = RecommendLiveAdapter(requireContext(), pagerWidth.roundToInt(), pagerHeight) {
if (SharedPreferenceManager.token.isNotBlank()) { if (SharedPreferenceManager.token.isNotBlank()) {
startActivity( startActivity(
Intent(requireContext(), UserProfileActivity::class.java).apply { CreatorChannelActivity.newIntent(requireContext(), it)
putExtra(Constants.EXTRA_USER_ID, it)
}
) )
} else { } else {
(requireActivity() as MainActivity).showLoginActivity() (requireActivity() as MainActivity).showLoginActivity()
@@ -314,9 +312,7 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
onClick = { onClick = {
if (SharedPreferenceManager.token.isNotBlank()) { if (SharedPreferenceManager.token.isNotBlank()) {
startActivity( startActivity(
Intent(requireContext(), UserProfileActivity::class.java).apply { CreatorChannelActivity.newIntent(requireContext(), it)
putExtra(Constants.EXTRA_USER_ID, it)
}
) )
} else { } else {
(requireActivity() as MainActivity).showLoginActivity() (requireActivity() as MainActivity).showLoginActivity()
@@ -388,12 +384,7 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
val adapter = LatestFinishedLiveAdapter { val adapter = LatestFinishedLiveAdapter {
if (SharedPreferenceManager.token.isNotBlank()) { if (SharedPreferenceManager.token.isNotBlank()) {
startActivity( startActivity(
Intent( CreatorChannelActivity.newIntent(requireContext(), it)
requireContext(),
UserProfileActivity::class.java
).apply {
putExtra(Constants.EXTRA_USER_ID, it)
}
) )
} else { } else {
(requireActivity() as MainActivity).showLoginActivity() (requireActivity() as MainActivity).showLoginActivity()
@@ -23,16 +23,15 @@ import com.yandex.mobile.ads.banner.BannerAdSize
import com.yandex.mobile.ads.common.AdRequest import com.yandex.mobile.ads.common.AdRequest
import kr.co.vividnext.sodalive.BuildConfig import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R 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.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentLiveRoomDetailBinding import kr.co.vividnext.sodalive.databinding.FragmentLiveRoomDetailBinding
import kr.co.vividnext.sodalive.databinding.ItemLiveDetailUserSummaryBinding 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.convertDateFormat
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.settings.language.LanguageManager import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper 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 org.koin.android.ext.android.inject
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
@@ -315,9 +314,7 @@ class LiveRoomDetailFragment(
if (manager.isCreator) { if (manager.isCreator) {
binding.tvManagerProfile.visibility = View.VISIBLE binding.tvManagerProfile.visibility = View.VISIBLE
binding.tvManagerProfile.setOnClickListener { binding.tvManagerProfile.setOnClickListener {
val intent = Intent(requireActivity(), UserProfileActivity::class.java) startActivity(CreatorChannelActivity.newIntent(requireActivity(), manager.id))
intent.putExtra(Constants.EXTRA_USER_ID, manager.id)
startActivity(intent)
} }
} else { } else {
binding.tvManagerProfile.visibility = View.GONE binding.tvManagerProfile.visibility = View.GONE
@@ -10,13 +10,14 @@ import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
import kr.co.vividnext.sodalive.app.SodaLiveApp import kr.co.vividnext.sodalive.app.SodaLiveApp
import kr.co.vividnext.sodalive.audition.AuditionActivity import kr.co.vividnext.sodalive.audition.AuditionActivity
import kr.co.vividnext.sodalive.common.Constants 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.explorer.profile.creator_community.all.CreatorCommunityAllActivity
import kr.co.vividnext.sodalive.live.room.LiveRoomActivity import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
import kr.co.vividnext.sodalive.message.MessageActivity import kr.co.vividnext.sodalive.message.MessageActivity
import kr.co.vividnext.sodalive.mypage.can.payment.CanPaymentActivity import kr.co.vividnext.sodalive.mypage.can.payment.CanPaymentActivity
import kr.co.vividnext.sodalive.splash.SplashActivity 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.MainV2Activity
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
import java.util.Locale import java.util.Locale
class DeepLinkActivity : AppCompatActivity() { 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) { if (SodaLiveApp.isAppInForeground && LiveRoomActivity.isForeground && deepLinkExtras != null) {
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast( LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(
Intent(Constants.ACTION_LIVE_ROOM_DEEPLINK_CONFIRM).apply { 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() val communityPostId = bundle.getString(Constants.EXTRA_COMMUNITY_POST_ID)?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_COMMUNITY_POST_ID).takeIf { it > 0 } ?: 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 { when {
roomId != null && roomId > 0 -> { roomId != null && roomId > 0 -> {
routeLiveInMain(roomId) routeLiveInMain(roomId)
@@ -275,9 +288,7 @@ class DeepLinkActivity : AppCompatActivity() {
channelId != null && channelId > 0 -> { channelId != null && channelId > 0 -> {
startActivity( startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply { CreatorChannelActivity.newIntent(applicationContext, channelId)
putExtra(Constants.EXTRA_USER_ID, channelId)
}
) )
return true return true
} }
@@ -319,6 +330,10 @@ class DeepLinkActivity : AppCompatActivity() {
return routeByDeepLinkValue(deepLinkValue = deepLinkValue, deepLinkValueId = deepLinkValueId) 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 { private fun routeByDeepLinkValue(deepLinkValue: String?, deepLinkValueId: Long?): Boolean {
if (deepLinkValue.isNullOrBlank()) { if (deepLinkValue.isNullOrBlank()) {
return false return false
@@ -357,9 +372,7 @@ class DeepLinkActivity : AppCompatActivity() {
} }
startActivity( startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply { CreatorChannelActivity.newIntent(applicationContext, deepLinkValueId)
putExtra(Constants.EXTRA_USER_ID, deepLinkValueId)
}
) )
true true
} }
@@ -427,6 +440,12 @@ class DeepLinkActivity : AppCompatActivity() {
putIfAbsent("deep_link_sub5", pathId) putIfAbsent("deep_link_sub5", pathId)
} }
"chat" -> {
putIfAbsent("room_id", pathId)
putIfAbsent("deep_link_value", "chat")
putIfAbsent("deep_link_sub5", pathId)
}
"content" -> { "content" -> {
putIfAbsent("content_id", pathId) putIfAbsent("content_id", pathId)
putIfAbsent("deep_link_value", "content") putIfAbsent("deep_link_value", "content")
@@ -46,7 +46,6 @@ import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityMainBinding import kr.co.vividnext.sodalive.databinding.ActivityMainBinding
import kr.co.vividnext.sodalive.databinding.ItemMainTabBinding 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.explorer.profile.creator_community.all.CreatorCommunityAllActivity
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.home.HomeFragment 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.event.EventDetailActivity
import kr.co.vividnext.sodalive.settings.notification.NotificationSettingsDialog import kr.co.vividnext.sodalive.settings.notification.NotificationSettingsDialog
import kr.co.vividnext.sodalive.user.login.LoginActivity 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 org.koin.android.ext.android.inject
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -316,8 +317,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
private fun executeBundleDeeplink(bundle: Bundle): Boolean { private fun executeBundleDeeplink(bundle: Bundle): Boolean {
val deepLinkUrl = bundle.getString("deep_link") val deepLinkUrl = bundle.getString("deep_link")
if (!deepLinkUrl.isNullOrBlank()) { if (!deepLinkUrl.isNullOrBlank()) {
val deepLinkBundle = buildBundleFromDeepLinkUrl(deepLinkUrl) val deepLinkBundle = Bundle(bundle).apply {
return executeBundleRoute(deepLinkBundle ?: bundle) buildBundleFromDeepLinkUrl(deepLinkUrl)?.let { putAll(it) }
}
return executeBundleRoute(deepLinkBundle)
} }
return executeBundleRoute(bundle) return executeBundleRoute(bundle)
@@ -339,6 +342,11 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
val communityPostId = bundle.getString(Constants.EXTRA_COMMUNITY_POST_ID)?.toLongOrNull() val communityPostId = bundle.getString(Constants.EXTRA_COMMUNITY_POST_ID)?.toLongOrNull()
?: bundle.getLong(Constants.EXTRA_COMMUNITY_POST_ID).takeIf { it > 0 } ?: bundle.getLong(Constants.EXTRA_COMMUNITY_POST_ID).takeIf { it > 0 }
when { when {
isDmChatDeepLink(bundle) && roomId != null && roomId > 0 -> {
startActivity(DmChatRoomActivity.newIntentByRoomId(applicationContext, roomId))
return true
}
roomId != null && roomId > 0 -> { roomId != null && roomId > 0 -> {
viewModel.clickTab(MainViewModel.CurrentTab.LIVE) viewModel.clickTab(MainViewModel.CurrentTab.LIVE)
@@ -349,9 +357,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
} }
channelId != null && channelId > 0 -> { channelId != null && channelId > 0 -> {
val nextIntent = Intent(applicationContext, UserProfileActivity::class.java) startActivity(CreatorChannelActivity.newIntent(applicationContext, channelId))
nextIntent.putExtra(Constants.EXTRA_USER_ID, channelId)
startActivity(nextIntent)
return true return true
} }
@@ -495,6 +501,12 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
putIfAbsent("deep_link_sub5", pathId) putIfAbsent("deep_link_sub5", pathId)
} }
"chat" -> {
putIfAbsent("room_id", pathId)
putIfAbsent("deep_link_value", "chat")
putIfAbsent("deep_link_sub5", pathId)
}
"content" -> { "content" -> {
putIfAbsent("content_id", pathId) putIfAbsent("content_id", pathId)
putIfAbsent("deep_link_value", "content") putIfAbsent("deep_link_value", "content")
@@ -577,9 +589,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
} }
startActivity( startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply { CreatorChannelActivity.newIntent(applicationContext, deepLinkValueId)
putExtra(Constants.EXTRA_USER_ID, deepLinkValueId)
}
) )
true 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() { private fun clearDeferredDeepLink() {
SharedPreferenceManager.marketingUtmSource = "" SharedPreferenceManager.marketingUtmSource = ""
SharedPreferenceManager.marketingUtmMedium = "" SharedPreferenceManager.marketingUtmMedium = ""
@@ -25,7 +25,6 @@ import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.databinding.FragmentMyBinding 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.dpToPx
import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.main.MainActivity 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.notice.NoticeDetailActivity
import kr.co.vividnext.sodalive.settings.notification.MemberRole import kr.co.vividnext.sodalive.settings.notification.MemberRole
import kr.co.vividnext.sodalive.splash.SplashActivity 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.MainV2Activity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@@ -253,15 +253,10 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
binding.tvMyChannel.visibility = View.VISIBLE binding.tvMyChannel.visibility = View.VISIBLE
binding.tvMyChannel.setOnClickListener { binding.tvMyChannel.setOnClickListener {
startActivity( startActivity(
Intent( CreatorChannelActivity.newIntent(
requireContext(), requireContext(),
UserProfileActivity::class.java
).apply {
putExtra(
Constants.EXTRA_USER_ID,
SharedPreferenceManager.userId SharedPreferenceManager.userId
) )
}
) )
} }
} else { } else {
@@ -25,8 +25,8 @@ import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivitySearchBinding 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.extensions.dpToPx
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -298,9 +298,7 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(ActivitySearchBinding
startActivity( startActivity(
when (item.type) { when (item.type) {
SearchResponseType.CREATOR -> { SearchResponseType.CREATOR -> {
Intent(applicationContext, UserProfileActivity::class.java).apply { CreatorChannelActivity.newIntent(applicationContext, item.id)
putExtra(Constants.EXTRA_USER_ID, item.id)
}
} }
SearchResponseType.CONTENT -> { SearchResponseType.CONTENT -> {
@@ -1,7 +1,6 @@
package kr.co.vividnext.sodalive.settings.notification package kr.co.vividnext.sodalive.settings.notification
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Rect import android.graphics.Rect
import android.os.Bundle import android.os.Bundle
import android.view.View 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.BuildConfig
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityNotificationReceiveSettingsBinding import kr.co.vividnext.sodalive.databinding.ActivityNotificationReceiveSettingsBinding
import kr.co.vividnext.sodalive.explorer.profile.CreatorFollowNotifyFragment 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.dpToPx
import kr.co.vividnext.sodalive.following.FollowingCreatorAdapter import kr.co.vividnext.sodalive.following.FollowingCreatorAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -90,9 +88,7 @@ class NotificationReceiveSettingsActivity : BaseActivity<ActivityNotificationRec
adapter = FollowingCreatorAdapter( adapter = FollowingCreatorAdapter(
onClickItem = { creatorId -> onClickItem = { creatorId ->
startActivity( startActivity(
Intent(applicationContext, UserProfileActivity::class.java).apply { CreatorChannelActivity.newIntent(applicationContext, creatorId)
putExtra(Constants.EXTRA_USER_ID, creatorId)
}
) )
}, },
onClickFollow = { creatorId, isFollow -> onClickFollow = { creatorId, isFollow ->
@@ -0,0 +1,26 @@
package kr.co.vividnext.sodalive.v2.common
import androidx.annotation.StringRes
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.R
enum class CreatorActivityType(
val code: String,
@StringRes val labelResId: Int
) {
@SerializedName("LIVE")
Live("LIVE", R.string.home_recommendation_activity_live),
@SerializedName("LIVE_REPLAY")
LiveReplay("LIVE_REPLAY", R.string.home_recommendation_activity_live),
@SerializedName("AUDIO")
Audio("AUDIO", R.string.home_recommendation_activity_audio),
@SerializedName("COMMUNITY")
Community("COMMUNITY", R.string.home_recommendation_activity_community);
companion object {
fun from(code: String): CreatorActivityType? = entries.firstOrNull { it.code.equals(code, ignoreCase = true) }
}
}
@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.v2.common.data
enum class ContentSort {
LATEST,
POPULAR,
OWNED,
PRICE_HIGH,
PRICE_LOW
}
@@ -0,0 +1,162 @@
package kr.co.vividnext.sodalive.v2.creator.channel
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelHomeBinding
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHeaderUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeUiState
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelHomeSectionAdapter
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBinding>(
FragmentCreatorChannelHomeBinding::inflate
) {
private val viewModel: CreatorChannelHomeViewModel by viewModel()
private val sectionAdapter = CreatorChannelHomeSectionAdapter(
onLiveClick = ::onCurrentLiveClicked,
onScheduleClick = ::onScheduleClicked,
onAudioContentClick = ::onAudioContentClicked,
onSeriesClick = ::onSeriesClicked,
onDonationClick = ::onDonationClicked
)
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
private val host: Host
get() = requireActivity() as Host
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.rvHomeSections.layoutManager = LinearLayoutManager(requireContext())
binding.rvHomeSections.adapter = sectionAdapter
observeViewModel()
host.onCreatorChannelHomeActionDelegateReady(
object : HomeActionDelegate {
override fun follow(follow: Boolean, notify: Boolean) {
viewModel.follow(follow = follow, notify = notify)
}
override fun createChatRoom(characterId: Long) {
viewModel.createChatRoom(characterId)
}
override fun blockUser() {
viewModel.blockUser()
}
override fun reportUser(reason: String) {
viewModel.reportUser(reason)
}
override fun reportProfile() {
viewModel.reportProfile()
}
override fun postChannelDonation(can: Int, isSecret: Boolean, message: String) {
viewModel.postChannelDonation(can = can, isSecret = isSecret, message = message)
}
override fun refreshHome() {
if (creatorId > 0L) {
viewModel.loadHome(creatorId)
}
}
}
)
if (creatorId > 0L) {
viewModel.loadHome(creatorId)
}
}
override fun onDestroyView() {
binding.rvHomeSections.adapter = null
host.onCreatorChannelHomeActionDelegateReady(null)
super.onDestroyView()
}
private fun observeViewModel() {
viewModel.homeStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) {
is CreatorChannelHomeUiState.Content -> {
host.onCreatorChannelHeaderChanged(state.header)
sectionAdapter.submitItems(state.sections)
host.onCreatorChannelHomeContentChanged()
}
is CreatorChannelHomeUiState.Error -> Unit
CreatorChannelHomeUiState.Empty -> Unit
CreatorChannelHomeUiState.Loading -> Unit
}
}
viewModel.chatRoomIdLiveData.observe(viewLifecycleOwner) { event ->
event.consume()?.let(host::onCreatorChannelChatRoomCreated)
}
viewModel.toastLiveData.observe(viewLifecycleOwner) { event ->
event.consume()?.let {
val message = it.message ?: it.resId?.let(::getString)
message?.let { text -> Toast.makeText(requireContext(), text, Toast.LENGTH_LONG).show() }
}
}
viewModel.isFollowInProgressLiveData.observe(viewLifecycleOwner) {
host.onCreatorChannelFollowProgressChanged(it)
}
}
private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse) {
host.onCreatorChannelScheduleClicked(schedule)
}
private fun onAudioContentClicked(audioContent: CreatorChannelAudioContentResponse) {
host.onCreatorChannelAudioContentClicked(audioContent)
}
private fun onSeriesClicked(series: CreatorChannelSeriesResponse) {
host.onCreatorChannelSeriesClicked(series)
}
private fun onDonationClicked() {
host.onCreatorChannelDonationClicked()
}
private fun onCurrentLiveClicked(live: CreatorChannelLiveResponse) {
host.onCreatorChannelCurrentLiveClicked(live)
}
interface Host {
fun onCreatorChannelHeaderChanged(header: CreatorChannelHeaderUiModel)
fun onCreatorChannelFollowProgressChanged(inProgress: Boolean)
fun onCreatorChannelChatRoomCreated(chatRoomId: Long)
fun onCreatorChannelScheduleClicked(schedule: CreatorChannelScheduleResponse)
fun onCreatorChannelAudioContentClicked(audioContent: CreatorChannelAudioContentResponse)
fun onCreatorChannelSeriesClicked(series: CreatorChannelSeriesResponse)
fun onCreatorChannelHomeActionDelegateReady(delegate: HomeActionDelegate?)
fun onCreatorChannelHomeContentChanged()
fun onCreatorChannelDonationClicked()
fun onCreatorChannelCurrentLiveClicked(live: CreatorChannelLiveResponse)
}
interface HomeActionDelegate {
fun follow(follow: Boolean, notify: Boolean)
fun createChatRoom(characterId: Long)
fun blockUser()
fun reportUser(reason: String)
fun reportProfile()
fun postChannelDonation(can: Int, isSecret: Boolean, message: String)
fun refreshHome()
}
companion object {
private const val ARG_CREATOR_ID: String = "arg_creator_id"
fun newInstance(creatorId: Long): CreatorChannelHomeFragment {
return CreatorChannelHomeFragment().apply {
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
}
}
}
}
@@ -0,0 +1,265 @@
package kr.co.vividnext.sodalive.v2.creator.channel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeUiState
import kr.co.vividnext.sodalive.v2.creator.channel.model.toUiContent
class CreatorChannelHomeViewModel(
private val repository: CreatorChannelRepository
) : BaseViewModel() {
private val _homeStateLiveData = MutableLiveData<CreatorChannelHomeUiState>()
val homeStateLiveData: LiveData<CreatorChannelHomeUiState>
get() = _homeStateLiveData
private val _toastLiveData = MutableLiveData<CreatorChannelEvent<ToastMessage>>()
val toastLiveData: LiveData<CreatorChannelEvent<ToastMessage>>
get() = _toastLiveData
private val _chatRoomIdLiveData = MutableLiveData<CreatorChannelEvent<Long>>()
val chatRoomIdLiveData: LiveData<CreatorChannelEvent<Long>>
get() = _chatRoomIdLiveData
private val _isFollowInProgressLiveData = MutableLiveData(false)
val isFollowInProgressLiveData: LiveData<Boolean>
get() = _isFollowInProgressLiveData
private var isFollowInProgress = false
private var isCreateChatRoomInProgress = false
private var isPostChannelDonationInProgress = false
fun loadHome(creatorId: Long) {
if (creatorId <= 0) return
_homeStateLiveData.value = CreatorChannelHomeUiState.Loading
compositeDisposable.add(
repository.getHome(creatorId = creatorId, token = authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
val data = it.data
if (it.success && data != null) {
_homeStateLiveData.value = data.toUiContent(currentMemberId = SharedPreferenceManager.userId)
} else {
showUnknownError(it.message)
}
},
{
it.message?.let { message -> Logger.e(message) }
showUnknownError(it.message)
}
)
)
}
fun follow(follow: Boolean, notify: Boolean) {
val content = _homeStateLiveData.value as? CreatorChannelHomeUiState.Content ?: return
if (isFollowInProgress) return
isFollowInProgress = true
_isFollowInProgressLiveData.value = true
compositeDisposable.add(
repository.followCreator(
creatorId = content.header.creatorId,
follow = follow,
notify = notify,
token = authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
isFollowInProgress = false
_isFollowInProgressLiveData.value = false
if (it.success) {
_homeStateLiveData.value = content.copy(
header = content.header.copy(isFollow = follow, isNotify = notify)
)
if (!follow) {
_toastLiveData.value = CreatorChannelEvent(
ToastMessage(resId = R.string.creator_channel_unfollow_success)
)
}
} else {
showUnknownErrorToast()
}
},
{
isFollowInProgress = false
_isFollowInProgressLiveData.value = false
it.message?.let { message -> Logger.e(message) }
showUnknownErrorToast()
}
)
)
}
fun createChatRoom(characterId: Long) {
if (characterId <= 0 || isCreateChatRoomInProgress) return
isCreateChatRoomInProgress = true
compositeDisposable.add(
repository.createChatRoom(characterId = characterId, token = authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
isCreateChatRoomInProgress = false
val data = it.data
if (it.success && data != null) {
_chatRoomIdLiveData.value = CreatorChannelEvent(data.chatRoomId)
} else {
showUnknownErrorToast()
}
},
{
isCreateChatRoomInProgress = false
it.message?.let { message -> Logger.e(message) }
showUnknownErrorToast()
}
)
)
}
fun postChannelDonation(can: Int, isSecret: Boolean, message: String) {
val content = _homeStateLiveData.value as? CreatorChannelHomeUiState.Content ?: return
if (isPostChannelDonationInProgress) return
isPostChannelDonationInProgress = true
compositeDisposable.add(
repository.postChannelDonation(
creatorId = content.header.creatorId,
can = can,
isSecret = isSecret,
message = message,
token = authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
isPostChannelDonationInProgress = false
if (it.success) {
SharedPreferenceManager.can = (SharedPreferenceManager.can - can).coerceAtLeast(0)
loadHome(content.header.creatorId)
} else {
showUnknownErrorToast()
}
},
{
isPostChannelDonationInProgress = false
it.message?.let { message -> Logger.e(message) }
showUnknownErrorToast()
}
)
)
}
fun blockUser() {
val content = _homeStateLiveData.value as? CreatorChannelHomeUiState.Content ?: return
compositeDisposable.add(
repository.blockUser(content.header.creatorId, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success) {
_toastLiveData.value = CreatorChannelEvent(
ToastMessage(resId = R.string.creator_channel_block_success)
)
} else {
showUnknownErrorToast()
}
},
{
it.message?.let { message -> Logger.e(message) }
showUnknownErrorToast()
}
)
)
}
fun reportUser(reason: String) {
val content = _homeStateLiveData.value as? CreatorChannelHomeUiState.Content ?: return
compositeDisposable.add(
repository.reportUser(content.header.creatorId, reason, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success) {
showReportSubmittedToast()
} else {
showUnknownErrorToast()
}
},
{
it.message?.let { message -> Logger.e(message) }
showUnknownErrorToast()
}
)
)
}
fun reportProfile() {
val content = _homeStateLiveData.value as? CreatorChannelHomeUiState.Content ?: return
val reason = SodaLiveApplicationHolder.get().getString(R.string.dialog_member_profile_report_profile)
compositeDisposable.add(
repository.reportProfile(content.header.creatorId, reason, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success) {
showReportSubmittedToast()
} else {
showUnknownErrorToast()
}
},
{
it.message?.let { message -> Logger.e(message) }
showUnknownErrorToast()
}
)
)
}
private fun showUnknownError(message: String?) {
_homeStateLiveData.value = CreatorChannelHomeUiState.Error(message = message)
showUnknownErrorToast()
}
private fun showUnknownErrorToast() {
_toastLiveData.value = CreatorChannelEvent(ToastMessage(resId = R.string.common_error_unknown))
}
private fun showReportSubmittedToast() {
_toastLiveData.value = CreatorChannelEvent(ToastMessage(resId = R.string.character_comment_report_submitted))
}
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
}
class CreatorChannelEvent<out T>(private val value: T) {
private var consumed: Boolean = false
fun consume(): T? {
if (consumed) return null
consumed = true
return value
}
}
@@ -0,0 +1,249 @@
package kr.co.vividnext.sodalive.v2.creator.channel
import android.content.Intent
import android.view.LayoutInflater
import android.widget.Toast
import androidx.fragment.app.FragmentManager
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.live.LiveViewModel
import kr.co.vividnext.sodalive.live.reservation.complete.LiveReservationCompleteActivity
import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse
import kr.co.vividnext.sodalive.live.room.detail.LiveRoomDetailFragment
import kr.co.vividnext.sodalive.live.room.dialog.LiveCancelDialog
import kr.co.vividnext.sodalive.live.room.dialog.LivePaymentDialog
import kr.co.vividnext.sodalive.live.room.dialog.LiveRoomPasswordDialog
import kr.co.vividnext.sodalive.live.room.update.LiveRoomEditActivity
import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class CreatorChannelLiveCoordinator(
private val activity: CreatorChannelActivity,
private val layoutInflater: LayoutInflater,
private val fragmentManager: FragmentManager,
private val liveViewModel: LiveViewModel,
private val screenWidthProvider: () -> Int,
private val refreshHome: () -> Unit
) {
fun showLiveRoomDetail(roomId: Long) {
val detailFragment = LiveRoomDetailFragment(
roomId,
onClickParticipant = {},
onClickReservation = { reservationRoom(roomId) },
onClickModify = { roomDetailResponse -> modifyLive(roomDetailResponse) },
onClickStart = { startLive(roomId) },
onClickCancel = { cancelLive(roomId) }
)
if (detailFragment.isAdded) return
detailFragment.show(fragmentManager, detailFragment.tag)
}
fun enterLiveRoom(roomId: Long) {
activity.startService(
Intent(activity, AudioContentPlayService::class.java).apply {
action = AudioContentPlayService.MusicAction.STOP.name
}
)
activity.startService(
Intent(activity, AudioContentPlayerService::class.java).apply {
action = "STOP_SERVICE"
}
)
val onEnterRoomSuccess = {
activity.runOnUiThread { openLiveRoom(roomId) }
}
liveViewModel.getRoomDetail(roomId) {
if (!it.channelName.isNullOrBlank()) {
if (it.manager.id == SharedPreferenceManager.userId) {
liveViewModel.enterRoom(roomId, onEnterRoomSuccess)
} else if (it.price == 0 || it.isPaid) {
if (it.isPrivateRoom) {
LiveRoomPasswordDialog(
activity = activity,
layoutInflater = layoutInflater,
can = 0,
confirmButtonClick = { password ->
liveViewModel.enterRoom(
roomId = roomId,
onSuccess = onEnterRoomSuccess,
password = password
)
}
).show(screenWidthProvider())
} else {
liveViewModel.enterRoom(roomId, onEnterRoomSuccess)
}
} else {
showPaidLiveEntryDialog(
roomId = roomId,
beginDateTimeUtc = it.beginDateTimeUtc,
price = it.price,
isPrivateRoom = it.isPrivateRoom,
onEnterRoomSuccess = onEnterRoomSuccess
)
}
} else {
showLiveRoomDetail(roomId)
}
}
}
private fun reservationRoom(roomId: Long) {
liveViewModel.getRoomDetail(roomId) {
if (it.manager.id == SharedPreferenceManager.userId) {
Toast.makeText(
activity,
activity.getString(R.string.screen_live_reservation_self_block),
Toast.LENGTH_LONG
).show()
return@getRoomDetail
}
if (it.isPrivateRoom) {
LiveRoomPasswordDialog(
activity = activity,
layoutInflater = layoutInflater,
can = if (it.isPaid) 0 else it.price,
confirmButtonClick = { password -> processLiveReservation(roomId, password) }
).show(screenWidthProvider())
} else if (it.price == 0 || it.isPaid) {
processLiveReservation(roomId)
} else {
LivePaymentDialog(
activity = activity,
layoutInflater = layoutInflater,
title = activity.getString(
R.string.screen_live_reservation_pay_title,
it.price.moneyFormat()
),
desc = activity.getString(R.string.screen_live_reservation_pay_desc, it.title),
confirmButtonTitle = activity.getString(R.string.screen_live_reservation_confirm),
confirmButtonClick = { processLiveReservation(roomId) },
cancelButtonTitle = activity.getString(R.string.cancel),
cancelButtonClick = {}
).show(screenWidthProvider())
}
}
}
private fun processLiveReservation(roomId: Long, password: String? = null) {
liveViewModel.reservationRoom(roomId, password) {
refreshHome()
activity.startActivity(
Intent(activity, LiveReservationCompleteActivity::class.java).apply {
putExtra(Constants.EXTRA_LIVE_RESERVATION_RESPONSE, it)
}
)
}
}
private fun modifyLive(roomDetail: GetRoomDetailResponse) {
activity.startActivity(
Intent(activity, LiveRoomEditActivity::class.java).apply {
putExtra(Constants.EXTRA_ROOM_DETAIL, roomDetail)
}
)
}
private fun startLive(roomId: Long) {
liveViewModel.startLive(roomId) {
refreshHome()
activity.runOnUiThread { openLiveRoom(roomId) }
}
}
private fun cancelLive(roomId: Long) {
LiveCancelDialog(
activity = activity,
layoutInflater = layoutInflater,
title = activity.getString(R.string.screen_live_cancel_title),
hint = activity.getString(R.string.screen_live_cancel_hint),
confirmButtonTitle = activity.getString(R.string.screen_live_cancel_confirm),
confirmButtonClick = { reason ->
liveViewModel.cancelLive(roomId, reason) {
Toast.makeText(
activity,
activity.getString(R.string.screen_live_cancel_success),
Toast.LENGTH_LONG
).show()
refreshHome()
}
},
cancelButtonTitle = activity.getString(R.string.dialog_close),
cancelButtonClick = {}
).show(screenWidthProvider())
}
private fun showPaidLiveEntryDialog(
roomId: Long,
beginDateTimeUtc: String,
price: Int,
isPrivateRoom: Boolean,
onEnterRoomSuccess: () -> Unit
) {
val locale = Locale(LanguageManager.getEffectiveLanguage(activity))
val wrappedContext = LocaleHelper.wrap(activity)
val beginDate = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.parse(beginDateTimeUtc) ?: return
val now = Date()
val dateFormat = SimpleDateFormat("yyyy-MM-dd, HH:mm", locale)
val diffTime = now.time - beginDate.time
val hours = (diffTime / (1000 * 60 * 60)).toInt()
val mins = (diffTime / (1000 * 60)).toInt() % 60
if (isPrivateRoom) {
LiveRoomPasswordDialog(
activity = activity,
layoutInflater = layoutInflater,
can = price,
confirmButtonClick = { password ->
liveViewModel.enterRoom(
roomId = roomId,
onSuccess = onEnterRoomSuccess,
password = password
)
}
).show(screenWidthProvider())
return
}
LivePaymentDialog(
activity = activity,
layoutInflater = layoutInflater,
title = wrappedContext.getString(R.string.live_paid_title),
startDateTime = if (hours >= 1) dateFormat.format(beginDate) else null,
nowDateTime = if (hours >= 1) dateFormat.format(now) else null,
desc = wrappedContext.getString(R.string.live_paid_desc, price),
desc2 = if (hours >= 1) {
wrappedContext.getString(R.string.live_paid_warning, hours, mins)
} else {
null
},
confirmButtonTitle = wrappedContext.getString(R.string.live_paid_confirm),
confirmButtonClick = { liveViewModel.enterRoom(roomId, onEnterRoomSuccess) },
cancelButtonTitle = wrappedContext.getString(R.string.cancel),
cancelButtonClick = {}
).show(screenWidthProvider())
}
private fun openLiveRoom(roomId: Long) {
activity.startActivity(
Intent(activity, LiveRoomActivity::class.java).apply {
putExtra(Constants.EXTRA_ROOM_ID, roomId)
}
)
}
}
@@ -0,0 +1,54 @@
package kr.co.vividnext.sodalive.v2.creator.channel
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kr.co.vividnext.sodalive.databinding.DialogCreatorChannelMoreBinding
class CreatorChannelMoreBottomSheet : BottomSheetDialogFragment() {
var onClickBlock: (() -> Unit)? = null
var onClickUserReport: (() -> Unit)? = null
var onClickProfileReport: (() -> Unit)? = null
private var binding: DialogCreatorChannelMoreBinding? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val viewBinding = DialogCreatorChannelMoreBinding.inflate(inflater, container, false)
binding = viewBinding
return viewBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val viewBinding = binding ?: return
viewBinding.tvUserBlock.setOnClickListener {
dismiss()
onClickBlock?.invoke()
}
viewBinding.tvUserReport.setOnClickListener {
dismiss()
onClickUserReport?.invoke()
}
viewBinding.tvProfileReport.setOnClickListener {
dismiss()
onClickProfileReport?.invoke()
}
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
companion object {
fun newInstance(): CreatorChannelMoreBottomSheet = CreatorChannelMoreBottomSheet()
}
}
@@ -0,0 +1,35 @@
package kr.co.vividnext.sodalive.v2.creator.channel
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.audio.CreatorChannelAudioFragment
import kr.co.vividnext.sodalive.v2.creator.channel.community.CreatorChannelCommunityFragment
import kr.co.vividnext.sodalive.v2.creator.channel.donation.CreatorChannelDonationFragment
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.CreatorChannelFanTalkFragment
import kr.co.vividnext.sodalive.v2.creator.channel.live.CreatorChannelLiveFragment
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTab
import kr.co.vividnext.sodalive.v2.creator.channel.series.CreatorChannelSeriesFragment
class CreatorChannelPagerAdapter(
activity: FragmentActivity,
private val creatorId: Long,
private val tabs: List<CreatorChannelTab> = CreatorChannelTab.entries
) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = tabs.size
override fun createFragment(position: Int): Fragment {
val tab = tabs[position]
return when (tab) {
CreatorChannelTab.Home -> CreatorChannelHomeFragment.newInstance(creatorId)
CreatorChannelTab.Live -> CreatorChannelLiveFragment.newInstance(creatorId)
CreatorChannelTab.Audio -> CreatorChannelAudioFragment.newInstance(creatorId)
CreatorChannelTab.Series -> CreatorChannelSeriesFragment.newInstance(creatorId)
CreatorChannelTab.Community -> CreatorChannelCommunityFragment.newInstance(creatorId)
CreatorChannelTab.FanTalk -> CreatorChannelFanTalkFragment.newInstance(creatorId)
CreatorChannelTab.Donation -> CreatorChannelDonationFragment.newInstance(creatorId)
else -> CreatorChannelPlaceholderFragment.newInstance(tab)
}
}
}
@@ -0,0 +1,29 @@
package kr.co.vividnext.sodalive.v2.creator.channel
import android.os.Bundle
import android.view.View
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelPlaceholderBinding
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTab
class CreatorChannelPlaceholderFragment : BaseFragment<FragmentCreatorChannelPlaceholderBinding>(
FragmentCreatorChannelPlaceholderBinding::inflate
) {
private val tabName: String by lazy { arguments?.getString(ARG_TAB_NAME).orEmpty() }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.tvPlaceholder.text = tabName
}
companion object {
private const val ARG_TAB_NAME: String = "arg_tab_name"
fun newInstance(tab: CreatorChannelTab): CreatorChannelPlaceholderFragment {
return CreatorChannelPlaceholderFragment().apply {
arguments = Bundle().apply { putString(ARG_TAB_NAME, tab.name) }
}
}
}
}
@@ -0,0 +1,270 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio
import android.os.Bundle
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import android.view.View
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelAudioBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.CreatorChannelAudioRateUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelAudioContentAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelSortPopup
import kr.co.vividnext.sodalive.v2.creator.channel.model.toLabelResId
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelAudioFragment : BaseFragment<FragmentCreatorChannelAudioBinding>(
FragmentCreatorChannelAudioBinding::inflate
) {
private val viewModel: CreatorChannelAudioViewModel by viewModel()
private val audioContentAdapter = CreatorChannelAudioContentAdapter { item ->
host.onCreatorChannelAudioContentClicked(item.audioContentId)
}
private var sortPopup: CreatorChannelSortPopup? = null
private var currentContentState: CreatorChannelAudioUiState.Content? = null
private var lastContentLayoutKey: CreatorChannelAudioContentLayoutKey? = null
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
private val host: Host
get() = requireActivity() as Host
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindLoading()
setupAudioList()
setupClickListeners()
observeViewModel()
}
override fun onDestroyView() {
sortPopup?.dismiss()
sortPopup = null
currentContentState = null
lastContentLayoutKey = null
binding.rvCreatorChannelAudioContents.adapter = null
super.onDestroyView()
}
private fun setupAudioList() = with(binding.rvCreatorChannelAudioContents) {
layoutManager = LinearLayoutManager(requireContext())
adapter = audioContentAdapter
}
private fun setupClickListeners() = with(binding) {
ivCreatorChannelAudioSort.setImageResource(R.drawable.ic_new_sort)
layoutCreatorChannelAudioSortButton.setOnClickListener {
currentContentState?.let { state -> showSortPopup(state) }
}
viewCreatorChannelAudioThemeTabs.root.setOnTabSelectedListener { index ->
currentContentState?.themes?.getOrNull(index)?.let { theme ->
viewModel.changeTheme(theme.themeId)
}
}
btnCreatorChannelAudioRetry.setOnClickListener {
viewModel.retryAudio()
}
}
private fun observeViewModel() {
viewModel.audioStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) {
CreatorChannelAudioUiState.Loading -> bindLoading()
CreatorChannelAudioUiState.Empty -> bindEmpty()
is CreatorChannelAudioUiState.Error -> bindError(state)
is CreatorChannelAudioUiState.Content -> bindContent(state)
}
}
}
fun onCreatorChannelAudioTabSelected() {
if (creatorId > 0L) {
viewModel.loadAudio(creatorId, isOwner = host.isCreatorChannelOwner())
}
}
fun onCreatorChannelAudioScrolledToBottom() {
viewModel.loadMore()
}
@Suppress("UNUSED_PARAMETER")
fun onCreatorChannelAudioViewportHeightChanged(minHeight: Int) = Unit
fun onCreatorChannelAudioOwnerCtaVisibilityChanged(isVisible: Boolean) = with(binding) {
val bottomPadding = if (isVisible) {
OWNER_CTA_LIST_BOTTOM_PADDING_DP.dpToPx().toInt()
} else {
DEFAULT_LIST_BOTTOM_PADDING_DP.dpToPx().toInt()
}
rvCreatorChannelAudioContents.updatePadding(bottom = bottomPadding)
layoutCreatorChannelAudioEmpty.updatePadding(bottom = bottomPadding)
}
private fun bindLoading() = with(binding) {
currentContentState = null
lastContentLayoutKey = null
viewCreatorChannelAudioThemeTabs.root.isVisible = false
layoutCreatorChannelAudioSortBar.isVisible = false
layoutCreatorChannelAudioRateCard.isVisible = false
rvCreatorChannelAudioContents.isVisible = false
layoutCreatorChannelAudioEmpty.isVisible = false
tvCreatorChannelAudioErrorMessage.isVisible = false
btnCreatorChannelAudioRetry.isVisible = false
}
private fun bindEmpty() = with(binding) {
currentContentState = null
lastContentLayoutKey = null
viewCreatorChannelAudioThemeTabs.root.isVisible = false
layoutCreatorChannelAudioSortBar.isVisible = false
layoutCreatorChannelAudioRateCard.isVisible = false
rvCreatorChannelAudioContents.isVisible = false
layoutCreatorChannelAudioEmpty.isVisible = true
tvCreatorChannelAudioErrorMessage.isVisible = false
btnCreatorChannelAudioRetry.isVisible = false
host.onCreatorChannelAudioContentChanged()
}
private fun bindError(state: CreatorChannelAudioUiState.Error) = with(binding) {
currentContentState = null
lastContentLayoutKey = null
viewCreatorChannelAudioThemeTabs.root.isVisible = false
layoutCreatorChannelAudioSortBar.isVisible = false
layoutCreatorChannelAudioRateCard.isVisible = false
rvCreatorChannelAudioContents.isVisible = false
layoutCreatorChannelAudioEmpty.isVisible = false
tvCreatorChannelAudioErrorMessage.isVisible = true
tvCreatorChannelAudioErrorMessage.text = state.message ?: getString(R.string.creator_channel_audio_error_message)
btnCreatorChannelAudioRetry.isVisible = true
host.onCreatorChannelAudioContentChanged()
}
private fun bindContent(state: CreatorChannelAudioUiState.Content) = with(binding) {
currentContentState = state
viewCreatorChannelAudioThemeTabs.root.isVisible = true
viewCreatorChannelAudioThemeTabs.root.setMenus(
menus = state.themes.map { it.title },
selectedIndex = state.themes.indexOfFirst { it.isSelected }.coerceAtLeast(0)
)
layoutCreatorChannelAudioSortBar.isVisible = true
tvCreatorChannelAudioTotalCount.text = state.audioContentCount.moneyFormat()
tvCreatorChannelAudioSortLabel.setText(state.selectedSort.toLabelResId())
bindRate(state.rate)
rvCreatorChannelAudioContents.isVisible = true
audioContentAdapter.submitItems(state.audioContents)
layoutCreatorChannelAudioEmpty.isVisible = false
tvCreatorChannelAudioErrorMessage.isVisible = false
btnCreatorChannelAudioRetry.isVisible = false
notifyContentChangedIfLayoutChanged(state)
state.paginationErrorMessage?.let {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
viewModel.consumePaginationErrorMessage()
}
}
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelAudioUiState.Content) {
val contentLayoutKey = state.toContentLayoutKey()
if (contentLayoutKey == lastContentLayoutKey) return
lastContentLayoutKey = contentLayoutKey
host.onCreatorChannelAudioContentChanged()
}
private fun showSortPopup(state: CreatorChannelAudioUiState.Content) {
sortPopup?.dismiss()
sortPopup = CreatorChannelSortPopup(
anchor = binding.layoutCreatorChannelAudioSortButton,
selectedSort = state.selectedSort,
onSortSelected = { sort -> viewModel.changeSort(sort) }
).also { it.show() }
}
private fun bindRate(rate: CreatorChannelAudioRateUiModel?) = with(binding) {
layoutCreatorChannelAudioRateCard.isVisible = rate != null
if (rate == null) return@with
val ratePercentText = rate.ratePercent.toInt().toString()
val rateMessage = getString(
R.string.creator_channel_audio_owned_rate_message,
ratePercentText
)
tvCreatorChannelAudioRateMessage.text = rateMessage.highlightRatePercent(ratePercentText)
val purchasedCountText = rate.purchasedCount.moneyFormat()
val rateCount = getString(
R.string.creator_channel_audio_owned_rate_count,
purchasedCountText,
rate.paidCount.moneyFormat()
)
tvCreatorChannelAudioRateCount.text = rateCount.highlightPaidCount(purchasedCountText)
viewCreatorChannelAudioRateFill.pivotX = 0f
viewCreatorChannelAudioRateFill.scaleX = (rate.ratePercent / 100.0).toFloat().coerceIn(0f, 1f)
}
private fun String.highlightRatePercent(ratePercentText: String): SpannableString {
val spannable = SpannableString(this)
val target = "$ratePercentText%"
val start = indexOf(target)
if (start < 0) return spannable
spannable.setSpan(
ForegroundColorSpan(ContextCompat.getColor(requireContext(), R.color.soda_400)),
start,
start + target.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
return spannable
}
private fun String.highlightPaidCount(purchasedCountText: String): SpannableString {
val spannable = SpannableString(this)
val start = purchasedCountText.length
if (start >= length) return spannable
spannable.setSpan(
ForegroundColorSpan(ContextCompat.getColor(requireContext(), R.color.gray_500)),
start,
length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
return spannable
}
interface Host {
fun isCreatorChannelOwner(): Boolean
fun onCreatorChannelAudioContentClicked(audioContentId: Long)
fun onCreatorChannelAudioContentChanged()
}
companion object {
private const val ARG_CREATOR_ID: String = "arg_creator_id"
private const val DEFAULT_LIST_BOTTOM_PADDING_DP = 32
private const val OWNER_CTA_LIST_BOTTOM_PADDING_DP = 132
fun newInstance(creatorId: Long): CreatorChannelAudioFragment {
return CreatorChannelAudioFragment().apply {
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
}
}
}
}
private data class CreatorChannelAudioContentLayoutKey(
val audioContentCount: Int,
val selectedThemeId: Long?,
val audioContentIds: List<Long>
)
private fun CreatorChannelAudioUiState.Content.toContentLayoutKey(): CreatorChannelAudioContentLayoutKey {
return CreatorChannelAudioContentLayoutKey(
audioContentCount = audioContentCount,
selectedThemeId = selectedThemeId,
audioContentIds = audioContents.map { it.audioContentId }
)
}
@@ -0,0 +1,205 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.data.CreatorChannelAudioTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelAudioContentUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.CreatorChannelAudioRateUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.CreatorChannelAudioThemeUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.effectiveSelectedThemeId
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.toAudioContentUiModels
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.toRateUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.audio.model.toThemeUiModels
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
class CreatorChannelAudioViewModel(
private val repository: CreatorChannelRepository
) : BaseViewModel() {
private val _audioStateLiveData = MutableLiveData<CreatorChannelAudioUiState>()
val audioStateLiveData: LiveData<CreatorChannelAudioUiState>
get() = _audioStateLiveData
private var creatorId: Long = 0L
private var isOwner: Boolean = false
private var selectedSort: ContentSort = ContentSort.LATEST
private var selectedThemeId: Long? = null
private var requestGeneration: Int = 0
fun loadAudio(creatorId: Long, isOwner: Boolean) {
if (creatorId <= 0) return
val shouldSkipReload = this.creatorId == creatorId && this.isOwner == isOwner && _audioStateLiveData.value != null
if (shouldSkipReload) return
this.creatorId = creatorId
this.isOwner = isOwner
loadFirstPage(selectedSort, selectedThemeId)
}
fun changeSort(sort: ContentSort) {
if (sort == selectedSort) return
if (creatorId <= 0) return
selectedSort = sort
loadFirstPage(sort, selectedThemeId)
}
fun changeTheme(themeId: Long?) {
if (themeId == selectedThemeId) return
if (creatorId <= 0) return
selectedThemeId = themeId
loadFirstPage(selectedSort, themeId)
}
fun retryAudio() {
if (creatorId <= 0) return
loadFirstPage(selectedSort, selectedThemeId)
}
fun loadMore() {
val content = _audioStateLiveData.value as? CreatorChannelAudioUiState.Content ?: return
if (!content.hasNext || content.isLoadingMore || creatorId <= 0) return
val generation = requestGeneration
_audioStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null)
requestAudio(
page = content.page + 1,
sort = content.selectedSort,
themeId = content.selectedThemeId,
generation = generation
) { response ->
val data = response.data
val current = _audioStateLiveData.value as? CreatorChannelAudioUiState.Content ?: content
if (response.success && data != null) {
_audioStateLiveData.value = current.copy(
audioContents = current.audioContents + data.audioContents.toAudioContentUiModels(),
page = data.page,
size = data.size,
hasNext = data.hasNext,
isLoadingMore = false
)
} else {
_audioStateLiveData.value = current.copy(
isLoadingMore = false,
paginationErrorMessage = response.message
)
}
}
}
fun consumePaginationErrorMessage() {
val content = _audioStateLiveData.value as? CreatorChannelAudioUiState.Content ?: return
if (content.paginationErrorMessage == null) return
_audioStateLiveData.value = content.copy(paginationErrorMessage = null)
}
private fun loadFirstPage(sort: ContentSort, themeId: Long?) {
val generation = ++requestGeneration
_audioStateLiveData.value = CreatorChannelAudioUiState.Loading
requestAudio(page = FIRST_PAGE, sort = sort, themeId = themeId, generation = generation) { response ->
val data = response.data
if (response.success && data != null) {
selectedThemeId = data.effectiveSelectedThemeId()
val audioContents = data.audioContents.toAudioContentUiModels()
_audioStateLiveData.value = if (audioContents.isEmpty() || data.audioContentCount == 0) {
CreatorChannelAudioUiState.Empty
} else {
data.toContentState(audioContents = audioContents)
}
} else {
_audioStateLiveData.value = CreatorChannelAudioUiState.Error(response.message)
}
}
}
private fun requestAudio(
page: Int,
sort: ContentSort,
themeId: Long?,
generation: Int,
onSuccess: (ApiResponse<CreatorChannelAudioTabResponse>) -> Unit
) {
compositeDisposable.add(
repository.getAudio(
creatorId = creatorId,
page = page,
size = DEFAULT_PAGE_SIZE,
sort = sort,
themeId = themeId,
token = authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (generation == requestGeneration) {
onSuccess(it)
}
},
{
if (generation != requestGeneration) return@subscribe
it.message?.let { message -> Logger.e(message) }
val current = _audioStateLiveData.value as? CreatorChannelAudioUiState.Content
_audioStateLiveData.value = if (current != null && page > FIRST_PAGE) {
current.copy(isLoadingMore = false, paginationErrorMessage = it.message)
} else {
CreatorChannelAudioUiState.Error(it.message)
}
}
)
)
}
private fun CreatorChannelAudioTabResponse.toContentState(
audioContents: List<CreatorChannelAudioContentUiModel>,
isLoadingMore: Boolean = false
) = CreatorChannelAudioUiState.Content(
audioContentCount = audioContentCount,
themes = toThemeUiModels(),
selectedSort = sort,
selectedThemeId = effectiveSelectedThemeId(),
rate = toRateUiModel(isOwner),
audioContents = audioContents,
page = page,
size = size,
hasNext = hasNext,
isLoadingMore = isLoadingMore
)
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
companion object {
val DEFAULT_PAGE_SIZE = 20
private const val FIRST_PAGE = 0
}
}
sealed interface CreatorChannelAudioUiState {
data object Loading : CreatorChannelAudioUiState
data object Empty : CreatorChannelAudioUiState
data class Error(val message: String?) : CreatorChannelAudioUiState
data class Content(
val audioContentCount: Int,
val themes: List<CreatorChannelAudioThemeUiModel>,
val selectedSort: ContentSort,
val selectedThemeId: Long?,
val rate: CreatorChannelAudioRateUiModel?,
val audioContents: List<CreatorChannelAudioContentUiModel>,
val page: Int,
val size: Int,
val hasNext: Boolean,
val isLoadingMore: Boolean = false,
val paginationErrorMessage: String? = null
) : CreatorChannelAudioUiState
}
@@ -0,0 +1,27 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.data
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
@Keep
data class CreatorChannelAudioTabResponse(
@SerializedName("audioContentCount") val audioContentCount: Int,
@SerializedName("themes") val themes: List<CreatorChannelAudioThemeResponse>,
@SerializedName("themeId") val themeId: Long?,
@SerializedName("purchasedAudioContentRate") val purchasedAudioContentRate: Double,
@SerializedName("purchasedAudioContentCount") val purchasedAudioContentCount: Int,
@SerializedName("paidAudioContentCount") val paidAudioContentCount: Int,
@SerializedName("audioContents") val audioContents: List<CreatorChannelAudioContentResponse>,
@SerializedName("sort") val sort: ContentSort,
@SerializedName("page") val page: Int,
@SerializedName("size") val size: Int,
@SerializedName("hasNext") val hasNext: Boolean
)
@Keep
data class CreatorChannelAudioThemeResponse(
@SerializedName("themeId") val themeId: Long,
@SerializedName("themeName") val themeName: String
)
@@ -0,0 +1,77 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.model
import kr.co.vividnext.sodalive.v2.creator.channel.audio.data.CreatorChannelAudioTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelAudioContentStatus
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelAudioContentUiModel
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
private const val ALL_THEME_TITLE = "전체"
fun CreatorChannelAudioTabResponse.toThemeUiModels(): List<CreatorChannelAudioThemeUiModel> =
listOf(
CreatorChannelAudioThemeUiModel(
themeId = null,
title = ALL_THEME_TITLE,
isSelected = effectiveSelectedThemeId() == null
)
) +
themes.map { theme ->
CreatorChannelAudioThemeUiModel(
themeId = theme.themeId,
title = theme.themeName,
isSelected = theme.themeId == effectiveSelectedThemeId()
)
}
fun CreatorChannelAudioTabResponse.effectiveSelectedThemeId(): Long? =
themeId?.takeIf { selectedThemeId -> themes.any { it.themeId == selectedThemeId } }
fun CreatorChannelAudioTabResponse.toRateUiModel(isOwner: Boolean): CreatorChannelAudioRateUiModel? =
if (!isOwner && effectiveSelectedThemeId() == null) {
CreatorChannelAudioRateUiModel(
ratePercent = purchasedAudioContentRate,
purchasedCount = purchasedAudioContentCount,
paidCount = paidAudioContentCount
)
} else {
null
}
fun List<CreatorChannelAudioContentResponse>.toAudioContentUiModels(): List<CreatorChannelAudioContentUiModel> =
mapNotNull { it.toAudioContentUiModel() }
private fun CreatorChannelAudioContentResponse.toAudioContentUiModel(): CreatorChannelAudioContentUiModel? {
val duration = duration ?: return null
return CreatorChannelAudioContentUiModel(
audioContentId = audioContentId,
title = title,
secondaryText = secondaryText(duration),
imageUrl = imageUrl,
price = price,
showAdultBadge = isAdult,
tags = toAudioContentTags(),
status = toAudioContentStatus()
)
}
private fun CreatorChannelAudioContentResponse.secondaryText(duration: String): String =
if (seriesName.isNullOrBlank()) {
duration
} else {
"$duration • $seriesName"
}
private fun CreatorChannelAudioContentResponse.toAudioContentTags(): Set<AudioContentTag> = buildSet {
if (isOriginalSeries == true) add(AudioContentTag.Original)
if (isFirstContent) add(AudioContentTag.First)
if (isPointAvailable) add(AudioContentTag.Point)
if (price == 0) add(AudioContentTag.Free)
}
private fun CreatorChannelAudioContentResponse.toAudioContentStatus(): CreatorChannelAudioContentStatus = when {
isOwned -> CreatorChannelAudioContentStatus.Owned
isRented -> CreatorChannelAudioContentStatus.Rented
price == 0 -> CreatorChannelAudioContentStatus.Play
else -> CreatorChannelAudioContentStatus.Price(price)
}
@@ -0,0 +1,13 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.model
data class CreatorChannelAudioThemeUiModel(
val themeId: Long?,
val title: String,
val isSelected: Boolean
)
data class CreatorChannelAudioRateUiModel(
val ratePercent: Double,
val purchasedCount: Int,
val paidCount: Int
)
@@ -0,0 +1,257 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.core.view.doOnLayout
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelCommunityBinding
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.CreatorCommunityContentItem
import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.player.CreatorCommunityMediaPlayerManager
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityViewMode
import kr.co.vividnext.sodalive.v2.creator.channel.community.ui.CreatorChannelCommunityGridAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.community.ui.calculateCreatorChannelCommunityGridItemSize
import kr.co.vividnext.sodalive.v2.creator.channel.community.ui.CreatorChannelCommunityListAdapter
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelCommunityFragment : BaseFragment<FragmentCreatorChannelCommunityBinding>(
FragmentCreatorChannelCommunityBinding::inflate
) {
private val viewModel: CreatorChannelCommunityViewModel by viewModel()
private val listAdapter = CreatorChannelCommunityListAdapter(
onPlayClick = { item -> toggleCommunityAudio(item) },
onOwnerMoreClick = { item -> host.onCreatorChannelCommunityOwnerMoreClicked(item) },
isPlayingContent = { postId -> mediaPlayerManager?.isPlayingContent(postId) == true }
)
private val gridAdapter = CreatorChannelCommunityGridAdapter()
private var mediaPlayerManager: CreatorCommunityMediaPlayerManager? = null
private var currentContentState: CreatorChannelCommunityUiState.Content? = null
private var lastContentLayoutKey: CreatorChannelCommunityContentLayoutKey? = null
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
private val host: Host
get() = requireActivity() as Host
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mediaPlayerManager = CreatorCommunityMediaPlayerManager(requireContext()) { listAdapter.notifyDataSetChanged() }
bindLoading()
setupCommunityList()
setupClickListeners()
observeViewModel()
}
override fun onDestroyView() {
mediaPlayerManager?.stopContent()
mediaPlayerManager = null
currentContentState = null
lastContentLayoutKey = null
binding.rvCreatorChannelCommunity.adapter = null
super.onDestroyView()
}
override fun onPause() {
mediaPlayerManager?.pauseContent()
super.onPause()
}
private fun setupCommunityList() = with(binding.rvCreatorChannelCommunity) {
layoutManager = LinearLayoutManager(requireContext())
adapter = listAdapter
}
private fun setupClickListeners() = with(binding) {
layoutCreatorChannelCommunityViewModeButton.setOnClickListener {
viewModel.toggleViewMode()
}
btnCreatorChannelCommunityRetry.setOnClickListener {
viewModel.retryCommunity()
}
}
private fun observeViewModel() {
viewModel.communityStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) {
CreatorChannelCommunityUiState.Loading -> bindLoading()
CreatorChannelCommunityUiState.Empty -> bindEmpty()
is CreatorChannelCommunityUiState.Error -> bindError(state)
is CreatorChannelCommunityUiState.Content -> bindContent(state)
}
}
}
fun onCreatorChannelCommunityTabSelected() {
if (creatorId > 0L) {
viewModel.loadCommunity(creatorId, isOwner = host.isCreatorChannelOwner())
}
}
fun onCreatorChannelCommunityScrolledToBottom() {
viewModel.loadMore()
}
fun onCreatorChannelCommunityRefreshRequested() {
viewModel.refreshCommunity()
}
fun onCreatorChannelCommunityOwnerCtaVisibilityChanged(isVisible: Boolean) {
applyOwnerCtaPadding(isVisible)
}
private fun bindLoading() = with(binding) {
currentContentState = null
lastContentLayoutKey = null
layoutCreatorChannelCommunitySortBar.isVisible = false
rvCreatorChannelCommunity.isVisible = false
layoutCreatorChannelCommunityEmpty.isVisible = false
tvCreatorChannelCommunityErrorMessage.isVisible = false
btnCreatorChannelCommunityRetry.isVisible = false
}
private fun bindEmpty() = with(binding) {
currentContentState = null
lastContentLayoutKey = null
layoutCreatorChannelCommunitySortBar.isVisible = false
rvCreatorChannelCommunity.isVisible = false
layoutCreatorChannelCommunityEmpty.isVisible = true
tvCreatorChannelCommunityErrorMessage.isVisible = false
btnCreatorChannelCommunityRetry.isVisible = false
host.onCreatorChannelCommunityContentChanged()
}
private fun bindError(state: CreatorChannelCommunityUiState.Error) = with(binding) {
currentContentState = null
lastContentLayoutKey = null
layoutCreatorChannelCommunitySortBar.isVisible = false
rvCreatorChannelCommunity.isVisible = false
layoutCreatorChannelCommunityEmpty.isVisible = false
tvCreatorChannelCommunityErrorMessage.isVisible = true
tvCreatorChannelCommunityErrorMessage.text = state.message ?: getString(R.string.creator_channel_community_error_message)
btnCreatorChannelCommunityRetry.isVisible = true
host.onCreatorChannelCommunityContentChanged()
}
private fun bindContent(state: CreatorChannelCommunityUiState.Content) = with(binding) {
currentContentState = state
layoutCreatorChannelCommunitySortBar.isVisible = true
tvCreatorChannelCommunityTotalCount.text = state.communityPostCount.moneyFormat()
tvCreatorChannelCommunityViewModeLabel.setText(state.viewMode.labelResId)
ivCreatorChannelCommunityViewMode.setImageResource(state.viewMode.iconResId)
rvCreatorChannelCommunity.isVisible = true
bindCommunityAdapter(state)
layoutCreatorChannelCommunityEmpty.isVisible = false
tvCreatorChannelCommunityErrorMessage.isVisible = false
btnCreatorChannelCommunityRetry.isVisible = false
notifyContentChangedIfLayoutChanged(state)
state.paginationErrorMessage?.let {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
viewModel.consumePaginationErrorMessage()
}
}
private fun bindCommunityAdapter(state: CreatorChannelCommunityUiState.Content) = with(binding.rvCreatorChannelCommunity) {
when (state.viewMode) {
CreatorChannelCommunityViewMode.List -> {
if (adapter !== listAdapter) {
layoutManager = LinearLayoutManager(requireContext())
adapter = listAdapter
}
applyCommunityListPadding()
listAdapter.submitItems(state.communityPosts)
}
CreatorChannelCommunityViewMode.Grid -> {
if (adapter !== gridAdapter) {
layoutManager = GridLayoutManager(requireContext(), 3)
adapter = gridAdapter
}
applyCommunityGridPadding()
updateGridItemSize()
doOnLayout { updateGridItemSize() }
gridAdapter.submitItems(state.communityPosts)
}
}
}
private fun applyCommunityListPadding() = with(binding.rvCreatorChannelCommunity) {
updatePadding(
left = DEFAULT_LIST_HORIZONTAL_PADDING_DP.dpToPx().toInt(),
right = DEFAULT_LIST_HORIZONTAL_PADDING_DP.dpToPx().toInt()
)
}
private fun applyCommunityGridPadding() = with(binding.rvCreatorChannelCommunity) {
updatePadding(left = 0, right = 0)
}
private fun calculateGridItemSize(): Int = with(binding.rvCreatorChannelCommunity) {
return calculateCreatorChannelCommunityGridItemSize(width - paddingStart - paddingEnd)
}
private fun updateGridItemSize() {
gridAdapter.setItemSizePx(calculateGridItemSize())
}
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelCommunityUiState.Content) {
val contentLayoutKey = state.toContentLayoutKey()
if (contentLayoutKey == lastContentLayoutKey) return
lastContentLayoutKey = contentLayoutKey
host.onCreatorChannelCommunityContentChanged()
}
private fun applyOwnerCtaPadding(isVisible: Boolean) = with(binding) {
val bottomPadding = if (isVisible) {
OWNER_CTA_LIST_BOTTOM_PADDING_DP.dpToPx().toInt()
} else {
DEFAULT_LIST_BOTTOM_PADDING_DP.dpToPx().toInt()
}
rvCreatorChannelCommunity.updatePadding(bottom = bottomPadding)
layoutCreatorChannelCommunityEmpty.updatePadding(bottom = bottomPadding)
}
private fun toggleCommunityAudio(item: CreatorChannelCommunityPostUiModel) {
val audioUrl = item.audioUrl ?: return
mediaPlayerManager?.toggleContent(CreatorCommunityContentItem(item.postId, audioUrl))
}
interface Host {
fun isCreatorChannelOwner(): Boolean
fun onCreatorChannelCommunityContentChanged()
fun onCreatorChannelCommunityOwnerMoreClicked(item: CreatorChannelCommunityPostUiModel)
}
companion object {
private const val ARG_CREATOR_ID: String = "arg_creator_id"
private const val DEFAULT_LIST_HORIZONTAL_PADDING_DP = 14
private const val DEFAULT_LIST_BOTTOM_PADDING_DP = 32
private const val OWNER_CTA_LIST_BOTTOM_PADDING_DP = 132
fun newInstance(creatorId: Long): CreatorChannelCommunityFragment {
return CreatorChannelCommunityFragment().apply {
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
}
}
}
}
private data class CreatorChannelCommunityContentLayoutKey(
val communityPostCount: Int,
val viewMode: CreatorChannelCommunityViewMode,
val communityPostIds: List<Long>
)
private fun CreatorChannelCommunityUiState.Content.toContentLayoutKey(): CreatorChannelCommunityContentLayoutKey {
return CreatorChannelCommunityContentLayoutKey(
communityPostCount = communityPostCount,
viewMode = viewMode,
communityPostIds = communityPosts.map { it.postId }
)
}
@@ -0,0 +1,4 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community
typealias CreatorChannelCommunityViewMode =
kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityViewMode
@@ -0,0 +1,186 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.toCommunityPostUiModels
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
class CreatorChannelCommunityViewModel(
private val repository: CreatorChannelRepository,
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) : BaseViewModel() {
private val _communityStateLiveData = MutableLiveData<CreatorChannelCommunityUiState>()
val communityStateLiveData: LiveData<CreatorChannelCommunityUiState>
get() = _communityStateLiveData
private var creatorId: Long = 0L
private var isOwner: Boolean = false
private var viewMode: CreatorChannelCommunityViewMode = CreatorChannelCommunityViewMode.List
private var requestGeneration: Int = 0
fun loadCommunity(creatorId: Long, isOwner: Boolean) {
if (creatorId <= 0) return
val shouldSkipReload = this.creatorId == creatorId && this.isOwner == isOwner && _communityStateLiveData.value != null
if (shouldSkipReload) return
this.creatorId = creatorId
this.isOwner = isOwner
loadFirstPage()
}
fun toggleViewMode() {
val content = _communityStateLiveData.value as? CreatorChannelCommunityUiState.Content ?: return
viewMode = when (content.viewMode) {
CreatorChannelCommunityViewMode.List -> CreatorChannelCommunityViewMode.Grid
CreatorChannelCommunityViewMode.Grid -> CreatorChannelCommunityViewMode.List
}
_communityStateLiveData.value = content.copy(viewMode = viewMode)
}
fun retryCommunity() {
if (creatorId <= 0) return
loadFirstPage()
}
fun refreshCommunity() {
if (creatorId <= 0) return
loadFirstPage()
}
fun loadMore() {
val content = _communityStateLiveData.value as? CreatorChannelCommunityUiState.Content ?: return
if (!content.hasNext || content.isLoadingMore || creatorId <= 0) return
val generation = requestGeneration
_communityStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null)
requestCommunity(page = content.page + 1, generation = generation) { response ->
val data = response.data
val current = _communityStateLiveData.value as? CreatorChannelCommunityUiState.Content ?: content
if (response.success && data != null) {
_communityStateLiveData.value = current.copy(
communityPosts = current.communityPosts + data.toCommunityPostUiModels(),
page = data.page,
size = data.size,
hasNext = data.hasNext,
isLoadingMore = false
)
} else {
_communityStateLiveData.value = current.copy(
isLoadingMore = false,
paginationErrorMessage = response.message
)
}
}
}
fun consumePaginationErrorMessage() {
val content = _communityStateLiveData.value as? CreatorChannelCommunityUiState.Content ?: return
if (content.paginationErrorMessage == null) return
_communityStateLiveData.value = content.copy(paginationErrorMessage = null)
}
private fun loadFirstPage() {
val generation = ++requestGeneration
_communityStateLiveData.value = CreatorChannelCommunityUiState.Loading
requestCommunity(page = FIRST_PAGE, generation = generation) { response ->
val data = response.data
if (response.success && data != null) {
val communityPosts = data.toCommunityPostUiModels()
_communityStateLiveData.value = if (communityPosts.isEmpty() || data.communityPostCount == 0) {
CreatorChannelCommunityUiState.Empty
} else {
data.toContentState(communityPosts = communityPosts)
}
} else {
_communityStateLiveData.value = CreatorChannelCommunityUiState.Error(response.message)
}
}
}
private fun requestCommunity(
page: Int,
generation: Int,
onSuccess: (ApiResponse<CreatorChannelCommunityTabResponse>) -> Unit
) {
compositeDisposable.add(
repository.getCommunity(
creatorId = creatorId,
page = page,
size = DEFAULT_PAGE_SIZE,
token = authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (generation == requestGeneration) {
onSuccess(it)
}
},
{
if (generation != requestGeneration) return@subscribe
val current = _communityStateLiveData.value as? CreatorChannelCommunityUiState.Content
_communityStateLiveData.value = if (current != null && page > FIRST_PAGE) {
current.copy(isLoadingMore = false, paginationErrorMessage = it.message)
} else {
CreatorChannelCommunityUiState.Error(it.message)
}
}
)
)
}
private fun CreatorChannelCommunityTabResponse.toContentState(
communityPosts: List<CreatorChannelCommunityPostUiModel>
) = CreatorChannelCommunityUiState.Content(
communityPostCount = communityPostCount,
communityPosts = communityPosts,
viewMode = viewMode,
page = page,
size = size,
hasNext = hasNext
)
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
private fun CreatorChannelCommunityTabResponse.toCommunityPostUiModels(): List<CreatorChannelCommunityPostUiModel> =
communityPosts.toCommunityPostUiModels(
relativeTimeTextFormatter = relativeTimeTextFormatter,
isOwner = isOwner,
currentUserId = SharedPreferenceManager.userId
)
companion object {
const val DEFAULT_PAGE_SIZE = 20
private const val FIRST_PAGE = 0
}
}
sealed interface CreatorChannelCommunityUiState {
data object Loading : CreatorChannelCommunityUiState
data object Empty : CreatorChannelCommunityUiState
data class Error(val message: String?) : CreatorChannelCommunityUiState
data class Content(
val communityPostCount: Int,
val communityPosts: List<CreatorChannelCommunityPostUiModel>,
val viewMode: CreatorChannelCommunityViewMode,
val page: Int,
val size: Int,
val hasNext: Boolean,
val isLoadingMore: Boolean = false,
val paginationErrorMessage: String? = null
) : CreatorChannelCommunityUiState
}
@@ -0,0 +1,31 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.data
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class CreatorChannelCommunityTabResponse(
@SerializedName("communityPostCount") val communityPostCount: Int,
@SerializedName("communityPosts") val communityPosts: List<CreatorChannelCommunityPostResponse>,
@SerializedName("page") val page: Int,
@SerializedName("size") val size: Int,
@SerializedName("hasNext") val hasNext: Boolean
)
@Keep
data class CreatorChannelCommunityPostResponse(
@SerializedName("postId") val postId: Long,
@SerializedName("creatorId") val creatorId: Long,
@SerializedName("creatorNickname") val creatorNickname: String,
@SerializedName("creatorProfileUrl") val creatorProfileUrl: String,
@SerializedName("createdAtUtc") val createdAtUtc: String,
@SerializedName("content") val content: String,
@SerializedName("imageUrl") val imageUrl: String?,
@SerializedName("audioUrl") val audioUrl: String?,
@SerializedName("price") val price: Int,
@SerializedName("existOrdered") val existOrdered: Boolean,
@SerializedName("isCommentAvailable") val isCommentAvailable: Boolean,
@SerializedName("likeCount") val likeCount: Int,
@SerializedName("commentCount") val commentCount: Int,
@SerializedName("isPinned") val isPinned: Boolean
)
@@ -0,0 +1,61 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.model
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityPostResponse
private const val GRID_PREVIEW_MAX_LENGTH = 24
fun List<CreatorChannelCommunityPostResponse>.toCommunityPostUiModels(
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter,
isOwner: Boolean,
currentUserId: Long
): List<CreatorChannelCommunityPostUiModel> = map {
it.toCommunityPostUiModel(relativeTimeTextFormatter, isOwner, currentUserId)
}
private fun CreatorChannelCommunityPostResponse.toCommunityPostUiModel(
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter,
isOwner: Boolean,
currentUserId: Long
): CreatorChannelCommunityPostUiModel {
val isLocked = price > 0 && !existOrdered && !isOwner
val showOwnerActions = isOwner && creatorId == currentUserId
val visibleImageUrl = imageUrl.takeUnless { isLocked }
val showPlayButton = !isLocked && !audioUrl.isNullOrBlank() && !visibleImageUrl.isNullOrBlank()
return CreatorChannelCommunityPostUiModel(
postId = postId,
creatorId = creatorId,
creatorNickname = creatorNickname,
creatorProfileUrl = creatorProfileUrl,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
content = content,
imageUrl = visibleImageUrl,
audioUrl = audioUrl,
price = price,
existOrdered = existOrdered,
likeCount = likeCount,
commentCount = commentCount,
showComment = isCommentAvailable,
showNotice = isPinned,
isPinned = isPinned,
isLocked = isLocked,
showOwnerMore = showOwnerActions,
showOwnerTopPrice = showOwnerActions && price > 0,
showPlayButton = showPlayButton,
gridPreviewText = content.toGridPreviewText(),
imageMode = toImageMode(isLocked, visibleImageUrl)
)
}
private fun CreatorChannelCommunityPostResponse.toImageMode(
isLocked: Boolean,
visibleImageUrl: String?
): CreatorChannelCommunityImageMode = when {
isLocked -> CreatorChannelCommunityImageMode.LockedGray
visibleImageUrl.isNullOrBlank() -> CreatorChannelCommunityImageMode.TextPreview
else -> CreatorChannelCommunityImageMode.Image
}
private fun String.toGridPreviewText(): String = replace("\n", " ")
.trim()
.take(GRID_PREVIEW_MAX_LENGTH)
@@ -0,0 +1,49 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import kr.co.vividnext.sodalive.R
enum class CreatorChannelCommunityViewMode(
@StringRes val labelResId: Int,
@DrawableRes val iconResId: Int
) {
List(
labelResId = R.string.creator_channel_community_view_mode_list,
iconResId = R.drawable.ic_new_list
),
Grid(
labelResId = R.string.creator_channel_community_view_mode_grid,
iconResId = R.drawable.ic_new_grid
)
}
enum class CreatorChannelCommunityImageMode {
Image,
TextPreview,
LockedGray
}
data class CreatorChannelCommunityPostUiModel(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String,
val createdAtText: String,
val content: String,
val imageUrl: String?,
val audioUrl: String?,
val price: Int,
val existOrdered: Boolean,
val likeCount: Int,
val commentCount: Int,
val showComment: Boolean,
val showNotice: Boolean,
val isPinned: Boolean,
val isLocked: Boolean,
val showOwnerMore: Boolean,
val showOwnerTopPrice: Boolean,
val showPlayButton: Boolean,
val gridPreviewText: String,
val imageMode: CreatorChannelCommunityImageMode
)
@@ -0,0 +1,90 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import com.bumptech.glide.request.RequestOptions
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityGridBinding
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityImageMode
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
class CreatorChannelCommunityGridAdapter : RecyclerView.Adapter<CreatorChannelCommunityGridAdapter.ViewHolder>() {
private var items: List<CreatorChannelCommunityPostUiModel> = emptyList()
private var itemSizePx: Int = 0
fun submitItems(items: List<CreatorChannelCommunityPostUiModel>) {
this.items = items
notifyDataSetChanged()
}
fun setItemSizePx(itemSizePx: Int) {
if (this.itemSizePx == itemSizePx) return
this.itemSizePx = itemSizePx
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(ItemCreatorChannelCommunityGridBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position], itemSizePx)
}
override fun getItemCount(): Int = items.size
class ViewHolder(
private val binding: ItemCreatorChannelCommunityGridBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CreatorChannelCommunityPostUiModel, itemSizePx: Int) = with(binding) {
if (itemSizePx > 0) {
root.layoutParams = root.layoutParams.apply {
width = itemSizePx
height = itemSizePx
}
}
val visibleImageUrl = item.imageUrl.takeIf { item.imageMode == CreatorChannelCommunityImageMode.Image }
ivCreatorChannelCommunityGridImage.isVisible = visibleImageUrl != null
if (visibleImageUrl != null) {
Glide.with(ivCreatorChannelCommunityGridImage)
.asBitmap()
.load(visibleImageUrl)
.placeholder(R.drawable.ic_place_holder)
.apply(communityImageRequestOptions())
.into(ivCreatorChannelCommunityGridImage)
} else {
Glide.with(ivCreatorChannelCommunityGridImage).clear(ivCreatorChannelCommunityGridImage)
ivCreatorChannelCommunityGridImage.setImageDrawable(null)
}
tvCreatorChannelCommunityGridTextPreview.isVisible =
!item.isLocked && item.imageMode != CreatorChannelCommunityImageMode.Image
tvCreatorChannelCommunityGridTextPreview.text = item.gridPreviewText
layoutCreatorChannelCommunityGridLockedOverlay.isVisible = item.isLocked
ivCreatorChannelCommunityGridLock.isVisible = item.isLocked
tvCreatorChannelCommunityGridLockPrice.isVisible = item.isLocked
tvCreatorChannelCommunityGridLockPrice.text = item.price.moneyFormat()
ivCreatorChannelCommunityGridNotice.isVisible = item.showNotice
}
private fun communityImageRequestOptions(): RequestOptions {
return RequestOptions().transform(CenterCrop())
}
}
internal companion object {
const val GRID_SPAN_COUNT = 3
}
}
internal fun calculateCreatorChannelCommunityGridItemSize(availableWidth: Int): Int {
return availableWidth.coerceAtLeast(0) / CreatorChannelCommunityGridAdapter.GRID_SPAN_COUNT
}
@@ -0,0 +1,107 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import coil.transform.CircleCropTransformation
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.RequestOptions
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelCommunityListBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.community.model.CreatorChannelCommunityPostUiModel
class CreatorChannelCommunityListAdapter(
private val onPlayClick: (CreatorChannelCommunityPostUiModel) -> Unit = {},
private val onOwnerMoreClick: (CreatorChannelCommunityPostUiModel) -> Unit = {},
private val isPlayingContent: (Long) -> Boolean = { false }
) : RecyclerView.Adapter<CreatorChannelCommunityListAdapter.ViewHolder>() {
private var items: List<CreatorChannelCommunityPostUiModel> = emptyList()
fun submitItems(items: List<CreatorChannelCommunityPostUiModel>) {
this.items = items
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemCreatorChannelCommunityListBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onPlayClick,
onOwnerMoreClick,
isPlayingContent
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
class ViewHolder(
private val binding: ItemCreatorChannelCommunityListBinding,
private val onPlayClick: (CreatorChannelCommunityPostUiModel) -> Unit,
private val onOwnerMoreClick: (CreatorChannelCommunityPostUiModel) -> Unit,
private val isPlayingContent: (Long) -> Boolean
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CreatorChannelCommunityPostUiModel) = with(binding) {
ivCreatorChannelCommunityListProfile.loadUrl(item.creatorProfileUrl) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(CircleCropTransformation())
}
tvCreatorChannelCommunityListNickname.text = item.creatorNickname
tvCreatorChannelCommunityListTime.text = item.createdAtText
layoutCreatorChannelCommunityListNotice.isVisible = item.showNotice
tvCreatorChannelCommunityListBody.text = item.content
tvCreatorChannelCommunityListCommentCount.text = item.commentCount.moneyFormat()
tvCreatorChannelCommunityListCommentCount.isVisible = item.showComment
ivCreatorChannelCommunityListComment.isVisible = item.showComment
tvCreatorChannelCommunityListLikeCount.text = item.likeCount.moneyFormat()
val visibleImageUrl = item.imageUrl.takeUnless { item.isLocked }
layoutCreatorChannelCommunityListImageContainer.isVisible =
visibleImageUrl != null || item.isLocked || item.showPlayButton
ivCreatorChannelCommunityListImage.isVisible = visibleImageUrl != null
if (visibleImageUrl != null) {
Glide.with(ivCreatorChannelCommunityListImage)
.load(visibleImageUrl)
.placeholder(R.drawable.ic_place_holder)
.apply(communityImageRequestOptions())
.into(ivCreatorChannelCommunityListImage)
} else {
Glide.with(ivCreatorChannelCommunityListImage).clear(ivCreatorChannelCommunityListImage)
ivCreatorChannelCommunityListImage.setImageDrawable(null)
}
layoutCreatorChannelCommunityListLockedOverlay.isVisible = item.isLocked
ivCreatorChannelCommunityListLock.isVisible = item.isLocked
tvCreatorChannelCommunityListLockedPrice.isVisible = item.isLocked
tvCreatorChannelCommunityListLockedPrice.text = item.price.moneyFormat()
ivCreatorChannelCommunityListPlay.isVisible = item.showPlayButton
ivCreatorChannelCommunityListPlay.setImageResource(
if (isPlayingContent(item.postId)) R.drawable.ic_player_pause else R.drawable.ic_new_player_play
)
ivCreatorChannelCommunityListPlay.setOnClickListener { onPlayClick(item) }
layoutCreatorChannelCommunityListTopActions.isVisible = item.showOwnerMore || item.showOwnerTopPrice
layoutCreatorChannelCommunityListTopPrice.isVisible = item.showOwnerTopPrice
tvCreatorChannelCommunityListTopPrice.text = item.price.moneyFormat()
ivCreatorChannelCommunityListOwnerMore.isVisible = item.showOwnerMore
ivCreatorChannelCommunityListOwnerMore.setOnClickListener { onOwnerMoreClick(item) }
}
private fun communityImageRequestOptions(): RequestOptions {
return RequestOptions().transform(
CenterCrop(),
RoundedCorners(14f.dpToPx().toInt())
)
}
}
}
@@ -0,0 +1,75 @@
package kr.co.vividnext.sodalive.v2.creator.channel.data
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.data.CreatorChannelAudioTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.data.CreatorChannelCommunityTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.CreatorChannelDonationTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.live.data.CreatorChannelLiveTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.series.data.CreatorChannelSeriesTabResponse
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Path
import retrofit2.http.Query
interface CreatorChannelApi {
@GET("/api/v2/creator-channels/{creatorId}/home")
fun getHome(
@Path("creatorId") creatorId: Long,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CreatorChannelHomeResponse>>
@GET("/api/v2/creator-channels/{creatorId}/live")
fun getLive(
@Path("creatorId") creatorId: Long,
@Query("page") page: Int,
@Query("size") size: Int,
@Query("sort") sort: ContentSort,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CreatorChannelLiveTabResponse>>
@GET("/api/v2/creator-channels/{creatorId}/audio")
fun getAudio(
@Path("creatorId") creatorId: Long,
@Query("page") page: Int,
@Query("size") size: Int,
@Query("sort") sort: ContentSort,
@Query("themeId") themeId: Long?,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CreatorChannelAudioTabResponse>>
@GET("/api/v2/creator-channels/{creatorId}/series")
fun getSeries(
@Path("creatorId") creatorId: Long,
@Query("page") page: Int,
@Query("size") size: Int,
@Query("sort") sort: ContentSort,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CreatorChannelSeriesTabResponse>>
@GET("/api/v2/creator-channels/{creatorId}/community")
fun getCommunity(
@Path("creatorId") creatorId: Long,
@Query("page") page: Int,
@Query("size") size: Int,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CreatorChannelCommunityTabResponse>>
@GET("/api/v2/creator-channels/{creatorId}/fan-talks")
fun getFanTalks(
@Path("creatorId") creatorId: Long,
@Query("page") page: Int,
@Query("size") size: Int,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CreatorChannelFanTalkTabResponse>>
@GET("/api/v2/creator-channels/{creatorId}/donations")
fun getDonations(
@Path("creatorId") creatorId: Long,
@Query("page") page: Int,
@Query("size") size: Int,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CreatorChannelDonationTabResponse>>
}
@@ -0,0 +1,144 @@
package kr.co.vividnext.sodalive.v2.creator.channel.data
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
@Keep
data class CreatorChannelHomeResponse(
@SerializedName("creator") val creator: CreatorChannelCreatorResponse,
@SerializedName("currentLive") val currentLive: CreatorChannelLiveResponse?,
@SerializedName("latestAudioContent") val latestAudioContent: CreatorChannelAudioContentResponse?,
@SerializedName("channelDonations") val channelDonations: List<CreatorChannelDonationResponse>,
@SerializedName("notices") val notices: List<CreatorChannelCommunityPostResponse>,
@SerializedName("schedules") val schedules: List<CreatorChannelScheduleResponse>,
@SerializedName("audioContents") val audioContents: List<CreatorChannelAudioContentResponse>,
@SerializedName("series") val series: List<CreatorChannelSeriesResponse>,
@SerializedName("communities") val communities: List<CreatorChannelCommunityPostResponse>,
@SerializedName("fanTalk") val fanTalk: CreatorChannelFanTalkSummaryResponse,
@SerializedName("introduce") val introduce: String,
@SerializedName("activity") val activity: CreatorChannelActivityResponse,
@SerializedName("sns") val sns: CreatorChannelSnsResponse
)
@Keep
data class CreatorChannelCreatorResponse(
@SerializedName("creatorId") val creatorId: Long,
@SerializedName("characterId") val characterId: Long?,
@SerializedName("nickname") val nickname: String,
@SerializedName("profileImageUrl") val profileImageUrl: String,
@SerializedName("followerCount") val followerCount: Int,
@SerializedName("isAiChatAvailable") val isAiChatAvailable: Boolean,
@SerializedName("isDmAvailable") val isDmAvailable: Boolean,
@SerializedName("isFollow") val isFollow: Boolean,
@SerializedName("isNotify") val isNotify: Boolean
)
@Keep
data class CreatorChannelLiveResponse(
@SerializedName("liveId") val liveId: Long,
@SerializedName("title") val title: String,
@SerializedName("coverImageUrl") val coverImageUrl: String?,
@SerializedName("beginDateTimeUtc") val beginDateTimeUtc: String,
@SerializedName("price") val price: Int,
@SerializedName("isAdult") val isAdult: Boolean
)
@Keep
data class CreatorChannelAudioContentResponse(
@SerializedName("audioContentId") val audioContentId: Long,
@SerializedName("title") val title: String,
@SerializedName("duration") val duration: String?,
@SerializedName("imageUrl") val imageUrl: String?,
@SerializedName("price") val price: Int,
@SerializedName("isPointAvailable") val isPointAvailable: Boolean,
@SerializedName("isFirstContent") val isFirstContent: Boolean,
@SerializedName("seriesName") val seriesName: String?,
@SerializedName("isOriginalSeries") val isOriginalSeries: Boolean?,
@SerializedName("isAdult") val isAdult: Boolean = false,
@SerializedName("isOwned") val isOwned: Boolean = false,
@SerializedName("isRented") val isRented: Boolean = false
)
@Keep
data class CreatorChannelDonationResponse(
@SerializedName("donationId") val donationId: Long,
@SerializedName("memberId") val memberId: Long,
@SerializedName("nickname") val nickname: String,
@SerializedName("profileImageUrl") val profileImageUrl: String,
@SerializedName("can") val can: Int,
@SerializedName("isSecret") val isSecret: Boolean,
@SerializedName("message") val message: String,
@SerializedName("createdAtUtc") val createdAtUtc: String
)
@Keep
data class CreatorChannelScheduleResponse(
@SerializedName("scheduledAtUtc") val scheduledAtUtc: String,
@SerializedName("title") val title: String,
@SerializedName("type") val type: CreatorActivityType,
@SerializedName("targetId") val targetId: Long
)
@Keep
data class CreatorChannelSeriesResponse(
@SerializedName("seriesId") val seriesId: Long,
@SerializedName("title") val title: String,
@SerializedName("coverImageUrl") val coverImageUrl: String,
@SerializedName("numberOfContent") val numberOfContent: Int,
@SerializedName("isNew") val isNew: Boolean,
@SerializedName("isOriginal") val isOriginal: Boolean
)
@Keep
data class CreatorChannelCommunityPostResponse(
@SerializedName("postId") val postId: Long,
@SerializedName("creatorId") val creatorId: Long,
@SerializedName("creatorNickname") val creatorNickname: String,
@SerializedName("creatorProfileUrl") val creatorProfileUrl: String,
@SerializedName("imageUrl") val imageUrl: String?,
@SerializedName("audioUrl") val audioUrl: String?,
@SerializedName("content") val content: String,
@SerializedName("price") val price: Int,
@SerializedName("dateUtc") val dateUtc: String,
@SerializedName("existOrdered") val existOrdered: Boolean,
@SerializedName("likeCount") val likeCount: Int,
@SerializedName("commentCount") val commentCount: Int
)
@Keep
data class CreatorChannelFanTalkSummaryResponse(
@SerializedName("totalCount") val totalCount: Int,
@SerializedName("latestFanTalk") val latestFanTalk: CreatorChannelFanTalkResponse?
)
@Keep
data class CreatorChannelFanTalkResponse(
@SerializedName("fanTalkId") val fanTalkId: Long,
@SerializedName("memberId") val memberId: Long,
@SerializedName("nickname") val nickname: String,
@SerializedName("profileImageUrl") val profileImageUrl: String,
@SerializedName("content") val content: String,
@SerializedName("languageCode") val languageCode: String?,
@SerializedName("createdAtUtc") val createdAtUtc: String
)
@Keep
data class CreatorChannelActivityResponse(
@SerializedName("debutDateUtc") val debutDateUtc: String?,
@SerializedName("dday") val dDay: String,
@SerializedName("liveCount") val liveCount: Long,
@SerializedName("liveDurationHours") val liveDurationHours: Long,
@SerializedName("liveContributorCount") val liveContributorCount: Long,
@SerializedName("audioContentCount") val audioContentCount: Long,
@SerializedName("seriesCount") val seriesCount: Long
)
@Keep
data class CreatorChannelSnsResponse(
@SerializedName("instagramUrl") val instagramUrl: String,
@SerializedName("fancimmUrl") val fancimmUrl: String,
@SerializedName("xurl") val xUrl: String,
@SerializedName("youtubeUrl") val youtubeUrl: String,
@SerializedName("kakaoOpenChatUrl") val kakaoOpenChatUrl: String
)
@@ -0,0 +1,163 @@
package kr.co.vividnext.sodalive.v2.creator.channel.data
import kr.co.vividnext.sodalive.chat.talk.TalkApi
import kr.co.vividnext.sodalive.chat.talk.room.CreateChatRoomRequest
import kr.co.vividnext.sodalive.explorer.ExplorerRepository
import kr.co.vividnext.sodalive.explorer.profile.channel_donation.PostChannelDonationRequest
import kr.co.vividnext.sodalive.explorer.profile.cheers.PutModifyCheersRequest
import kr.co.vividnext.sodalive.report.ReportRepository
import kr.co.vividnext.sodalive.report.ReportRequest
import kr.co.vividnext.sodalive.report.ReportType
import kr.co.vividnext.sodalive.user.UserRepository
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
class CreatorChannelRepository(
private val api: CreatorChannelApi,
private val userRepository: UserRepository,
private val talkApi: TalkApi,
private val reportRepository: ReportRepository,
private val explorerRepository: ExplorerRepository
) {
fun getHome(creatorId: Long, token: String) = api.getHome(
creatorId = creatorId,
authHeader = token
)
fun getLive(
creatorId: Long,
page: Int,
size: Int,
sort: ContentSort,
token: String
) = api.getLive(
creatorId = creatorId,
page = page,
size = size,
sort = sort,
authHeader = token
)
fun getAudio(
creatorId: Long,
page: Int,
size: Int,
sort: ContentSort,
themeId: Long?,
token: String
) = api.getAudio(
creatorId = creatorId,
page = page,
size = size,
sort = sort,
themeId = themeId,
authHeader = token
)
fun getSeries(
creatorId: Long,
page: Int,
size: Int,
sort: ContentSort,
token: String
) = api.getSeries(
creatorId = creatorId,
page = page,
size = size,
sort = sort,
authHeader = token
)
fun getCommunity(
creatorId: Long,
page: Int,
size: Int,
token: String
) = api.getCommunity(
creatorId = creatorId,
page = page,
size = size,
authHeader = token
)
fun getFanTalks(
creatorId: Long,
page: Int,
size: Int,
token: String
) = api.getFanTalks(
creatorId = creatorId,
page = page,
size = size,
authHeader = token
)
fun getDonations(
creatorId: Long,
page: Int,
size: Int,
token: String
) = api.getDonations(
creatorId = creatorId,
page = page,
size = size,
authHeader = token
)
fun followCreator(
creatorId: Long,
follow: Boolean,
notify: Boolean,
token: String
) = userRepository.creatorFollow(
creatorId = creatorId,
follow = follow,
notify = notify,
token = token
)
fun createChatRoom(characterId: Long, token: String) = talkApi.createChatRoom(
authHeader = token,
request = CreateChatRoomRequest(characterId)
)
fun postChannelDonation(
creatorId: Long,
can: Int,
isSecret: Boolean,
message: String,
token: String
) = explorerRepository.postChannelDonation(
request = PostChannelDonationRequest(
creatorId = creatorId,
can = can,
isSecret = isSecret,
message = message
),
token = token
)
fun blockUser(userId: Long, token: String) = userRepository.memberBlock(
userId = userId,
token = token
)
fun reportUser(userId: Long, reason: String, token: String) = reportRepository.report(
request = ReportRequest(ReportType.USER, reason, reportedMemberId = userId),
token = token
)
fun reportProfile(userId: Long, reason: String, token: String) = reportRepository.report(
request = ReportRequest(ReportType.PROFILE, reason, reportedMemberId = userId),
token = token
)
fun reportFanTalk(fanTalkId: Long, reason: String, token: String) = reportRepository.report(
request = ReportRequest(ReportType.CHEERS, reason, cheersId = fanTalkId),
token = token
)
fun deleteFanTalk(fanTalkId: Long, token: String) = explorerRepository.modifyCheers(
request = PutModifyCheersRequest(cheersId = fanTalkId, isActive = false),
token = token
)
}
@@ -0,0 +1,213 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelDonationBinding
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.donation.ui.CreatorChannelDonationAdapter
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelDonationFragment : BaseFragment<FragmentCreatorChannelDonationBinding>(
FragmentCreatorChannelDonationBinding::inflate
) {
private val viewModel: CreatorChannelDonationViewModel by viewModel()
private val donationAdapter = CreatorChannelDonationAdapter(
onRankingAllClick = { host.onCreatorChannelDonationRankingAllClicked() },
onEmptyDonationClick = {
host.onCreatorChannelDonationRequested { can, isSecret, message ->
viewModel.postChannelDonation(can, isSecret, message)
}
}
)
private var lastContentLayoutKey: CreatorChannelDonationContentLayoutKey? = null
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
private val host: Host
get() = requireActivity() as Host
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindLoading()
setupDonationList()
setupClickListeners()
observeViewModel()
}
override fun onDestroyView() {
if (isAdded) {
host.onCreatorChannelDonationFloatingButtonVisibilityChanged(false)
}
lastContentLayoutKey = null
binding.rvCreatorChannelDonation.adapter = null
super.onDestroyView()
}
fun onCreatorChannelDonationTabSelected() {
if (creatorId > 0L) {
viewModel.loadDonations(creatorId, isOwner = host.isCreatorChannelOwner())
}
}
fun onCreatorChannelDonationScrolledToBottom() {
viewModel.loadMore()
}
fun onCreatorChannelDonationRefreshRequested() {
viewModel.refreshDonations()
}
fun onCreatorChannelDonationViewportHeightChanged(minHeight: Int) = Unit
fun onCreatorChannelDonationFloatingButtonClicked() {
host.onCreatorChannelDonationRequested { can, isSecret, message ->
viewModel.postChannelDonation(can, isSecret, message)
}
}
private fun setupDonationList() = with(binding.rvCreatorChannelDonation) {
layoutManager = LinearLayoutManager(requireContext())
adapter = donationAdapter
}
private fun setupClickListeners() = with(binding) {
btnCreatorChannelDonationRetry.setOnClickListener {
viewModel.retryDonations()
}
}
private fun observeViewModel() {
viewModel.donationStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) {
CreatorChannelDonationUiState.Loading -> bindLoading()
is CreatorChannelDonationUiState.Empty -> bindEmpty(state)
is CreatorChannelDonationUiState.Error -> bindError(state)
is CreatorChannelDonationUiState.Content -> bindContent(state)
}
handleActionToastMessage(state)
handleDonationSuccessEvent()
}
}
private fun bindLoading() = with(binding) {
lastContentLayoutKey = null
layoutCreatorChannelDonationCountBar.isVisible = false
rvCreatorChannelDonation.isVisible = false
tvCreatorChannelDonationErrorMessage.isVisible = false
btnCreatorChannelDonationRetry.isVisible = false
host.onCreatorChannelDonationFloatingButtonVisibilityChanged(false)
}
private fun bindEmpty(state: CreatorChannelDonationUiState.Empty) = with(binding) {
lastContentLayoutKey = null
layoutCreatorChannelDonationCountBar.isVisible = false
rvCreatorChannelDonation.isVisible = true
donationAdapter.submitEmpty(state.rankings, state.isOwner)
tvCreatorChannelDonationErrorMessage.isVisible = false
btnCreatorChannelDonationRetry.isVisible = false
host.onCreatorChannelDonationFloatingButtonVisibilityChanged(false)
host.onCreatorChannelDonationContentChanged()
}
private fun bindError(state: CreatorChannelDonationUiState.Error) = with(binding) {
lastContentLayoutKey = null
layoutCreatorChannelDonationCountBar.isVisible = false
rvCreatorChannelDonation.isVisible = false
tvCreatorChannelDonationErrorMessage.isVisible = true
tvCreatorChannelDonationErrorMessage.text = state.message ?: getString(R.string.creator_channel_donation_error_message)
btnCreatorChannelDonationRetry.isVisible = true
host.onCreatorChannelDonationFloatingButtonVisibilityChanged(false)
host.onCreatorChannelDonationContentChanged()
}
private fun bindContent(state: CreatorChannelDonationUiState.Content) = with(binding) {
layoutCreatorChannelDonationCountBar.isVisible = true
tvCreatorChannelDonationTotalCount.text = state.donationCount.moneyFormat()
rvCreatorChannelDonation.isVisible = true
donationAdapter.submitItems(state.rankings, state.donations)
tvCreatorChannelDonationErrorMessage.isVisible = false
btnCreatorChannelDonationRetry.isVisible = false
host.onCreatorChannelDonationFloatingButtonVisibilityChanged(!state.isOwner)
notifyContentChangedIfLayoutChanged(state)
state.paginationErrorMessage?.let {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
viewModel.consumePaginationErrorMessage()
}
}
private fun handleActionToastMessage(state: CreatorChannelDonationUiState) {
val message = when (state) {
is CreatorChannelDonationUiState.Empty -> state.actionToastMessage
is CreatorChannelDonationUiState.Content -> state.actionToastMessage
else -> null
} ?: return
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
viewModel.consumeActionToastMessage()
}
private fun handleDonationSuccessEvent() {
if (viewModel.consumeDonationSuccessEvent()) {
host.onCreatorChannelDonationCompleted()
}
}
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelDonationUiState.Content) {
val contentLayoutKey = state.toContentLayoutKey()
if (contentLayoutKey == lastContentLayoutKey) return
lastContentLayoutKey = contentLayoutKey
host.onCreatorChannelDonationContentChanged()
}
interface Host {
fun isCreatorChannelOwner(): Boolean
fun onCreatorChannelDonationContentChanged()
fun onCreatorChannelDonationFloatingButtonVisibilityChanged(isVisible: Boolean)
fun onCreatorChannelDonationRequested(onSubmit: (can: Int, isSecret: Boolean, message: String) -> Unit)
fun onCreatorChannelDonationRankingAllClicked()
fun onCreatorChannelDonationCompleted()
}
companion object {
private const val ARG_CREATOR_ID: String = "arg_creator_id"
fun newInstance(creatorId: Long): CreatorChannelDonationFragment {
return CreatorChannelDonationFragment().apply {
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
}
}
}
}
private data class CreatorChannelDonationContentLayoutKey(
val donationCount: Int,
val rankingUserIds: List<Long>,
val donationItems: List<CreatorChannelDonationItemLayoutKey>
)
private data class CreatorChannelDonationItemLayoutKey(
val nickname: String,
val can: Int,
val message: String,
val createdAtText: String
)
private fun CreatorChannelDonationUiState.Content.toContentLayoutKey(): CreatorChannelDonationContentLayoutKey {
return CreatorChannelDonationContentLayoutKey(
donationCount = donationCount,
rankingUserIds = rankings.map { it.userId },
donationItems = donations.map { donation ->
CreatorChannelDonationItemLayoutKey(
nickname = donation.nickname,
can = donation.can,
message = donation.message,
createdAtText = donation.createdAtText
)
}
)
}
@@ -0,0 +1,256 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.CreatorChannelDonationTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.CreatorChannelDonationRankingUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.CreatorChannelDonationUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.toDonationRankingUiModels
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.toDonationUiModels
class CreatorChannelDonationViewModel(
private val repository: CreatorChannelRepository,
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) : BaseViewModel() {
private val _donationStateLiveData = MutableLiveData<CreatorChannelDonationUiState>()
val donationStateLiveData: LiveData<CreatorChannelDonationUiState>
get() = _donationStateLiveData
private val context = SodaLiveApplicationHolder.get()
private var creatorId: Long = 0L
private var isOwner: Boolean = false
private var requestGeneration: Int = 0
private var isPostChannelDonationInProgress: Boolean = false
private var donationSuccessEvent: Boolean = false
fun loadDonations(creatorId: Long, isOwner: Boolean) {
if (creatorId <= 0) return
val shouldSkipReload = this.creatorId == creatorId && this.isOwner == isOwner && _donationStateLiveData.value != null
if (shouldSkipReload) return
this.creatorId = creatorId
this.isOwner = isOwner
loadFirstPage()
}
fun retryDonations() {
if (creatorId <= 0) return
loadFirstPage()
}
fun refreshDonations() {
if (creatorId <= 0) return
loadFirstPage()
}
fun loadMore() {
val content = _donationStateLiveData.value as? CreatorChannelDonationUiState.Content ?: return
if (!content.hasNext || content.isLoadingMore || creatorId <= 0) return
val generation = requestGeneration
_donationStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null)
requestDonations(page = content.page + 1, generation = generation) { response ->
val data = response.data
val current = _donationStateLiveData.value as? CreatorChannelDonationUiState.Content ?: content
if (response.success && data != null) {
_donationStateLiveData.value = current.copy(
donationCount = data.donationCount,
rankings = data.toDonationRankingUiModels(),
donations = current.donations + data.toDonationUiModels(),
page = data.page,
size = data.size,
hasNext = data.hasNext,
isLoadingMore = false
)
} else {
_donationStateLiveData.value = current.copy(
isLoadingMore = false,
paginationErrorMessage = response.message
)
}
}
}
fun postChannelDonation(can: Int, isSecret: Boolean, message: String) {
if (creatorId <= 0 || isPostChannelDonationInProgress) return
isPostChannelDonationInProgress = true
compositeDisposable.add(
repository.postChannelDonation(
creatorId = creatorId,
can = can,
isSecret = isSecret,
message = message,
token = authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
isPostChannelDonationInProgress = false
if (it.success) {
SharedPreferenceManager.can = (SharedPreferenceManager.can - can).coerceAtLeast(0)
donationSuccessEvent = true
loadFirstPage()
} else {
setActionToastMessage(it.message)
}
},
{
isPostChannelDonationInProgress = false
setActionToastMessage(it.message)
}
)
)
}
fun consumePaginationErrorMessage() {
val content = _donationStateLiveData.value as? CreatorChannelDonationUiState.Content ?: return
if (content.paginationErrorMessage == null) return
_donationStateLiveData.value = content.copy(paginationErrorMessage = null)
}
fun consumeActionToastMessage() {
when (val state = _donationStateLiveData.value) {
is CreatorChannelDonationUiState.Empty -> {
if (state.actionToastMessage != null) {
_donationStateLiveData.value = state.copy(actionToastMessage = null)
}
}
is CreatorChannelDonationUiState.Content -> {
if (state.actionToastMessage != null) {
_donationStateLiveData.value = state.copy(actionToastMessage = null)
}
}
else -> Unit
}
}
fun consumeDonationSuccessEvent(): Boolean {
val event = donationSuccessEvent
donationSuccessEvent = false
return event
}
private fun loadFirstPage() {
val generation = ++requestGeneration
_donationStateLiveData.value = CreatorChannelDonationUiState.Loading
requestDonations(page = FIRST_PAGE, generation = generation) { response ->
val data = response.data
if (response.success && data != null) {
val donations = data.toDonationUiModels()
val rankings = data.toDonationRankingUiModels()
_donationStateLiveData.value = if (donations.isEmpty() || data.donationCount == 0) {
CreatorChannelDonationUiState.Empty(data.donationCount, rankings, isOwner)
} else {
data.toContentState(rankings, donations)
}
} else {
_donationStateLiveData.value = CreatorChannelDonationUiState.Error(response.message)
}
}
}
private fun requestDonations(
page: Int,
generation: Int,
onSuccess: (ApiResponse<CreatorChannelDonationTabResponse>) -> Unit
) {
compositeDisposable.add(
repository.getDonations(
creatorId = creatorId,
page = page,
size = DEFAULT_PAGE_SIZE,
token = authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (generation == requestGeneration) {
onSuccess(it)
}
},
{
if (generation != requestGeneration) return@subscribe
val current = _donationStateLiveData.value as? CreatorChannelDonationUiState.Content
_donationStateLiveData.value = if (current != null && page > FIRST_PAGE) {
current.copy(isLoadingMore = false, paginationErrorMessage = it.message)
} else {
CreatorChannelDonationUiState.Error(it.message)
}
}
)
)
}
private fun setActionToastMessage(message: String?) {
when (val state = _donationStateLiveData.value) {
is CreatorChannelDonationUiState.Empty -> _donationStateLiveData.value = state.copy(actionToastMessage = message)
is CreatorChannelDonationUiState.Content -> _donationStateLiveData.value = state.copy(actionToastMessage = message)
else -> Unit
}
}
private fun CreatorChannelDonationTabResponse.toContentState(
rankings: List<CreatorChannelDonationRankingUiModel>,
donations: List<CreatorChannelDonationUiModel>
) = CreatorChannelDonationUiState.Content(
donationCount = donationCount,
rankings = rankings,
donations = donations,
page = page,
size = size,
hasNext = hasNext,
isOwner = isOwner
)
private fun CreatorChannelDonationTabResponse.toDonationRankingUiModels(): List<CreatorChannelDonationRankingUiModel> =
rankings.toDonationRankingUiModels()
private fun CreatorChannelDonationTabResponse.toDonationUiModels(): List<CreatorChannelDonationUiModel> =
donations.toDonationUiModels(context, relativeTimeTextFormatter)
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
companion object {
const val DEFAULT_PAGE_SIZE = 20
private const val FIRST_PAGE = 0
}
}
sealed interface CreatorChannelDonationUiState {
data object Loading : CreatorChannelDonationUiState
data class Empty(
val donationCount: Int,
val rankings: List<CreatorChannelDonationRankingUiModel>,
val isOwner: Boolean,
val actionToastMessage: String? = null
) : CreatorChannelDonationUiState
data class Error(val message: String?) : CreatorChannelDonationUiState
data class Content(
val donationCount: Int,
val rankings: List<CreatorChannelDonationRankingUiModel>,
val donations: List<CreatorChannelDonationUiModel>,
val page: Int,
val size: Int,
val hasNext: Boolean,
val isOwner: Boolean,
val isLoadingMore: Boolean = false,
val paginationErrorMessage: String? = null,
val actionToastMessage: String? = null
) : CreatorChannelDonationUiState
}
@@ -0,0 +1,31 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.data
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class CreatorChannelDonationTabResponse(
@SerializedName("donationCount") val donationCount: Int,
@SerializedName("rankings") val rankings: List<MemberDonationRankingResponse>,
@SerializedName("donations") val donations: List<CreatorChannelDonationResponse>,
@SerializedName("page") val page: Int,
@SerializedName("size") val size: Int,
@SerializedName("hasNext") val hasNext: Boolean
)
@Keep
data class MemberDonationRankingResponse(
@SerializedName("userId") val userId: Long,
@SerializedName("nickname") val nickname: String,
@SerializedName("profileImage") val profileImage: String,
@SerializedName("donationCan") val donationCan: Int
)
@Keep
data class CreatorChannelDonationResponse(
@SerializedName("nickname") val nickname: String,
@SerializedName("profileImageUrl") val profileImageUrl: String,
@SerializedName("can") val can: Int,
@SerializedName("message") val message: String,
@SerializedName("createdAtUtc") val createdAtUtc: String
)
@@ -0,0 +1,44 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.model
import android.content.Context
import androidx.annotation.ColorRes
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.CreatorChannelDonationResponse
import kr.co.vividnext.sodalive.v2.creator.channel.donation.data.MemberDonationRankingResponse
fun List<MemberDonationRankingResponse>.toDonationRankingUiModels(): List<CreatorChannelDonationRankingUiModel> =
mapIndexed { index, response -> response.toDonationRankingUiModel(rank = index + 1) }
fun List<CreatorChannelDonationResponse>.toDonationUiModels(
context: Context,
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
): List<CreatorChannelDonationUiModel> = map { it.toDonationUiModel(context, relativeTimeTextFormatter) }
private fun MemberDonationRankingResponse.toDonationRankingUiModel(rank: Int) = CreatorChannelDonationRankingUiModel(
rank = rank,
userId = userId,
nickname = nickname,
profileImageUrl = profileImage,
donationCan = donationCan
)
private fun CreatorChannelDonationResponse.toDonationUiModel(
context: Context,
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) = CreatorChannelDonationUiModel(
nickname = nickname,
profileImageUrl = profileImageUrl,
can = can,
message = message.takeUnless { it.isBlank() } ?: context.getString(R.string.creator_channel_donation_fallback_message, can),
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
headerColorResId = calculateDonationHeaderColorRes(can)
)
@ColorRes
internal fun calculateDonationHeaderColorRes(can: Int): Int = when {
can >= 500 -> R.color.red_400
can >= 101 -> R.color.creator_channel_donation_cyan
can >= 51 -> R.color.green_400
else -> R.color.gray_200
}
@@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.model
import androidx.annotation.ColorRes
data class CreatorChannelDonationRankingUiModel(
val rank: Int,
val userId: Long,
val nickname: String,
val profileImageUrl: String,
val donationCan: Int
)
data class CreatorChannelDonationUiModel(
val nickname: String,
val profileImageUrl: String,
val can: Int,
val message: String,
val createdAtText: String,
@param:ColorRes val headerColorResId: Int
)
@@ -0,0 +1,168 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.ui
import android.content.Context
import android.graphics.Rect
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationBinding
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationEmptyBinding
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationRankingBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.CreatorChannelDonationRankingUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.CreatorChannelDonationUiModel
class CreatorChannelDonationAdapter(
private val onRankingAllClick: () -> Unit = { },
private val onEmptyDonationClick: () -> Unit = { }
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var items: List<CreatorChannelDonationListItem> = emptyList()
fun submitItems(
rankings: List<CreatorChannelDonationRankingUiModel>,
donations: List<CreatorChannelDonationUiModel>
) {
items = buildList {
if (rankings.isNotEmpty()) add(CreatorChannelDonationListItem.Ranking(rankings))
donations.forEach { add(CreatorChannelDonationListItem.Donation(it)) }
}
notifyDataSetChanged()
}
fun submitEmpty(
rankings: List<CreatorChannelDonationRankingUiModel>,
isOwner: Boolean
) {
items = buildList {
if (rankings.isNotEmpty()) add(CreatorChannelDonationListItem.Ranking(rankings))
add(CreatorChannelDonationListItem.Empty(isOwner))
}
notifyDataSetChanged()
}
override fun getItemViewType(position: Int): Int = when (items[position]) {
is CreatorChannelDonationListItem.Ranking -> VIEW_TYPE_RANKING
is CreatorChannelDonationListItem.Empty -> VIEW_TYPE_EMPTY
is CreatorChannelDonationListItem.Donation -> VIEW_TYPE_DONATION
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
VIEW_TYPE_RANKING -> RankingViewHolder(
ItemCreatorChannelDonationRankingBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onRankingAllClick
)
VIEW_TYPE_EMPTY -> EmptyViewHolder(
ItemCreatorChannelDonationEmptyBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onEmptyDonationClick
)
else -> DonationViewHolder(
ItemCreatorChannelDonationBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) {
is CreatorChannelDonationListItem.Ranking -> (holder as RankingViewHolder).bind(item.rankings)
is CreatorChannelDonationListItem.Empty -> (holder as EmptyViewHolder).bind(item.isOwner)
is CreatorChannelDonationListItem.Donation -> (holder as DonationViewHolder).bind(item.donation)
}
}
override fun getItemCount(): Int = items.size
class RankingViewHolder(
private val binding: ItemCreatorChannelDonationRankingBinding,
private val onRankingAllClick: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
private val rankingAdapter = CreatorChannelDonationRankingAdapter()
init {
binding.rvCreatorChannelDonationRankingMembers.layoutManager = GridLayoutManager(itemView.context, 4)
binding.rvCreatorChannelDonationRankingMembers.addItemDecoration(
CreatorChannelDonationRankingItemDecoration(itemView.context, spanCount = 4)
)
binding.rvCreatorChannelDonationRankingMembers.adapter = rankingAdapter
binding.btnCreatorChannelDonationRankingAll.setOnClickListener { onRankingAllClick() }
}
fun bind(rankings: List<CreatorChannelDonationRankingUiModel>) {
rankingAdapter.submitItems(rankings)
}
}
class EmptyViewHolder(
private val binding: ItemCreatorChannelDonationEmptyBinding,
private val onEmptyDonationClick: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.btnCreatorChannelDonationEmptyWrite.setOnClickListener { onEmptyDonationClick() }
}
fun bind(isOwner: Boolean) = with(binding) {
btnCreatorChannelDonationEmptyWrite.isVisible = !isOwner
}
}
class DonationViewHolder(
private val binding: ItemCreatorChannelDonationBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CreatorChannelDonationUiModel) = with(binding) {
layoutCreatorChannelDonationHeader.setBackgroundColor(root.context.getColor(item.headerColorResId))
ivCreatorChannelDonationProfile.loadUrl(item.profileImageUrl) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
tvCreatorChannelDonationNickname.text = item.nickname
tvCreatorChannelDonationCreatedAt.text = item.createdAtText
tvCreatorChannelDonationCan.text = root.context.getString(
R.string.creator_channel_donation_can_format,
item.can.moneyFormat()
)
tvCreatorChannelDonationMessage.text = item.message
}
}
companion object {
private const val VIEW_TYPE_RANKING = 0
private const val VIEW_TYPE_EMPTY = 1
private const val VIEW_TYPE_DONATION = 2
}
}
private sealed interface CreatorChannelDonationListItem {
data class Ranking(val rankings: List<CreatorChannelDonationRankingUiModel>) : CreatorChannelDonationListItem
data class Empty(val isOwner: Boolean) : CreatorChannelDonationListItem
data class Donation(val donation: CreatorChannelDonationUiModel) : CreatorChannelDonationListItem
}
private class CreatorChannelDonationRankingItemDecoration(
context: Context,
private val spanCount: Int
) : RecyclerView.ItemDecoration() {
private val spacing: Int = (14 * context.resources.displayMetrics.density).toInt()
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val position = parent.getChildAdapterPosition(view)
if (position == RecyclerView.NO_POSITION) return
val column = position % spanCount
outRect.left = column * spacing / spanCount
outRect.right = spacing - (column + 1) * spacing / spanCount
outRect.top = if (position >= spanCount) spacing else 0
}
}
@@ -0,0 +1,52 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelDonationRankingMemberBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.creator.channel.donation.model.CreatorChannelDonationRankingUiModel
class CreatorChannelDonationRankingAdapter : RecyclerView.Adapter<CreatorChannelDonationRankingAdapter.ViewHolder>() {
private var items: List<CreatorChannelDonationRankingUiModel> = emptyList()
fun submitItems(items: List<CreatorChannelDonationRankingUiModel>) {
this.items = items.take(MAX_VISIBLE_RANKING_COUNT)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemCreatorChannelDonationRankingMemberBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
class ViewHolder(
private val binding: ItemCreatorChannelDonationRankingMemberBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CreatorChannelDonationRankingUiModel) = with(binding) {
ivCreatorChannelDonationRankingProfile.loadUrl(item.profileImageUrl) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
tvCreatorChannelDonationRankingRank.text = item.rank.toString()
tvCreatorChannelDonationRankingNickname.text = item.nickname
}
}
companion object {
private const val MAX_VISIBLE_RANKING_COUNT = 8
}
}
@@ -0,0 +1,205 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelFantalkBinding
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.report.CheersReportDialog
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkRightAction
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.ui.CreatorChannelFanTalkAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.ui.CreatorChannelFanTalkMorePopup
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelFanTalkFragment : BaseFragment<FragmentCreatorChannelFantalkBinding>(
FragmentCreatorChannelFantalkBinding::inflate
) {
private val viewModel: CreatorChannelFanTalkViewModel by viewModel()
private val fanTalkAdapter = CreatorChannelFanTalkAdapter(
onOwnerMoreClick = ::showOwnerMorePopup,
onReportClick = ::showReportDialog
)
private var morePopup: CreatorChannelFanTalkMorePopup? = null
private var lastContentLayoutKey: CreatorChannelFanTalkContentLayoutKey? = null
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
private val host: Host
get() = requireActivity() as Host
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindLoading()
setupFanTalkList()
setupClickListeners()
observeViewModel()
}
override fun onDestroyView() {
morePopup?.dismiss()
morePopup = null
lastContentLayoutKey = null
binding.rvCreatorChannelFantalk.adapter = null
super.onDestroyView()
}
fun onCreatorChannelFanTalkTabSelected() {
if (creatorId > 0L) {
viewModel.loadFanTalks(creatorId, isOwner = host.isCreatorChannelOwner())
}
}
fun onCreatorChannelFanTalkScrolledToBottom() {
viewModel.loadMore()
}
fun onCreatorChannelFanTalkRefreshRequested() {
viewModel.refreshFanTalks()
}
@Suppress("UNUSED_PARAMETER")
fun onCreatorChannelFanTalkViewportHeightChanged(minHeight: Int) = Unit
fun onCreatorChannelFanTalkDeleteConfirmed(fanTalkId: Long) {
viewModel.deleteFanTalk(fanTalkId)
}
private fun setupFanTalkList() = with(binding.rvCreatorChannelFantalk) {
layoutManager = LinearLayoutManager(requireContext())
adapter = fanTalkAdapter
}
private fun setupClickListeners() = with(binding) {
btnCreatorChannelFantalkRetry.setOnClickListener {
viewModel.retryFanTalks()
}
btnCreatorChannelFantalkWrite.setOnClickListener { }
layoutCreatorChannelFantalkEmptyWriteButton.setOnClickListener { }
}
private fun observeViewModel() {
viewModel.fanTalkStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) {
CreatorChannelFanTalkUiState.Loading -> bindLoading()
is CreatorChannelFanTalkUiState.Empty -> bindEmpty()
is CreatorChannelFanTalkUiState.Error -> bindError(state)
is CreatorChannelFanTalkUiState.Content -> bindContent(state)
}
}
}
private fun bindLoading() = with(binding) {
lastContentLayoutKey = null
layoutCreatorChannelFantalkCountBar.isVisible = false
rvCreatorChannelFantalk.isVisible = false
layoutCreatorChannelFantalkEmpty.isVisible = false
tvCreatorChannelFantalkErrorMessage.isVisible = false
btnCreatorChannelFantalkRetry.isVisible = false
btnCreatorChannelFantalkWrite.isVisible = false
}
private fun bindEmpty() = with(binding) {
lastContentLayoutKey = null
layoutCreatorChannelFantalkCountBar.isVisible = false
rvCreatorChannelFantalk.isVisible = false
layoutCreatorChannelFantalkEmpty.isVisible = true
tvCreatorChannelFantalkErrorMessage.isVisible = false
btnCreatorChannelFantalkRetry.isVisible = false
btnCreatorChannelFantalkWrite.isVisible = false
host.onCreatorChannelFanTalkContentChanged()
}
private fun bindError(state: CreatorChannelFanTalkUiState.Error) = with(binding) {
lastContentLayoutKey = null
layoutCreatorChannelFantalkCountBar.isVisible = false
rvCreatorChannelFantalk.isVisible = false
layoutCreatorChannelFantalkEmpty.isVisible = false
tvCreatorChannelFantalkErrorMessage.isVisible = true
tvCreatorChannelFantalkErrorMessage.text = state.message ?: getString(R.string.creator_channel_fantalk_error_message)
btnCreatorChannelFantalkRetry.isVisible = true
btnCreatorChannelFantalkWrite.isVisible = false
host.onCreatorChannelFanTalkContentChanged()
}
private fun bindContent(state: CreatorChannelFanTalkUiState.Content) = with(binding) {
layoutCreatorChannelFantalkCountBar.isVisible = true
tvCreatorChannelFantalkTotalCount.text = state.fanTalkCount.moneyFormat()
rvCreatorChannelFantalk.isVisible = true
fanTalkAdapter.submitItems(state.fanTalks)
layoutCreatorChannelFantalkEmpty.isVisible = false
tvCreatorChannelFantalkErrorMessage.isVisible = false
btnCreatorChannelFantalkRetry.isVisible = false
btnCreatorChannelFantalkWrite.isVisible = true
notifyContentChangedIfLayoutChanged(state)
state.paginationErrorMessage?.let {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
viewModel.consumePaginationErrorMessage()
}
state.actionToastMessage?.let {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
viewModel.consumeActionToastMessage()
}
}
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelFanTalkUiState.Content) {
val contentLayoutKey = state.toContentLayoutKey()
if (contentLayoutKey == lastContentLayoutKey) return
lastContentLayoutKey = contentLayoutKey
host.onCreatorChannelFanTalkContentChanged()
}
private fun showReportDialog(item: CreatorChannelFanTalkUiModel) {
CheersReportDialog(requireActivity(), layoutInflater) { reason ->
if (reason.isBlank()) {
showToast(getString(R.string.screen_user_profile_fantalk_report_reason_required))
} else {
viewModel.reportFanTalk(item.fanTalkId, reason)
}
}.show(screenWidth)
}
private fun showOwnerMorePopup(anchor: View, item: CreatorChannelFanTalkUiModel) {
val ownerMore = item.rightAction as? CreatorChannelFanTalkRightAction.OwnerMore ?: return
morePopup?.dismiss()
morePopup = CreatorChannelFanTalkMorePopup(
anchor = anchor,
fanTalkId = item.fanTalkId,
showEdit = ownerMore.showEdit,
showDelete = ownerMore.showDelete,
onDeleteClick = host::onCreatorChannelFanTalkDeleteClicked
).also { it.show() }
}
interface Host {
fun isCreatorChannelOwner(): Boolean
fun onCreatorChannelFanTalkContentChanged()
fun onCreatorChannelFanTalkDeleteClicked(fanTalkId: Long)
}
companion object {
private const val ARG_CREATOR_ID: String = "arg_creator_id"
fun newInstance(creatorId: Long): CreatorChannelFanTalkFragment {
return CreatorChannelFanTalkFragment().apply {
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
}
}
}
}
private data class CreatorChannelFanTalkContentLayoutKey(
val fanTalkCount: Int,
val fanTalkIds: List<Long>
)
private fun CreatorChannelFanTalkUiState.Content.toContentLayoutKey(): CreatorChannelFanTalkContentLayoutKey {
return CreatorChannelFanTalkContentLayoutKey(
fanTalkCount = fanTalkCount,
fanTalkIds = fanTalks.map { it.fanTalkId }
)
}
@@ -0,0 +1,228 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.toFanTalkUiModels
class CreatorChannelFanTalkViewModel(
private val repository: CreatorChannelRepository,
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) : BaseViewModel() {
private val _fanTalkStateLiveData = MutableLiveData<CreatorChannelFanTalkUiState>()
val fanTalkStateLiveData: LiveData<CreatorChannelFanTalkUiState>
get() = _fanTalkStateLiveData
private var creatorId: Long = 0L
private var isOwner: Boolean = false
private var requestGeneration: Int = 0
fun loadFanTalks(creatorId: Long, isOwner: Boolean) {
if (creatorId <= 0) return
val shouldSkipReload = this.creatorId == creatorId && this.isOwner == isOwner && _fanTalkStateLiveData.value != null
if (shouldSkipReload) return
this.creatorId = creatorId
this.isOwner = isOwner
loadFirstPage()
}
fun retryFanTalks() {
if (creatorId <= 0) return
loadFirstPage()
}
fun refreshFanTalks() {
if (creatorId <= 0) return
loadFirstPage()
}
fun loadMore() {
val content = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: return
if (!content.hasNext || content.isLoadingMore || creatorId <= 0) return
val generation = requestGeneration
_fanTalkStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null)
requestFanTalks(page = content.page + 1, generation = generation) { response ->
val data = response.data
val current = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: content
if (response.success && data != null) {
_fanTalkStateLiveData.value = current.copy(
fanTalks = current.fanTalks + data.toFanTalkUiModels(),
page = data.page,
size = data.size,
hasNext = data.hasNext,
isLoadingMore = false
)
} else {
_fanTalkStateLiveData.value = current.copy(
isLoadingMore = false,
paginationErrorMessage = response.message
)
}
}
}
fun consumePaginationErrorMessage() {
val content = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: return
if (content.paginationErrorMessage == null) return
_fanTalkStateLiveData.value = content.copy(paginationErrorMessage = null)
}
fun consumeActionToastMessage() {
val content = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: return
if (content.actionToastMessage == null) return
_fanTalkStateLiveData.value = content.copy(actionToastMessage = null)
}
fun reportFanTalk(fanTalkId: Long, reason: String) {
val content = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: return
compositeDisposable.add(
repository.reportFanTalk(fanTalkId = fanTalkId, reason = reason, token = authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
val current = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: content
val message = it.message.takeUnless { message -> message.isNullOrBlank() }
?: SodaLiveApplicationHolder.get().getString(R.string.character_comment_report_submitted)
_fanTalkStateLiveData.value = current.copy(actionToastMessage = message)
},
{
val current = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: content
_fanTalkStateLiveData.value = current.copy(actionToastMessage = it.message)
}
)
)
}
fun deleteFanTalk(fanTalkId: Long) {
val content = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: return
compositeDisposable.add(
repository.deleteFanTalk(fanTalkId = fanTalkId, token = authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (it.success) {
refreshFanTalks()
} else {
val current = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: content
_fanTalkStateLiveData.value = current.copy(actionToastMessage = it.message)
}
},
{
val current = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content ?: content
_fanTalkStateLiveData.value = current.copy(actionToastMessage = it.message)
}
)
)
}
private fun loadFirstPage() {
val generation = ++requestGeneration
_fanTalkStateLiveData.value = CreatorChannelFanTalkUiState.Loading
requestFanTalks(page = FIRST_PAGE, generation = generation) { response ->
val data = response.data
if (response.success && data != null) {
val fanTalks = data.toFanTalkUiModels()
_fanTalkStateLiveData.value = if (fanTalks.isEmpty() || data.fanTalkCount == 0) {
CreatorChannelFanTalkUiState.Empty(data.fanTalkCount)
} else {
data.toContentState(fanTalks = fanTalks)
}
} else {
_fanTalkStateLiveData.value = CreatorChannelFanTalkUiState.Error(response.message)
}
}
}
private fun requestFanTalks(
page: Int,
generation: Int,
onSuccess: (ApiResponse<CreatorChannelFanTalkTabResponse>) -> Unit
) {
compositeDisposable.add(
repository.getFanTalks(
creatorId = creatorId,
page = page,
size = DEFAULT_PAGE_SIZE,
token = authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (generation == requestGeneration) {
onSuccess(it)
}
},
{
if (generation != requestGeneration) return@subscribe
val current = _fanTalkStateLiveData.value as? CreatorChannelFanTalkUiState.Content
_fanTalkStateLiveData.value = if (current != null && page > FIRST_PAGE) {
current.copy(isLoadingMore = false, paginationErrorMessage = it.message)
} else {
CreatorChannelFanTalkUiState.Error(it.message)
}
}
)
)
}
private fun CreatorChannelFanTalkTabResponse.toContentState(
fanTalks: List<CreatorChannelFanTalkUiModel>
) = CreatorChannelFanTalkUiState.Content(
fanTalkCount = fanTalkCount,
fanTalks = fanTalks,
page = page,
size = size,
hasNext = hasNext
)
private fun CreatorChannelFanTalkTabResponse.toFanTalkUiModels(): List<CreatorChannelFanTalkUiModel> =
fanTalks.toFanTalkUiModels(
relativeTimeTextFormatter = relativeTimeTextFormatter,
isOwner = isOwner,
currentUserId = SharedPreferenceManager.userId
)
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
companion object {
const val DEFAULT_PAGE_SIZE = 20
private const val FIRST_PAGE = 0
}
}
sealed interface CreatorChannelFanTalkUiState {
data object Loading : CreatorChannelFanTalkUiState
data class Empty(val fanTalkCount: Int) : CreatorChannelFanTalkUiState
data class Error(val message: String?) : CreatorChannelFanTalkUiState
data class Content(
val fanTalkCount: Int,
val fanTalks: List<CreatorChannelFanTalkUiModel>,
val page: Int,
val size: Int,
val hasNext: Boolean,
val isLoadingMore: Boolean = false,
val paginationErrorMessage: String? = null,
val actionToastMessage: String? = null
) : CreatorChannelFanTalkUiState
}
@@ -0,0 +1,34 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class CreatorChannelFanTalkTabResponse(
@SerializedName("fanTalkCount") val fanTalkCount: Int,
@SerializedName("fanTalks") val fanTalks: List<CreatorChannelFanTalkResponse>,
@SerializedName("page") val page: Int,
@SerializedName("size") val size: Int,
@SerializedName("hasNext") val hasNext: Boolean
)
@Keep
data class CreatorChannelFanTalkResponse(
@SerializedName("fanTalkId") val fanTalkId: Long,
@SerializedName("writerId") val writerId: Long,
@SerializedName("writerNickname") val writerNickname: String,
@SerializedName("writerProfileImageUrl") val writerProfileImageUrl: String,
@SerializedName("content") val content: String,
@SerializedName("createdAtUtc") val createdAtUtc: String,
@SerializedName("creatorReplies") val creatorReplies: List<CreatorChannelFanTalkReplyResponse>
)
@Keep
data class CreatorChannelFanTalkReplyResponse(
@SerializedName("fanTalkId") val fanTalkId: Long,
@SerializedName("writerId") val writerId: Long,
@SerializedName("writerNickname") val writerNickname: String,
@SerializedName("writerProfileImageUrl") val writerProfileImageUrl: String,
@SerializedName("content") val content: String,
@SerializedName("createdAtUtc") val createdAtUtc: String
)
@@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkReplyResponse
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.data.CreatorChannelFanTalkResponse
fun List<CreatorChannelFanTalkResponse>.toFanTalkUiModels(
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter,
isOwner: Boolean,
currentUserId: Long
): List<CreatorChannelFanTalkUiModel> = map {
it.toFanTalkUiModel(relativeTimeTextFormatter, isOwner, currentUserId)
}
private fun CreatorChannelFanTalkResponse.toFanTalkUiModel(
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter,
isOwner: Boolean,
currentUserId: Long
) = CreatorChannelFanTalkUiModel(
fanTalkId = fanTalkId,
writerId = writerId,
writerNickname = writerNickname,
writerProfileImageUrl = writerProfileImageUrl,
content = content,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
reply = creatorReplies.firstOrNull()?.toReplyUiModel(relativeTimeTextFormatter),
rightAction = toRightAction(isOwner = isOwner, currentUserId = currentUserId)
)
private fun CreatorChannelFanTalkReplyResponse.toReplyUiModel(
relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) = CreatorChannelFanTalkReplyUiModel(
fanTalkId = fanTalkId,
writerId = writerId,
writerNickname = writerNickname,
writerProfileImageUrl = writerProfileImageUrl,
content = content,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc)
)
private fun CreatorChannelFanTalkResponse.toRightAction(
isOwner: Boolean,
currentUserId: Long
): CreatorChannelFanTalkRightAction = when {
writerId == currentUserId -> CreatorChannelFanTalkRightAction.OwnerMore(showEdit = true, showDelete = true)
isOwner -> CreatorChannelFanTalkRightAction.OwnerMore(showEdit = false, showDelete = true)
else -> CreatorChannelFanTalkRightAction.Report
}
@@ -0,0 +1,29 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model
data class CreatorChannelFanTalkUiModel(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtText: String,
val reply: CreatorChannelFanTalkReplyUiModel?,
val rightAction: CreatorChannelFanTalkRightAction
)
data class CreatorChannelFanTalkReplyUiModel(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtText: String
)
sealed interface CreatorChannelFanTalkRightAction {
data object Report : CreatorChannelFanTalkRightAction
data class OwnerMore(
val showEdit: Boolean,
val showDelete: Boolean
) : CreatorChannelFanTalkRightAction
}
@@ -0,0 +1,95 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelFantalkBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkReplyUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkRightAction
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.model.CreatorChannelFanTalkUiModel
class CreatorChannelFanTalkAdapter(
private val onOwnerMoreClick: (View, CreatorChannelFanTalkUiModel) -> Unit = { _, _ -> },
private val onReportClick: (CreatorChannelFanTalkUiModel) -> Unit = { }
) : RecyclerView.Adapter<CreatorChannelFanTalkAdapter.ViewHolder>() {
private var items: List<CreatorChannelFanTalkUiModel> = emptyList()
fun submitItems(items: List<CreatorChannelFanTalkUiModel>) {
this.items = items
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemCreatorChannelFantalkBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onOwnerMoreClick,
onReportClick
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
class ViewHolder(
private val binding: ItemCreatorChannelFantalkBinding,
private val onOwnerMoreClick: (View, CreatorChannelFanTalkUiModel) -> Unit,
private val onReportClick: (CreatorChannelFanTalkUiModel) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CreatorChannelFanTalkUiModel) = with(binding) {
ivCreatorChannelFantalkProfile.loadProfile(item.writerProfileImageUrl)
tvCreatorChannelFantalkNickname.text = item.writerNickname
tvCreatorChannelFantalkTime.text = item.createdAtText
tvCreatorChannelFantalkContent.text = item.content
bindRightAction(item)
bindReply(item.reply)
}
private fun ItemCreatorChannelFantalkBinding.bindRightAction(item: CreatorChannelFanTalkUiModel) {
when (item.rightAction) {
CreatorChannelFanTalkRightAction.Report -> {
tvCreatorChannelFantalkReport.isVisible = true
tvCreatorChannelFantalkReport.setOnClickListener { onReportClick(item) }
ivCreatorChannelFantalkMore.isVisible = false
ivCreatorChannelFantalkMore.setOnClickListener(null)
}
is CreatorChannelFanTalkRightAction.OwnerMore -> {
tvCreatorChannelFantalkReport.isVisible = false
tvCreatorChannelFantalkReport.setOnClickListener(null)
ivCreatorChannelFantalkMore.isVisible = true
ivCreatorChannelFantalkMore.setOnClickListener { onOwnerMoreClick(it, item) }
}
}
}
private fun ItemCreatorChannelFantalkBinding.bindReply(reply: CreatorChannelFanTalkReplyUiModel?) {
val hasReply = reply != null
viewCreatorChannelFantalkReplyConnector.isVisible = hasReply
layoutCreatorChannelFantalkReply.isVisible = hasReply
if (reply == null) return
ivCreatorChannelFantalkReplyProfile.loadProfile(reply.writerProfileImageUrl)
tvCreatorChannelFantalkReplyNickname.text = reply.writerNickname
tvCreatorChannelFantalkReplyTime.text = reply.createdAtText
tvCreatorChannelFantalkReplyContent.text = reply.content
}
private fun android.widget.ImageView.loadProfile(url: String) {
loadUrl(url) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
}
}
}
@@ -0,0 +1,49 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.ui
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.isVisible
import kr.co.vividnext.sodalive.databinding.ViewCreatorChannelFantalkMorePopupBinding
class CreatorChannelFanTalkMorePopup(
private val anchor: View,
private val fanTalkId: Long,
private val showEdit: Boolean,
private val showDelete: Boolean,
private val onDeleteClick: (Long) -> Unit
) {
private val binding = ViewCreatorChannelFantalkMorePopupBinding.inflate(LayoutInflater.from(anchor.context))
private val popupWindow: PopupWindow = PopupWindow(anchor.context).apply {
contentView = binding.root
width = ViewGroup.LayoutParams.WRAP_CONTENT
height = ViewGroup.LayoutParams.WRAP_CONTENT
isOutsideTouchable = true
isFocusable = true
setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
}
init {
binding.tvCreatorChannelFantalkMoreEdit.isVisible = showEdit
binding.tvCreatorChannelFantalkMoreDelete.isVisible = showDelete
binding.tvCreatorChannelFantalkMoreEdit.setOnClickListener {
dismiss()
}
binding.tvCreatorChannelFantalkMoreDelete.setOnClickListener {
dismiss()
onDeleteClick(fanTalkId)
}
}
fun show() {
popupWindow.showAsDropDown(anchor)
}
fun dismiss() {
popupWindow.dismiss()
}
}
@@ -0,0 +1,221 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelLiveBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
import kr.co.vividnext.sodalive.v2.creator.channel.live.model.toReplayUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelAudioContentAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.model.toLabelResId
import kr.co.vividnext.sodalive.v2.creator.channel.ui.formatCreatorChannelLiveDateTime
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelSortPopup
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelLiveFragment : BaseFragment<FragmentCreatorChannelLiveBinding>(
FragmentCreatorChannelLiveBinding::inflate
) {
private val viewModel: CreatorChannelLiveViewModel by viewModel()
private val replayAdapter = CreatorChannelAudioContentAdapter { item ->
host.onCreatorChannelLiveReplayClicked(item.audioContentId)
}
private var lastContentLayoutKey: CreatorChannelLiveContentLayoutKey? = null
private var sortPopup: CreatorChannelSortPopup? = null
private var currentContentState: CreatorChannelLiveUiState.Content? = null
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
private val host: Host
get() = requireActivity() as Host
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindLoading()
setupReplayList()
setupClickListeners()
observeViewModel()
}
fun onCreatorChannelLiveTabSelected() {
if (creatorId > 0L) {
viewModel.loadLive(creatorId)
}
}
override fun onDestroyView() {
sortPopup?.dismiss()
sortPopup = null
currentContentState = null
binding.rvCreatorChannelLiveReplays.adapter = null
super.onDestroyView()
}
private fun setupReplayList() = with(binding.rvCreatorChannelLiveReplays) {
layoutManager = LinearLayoutManager(requireContext())
adapter = replayAdapter
}
fun onCreatorChannelLiveScrolledToBottom() {
viewModel.loadMore()
}
@Suppress("UNUSED_PARAMETER")
fun onCreatorChannelLiveViewportHeightChanged(minHeight: Int) = Unit
fun onCreatorChannelLiveOwnerCtaVisibilityChanged(isVisible: Boolean) = with(binding) {
val bottomPadding = if (isVisible) {
LIVE_OWNER_CTA_LIST_BOTTOM_PADDING_DP.dpToPx().toInt()
} else {
DEFAULT_LIST_BOTTOM_PADDING_DP.dpToPx().toInt()
}
rvCreatorChannelLiveReplays.updatePadding(bottom = bottomPadding)
}
private fun setupClickListeners() {
binding.ivCreatorChannelLiveSort.setImageResource(R.drawable.ic_new_sort)
binding.layoutCreatorChannelLiveSortButton.setOnClickListener {
currentContentState?.let { state -> showSortPopup(state) }
}
binding.btnCreatorChannelLiveRetry.setOnClickListener {
viewModel.retryLive()
}
}
private fun observeViewModel() {
viewModel.liveStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) {
CreatorChannelLiveUiState.Loading -> bindLoading()
CreatorChannelLiveUiState.Empty -> bindEmpty()
is CreatorChannelLiveUiState.Error -> bindError(state)
is CreatorChannelLiveUiState.Content -> bindContent(state)
}
}
}
private fun bindLoading() = with(binding) {
currentContentState = null
lastContentLayoutKey = null
layoutCreatorChannelLiveSortBar.isVisible = false
layoutCreatorChannelLiveCurrentCard.isVisible = false
rvCreatorChannelLiveReplays.isVisible = false
layoutCreatorChannelLiveEmpty.isVisible = false
tvCreatorChannelLiveErrorMessage.isVisible = false
btnCreatorChannelLiveRetry.isVisible = false
}
private fun bindEmpty() = with(binding) {
currentContentState = null
lastContentLayoutKey = null
layoutCreatorChannelLiveSortBar.isVisible = false
layoutCreatorChannelLiveCurrentCard.isVisible = false
rvCreatorChannelLiveReplays.isVisible = false
layoutCreatorChannelLiveEmpty.isVisible = true
tvCreatorChannelLiveErrorMessage.isVisible = false
btnCreatorChannelLiveRetry.isVisible = false
host.onCreatorChannelLiveContentChanged()
}
private fun bindError(state: CreatorChannelLiveUiState.Error) = with(binding) {
currentContentState = null
lastContentLayoutKey = null
layoutCreatorChannelLiveSortBar.isVisible = false
layoutCreatorChannelLiveCurrentCard.isVisible = false
rvCreatorChannelLiveReplays.isVisible = false
layoutCreatorChannelLiveEmpty.isVisible = false
tvCreatorChannelLiveErrorMessage.isVisible = true
tvCreatorChannelLiveErrorMessage.text = state.message ?: getString(R.string.creator_channel_live_error_message)
btnCreatorChannelLiveRetry.isVisible = true
host.onCreatorChannelLiveContentChanged()
}
private fun bindContent(state: CreatorChannelLiveUiState.Content) = with(binding) {
currentContentState = state
layoutCreatorChannelLiveEmpty.isVisible = false
tvCreatorChannelLiveErrorMessage.isVisible = false
btnCreatorChannelLiveRetry.isVisible = false
layoutCreatorChannelLiveSortBar.isVisible = true
tvCreatorChannelLiveTotalCount.text = state.liveReplayContentCount.moneyFormat()
tvCreatorChannelLiveSortLabel.setText(state.selectedSort.toLabelResId())
bindCurrentLive(state.currentLive)
rvCreatorChannelLiveReplays.isVisible = true
replayAdapter.submitItems(state.liveReplayContents.map { it.toReplayUiModel() })
notifyContentChangedIfLayoutChanged(state)
state.paginationErrorMessage?.let {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
viewModel.consumePaginationErrorMessage()
}
}
private fun showSortPopup(state: CreatorChannelLiveUiState.Content) {
sortPopup?.dismiss()
sortPopup = CreatorChannelSortPopup(
anchor = binding.layoutCreatorChannelLiveSortButton,
selectedSort = state.selectedSort,
onSortSelected = { sort -> viewModel.changeSort(sort) }
).also { it.show() }
}
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelLiveUiState.Content) {
val contentLayoutKey = state.toContentLayoutKey()
if (contentLayoutKey == lastContentLayoutKey) return
lastContentLayoutKey = contentLayoutKey
host.onCreatorChannelLiveContentChanged()
}
private fun bindCurrentLive(live: CreatorChannelLiveResponse?) = with(binding) {
layoutCreatorChannelLiveCurrentCard.isVisible = live != null
if (live == null) return@with
tvCreatorChannelLiveCurrentTitle.text = live.title
tvCreatorChannelLiveCurrentTime.text = formatCreatorChannelLiveDateTime(live.beginDateTimeUtc)
tvCreatorChannelLiveCurrentPrice.text = if (live.price > 0) {
live.price.moneyFormat()
} else {
getString(R.string.audio_content_tag_free)
}
layoutCreatorChannelLiveCurrentPrice.isVisible = true
ivCreatorChannelLiveCurrentPriceCash.isVisible = live.price > 0
layoutCreatorChannelLiveCurrentCard.setOnClickListener {
host.onCreatorChannelCurrentLiveClicked(live)
}
}
interface Host {
fun onCreatorChannelCurrentLiveClicked(live: CreatorChannelLiveResponse)
fun onCreatorChannelLiveReplayClicked(audioContentId: Long)
fun onCreatorChannelLiveContentChanged()
}
companion object {
private const val ARG_CREATOR_ID: String = "arg_creator_id"
private const val DEFAULT_LIST_BOTTOM_PADDING_DP = 32
private const val LIVE_OWNER_CTA_LIST_BOTTOM_PADDING_DP = 132
fun newInstance(creatorId: Long): CreatorChannelLiveFragment {
return CreatorChannelLiveFragment().apply {
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
}
}
}
}
private data class CreatorChannelLiveContentLayoutKey(
val liveReplayContentCount: Int,
val currentLive: CreatorChannelLiveResponse?,
val liveReplayContentIds: List<Long>
)
private fun CreatorChannelLiveUiState.Content.toContentLayoutKey(): CreatorChannelLiveContentLayoutKey {
return CreatorChannelLiveContentLayoutKey(
liveReplayContentCount = liveReplayContentCount,
currentLive = currentLive,
liveReplayContentIds = liveReplayContents.map { it.audioContentId }
)
}
@@ -0,0 +1,175 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.live.data.CreatorChannelLiveTabResponse
class CreatorChannelLiveViewModel(
private val repository: CreatorChannelRepository
) : BaseViewModel() {
private val _liveStateLiveData = MutableLiveData<CreatorChannelLiveUiState>()
val liveStateLiveData: LiveData<CreatorChannelLiveUiState>
get() = _liveStateLiveData
private var creatorId: Long = 0L
private var selectedSort: ContentSort = ContentSort.LATEST
private var requestGeneration: Int = 0
fun loadLive(creatorId: Long) {
if (creatorId <= 0) return
if (this.creatorId == creatorId && _liveStateLiveData.value != null) return
this.creatorId = creatorId
loadFirstPage(selectedSort)
}
fun changeSort(sort: ContentSort) {
if (sort == selectedSort) return
if (creatorId <= 0) return
selectedSort = sort
loadFirstPage(sort)
}
fun retryLive() {
if (creatorId <= 0) return
loadFirstPage(selectedSort)
}
fun loadMore() {
val content = _liveStateLiveData.value as? CreatorChannelLiveUiState.Content ?: return
if (!content.hasNext || content.isLoadingMore || creatorId <= 0) return
val generation = requestGeneration
_liveStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null)
requestLive(page = content.page + 1, sort = content.selectedSort, generation = generation) { response ->
val data = response.data
val current = _liveStateLiveData.value as? CreatorChannelLiveUiState.Content ?: content
if (response.success && data != null) {
_liveStateLiveData.value = current.copy(
liveReplayContents = current.liveReplayContents + data.liveReplayContents,
page = data.page,
size = data.size,
hasNext = data.hasNext,
isLoadingMore = false
)
} else {
_liveStateLiveData.value = current.copy(
isLoadingMore = false,
paginationErrorMessage = response.message
)
}
}
}
fun consumePaginationErrorMessage() {
val content = _liveStateLiveData.value as? CreatorChannelLiveUiState.Content ?: return
if (content.paginationErrorMessage == null) return
_liveStateLiveData.value = content.copy(paginationErrorMessage = null)
}
private fun loadFirstPage(sort: ContentSort) {
val generation = ++requestGeneration
_liveStateLiveData.value = CreatorChannelLiveUiState.Loading
requestLive(page = FIRST_PAGE, sort = sort, generation = generation) { response ->
val data = response.data
if (response.success && data != null) {
_liveStateLiveData.value = if (data.currentLive == null && data.liveReplayContents.isEmpty()) {
CreatorChannelLiveUiState.Empty
} else {
data.toContentState(liveReplayContents = data.liveReplayContents)
}
} else {
_liveStateLiveData.value = CreatorChannelLiveUiState.Error(response.message)
}
}
}
private fun requestLive(
page: Int,
sort: ContentSort,
generation: Int,
onSuccess: (ApiResponse<CreatorChannelLiveTabResponse>) -> Unit
) {
compositeDisposable.add(
repository.getLive(
creatorId = creatorId,
page = page,
size = DEFAULT_PAGE_SIZE,
sort = sort,
token = authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (generation == requestGeneration) {
onSuccess(it)
}
},
{
if (generation != requestGeneration) return@subscribe
it.message?.let { message -> Logger.e(message) }
val current = _liveStateLiveData.value as? CreatorChannelLiveUiState.Content
_liveStateLiveData.value = if (current != null && page > FIRST_PAGE) {
current.copy(isLoadingMore = false, paginationErrorMessage = it.message)
} else {
CreatorChannelLiveUiState.Error(it.message)
}
}
)
)
}
private fun CreatorChannelLiveTabResponse.toContentState(
liveReplayContents: List<CreatorChannelAudioContentResponse>,
isLoadingMore: Boolean = false
) = CreatorChannelLiveUiState.Content(
liveReplayContentCount = liveReplayContentCount,
currentLive = currentLive,
liveReplayContents = liveReplayContents,
selectedSort = sort,
page = page,
size = size,
hasNext = hasNext,
isLoadingMore = isLoadingMore
)
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
companion object {
val DEFAULT_PAGE_SIZE = 20
private const val FIRST_PAGE = 0
}
}
sealed interface CreatorChannelLiveUiState {
data object Loading : CreatorChannelLiveUiState
data object Empty : CreatorChannelLiveUiState
data class Error(val message: String?) : CreatorChannelLiveUiState
data class Content(
val liveReplayContentCount: Int,
val currentLive: CreatorChannelLiveResponse?,
val liveReplayContents: List<CreatorChannelAudioContentResponse>,
val selectedSort: ContentSort,
val page: Int,
val size: Int,
val hasNext: Boolean,
val isLoadingMore: Boolean = false,
val paginationErrorMessage: String? = null
) : CreatorChannelLiveUiState
}
@@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.data
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
@Keep
data class CreatorChannelLiveTabResponse(
@SerializedName("liveReplayContentCount") val liveReplayContentCount: Int,
@SerializedName("currentLive") val currentLive: CreatorChannelLiveResponse?,
@SerializedName("liveReplayContents") val liveReplayContents: List<CreatorChannelAudioContentResponse>,
@SerializedName("sort") val sort: ContentSort,
@SerializedName("page") val page: Int,
@SerializedName("size") val size: Int,
@SerializedName("hasNext") val hasNext: Boolean
)
@@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.model
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelAudioContentStatus
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelAudioContentUiModel
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
fun CreatorChannelAudioContentResponse.toReplayUiModel(): CreatorChannelAudioContentUiModel =
CreatorChannelAudioContentUiModel(
audioContentId = audioContentId,
title = title,
secondaryText = duration,
imageUrl = imageUrl,
price = price,
showAdultBadge = isAdult,
tags = toAudioContentTags(),
status = toReplayStatus()
)
private fun CreatorChannelAudioContentResponse.toAudioContentTags(): Set<AudioContentTag> = buildSet {
if (isOriginalSeries == true) add(AudioContentTag.Original)
if (isFirstContent) add(AudioContentTag.First)
if (isPointAvailable) add(AudioContentTag.Point)
if (price == 0) add(AudioContentTag.Free)
}
private fun CreatorChannelAudioContentResponse.toReplayStatus(): CreatorChannelAudioContentStatus = when {
isOwned -> CreatorChannelAudioContentStatus.Owned
isRented -> CreatorChannelAudioContentStatus.Rented
price == 0 -> CreatorChannelAudioContentStatus.Play
else -> CreatorChannelAudioContentStatus.Price(price)
}
@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.v2.creator.channel.model
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
data class CreatorChannelAudioContentUiModel(
val audioContentId: Long,
val title: String,
val secondaryText: String?,
val imageUrl: String?,
val price: Int,
val showAdultBadge: Boolean,
val tags: Set<AudioContentTag>,
val status: CreatorChannelAudioContentStatus
)
sealed interface CreatorChannelAudioContentStatus {
data object Play : CreatorChannelAudioContentStatus
data object Owned : CreatorChannelAudioContentStatus
data object Rented : CreatorChannelAudioContentStatus
data class Price(val price: Int) : CreatorChannelAudioContentStatus
}
@@ -0,0 +1,67 @@
package kr.co.vividnext.sodalive.v2.creator.channel.model
import androidx.annotation.DrawableRes
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelHomeResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSnsResponse
import java.net.URI
fun CreatorChannelHomeResponse.toUiContent(currentMemberId: Long): CreatorChannelHomeUiState.Content {
val isOwner = creator.creatorId == currentMemberId
val sections = buildList {
currentLive?.let { add(CreatorChannelHomeSection.CurrentLive(it)) }
latestAudioContent?.let { add(CreatorChannelHomeSection.LatestAudioContent(it)) }
add(CreatorChannelHomeSection.Donations(donations = channelDonations, isOwner = isOwner))
notices.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Notices(it)) }
schedules.sortedBy { it.scheduledAtUtc }.take(MAX_SCHEDULE_ITEM_COUNT)
.takeIf { it.isNotEmpty() }
?.let { add(CreatorChannelHomeSection.Schedules(it)) }
audioContents.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.AudioContents(it)) }
series.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Series(it)) }
communities.takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Communities(it)) }
add(CreatorChannelHomeSection.FanTalk(fanTalk))
introduce.takeIf { it.isNotBlank() }?.let { add(CreatorChannelHomeSection.Introduce(it)) }
add(CreatorChannelHomeSection.Activity(activity))
sns.toUiItems().takeIf { it.isNotEmpty() }?.let { add(CreatorChannelHomeSection.Sns(it)) }
}
return CreatorChannelHomeUiState.Content(
header = CreatorChannelHeaderUiModel(
creatorId = creator.creatorId,
characterId = creator.characterId,
nickname = creator.nickname,
profileImageUrl = creator.profileImageUrl,
followerCount = creator.followerCount,
isFollow = creator.isFollow,
isNotify = creator.isNotify,
isAiChatAvailable = creator.isAiChatAvailable,
isDmAvailable = creator.isDmAvailable,
isOwner = isOwner
),
tabs = CreatorChannelTab.entries,
sections = sections
)
}
private fun CreatorChannelSnsResponse.toUiItems(): List<CreatorChannelSnsUiItem> = buildList {
instagramUrl.toSnsItem(R.drawable.ic_sns_instagram, "Instagram")?.let(::add)
youtubeUrl.toSnsItem(R.drawable.ic_sns_youtube, "YouTube")?.let(::add)
xUrl.toSnsItem(R.drawable.ic_sns_x, "X")?.let(::add)
kakaoOpenChatUrl.toSnsItem(R.drawable.ic_sns_kakao, "Kakao Open Chat")?.let(::add)
fancimmUrl.toSnsItem(R.drawable.ic_sns_fancimm, "Fancimm")?.let(::add)
}
private fun String.toSnsItem(
@DrawableRes iconResId: Int,
label: String
): CreatorChannelSnsUiItem? = trim().takeIf(::isValidCreatorChannelSnsUrl)?.let {
CreatorChannelSnsUiItem(iconResId = iconResId, label = label, url = it)
}
internal fun isValidCreatorChannelSnsUrl(url: String): Boolean {
val uri = runCatching { URI(url) }.getOrNull() ?: return false
val scheme = uri.scheme?.lowercase() ?: return false
return scheme in setOf("http", "https") && !uri.host.isNullOrBlank()
}
private const val MAX_SCHEDULE_ITEM_COUNT = 3
@@ -0,0 +1,71 @@
package kr.co.vividnext.sodalive.v2.creator.channel.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelActivityResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelCommunityPostResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelDonationResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelFanTalkSummaryResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse
sealed interface CreatorChannelHomeUiState {
data object Loading : CreatorChannelHomeUiState
data object Empty : CreatorChannelHomeUiState
data class Error(val message: String?) : CreatorChannelHomeUiState
data class Content(
val header: CreatorChannelHeaderUiModel,
val tabs: List<CreatorChannelTab>,
val sections: List<CreatorChannelHomeSection>
) : CreatorChannelHomeUiState
}
enum class CreatorChannelTab(@StringRes val labelResId: Int) {
Home(R.string.creator_channel_tab_home),
Live(R.string.creator_channel_tab_live),
Audio(R.string.creator_channel_tab_audio),
Series(R.string.creator_channel_tab_series),
Community(R.string.creator_channel_tab_community),
FanTalk(R.string.creator_channel_tab_fantalk),
Donation(R.string.creator_channel_tab_donation)
}
data class CreatorChannelHeaderUiModel(
val creatorId: Long,
val characterId: Long?,
val nickname: String,
val profileImageUrl: String,
val followerCount: Int,
val isFollow: Boolean,
val isNotify: Boolean,
val isAiChatAvailable: Boolean,
val isDmAvailable: Boolean,
val isOwner: Boolean
)
sealed interface CreatorChannelHomeSection {
data class CurrentLive(val live: CreatorChannelLiveResponse) : CreatorChannelHomeSection
data class LatestAudioContent(val audioContent: CreatorChannelAudioContentResponse) : CreatorChannelHomeSection
data class Donations(
val donations: List<CreatorChannelDonationResponse>,
val isOwner: Boolean
) : CreatorChannelHomeSection
data class Notices(val notices: List<CreatorChannelCommunityPostResponse>) : CreatorChannelHomeSection
data class Schedules(val schedules: List<CreatorChannelScheduleResponse>) : CreatorChannelHomeSection
data class AudioContents(val audioContents: List<CreatorChannelAudioContentResponse>) : CreatorChannelHomeSection
data class Series(val series: List<CreatorChannelSeriesResponse>) : CreatorChannelHomeSection
data class Communities(val communities: List<CreatorChannelCommunityPostResponse>) : CreatorChannelHomeSection
data class FanTalk(val fanTalk: CreatorChannelFanTalkSummaryResponse) : CreatorChannelHomeSection
data class Introduce(val introduce: String) : CreatorChannelHomeSection
data class Activity(val activity: CreatorChannelActivityResponse) : CreatorChannelHomeSection
data class Sns(val items: List<CreatorChannelSnsUiItem>) : CreatorChannelHomeSection
}
data class CreatorChannelSnsUiItem(
@DrawableRes val iconResId: Int,
val label: String,
val url: String
)
@@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.v2.creator.channel.model
object CreatorChannelScrollState {
fun shouldUseBlackTitleBar(
titleBarBottom: Int,
tabBarTop: Int,
profileImageVisibleHeight: Int,
profileImageTotalHeight: Int
): Boolean {
val isTabBarCloseToTitleBar = tabBarTop <= titleBarBottom
val isProfileImageHalfHidden = profileImageVisibleHeight <= profileImageTotalHeight / 2
return isTabBarCloseToTitleBar && isProfileImageHalfHidden
}
fun calculateStickyTop(statusBarHeight: Int, titleBarHeight: Int): Int {
return statusBarHeight + titleBarHeight
}
}
@@ -0,0 +1,27 @@
package kr.co.vividnext.sodalive.v2.creator.channel.model
import androidx.annotation.StringRes
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
data class CreatorChannelSortOptionUiModel(
val sort: ContentSort,
@param:StringRes val labelResId: Int,
val isSelected: Boolean
)
@StringRes
fun ContentSort.toLabelResId(): Int = when (this) {
ContentSort.LATEST -> R.string.screen_audio_content_sort_newest
ContentSort.POPULAR -> R.string.screen_audio_content_sort_popularity
ContentSort.OWNED -> R.string.creator_channel_live_sort_owned
ContentSort.PRICE_HIGH -> R.string.screen_audio_content_sort_price_high
ContentSort.PRICE_LOW -> R.string.screen_audio_content_sort_price_low
}
fun ContentSort.toSortOptionUiModel(selectedSort: ContentSort): CreatorChannelSortOptionUiModel =
CreatorChannelSortOptionUiModel(
sort = this,
labelResId = toLabelResId(),
isSelected = this == selectedSort
)
@@ -0,0 +1,26 @@
package kr.co.vividnext.sodalive.v2.creator.channel.model
import androidx.annotation.DrawableRes
import kr.co.vividnext.sodalive.R
data class CreatorChannelTitleBarState(
@DrawableRes val followIconResId: Int,
@DrawableRes val bellIconResId: Int?,
val isActionEnabled: Boolean
) {
companion object {
fun from(
isFollow: Boolean,
isNotify: Boolean,
isInProgress: Boolean
): CreatorChannelTitleBarState = CreatorChannelTitleBarState(
followIconResId = if (isFollow) R.drawable.ic_new_following else R.drawable.ic_new_follow,
bellIconResId = when {
!isFollow -> null
isNotify -> R.drawable.ic_bar_bell_colored
else -> R.drawable.ic_bar_bell
},
isActionEnabled = !isInProgress
)
}
}
@@ -0,0 +1,182 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelSeriesBinding
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.model.toLabelResId
import kr.co.vividnext.sodalive.v2.creator.channel.series.ui.CreatorChannelSeriesAdapter
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelSortPopup
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelSeriesFragment : BaseFragment<FragmentCreatorChannelSeriesBinding>(
FragmentCreatorChannelSeriesBinding::inflate
) {
private val viewModel: CreatorChannelSeriesViewModel by viewModel()
private val seriesAdapter = CreatorChannelSeriesAdapter { seriesId ->
host.onCreatorChannelSeriesClicked(seriesId)
}
private var sortPopup: CreatorChannelSortPopup? = null
private var currentContentState: CreatorChannelSeriesUiState.Content? = null
private var lastContentLayoutKey: CreatorChannelSeriesContentLayoutKey? = null
private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L }
private val host: Host
get() = requireActivity() as Host
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindLoading()
setupSeriesList()
setupClickListeners()
observeViewModel()
}
override fun onDestroyView() {
sortPopup?.dismiss()
sortPopup = null
currentContentState = null
lastContentLayoutKey = null
binding.rvCreatorChannelSeries.adapter = null
super.onDestroyView()
}
private fun setupSeriesList() = with(binding.rvCreatorChannelSeries) {
layoutManager = LinearLayoutManager(requireContext())
adapter = seriesAdapter
}
private fun setupClickListeners() = with(binding) {
ivCreatorChannelSeriesSort.setImageResource(R.drawable.ic_new_sort)
layoutCreatorChannelSeriesSortButton.setOnClickListener {
currentContentState?.let { state -> showSortPopup(state) }
}
btnCreatorChannelSeriesRetry.setOnClickListener {
viewModel.retrySeries()
}
}
private fun observeViewModel() {
viewModel.seriesStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) {
CreatorChannelSeriesUiState.Loading -> bindLoading()
CreatorChannelSeriesUiState.Empty -> bindEmpty()
is CreatorChannelSeriesUiState.Error -> bindError(state)
is CreatorChannelSeriesUiState.Content -> bindContent(state)
}
}
}
fun onCreatorChannelSeriesTabSelected() {
if (creatorId > 0L) {
viewModel.loadSeries(creatorId, isOwner = host.isCreatorChannelOwner())
}
}
fun onCreatorChannelSeriesScrolledToBottom() {
viewModel.loadMore()
}
@Suppress("UNUSED_PARAMETER")
fun onCreatorChannelSeriesViewportHeightChanged(minHeight: Int) = Unit
private fun bindLoading() = with(binding) {
currentContentState = null
lastContentLayoutKey = null
layoutCreatorChannelSeriesSortBar.isVisible = false
rvCreatorChannelSeries.isVisible = false
layoutCreatorChannelSeriesEmpty.isVisible = false
tvCreatorChannelSeriesErrorMessage.isVisible = false
btnCreatorChannelSeriesRetry.isVisible = false
}
private fun bindEmpty() = with(binding) {
currentContentState = null
lastContentLayoutKey = null
layoutCreatorChannelSeriesSortBar.isVisible = false
rvCreatorChannelSeries.isVisible = false
layoutCreatorChannelSeriesEmpty.isVisible = true
tvCreatorChannelSeriesErrorMessage.isVisible = false
btnCreatorChannelSeriesRetry.isVisible = false
host.onCreatorChannelSeriesContentChanged()
}
private fun bindError(state: CreatorChannelSeriesUiState.Error) = with(binding) {
currentContentState = null
lastContentLayoutKey = null
layoutCreatorChannelSeriesSortBar.isVisible = false
rvCreatorChannelSeries.isVisible = false
layoutCreatorChannelSeriesEmpty.isVisible = false
tvCreatorChannelSeriesErrorMessage.isVisible = true
tvCreatorChannelSeriesErrorMessage.text = state.message ?: getString(R.string.creator_channel_series_error_message)
btnCreatorChannelSeriesRetry.isVisible = true
host.onCreatorChannelSeriesContentChanged()
}
private fun bindContent(state: CreatorChannelSeriesUiState.Content) = with(binding) {
currentContentState = state
layoutCreatorChannelSeriesSortBar.isVisible = true
tvCreatorChannelSeriesTotalCount.text = state.seriesCount.moneyFormat()
tvCreatorChannelSeriesSortLabel.setText(state.selectedSort.toLabelResId())
rvCreatorChannelSeries.isVisible = true
seriesAdapter.submitItems(state.series)
layoutCreatorChannelSeriesEmpty.isVisible = false
tvCreatorChannelSeriesErrorMessage.isVisible = false
btnCreatorChannelSeriesRetry.isVisible = false
notifyContentChangedIfLayoutChanged(state)
state.paginationErrorMessage?.let {
Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()
viewModel.consumePaginationErrorMessage()
}
}
private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelSeriesUiState.Content) {
val contentLayoutKey = state.toContentLayoutKey()
if (contentLayoutKey == lastContentLayoutKey) return
lastContentLayoutKey = contentLayoutKey
host.onCreatorChannelSeriesContentChanged()
}
private fun showSortPopup(state: CreatorChannelSeriesUiState.Content) {
sortPopup?.dismiss()
sortPopup = CreatorChannelSortPopup(
anchor = binding.layoutCreatorChannelSeriesSortButton,
selectedSort = state.selectedSort,
onSortSelected = { sort -> viewModel.changeSort(sort) }
).also { it.show() }
}
interface Host {
fun isCreatorChannelOwner(): Boolean
fun onCreatorChannelSeriesClicked(seriesId: Long)
fun onCreatorChannelSeriesContentChanged()
}
companion object {
private const val ARG_CREATOR_ID: String = "arg_creator_id"
fun newInstance(creatorId: Long): CreatorChannelSeriesFragment {
return CreatorChannelSeriesFragment().apply {
arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) }
}
}
}
}
private data class CreatorChannelSeriesContentLayoutKey(
val seriesCount: Int,
val seriesIds: List<Long>
)
private fun CreatorChannelSeriesUiState.Content.toContentLayoutKey(): CreatorChannelSeriesContentLayoutKey {
return CreatorChannelSeriesContentLayoutKey(
seriesCount = seriesCount,
seriesIds = series.map { it.seriesId }
)
}
@@ -0,0 +1,175 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import kr.co.vividnext.sodalive.v2.creator.channel.series.data.CreatorChannelSeriesTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.series.model.CreatorChannelSeriesItemUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.series.model.toSeriesItemUiModels
class CreatorChannelSeriesViewModel(
private val repository: CreatorChannelRepository
) : BaseViewModel() {
private val _seriesStateLiveData = MutableLiveData<CreatorChannelSeriesUiState>()
val seriesStateLiveData: LiveData<CreatorChannelSeriesUiState>
get() = _seriesStateLiveData
private var creatorId: Long = 0L
private var isOwner: Boolean = false
private var selectedSort: ContentSort = ContentSort.LATEST
private var requestGeneration: Int = 0
fun loadSeries(creatorId: Long, isOwner: Boolean) {
if (creatorId <= 0) return
val shouldSkipReload = this.creatorId == creatorId && this.isOwner == isOwner && _seriesStateLiveData.value != null
if (shouldSkipReload) return
this.creatorId = creatorId
this.isOwner = isOwner
loadFirstPage(selectedSort)
}
fun changeSort(sort: ContentSort) {
if (sort == selectedSort) return
if (creatorId <= 0) return
selectedSort = sort
loadFirstPage(sort)
}
fun retrySeries() {
if (creatorId <= 0) return
loadFirstPage(selectedSort)
}
fun loadMore() {
val content = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content ?: return
if (!content.hasNext || content.isLoadingMore || creatorId <= 0) return
val generation = requestGeneration
_seriesStateLiveData.value = content.copy(isLoadingMore = true, paginationErrorMessage = null)
requestSeries(page = content.page + 1, sort = content.selectedSort, generation = generation) { response ->
val data = response.data
val current = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content ?: content
if (response.success && data != null) {
_seriesStateLiveData.value = current.copy(
series = current.series + data.series.toSeriesItemUiModels(isOwner),
page = data.page,
size = data.size,
hasNext = data.hasNext,
isLoadingMore = false
)
} else {
_seriesStateLiveData.value = current.copy(
isLoadingMore = false,
paginationErrorMessage = response.message
)
}
}
}
fun consumePaginationErrorMessage() {
val content = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content ?: return
if (content.paginationErrorMessage == null) return
_seriesStateLiveData.value = content.copy(paginationErrorMessage = null)
}
private fun loadFirstPage(sort: ContentSort) {
val generation = ++requestGeneration
_seriesStateLiveData.value = CreatorChannelSeriesUiState.Loading
requestSeries(page = FIRST_PAGE, sort = sort, generation = generation) { response ->
val data = response.data
if (response.success && data != null) {
val series = data.series.toSeriesItemUiModels(isOwner)
_seriesStateLiveData.value = if (series.isEmpty() || data.seriesCount == 0) {
CreatorChannelSeriesUiState.Empty
} else {
data.toContentState(series = series)
}
} else {
_seriesStateLiveData.value = CreatorChannelSeriesUiState.Error(response.message)
}
}
}
private fun requestSeries(
page: Int,
sort: ContentSort,
generation: Int,
onSuccess: (ApiResponse<CreatorChannelSeriesTabResponse>) -> Unit
) {
compositeDisposable.add(
repository.getSeries(
creatorId = creatorId,
page = page,
size = DEFAULT_PAGE_SIZE,
sort = sort,
token = authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (generation == requestGeneration) {
onSuccess(it)
}
},
{
if (generation != requestGeneration) return@subscribe
it.message?.let { message -> Logger.e(message) }
val current = _seriesStateLiveData.value as? CreatorChannelSeriesUiState.Content
_seriesStateLiveData.value = if (current != null && page > FIRST_PAGE) {
current.copy(isLoadingMore = false, paginationErrorMessage = it.message)
} else {
CreatorChannelSeriesUiState.Error(it.message)
}
}
)
)
}
private fun CreatorChannelSeriesTabResponse.toContentState(
series: List<CreatorChannelSeriesItemUiModel>
) = CreatorChannelSeriesUiState.Content(
seriesCount = seriesCount,
selectedSort = sort,
series = series,
page = page,
size = size,
hasNext = hasNext
)
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
companion object {
val DEFAULT_PAGE_SIZE = 20
private const val FIRST_PAGE = 0
}
}
sealed interface CreatorChannelSeriesUiState {
data object Loading : CreatorChannelSeriesUiState
data object Empty : CreatorChannelSeriesUiState
data class Error(val message: String?) : CreatorChannelSeriesUiState
data class Content(
val seriesCount: Int,
val selectedSort: ContentSort,
val series: List<CreatorChannelSeriesItemUiModel>,
val page: Int,
val size: Int,
val hasNext: Boolean,
val isLoadingMore: Boolean = false,
val paginationErrorMessage: String? = null
) : CreatorChannelSeriesUiState
}
@@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series.data
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
@Keep
data class CreatorChannelSeriesTabResponse(
@SerializedName("seriesCount") val seriesCount: Int,
@SerializedName("series") val series: List<CreatorChannelSeriesResponse>,
@SerializedName("sort") val sort: ContentSort,
@SerializedName("page") val page: Int,
@SerializedName("size") val size: Int,
@SerializedName("hasNext") val hasNext: Boolean
)
@Keep
data class CreatorChannelSeriesResponse(
@SerializedName("seriesId") val seriesId: Long,
@SerializedName("title") val title: String,
@SerializedName("coverImageUrl") val coverImageUrl: String?,
@SerializedName("publishedDaysOfWeek") val publishedDaysOfWeek: String?,
@SerializedName("contentCount") val contentCount: Int,
@SerializedName("isProceeding") val isProceeding: Boolean,
@SerializedName("isOriginal") val isOriginal: Boolean,
@SerializedName("isAdult") val isAdult: Boolean,
@SerializedName("purchasedContentCount") val purchasedContentCount: Int?,
@SerializedName("paidContentCount") val paidContentCount: Int?,
@SerializedName("purchasedPaidContentRate") val purchasedPaidContentRate: Double?
)
@@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series.model
import kr.co.vividnext.sodalive.v2.creator.channel.series.data.CreatorChannelSeriesResponse
fun List<CreatorChannelSeriesResponse>.toSeriesItemUiModels(
isOwner: Boolean
): List<CreatorChannelSeriesItemUiModel> = mapNotNull { it.toSeriesItemUiModel(isOwner) }
private fun CreatorChannelSeriesResponse.toSeriesItemUiModel(isOwner: Boolean): CreatorChannelSeriesItemUiModel? {
if (title.isBlank()) return null
return CreatorChannelSeriesItemUiModel(
seriesId = seriesId,
title = title,
subtitle = toSubtitleUiModel(),
coverImageUrl = coverImageUrl,
showOriginalTag = isOriginal,
showAdultBadge = isAdult,
progress = toProgressUiModel(isOwner)
)
}
private fun CreatorChannelSeriesResponse.toSubtitleUiModel(): CreatorChannelSeriesSubtitleUiModel {
return CreatorChannelSeriesSubtitleUiModel(
publishedDaysOfWeek = publishedDaysOfWeek?.takeIf { it.isNotBlank() },
contentCount = contentCount,
isProceeding = isProceeding
)
}
private fun CreatorChannelSeriesResponse.toProgressUiModel(isOwner: Boolean): CreatorChannelSeriesProgressUiModel? {
if (isOwner) return null
val purchasedCount = purchasedContentCount ?: return null
val paidCount = paidContentCount ?: return null
val ratePercent = purchasedPaidContentRate ?: return null
return CreatorChannelSeriesProgressUiModel(
purchasedCount = purchasedCount,
paidCount = paidCount,
ratePercent = ratePercent,
progressScale = (ratePercent / 100f).toFloat().coerceIn(0f, 1f)
)
}
@@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series.model
data class CreatorChannelSeriesItemUiModel(
val seriesId: Long,
val title: String,
val subtitle: CreatorChannelSeriesSubtitleUiModel,
val coverImageUrl: String?,
val showOriginalTag: Boolean,
val showAdultBadge: Boolean,
val progress: CreatorChannelSeriesProgressUiModel?
)
data class CreatorChannelSeriesSubtitleUiModel(
val publishedDaysOfWeek: String?,
val contentCount: Int,
val isProceeding: Boolean
)
data class CreatorChannelSeriesProgressUiModel(
val purchasedCount: Int,
val paidCount: Int,
val ratePercent: Double,
val progressScale: Float
)
@@ -0,0 +1,110 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series.ui
import android.graphics.Outline
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelSeriesBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.series.model.CreatorChannelSeriesItemUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.series.model.CreatorChannelSeriesSubtitleUiModel
class CreatorChannelSeriesAdapter(
private val onSeriesClicked: (Long) -> Unit = {}
) : RecyclerView.Adapter<CreatorChannelSeriesAdapter.ViewHolder>() {
private var items: List<CreatorChannelSeriesItemUiModel> = emptyList()
fun submitItems(items: List<CreatorChannelSeriesItemUiModel>) {
this.items = items
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemCreatorChannelSeriesBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onSeriesClicked
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
class ViewHolder(
private val binding: ItemCreatorChannelSeriesBinding,
private val onSeriesClicked: (Long) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.layoutCreatorChannelSeriesThumbnail.clipToOutline = true
binding.layoutCreatorChannelSeriesThumbnail.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(
0,
0,
view.width,
view.height,
view.resources.getDimension(R.dimen.radius_14)
)
}
}
}
fun bind(item: CreatorChannelSeriesItemUiModel) = with(binding) {
ivCreatorChannelSeriesThumbnail.loadUrl(item.coverImageUrl)
layoutCreatorChannelSeriesOriginalTag.isVisible = item.showOriginalTag
ivCreatorChannelSeriesAdultBadge.isVisible = item.showAdultBadge
tvCreatorChannelSeriesTitle.text = item.title
tvCreatorChannelSeriesSubtitle.text = formatSubtitle(item.subtitle)
bindProgress(item)
root.setOnClickListener { onSeriesClicked(item.seriesId) }
}
private fun formatSubtitle(subtitle: CreatorChannelSeriesSubtitleUiModel): String {
return listOfNotNull(
subtitle.publishedDaysOfWeek,
binding.root.context.getString(
R.string.creator_channel_series_subtitle_content_count,
subtitle.contentCount.moneyFormat()
),
binding.root.context.getString(
if (subtitle.isProceeding) {
R.string.creator_channel_series_status_proceeding
} else {
R.string.creator_channel_series_status_completed
}
)
).joinToString(BULLET_SEPARATOR)
}
private fun bindProgress(item: CreatorChannelSeriesItemUiModel) = with(binding) {
val progress = item.progress
layoutCreatorChannelSeriesProgress.isVisible = progress != null
if (progress == null) return@with
tvCreatorChannelSeriesProgressCount.text = root.context.getString(
R.string.creator_channel_series_progress_count,
progress.purchasedCount.moneyFormat(),
progress.paidCount.moneyFormat()
)
tvCreatorChannelSeriesProgressPercent.text = root.context.getString(
R.string.creator_channel_series_progress_percent,
progress.ratePercent.toInt().moneyFormat()
)
viewCreatorChannelSeriesProgressFill.pivotX = 0f
viewCreatorChannelSeriesProgressFill.scaleX = progress.progressScale
}
}
companion object {
private const val BULLET_SEPARATOR = " • "
}
}
@@ -0,0 +1,107 @@
package kr.co.vividnext.sodalive.v2.creator.channel.ui
import android.graphics.Outline
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelAudioContentBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelAudioContentStatus
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelAudioContentUiModel
import kr.co.vividnext.sodalive.v2.widget.AudioContentTag
class CreatorChannelAudioContentAdapter(
private val onAudioContentClick: (CreatorChannelAudioContentUiModel) -> Unit = {}
) : RecyclerView.Adapter<CreatorChannelAudioContentAdapter.ViewHolder>() {
private var items: List<CreatorChannelAudioContentUiModel> = emptyList()
fun submitItems(items: List<CreatorChannelAudioContentUiModel>) {
this.items = items
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemCreatorChannelAudioContentBinding.inflate(LayoutInflater.from(parent.context), parent, false),
onAudioContentClick
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
class ViewHolder(
private val binding: ItemCreatorChannelAudioContentBinding,
private val onAudioContentClick: (CreatorChannelAudioContentUiModel) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.layoutCreatorChannelAudioContentThumbnail.clipToOutline = true
binding.layoutCreatorChannelAudioContentThumbnail.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(
0,
0,
view.width,
view.height,
view.resources.getDimension(R.dimen.radius_14)
)
}
}
}
fun bind(item: CreatorChannelAudioContentUiModel) = with(binding) {
ivCreatorChannelAudioContentThumbnail.loadUrl(item.imageUrl)
tvCreatorChannelAudioContentTitle.text = item.title
tvCreatorChannelAudioContentSecondaryText.text = item.secondaryText.orEmpty()
tvCreatorChannelAudioContentSecondaryText.isVisible = !item.secondaryText.isNullOrBlank()
ivCreatorChannelAudioContentAdultBadge.setImageResource(R.drawable.ic_new_shield_small)
ivCreatorChannelAudioContentAdultBadge.isVisible = item.showAdultBadge
bindTag(ivCreatorChannelAudioContentOriginalTag, AudioContentTag.Original, item.tags)
bindTag(ivCreatorChannelAudioContentFirstTag, AudioContentTag.First, item.tags)
bindTag(ivCreatorChannelAudioContentPointTag, AudioContentTag.Point, item.tags)
tvCreatorChannelAudioContentFreeTag.isVisible = AudioContentTag.Free in item.tags
bindStatus(item.status)
root.setOnClickListener { onAudioContentClick(item) }
}
private fun bindTag(view: View, tag: AudioContentTag, tags: Set<AudioContentTag>) {
view.isVisible = tag in tags
}
private fun bindStatus(status: CreatorChannelAudioContentStatus) = with(binding) {
ivCreatorChannelAudioContentPlay.setImageResource(R.drawable.ic_new_player_play)
when (status) {
CreatorChannelAudioContentStatus.Play -> {
ivCreatorChannelAudioContentPlay.isVisible = true
ivCreatorChannelAudioContentCan.isVisible = false
layoutCreatorChannelAudioContentActionText.isVisible = false
}
CreatorChannelAudioContentStatus.Owned -> bindTextStatus(R.string.audio_content_badge_owned)
CreatorChannelAudioContentStatus.Rented -> bindTextStatus(R.string.audio_content_badge_rented)
is CreatorChannelAudioContentStatus.Price -> {
ivCreatorChannelAudioContentPlay.isVisible = false
layoutCreatorChannelAudioContentActionText.isVisible = true
ivCreatorChannelAudioContentCan.isVisible = true
tvCreatorChannelAudioContentActionText.text = status.price.moneyFormat()
}
}
}
private fun bindTextStatus(textResId: Int) = with(binding) {
ivCreatorChannelAudioContentPlay.isVisible = true
layoutCreatorChannelAudioContentActionText.isVisible = true
ivCreatorChannelAudioContentCan.isVisible = false
tvCreatorChannelAudioContentActionText.setText(textResId)
}
}
}
@@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.v2.creator.channel.ui
import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import android.widget.LinearLayout
import kr.co.vividnext.sodalive.R
class CreatorChannelDonationCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
init {
clipToOutline = true
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
}
}
}
}
@@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.v2.creator.channel.ui
import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import android.widget.LinearLayout
import kr.co.vividnext.sodalive.R
class CreatorChannelFanTalkCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
init {
clipToOutline = true
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
}
}
}
}
@@ -0,0 +1,53 @@
package kr.co.vividnext.sodalive.v2.creator.channel.ui
import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
class CreatorChannelHomeAudioContentCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private val thumbnailContainer: View by lazy { findViewById(R.id.layout_audio_content_thumbnail) }
private val thumbnail: ImageView by lazy { findViewById(R.id.iv_audio_content_thumbnail) }
private val originalTag: ImageView by lazy { findViewById(R.id.iv_audio_content_original_tag) }
private val pointTag: ImageView by lazy { findViewById(R.id.iv_audio_content_point_tag) }
private val firstTag: ImageView by lazy { findViewById(R.id.iv_audio_content_first_tag) }
private val freeTag: TextView by lazy { findViewById(R.id.tv_audio_content_free_tag) }
private val title: TextView by lazy { findViewById(R.id.tv_audio_content_title) }
private val secondary: TextView by lazy { findViewById(R.id.tv_audio_content_secondary) }
override fun onFinishInflate() {
super.onFinishInflate()
thumbnailContainer.clipToOutline = true
thumbnailContainer.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
}
}
}
fun bind(audioContent: CreatorChannelAudioContentResponse) {
title.text = audioContent.title
secondary.text = listOfNotNull(audioContent.duration, audioContent.seriesName).joinToCreatorChannelAudioText()
thumbnail.loadUrl(audioContent.imageUrl)
originalTag.isVisible = audioContent.isOriginalSeries == true
pointTag.isVisible = audioContent.isPointAvailable
firstTag.isVisible = audioContent.isFirstContent
freeTag.isVisible = audioContent.price <= 0
}
private fun List<String?>.joinToCreatorChannelAudioText(): String =
filterNot { it.isNullOrBlank() }.joinToString(separator = " • ") { it.orEmpty() }
}
@@ -0,0 +1,743 @@
package kr.co.vividnext.sodalive.v2.creator.channel.ui
import android.content.Intent
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.annotation.ColorRes
import androidx.annotation.LayoutRes
import androidx.annotation.StringRes
import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText
import kr.co.vividnext.sodalive.common.image.BlurTransformation
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelCommunityPostResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeSection
import kr.co.vividnext.sodalive.v2.widget.feed.FeedCommunityView
import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem
import kr.co.vividnext.sodalive.v2.widget.feed.FeedSize
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
import kotlin.math.roundToInt
class CreatorChannelHomeSectionAdapter(
private val onLiveClick: (CreatorChannelLiveResponse) -> Unit = {},
private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit = {},
private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit = {},
private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit = {},
private val onDonationClick: () -> Unit = {}
) : RecyclerView.Adapter<CreatorChannelHomeSectionAdapter.SectionViewHolder>() {
private var items: List<CreatorChannelHomeSection> = emptyList()
fun submitItems(items: List<CreatorChannelHomeSection>) {
this.items = items
notifyDataSetChanged()
}
override fun getItemViewType(position: Int): Int = items[position].layoutResId
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return SectionViewHolder(view, onLiveClick, onScheduleClick, onAudioContentClick, onSeriesClick, onDonationClick)
}
override fun onBindViewHolder(holder: SectionViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
class SectionViewHolder(
view: View,
private val onLiveClick: (CreatorChannelLiveResponse) -> Unit,
private val onScheduleClick: (CreatorChannelScheduleResponse) -> Unit,
private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit,
private val onSeriesClick: (CreatorChannelSeriesResponse) -> Unit,
private val onDonationClick: () -> Unit
) : RecyclerView.ViewHolder(view) {
private val title: TextView? = view.findViewById(R.id.tv_section_title)
private val currentLiveTitle: TextView? = view.findViewById(R.id.tv_current_live_title)
private val currentLiveStartTime: TextView? = view.findViewById(R.id.tv_current_live_start_time)
private val currentLivePrice: TextView? = view.findViewById(R.id.tv_current_live_price)
private val currentLiveAdult: TextView? = view.findViewById(R.id.tv_current_live_adult)
private val currentLivePriceLayout: View? = view.findViewById(R.id.layout_current_live_price)
private val currentLiveCard: View? = view.findViewById(R.id.layout_current_live_card)
private val latestAudioThumbnail: ImageView? = view.findViewById(R.id.iv_latest_audio_thumbnail)
private val latestAudioPointTag: ImageView? = view.findViewById(R.id.iv_latest_audio_point_tag)
private val latestAudioTitle: TextView? = view.findViewById(R.id.tv_latest_audio_title)
private val latestAudioDuration: TextView? = view.findViewById(R.id.tv_latest_audio_duration)
private val donationItemsScrollView: View? = view.findViewById(R.id.hsv_donation_items)
private val donationItems: LinearLayout? = view.findViewById(R.id.ll_donation_items)
private val donationEmpty: View? = view.findViewById(R.id.layout_donation_empty)
private val donationEmptyButton: View? = view.findViewById(R.id.layout_donation_empty_button)
private val donationButton: View? = view.findViewById(R.id.layout_donation_button)
private val donationEmptyTitle: TextView? = view.findViewById(R.id.tv_donation_empty_title)
private val noticeItems: LinearLayout? = view.findViewById(R.id.ll_notice_items)
private val scheduleTimeline: LinearLayout? = view.findViewById(R.id.ll_schedule_timeline)
private val scheduleItems: LinearLayout? = view.findViewById(R.id.ll_schedule_items)
private val audioContentsRecyclerView: RecyclerView? = view.findViewById(R.id.rv_audio_contents)
private val seriesItems: LinearLayout? = view.findViewById(R.id.ll_series_items)
private val communityItems: LinearLayout? = view.findViewById(R.id.ll_community_items)
private val fanTalkCard: View? = view.findViewById(R.id.layout_fantalk_card)
private val fanTalkTotalRow: View? = view.findViewById(R.id.layout_fantalk_total_row)
private val fanTalkLatestRow: View? = view.findViewById(R.id.layout_fantalk_latest_row)
private val fanTalkEmpty: View? = view.findViewById(R.id.layout_fantalk_empty)
private val fanTalkTotalCount: TextView? = view.findViewById(R.id.tv_fantalk_total_count)
private val fanTalkProfile: ImageView? = view.findViewById(R.id.iv_fantalk_profile)
private val fanTalkContent: TextView? = view.findViewById(R.id.tv_fantalk_content)
private val introduceBody: TextView? = view.findViewById(R.id.tv_introduce_body)
private val activityDebutValue: TextView? = view.findViewById(R.id.tv_activity_debut_value)
private val activityLiveCountValue: TextView? = view.findViewById(R.id.tv_activity_live_count_value)
private val activityLiveDurationValue: TextView? = view.findViewById(R.id.tv_activity_live_duration_value)
private val activityLiveContributorValue: TextView? = view.findViewById(R.id.tv_activity_live_contributor_value)
private val activityAudioCountValue: TextView? = view.findViewById(R.id.tv_activity_audio_count_value)
private val activitySeriesCountValue: TextView? = view.findViewById(R.id.tv_activity_series_count_value)
private val snsItems: LinearLayout? = view.findViewById(R.id.ll_sns_items)
private val audioContentGridAdapter = AudioContentGridAdapter(
itemWidth = calculateCreatorChannelAudioItemWidthDp(itemView.resources.configuration.screenWidthDp).dp(),
onAudioContentClick = onAudioContentClick
)
init {
setupAudioContentsRecyclerView()
}
fun bind(item: CreatorChannelHomeSection) {
itemView.setOnClickListener(null)
title?.setText(item.titleResId)
donationItems?.removeAllViews()
noticeItems?.removeAllViews()
scheduleTimeline?.removeAllViews()
scheduleItems?.removeAllViews()
seriesItems?.removeAllViews()
communityItems?.removeAllViews()
snsItems?.removeAllViews()
when (item) {
is CreatorChannelHomeSection.CurrentLive -> bindCurrentLive(item)
is CreatorChannelHomeSection.LatestAudioContent -> bindLatestAudioContent(item)
is CreatorChannelHomeSection.Donations -> bindDonations(item)
is CreatorChannelHomeSection.Notices -> bindNotices(item)
is CreatorChannelHomeSection.Schedules -> bindSchedules(item)
is CreatorChannelHomeSection.AudioContents -> bindAudioContents(item)
is CreatorChannelHomeSection.Series -> bindSeries(item)
is CreatorChannelHomeSection.Communities -> bindCommunities(item)
is CreatorChannelHomeSection.FanTalk -> bindFanTalk(item)
is CreatorChannelHomeSection.Introduce -> bindIntroduce(item)
is CreatorChannelHomeSection.Activity -> bindActivity(item)
is CreatorChannelHomeSection.Sns -> bindSns(item)
}
}
private fun bindCurrentLive(item: CreatorChannelHomeSection.CurrentLive) {
currentLiveTitle?.text = item.live.title
currentLiveStartTime?.text = formatCreatorChannelLiveDateTime(item.live.beginDateTimeUtc)
currentLivePrice?.text = item.live.price.toString()
currentLivePriceLayout?.isVisible = item.live.price > 0
currentLiveAdult?.isVisible = item.live.isAdult
currentLiveCard?.setOnClickListener { onLiveClick(item.live) }
}
private fun bindLatestAudioContent(item: CreatorChannelHomeSection.LatestAudioContent) {
latestAudioTitle?.text = item.audioContent.title
latestAudioDuration?.text = item.audioContent.duration.orEmpty()
latestAudioPointTag?.isVisible = item.audioContent.isPointAvailable
latestAudioThumbnail?.loadUrl(item.audioContent.imageUrl)
itemView.setOnClickListener { onAudioContentClick(item.audioContent) }
}
private fun bindDonations(item: CreatorChannelHomeSection.Donations) {
donationItems?.removeAllViews()
donationItemsScrollView?.isVisible = item.donations.isNotEmpty()
donationEmpty?.isVisible = item.donations.isEmpty()
val isDonationButtonVisible = item.donations.isNotEmpty() && !item.isOwner
val isDonationEmptyButtonVisible = !item.isOwner
donationButton?.isVisible = isDonationButtonVisible
donationEmptyButton?.isVisible = isDonationEmptyButtonVisible
donationEmptyTitle?.setText(
if (item.isOwner) {
R.string.creator_channel_donation_empty_owner_title
} else {
R.string.creator_channel_donation_empty_title
}
)
donationButton?.setOnClickListener(
if (isDonationButtonVisible) View.OnClickListener { onDonationClick() } else null
)
donationEmptyButton?.setOnClickListener(
if (isDonationEmptyButtonVisible) View.OnClickListener { onDonationClick() } else null
)
val visibleDonations = item.donations.take(MAX_DONATION_ITEM_COUNT)
visibleDonations.forEachIndexed { index, donation ->
val row = LayoutInflater.from(itemView.context).inflate(
R.layout.item_creator_channel_home_donation_row,
donationItems,
false
)
row.layoutParams = LinearLayout.LayoutParams(
calculateCreatorChannelDonationCardWidthDp(itemView.resources.configuration.screenWidthDp).dp(),
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
marginEnd = if (index == visibleDonations.lastIndex) 0 else 4.dp()
}
row.findViewById<View>(R.id.layout_donation_header)
.setBackgroundColor(itemView.context.getColor(calculateCreatorChannelDonationHeaderColorRes(donation.can)))
row.findViewById<ImageView>(R.id.iv_donation_profile).loadUrl(donation.profileImageUrl) {
placeholder(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
row.findViewById<TextView>(R.id.tv_donation_nickname).text = donation.nickname
row.findViewById<TextView>(R.id.tv_donation_created_at).text =
formatUtcRelativeTimeText(itemView.context, donation.createdAtUtc)
row.findViewById<TextView>(R.id.tv_donation_can).text = itemView.context.getString(
R.string.creator_channel_donation_can_format,
donation.can.moneyFormat()
)
row.findViewById<TextView>(R.id.tv_donation_message).text = donation.message.ifBlank {
itemView.context.getString(R.string.creator_channel_donation_fallback_message, donation.can)
}
donationItems?.addView(row)
}
}
private fun bindNotices(item: CreatorChannelHomeSection.Notices) {
val visibleNotices = item.notices.take(MAX_NOTICE_ITEM_COUNT)
visibleNotices.forEachIndexed { index, notice ->
val row = LayoutInflater.from(itemView.context).inflate(
R.layout.item_creator_channel_home_notice_row,
noticeItems,
false
)
row.layoutParams = LinearLayout.LayoutParams(
calculateCreatorChannelNoticeCardWidthDp(itemView.resources.configuration.screenWidthDp).dp(),
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
marginEnd = if (index == visibleNotices.lastIndex) 0 else 4.dp()
}
row.findViewById<ImageView>(R.id.iv_notice_profile).loadUrl(notice.creatorProfileUrl) {
placeholder(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
row.findViewById<TextView>(R.id.tv_notice_creator_name).text = notice.creatorNickname
row.findViewById<TextView>(R.id.tv_notice_created_at).text =
formatUtcRelativeTimeText(itemView.context, notice.dateUtc)
row.findViewById<TextView>(R.id.tv_notice_content).text = notice.content
val noticeThumbnail = row.findViewById<ImageView>(R.id.iv_notice_thumbnail)
noticeThumbnail.isVisible = !notice.imageUrl.isNullOrBlank()
notice.imageUrl?.let(noticeThumbnail::loadUrl)
noticeItems?.addView(row)
}
}
private fun bindSchedules(item: CreatorChannelHomeSection.Schedules) {
val visibleSchedules = item.schedules.take(MAX_SCHEDULE_ITEM_COUNT)
bindScheduleTimeline(visibleSchedules.size)
visibleSchedules.forEachIndexed { index, schedule ->
val row = LayoutInflater.from(itemView.context).inflate(
R.layout.item_creator_channel_home_schedule_row,
scheduleItems,
false
)
row.layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
bottomMargin = if (index == visibleSchedules.lastIndex) 0 else 4.dp()
}
row.findViewById<TextView>(R.id.tv_schedule_date).text =
formatCreatorChannelScheduleDate(schedule.scheduledAtUtc)
row.findViewById<TextView>(R.id.tv_schedule_day_of_week).text =
formatCreatorChannelScheduleDayOfWeek(schedule.scheduledAtUtc)
row.findViewById<TextView>(R.id.tv_schedule_title).text = schedule.title
row.findViewById<TextView>(R.id.tv_schedule_type).text = itemView.context.getString(schedule.type.labelResId)
row.findViewById<TextView>(R.id.tv_schedule_time).text =
formatCreatorChannelScheduleTime(schedule.scheduledAtUtc)
row.setOnClickListener { onScheduleClick(schedule) }
scheduleItems?.addView(row)
}
}
private fun bindScheduleTimeline(count: Int) {
repeat(count) { index ->
scheduleTimeline?.addView(createScheduleTimelineDot(index == 0))
if (index < calculateCreatorChannelScheduleTimelineLineCount(count)) {
scheduleTimeline?.addView(createScheduleTimelineLine())
}
}
}
private fun createScheduleTimelineDot(isFirst: Boolean): View = View(itemView.context).apply {
setBackgroundResource(R.drawable.bg_creator_channel_schedule_timeline_dot)
if (isFirst) {
background.setTint(itemView.context.getColor(R.color.soda_400))
}
layoutParams = LinearLayout.LayoutParams(8.dp(), 8.dp()).apply {
topMargin = if (isFirst) 37.dp() else 0
}
}
private fun createScheduleTimelineLine(): View = View(itemView.context).apply {
setBackgroundResource(R.drawable.bg_creator_channel_schedule_timeline_line)
layoutParams = LinearLayout.LayoutParams(2.dp(), 63.dp()).apply {
gravity = Gravity.CENTER_HORIZONTAL
}
}
private fun bindAudioContents(item: CreatorChannelHomeSection.AudioContents) {
val visibleAudioContents = item.audioContents.take(MAX_AUDIO_ITEM_COUNT)
updateAudioContentsGridSpan(visibleAudioContents.size)
audioContentGridAdapter.submitItems(visibleAudioContents)
}
private fun updateAudioContentsGridSpan(itemCount: Int) {
(audioContentsRecyclerView?.layoutManager as? GridLayoutManager)?.spanCount = itemCount.coerceIn(
1,
AUDIO_GRID_SPAN_COUNT
)
}
private fun setupAudioContentsRecyclerView() {
audioContentsRecyclerView?.apply {
if (layoutManager == null) {
layoutManager = GridLayoutManager(itemView.context, AUDIO_GRID_SPAN_COUNT, RecyclerView.HORIZONTAL, false)
}
if (adapter == null) {
adapter = audioContentGridAdapter
}
if (itemDecorationCount == 0) {
addItemDecoration(AudioContentGridSpacingDecoration(horizontalSpacing = 8.dp(), verticalSpacing = 8.dp()))
}
}
}
private fun bindSeries(item: CreatorChannelHomeSection.Series) {
val visibleSeries = item.series.take(MAX_SERIES_ITEM_COUNT)
visibleSeries.forEachIndexed { index, series ->
val seriesWidthDp = calculateCreatorChannelSeriesCardWidthDp(itemView.resources.configuration.screenWidthDp)
val row = LayoutInflater.from(itemView.context).inflate(
R.layout.item_creator_channel_home_series_content,
seriesItems,
false
)
row.layoutParams = LinearLayout.LayoutParams(
seriesWidthDp.dp(),
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
marginEnd = if (index == visibleSeries.lastIndex) 0 else 4.dp()
}
(row as CreatorChannelHomeSeriesCardView).apply {
setThumbnailSize(seriesWidthDp, calculateCreatorChannelSeriesCardHeightDp(seriesWidthDp))
bind(series)
}
row.setOnClickListener { onSeriesClick(series) }
seriesItems?.addView(row)
}
}
private fun bindCommunities(item: CreatorChannelHomeSection.Communities) {
val visibleCommunities = item.communities.take(MAX_COMMUNITY_ITEM_COUNT)
visibleCommunities.forEachIndexed { index, community ->
val communityWidthDp = calculateCreatorChannelCommunityCardWidthDp(
itemView.resources.configuration.screenWidthDp
)
val row = LayoutInflater.from(itemView.context).inflate(
R.layout.view_feed_community,
communityItems,
false
)
(row as FeedCommunityView).apply {
setFeedSize(
FeedSize(
rootWidthDp = communityWidthDp
)
)
setHideEmptyTextRows(true)
bind(community.toFeedCommunityItem())
}
bindCommunityImages(row, community)
row.layoutParams = LinearLayout.LayoutParams(
communityWidthDp.dp(),
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
bottomMargin = if (index == visibleCommunities.lastIndex) 0 else 8.dp()
}
communityItems?.addView(row)
}
}
private fun bindFanTalk(item: CreatorChannelHomeSection.FanTalk) {
fanTalkCard?.layoutParams = fanTalkCard.layoutParams?.apply {
width = calculateCreatorChannelFanTalkCardWidthDp(itemView.resources.configuration.screenWidthDp).dp()
}
fanTalkTotalCount?.text = item.fanTalk.totalCount.toString()
val fanTalk = item.fanTalk.latestFanTalk
fanTalkTotalRow?.isVisible = fanTalk != null && item.fanTalk.totalCount > 0
fanTalkLatestRow?.isVisible = fanTalk != null && item.fanTalk.totalCount > 0
fanTalkEmpty?.isVisible = fanTalk == null || item.fanTalk.totalCount <= 0
if (fanTalk != null) {
fanTalkContent?.text = fanTalk.content
fanTalkProfile?.loadUrl(fanTalk.profileImageUrl) {
placeholder(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
} else {
fanTalkContent?.text = ""
fanTalkProfile?.setImageResource(R.drawable.ic_placeholder_profile)
}
}
private fun bindCommunityImages(row: View, community: CreatorChannelCommunityPostResponse) {
val communityRow = row as FeedCommunityView
communityRow.profileImageView().loadUrl(community.creatorProfileUrl) {
placeholder(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
val isCommunityLocked = community.price > 0 && !community.existOrdered
val communityImageUrl = community.imageUrl.takeIf { !it.isNullOrBlank() }
if (communityImageUrl != null) {
communityRow.communityImageView().loadUrl(communityImageUrl) {
if (isCommunityLocked) {
transformations(BlurTransformation(itemView.context, 25f, 2.5f))
}
}
} else {
communityRow.communityImageView().setImageDrawable(null)
}
}
private fun CreatorChannelCommunityPostResponse.toFeedCommunityItem(): FeedItem.Community = FeedItem.Community(
feedId = postId.toString(),
creatorId = creatorId.toString(),
creatorName = creatorNickname,
creatorImageUrl = creatorProfileUrl,
postId = postId.toString(),
bodyText = content,
keywordText = "",
createdAtText = formatUtcRelativeTimeText(itemView.context, dateUtc),
commentCount = commentCount,
likeCount = likeCount,
imageUrl = imageUrl,
audioUrl = audioUrl,
price = price,
existOrdered = existOrdered,
showKeyword = false
)
private fun bindIntroduce(item: CreatorChannelHomeSection.Introduce) {
introduceBody?.text = item.introduce
}
private fun bindActivity(item: CreatorChannelHomeSection.Activity) {
val activity = item.activity
activityDebutValue?.text = formatCreatorChannelDebutActivityValue(activity.debutDateUtc, activity.dDay)
activityLiveCountValue?.text = itemView.context.getString(
R.string.creator_channel_activity_live_count_format,
activity.liveCount
)
activityLiveDurationValue?.text = itemView.context.getString(
R.string.creator_channel_activity_live_duration_format,
activity.liveDurationHours
)
activityLiveContributorValue?.text = itemView.context.getString(
R.string.creator_channel_activity_live_contributor_format,
activity.liveContributorCount
)
activityAudioCountValue?.text = itemView.context.getString(
R.string.creator_channel_activity_audio_count_format,
activity.audioContentCount
)
activitySeriesCountValue?.text = itemView.context.getString(
R.string.creator_channel_activity_series_count_format,
activity.seriesCount
)
}
private fun bindSns(item: CreatorChannelHomeSection.Sns) {
item.items.forEachIndexed { index, sns ->
val button = LayoutInflater.from(itemView.context).inflate(
R.layout.item_creator_channel_home_sns_icon,
snsItems,
false
) as ImageView
val buttonSize = calculateCreatorChannelSnsButtonSizeDp(itemView.resources.configuration.screenWidthDp).dp()
button.layoutParams = LinearLayout.LayoutParams(buttonSize, buttonSize).apply {
marginEnd = if (index == item.items.lastIndex) 0 else 16.dp()
}
button.setImageResource(sns.iconResId)
button.contentDescription = sns.label
button.setOnClickListener {
val intent = Intent(Intent.ACTION_VIEW, sns.url.toUri())
if (intent.resolveActivity(itemView.context.packageManager) != null) {
itemView.context.startActivity(intent)
}
}
snsItems?.addView(button)
}
}
private fun Int.dp(): Int = (this * itemView.resources.displayMetrics.density).toInt()
}
private class AudioContentGridAdapter(
private val itemWidth: Int,
private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit
) : RecyclerView.Adapter<AudioContentGridAdapter.AudioContentViewHolder>() {
private var items: List<CreatorChannelAudioContentResponse> = emptyList()
fun submitItems(items: List<CreatorChannelAudioContentResponse>) {
this.items = items
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AudioContentViewHolder {
val view = LayoutInflater.from(parent.context).inflate(
R.layout.item_creator_channel_home_audio_content,
parent,
false
)
view.layoutParams = RecyclerView.LayoutParams(itemWidth, RecyclerView.LayoutParams.WRAP_CONTENT)
return AudioContentViewHolder(view, onAudioContentClick)
}
override fun onBindViewHolder(holder: AudioContentViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
class AudioContentViewHolder(
view: View,
private val onAudioContentClick: (CreatorChannelAudioContentResponse) -> Unit
) : RecyclerView.ViewHolder(view) {
private val card: CreatorChannelHomeAudioContentCardView = view as CreatorChannelHomeAudioContentCardView
fun bind(audioContent: CreatorChannelAudioContentResponse) {
card.bind(audioContent)
card.setOnClickListener { onAudioContentClick(audioContent) }
}
}
}
private class AudioContentGridSpacingDecoration(
private val horizontalSpacing: Int,
private val verticalSpacing: Int
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: android.graphics.Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val position = parent.getChildAdapterPosition(view)
if (position == RecyclerView.NO_POSITION) return
val itemCount = parent.adapter?.itemCount ?: return
val lastColumnStartPosition = ((itemCount - 1) / AUDIO_GRID_SPAN_COUNT) * AUDIO_GRID_SPAN_COUNT
outRect.right = if (position >= lastColumnStartPosition) 0 else horizontalSpacing
outRect.bottom = if (position % AUDIO_GRID_SPAN_COUNT == AUDIO_GRID_SPAN_COUNT - 1) 0 else verticalSpacing
}
}
private companion object {
@get:LayoutRes
val CreatorChannelHomeSection.layoutResId: Int
get() = when (this) {
is CreatorChannelHomeSection.CurrentLive -> R.layout.item_creator_channel_home_live
is CreatorChannelHomeSection.LatestAudioContent -> R.layout.item_creator_channel_home_latest_audio
is CreatorChannelHomeSection.Donations -> R.layout.item_creator_channel_home_donation
is CreatorChannelHomeSection.Notices -> R.layout.item_creator_channel_home_notice
is CreatorChannelHomeSection.Schedules -> R.layout.item_creator_channel_home_schedule
is CreatorChannelHomeSection.AudioContents -> R.layout.item_creator_channel_home_audio
is CreatorChannelHomeSection.Series -> R.layout.item_creator_channel_home_series
is CreatorChannelHomeSection.Communities -> R.layout.item_creator_channel_home_community
is CreatorChannelHomeSection.FanTalk -> R.layout.item_creator_channel_home_fantalk
is CreatorChannelHomeSection.Introduce -> R.layout.item_creator_channel_home_introduce
is CreatorChannelHomeSection.Activity -> R.layout.item_creator_channel_home_activity
is CreatorChannelHomeSection.Sns -> R.layout.item_creator_channel_home_sns
}
@get:StringRes
val CreatorChannelHomeSection.titleResId: Int
get() = when (this) {
is CreatorChannelHomeSection.CurrentLive -> R.string.creator_channel_section_live
is CreatorChannelHomeSection.LatestAudioContent -> R.string.creator_channel_section_latest_audio
is CreatorChannelHomeSection.Donations -> R.string.creator_channel_section_donation
is CreatorChannelHomeSection.Notices -> R.string.creator_channel_section_notice
is CreatorChannelHomeSection.Schedules -> R.string.creator_channel_section_schedule
is CreatorChannelHomeSection.AudioContents -> R.string.creator_channel_section_audio
is CreatorChannelHomeSection.Series -> R.string.creator_channel_section_series
is CreatorChannelHomeSection.Communities -> R.string.creator_channel_section_community
is CreatorChannelHomeSection.FanTalk -> R.string.creator_channel_section_fantalk
is CreatorChannelHomeSection.Introduce -> R.string.creator_channel_section_introduce
is CreatorChannelHomeSection.Activity -> R.string.creator_channel_section_activity
is CreatorChannelHomeSection.Sns -> R.string.creator_channel_section_sns
}
private const val MAX_DONATION_ITEM_COUNT = 8
private const val MAX_NOTICE_ITEM_COUNT = 3
private const val MAX_SCHEDULE_ITEM_COUNT = 3
private const val MAX_AUDIO_ITEM_COUNT = 9
private const val MAX_SERIES_ITEM_COUNT = 10
private const val MAX_COMMUNITY_ITEM_COUNT = 3
private const val AUDIO_GRID_SPAN_COUNT = 3
fun List<String>.joinToText(): String = filter(String::isNotBlank).joinToString(separator = " · ")
}
}
internal fun formatCreatorChannelScheduleDate(
scheduledAtUtc: String,
timeZone: TimeZone = TimeZone.getDefault(),
locale: Locale = Locale.getDefault()
): String = formatCreatorChannelScheduleUtc(scheduledAtUtc, "d", timeZone, locale)
internal fun formatCreatorChannelScheduleDayOfWeek(
scheduledAtUtc: String,
timeZone: TimeZone = TimeZone.getDefault(),
locale: Locale = Locale.getDefault()
): String = formatCreatorChannelScheduleUtc(scheduledAtUtc, "E", timeZone, locale)
internal fun formatCreatorChannelScheduleTime(
scheduledAtUtc: String,
timeZone: TimeZone = TimeZone.getDefault(),
locale: Locale = Locale.getDefault()
): String = formatCreatorChannelScheduleUtc(scheduledAtUtc, "a hh:mm", timeZone, locale)
internal fun formatCreatorChannelLiveDateTime(
beginDateTimeUtc: String,
timeZone: TimeZone = TimeZone.getDefault(),
locale: Locale = Locale.getDefault()
): String = formatCreatorChannelUtcOrNull(beginDateTimeUtc, "yyyy.MM.dd HH:mm:ss", timeZone, locale).orEmpty()
internal fun formatCreatorChannelDebutActivityValue(
debutDateUtc: String?,
dDay: String,
timeZone: TimeZone = TimeZone.getDefault(),
locale: Locale = Locale.getDefault()
): String {
val debutDate = debutDateUtc?.takeIf(String::isNotBlank)?.let { dateUtc ->
formatCreatorChannelUtcOrNull(dateUtc, "yyyy.MM.dd", timeZone, locale)
}
return if (debutDate.isNullOrBlank()) {
dDay
} else {
"$debutDate($dDay)"
}
}
private fun formatCreatorChannelScheduleUtc(
scheduledAtUtc: String,
pattern: String,
timeZone: TimeZone,
locale: Locale
): String {
return formatCreatorChannelUtcOrNull(scheduledAtUtc, pattern, timeZone, locale).orEmpty()
}
private fun formatCreatorChannelUtcOrNull(
utc: String,
pattern: String,
timeZone: TimeZone,
locale: Locale
): String? {
val date = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
this.timeZone = TimeZone.getTimeZone("UTC")
isLenient = false
}.runCatching { parse(utc) }.getOrNull() ?: return null
return SimpleDateFormat(pattern, locale).apply { this.timeZone = timeZone }.format(date)
}
internal fun calculateCreatorChannelSnsButtonSizeDp(screenWidthDp: Int): Int {
val width = screenWidthDp.takeIf { it > 0 } ?: 402
return minOf(
SNS_MAX_ICON_SIZE_DP,
(width - SNS_HORIZONTAL_PADDING_DP - SNS_TOTAL_GAP_DP).coerceAtLeast(0) / SNS_ICON_COUNT
)
}
private const val SNS_ICON_COUNT = 5
private const val SNS_MAX_ICON_SIZE_DP = 52
private const val SNS_HORIZONTAL_PADDING_DP = 40
private const val SNS_TOTAL_GAP_DP = 64
@ColorRes
internal fun calculateCreatorChannelDonationHeaderColorRes(can: Int): Int = when {
can >= 500 -> R.color.red_400
can >= 101 -> R.color.creator_channel_donation_cyan
can >= 51 -> R.color.green_400
else -> R.color.gray_200
}
internal fun calculateCreatorChannelDonationCardWidthDp(screenWidthDp: Int): Int {
val width = screenWidthDp.takeIf { it > 0 } ?: 402
return if (width >= 402) {
374
} else {
(374f * width / 402f).roundToInt()
}
}
internal fun calculateCreatorChannelNoticeCardWidthDp(screenWidthDp: Int): Int {
val width = screenWidthDp.takeIf { it > 0 } ?: 402
return if (width >= 402) {
346
} else {
(346f * width / 402f).roundToInt()
}
}
internal fun calculateCreatorChannelAudioItemWidthDp(screenWidthDp: Int): Int {
val width = screenWidthDp.takeIf { it > 0 } ?: 402
return if (width >= 402) {
346
} else {
(346f * width / 402f).roundToInt()
}
}
internal fun calculateCreatorChannelSeriesCardWidthDp(screenWidthDp: Int): Int {
val width = screenWidthDp.takeIf { it > 0 } ?: 402
return if (width >= 402) {
163
} else {
(163f * width / 402f).roundToInt()
}
}
internal fun calculateCreatorChannelSeriesCardHeightDp(widthDp: Int): Int = (widthDp * 230f / 163f).roundToInt()
internal fun calculateCreatorChannelCommunityCardWidthDp(screenWidthDp: Int): Int {
val width = screenWidthDp.takeIf { it > 0 } ?: 402
return if (width >= 402) {
374
} else {
(374f * width / 402f).roundToInt()
}
}
internal fun calculateCreatorChannelFanTalkCardWidthDp(screenWidthDp: Int): Int {
val width = screenWidthDp.takeIf { it > 0 } ?: 402
return if (width >= 402) {
374
} else {
(374f * width / 402f).roundToInt()
}
}
internal fun calculateCreatorChannelScheduleTimelineLineCount(scheduleCount: Int): Int = (scheduleCount - 1).coerceAtLeast(0)
@@ -0,0 +1,49 @@
package kr.co.vividnext.sodalive.v2.creator.channel.ui
import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.core.view.isVisible
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse
class CreatorChannelHomeSeriesCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private val thumbnailContainer: View by lazy { findViewById(R.id.layout_series_thumbnail) }
private val thumbnail: ImageView by lazy { findViewById(R.id.iv_series_thumbnail) }
private val originalTag: View by lazy { findViewById(R.id.layout_series_original_tag) }
override fun onFinishInflate() {
super.onFinishInflate()
thumbnailContainer.clipToOutline = true
thumbnailContainer.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
}
}
}
fun bind(series: CreatorChannelSeriesResponse) {
thumbnail.loadUrl(series.coverImageUrl)
originalTag.isVisible = series.isOriginal
}
fun setThumbnailSize(widthDp: Int, heightDp: Int) {
thumbnailContainer.layoutParams = thumbnailContainer.layoutParams.apply {
width = widthDp.dp()
height = heightDp.dp()
} ?: ViewGroup.LayoutParams(widthDp.dp(), heightDp.dp())
}
private fun Int.dp(): Int = (this * resources.displayMetrics.density).toInt()
}
@@ -0,0 +1,26 @@
package kr.co.vividnext.sodalive.v2.creator.channel.ui
import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import kr.co.vividnext.sodalive.R
class CreatorChannelLatestAudioThumbnailView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
override fun onFinishInflate() {
super.onFinishInflate()
clipToOutline = true
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
}
}
}
}
@@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.v2.creator.channel.ui
import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import android.widget.LinearLayout
import kr.co.vividnext.sodalive.R
class CreatorChannelNoticeCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
init {
clipToOutline = true
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
}
}
}
}
@@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.v2.creator.channel.ui
import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import androidx.appcompat.widget.AppCompatImageView
import kr.co.vividnext.sodalive.R
class CreatorChannelNoticeThumbnailView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
init {
clipToOutline = true
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
}
}
}
}
@@ -0,0 +1,105 @@
package kr.co.vividnext.sodalive.v2.creator.channel.ui
import android.graphics.Color
import android.graphics.Outline
import android.graphics.Rect
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.PopupWindow
import android.widget.TextView
import androidx.core.graphics.drawable.toDrawable
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.model.toSortOptionUiModel
class CreatorChannelSortPopup(
private val anchor: View,
private val selectedSort: ContentSort,
private val onSortSelected: (ContentSort) -> Unit
) {
private val popupWindow: PopupWindow = PopupWindow(anchor.context).apply {
contentView = createContentView()
width = ViewGroup.LayoutParams.WRAP_CONTENT
height = ViewGroup.LayoutParams.WRAP_CONTENT
isOutsideTouchable = true
isFocusable = true
setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
}
fun show() {
val content = popupWindow.contentView
content.measure(
View.MeasureSpec.makeMeasureSpec(anchor.rootView.width, View.MeasureSpec.AT_MOST),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
popupWindow.showAsDropDown(anchor, calculateHorizontalOffset(content.measuredWidth), 0)
}
fun dismiss() {
popupWindow.dismiss()
}
private fun createContentView(): View {
val root = FrameLayout(anchor.context)
val view = LayoutInflater.from(anchor.context).inflate(
R.layout.view_creator_channel_sort_menu,
root,
false
)
view.clipToOutline = true
view.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, view.resources.getDimension(R.dimen.radius_14))
}
}
val container = view.findViewById<LinearLayout>(R.id.layout_creator_channel_sort_options)
val sample = view.findViewById<TextView>(R.id.tv_creator_channel_sort_option_sample)
container.removeView(sample)
ContentSort.entries.map { it.toSortOptionUiModel(selectedSort) }.forEach { option ->
val row = TextView(anchor.context).apply {
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
typeface = sample.typeface
includeFontPadding = false
setPadding(sample.paddingStart, sample.paddingTop, sample.paddingEnd, sample.paddingBottom)
setText(option.labelResId)
setTextColor(sample.currentTextColor)
textSize = 16f
if (option.isSelected) {
setBackgroundResource(R.drawable.bg_creator_channel_sort_selected)
}
setOnClickListener {
if (option.sort != selectedSort) {
onSortSelected(option.sort)
}
dismiss()
}
}
container.addView(row)
}
return view
}
private fun calculateHorizontalOffset(popupWidth: Int): Int {
val visibleDisplayFrame = Rect()
anchor.rootView.getWindowVisibleDisplayFrame(visibleDisplayFrame)
val anchorLocation = IntArray(2)
anchor.getLocationOnScreen(anchorLocation)
val popupRight = anchorLocation[0] + popupWidth
return if (popupRight > visibleDisplayFrame.right) {
visibleDisplayFrame.right - popupRight
} else {
0
}
}
}
@@ -0,0 +1,311 @@
package kr.co.vividnext.sodalive.v2.live.onair
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.databinding.ActivityHomeOnAirLiveBinding
import kr.co.vividnext.sodalive.live.LiveViewModel
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse
import kr.co.vividnext.sodalive.live.room.LiveRoomActivity
import kr.co.vividnext.sodalive.live.room.dialog.LivePaymentDialog
import kr.co.vividnext.sodalive.live.room.dialog.LiveRoomPasswordDialog
import kr.co.vividnext.sodalive.mypage.MyPageViewModel
import kr.co.vividnext.sodalive.mypage.auth.Auth
import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
import kr.co.vividnext.sodalive.splash.SplashActivity
import kr.co.vividnext.sodalive.user.login.LoginActivity
import kr.co.vividnext.sodalive.v2.live.onair.model.HomeOnAirLivePageUiState
import kr.co.vividnext.sodalive.v2.live.onair.model.canEnterHomeOnAirLiveRoom
import kr.co.vividnext.sodalive.v2.live.onair.ui.HomeOnAirLiveAdapter
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
@UnstableApi
class HomeOnAirLiveActivity : BaseActivity<ActivityHomeOnAirLiveBinding>(
ActivityHomeOnAirLiveBinding::inflate
) {
private val viewModel: HomeOnAirLiveViewModel by viewModel()
private val liveViewModel: LiveViewModel by inject()
private val myPageViewModel: MyPageViewModel by inject()
private val loadingDialog: LoadingDialog by lazy { LoadingDialog(this, layoutInflater) }
private val adapter = HomeOnAirLiveAdapter { enterLiveRoom(it.roomId) }
private var isPageLoading = false
private var isLiveEntryLoading = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bindData()
viewModel.loadFirstPage()
}
override fun setupView() {
binding.toolbar.tvBack.setText(R.string.live_now)
binding.toolbar.tvBack.setOnClickListener { finish() }
binding.rvHomeOnAirLive.apply {
layoutManager = LinearLayoutManager(this@HomeOnAirLiveActivity)
adapter = this@HomeOnAirLiveActivity.adapter
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (dy > 0 && !recyclerView.canScrollVertically(1)) {
viewModel.loadNextPage()
}
}
})
}
}
private fun bindData() {
viewModel.onAirLiveStateLiveData.observe(this) { state ->
when (state) {
HomeOnAirLivePageUiState.Loading -> Unit
HomeOnAirLivePageUiState.Empty -> showEmpty()
is HomeOnAirLivePageUiState.Error -> showEmpty()
is HomeOnAirLivePageUiState.Content -> {
binding.rvHomeOnAirLive.isVisible = true
binding.tvHomeOnAirLiveEmpty.isVisible = false
adapter.submitItems(state.content.items)
state.content.paginationErrorMessage?.let { message ->
Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
viewModel.consumePaginationErrorMessage()
}
}
}
}
viewModel.isLoading.observe(this) { isLoading ->
isPageLoading = isLoading
updateLoadingDialog()
}
viewModel.toastLiveData.observe(this) { toastMessage ->
toastMessage?.let(::showToast)
}
liveViewModel.isLoading.observe(this) { isLoading ->
isLiveEntryLoading = isLoading
updateLoadingDialog()
}
liveViewModel.toastLiveData.observe(this) { message ->
message?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
}
}
private fun showEmpty() {
binding.rvHomeOnAirLive.isVisible = false
binding.tvHomeOnAirLiveEmpty.isVisible = true
adapter.submitItems(emptyList())
}
private fun enterLiveRoom(roomId: Long) {
ensureLoginAndAdultAuth(isAdult = false) {
liveViewModel.getRoomDetail(roomId) { roomDetail ->
if (!canEnterHomeOnAirLiveRoom(roomDetail)) {
Toast.makeText(applicationContext, R.string.common_error_unknown, Toast.LENGTH_LONG).show()
return@getRoomDetail
}
ensureLoginAndAdultAuth(isAdult = roomDetail.isAdult) {
enterLiveRoom(roomId, roomDetail)
}
}
}
}
private fun enterLiveRoom(roomId: Long, roomDetail: GetRoomDetailResponse) {
startService(
Intent(applicationContext, AudioContentPlayService::class.java).apply {
action = AudioContentPlayService.MusicAction.STOP.name
}
)
startService(
Intent(applicationContext, AudioContentPlayerService::class.java).apply {
action = "STOP_SERVICE"
}
)
val onEnterRoomSuccess = {
runOnUiThread {
startActivity(
Intent(applicationContext, LiveRoomActivity::class.java).apply {
putExtra(Constants.EXTRA_ROOM_ID, roomId)
}
)
}
}
if (roomDetail.manager.id == SharedPreferenceManager.userId) {
liveViewModel.enterRoom(roomId, onEnterRoomSuccess)
} else if (roomDetail.price == 0 || roomDetail.isPaid) {
if (roomDetail.isPrivateRoom) {
showPasswordDialog(roomId, can = 0, onEnterRoomSuccess = onEnterRoomSuccess)
} else {
liveViewModel.enterRoom(roomId, onEnterRoomSuccess)
}
} else {
showPaidLiveEntryDialog(
roomId = roomId,
beginDateTimeUtc = roomDetail.beginDateTimeUtc,
price = roomDetail.price,
isPrivateRoom = roomDetail.isPrivateRoom,
onEnterRoomSuccess = onEnterRoomSuccess
)
}
}
private fun ensureLoginAndAdultAuth(isAdult: Boolean, onAuthed: () -> Unit) {
if (SharedPreferenceManager.token.isBlank()) {
showLoginActivity()
return
}
if (isAdult) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.auth_title),
desc = getString(R.string.auth_desc_live),
confirmButtonTitle = getString(R.string.auth_go),
confirmButtonClick = { startAuthFlow() },
cancelButtonTitle = getString(R.string.cancel),
cancelButtonClick = {},
descGravity = Gravity.CENTER
).show(screenWidth)
return
}
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(applicationContext, ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
}
onAuthed()
}
private fun showLoginActivity() {
if (SharedPreferenceManager.token.isBlank()) {
startActivity(
Intent(applicationContext, LoginActivity::class.java).apply {
putExtra(Constants.EXTRA_DATA, intent.extras)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
)
}
}
private fun startAuthFlow() {
Auth.auth(this, this) { json ->
val bootpayResponse = Gson().fromJson(json, BootpayResponse::class.java)
val request = AuthVerifyRequest(receiptId = bootpayResponse.data.receiptId)
runOnUiThread {
myPageViewModel.authVerify(request) {
startActivity(
Intent(applicationContext, SplashActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
finish()
}
}
}
}
private fun showPasswordDialog(roomId: Long, can: Int, onEnterRoomSuccess: () -> Unit) {
LiveRoomPasswordDialog(
activity = this,
layoutInflater = layoutInflater,
can = can,
confirmButtonClick = { password ->
liveViewModel.enterRoom(
roomId = roomId,
onSuccess = onEnterRoomSuccess,
password = password
)
}
).show(screenWidth)
}
private fun showPaidLiveEntryDialog(
roomId: Long,
beginDateTimeUtc: String,
price: Int,
isPrivateRoom: Boolean,
onEnterRoomSuccess: () -> Unit
) {
if (isPrivateRoom) {
showPasswordDialog(roomId, can = price, onEnterRoomSuccess = onEnterRoomSuccess)
return
}
val locale = Locale(LanguageManager.getEffectiveLanguage(this))
val wrappedContext = LocaleHelper.wrap(this)
val beginDate = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.parse(beginDateTimeUtc) ?: return
val now = Date()
val dateFormat = SimpleDateFormat("yyyy-MM-dd, HH:mm", locale)
val diffTime = now.time - beginDate.time
val hours = (diffTime / (1000 * 60 * 60)).toInt()
val mins = (diffTime / (1000 * 60)).toInt() % 60
LivePaymentDialog(
activity = this,
layoutInflater = layoutInflater,
title = wrappedContext.getString(R.string.live_paid_title),
startDateTime = if (hours >= 1) dateFormat.format(beginDate) else null,
nowDateTime = if (hours >= 1) dateFormat.format(now) else null,
desc = wrappedContext.getString(R.string.live_paid_desc, price),
desc2 = if (hours >= 1) wrappedContext.getString(R.string.live_paid_warning, hours, mins) else null,
confirmButtonTitle = wrappedContext.getString(R.string.live_paid_confirm),
confirmButtonClick = { liveViewModel.enterRoom(roomId, onEnterRoomSuccess) },
cancelButtonTitle = wrappedContext.getString(R.string.cancel),
cancelButtonClick = {}
).show(screenWidth)
}
private fun showToast(toastMessage: ToastMessage) {
toastMessage.message?.let { message -> showToast(message) }
?: toastMessage.resId?.let { resId -> showToast(getString(resId)) }
}
private fun updateLoadingDialog() {
if (isPageLoading || isLiveEntryLoading) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
companion object {
fun newIntent(context: Context): Intent = Intent(context, HomeOnAirLiveActivity::class.java)
}
}
@@ -0,0 +1,140 @@
package kr.co.vividnext.sodalive.v2.live.onair
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.v2.live.onair.data.HomeOnAirLivePageResponse
import kr.co.vividnext.sodalive.v2.live.onair.data.HomeOnAirLiveRepository
import kr.co.vividnext.sodalive.v2.live.onair.model.HomeOnAirLivePageUiState
import kr.co.vividnext.sodalive.v2.live.onair.model.homeOnAirLiveAuthHeader
import kr.co.vividnext.sodalive.v2.live.onair.model.toUiState
class HomeOnAirLiveViewModel(
private val repository: HomeOnAirLiveRepository
) : BaseViewModel() {
private val _onAirLiveStateLiveData = MutableLiveData<HomeOnAirLivePageUiState>()
val onAirLiveStateLiveData: LiveData<HomeOnAirLivePageUiState>
get() = _onAirLiveStateLiveData
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _toastLiveData = MutableLiveData<ToastMessage?>()
val toastLiveData: LiveData<ToastMessage?>
get() = _toastLiveData
private var requestGeneration: Int = 0
fun loadFirstPage() {
val generation = ++requestGeneration
_isLoading.value = true
_onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Loading
requestOnAirLives(page = FIRST_PAGE, generation = generation) { response ->
_isLoading.value = false
val data = response.data
if (response.success && data != null) {
val state = data.toUiState()
_onAirLiveStateLiveData.value = if (state.items.isEmpty()) {
HomeOnAirLivePageUiState.Empty
} else {
HomeOnAirLivePageUiState.Content(state)
}
} else {
showFirstPageError(response.message)
}
}
}
fun loadNextPage() {
val content = (_onAirLiveStateLiveData.value as? HomeOnAirLivePageUiState.Content)?.content ?: return
if (!content.hasNext || content.isLoadingMore) return
val generation = requestGeneration
_onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Content(
content.copy(isLoadingMore = true, paginationErrorMessage = null)
)
requestOnAirLives(page = content.page + 1, generation = generation) { response ->
val current = (_onAirLiveStateLiveData.value as? HomeOnAirLivePageUiState.Content)?.content ?: content
val data = response.data
if (response.success && data != null) {
val next = data.toUiState()
_onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Content(
current.copy(
items = current.items + next.items,
page = next.page,
size = next.size,
hasNext = next.hasNext,
isLoadingMore = false,
paginationErrorMessage = null
)
)
} else {
_onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Content(
current.copy(isLoadingMore = false, paginationErrorMessage = response.message)
)
}
}
}
fun consumePaginationErrorMessage() {
val content = (_onAirLiveStateLiveData.value as? HomeOnAirLivePageUiState.Content)?.content ?: return
if (content.paginationErrorMessage == null) return
_onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Content(
content.copy(paginationErrorMessage = null)
)
}
private fun requestOnAirLives(
page: Int,
generation: Int,
onSuccess: (ApiResponse<HomeOnAirLivePageResponse>) -> Unit
) {
compositeDisposable.add(
repository.getOnAirLives(authHeader = authHeader(), page = page)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
if (generation == requestGeneration) {
onSuccess(it)
}
},
{
if (generation != requestGeneration) return@subscribe
it.message?.let { message -> Logger.e(message) }
_isLoading.value = false
val current = (_onAirLiveStateLiveData.value as? HomeOnAirLivePageUiState.Content)?.content
if (current != null && page > FIRST_PAGE) {
_onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Content(
current.copy(isLoadingMore = false, paginationErrorMessage = it.message)
)
} else {
showFirstPageError(it.message)
}
}
)
)
}
private fun showFirstPageError(message: String?) {
_onAirLiveStateLiveData.value = HomeOnAirLivePageUiState.Error(message)
_toastLiveData.value = ToastMessage(resId = R.string.common_error_unknown)
}
private fun authHeader(): String? = homeOnAirLiveAuthHeader(SharedPreferenceManager.token)
companion object {
private const val FIRST_PAGE = 0
}
}
@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.v2.live.onair.data
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Query
interface HomeOnAirLiveApi {
@GET("/api/v2/home/on-air-lives")
fun getOnAirLives(
@Header("Authorization") authHeader: String?,
@Query("page") page: Int
): Single<ApiResponse<HomeOnAirLivePageResponse>>
}
@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.v2.live.onair.data
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class HomeOnAirLivePageResponse(
@SerializedName("items") val items: List<HomeOnAirLiveResponse>,
@SerializedName("page") val page: Int,
@SerializedName("size") val size: Int,
@SerializedName("hasNext") val hasNext: Boolean
)
@Keep
data class HomeOnAirLiveResponse(
@SerializedName("roomId") val roomId: Long,
@SerializedName("creatorNickname") val creatorNickname: String,
@SerializedName("creatorProfileImage") val creatorProfileImage: String,
@SerializedName("title") val title: String,
@SerializedName("price") val price: Int,
@SerializedName("beginDateTimeUtc") val beginDateTimeUtc: String
)
@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.v2.live.onair.data
class HomeOnAirLiveRepository(
private val api: HomeOnAirLiveApi
) {
fun getOnAirLives(authHeader: String?, page: Int) = api.getOnAirLives(
authHeader = authHeader,
page = page
)
}
@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.v2.live.onair.model
fun homeOnAirLiveAuthHeader(token: String): String? = token
.trim()
.takeIf { it.isNotEmpty() }
?.let { "Bearer $it" }
@@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.v2.live.onair.model
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailResponse
fun canEnterHomeOnAirLiveRoom(roomDetail: GetRoomDetailResponse): Boolean {
return roomDetail.channelName.isNullOrBlank().not()
}

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