Compare commits

...

246 Commits

Author SHA1 Message Date
194c4bad84 feature(agora): rtc version 4.5.2 2025-10-31 14:17:04 +09:00
1b7ba7825e feature(version): versionCode 198, versionName 1.43.0 2025-10-30 17:21:05 +09:00
5689dd10a5 feature(home): 지금 라이브 중인 라이브의 이미지를 크리에이터의 프로필 이미지가 표시되도록 수정 2025-10-30 17:02:29 +09:00
648064eac7 feature(version): versionCode 197, versionName 1.43.0 2025-10-30 16:01:10 +09:00
1ca6d068d0 live-room(agora): rtm version 1.5.3 -> 2.2.6 2025-10-30 14:54:21 +09:00
f08c481807 refactor(agora): 코드 파악을 좀 더 쉽게 할 수 있도록 코드 재배치 2025-10-27 23:07:44 +09:00
f64b28af1b feat(live-room): 사용하지 않는 후원현황 채팅 제거 2025-10-27 18:13:07 +09:00
2a50d0f5a0 build(live-room): agora rtc voice-sdk library version up
- voice-sdk:4.6.0
2025-10-24 01:19:39 +09:00
149d7358f0 build, fix(app): targetSdk 35 업그레이드 점검 및 Android 15 정확 알람 호환성 보완, Android 15 대응 보완
- 정확 알람 예외 처리 및 백그라운드 서비스 시작 회피
- setAlarmClock 호출부 SecurityException 처리 추가(1회/반복 알람)
- 401 응답 시 startService → stopService로 변경해 O+/15 백그라운드 서비스 제약 회피
2025-10-24 00:45:11 +09:00
a86e55eeae build(app): library upgrade
media3-session:1.8.0
media3-exoplayer:1.8.0
mockito-core:5.20.0
mockk:1.14.6
2025-10-24 00:28:21 +09:00
3979d37e76 build(app): library upgrade
firebase-bom:33.16.0
androidx.room:2.8.3
kotlinx-coroutines-android:1.10.2
af-android-sdk:6.17.4
2025-10-24 00:19:00 +09:00
d8d05b57cb build(app): library upgrade
media:1.7.1
core-ktx:1.16.0
appcompat:1.7.1
recyclerview:1.4.0
material:1.13.0
constraintlayout:2.2.1

webkit:1.14.0
lifecycle-livedata-ktx:2.9.4
lifecycle-viewmodel-ktx:2.9.4

gson:2.13.2
retrofit:3.0.0
converter-gson:3.0.0
adapter-rxjava3:3.0.0
logging-interceptor:5.2.1
tedpermission-normal:3.4.2
2025-10-24 00:02:40 +09:00
f1d718a45f build(app): bump compileSdk/targetSdk to 35
- compileSdk 35, targetSdk 35로 상향
- edge-to-edge를 적용하고 전체 화면에 insets를 추가 적용하여 이전과 동일하게 statusbar, navigationbar를 침범하지 않도록 처리
2025-10-23 23:32:58 +09:00
d33ab59378 fix(in-app-purchase): 인 앱 결제 완료 후 충전내역으로 이동하도록 코드 수정 2025-10-23 14:10:29 +09:00
f8e4a4fd45 build: versionCode 196, versionName 1.43.0 2025-10-23 14:09:39 +09:00
6d099e0aab build: versionCode 195, versionName 1.43.0 2025-10-23 12:06:11 +09:00
c5eb9767aa fix(iap): 인 앱 결제 라이브러리 버전 8.0.0 적용, 결제 보완사항 적용 — 즉시 소비, ITEM_ALREADY_OWNED 처리, obfuscatedAccountId 설정
- 구매 성공 직후 consume 처리하여 재구매 불가(ITEM_ALREADY_OWNED) 이슈 완화
- ITEM_ALREADY_OWNED 응답 시 미소비 구매 자동 정리 및 안내 메시지
- BillingFlowParams에 obfuscatedAccountId 설정으로 계정 연계 강화
- 서비스 연결 문제에 대한 사용자 메시지 보강
2025-10-22 23:40:14 +09:00
24672b7cf2 build(gradle): jvmTarget를 compilerOptions+jvmToolchain으로 마이그레이션
Kotlin 2.x에서 deprecated된 `kotlinOptions.jvmTarget` 사용을 제거하고
최신 DSL(`kotlin { jvmToolchain(17); compilerOptions { jvmTarget = JVM_17 } }`)로 전환했습니다.

왜: Kotlin Gradle Plugin 2.x에서 `kotlinOptions.jvmTarget`가 deprecated되어 빌드 경고가 발생했습니다.
무엇: `app/build.gradle`의 `kotlinOptions { jvmTarget = ... }` 제거 후, 최상위 `kotlin` 블록을 추가하여
- `jvmToolchain(17)` 설정
- `compilerOptions { jvmTarget.set(JVM_17) }` 적용
영향: 컴파일 타깃과 JDK 툴체인을 명시적으로 17로 고정하여 빌드 일관성을 확보하고 경고를 제거합니다.
2025-10-22 21:05:08 +09:00
db6de22273 사용하지 않는 databinding 설정 제거 2025-10-22 20:51:19 +09:00
8cdb82765f feat(build): agp 8.13.0, gradle-wrapper 8.14.3 업그레이드 2025-10-22 20:41:19 +09:00
172d7c0b80 feat(build): kotlin 2.2.20, agp 8.11.1 업그레이드 2025-10-22 20:08:40 +09:00
cf86dd3f30 fix(room): Kotlin 2.1/KSP 2.0 환경에서 KSP 오류 해결을 위해 Room 2.7.0으로 업그레이드
Kotlin을 2.1.21, KSP를 2.1.21-2.0.2로 올린 뒤 발생한
`unexpected jvm signature V` 예외를 해결하기 위해 Room(compiler, runtime, ktx, rxjava3)
버전을 2.6.1 → 2.7.0으로 업그레이드.

빌드가 정상 완료되며 KSP 태스크도 성공적으로 수행됨을 확인함.
2025-10-22 19:39:50 +09:00
23c05b91d5 build(room): KSP room.schemaLocation 설정 및 exportSchema=true로 스키마 export 활성화
프로젝트가 이미 KSP를 사용하고 있어 KSP 인수 기반으로 Room 스키마 export를 활성화했습니다.
- app/build.gradle: ksp { room.schemaLocation 등 } 추가
- Room DB 클래스 3종: exportSchema=true
- app/schemas 디렉터리 버전 관리
2025-10-22 19:23:58 +09:00
7ff3d7f1e5 refactor(root-gradle): deprecated 문법 신규 문법으로 전환
- task -> tasks.register로 전환
- rootProject.buildDir -> rootProject.layout.buildDirectory로 수정
2025-10-22 18:11:37 +09:00
912518c1ae refactor(config): buildConfig 설정 위치 권장 설정 위치로 변경
- 기존: gradle.properties android.defaults.buildfeatures.buildconfig=true

- 변경: build.gradle buildFeatures { buildConfig = true }
2025-10-22 16:33:46 +09:00
9b825ee244 refactor(db): ObjectBox 제거 및 Room으로 마이그레이션
- 최상위/app Gradle에서 ObjectBox 플러그인 제거
- PlaybackTracking을 Room Entity/DAO/Database로 전환
- Repository를 Room 기반으로 수정 및 Koin DI 주입 변경
2025-10-22 16:25:32 +09:00
bc581d763b fix(build): Room KAPT→KSP 마이그레이션 및 configuration cache 비활성화로 Kotlin 2.0 빌드 오류 해결
- Room을 2.6.1로 업데이트하고 KAPT를 KSP로 전환
- room-rxjava3 의존성 추가(RxJava3 반환 타입 지원)
- ObjectBox 플러그인과 충돌 회피를 위해 configuration cache 비활성화
- AGP 8.4.2 + Kotlin 2.0.21 환경에서 빌드 성공 확인
2025-10-22 13:50:42 +09:00
dd236d8f19 feat(live-reservation-all): 주간 캘린더 라이브러리 제거 및 개별 구현 2025-10-22 12:12:02 +09:00
ff236ee6a1 remove audio visualizer 2025-10-21 15:31:57 +09:00
66a6f992eb feat: versionCode 194, versionName: 1.42.1 2025-10-21 11:17:19 +09:00
c6438bef67 fix(home): 인기 캐릭터 -> 인기 캐릭터 채팅 2025-10-20 22:33:02 +09:00
ee5490939b fix(ChatRoom): 채팅 quota 구매 캔 개수 표시 수정
- 기존: 30결제하고 바로 대화 시작 -> 수정: 10(채팅 12개) 바로 대화 시작
2025-10-20 21:44:25 +09:00
65a2b47045 fix(GetHomeResponse): Character 클래스가 잘못 import 되어 있던 것 수정 2025-10-20 20:15:44 +09:00
a56c21f856 feat(user-profile): 팔로워 수 문구 팔로워 OO에서 팔로워 OO명으로 변경 2025-10-20 19:20:23 +09:00
7e501c794d feat(user-profile): 팬 Talk 답변 글 배경색 변경 2025-10-20 19:18:02 +09:00
c07fb33968 feat(user-profile): 더보기 버튼 흰색으로 변경 2025-10-20 18:58:14 +09:00
7ecb36a7be feat(home): 인기 캐릭터 색션 추가 2025-10-20 18:57:09 +09:00
1cec07f8c5 feat(user-profile): 팔로우/팔로잉 버튼 변경 2025-10-20 14:28:05 +09:00
ddcf191ade feat(user-profile): 최신콘텐츠 좋아요, 댓글 아이콘 크기 24x24 -> 18x18로 변경 2025-10-20 14:07:01 +09:00
945e3bd239 feat(temp): 작품별 탭 임시 제거 2025-10-17 14:44:49 +09:00
09ed73300d feat(user-channel): 팬 Talk 섹션 아이템 UI 수정 2025-10-17 09:22:41 +09:00
83fa3b870c feat(home): 인기 크리에이터 섹션 아이템 팔로우 버튼 표시 조건 추가
- 크리에이터 != 나 인 경우에만 팔로우/팔로잉 버튼 표시
2025-10-17 04:42:59 +09:00
cb67787925 feat(user-channel): 유저 채널 상단 툴바 오른쪽 상단 공유/메뉴 아이콘 정렬 수정
- LinearLayout으로 감쌈
- 메뉴 아이콘이 없어도 공유 아이콘이 오른쪽 상단에 위치할 수 있도록 정렬
2025-10-16 23:54:42 +09:00
ad053ef889 feat(user-channel): 유저 채널 라이브 아이템 터치 이벤트 추가 2025-10-16 23:52:25 +09:00
ae92921b7b feat(user-channel): 유저 채널 UI 수정
- 최신 콘텐츠 아이템 표시
- 후원 순위 아이템 사이즈 수정
- 섹션 제목 사이즈 업
2025-10-16 23:30:58 +09:00
9ba053b807 feat(user-channel): 유저 채널의 라이브 아이템 UI 수정 2025-10-16 19:00:46 +09:00
2b8b581082 feat(user-channel): 유저 채널의 프로필 이미지 사이즈와 섹션 순서 변경 2025-10-16 00:13:29 +09:00
0b775ed380 fix(payverse-webview): webView 세팅 조정을 통해 네이버페이가 동작하지 않던 버그 수정
- 참고: line 315 ~ 325
2025-10-15 15:39:34 +09:00
a90f4b1c5a fix(creator-community-write): 이미지를 선택하면 recordAudio영역이 보이도록 수정 2025-10-13 11:06:30 +09:00
5bc2b385fa feat(can): 사용 하지 않는 price 값 제거
feat(webview): payverse:// 스킴은 앱이 있으면 앱을 실행하도록 처리
2025-10-03 00:04:43 +09:00
21f57444c8 feat(can-payment): 다국적 통화 표기 지원 및 결제 금액 표시 개선
- KRW 고정 표기에서 벗어나 PG/해외 결제 등 다양한 통화 표기를 정확히 지원하기 위함
2025-10-02 17:14:49 +09:00
662f18bceb feat(can-charge): 이롬넷(Payverse) 통합결제 추가 2025-10-01 01:47:42 +09:00
2635b7d3c3 versionCode 191, versionName 1.42.1 2025-09-25 12:06:14 +09:00
aac3910b43 feat(original): 작품별 상세 UI
- 블러 처리한 배경의 세로 크기 절반으로 축소
2025-09-24 17:00:35 +09:00
0319981650 feat(original): UI 변경
- 캐릭터 / 작품 정보 탭 추가
- 작품 정보 탭 구성
  - 작품 소개
  - 원작 보러 가기
  - 상세 정보
    - 작가
    - 제작사
    - 원작
2025-09-19 18:35:18 +09:00
44e209d7b1 fix(ImagePickerCropper): openDocument 제거, excludeGif가 true이고 GIF 선택시 "GIF는 지원하지 않습니다." 메시지 반환 2025-09-18 22:02:54 +09:00
0f170c6daa fix(프로필 수정): gif 선택이 불가능 하도록 수정 2025-09-18 01:26:43 +09:00
67109bfe3c fix(Manifest): com.yalantis.ucrop.UCropActivity 추가 2025-09-18 01:02:18 +09:00
d22907c7d5 fix(이미지 선택): 이미지 선택 및 크롭 로직 수정 2025-09-18 00:17:20 +09:00
02155065f7 fix(liveroom-create): 경고 제거 2025-09-17 19:01:30 +09:00
3c21b36e88 fix: 라이브 생성 이미지 선택
- 이미지 선택 및 Crop 방법 변경
2025-09-17 18:49:36 +09:00
93fa042522 feat(character): 신규 캐릭터 전체보기 페이지 GRID
- 3단 구성에서 2단구성으로 변경
2025-09-17 02:45:45 +09:00
dcde2b125e feat(chat-original): 원작 상세 화면 및 캐릭터 무한 스크롤 로딩 구현 2025-09-15 19:19:00 +09:00
f15c6be1a4 feat(chat-original): ChatFragment에 작품별 탭 및 리스트 UI/API 연동 추가
- ChatFragment에 '작품별' 탭 추가 및 프래그먼트 스위칭 로직 반영
- /api/chat/original/list API, 모델, 레포지토리, ViewModel 추가
- OriginalTabFragment/Adapter/레이아웃 구현 (3단 그리드, 간격 16dp, 이미지 라운드 16dp, 아이템 이미지의 레이아웃 비율을 306:432)
- 스크롤 끝 감지를 구현하여 무한 스크롤을 지원
2025-09-15 16:21:54 +09:00
05208d3031 feat(chat-character): 신규 캐릭터 전체보기 화면 및 API 연동 추가 2025-09-13 02:15:01 +09:00
2b892fe783 feat(character): 본인인증 하지 않은 유저가 캐릭터 상세보기로 들어갈 때 본인인증 팝업 띄움 2025-09-12 01:13:08 +09:00
c3c19db730 feat(icon): 앱 아이콘 변경 2025-09-12 01:12:29 +09:00
b70c8058e8 feat(splash): 스플래시 페이지 수정 2025-09-11 22:16:00 +09:00
cdc59d0877 fix(main): 라이브 탭 <-> 채팅 탭 순서 변경 2025-09-11 20:06:16 +09:00
88d13ce77a fix(character): 인기 캐릭터
- TextView 숫자 하단 여백(descent) 제거
2025-09-11 20:04:13 +09:00
f830c98b8e fix(character-detail): 캐릭터 정보
- 캐릭터 이름과 MBTI 사이 간격 8로 수정
2025-09-11 14:58:30 +09:00
8de0dc2242 feat(chat): Talk 탭에 RecyclerView 스크롤 페이지네이션 추가
- /api/chat/room/list 호출에 page 파라미터 적용 (0부터 시작)
- ViewModel에 currentPage/lastPageReached 상태 추가 및 append 로직 구현
- Fragment에 스크롤 리스너로 바닥 근접 시 다음 페이지 자동 로드
- 빈 데이터 시 마지막 페이지로 간주하여 추가 로딩 중단
2025-09-11 14:38:29 +09:00
56e99912d4 "fix(chat-room): 쿼터 UI를 totalRemaining 대신 nextRechargeAtEpoch 기준으로 갱신 2025-09-10 13:51:07 +09:00
9ed3c046b3 fix(chat-room): 채팅방
- 쿼터 상태 조회, 쿼터 구매 API URL 변경
2025-09-10 12:03:49 +09:00
65791c55ca feat(ui): enforce 2:3 aspect ratio and center chatroom background
- item_character_gallery.xml: set iv_image to 2:3
- activity_chat_room.xml: apply H,2:3 ratio and center frame by constraining top/bottom to parent
- item_chat_background_image.xml: set picker item to 2:3
- align dim view constraints to match background area
2025-09-05 18:30:58 +09:00
0422746267 fix(chat-room settings): 배경 사진 -> 배경 이미지 로 변경 2025-09-05 18:27:22 +09:00
cc3aca34f5 fix(character-detail): 캐릭터 정보 추가
- mbti, 나이, 성별 추가
2025-09-05 17:43:04 +09:00
e39bdb6b03 fix(character-detail): 상단 툴바 제목을 "캐릭터 정보"로 고정 2025-09-05 14:16:25 +09:00
27a36d2d44 fix: place_holder 변경 2025-09-05 12:52:33 +09:00
60b7bb7e7e fix(character): 캐릭터 이미지 RoundedCorner 16dp 적용 2025-09-05 12:48:26 +09:00
8ebaaefd6f fix(character-main): 큐레이션 섹션 데이터 이름 수정
- CurationSection.kt
- id -> characterCurationId
2025-08-29 14:47:05 +09:00
201ab488b2 fix(character-main): 최근 대화 캐릭터
- 터치시 채팅방이 아닌 캐릭터 상세 페이지로 이동
2025-08-28 20:00:22 +09:00
8b241709e1 fix(chat): 대화 설정
- 대화 초기화 오른쪽에 30캔 안내 추가
2025-08-28 01:53:45 +09:00
d9cb12e882 fix(chat): 채팅방 입장 시 서버 멤버 정보로 캔 배지 동기화
- ChatRoomActivity에서 getMemberInfo 호출 추가
- 응답 성공 시 SharedPreferenceManager.can/point 갱신 및 헤더 배지 즉시 반영
- 네트워크 실패 시 UI 흐름 방해 없이 조용히 무시 처리
2025-08-28 00:46:52 +09:00
5c78c567ca fix(chat): 대화 초기화 성공 시 로컬 데이터 삭제 및 로딩 다이얼로그 적용
- ChatMessageDao: deleteMessagesByRoomId(roomId) 추가
- ChatRepository: clearMessagesByRoom(roomId) 추가
- ChatRoomActivity:
  - clearLocalPrefsForRoom(roomId) 구현
  - reset 플로우에 Prefs/DB 삭제 체인 연결
  - onResetChatRequested()에서 LoadingDialog 표시 및 doFinally로 닫힘 보장
2025-08-28 00:23:14 +09:00
e3bcc6d3a6 사용하지 않는 함수 삭제 2025-08-27 16:50:54 +09:00
05e8874d81 fix(chat): 대화 초기화 성공 시 방별 로컬 데이터(배경/공지/메시지) 삭제 처리
- ChatMessageDao: deleteMessagesByRoomId(roomId) 추가
- ChatRepository: clearMessagesByRoom(roomId) 추가
- ChatRoomActivity: clearLocalPrefsForRoom(roomId) 구현 및 reset 플로우에 Prefs/DB 삭제 체인 연결
- 요구사항: 대화 초기화 API 성공 시 해당 방의 배경 데이터와 로컬 메시지 등 모든 관련 데이터 제거
2025-08-27 16:49:27 +09:00
88e3ae7b51 fix(chat): 배경 선택 다이얼로그에서 초기 선택 복원이 되지 않는 문제 수정
- 선택 상태를 URL 비교에서 이미지 ID 우선 방식으로 변경
- URL만 저장된 기존 데이터에 대해 목록 로드 후 URL→ID 마이그레이션 추가
- SharedPreferences에 chat_bg_image_id_room_{roomId} 키 도입(호환 위해 URL 키 유지)
2025-08-27 15:53:43 +09:00
02df0b6774 feat(chat): 메시지 괄호 지문 색상을 회색으로 변경 2025-08-27 14:10:30 +09:00
a941d0bfab feat(chat): 채팅방 배경 사진 변경 기능 추가
- ChatRoomMoreDialog에서 배경 사진 변경 Picker 연결
- my-list API 추가 및 Repository 위임 추가
- 배경 선택 Dialog(3열 Grid, 4:5 비율) 및 선택 상태 UI 구현
- SharedPreferences로 roomId별 배경 URL 저장/로드
- ChatRoomActivity에 배경 저장/적용 헬퍼 추가 및 기본 프로필 적용 로직 구현
2025-08-27 02:37:20 +09:00
2e837bec5d feat(chat-quota): 쿼터 연동 및 카운트 다운 / 쿼터 구매 UX 개선(+5초 표시 보정)
- TalkApi: /api/chat/quota/me, /api/chat/quota/purchase 엔드포인트 추가
- Repository: getChatQuotaStatus(), purchaseChatQuota() 추가, sendMessage 응답 타입을 SendChatMessageResponse로 전환
- Model: ChatQuotaStatusResponse/ChatQuotaPurchaseRequest 추가, SendChatMessageResponse/ChatRoomEnterResponse 기본값 추가
- UI(Adapter): QuotaNotice 뷰타입/레이아웃 추가, 안정 ID/부분 갱신(payload) 적용, Change 애니메이션 비활성화로 깜빡임 최소화
- UI(Activity): 쿼터 0 시 입력창 숨김 + 안내 노출, 00:00:00 도달 시 /quota/me 조회
- 카운트다운 계산: epoch 기반 남은 시간 계산 + 표시용 +5초(DISPLAY_FUDGE_MS) 가산
- 구매 성공 시 로컬 30캔 차감 및 헤더 배지 즉시 갱신
2025-08-26 21:36:31 +09:00
9b1a83bd69 feat(chat-room): 대화 설정 다이얼로그 구현 및 채팅방 초기화 API 연동
- MoreDialog UI 구성 및 동작(배경 스위치/변경, 대화 초기화, 신고하기)
- 방별 배경 표시 SharedPreferences 저장 및 화면 반영
- TalkApi에 resetChatRoom 엔드포인트 추가, Repository 메서드 추가
- ChatRoomActivity와 다이얼로그 연동, 초기화 플로우 구현
2025-08-26 13:37:58 +09:00
b3553f80c6 feat(chat): 채팅방 상단 캔 배지 및 더보기 전체화면 다이얼로그 추가
- 헤더 우측에 캔 배지(tv_can_badge)와 더보기(iv_more) 추가
- 캔 배지 스타일 적용(배경 #263238, 텍스트 white, v5/h8 패딩, can 아이콘)
- 더보기 클릭 시 전체화면 다이얼로그 표시(플레이스홀더 UI)
2025-08-26 12:11:43 +09:00
5d76ff1590 feat(chat): AI 유료/이미지 메시지 및 구매 플로우 추가
- ServerChatMessage/ChatMessage에 messageType/imageUrl/price/hasAccess 필드 반영
- TalkApi/Repository: 유료 메시지 구매 API 연동 및 성공 시 로컬 DB 반영
- ChatRoomActivity: 구매 팝업 SodaDialog 적용(취소/잠금해제) 및 구매 성공 시 메시지 교체
- ChatMessageAdapter: 이미지 렌더링(라운드 10dp), 유료 오버레이(가격+"눌러서 잠금해제") 처리,
  구매/캐러셀 오픈 콜백 추가
- 구매된 이미지 클릭 시 전체화면 캐러셀 지원
- item_chat_ai_message.xml: 메시지 UI 최대 90% 폭, 시간 텍스트 배치 개선, 이미지 4:5 비율 적용
- 그룹 메시지 간 간격 절반 적용(ItemDecoration)
- Room DB v2 마이그레이션: messageType/imageUrl/price/hasAccess 컬럼 추가로 재입장 시 표시 문제 해결

왜:
- 유료/이미지 메시지 기능 제공 및 일관된 구매 경험 필요
- 재입장 시 이미지/유료 정보 누락 문제(DB 정합) 해결
- 시간 잘림/배치 문제와 그룹 간격 시인성 개선
2025-08-25 17:22:56 +09:00
6c57c5a98a feat(character-gallery): 구매 이미지 전체화면 Carousel 뷰어 추가
구매된 이미지를 탭하면 전체화면 DialogFragment로 열리고,
ViewPager2 기반 Carousel로 좌우 슬라이딩 탐색이 가능하도록 구현.
2025-08-23 01:48:57 +09:00
770c4179a3 fix(gallery): 구매 다이얼로그를 AlertDialog에서 SodaDialog로 교체
디자인 일관성 및 공통 컴포넌트 적용을 위해 갤러리 탭의 구매 확인 다이얼로그에
SodaDialog를 사용하도록 변경
2025-08-22 22:17:31 +09:00
9164942395 feat(gallery): 로딩 다이얼로그 표시 및 이미지 캐싱 적용
Fragment에서 isLoading에 따라 Loading Dialog를 표시/해제.
Glide에 디스크 캐싱 적용으로 스크롤 성능 개선.
2025-08-22 22:12:36 +09:00
e3ed816fb3 feat(gallery): 캐릭터 이미지 구매 기능 추가
갤러리 아이템의 구매 버튼 클릭 시 확인 다이얼로그를 표시하고,
확인 시 /api/chat/character/image/purchase API를 호출하여 이미지 URL을 갱신.
구매 성공 시 isOwned=true 처리 및 보유 비율/개수 업데이트.
2025-08-22 21:49:44 +09:00
13ee098cfc feat(character-gallery): 갤러리 탭 UI/페이징 및 API 연동, DI 적용
- API: CharacterApi에 이미지 리스트 API 추가(characterId, page, size)
- VM: 페이징(loadInitial/loadNext), 요청 중복 방지, 마지막 페이지 판단, 누적 리스트 관리
- UI: ProgressBar(배경 #37474F/진행 #3BB9F1, radius 999dp, 비활성) + 좌/우 텍스트 구성
- Grid 3열 + 2dp 간격, item 4:5 비율, 잠금/구매 버튼 UI 적용
- UX: tv_ratio_right에서 ownedCount만 #FDD453로 강조(white 대비)
2025-08-22 17:03:01 +09:00
f917eb8c93 fix(character-detail): characterId 전달 및 상세 탭 전환 로직 수정
fix(character-detail): 탭 전환 시 프래그먼트 캐싱하여 재로딩 방지

CharacterDetailFragment에 newInstance(characterId) 도입 및 ARG 전달 구조 추가.
Fragment에서 잘못된 intent 참조 제거하고 arguments → activity.intent 순으로 안전하게 조회.
Activity 초기 진입 시 상세 탭 로딩 경로 정리 및 characterId 유효성 검사 시 종료 처리 보강.

replace 기반 교체를 add/show/hide 구조로 전환.
TAG_DETAIL/TAG_GALLERY로 인스턴스를 식별하여 FragmentManager 복원/재사용.
탭 이동 시 기존 인스턴스 표시만 수행하여 onViewCreated 재호출/네트워크 재요청 방지.
2025-08-22 15:23:17 +09:00
989a0f361b feat(character-detail): 캐릭터 상세
- 탭 UI 추가
2025-08-22 03:39:36 +09:00
52c1f61109 feat(report): 캐릭터 댓글 신고 사유를 라디오 버튼으로 변경 및 비활성 시각화
- 댓글 신고 사유 리스트 변경
- 댓글 신고 사유 선택 UI를 RadioGroup/RadioButton으로 전환
- 선택 전 신고 버튼 비활성화 및 alpha 적용으로 시각적 비활성화 처리
- 선택 시 버튼 활성화 및 alpha 복구
2025-08-22 03:04:50 +09:00
7dd6d46a5f fix(talk-tab): 채팅방 리스트
- 채팅방 사이 간격 24
- 이미지 원형으로 변경
2025-08-22 02:34:05 +09:00
3a1943ba87 refactor(character-comment): 캐릭터 댓글/답글 리스트
- 배경색 변경
- 댓글 사이 간격 조정
2025-08-20 18:38:16 +09:00
ab1dd04a60 refactor(character-comment): 답글 리스트 MVVM 적용 및 ViewModel 추가
- CharacterCommentReplyViewModel 추가: 로딩/토스트/페이지네이션/CRUD 로직 이관
- AppDI Koin 모듈에 Reply ViewModel 등록
- CharacterCommentReplyFragment에서 Repository 직접 접근 제거 및 바인딩 로직 추가
2025-08-20 16:49:51 +09:00
ccd88dad47 refactor(chat/character): 댓글 리스트 화면에 ViewModel 도입 및 Fragment-Repository 직접 의존 제거
CharacterCommentListViewModel을 추가하여 댓글 조회/등록/삭제/신고 및 페이지네이션 로직을 ViewModel로 이전.
Fragment는 UI 업데이트와 사용자 입력 처리에 집중하도록 리팩토링.
Koin DI에 ViewModel 등록.
2025-08-20 16:22:34 +09:00
fdc9ba80e0 fix(comment): 답글 더보기 Bottom Sheet 적용 및 삭제/신고 API 연동
답글 리스트에서 PopupMenu를 Bottom Sheet로 통일하고, 내 답글은 삭제, 타인 답글은 신고 메뉴만 노출하도록 변경.
삭제는 원 댓글 삭제와 동일한 API(deleteComment)를 사용하며, 신고는 reportComment로 연동.
2025-08-20 15:55:59 +09:00
d1c62fd2b6 fix(comment): 캐릭터 댓글 신고 BottomSheet가 표시되지 않는 문제 수정
- childFragmentManager 대신 parentFragmentManager로 신고 BottomSheet 표시
- BottomSheet dismiss 직후 show 트랜잭션 충돌/우선순위 이슈 완화
2025-08-20 15:33:47 +09:00
3e2cdd502c fix(character-comment): 캐릭터 댓글 수 표시 수정
- 서버에서 받아온 댓글 수를 표시하도록 수정
2025-08-20 14:00:46 +09:00
c78aed2551 fix(comment): 캐릭터 댓글 더보기에서 삭제 API 연동 및 UI 반영
- Bottom Sheet 삭제 선택 시 deleteComment API 호출 추가
- 성공 시 목록에서 항목 제거
- 오류 시 사용자에게 에러 토스트 노출
2025-08-20 03:19:23 +09:00
e881178f2a feat(character-comment): 답글 리스트 API 연동 및 커서 기반 무한 스크롤 적용
feat(character-comment): 답글 작성 API 연동 및 성공 시 낙관적 UI 반영

- CharacterCommentReplyFragment에 listReplies API 연동
- 초기 1회 로드 허용, 이후 cursor != null일 때만 추가 로드
- isLoading 플래그로 중복 요청 방지
- 어댑터 헤더(원본 댓글) 유지, replies만 순차 추가

- CharacterCommentReplyFragment에서 createReply API 호출로 스텁 제거
- 요청 중 로딩 다이얼로그 표시, 성공 시 입력 초기화 및 리스트에 즉시 추가
- 에러 처리(토스트) 적용
2025-08-20 03:07:35 +09:00
b995a0b151 feat(character-comment): 답글 리스트 API 연동 및 커서 기반 무한 스크롤 적용
- CharacterCommentReplyFragment에 listReplies API 연동
- 초기 1회 로드 허용, 이후 cursor != null일 때만 추가 로드
- isLoading 플래그로 중복 요청 방지
- 어댑터 헤더(원본 댓글) 유지, replies만 순차 추가
2025-08-20 02:48:01 +09:00
ec315c4747 feat(character-comment): 캐릭터 댓글 리스트 등록/목록/신고 API 연동 및 DI 등록
fix(character-comment): 캐릭터 댓글 리스트 무한 스크롤에서 cursor null 시 추가 호출 방지

- CharacterCommentApi/Repository 추가
- AppDI에 API/Repository 등록
- CharacterCommentListFragment: 등록 버튼 클릭 시 API 호출로 전환, 커서 페이징 목록 로드 적용, 신고 API 연동
- 로딩/에러 처리 및 중복 로드 방지 플래그 추가

- 스크롤 리스너에 canLoadMore 조건 추가(초기 또는 cursor 존재 시에만 호출)
- loadMore()에 종료 가드 추가(adapter 비어있지 않고 cursor null이면 반환)
- 댓글 1개인 경우 동일 내용 반복 로딩 문제 해결
2025-08-20 02:37:14 +09:00
52ff0c82cb feat(character-comment): 신고 BottomSheet 추가 및 삭제 확인 팝업 도입
- 신고 BottomSheet(제목/단일선택 리스트/신고 버튼) 구현 및 더보기→신고 흐름 연동
- 삭제 버튼 클릭 시 확인 다이얼로그 표시 후 확정 시 리스트에서 제거
- 신고/삭제 API 호출부는 스텁으로 남겨둠(후속 연동 예정)
2025-08-20 01:22:56 +09:00
d4ec2fbdef feat(character-comment): 답글 페이지 UI 및 페이징 스텁 구현
- 댓글 리스트 아이템 터치 시 답글 페이지로 전환 연결
- 상단 뒤로 가기/닫기, 입력 폼, divider, 원본 댓글, 들여 쓰기된 답글 목록 구성
- RecyclerView 최하단 도달 시 더미 데이터 추가 로드(무한 스크롤 스텁)
- 답글 등록/수정/삭제 동작 스텁 처리
- 추가 파일
  - layout: fragment_character_comment_reply.xml, item_character_comment_reply.xml
  - 코드: CharacterCommentReplyFragment, CharacterCommentReplyAdapter
- 변경 파일
  - CharacterCommentListBottomSheet: openReply() 추가
  - CharacterCommentListFragment: 아이템 클릭 시 답글 페이지 진입
2025-08-20 00:54:00 +09:00
a9742a07c0 feat(character-comment): 캐릭터 댓글 리스트 BottomSheet UI 및 페이징 스텁 구현
- CharacterDetail 댓글 섹션 터치 시 BottomSheet 표시
- 헤더/입력폼/Divider/리스트/더보기 BottomSheet 구성
- RecyclerView 하단 도달 시 더미 데이터 추가 로드(Stub)
- 상대시간 표기(분/시간/일/년 전)
- API 연동은 이후 작업 예정 (스텁)
2025-08-20 00:42:15 +09:00
df1746976c feat(character-detail): 캐릭터 상세 댓글 섹션 추가 및 데이터 바인딩
- 댓글 입력 필드 stroke(흰색 1dp stroke와 radius 5dp) 추가
- 입력 박스 내부 우측에 전송 아이콘(ic_message_send) 추가
- 배경 드로어블(#263238, radius 10dp) 추가
- CharacterCommentResponse에 comment(nullable) 필드 추가
- CharacterDetailActivity에서 latestComment/totalComments 바인딩 및 UI 분기 처리
2025-08-19 18:37:12 +09:00
61cfbe249c fix(character-detail): 더보기 버튼 미표시 문제 수정 (줄 수 측정 시점 조정)
세계관/성격 텍스트의 줄 수를 maxLines=3 적용 이전에 측정하도록 순서 변경.
측정 후 더보기 가시성 결정, 그 다음 접힘 레이아웃 적용.
확장 상태 플래그 및 아이콘/문구 초기화 추가.
2025-08-18 19:13:26 +09:00
f9b50089dd fix(chat): 캐릭터 상세
- 세계관 -> [세계관 및 작품 소개]
- 성격 -> [성격 및 특징]
- 전체보기 -> 더보기
2025-08-18 16:37:29 +09:00
95983dcf5b fix(chat): 최근 대화한 캐릭터
- 캐릭터 이미지 원형으로 변경
2025-08-18 16:31:46 +09:00
16e8941c15 fix(chat): 캐릭터 상세
- 캐릭터 이미지 딤 제거
- 캐릭터 정보: 이미지 아래로 이동
2025-08-15 01:03:43 +09:00
cd4a098bff fix(chat): 동시간대 메시지 정렬을 messageId 오름차순으로 안정화
createdAt만 사용하던 정렬 로직을 다중 키로 변경하여
동일 시간에 messageId 오름차순이 보장되도록 수정.
- 로컬 초기 로드: createdAt -> messageId -> localId asc
- 서버 초기/증분 로드: createdAt -> messageId asc
2025-08-15 00:45:22 +09:00
4a0940ad26 fix(chat-room): 프로필 이미지 circle로 변경 2025-08-15 00:37:08 +09:00
dd7251f18b fix(chat-room): 채팅 아이템 UI, 메시지 입력 창 UI
- 채팅 아이템이 화면을 벗어나는 버그 수정
- 메시지 입력창 글자크기 14sp, rounded corner 32dp
2025-08-15 00:29:56 +09:00
3d727f07fa fix(chat-room): header_container
- 이름과 캐릭터 타입을 세로로 표시
2025-08-14 23:01:31 +09:00
92883ee577 fix(chat-room): 메시지 전송 API URL 수정
기존
/api/chat/room/{roomId}/messages

변경
/api/chat/room/{roomId}/send
2025-08-14 22:40:27 +09:00
2790bea1d8 fix(chat-room): stable IDs 설정 시점을 setAdapter 이전으로 이동
- ChatMessageAdapter: onAttachedToRecyclerView에서 setHasStableIds 호출 제거
- ChatRoomActivity: 어댑터 생성 직후 setHasStableIds(true) 설정 후 RecyclerView에 연결

원인: 옵저버 등록 이후 setHasStableIds 변경으로 런타임 예외 발생
검증: 단위 테스트 모두 통과, 빌드 성공
2025-08-14 22:36:50 +09:00
3f87b35816 refactor(chat-room): 페이징 커서 fallback/저장 로직을 createdAt→messageId로 정합성 수정
- 왜: 서버 계약에 따라 cursor 의미가 단독 messageId로 확정됨. createdAt 기반 커서는 페이징 경계에서 중복/누락을 유발할 수 있음
- 무엇: ChatRoomActivity.loadMoreMessages()/loadInitialMessages()에서 cursor 계산 및 nextCursor 대체 저장을 messageId 기준으로 변경. Repository/API 타입은 그대로 유지
2025-08-14 21:27:17 +09:00
bd86d1610a fix(chat-room): api url 수정
- /api/chat/rooms/... -> /api/chat/room/...
2025-08-14 20:29:36 +09:00
7f1b1b1ed3 feat(chat-room): 안내 메시지 접힘 상태 저장시 사용하는 key
- string 오류로 인해 제대로 표시 되지 않던 버그 수정
2025-08-14 20:25:35 +09:00
09b8979ba0 feat(chat-room): sendMessage 응답 다건 변경 반영
- TalkApi.sendMessage: ApiResponse<List<ServerChatMessage>>로 변경
  - ChatRepository.sendMessage: Single<List<ServerChatMessage>>로 변경. 로컬 SENDING→SENT 업데이트 후, 응답 메시지 전체를 DB에 저장
  - ChatRoomActivity: 구독부에서 List를 처리하며 mine == false(AI) 메시지들만 순서대로 append. 타이핑 인디케이터는 성공/실패 시 동일하게 제거
2025-08-14 20:23:21 +09:00
02747c539b test(chat-room): 타이핑 인디케이터 표시/중복/숨김 테스트 추가
- showTypingIndicator 중복 호출 시 중복 삽입 방지 검증
- hideTypingIndicator 안전성 검증(표시되지 않은 경우도 안전)
- NPE 회귀 방지

fix(adapter): RecyclerView 미부착 상태에서 notify 호출로 NPE 발생 방지
2025-08-14 19:19:38 +09:00
c1012586ce fix(chat-room): 접근성 라벨 및 다국어 문자열 적용
- 레이아웃 contentDescription 하드코딩 제거 및 strings 리소스화
- ChatMessageAdapter 접근성 문구를 리소스 기반으로 변환
- values-en 추가로 안내/버튼/접근성/상태 문구 영문화
- 타이핑 인디케이터 접근성 라벨 추가
2025-08-14 18:50:32 +09:00
c9b6623eac perf(chat): DiffUtil 및 stableIds 적용으로 채팅 리스트 갱신 최적화
- ChatMessageAdapter에 DiffUtil 기반 submitList 도입으로 불필요한 전체 바인딩 제거
- RecyclerView 연결 시점에만 stableIds 활성화하여 테스트 환경 NPE 회피
- AI 프로필 이미지 중복 로딩 방지(tag 비교)로 네트워크/디코딩 비용 절감
- onViewRecycled에서 애니메이션/리스너/이미지 정리로 메모리 안정성 향상
2025-08-14 18:13:40 +09:00
d662bd0b65 feat(chat-ui): 메시지 그룹화, 시간 포맷팅, Repository 테스트 추가 2025-08-14 18:08:01 +09:00
ec60d4f143 fix(settings): 로그아웃 시 로컬 채팅 메시지 전체 삭제 연동
- SettingsViewModel에 ChatRepository 주입 및 삭제 로직 처리
- DI(Koin) 수정으로 SettingsViewModel에 ChatRepository 바인딩
- 삭제 실패 시에도 사용자 로그아웃 흐름 유지
2025-08-14 17:30:44 +09:00
373752f592 add(gitignore): .idea/deviceManager.xml 추가 2025-08-14 17:15:50 +09:00
933e650183 feat(chat-room): 채팅 API 연동 및 전송/페이징 플로우 구현 완료
- TalkApi에 입장/전송/점진 로딩 엔드포인트 구현(9.1)
- ChatRepository를 통한 서버 연동 및 로컬 동기화 추가
- ChatRoomActivity에서 입장/전송/페이징 연동, 타이핑 인디케이터/에러 처리 반영(9.2)
2025-08-14 17:14:43 +09:00
6a6aa271ef feat(chat): 톡 목록 스키마 반영 및 채팅방 진입 연결
- TalkRoom 필드 변경 및 신규 스키마 적용
- 어댑터 바인딩/DiffUtil 수정, 프로필 이미지 28dp 라운드 처리
- 아이템 클릭 시 ChatRoomActivity로 이동(roomId 전달)
- item_talk 배경 제거, 최근 캐릭터 썸네일 모서리 28dp로 통일
2025-08-14 14:46:12 +09:00
012437e599 feat(character-main): 최근 대화한 캐릭터
- 이미지 표시 및 클릭 이벤트 적용
2025-08-14 01:04:53 +09:00
d3a64d8359 feat(chat-room): Coil 기반 프로필 이미지 로딩 유틸 도입 및 적용
채팅방의 프로필 이미지 로딩을 공용 유틸(loadProfileImage)로 통일하고
플레이스홀더/에러 처리 및 둥근 모서리 변환을 기본 적용했습니다.

- ImageLoader.kt 추가: loadProfileImage(ImageView, url, cornerRadiusDp)
- ChatMessageAdapter: AI 프로필 이미지 로딩에 유틸 적용
- ChatRoomActivity: 헤더 프로필 이미지 로딩에 유틸 적용 (배경 이미지는 기존 유지)
2025-08-14 00:05:18 +09:00
7451fccff9 feat(chat-room): 시간 포맷팅 유틸 formatMessageTime 도입 및 어댑터 리팩토링
UTC timestamp를 로컬 타임존/로케일 기준 "오전/오후 h:mm" 형식으로 변환하는
공용 유틸(TimeUtils.kt)을 추가하고, ChatMessageAdapter에서 기존 파일 레벨
함수를 제거하여 공용 유틸을 사용하도록 리팩토링했습니다.

- TimeUtils.kt 추가: formatMessageTime(timestamp: Long, locale: Locale)
- ChatMessageAdapter: private 함수 제거 및 import 정리
2025-08-13 23:57:39 +09:00
1882139fac feat(chat-room): 7.1 로컬 우선 표시 및 오프라인 대체 처리 추가
- 진입 시 로컬 최근 20개 메시지 즉시 표시
- enterChatRoom 응답으로 최신 상태로 전체 갱신
- 네트워크 실패 시 로컬 UI 유지 및 토스트 노출
2025-08-13 23:46:45 +09:00
7fc72da905 feat(chat-room): 7.3 로컬 DB 동기화 및 메시지 상태/정리 로직 구현
- ChatMessageDao에 상태 업데이트/정리 보조 쿼리 추가
- ChatRepository에 로컬 저장, 상태 업데이트, 오래된 메시지 정리 API 추가
- Activity 전송/상태 변경 시 DB 반영 및 로딩 후 정리 트리거
2025-08-13 23:36:50 +09:00
9fa270da10 feat(chat-room): 7.2 점진적 메시지 로딩 구현 및 중복 방지 처리
- 상단 스크롤 시 loadMoreMessages로 이전 메시지 로드
- 커서(timestamp) 기반 페이징 및 hasMore/nextCursor 상태 갱신
- messageId 기반 중복 제거, prepend 시 스크롤 위치 보정
2025-08-13 23:30:41 +09:00
637595e8cd feat(chat-room): 7.1 초기 데이터 로딩 구현 및 ServerChatMessage 매퍼 추가
- enterChatRoom API 연동하여 캐릭터/메시지 초기 로딩
- ServerChatMessage -> ChatMessage 매퍼 추가(toDomain)
- ChatRoomActivity에서 어댑터에 초기 메시지 세팅 및 헤더 갱신
- hasMore/nextCursor 상태 갱신 및 오류 처리
2025-08-13 23:26:01 +09:00
ceae25ea06 feat(chat-room): 메시지 입력/전송/실패 처리(6.1~6.3) 구현
- 왜: 채팅방에서 메시지 입력/전송 및 오류 대응 UX 완성을 위해 6.x 과업을 구현했습니다.
- 무엇:
  - 6.1 입력창 UI
    - EditText placeholder 리소스(@string/chat_input_placeholder) 적용, 최대 200자 제한
    - imeOptions(actionSend|flagNoEnterAction)로 IME 전송 액션 지원
    - 전송 버튼 활성/비활성 상태 관리(TextWatcher), 접근성 라벨(@string/action_send)
    - 입력창 포커스/클릭 시 키보드 표시, 전송 후 키보드 숨김
  - 6.2 전송 플로우
    - onSendClicked()/sendMessage() 도입: 즉시 SENDING 상태로 사용자 메시지 추가
    - 타이핑 인디케이터 표시/숨김 제어(ChatMessageAdapter.show/hideTypingIndicator)
    - 성공 시뮬레이션 후 SENT로 상태 업데이트 및 AI 응답 메시지 추가
    - TODO: 실제 TalkApi POST 연동 지점 주석 추가
  - 6.3 전송 실패 처리
    - FAILED 상태 시 사용자 메시지에 재전송 버튼 노출(item_chat_user_message.xml: iv_retry)
    - 어댑터 콜백을 통한 onRetrySend(localId) 처리 → 재시도 시 SENDING → SENT(성공 시)로 전환
    - strings: action_retry 추가, 접근성 라벨 적용
2025-08-13 23:10:32 +09:00
0cf0d2e790 feat(chat-room-ui): 5.1~5.5 구현 - Activity 구조/헤더/안내/배경 및 스크롤
왜: 채팅방 UI tasks 5를 완료하여 기본 화면 구성을 완성하고 사용자 경험을 개선하기 위함

무엇: \n- 5.1 기본 Activity 구조 구현 (roomId 처리, setupView 골격)\n- 5.2 RecyclerView 설정 및 무한 스크롤/자동 스크롤/상단 prepend 보정 로직\n- 5.3 헤더 영역: 뒤로가기, 프로필(CoIL), 이름, 타입 배지(기존 배경 리소스)\n- 5.4 안내 메시지: SharedPreferences로 접기 상태 저장, 캐릭터 타입별 안내, strings 리소스 사용\n- 5.5 배경 프로필 이미지 로딩 및 딤 처리 적용(레이아웃 구성 활용)

추가: 관련 문서 docs/ (5.1/5.2/5.3/5.4/5.5, notice strings) 작성 및 정리
2025-08-13 21:37:42 +09:00
45b76da1e8 feat(chat-room-ui): ChatMessageAdapter 구현 2025-08-13 21:08:01 +09:00
9bb8dcd881 feat(chat-room-ui): 사용자 메시지, AI 메시지 아이템 레이아웃, 타이핑 인디케이터 아이템 레이아웃 및 애니메이션 추가
item_chat_user_message.xml
- 오른쪽 정렬된 메시지 버블 구현
- 버블 왼쪽에 시간 텍스트(tv_time) 배치
- bg_chat_user_message 배경 및 패딩 적용
- 텍스트 접근성과 가독성 향상을 위한 속성 설정

item_chat_ai_message.xml
- 왼쪽 정렬된 메시지, 프로필 이미지와 이름, 오른쪽 시간 표시 구조 구현
- 그룹화 대응을 위한 조건부 표시(View visibility) 구조 마련
- bg_chat_ai_message 배경과 가독성 개선 속성 적용

item_chat_typing_indicator.xml, typing_dots_animation.xml
- AI 메시지와 동일한 좌측 정렬 구조에 3개 점 애니메이션 영역 구현
- 600ms alpha 애니메이션 반복으로 로딩 상태 시각화
- 추후 ViewHolder에서 점별 startOffset 설정을 통해 순차 반짝임 완성 예정
2025-08-13 20:30:07 +09:00
760cbb8228 feat(chat-room-ui): implement main chat room layout (task 3.1) 2025-08-13 20:14:51 +09:00
4a214523c6 feat(chat): 채팅 문자열 리소스 추가 - task 2.3 완료 (chat_notice_clone, chat_notice_character, chat_input_placeholder) - requirements 6.1/6.2, 4.2 충족 - 파일: app/src/main/res/values/strings.xml 2025-08-13 19:58:11 +09:00
6345b1dbee feat(chat): 타이핑 인디케이터 애니메이션 추가\n\n- task 2.2 완료: typing_dots_animation.xml(alpha, 600ms, reverse, infinite) 생성\n- 사용자 메시지 전송 후 AI 응답 대기 시 점(•••) 순차 반짝임 효과 제공\n- 파일: app/src/main/res/anim/typing_dots_animation.xml\n\n왜: 사용자 메시지 전송 직후 로딩 상태를 시각적으로 표시하기 위함\n무엇: 세 점에 동일 애니메이션을 적용하고 startOffset(0/200/400ms)으로 시퀀싱하여 반짝임 구현\n관련: .kiro/specs/chat-room-ui/tasks.md 2.2, design.md 453~464 2025-08-13 19:56:59 +09:00
228acadf5a feat(chat-ui): 채팅 메시지 배경 drawable 추가 (Task 2.1)
- 사용자/AI/입력/안내 배경 리소스 생성
- 기존 라운드 리소스 재활용 및 불투명도 적용
- 요구사항 2,6 및 디자인 가이드 반영
- docs: Task 2.1 수행 내역 문서 추가 (docs/chat-room-ui-2.1-drawables.md)
2025-08-13 19:41:33 +09:00
6388895e6e feat(chat-room): ChatRepository 도입 및 TalkApi에 입장/메시지 조회 API 추가
- Repository 패턴 구현: 로컬 DB(Room) + 네트워크(TalkApi) 통합
- enterChatRoom, loadMoreMessages, clearAllMessagesOnLogout 제공
- TalkApi에 /enter, /messages 엔드포인트 추가
- Entity↔도메인 매퍼 추가
- Koin 모듈에 ChatRepository 바인딩
2025-08-13 17:30:04 +09:00
725c4335e1 feat(chat-talk-room): Room Database 설정 및 Entity 생성
refactor(chat-talk-room): 패키지 chat.room → chat.talk.room 마이그레이션 및 DI 모듈 분리

왜: 기능 영역 명확화(talk) 및 DI 책임 분리로 유지보수성과 확장성을 높이기 위함
무엇:
- 모델/응답/enum 파일들을 chat.room → chat.talk.room 으로 이동
- Room DB 패키지를 chat.room.db → chat.talk.room.db 로 이동
- AppDatabase 클래스명을 역할에 맞게 ChatMessageDatabase로 변경

문서:
- docs/chat-talk-room-package-migration-and-di-module.md 추가
- docs/chat-room-room-database.md 내용 클래스명/경로 갱신
2025-08-13 17:10:06 +09:00
64deadda0b feat(chat-room): 1.1 데이터 모델 생성 및 채팅 메시지 모델 서버-로컬 분리
왜: 서버 스키마와 클라이언트 전용 필드가 혼재되어 혼란을 야기하던 문제를 해결하고, 유지보수성과 확장성을 높이기 위함.

무엇:
- tasks 1.1 수행 (데이터 모델 클래스 생성)
  - ChatMessage 데이터 클래스 생성 (로컬/UI/도메인용)
  - MessageStatus enum 생성 (SENDING, SENT, FAILED)
  - MessageType enum 생성 (USER_MESSAGE, AI_MESSAGE, NOTICE, TYPING_INDICATOR)
  - CharacterType 기존 enum 재사용 (chat/character/detail/CharacterDetailResponse.kt)
  - ChatRoomEnterResponse, ChatMessagesResponse 데이터 클래스 생성
- 채팅 메시지 모델 서버-로컬 분리 및 응답 모델 정리
  - ServerChatMessage DTO 추가 (서버 응답 전용: messageId, message, profileImageUrl, mine, createdAt)
  - ChatMessageMappers 추가: ServerChatMessage.toLocal(isGrouped: Boolean = false)
  - ChatRoomEnterResponse, ChatMessagesResponse에서 messages 타입을 List<ServerChatMessage>로 정리
- 문서
  - docs/chat-room-data-models.md 갱신 (서버/로컬 분리 사항 반영)
  - docs/chat-room-message-model-separation.md 신설 (분리 배경/가이드)

추가 참고:
- 시간 포맷 유틸은 후속 태스크(8.1)에서 테스트와 함께 구현 예정
2025-08-13 05:23:12 +09:00
558f74d861 feat(chat): 캐릭터 상세에서 채팅방 생성 후 ChatRoomActivity로 네비게이션 추가
- ChatRoomActivity에 EXTRA_ROOM_ID 및 newIntent 추가
- CharacterDetailActivity에서 chatRoomId 수신 시 화면 이동 처리
- 이벤트 소비 유지로 중복 네비게이션 방지
2025-08-13 02:21:43 +09:00
4eedecd1ce feat(chat-character): 채팅 톡 탭
- 데이터가 없으면 "대화 중인 톡이 없습니다" 메시지 표시
2025-08-13 01:23:56 +09:00
08f9d398c4 feat(chat-character): 캐릭터 상세
- 원작의 UI 레벨을 세계관 하위로 이동
2025-08-13 01:17:27 +09:00
f102c84ea6 feat(chat-character): 캐릭터 탭 모든 액션
- 로그인과 본인인증이 되어 있어야 가능하도록 수정
2025-08-13 01:09:34 +09:00
0c3bca0f9e feat(chat-character): 캐릭터 상세 페이지 API 연동 및 UI 상태 처리
- CharacterApi에 캐릭터 상세 조회 엔드포인트 추가
- CharacterDetailRepository 생성 및 Koin DI 등록
- CharacterDetailViewModel에서 실제 API 호출/로딩/에러 상태 관리
- CharacterDetailActivity에서 loadMock 제거 후 load 호출, Koin 주입으로 전환
- 로딩 다이얼로그 및 에러 토스트 처리 로직 추가
2025-08-13 00:52:24 +09:00
ff1e134fe4 feat(character list): 캐릭터 탭
- 배너 리스트 추가
- 배너, 캐릭터 클릭시 캐릭터 상세 페이지로 이동
2025-08-13 00:05:39 +09:00
d8b48fe362 feat(character list): 캐릭터 이미지 배경색 제거 2025-08-12 23:39:22 +09:00
ac2482a645 feat(character detail): 캐릭터 상세 페이지 UI 추가 2025-08-12 22:15:52 +09:00
5090809be8 gitignore 규칙 추가
- .kiro/
2025-08-11 15:44:05 +09:00
80c593bc11 fix: 채팅방 리스트 API URL 수정
- /api/chat/talk/rooms -> /api/chat/room/list
2025-08-11 14:55:31 +09:00
18b61ab74f fix: 채팅 탭 data class
- SerializedName 추가
2025-08-11 11:24:10 +09:00
ea22c7244c feat(ui): 캐릭터 탭
- loadingDialog, Toast 라이브 데이터 옵저버 연결
2025-08-05 02:07:46 +09:00
b1c9c3e124 feat(ui): 톡 탭
- api, viewmodel, repository 연결
- 채팅방 리스트 UI 추가
2025-08-05 02:01:19 +09:00
93fc837b7a feat(ui): 캐릭터 탭
- 섹션별로 데이터가 있으면 보여주고 없으면 UI를 제거하도록 로직 추가
2025-08-04 23:38:51 +09:00
f0eda41c7c feat(ui): viewmodel, repository, api 추가 2025-08-04 22:24:13 +09:00
47717002e8 feat(ui): banner 추가 2025-08-04 22:10:27 +09:00
7b7513561d refactor: item decoration 추가 2025-08-04 22:04:19 +09:00
33bdaa7dbd refactor: 캐릭터 탭 내부에서 사용하는 Adapter 코드를 ViewBinding 코드로 리팩토링 2025-08-04 21:02:05 +09:00
b919691689 feat(character): 캐릭터 탭 UI 및 기본 기능 구현 2025-08-04 20:27:33 +09:00
e90222e8db feat(ui): 채팅 탭 내 TabLayout 캐릭터, 톡 탭 추가 2025-08-01 19:25:14 +09:00
3cf57c1f91 feat(ui): 채팅 탭 추가 2025-08-01 14:47:51 +09:00
f6e7229246 chore: .gitignore 파일에 .idea 관련 파일 추가 2025-08-01 14:34:58 +09:00
f55e74c8dc feat: git 제외 파일 및 폴더
- docs
- junie guidelines
2025-07-31 20:16:25 +09:00
e25276658d feat: 마이페이지
- 내 채널 보기 추가
2025-07-30 14:52:59 +09:00
d088c6f6b3 # 고객센터 UI 개선 및 버전 업데이트 (v1.41.0)
## 변경사항
- 앱 버전을 1.40.0(179)에서 1.41.0(181)으로 업데이트
- 고객센터 화면에 전용 로고 이미지 추가 및 UI 개선
  - 플레이스홀더 이미지를 고객센터 전용 로고로 교체
  - 텍스트 마진 조정 (13.3dp → 16dp)
- 마이페이지 화면 UI 개선
  - 본인인증 버튼 텍스트 간소화 ("본인인증 완료" → "인증완료")
  - 레이아웃 구조 개선 (패딩/마진 조정)
  - RecyclerView 스크롤 경험 개선 (clipToPadding 속성 추가)
2025-07-28 17:33:03 +09:00
9361610647 feat: 마이페이지
- 상단에 최신 공지사항 추가
2025-07-25 22:18:22 +09:00
7ed5e921bd feat: 마이페이지
- 최근 들은 콘텐츠 추가
2025-07-25 21:36:34 +09:00
39be49b481 feat: 마이페이지
- 신규 UI 적용
2025-07-25 16:52:34 +09:00
3b7b5f98bd fix: 메인 라이브 - 최근 종료한 라이브
- 이미지 사이즈 72 -> 84
2025-07-21 20:07:46 +09:00
9be1b86c5d fix: 메인 홈 - 인기 크리에이터
- 팔로우/팔로잉 배경색 변경
2025-07-21 19:52:23 +09:00
cfe9d3ab11 fix: 메인 라이브 - 최근 종료한 라이브
- 비로그인 상태에서 터치시 로그인 페이지로 이동
2025-07-21 18:56:44 +09:00
accb413636 feat: 메인 홈 - 오디션
- 비로그인 상태에서 터치시 로그인 페이지로 이동
2025-07-21 18:51:01 +09:00
bdac7b7899 feat: 메인 홈 - 인기 크리에이터
- 팔로우/언팔로우 기능 추가
2025-07-21 18:48:09 +09:00
58bc42cc0f feat: 메인 라이브 - 최근 종료한 라이브
- 사용 하지 않는 데이터 제거
2025-07-21 18:24:53 +09:00
44d7ce65ae feat: 메인 라이브
- 신규 UI 적용
2025-07-21 18:00:31 +09:00
c55cc68f5c feat: 메인 라이브, 메인 홈
- 섹션 제목 크기 26 -> 24
- 오디션 배너 변경
- 추천 채널 아이템 bg 톤다운
2025-07-19 04:02:29 +09:00
d7cc874684 feat: 메인 라이브
- 최근 종료한 라이브, 라이브 다시 듣기, 라이브 예약 아이템 사이즈 조절
2025-07-19 02:17:47 +09:00
f1164bbd30 feat: 메인 라이브 - 지금 라이브 중
- bg => #263238로 변경
- 가로 => 168 -> 144
- 세로 => 238 -> 204
2025-07-19 01:26:21 +09:00
5f6d26c83e feat: 메인 라이브
- 최근 종료한 라이브 - 라이브 아이콘 제거
- 커뮤니티 - 이미지 사이즈 수정 (53.3 -> 64)
2025-07-18 23:07:19 +09:00
fcd341a1f4 feat: 메인 라이브
- 예약 라이브 - 유료 라이브 금액 나오지 않던 버그 수정
- 팔로잉 채널 - 위치 커뮤니티와 지금 라이브 중 사이로 이동
2025-07-18 22:51:21 +09:00
6e5a4cff45 feat: 메인 라이브
- 변경된 커뮤니티 게시글 아이템 UI 적용
2025-07-18 21:37:16 +09:00
45fd75ab36 feat: 메인 홈
- 오디션 리스트를 보여주지 않고 터치시 오디션 페이지로 이동하도록 수정
2025-07-18 21:06:13 +09:00
2f9bace3de feat: 메인 라이브
- 라이브 다시 듣기 UI 추가
2025-07-18 20:43:30 +09:00
964f697466 feat: 메인 라이브
- 개편된 지금 라이브 중 UI 적용
2025-07-18 19:21:20 +09:00
bb23f9cf93 feat: 메인 라이브
- 최근 종료한 라이브 UI 추가
2025-07-18 18:57:11 +09:00
440104a7d1 feat: 메인 라이브
- 라이브 예약 중 UI 변경
2025-07-17 20:49:44 +09:00
0c7c7946c6 feat: 메인 라이브
- 새로운 UI의 기본 골격 적용
2025-07-16 22:07:07 +09:00
386f9aae32 feat: 메인 홈
- 섹션 간의 간격 수정
- 기존: 밑에 있는 섹션에서 marginTop="48dp"
- 변경: 위에 있는 섹션에서 marginBottom=48dp"
2025-07-16 16:24:07 +09:00
b5d0309f2b feat: 메인 홈
- 돋보기 터치시 검색 페이지 연결
2025-07-16 14:13:18 +09:00
3e525b05a5 feat: 메인 홈
- UI 수정
2025-07-15 21:42:56 +09:00
141e7fe416 feat: 메인 홈
- 다른 페이지로 이동시 로그인 안되어 있으면 로그인 페이지로 이동
2025-07-15 20:41:35 +09:00
db2e3bc8f2 feat: 메인 홈
- 추천 채널 UI 추가
2025-07-15 20:20:54 +09:00
66a6f4bbab feat: 메인 홈
- 큐레이션 UI 추가
2025-07-15 19:01:29 +09:00
a328ea9c3c feat: 메인 홈
- 무료 콘텐츠 UI 추가
2025-07-15 18:44:14 +09:00
76b8b74d41 feat: 메인 홈
- 보온 주간 차트 UI 추가
2025-07-15 18:34:46 +09:00
5c4141dad9 feat: 메인 홈
- 요일별 시리즈 UI 추가
2025-07-15 17:54:53 +09:00
e787872cc5 feat: 메인 홈
- 오디션 배너 UI 추가
2025-07-15 16:27:14 +09:00
af818bda93 feat: 메인 홈
- 오직 보이스온에서만 UI 추가
2025-07-15 16:08:25 +09:00
ccc774da0d feat: 메인 홈 - 최신 콘텐츠
- 데이터가 1개만 있을 때도 2줄 영역을 차지하던 버그 수정
2025-07-15 15:28:50 +09:00
32d61d9808 feat: 메인 홈
- 이벤트 배너 UI 추가
2025-07-15 06:34:41 +09:00
83a30fa088 feat: 메인 홈
- 최신 콘텐츠 UI 추가
2025-07-15 06:27:33 +09:00
f24cd97afa feat: 메인 홈
- 인기 크리에이터 UI 추가
2025-07-15 05:39:04 +09:00
388770889f feat: 메인 홈
- 라이브 UI 추가
2025-07-15 05:04:21 +09:00
e3121fc49b feat: 스플래시 변경 2025-07-14 21:47:59 +09:00
f1958995f6 feat: 하단 탭 아이콘 변경 2025-07-07 20:23:23 +09:00
ba7b681e48 feat: 커뮤니티 전체보기
- gif 재생 되도록 추가
2025-07-03 14:33:45 +09:00
e4012a1301 feat: 커뮤니티 글쓰기/수정
- 이미지 gif 등록 기능 추가
2025-07-03 13:15:01 +09:00
6ff0d8bd61 fix: 사용하지 않는 퍼미션 제거
- GET_ACCOUNTS
2025-06-16 16:09:37 +09:00
898afc78ef fix: 커뮤니티 댓글
- 무료 커뮤니티 글, 내 커뮤니티 글 에서 비밀댓글 체크박스가 보이지 않도록 수정
2025-06-13 21:06:03 +09:00
c527f55721 feat: 팔로워 리스트
- 프로필 이미지를 터치하면 프로필 다이얼로그 표시
2025-06-13 19:36:38 +09:00
89277c5668 feat: 커뮤니티 댓글 리스트
- 비밀댓글 태그 추가
2025-06-13 17:07:16 +09:00
28388497b8 feat: 커뮤니티 댓글
- 유료 커뮤니티 구매시 비밀 댓글 쓰기 기능 추가
2025-06-13 16:52:40 +09:00
09a2a96596 refactor: 콘텐츠 상세
- cleanup code를 실행하여 불필요한 코드 제거
2025-06-12 16:16:52 +09:00
d3f6a02be2 feat: 쿠폰 등록, 인기 단편 전체보기
- 쿠폰 등록 후 캔 내역 페이지가 아닌 바로 이전 페이지로 이동하도록 수정
- 인기 단편 전체보기에 포인트 사용 여부 표시
2025-06-10 20:49:06 +09:00
c8cc0457e4 feat: 쿠폰 등록 안내 문구 수정 2025-06-10 19:57:56 +09:00
4d9e68d60b feat: 시리즈 상세 - 콘텐츠 리스트
- 포인트 사용 가능 여부 표시
2025-06-10 18:24:46 +09:00
74585bfb7f feat: 크리에이터 채널 - 콘텐츠 리스트
- 포인트 사용 가능 여부 표시
2025-06-10 14:54:20 +09:00
ea766afba9 feat: 콘텐츠 메인 - 새로운 콘텐츠, 큐레이션
- 포인트 사용이 가능한 콘텐츠의 썸네일 우측 상단에 포인트 사용 가능 표시
2025-06-10 12:26:04 +09:00
f10d848797 feat: 콘텐츠 메인 - 채널별 인기 콘텐츠
- 포인트 사용이 가능한 콘텐츠의 썸네일 우측 상단에 포인트 사용 가능 표시
2025-06-10 12:03:35 +09:00
3bda97b0a7 feat: 콘텐츠 수정
- 태그 수정 기능 추가
- 포인트 사용여부 수정 기능 추가
2025-06-04 20:03:42 +09:00
19c39f636d feat: 콘텐츠 업로드
- 포인트 사용 가능 여부 추가
2025-06-02 15:22:29 +09:00
8b7894a370 feat: 라이브 후원 메시지 글자 수 조정
- 200자 -> 1000자
2025-05-23 19:26:55 +09:00
d1056bda99 feat: 구매 확인 Dialog
- 포인트 사용이 가능한 경우 포인트를 같이 표시하도록 수정
2025-05-20 18:40:41 +09:00
5dbf9bd987 fix: 앱 실행시 처음 실행하는 유저 정보 조회
- point를 가져와서 SharedPreferences에 저장
2025-05-20 18:03:28 +09:00
23494d0936 feat: 포인트 소멸 안내 메시지 추가 2025-05-20 17:43:15 +09:00
116d4b3ecf feat: 포인트 내역 UI 추가 2025-05-20 00:29:00 +09:00
8b8f5b80b8 fix: 로그아웃시 UserDefaults에서 푸시토큰을 삭제하지 않도록 수정 2025-05-17 21:59:28 +09:00
0b9abf39f1 refactor: 라이브 연속 참여 시간 계산시 initialDelay와 period에 있는 같은 값을 period 변수로 선언 2025-05-17 21:43:45 +09:00
9260d271a7 feat: 라이브 30분 연속 청취시 트래킹 API 호출 기능 추가 2025-05-17 16:57:12 +09:00
496 changed files with 24229 additions and 3890 deletions

8
.gitignore vendored
View File

@@ -44,6 +44,7 @@ captures/
# IntelliJ
*.iml
.idea/deviceManager.xml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
@@ -57,6 +58,9 @@ captures/
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
.idea/AndroidProjectSystem.xml
.idea/runConfigurations.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
@@ -306,4 +310,8 @@ fabric.properties
app/debug/
app/release/
docs/
.junie/
.kiro/
# End of https://www.toptal.com/developers/gitignore/api/macos,android,androidstudio,visualstudiocode,git,kotlin,java

View File

@@ -1,5 +1,40 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JavaCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="" withSubpackages="true" static="false" module="true" />
<package name="android" withSubpackages="true" static="true" />
<package name="androidx" withSubpackages="true" static="true" />
<package name="com" withSubpackages="true" static="true" />
<package name="junit" withSubpackages="true" static="true" />
<package name="net" withSubpackages="true" static="true" />
<package name="org" withSubpackages="true" static="true" />
<package name="java" withSubpackages="true" static="true" />
<package name="javax" withSubpackages="true" static="true" />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>

View File

@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2024-10-14T08:13:14.161127Z">
<DropdownSelection timestamp="2025-10-23T14:41:22.468459Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=2cec640c34017ece" />
<DeviceId pluginId="PhysicalDevice" identifier="serial=ce0917195d15ab39017e" />
</handle>
</Target>
</DropdownSelection>

View File

@@ -1,27 +1,28 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'com.google.gms.google-services'
id 'com.google.android.gms.oss-licenses-plugin'
id 'kotlin-kapt'
id 'com.google.devtools.ksp'
id 'kotlin-parcelize'
id 'org.jlleitschuh.gradle.ktlint'
id 'io.objectbox'
id 'com.google.firebase.crashlytics'
}
android {
namespace 'kr.co.vividnext.sodalive'
compileSdk 34
compileSdk = 35
viewBinding {
enabled true
}
buildFeatures {
dataBinding true
buildConfig true
}
dependenciesInfo {
@@ -31,12 +32,39 @@ android {
includeInBundle = false
}
packaging {
// JNI(.so) 관련
jniLibs {
// pickFirsts: 충돌 시 첫 파일만 채택
pickFirsts += ["**/libaosl.so"]
}
// 일반 리소스(META-INF 등) 관련
resources {
// pickFirsts: 충돌 시 첫 파일만 채택
pickFirsts += [
"META-INF/LICENSE.txt",
"META-INF/NOTICE*"
]
// 자주 쓰는 제외/병합 예시
excludes += [
"META-INF/DEPENDENCIES",
"META-INF/AL2.0",
"META-INF/LGPL2.1"
]
merges += [
"META-INF/services/**"
]
}
}
defaultConfig {
applicationId "kr.co.vividnext.sodalive"
minSdk 23
targetSdk 34
versionCode 165
versionName "1.36.0"
targetSdk 35
versionCode 198
versionName "1.43.0"
}
buildTypes {
@@ -54,6 +82,7 @@ android {
buildConfigField 'String', 'NOTIFLY_PASSWORD', '"c6c585db0aaa4189be44d0467c7d66b6@A"'
buildConfigField 'String', 'KAKAO_APP_KEY', '"231cf78acfa8252fca38b9eedf87c5cb"'
buildConfigField 'String', 'GOOGLE_CLIENT_ID', '"983594297130-5hrmkh6vpskeq6v34350kmilf74574h2.apps.googleusercontent.com"'
buildConfigField 'String', 'APPSCHEME', '"voiceon"'
manifestPlaceholders = [
URISCHEME : "voiceon",
APPLINK_HOST : "voiceon.onelink.me",
@@ -64,7 +93,7 @@ android {
}
debug {
minifyEnabled true
minifyEnabled false
debuggable true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
applicationIdSuffix '.debug'
@@ -79,6 +108,7 @@ android {
buildConfigField 'String', 'NOTIFLY_PASSWORD', '"c6c585db0aaa4189be44d0467c7d66b6@A"'
buildConfigField 'String', 'KAKAO_APP_KEY', '"20cf19413d63bfdfd30e8e6dff933d33"'
buildConfigField 'String', 'GOOGLE_CLIENT_ID', '"758414412471-mosodbj2chno7l1j0iihldh6edmk0gk9.apps.googleusercontent.com"'
buildConfigField 'String', 'APPSCHEME', '"voiceon-test"'
manifestPlaceholders = [
URISCHEME : "voiceon-test",
APPLINK_HOST : "voiceon-test.onelink.me",
@@ -92,9 +122,6 @@ android {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
lint {
checkDependencies true
checkReleaseBuilds false
@@ -102,17 +129,17 @@ android {
}
dependencies {
implementation "androidx.media:media:1.7.0"
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
implementation "androidx.media:media:1.7.1"
implementation 'androidx.core:core-ktx:1.16.0'
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'androidx.recyclerview:recyclerview:1.4.0'
implementation 'com.google.android.material:material:1.13.0'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'androidx.webkit:webkit:1.12.1'
implementation 'androidx.webkit:webkit:1.14.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.7'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.9.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4'
// Logger
implementation("com.orhanobut:logger:2.2.0") {
@@ -132,29 +159,29 @@ dependencies {
}
// Gson
implementation "com.google.code.gson:gson:2.10.1"
implementation "com.google.code.gson:gson:2.13.2"
// Network
implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation "com.squareup.retrofit2:converter-gson:2.9.0"
implementation "com.squareup.retrofit2:adapter-rxjava3:2.9.0"
implementation "com.squareup.okhttp3:logging-interceptor:4.9.3"
implementation "com.squareup.retrofit2:retrofit:3.0.0"
implementation "com.squareup.retrofit2:converter-gson:3.0.0"
implementation "com.squareup.retrofit2:adapter-rxjava3:3.0.0"
implementation "com.squareup.okhttp3:logging-interceptor:5.2.1"
// RxJava3
implementation "io.reactivex.rxjava3:rxjava:3.1.6"
implementation "io.reactivex.rxjava3:rxjava:3.1.12"
implementation "io.reactivex.rxjava3:rxandroid:3.0.2"
implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0"
// permission
implementation "io.github.ParkSangGwon:tedpermission-normal:3.3.0"
implementation "io.github.ParkSangGwon:tedpermission-normal:3.4.2"
implementation 'com.github.dhaval2404:imagepicker:2.1'
implementation 'com.github.yalantis:ucrop:2.2.11'
implementation 'com.github.zhpanvip:bannerviewpager:3.5.7'
implementation 'com.google.android.gms:play-services-oss-licenses:17.1.0'
// Firebase
implementation platform('com.google.firebase:firebase-bom:32.2.2')
implementation platform('com.google.firebase:firebase-bom:33.16.0')
implementation 'com.google.firebase:firebase-crashlytics-ktx'
implementation 'com.google.firebase:firebase-analytics-ktx'
implementation 'com.google.firebase:firebase-messaging-ktx'
@@ -168,36 +195,32 @@ dependencies {
implementation "io.github.bootpay:android:4.4.3"
// agora
implementation "io.agora.rtc:voice-sdk:4.2.6"
implementation 'io.agora.rtm:rtm-sdk:1.5.3'
// sound visualizer
implementation "com.gauravk.audiovisualizer:audiovisualizer:0.9.2"
implementation "io.agora.rtc:voice-sdk:4.5.2"
implementation 'io.agora:agora-rtm:2.2.6'
// Glide
implementation 'com.github.bumptech.glide:glide:4.12.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
implementation "com.michalsvec:single-row-calednar:1.0.0"
implementation 'com.github.bumptech.glide:glide:5.0.5'
annotationProcessor 'com.github.bumptech.glide:compiler:5.0.5'
// google in-app-purchase
implementation "com.android.billingclient:billing-ktx:6.2.0"
implementation "com.android.billingclient:billing-ktx:8.0.0"
// ROOM
kapt "androidx.room:room-compiler:2.5.0"
implementation "androidx.room:room-ktx:2.5.0"
implementation "androidx.room:room-runtime:2.5.0"
ksp "androidx.room:room-compiler:2.8.3"
implementation "androidx.room:room-ktx:2.8.3"
implementation "androidx.room:room-runtime:2.8.3"
implementation "androidx.room:room-rxjava3:2.8.3"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
implementation "androidx.media3:media3-session:1.4.1"
implementation "androidx.media3:media3-exoplayer:1.4.1"
implementation "androidx.media3:media3-session:1.8.0"
implementation "androidx.media3:media3-exoplayer:1.8.0"
// Facebook
implementation "com.facebook.android:facebook-core:18.0.0"
// Appsflyer
implementation 'com.appsflyer:af-android-sdk:6.16.1'
implementation 'com.appsflyer:af-android-sdk:6.17.4'
// 노티플라이
implementation 'com.github.team-michael:notifly-android-sdk:1.12.0'
@@ -206,4 +229,33 @@ dependencies {
implementation "com.kakao.sdk:v2-common:2.21.0"
implementation "com.kakao.sdk:v2-auth:2.21.0"
implementation "com.kakao.sdk:v2-user:2.21.0"
implementation 'io.github.glailton.expandabletextview:expandabletextview:1.0.4'
// ----- Test dependencies -----
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.20.0'
testImplementation 'org.mockito:mockito-inline:5.2.0'
testImplementation 'org.mockito.kotlin:mockito-kotlin:6.1.0'
testImplementation 'io.mockk:mockk:1.14.6'
}
// KSP args for Room schema export
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
arg("room.incremental", "true")
arg("room.expandProjection", "true")
}
// Kotlin compiler and toolchain configuration (migrated from deprecated kotlinOptions.jvmTarget)
kotlin {
// Ensures Kotlin compiles with Java 17 toolchain
jvmToolchain(17)
// New DSL replacing kotlinOptions.jvmTarget
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}

View File

@@ -237,3 +237,9 @@
-dontwarn org.bouncycastle.jsse.**
-dontwarn org.conscrypt.*
-dontwarn org.openjsse.**
-keep interface kr.co.vividnext.sodalive.tracking.UserEventApi
-dontwarn com.yalantis.ucrop**
-keep class com.yalantis.ucrop** { *; }
-keep interface com.yalantis.ucrop** { *; }

1
app/schemas/.gitkeep Normal file
View File

@@ -0,0 +1 @@
# Keep schemas directory under version control

View File

@@ -0,0 +1,76 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "b9a331035b36b70f8ca7a14962b13fdf",
"entities": [
{
"tableName": "playback_tracking",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `contentId` INTEGER NOT NULL, `totalDuration` INTEGER NOT NULL, `startPosition` INTEGER NOT NULL, `isFree` INTEGER NOT NULL, `isPreview` INTEGER NOT NULL, `endPosition` INTEGER, `playDateTime` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentId",
"columnName": "contentId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalDuration",
"columnName": "totalDuration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startPosition",
"columnName": "startPosition",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isFree",
"columnName": "isFree",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPreview",
"columnName": "isPreview",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endPosition",
"columnName": "endPosition",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "playDateTime",
"columnName": "playDateTime",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b9a331035b36b70f8ca7a14962b13fdf')"
]
}
}

View File

@@ -0,0 +1,82 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "7429c2998f64cb70e5e8b1d2525a4708",
"entities": [
{
"tableName": "alarms",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `time` INTEGER NOT NULL, `days` TEXT NOT NULL, `contentId` INTEGER NOT NULL, `contentTitle` TEXT NOT NULL, `contentCreatorNickname` TEXT NOT NULL, `volume` INTEGER NOT NULL, `isEnabled` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "days",
"columnName": "days",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contentId",
"columnName": "contentId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentTitle",
"columnName": "contentTitle",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contentCreatorNickname",
"columnName": "contentCreatorNickname",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "volume",
"columnName": "volume",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isEnabled",
"columnName": "isEnabled",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7429c2998f64cb70e5e8b1d2525a4708')"
]
}
}

View File

@@ -0,0 +1,58 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "e46a8b457c3ea6ceefd0db76bb763056",
"entities": [
{
"tableName": "recent_contents",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contentId` INTEGER NOT NULL, `coverImageUrl` TEXT NOT NULL, `title` TEXT NOT NULL, `creatorNickname` TEXT NOT NULL, `listenedAt` INTEGER NOT NULL, PRIMARY KEY(`contentId`))",
"fields": [
{
"fieldPath": "contentId",
"columnName": "contentId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coverImageUrl",
"columnName": "coverImageUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "creatorNickname",
"columnName": "creatorNickname",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "listenedAt",
"columnName": "listenedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"contentId"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e46a8b457c3ea6ceefd0db76bb763056')"
]
}
}

View File

@@ -38,7 +38,6 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
@@ -86,6 +85,15 @@
<data android:scheme="${URISCHEME}" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<!-- PayVerse 리다이렉트에 등록할 커스텀 스킴/호스트 -->
<data android:scheme="${URISCHEME}"
android:host="payverse"
android:path="/result"/>
</intent-filter>
</activity>
<activity
android:name=".splash.SplashActivity"
@@ -104,10 +112,13 @@
<activity android:name=".settings.terms.TermsActivity" />
<activity android:name=".user.find_password.FindPasswordActivity" />
<activity android:name=".mypage.can.status.CanStatusActivity" />
<activity android:name=".mypage.point.PointStatusActivity" />
<activity
android:name=".mypage.can.charge.CanChargeActivity"
android:configChanges="orientation|screenSize|keyboardHidden" />
<activity android:name=".mypage.can.payment.CanPaymentActivity" />
<activity
android:name=".mypage.can.payment.CanPaymentActivity"
android:launchMode="singleTop" />
<activity android:name=".mypage.can.payment.CanPaymentTempActivity" />
<activity android:name=".mypage.can.coupon.CanCouponActivity" />
<activity android:name=".live.room.create.LiveRoomCreateActivity" />
@@ -175,6 +186,7 @@
<activity android:name=".audio_content.main.v2.series.completed.CompletedSeriesActivity" />
<activity android:name=".search.SearchActivity" />
<activity android:name=".audition.AuditionActivity" />
<activity android:name=".mypage.alarm.AlarmListActivity" />
<activity android:name=".mypage.alarm.AddAlarmActivity" />
@@ -190,6 +202,8 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity android:name=".chat.character.newcharacters.NewCharactersAllActivity" />
<activity android:name=".chat.original.detail.OriginalWorkDetailActivity" />
<activity
android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity"
@@ -203,11 +217,13 @@
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Redirect URI: "kakao${NATIVE_APP_KEY}://oauth" -->
<data android:host="oauth"
<data
android:host="oauth"
android:scheme="kakao${KAKAO_APP_KEY}" />
</intent-filter>
</activity>
@@ -279,5 +295,28 @@
android:name="com.facebook.FacebookActivity"
android:configChanges="keyboard|keyboardHidden|screenLayout|screenSize|orientation" />
<!-- [END facebook] -->
<!-- Character Detail -->
<activity android:name=".chat.character.detail.CharacterDetailActivity" />
<activity android:name=".chat.talk.room.ChatRoomActivity" />
<activity
android:name="com.yalantis.ucrop.UCropActivity"
android:exported="false"
android:screenOrientation="portrait"
android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<!-- ★ 이 meta-data가 꼭 필요 -->
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,25 @@
<!-- app/src/main/assets/payverse_starter_debug.html -->
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<!-- PayVerse SDK -->
<script src="https://ui.payverseglobal.com/js/payments.js"></script>
</head>
<body>
<script>
// 안드로이드에서 JSON 문자열을 넘기면 이 함수를 호출
function startPay(payloadJson) {
try {
const p = JSON.parse(payloadJson);
// 즉시 실행: 페이지가 열리자마자 결제창 시작
window.payVerse.requestUI(p);
} catch (e) {
console.error('startPay error', e);
alert('결제 초기화에 실패했습니다.');
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,25 @@
<!-- app/src/main/assets/payverse_starter_debug.html -->
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<!-- PayVerse SDK -->
<script src="https://ui-snd.payverseglobal.com/js/payments.js"></script>
</head>
<body>
<script>
// 안드로이드에서 JSON 문자열을 넘기면 이 함수를 호출
function startPay(payloadJson) {
try {
const p = JSON.parse(payloadJson);
// 즉시 실행: 페이지가 열리자마자 결제창 시작
window.payVerse.requestUI(p);
} catch (e) {
console.error('startPay error', e);
alert('결제 초기화에 실패했습니다.');
}
}
</script>
</body>
</html>

View File

@@ -6,28 +6,31 @@ import io.agora.rtc2.Constants
import io.agora.rtc2.IRtcEngineEventHandler
import io.agora.rtc2.RtcEngine
import io.agora.rtm.ErrorInfo
import io.agora.rtm.GetOnlineUsersOptions
import io.agora.rtm.GetOnlineUsersResult
import io.agora.rtm.PublishOptions
import io.agora.rtm.ResultCallback
import io.agora.rtm.RtmChannel
import io.agora.rtm.RtmChannelListener
import io.agora.rtm.RtmClient
import io.agora.rtm.RtmClientListener
import io.agora.rtm.SendMessageOptions
import io.agora.rtm.RtmConfig
import io.agora.rtm.RtmConstants
import io.agora.rtm.RtmEventListener
import io.agora.rtm.SubscribeOptions
import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.live.room.LiveRoomRequestType
import kotlin.concurrent.thread
class Agora(
private val uid: Long,
private val context: Context,
private val rtcEventHandler: IRtcEngineEventHandler,
private val rtmClientListener: RtmClientListener
private val rtmEventListener: RtmEventListener
) {
// RTM client instance
private var rtmClient: RtmClient? = null
// 상태 플래그: RTM 로그인 완료 여부
private var rtmLoggedIn: Boolean = false
// RTM channel instance
private var rtmChannel: RtmChannel? = null
private var rtcEngine: RtcEngine? = null
// 상태 플래그: RTM 로그인 진행 중 여부
private var rtmLoginInProgress: Boolean = false
init {
initAgoraEngine()
@@ -35,11 +38,30 @@ class Agora(
private fun initAgoraEngine() {
try {
initRtcEngine()
initRtmClient()
} catch (e: Exception) {
e.printStackTrace()
}
}
fun deInitAgoraEngine(rtmEventListener: RtmEventListener) {
deInitRtcEngine()
deInitRtmClient(rtmEventListener)
}
// region RtcEngine
private var rtcEngine: RtcEngine? = null
@Throws(Exception::class)
private fun initRtcEngine() {
Logger.e("initRtcEngine")
rtcEngine = RtcEngine.create(
context,
BuildConfig.AGORA_APP_ID,
rtcEventHandler
)
Logger.e("initRtcEngine - rtcEngine: ${rtcEngine != null}")
rtcEngine!!.setChannelProfile(Constants.CHANNEL_PROFILE_LIVE_BROADCASTING)
rtcEngine!!.setAudioProfile(
@@ -48,52 +70,18 @@ class Agora(
)
rtcEngine!!.enableAudio()
rtcEngine!!.enableAudioVolumeIndication(500, 3, true)
rtmClient = RtmClient.createInstance(
context,
BuildConfig.AGORA_APP_ID,
rtmClientListener
)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun deInitAgoraEngine() {
if (rtcEngine != null) {
rtcEngine!!.leaveChannel()
thread {
RtcEngine.destroy()
rtcEngine = null
}
}
rtmChannel?.leave(null)
rtmChannel?.release()
rtmClient?.logout(null)
}
fun inputChat(message: String) {
val rtmMessage = rtmClient!!.createMessage()
rtmMessage.text = message
rtmChannel!!.sendMessage(
rtmMessage,
object : ResultCallback<Void?> {
override fun onSuccess(p0: Void?) {
Logger.e("sendMessage - onSuccess")
}
override fun onFailure(p0: ErrorInfo) {
Logger.e("sendMessage fail - ${p0.errorCode}")
Logger.e("sendMessage fail - ${p0.errorDescription}")
}
}
)
}
fun joinRtcChannel(uid: Int, rtcToken: String, channelName: String) {
val state = rtcEngine?.connectionState
val isDisconnected = state == null || state == Constants.CONNECTION_STATE_DISCONNECTED
if (!isDisconnected) {
Logger.e("joinRtcChannel - skip (state=$state)")
return
}
Logger.e("joinRtcChannel - proceed (state=$state) uid=$uid channel=$channelName")
rtcEngine!!.joinChannel(
rtcToken,
channelName,
@@ -102,62 +90,6 @@ class Agora(
)
}
fun createRtmChannelAndLogin(
uid: String,
rtmToken: String,
channelName: String,
rtmChannelListener: RtmChannelListener,
rtmChannelJoinSuccess: () -> Unit,
rtmChannelJoinFail: () -> Unit
) {
rtmChannel = rtmClient!!.createChannel(channelName, rtmChannelListener)
rtmClient!!.login(
rtmToken,
uid,
object : ResultCallback<Void> {
override fun onSuccess(p0: Void?) {
rtmChannel!!.join(object : ResultCallback<Void> {
override fun onSuccess(p0: Void?) {
Logger.e("rtmChannel join - onSuccess")
rtmChannelJoinSuccess()
}
override fun onFailure(p0: ErrorInfo?) {
rtmChannelJoinFail()
}
})
}
override fun onFailure(p0: ErrorInfo?) {
}
}
)
}
fun sendRawMessageToGroup(
rawMessage: ByteArray,
onSuccess: (() -> Unit)? = null,
onFailure: (() -> Unit)? = null
) {
val message = rtmClient!!.createMessage()
message.rawMessage = rawMessage
rtmChannel!!.sendMessage(
message,
object : ResultCallback<Void?> {
override fun onSuccess(p0: Void?) {
Logger.e("sendMessage - onSuccess")
onSuccess?.invoke()
}
override fun onFailure(p0: ErrorInfo) {
Logger.e("sendMessage fail - ${p0.errorCode}")
Logger.e("sendMessage fail - ${p0.errorDescription}")
onFailure?.invoke()
}
}
)
}
fun setClientRole(role: Int) {
rtcEngine!!.setClientRole(role)
}
@@ -170,37 +102,304 @@ class Agora(
rtcEngine?.muteAllRemoteAudioStreams(mute)
}
fun getConnectionState(): Int {
return rtcEngine!!.connectionState
}
fun isRtmLoggedIn(): Boolean {
return rtmLoggedIn
}
fun deInitRtcEngine() {
if (rtcEngine != null) {
rtcEngine!!.leaveChannel()
thread {
RtcEngine.destroy()
rtcEngine = null
}
}
}
// endregion
// region RtmClient
private var rtmClient: RtmClient? = null
private var roomChannelName: String? = null
@Throws(Exception::class)
private fun initRtmClient() {
val rtmConfig = RtmConfig.Builder(BuildConfig.AGORA_APP_ID, uid.toString())
.eventListener(rtmEventListener)
.build()
rtmClient = RtmClient.create(rtmConfig)
}
fun rtmLogin(
rtmToken: String,
channelName: String,
rtmChannelJoinSuccess: () -> Unit,
rtmChannelJoinFail: () -> Unit
) {
// 이미 RTM 로그인 및 구독이 완료된 경우 재호출 방지
if (rtmLoggedIn && roomChannelName == channelName) {
Logger.e("rtmLogin - already logged in and subscribed. skip")
return
}
// 로그인 시도 중이면 재호출 방지
if (rtmLoginInProgress) {
Logger.e("rtmLogin - already in progress. skip")
return
}
roomChannelName = channelName
fun attemptLogin(attempt: Int) {
rtmClient!!.login(
rtmToken,
object : ResultCallback<Void> {
override fun onSuccess(p0: Void?) {
Logger.e("rtmClient login - success (attempt=$attempt)")
// 로그인 성공 후 두 채널 구독 시도
subscribeChannel(rtmChannelJoinSuccess, rtmChannelJoinFail)
}
override fun onFailure(p0: ErrorInfo?) {
Logger.e("rtmClient login - fail (attempt=$attempt), ${p0?.errorReason}")
if (attempt < 4) {
attemptLogin(attempt + 1)
} else {
rtmLoginInProgress = false
rtmChannelJoinFail()
}
}
}
)
}
rtmLoginInProgress = true
attemptLogin(1)
}
private fun subscribeChannel(
rtmChannelJoinSuccess: () -> Unit,
rtmChannelJoinFail: () -> Unit
) {
val targetRoom = roomChannelName
if (targetRoom == null) {
Logger.e("subscribeChannel - roomChannelName is null")
rtmChannelJoinFail()
return
}
var completed = false
var roomSubscribed = false
var inboxSubscribed = false
fun completeSuccessIfReady() {
if (!completed && roomSubscribed && inboxSubscribed) {
completed = true
rtmLoggedIn = true
rtmLoginInProgress = false
Logger.e("RTM subscribe - both channels subscribed")
rtmChannelJoinSuccess()
}
}
fun failOnce(reason: String?) {
if (!completed) {
completed = true
Logger.e("RTM subscribe failed: $reason")
rtmChannelJoinFail()
}
}
fun subscribeRoom(attempt: Int) {
val channelOptions = SubscribeOptions()
channelOptions.withMessage = true
channelOptions.withPresence = true
Logger.e("RTM subscribe(room: $targetRoom) attempt=$attempt")
rtmClient!!.subscribe(
targetRoom,
channelOptions,
object : ResultCallback<Void> {
override fun onSuccess(responseInfo: Void?) {
Logger.e("RTM subscribe(room) success at attempt=$attempt")
roomSubscribed = true
completeSuccessIfReady()
}
override fun onFailure(errorInfo: ErrorInfo?) {
Logger.e("RTM subscribe(room) failure at attempt=$attempt reason=${errorInfo?.errorReason}")
if (attempt < 4) {
subscribeRoom(attempt + 1)
} else {
failOnce("room subscribe failed after 3 retries (4 attempts)")
}
}
}
)
}
fun subscribeInbox(attempt: Int) {
val inboxChannel = "inbox_$uid"
val inboxChannelOptions = SubscribeOptions()
inboxChannelOptions.withMessage = true
Logger.e("RTM subscribe(inbox: $inboxChannel) attempt=$attempt")
rtmClient!!.subscribe(
inboxChannel,
inboxChannelOptions,
object : ResultCallback<Void> {
override fun onSuccess(responseInfo: Void?) {
Logger.e("RTM subscribe(inbox) success at attempt=$attempt")
inboxSubscribed = true
completeSuccessIfReady()
}
override fun onFailure(errorInfo: ErrorInfo?) {
Logger.e("RTM subscribe(inbox) failure at attempt=$attempt reason=${errorInfo?.errorReason}")
if (attempt < 4) {
subscribeInbox(attempt + 1)
} else {
failOnce("inbox subscribe failed after 3 retries (4 attempts)")
}
}
}
)
}
// 두 채널 구독을 병렬로 시도
subscribeRoom(1)
subscribeInbox(1)
}
fun inputChat(message: String, onFailure: () -> Unit) {
if (roomChannelName != null) {
val options = PublishOptions()
options.setChannelType(RtmConstants.RtmChannelType.MESSAGE)
rtmClient!!.publish(
roomChannelName!!,
message,
options,
object : ResultCallback<Void> {
override fun onSuccess(p0: Void?) {
Logger.e("sendMessage - onSuccess")
}
override fun onFailure(p0: ErrorInfo) {
Logger.e("sendMessage fail - ${p0.errorCode}")
Logger.e("sendMessage fail - ${p0.errorReason}")
}
}
)
} else {
Logger.e("inputChat - roomChannelName is null")
onFailure()
}
}
fun sendRawMessageToGroup(
rawMessage: ByteArray,
onSuccess: (() -> Unit)? = null,
onFailure: (() -> Unit)? = null
) {
if (roomChannelName != null) {
val options = PublishOptions()
options.customType = "ByteArray"
rtmClient!!.publish(
roomChannelName!!,
rawMessage,
options,
object : ResultCallback<Void> {
override fun onSuccess(p0: Void?) {
Logger.e("sendMessage - onSuccess")
onSuccess?.invoke()
}
override fun onFailure(p0: ErrorInfo) {
Logger.e("sendMessage fail - ${p0.errorCode}")
Logger.e("sendMessage fail - ${p0.errorReason}")
onFailure?.invoke()
}
}
)
} else {
Logger.e("inputChat - roomChannelName is null")
onFailure?.invoke()
}
}
fun sendRawMessageToPeer(
receiverUid: String,
requestType: LiveRoomRequestType? = null,
rawMessage: ByteArray? = null,
onSuccess: () -> Unit
) {
val option = SendMessageOptions()
val message = rtmClient!!.createMessage()
message.rawMessage = rawMessage ?: requestType.toString().toByteArray()
rtmClient!!.sendMessageToPeer(
receiverUid,
if (roomChannelName != null) {
val message = rawMessage ?: requestType.toString().toByteArray()
val options = PublishOptions()
options.customType = "ByteArray"
rtmClient!!.publish(
"inbox_$receiverUid",
message,
option,
object : ResultCallback<Void?> {
override fun onSuccess(aVoid: Void?) {
options,
object : ResultCallback<Void> {
override fun onSuccess(p0: Void?) {
Logger.e("sendMessage - onSuccess")
onSuccess()
}
override fun onFailure(errorInfo: ErrorInfo) {
override fun onFailure(p0: ErrorInfo) {
Logger.e("sendMessage fail - ${p0.errorCode}")
Logger.e("sendMessage fail - ${p0.errorReason}")
}
}
)
} else {
Logger.e("inputChat - roomChannelName is null")
}
}
fun rtmChannelIsNull(): Boolean {
return rtmChannel == null
fun deInitRtmClient(rtmEventListener: RtmEventListener) {
rtmClient?.removeEventListener(rtmEventListener)
rtmClient?.unsubscribe(roomChannelName, object : ResultCallback<Void> {
override fun onSuccess(responseInfo: Void?) {
Logger.e("RTM unsubscribe - $roomChannelName")
roomChannelName = null
}
fun getConnectionState(): Int {
return rtcEngine!!.connectionState
override fun onFailure(errorInfo: ErrorInfo) {
Logger.e("RTM unsubscribe fail - ${errorInfo.errorCode}")
Logger.e("RTM unsubscribe fail - ${errorInfo.errorReason}")
}
})
rtmClient?.unsubscribe(
"inbox_${SharedPreferenceManager.userId}",
object : ResultCallback<Void> {
override fun onSuccess(responseInfo: Void?) {
Logger.e("RTM unsubscribe - inbox_${SharedPreferenceManager.userId}")
}
override fun onFailure(errorInfo: ErrorInfo) {
Logger.e("RTM unsubscribe fail - ${errorInfo.errorCode}")
Logger.e("RTM unsubscribe fail - ${errorInfo.errorReason}")
}
})
rtmClient?.logout(object : ResultCallback<Void> {
override fun onSuccess(responseInfo: Void?) {
Logger.e("RTM logout")
rtmClient = null
}
override fun onFailure(errorInfo: ErrorInfo) {
Logger.e("RTM logout fail - ${errorInfo.errorCode}")
Logger.e("RTM logout fail - ${errorInfo.errorReason}")
}
})
// 상태 리셋
rtmLoggedIn = false
rtmLoginInProgress = false
}
// endregion
}

View File

@@ -28,6 +28,12 @@ class AudioContentAdapter(
View.GONE
}
binding.tvPoint.visibility = if (item.isPointAvailable) {
View.VISIBLE
} else {
View.GONE
}
binding.ivCover.load(item.coverImageUrl) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.audio_content
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.audio_content.all.GetNewContentAllResponse
import kr.co.vividnext.sodalive.audio_content.all.by_theme.GetContentByThemeResponse
@@ -56,6 +57,13 @@ interface AudioContentApi {
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetAudioContentListResponse>>
@GET("/audio-content/replay-live")
fun getAudioContentReplayLiveList(
@Query("isAdultContentVisible") isAdultContentVisible: Boolean,
@Query("contentType") contentType: ContentType,
@Header("Authorization") authHeader: String
): Flowable<ApiResponse<List<GetAudioContentMainItem>>>
@GET("/audio-content/theme")
fun getAudioContentThemeList(
@Header("Authorization") authHeader: String

View File

@@ -50,6 +50,12 @@ class AudioContentRepository(
authHeader = token
)
fun getAudioContentReplayLiveList(token: String) = api.getAudioContentReplayLiveList(
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = ContentType.values()[SharedPreferenceManager.contentPreference],
authHeader = token
)
fun getAudioContentThemeList(token: String) = api.getAudioContentThemeList(token)
fun uploadAudioContent(

View File

@@ -1,16 +1,16 @@
package kr.co.vividnext.sodalive.audio_content
import androidx.annotation.Keep
import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Entity
@Entity(tableName = "playback_tracking")
@Keep
data class PlaybackTracking(
@Id
@PrimaryKey(autoGenerate = true)
var id: Long = 0,
var contentId: Long,
var totalDuration: Int,

View File

@@ -1,29 +1,21 @@
package kr.co.vividnext.sodalive.audio_content
import kr.co.vividnext.sodalive.common.ObjectBox
import kr.co.vividnext.sodalive.audio_content.db.PlaybackTrackingDao
class PlaybackTrackingRepository(private val objectBox: ObjectBox) {
class PlaybackTrackingRepository(private val dao: PlaybackTrackingDao) {
fun savePlaybackTracking(data: PlaybackTracking): Long {
return objectBox.playbackTrackingBox.put(data)
return dao.insert(data)
}
fun getPlaybackTracking(id: Long): PlaybackTracking? {
val query = objectBox.playbackTrackingBox
.query(PlaybackTracking_.id.equal(id))
.build()
val playbackTracking = query.findFirst()
query.close()
return playbackTracking
return dao.getById(id)
}
fun getAllPlaybackTracking(): List<PlaybackTracking> {
return objectBox
.playbackTrackingBox
.all
return dao.getAll()
}
fun removeAllPlaybackTracking() {
objectBox.playbackTrackingBox.removeAll()
dao.deleteAll()
}
}

View File

@@ -2,8 +2,6 @@ package kr.co.vividnext.sodalive.audio_content.all
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -15,12 +13,9 @@ 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 com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.main.GetAudioContentMainItem
import kr.co.vividnext.sodalive.databinding.ItemAudioContentNewAllBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.moneyFormat
class AudioContentNewAllAdapter(
@@ -47,11 +42,18 @@ class AudioContentNewAllAdapter(
)
.into(binding.ivAudioContentCoverImage)
val layoutParams = binding.ivAudioContentCoverImage.layoutParams as ConstraintLayout.LayoutParams
val layoutParams =
binding.ivAudioContentCoverImage.layoutParams as ConstraintLayout.LayoutParams
layoutParams.width = itemWidth
layoutParams.height = itemWidth
binding.ivAudioContentCoverImage.layoutParams = layoutParams
binding.ivPoint.visibility = if (item.isPointAvailable) {
View.VISIBLE
} else {
View.GONE
}
binding.ivAudioContentCreator.load(item.creatorProfileImageUrl) {
crossfade(true)
placeholder(R.drawable.ic_place_holder)
@@ -94,7 +96,7 @@ class AudioContentNewAllAdapter(
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: AudioContentNewAllAdapter.ViewHolder, position: Int) {
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.audio_content.all
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
@@ -29,6 +30,12 @@ class AudioContentRankingAllAdapter(
transformations(RoundedCornersTransformation(5.3f.dpToPx()))
}
binding.tvPoint.visibility = if (item.isPointAvailable) {
View.VISIBLE
} else {
View.GONE
}
binding.tvTitle.text = item.title
binding.tvRank.text = index.plus(1).toString()
binding.tvTheme.text = item.themeStr

View File

@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.audio_content.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kr.co.vividnext.sodalive.audio_content.PlaybackTracking
@Dao
interface PlaybackTrackingDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(entity: PlaybackTracking): Long
@Query("SELECT * FROM playback_tracking WHERE id = :id LIMIT 1")
fun getById(id: Long): PlaybackTracking?
@Query("SELECT * FROM playback_tracking")
fun getAll(): List<PlaybackTracking>
@Query("DELETE FROM playback_tracking")
fun deleteAll()
}

View File

@@ -0,0 +1,35 @@
package kr.co.vividnext.sodalive.audio_content.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import kr.co.vividnext.sodalive.audio_content.PlaybackTracking
import kr.co.vividnext.sodalive.common.Converter
@Database(entities = [PlaybackTracking::class], version = 1, exportSchema = true)
@TypeConverters(Converter::class)
abstract class PlaybackTrackingDatabase : RoomDatabase() {
abstract fun playbackTrackingDao(): PlaybackTrackingDao
companion object {
@Volatile
private var INSTANCE: PlaybackTrackingDatabase? = null
fun getDatabase(context: Context): PlaybackTrackingDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
PlaybackTrackingDatabase::class.java,
"playback_tracking_database"
)
.fallbackToDestructiveMigration()
.allowMainThreadQueries()
.build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -1,8 +1,6 @@
package kr.co.vividnext.sodalive.audio_content.detail
import android.annotation.SuppressLint
import android.app.Activity
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -56,6 +54,8 @@ import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.mypage.can.payment.CanPaymentTempActivity
import kr.co.vividnext.sodalive.mypage.recent.RecentContentViewModel
import kr.co.vividnext.sodalive.mypage.recent.db.RecentContent
import kr.co.vividnext.sodalive.report.ReportType
import org.koin.android.ext.android.inject
import kotlin.math.ceil
@@ -65,6 +65,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
ActivityAudioContentDetailBinding::inflate
) {
private val viewModel: AudioContentDetailViewModel by inject()
private val recentContentViewModel: RecentContentViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private lateinit var creatorOtherContentAdapter: OtherContentAdapter
@@ -105,7 +106,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
audioContentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)
super.onCreate(savedInstanceState)
imm = getSystemService(Service.INPUT_METHOD_SERVICE) as InputMethodManager
imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
if (audioContentId <= 0) {
Toast.makeText(applicationContext, "잘못된 요청입니다.", Toast.LENGTH_LONG).show()
@@ -115,7 +116,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
activityResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == Activity.RESULT_OK) {
if (it.resultCode == RESULT_OK) {
contentOrder(audioContent, orderType)
}
}
@@ -129,7 +130,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
super.onResume()
val intentFilter = IntentFilter(Constants.ACTION_AUDIO_CONTENT_RECEIVER)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(audioContentReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED)
registerReceiver(audioContentReceiver, intentFilter, RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(audioContentReceiver, intentFilter)
}
@@ -808,6 +809,15 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
)
}
)
recentContentViewModel.insertRecentContent(
RecentContent(
contentId = response.contentId,
coverImageUrl = response.coverImageUrl,
title = response.title,
creatorNickname = response.creator.nickname
)
)
}
binding.ivPlayOrPause.setImageResource(
@@ -1105,6 +1115,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
} else {
audioContent.price
},
isAvailableUsePoint = binding.ivPoint.visibility == View.VISIBLE,
confirmButtonClick = {
startService(
Intent(this, AudioContentPlayService::class.java).apply {
@@ -1187,7 +1198,7 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
false
)
viewModel.isLoading.value = isLoading ?: false
viewModel.isLoading.value = isLoading == true
if (this@AudioContentDetailActivity.audioContentId == contentId) {
runOnUiThread {

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.audio_content.main
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
@@ -14,6 +15,12 @@ class AudioContentMainItemViewHolder(
private val onClickCreator: (Long) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: GetAudioContentMainItem) {
binding.ivPoint.visibility = if (item.isPointAvailable) {
View.VISIBLE
} else {
View.GONE
}
binding.ivAudioContentCoverImage.load(item.coverImageUrl) {
crossfade(true)
placeholder(R.drawable.ic_place_holder)

View File

@@ -20,7 +20,8 @@ data class GetAudioContentMainItem(
@SerializedName("creatorProfileImageUrl") val creatorProfileImageUrl: String,
@SerializedName("creatorNickname") val creatorNickname: String,
@SerializedName("price") val price: Int,
@SerializedName("duration") val duration: String
@SerializedName("duration") val duration: String,
@SerializedName("isPointAvailable") val isPointAvailable: Boolean
)
@Keep
@@ -40,6 +41,7 @@ data class GetAudioContentRankingItem(
@SerializedName("duration") val duration: String,
@SerializedName("creatorId") val creatorId: Long,
@SerializedName("creatorNickname") val creatorNickname: String,
@SerializedName("isPointAvailable") val isPointAvailable: Boolean,
@SerializedName("creatorProfileImageUrl") val creatorProfileImageUrl: String
)

View File

@@ -38,6 +38,12 @@ class PopularContentByCreatorAdapter(
lp.height = itemWidth
binding.ivCover.layoutParams = lp
binding.ivPoint.visibility = if (item.isPointAvailable) {
View.VISIBLE
} else {
View.GONE
}
Glide
.with(context)
.load(item.coverImageUrl)

View File

@@ -2,16 +2,18 @@ package kr.co.vividnext.sodalive.audio_content.modify
import android.Manifest
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.setPadding
import coil.load
import coil.transform.RoundedCornersTransformation
import com.github.dhaval2404.imagepicker.ImagePicker
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.RequestOptions
import com.gun0912.tedpermission.PermissionListener
import com.gun0912.tedpermission.normal.TedPermission
import com.jakewharton.rxbinding4.widget.textChanges
@@ -20,6 +22,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.ImagePickerCropper
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.RealPathUtil
import kr.co.vividnext.sodalive.databinding.ActivityAudioContentModifyBinding
@@ -33,36 +36,7 @@ class AudioContentModifyActivity : BaseActivity<ActivityAudioContentModifyBindin
private val viewModel: AudioContentModifyViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private val imageResult = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val resultCode = result.resultCode
val data = result.data
if (resultCode == RESULT_OK) {
val fileUri = data?.data
if (fileUri != null) {
binding.ivCover.setPadding(0)
binding.ivCover.background = null
binding.ivCover.load(fileUri) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(RoundedCornersTransformation(13.3f.dpToPx()))
}
viewModel.coverImageUri = fileUri
} else {
Toast.makeText(
this,
"잘못된 파일입니다.\n다시 선택해 주세요.",
Toast.LENGTH_SHORT
).show()
}
} else if (resultCode == ImagePicker.RESULT_ERROR) {
Toast.makeText(this, ImagePicker.getError(data), Toast.LENGTH_SHORT).show()
}
}
private lateinit var cropper: ImagePickerCropper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -82,24 +56,53 @@ class AudioContentModifyActivity : BaseActivity<ActivityAudioContentModifyBindin
viewModel.getAudioContentDetail(audioContentId = audioContentId) { finish() }
}
override fun onDestroy() {
cropper.cleanup()
super.onDestroy()
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
cropper = ImagePickerCropper(
caller = this,
context = this,
excludeGif = true,
isEnabledFreeStyleCrop = true,
config = ImagePickerCropper.Config(
aspectX = 1f, aspectY = 1f,
compressFormat = Bitmap.CompressFormat.JPEG,
compressQuality = 90
),
onSuccess = { file, uri ->
binding.ivCover.setPadding(0)
binding.ivCover.background = null
Glide.with(this)
.load(uri)
.placeholder(R.drawable.ic_place_holder)
.apply(
RequestOptions().transform(
RoundedCorners(
13.3f.dpToPx().toInt()
)
)
)
.into(binding.ivCover)
viewModel.coverImageFile = file
},
onError = { e ->
Toast.makeText(this, "${e.message}", Toast.LENGTH_SHORT).show()
}
)
binding.toolbar.tvBack.text = "콘텐츠 수정"
binding.toolbar.tvBack.setOnClickListener { finish() }
binding.ivPhotoPicker.setOnClickListener {
ImagePicker.with(this)
.crop()
.galleryOnly()
.galleryMimeTypes( // Exclude gif images
mimeTypes = arrayOf(
"image/png",
"image/jpg",
"image/jpeg"
)
)
.createIntent { imageResult.launch(it) }
}
binding.ivPhotoPicker.setOnClickListener { cropper.launch() }
binding.llAvailablePoint.setOnClickListener { viewModel.setAvailablePoint(true) }
binding.llNotAvailablePoint.setOnClickListener { viewModel.setAvailablePoint(false) }
binding.llCommentNo.setOnClickListener { viewModel.setAvailableComment(false) }
binding.llCommentYes.setOnClickListener { viewModel.setAvailableComment(true) }
@@ -152,6 +155,15 @@ class AudioContentModifyActivity : BaseActivity<ActivityAudioContentModifyBindin
}
)
compositeDisposable.add(
binding.etTag.textChanges().skip(1)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
viewModel.tags = it.toString()
}
)
viewModel.toastLiveData.observe(this) {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
}
@@ -164,6 +176,14 @@ class AudioContentModifyActivity : BaseActivity<ActivityAudioContentModifyBindin
}
}
viewModel.isAvailablePointLiveData.observe(this) {
if (it) {
checkAvailablePoint()
} else {
checkNotAvailablePoint()
}
}
viewModel.isAvailableCommentLiveData.observe(this) {
if (it) {
binding.ivCommentYes.visibility = View.VISIBLE
@@ -219,8 +239,8 @@ class AudioContentModifyActivity : BaseActivity<ActivityAudioContentModifyBindin
viewModel.setAdult(true)
}
viewModel.isAdultLiveData.observe(this) {
if (it) {
viewModel.isAdultLiveData.observe(this) { isAdult ->
if (isAdult) {
binding.ivAgeAll.visibility = View.GONE
binding.llAgeAll.setBackgroundResource(
R.drawable.bg_round_corner_6_7_13181b
@@ -284,5 +304,53 @@ class AudioContentModifyActivity : BaseActivity<ActivityAudioContentModifyBindin
viewModel.detailLiveData.observe(this) {
binding.etDetail.setText(it)
}
viewModel.tagsLiveData.observe(this) {
binding.etTag.setText(it)
}
}
private fun checkAvailablePoint() {
binding.ivAvailablePoint.visibility = View.VISIBLE
binding.tvAvailablePoint.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
binding.llAvailablePoint.setBackgroundResource(R.drawable.bg_round_corner_6_7_3bb9f1)
binding.ivNotAvailablePoint.visibility = View.GONE
binding.tvNotAvailablePoint.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_3bb9f1
)
)
binding.llNotAvailablePoint.setBackgroundResource(
R.drawable.bg_round_corner_6_7_13181b
)
}
private fun checkNotAvailablePoint() {
binding.ivNotAvailablePoint.visibility = View.VISIBLE
binding.tvNotAvailablePoint.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
binding.llNotAvailablePoint.setBackgroundResource(R.drawable.bg_round_corner_6_7_3bb9f1)
binding.ivAvailablePoint.visibility = View.GONE
binding.tvAvailablePoint.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_3bb9f1
)
)
binding.llAvailablePoint.setBackgroundResource(
R.drawable.bg_round_corner_6_7_13181b
)
}
}

View File

@@ -46,6 +46,10 @@ class AudioContentModifyViewModel(
val detailLiveData: LiveData<String>
get() = _detailLiveData
private val _tagsLiveData = MutableLiveData("")
val tagsLiveData: LiveData<String>
get() = _tagsLiveData
private val _coverImageLiveData = MutableLiveData("")
val coverImageLiveData: LiveData<String>
get() = _coverImageLiveData
@@ -54,12 +58,18 @@ class AudioContentModifyViewModel(
val isAdultShowUiLiveData: LiveData<Boolean>
get() = _isAdultShowUiLiveData
private val _isAvailablePointLiveData = MutableLiveData(false)
val isAvailablePointLiveData: LiveData<Boolean>
get() = _isAvailablePointLiveData
lateinit var getRealPathFromURI: (Uri) -> String?
var contentId: Long = 0
var title: String? = null
var detail: String? = null
var coverImageUri: Uri? = null
var tags: String? = null
var coverImageFile: File? = null
var isPointAvailable: Boolean? = null
fun setAdult(isAdult: Boolean) {
_isAdultLiveData.postValue(isAdult)
@@ -69,6 +79,11 @@ class AudioContentModifyViewModel(
_isAvailableCommentLiveData.postValue(isAvailableComment)
}
fun setAvailablePoint(isAvailablePoint: Boolean) {
isPointAvailable = isAvailablePoint
_isAvailablePointLiveData.value = isAvailablePoint
}
fun getAudioContentDetail(audioContentId: Long, onFailure: (() -> Unit)? = null) {
this.contentId = audioContentId
_isLoading.value = true
@@ -85,10 +100,12 @@ class AudioContentModifyViewModel(
if (it.success && it.data != null) {
_titleLiveData.value = it.data.title
_detailLiveData.value = it.data.detail
_tagsLiveData.value = it.data.tag
_coverImageLiveData.value = it.data.coverImageUrl
_isAvailableCommentLiveData.value = it.data.isCommentAvailable
_isAdultLiveData.value = it.data.isAdult
_isAdultShowUiLiveData.value = !it.data.isAdult
_isAvailablePointLiveData.value = it.data.isAvailableUsePoint
} else {
if (it.message != null) {
_toastLiveData.postValue(it.message)
@@ -125,14 +142,20 @@ class AudioContentModifyViewModel(
contentId = contentId,
title = title,
detail = detail,
tags = if (tags != _tagsLiveData.value!!) {
tags
} else {
null
},
isAdult = _isAdultLiveData.value!!,
isPointAvailable = isPointAvailable,
isCommentAvailable = _isAvailableCommentLiveData.value!!
)
val requestJson = Gson().toJson(request)
val coverImage = if (coverImageUri != null) {
val file = File(getRealPathFromURI(coverImageUri!!))
val coverImage = if (coverImageFile != null) {
val file = coverImageFile!!
MultipartBody.Part.createFormData(
"coverImage",
file.name,

View File

@@ -8,6 +8,8 @@ data class ModifyAudioContentRequest(
@SerializedName("contentId") val contentId: Long,
@SerializedName("title") val title: String?,
@SerializedName("detail") val detail: String?,
@SerializedName("tags") val tags: String?,
@SerializedName("isAdult") val isAdult: Boolean,
@SerializedName("isPointAvailable") val isPointAvailable: Boolean?,
@SerializedName("isCommentAvailable") val isCommentAvailable: Boolean
)

View File

@@ -29,6 +29,7 @@ class AudioContentOrderConfirmDialog(
duration: String,
orderType: OrderType,
price: Int,
isAvailableUsePoint: Boolean,
confirmButtonClick: () -> Unit,
) {
@@ -62,12 +63,52 @@ class AudioContentOrderConfirmDialog(
dialogView.tvDuration.text = duration
if (SharedPreferenceManager.userId == 17958L) {
dialogView.ivCan.visibility = View.GONE
dialogView.tvPrice.text = "${(price * 110).moneyFormat()}"
val maxUsablePoint = if (orderType == OrderType.RENTAL && isAvailableUsePoint) {
price * 10
} else {
0
}
val totalAvailablePoint = if (orderType == OrderType.RENTAL && isAvailableUsePoint) {
SharedPreferenceManager.point
} else {
0
}
val usablePoint = (minOf(totalAvailablePoint, maxUsablePoint) / 10) * 10
if (SharedPreferenceManager.userId == 17958L) {
dialogView.ivPoint.visibility = View.GONE
dialogView.tvPoint.visibility = View.GONE
dialogView.tvPlus.visibility = View.GONE
dialogView.ivCan.visibility = View.GONE
dialogView.tvCan.text = "${(price * 110).moneyFormat()}"
} else {
if (usablePoint > 0) {
dialogView.ivPoint.visibility = View.VISIBLE
dialogView.tvPoint.visibility = View.VISIBLE
dialogView.tvPoint.text = usablePoint.moneyFormat()
} else {
dialogView.ivPoint.visibility = View.GONE
dialogView.tvPoint.visibility = View.GONE
}
val remainingCan = ((price * 10) - usablePoint) / 10
dialogView.tvPlus.visibility = if (usablePoint > 0 && remainingCan > 0) {
View.VISIBLE
} else {
View.GONE
}
if (remainingCan > 0) {
dialogView.ivCan.visibility = View.VISIBLE
dialogView.tvPrice.text = price.moneyFormat()
dialogView.tvCan.visibility = View.VISIBLE
dialogView.tvCan.text = remainingCan.moneyFormat()
} else {
dialogView.ivCan.visibility = View.GONE
dialogView.tvCan.visibility = View.GONE
}
}
if (SharedPreferenceManager.userId == 17958L) {
@@ -78,9 +119,9 @@ class AudioContentOrderConfirmDialog(
}
} else {
dialogView.tvNotice.text = if (orderType == OrderType.RENTAL) {
"콘텐츠를 대여하시겠습니까?\n아래 이 차감됩니다."
"콘텐츠를 대여하시겠습니까?\n아래 금액이 차감됩니다."
} else {
"콘텐츠를 소장하시겠습니까?\n아래 이 차감됩니다."
"콘텐츠를 소장하시겠습니까?\n아래 금액이 차감됩니다."
}
}

View File

@@ -40,6 +40,9 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.Utils
import kr.co.vividnext.sodalive.databinding.FragmentAudioContentPlayerBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.mypage.recent.RecentContentViewModel
import kr.co.vividnext.sodalive.mypage.recent.db.RecentContent
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
@UnstableApi
@@ -53,6 +56,7 @@ class AudioContentPlayerFragment(
private lateinit var binding: FragmentAudioContentPlayerBinding
private val viewModel: AudioContentPlayerViewModel by viewModel()
private val recentContentViewModel: RecentContentViewModel by inject()
private var mediaController: MediaController? = null
private val handler = Handler(Looper.getMainLooper())
@@ -451,7 +455,19 @@ class AudioContentPlayerFragment(
transformations(RoundedCornersTransformation(8f.dpToPx()))
}
adapter.updateCurrentPlayingId(it.extras?.getLong(Constants.EXTRA_AUDIO_CONTENT_ID))
val contentId = it.extras?.getLong(Constants.EXTRA_AUDIO_CONTENT_ID)
adapter.updateCurrentPlayingId(contentId)
// Save to recent content
contentId?.let { id ->
val recentContent = RecentContent(
contentId = id,
coverImageUrl = it.artworkUri.toString(),
title = it.title.toString(),
creatorNickname = it.artist.toString()
)
recentContentViewModel.insertRecentContent(recentContent)
}
}
}

View File

@@ -28,6 +28,12 @@ class SeriesContentAdapter(
transformations(RoundedCornersTransformation(5.3f.dpToPx()))
}
binding.tvPoint.visibility = if (item.isPointAvailable) {
View.VISIBLE
} else {
View.GONE
}
binding.tvTitle.text = item.title
binding.tvDuration.text = item.duration

View File

@@ -21,5 +21,6 @@ data class GetSeriesContentListItem(
@SerializedName("duration") val duration: String,
@SerializedName("price") val price: Int,
@SerializedName("isRented") var isRented: Boolean,
@SerializedName("isOwned") var isOwned: Boolean
@SerializedName("isOwned") var isOwned: Boolean,
@SerializedName("isPointAvailable") var isPointAvailable: Boolean
) : Parcelable

View File

@@ -5,6 +5,7 @@ import android.annotation.SuppressLint
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -15,8 +16,9 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import coil.load
import coil.transform.CircleCropTransformation
import coil.transform.RoundedCornersTransformation
import com.github.dhaval2404.imagepicker.ImagePicker
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.RequestOptions
import com.gun0912.tedpermission.PermissionListener
import com.gun0912.tedpermission.normal.TedPermission
import com.jakewharton.rxbinding4.widget.textChanges
@@ -26,6 +28,7 @@ import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.PurchaseOption
import kr.co.vividnext.sodalive.audio_content.upload.theme.AudioContentThemeFragment
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.ImagePickerCropper
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.RealPathUtil
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
@@ -48,6 +51,7 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
private val viewModel: AudioContentUploadViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private lateinit var cropper: ImagePickerCropper
private val themeFragment: AudioContentThemeFragment by lazy {
AudioContentThemeFragment(
@@ -66,35 +70,6 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
)
}
private val imageResult = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val resultCode = result.resultCode
val data = result.data
if (resultCode == RESULT_OK) {
val fileUri = data?.data
if (fileUri != null) {
binding.ivCover.background = null
binding.ivCover.load(fileUri) {
crossfade(true)
placeholder(R.drawable.ic_place_holder)
transformations(RoundedCornersTransformation(13.3f.dpToPx()))
}
viewModel.coverImageUri = fileUri
} else {
Toast.makeText(
this,
"잘못된 파일입니다.\n다시 선택해 주세요.",
Toast.LENGTH_SHORT
).show()
}
} else if (resultCode == ImagePicker.RESULT_ERROR) {
Toast.makeText(this, ImagePicker.getError(data), Toast.LENGTH_SHORT).show()
}
}
private val selectAudioActivityResultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
@@ -113,18 +88,29 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
Toast.LENGTH_SHORT
).show()
}
} else if (resultCode == ImagePicker.RESULT_ERROR) {
} else {
binding.tvSelectContent.text = "파일 선택"
viewModel.contentUri = null
Toast.makeText(this, ImagePicker.getError(data), Toast.LENGTH_SHORT).show()
Toast.makeText(
this,
"파일 선택을 실패했습니다.\n다시 시도해 주세요.",
Toast.LENGTH_SHORT
).show()
}
}
private val datePickerDialogListener =
DatePickerDialog.OnDateSetListener { _, year, monthOfYear, dayOfMonth ->
viewModel.releaseDate = String.format("%d-%02d-%02d", year, monthOfYear + 1, dayOfMonth)
viewModel.releaseDate = String.format(
Locale.getDefault(),
"%d-%02d-%02d",
year,
monthOfYear + 1,
dayOfMonth
)
viewModel.setReservationDate(
String.format(
Locale.getDefault(),
"%d.%02d.%02d",
year,
monthOfYear + 1,
@@ -135,7 +121,7 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
private val timePickerDialogListener =
TimePickerDialog.OnTimeSetListener { _, hourOfDay, minute ->
val timeString = String.format("%02d:%02d", hourOfDay, minute)
val timeString = String.format(Locale.getDefault(), "%02d:%02d", hourOfDay, minute)
viewModel.releaseTime = timeString
viewModel.setReservationTime(timeString.convertDateFormat("HH:mm", "a hh:mm"))
}
@@ -151,9 +137,45 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
bindData()
}
override fun onDestroy() {
cropper.cleanup()
super.onDestroy()
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
cropper = ImagePickerCropper(
caller = this,
context = this,
excludeGif = true,
isEnabledFreeStyleCrop = true,
config = ImagePickerCropper.Config(
aspectX = 1f, aspectY = 1f,
compressFormat = Bitmap.CompressFormat.JPEG,
compressQuality = 90
),
onSuccess = { file, uri ->
binding.ivCover.background = null
Glide.with(this)
.load(uri)
.placeholder(R.drawable.ic_place_holder)
.apply(
RequestOptions().transform(
RoundedCorners(
13.3f.dpToPx().toInt()
)
)
)
.into(binding.ivCover)
viewModel.coverImageFile = file
},
onError = { e ->
Toast.makeText(this, "${e.message}", Toast.LENGTH_SHORT).show()
}
)
binding.tvServiceDate.text = if (SharedPreferenceManager.userId == 17958L) {
"※ 이용기간 : 대여(5일) | 소장(이용 기간 1년)"
} else {
@@ -167,19 +189,7 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
themeFragment.show(supportFragmentManager, themeFragment.tag)
}
binding.ivPhotoPicker.setOnClickListener {
ImagePicker.with(this)
.crop()
.galleryOnly()
.galleryMimeTypes( // Exclude gif images
mimeTypes = arrayOf(
"image/png",
"image/jpg",
"image/jpeg"
)
)
.createIntent { imageResult.launch(it) }
}
binding.ivPhotoPicker.setOnClickListener { cropper.launch() }
binding.tvSelectContent.setOnClickListener {
val intent = Intent().apply {
@@ -205,6 +215,8 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
binding.llPriceFree.setOnClickListener { viewModel.setPriceFree(true) }
binding.llPreviewYes.setOnClickListener { viewModel.setGeneratePreview(true) }
binding.llPreviewNo.setOnClickListener { viewModel.setGeneratePreview(false) }
binding.llAvailablePoint.setOnClickListener { viewModel.setAvailablePoint(true) }
binding.llNotAvailablePoint.setOnClickListener { viewModel.setAvailablePoint(false) }
binding.llLimited.setOnClickListener { viewModel.setLimited(true) }
binding.llNotLimited.setOnClickListener { viewModel.setLimited(false) }
binding.llBoth.setOnClickListener { viewModel.setPurchaseOption(PurchaseOption.BOTH) }
@@ -357,7 +369,7 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
.subscribe {
val price = it.toString().toIntOrNull()
if (price != null) {
viewModel.price = price.toInt()
viewModel.price = price
} else {
viewModel.price = 0
if (it.isNotBlank()) {
@@ -375,7 +387,7 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
.subscribe {
val limited = it.toString().toIntOrNull()
if (limited != null) {
viewModel.limited = limited.toInt()
viewModel.limited = limited
} else {
viewModel.limited = 0
if (it.isNotBlank()) {
@@ -448,6 +460,14 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
}
}
viewModel.isAvailablePointLiveData.observe(this) {
if (it) {
checkAvailablePoint()
} else {
checkNotAvailablePoint()
}
}
viewModel.purchaseOptionLiveData.observe(this) {
when (it) {
PurchaseOption.BOTH -> checkBoth()
@@ -631,6 +651,8 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
binding.llSetPrice.visibility = View.GONE
binding.llConfigPurchase.visibility = View.GONE
binding.tvTitleConfigKeep.visibility = View.GONE
binding.tvConfigPointTitle.visibility = View.GONE
binding.llConfigPoint.visibility = View.GONE
binding.ivPriceFree.visibility = View.VISIBLE
binding.tvPriceFree.setTextColor(
@@ -660,6 +682,8 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
binding.llSetPrice.visibility = View.VISIBLE
binding.llConfigPurchase.visibility = View.VISIBLE
binding.tvTitleConfigKeep.visibility = View.VISIBLE
binding.tvConfigPointTitle.visibility = View.VISIBLE
binding.llConfigPoint.visibility = View.VISIBLE
binding.ivPricePaid.visibility = View.VISIBLE
binding.tvPricePaid.setTextColor(
@@ -776,6 +800,50 @@ class AudioContentUploadActivity : BaseActivity<ActivityAudioContentUploadBindin
binding.llConfigPreviewTime.visibility = View.GONE
}
private fun checkAvailablePoint() {
binding.ivAvailablePoint.visibility = View.VISIBLE
binding.tvAvailablePoint.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
binding.llAvailablePoint.setBackgroundResource(R.drawable.bg_round_corner_6_7_3bb9f1)
binding.ivNotAvailablePoint.visibility = View.GONE
binding.tvNotAvailablePoint.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_3bb9f1
)
)
binding.llNotAvailablePoint.setBackgroundResource(
R.drawable.bg_round_corner_6_7_13181b
)
}
private fun checkNotAvailablePoint() {
binding.ivNotAvailablePoint.visibility = View.VISIBLE
binding.tvNotAvailablePoint.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_eeeeee
)
)
binding.llNotAvailablePoint.setBackgroundResource(R.drawable.bg_round_corner_6_7_3bb9f1)
binding.ivAvailablePoint.visibility = View.GONE
binding.tvAvailablePoint.setTextColor(
ContextCompat.getColor(
applicationContext,
R.color.color_3bb9f1
)
)
binding.llAvailablePoint.setBackgroundResource(
R.drawable.bg_round_corner_6_7_13181b
)
}
private fun uncheckPurchaseOption() {
binding.ivBoth.visibility = View.GONE
binding.ivBuyOnly.visibility = View.GONE

View File

@@ -63,6 +63,10 @@ class AudioContentUploadViewModel(
val isGeneratePreviewLiveData: LiveData<Boolean>
get() = _isGeneratePreviewLiveData
private val _isAvailablePointLiveData = MutableLiveData(false)
val isAvailablePointLiveData: LiveData<Boolean>
get() = _isAvailablePointLiveData
private val _isActiveReservationLiveData = MutableLiveData(false)
val isActiveReservationLiveData: LiveData<Boolean>
get() = _isActiveReservationLiveData
@@ -85,7 +89,7 @@ class AudioContentUploadViewModel(
var releaseDate = ""
var releaseTime = ""
var theme: GetAudioContentThemeResponse? = null
var coverImageUri: Uri? = null
var coverImageFile: File? = null
var contentUri: Uri? = null
var previewStartTime: String? = null
var previewEndTime: String? = null
@@ -107,6 +111,7 @@ class AudioContentUploadViewModel(
_isLimitedLiveData.postValue(false)
limited = 0
_isGeneratePreviewLiveData.postValue(true)
_isAvailablePointLiveData.postValue(false)
} else {
if (_purchaseOptionLiveData.value!! != PurchaseOption.RENT_ONLY) {
_isShowConfigLimitedLiveData.postValue(true)
@@ -118,6 +123,10 @@ class AudioContentUploadViewModel(
_isGeneratePreviewLiveData.value = isGeneratePreview
}
fun setAvailablePoint(isAvailablePoint: Boolean) {
_isAvailablePointLiveData.value = isAvailablePoint
}
fun setLimited(isLimited: Boolean) {
_isLimitedLiveData.value = isLimited
@@ -176,6 +185,7 @@ class AudioContentUploadViewModel(
themeId = theme!!.id,
isAdult = _isAdultLiveData.value!!,
isGeneratePreview = isGeneratePreview,
isPointAvailable = _isAvailablePointLiveData.value!!,
isCommentAvailable = _isAvailableCommentLiveData.value!!,
previewStartTime = if (isGeneratePreview) {
previewStartTime
@@ -193,8 +203,8 @@ class AudioContentUploadViewModel(
val requestJson = Gson().toJson(request)
val coverImage = if (coverImageUri != null) {
val file = File(getRealPathFromURI(coverImageUri!!))
val coverImage = if (coverImageFile != null) {
val file = coverImageFile!!
MultipartBody.Part.createFormData(
"coverImage",
file.name,
@@ -313,7 +323,7 @@ class AudioContentUploadViewModel(
return false
}
if (coverImageUri == null) {
if (coverImageFile == null) {
_toastLiveData.postValue("커버이미지를 선택해 주세요.")
return false
}
@@ -403,7 +413,7 @@ class AudioContentUploadViewModel(
// Check if the time difference is greater than 30 seconds (30000 milliseconds)
return date2.time - date1.time
} catch (e: Exception) {
} catch (_: Exception) {
// Handle invalid time formats or parsing errors
return 0
}

View File

@@ -17,6 +17,7 @@ data class CreateAudioContentRequest(
@SerializedName("themeId") val themeId: Long,
@SerializedName("isAdult") val isAdult: Boolean,
@SerializedName("isGeneratePreview") val isGeneratePreview: Boolean,
@SerializedName("isPointAvailable") val isPointAvailable: Boolean,
@SerializedName("isCommentAvailable") val isCommentAvailable: Boolean,
@SerializedName("previewStartTime") val previewStartTime: String? = null,
@SerializedName("previewEndTime") val previewEndTime: String? = null,

View File

@@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.audition
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.databinding.ActivityAuditionBinding
@OptIn(UnstableApi::class)
class AuditionActivity : BaseActivity<ActivityAuditionBinding>(
ActivityAuditionBinding::inflate
) {
override fun setupView() {
supportFragmentManager.beginTransaction()
.replace(R.id.fl_container, AuditionFragment())
.commit()
}
}

View File

@@ -49,6 +49,10 @@ class AuditionFragment : BaseFragment<FragmentAuditionBinding>(
private fun setupView() {
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
binding.tvBack.setOnClickListener {
requireActivity().finish()
}
val recyclerView = binding.rvAudition
adapter = AuditionListAdapter {
if (SharedPreferenceManager.token.isNotBlank()) {

View File

@@ -10,6 +10,10 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.viewbinding.ViewBinding
import io.reactivex.rxjava3.disposables.CompositeDisposable
@@ -44,9 +48,31 @@ abstract class BaseActivity<T : ViewBinding>(
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
binding = inflate(layoutInflater)
// 1) 시스템 바 아래로 그리기
WindowCompat.setDecorFitsSystemWindows(window, false)
setContentView(binding.root)
// 2) 시스템 바 아이콘(라이트/다크) 지정
val controller = WindowCompat.getInsetsController(window, binding.root)
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
val isDarkTheme = (resources.configuration.uiMode and 0x30) == 0x20 // NIGHT_YES 여부 단순 판단
controller.isAppearanceLightStatusBars = !isDarkTheme
controller.isAppearanceLightNavigationBars = !isDarkTheme
setupView()
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
// 루트는 좌/우/하만 처리(상단은 Toolbar에 위임)
v.setPadding(bars.left, bars.top, bars.right, bars.bottom)
insets
}
}
override fun onStart() {

View File

@@ -2,12 +2,12 @@ package kr.co.vividnext.sodalive.base
import android.app.Activity
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import androidx.appcompat.app.AlertDialog
import androidx.core.graphics.drawable.toDrawable
import kr.co.vividnext.sodalive.databinding.DialogSodaBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
@@ -33,7 +33,7 @@ open class SodaDialog(
alertDialog = dialogBuilder.create()
alertDialog.setCancelable(false)
alertDialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
alertDialog.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
dialogView.tvTitle.text = title
dialogView.tvDesc.text = desc

View File

@@ -0,0 +1,96 @@
package kr.co.vividnext.sodalive.chat
import android.content.Intent
import android.os.Bundle
import android.view.View
import com.google.android.material.tabs.TabLayout
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.chat.character.CharacterTabFragment
import kr.co.vividnext.sodalive.chat.talk.TalkTabFragment
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentChatBinding
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.search.SearchActivity
class ChatFragment : BaseFragment<FragmentChatBinding>(FragmentChatBinding::inflate) {
private var currentTab = 0
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupToolbar()
setupTabs()
}
private fun setupToolbar() {
if (SharedPreferenceManager.token.isNotBlank()) {
binding.llShortIcon.visibility = View.VISIBLE
binding.ivSearch.setOnClickListener {
startActivity(
Intent(
requireContext(),
SearchActivity::class.java
)
)
}
binding.ivCharge.setOnClickListener {
startActivity(
Intent(
requireContext(),
CanChargeActivity::class.java
)
)
}
} else {
binding.llShortIcon.visibility = View.GONE
}
}
private fun setupTabs() {
// 탭 추가
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("캐릭터"))
binding.tabLayout.addTab(binding.tabLayout.newTab().setText(""))
// 탭 선택 리스너 설정
binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
currentTab = tab.position
showTabContent(currentTab)
}
override fun onTabUnselected(tab: TabLayout.Tab) {
// 필요한 경우 구현
}
override fun onTabReselected(tab: TabLayout.Tab) {
// 필요한 경우 구현
}
})
// 초기 탭 선택
showTabContent(currentTab)
}
private fun showTabContent(position: Int) {
val fragmentManager = childFragmentManager
val fragmentTransaction = fragmentManager.beginTransaction()
// 기존 프래그먼트 제거
fragmentManager.fragments.forEach {
fragmentTransaction.remove(it)
}
// 선택된 탭에 따라 프래그먼트 표시
val fragment = when (position) {
0 -> CharacterTabFragment()
1 -> TalkTabFragment()
else -> CharacterTabFragment()
}
fragmentTransaction.add(R.id.fl_container, fragment)
fragmentTransaction.commit()
}
}

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.chat.character
import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
@Parcelize
@Keep
data class Character(
@SerializedName("characterId") val characterId: Long,
@SerializedName("name") val name: String,
@SerializedName("description") val description: String,
@SerializedName("imageUrl") val imageUrl: String
) : Parcelable

View File

@@ -0,0 +1,81 @@
package kr.co.vividnext.sodalive.chat.character
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.RoundedCornersTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCharacterBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
class CharacterAdapter(
private var characters: List<Character> = emptyList(),
private val showRanking: Boolean = false,
private val onCharacterClick: (Long) -> Unit = {}
) : RecyclerView.Adapter<CharacterAdapter.ViewHolder>() {
inner class ViewHolder(
private val binding: ItemCharacterBinding
) : RecyclerView.ViewHolder(binding.root) {
@SuppressLint("SetTextI18n")
fun bind(character: Character, index: Int) {
binding.tvCharacterName.text = character.name
binding.tvCharacterDescription.text = character.description
// 순위 표시 여부 결정
if (showRanking) {
binding.tvRanking.visibility = View.VISIBLE
binding.tvRanking.text = (index + 1).toString()
binding.tvRanking.apply {
includeFontPadding = false
maxLines = 1
// 뷰가 측정된 뒤 메트릭이 확정되므로, preDraw 시점에 보정
viewTreeObserver.addOnPreDrawListener(
object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
viewTreeObserver.removeOnPreDrawListener(this)
val fm = paint.fontMetrics
// 글리프 하단을 라인 박스 하단에 맞추기 위한 시프트
translationY = fm.descent
return true
}
})
}
} else {
binding.tvRanking.visibility = View.GONE
}
binding.ivCharacter.load(character.imageUrl) {
crossfade(true)
placeholder(R.drawable.ic_logo_service_center)
transformations(RoundedCornersTransformation(16f.dpToPx()))
}
binding.root.setOnClickListener { onCharacterClick(character.characterId) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
ItemCharacterBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(characters[position], index = position)
}
override fun getItemCount(): Int = characters.size
@SuppressLint("NotifyDataSetChanged")
fun updateCharacters(newCharacters: List<Character>) {
characters = newCharacters
notifyDataSetChanged()
}
}

View File

@@ -0,0 +1,59 @@
package kr.co.vividnext.sodalive.chat.character
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterDetailResponse
import kr.co.vividnext.sodalive.chat.character.detail.gallery.CharacterImageListResponse
import kr.co.vividnext.sodalive.chat.character.detail.gallery.CharacterImagePurchaseRequest
import kr.co.vividnext.sodalive.chat.character.detail.gallery.CharacterImagePurchaseResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Body
import retrofit2.http.POST
interface CharacterApi {
@GET("/api/chat/character/main")
fun getCharacterMain(
@Header("Authorization") authHeader: String
): Single<ApiResponse<CharacterHomeResponse>>
@GET("/api/chat/character/{characterId}")
fun getCharacterDetail(
@Header("Authorization") authHeader: String,
@Path("characterId") characterId: Long
): Single<ApiResponse<CharacterDetailResponse>>
@GET("/api/chat/character/image/list")
fun getCharacterImageList(
@Header("Authorization") authHeader: String,
@Query("characterId") characterId: Long,
@Query("page") page: Int,
@Query("size") size: Int
): Single<ApiResponse<CharacterImageListResponse>>
// 내 배경 이미지 리스트 (프로필 + 무료 + 구매 이미지)
// getCharacterImageList와 파라미터/응답 동일, 엔드포인트만 다름
@GET("/api/chat/character/image/my-list")
fun getMyCharacterImageList(
@Header("Authorization") authHeader: String,
@Query("characterId") characterId: Long,
@Query("page") page: Int,
@Query("size") size: Int
): Single<ApiResponse<CharacterImageListResponse>>
// 신규 캐릭터 전체보기
@GET("/api/chat/character/recent")
fun getRecentCharacters(
@Header("Authorization") authHeader: String,
@Query("page") page: Int,
@Query("size") size: Int
): Single<ApiResponse<kr.co.vividnext.sodalive.chat.character.newcharacters.RecentCharactersResponse>>
@POST("/api/chat/character/image/purchase")
fun purchaseCharacterImage(
@Header("Authorization") authHeader: String,
@Body request: CharacterImagePurchaseRequest
): Single<ApiResponse<CharacterImagePurchaseResponse>>
}

View File

@@ -0,0 +1,53 @@
package kr.co.vividnext.sodalive.chat.character
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.widget.FrameLayout
import android.widget.ImageView
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.zhpan.bannerview.BaseBannerAdapter
import com.zhpan.bannerview.BaseViewHolder
import kr.co.vividnext.sodalive.R
class CharacterBannerAdapter(
private val context: Context,
private val itemWidth: Int,
private val itemHeight: Int,
private val onClick: (CharacterBannerResponse) -> Unit
) : BaseBannerAdapter<CharacterBannerResponse>() {
override fun bindData(
holder: BaseViewHolder<CharacterBannerResponse>,
data: CharacterBannerResponse,
position: Int,
pageSize: Int
) {
val ivBanner = holder.findViewById<ImageView>(R.id.iv_recommend_live)
val layoutParams = ivBanner.layoutParams as FrameLayout.LayoutParams
layoutParams.width = itemWidth
layoutParams.height = itemHeight
Glide
.with(context)
.asBitmap()
.load(data.imageUrl)
.into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
ivBanner.setImageBitmap(resource)
ivBanner.layoutParams = layoutParams
}
override fun onLoadCleared(placeholder: Drawable?) {
}
})
ivBanner.setOnClickListener { onClick(data) }
}
override fun getLayoutId(viewType: Int): Int {
return R.layout.item_recommend_live
}
}

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.chat.character
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.chat.character.curation.CurationSection
import kr.co.vividnext.sodalive.chat.character.recent.RecentCharacter
@Keep
data class CharacterHomeResponse(
@SerializedName("banners") val banners: List<CharacterBannerResponse>,
@SerializedName("recentCharacters") val recentCharacters: List<RecentCharacter>,
@SerializedName("popularCharacters") val popularCharacters: List<Character>,
@SerializedName("newCharacters") val newCharacters: List<Character>,
@SerializedName("curationSections") val curationSections: List<CurationSection>
)
@Keep
data class CharacterBannerResponse(
@SerializedName("characterId") val characterId: Long,
@SerializedName("imageUrl") val imageUrl: String
)

View File

@@ -0,0 +1,444 @@
package kr.co.vividnext.sodalive.chat.character
import android.content.Intent
import android.graphics.Rect
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.widget.LinearLayout
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.core.content.ContextCompat
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson
import com.zhpan.bannerview.BaseBannerAdapter
import com.zhpan.indicator.enums.IndicatorSlideMode
import com.zhpan.indicator.enums.IndicatorStyle
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.chat.character.curation.CurationSectionAdapter
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity.Companion.EXTRA_CHARACTER_ID
import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersAllActivity
import kr.co.vividnext.sodalive.chat.character.recent.RecentCharacterAdapter
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentCharacterTabBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.main.MainActivity
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.splash.SplashActivity
import org.koin.android.ext.android.inject
// 캐릭터 탭 프래그먼트
@OptIn(UnstableApi::class)
class CharacterTabFragment : BaseFragment<FragmentCharacterTabBinding>(
FragmentCharacterTabBinding::inflate
) {
private val viewModel: CharacterTabViewModel by inject()
private val myPageViewModel: MyPageViewModel by inject()
private lateinit var contentBannerAdapter: CharacterBannerAdapter
private lateinit var recentCharacterAdapter: RecentCharacterAdapter
private lateinit var popularCharacterAdapter: CharacterAdapter
private lateinit var newCharacterAdapter: CharacterAdapter
private lateinit var curationSectionAdapter: CurationSectionAdapter
private lateinit var loadingDialog: LoadingDialog
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupView()
observeViewModel()
viewModel.fetchData()
}
private fun setupView() {
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
setupBanner()
setupRecentCharactersRecyclerView()
setupPopularCharactersRecyclerView()
setupNewCharactersRecyclerView()
setupCurationSectionsRecyclerView()
}
private fun setupBanner() {
val layoutParams = binding
.bannerSlider
.layoutParams as LinearLayout.LayoutParams
val pagerWidth = screenWidth
val pagerHeight = pagerWidth * 198 / 352
layoutParams.width = pagerWidth
layoutParams.height = pagerHeight
contentBannerAdapter = CharacterBannerAdapter(
requireContext(),
pagerWidth,
pagerHeight
) {
ensureLoginAndAuth {
startActivity(
Intent(requireContext(), CharacterDetailActivity::class.java).apply {
putExtra(EXTRA_CHARACTER_ID, it.characterId)
}
)
}
}
binding
.bannerSlider
.layoutParams = layoutParams
binding.bannerSlider.apply {
adapter = contentBannerAdapter as BaseBannerAdapter<Any>
setLifecycleRegistry(lifecycle)
setScrollDuration(1000)
setInterval(4 * 1000)
}.create()
binding
.bannerSlider
.setIndicatorView(binding.indicatorBanner)
.setIndicatorStyle(IndicatorStyle.ROUND_RECT)
.setIndicatorSlideMode(IndicatorSlideMode.SMOOTH)
.setIndicatorVisibility(View.GONE)
.setIndicatorSliderColor(
ContextCompat.getColor(requireContext(), R.color.color_909090),
ContextCompat.getColor(requireContext(), R.color.color_3bb9f1)
)
.setIndicatorSliderWidth(10f.dpToPx().toInt(), 10f.dpToPx().toInt())
.setIndicatorHeight(10f.dpToPx().toInt())
viewModel.bannerListLiveData.observe(viewLifecycleOwner) {
if (it.isNotEmpty()) {
binding.llBanner.visibility = View.VISIBLE
binding.bannerSlider.refreshData(it)
} else {
binding.llBanner.visibility = View.GONE
}
}
}
private fun setupRecentCharactersRecyclerView() {
// 최근 대화한 캐릭터 RecyclerView 설정
recentCharacterAdapter = RecentCharacterAdapter {
onCharacterClick(it)
}
val recyclerView = binding.rvRecentCharacters
recyclerView.layoutManager = LinearLayoutManager(
requireContext(),
LinearLayoutManager.HORIZONTAL,
false
)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.left = 0
outRect.right = 8f.dpToPx().toInt()
}
recentCharacterAdapter.itemCount - 1 -> {
outRect.left = 8f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 8f.dpToPx().toInt()
outRect.right = 8f.dpToPx().toInt()
}
}
}
})
recyclerView.adapter = recentCharacterAdapter
// 최근 대화한 캐릭터 LiveData 구독
viewModel.recentCharacters.observe(viewLifecycleOwner) {
if (it.isNotEmpty()) {
binding.llLatestCharacters.visibility = View.VISIBLE
recentCharacterAdapter.updateCharacters(it)
binding.tvLatestCharacterCount.text = it.size.toString()
} else {
binding.llLatestCharacters.visibility = View.GONE
}
}
}
private fun setupPopularCharactersRecyclerView() {
// 인기 캐릭터 RecyclerView 설정 (순위 표시)
popularCharacterAdapter = CharacterAdapter(
showRanking = true
) {
onCharacterClick(it)
}
val recyclerView = binding.rvPopularCharacters
recyclerView.layoutManager = LinearLayoutManager(
requireContext(),
LinearLayoutManager.HORIZONTAL,
false
)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.left = 0
outRect.right = 8f.dpToPx().toInt()
}
popularCharacterAdapter.itemCount - 1 -> {
outRect.left = 8f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 8f.dpToPx().toInt()
outRect.right = 8f.dpToPx().toInt()
}
}
}
})
recyclerView.adapter = popularCharacterAdapter
binding.tvPopularCharacterAll.setOnClickListener {
}
// 인기 캐릭터 LiveData 구독
viewModel.popularCharacters.observe(viewLifecycleOwner) {
if (it.isNotEmpty()) {
binding.llPopularCharacters.visibility = View.VISIBLE
popularCharacterAdapter.updateCharacters(it)
} else {
binding.llPopularCharacters.visibility = View.GONE
}
}
}
private fun setupNewCharactersRecyclerView() {
// 신규 캐릭터 RecyclerView 설정
newCharacterAdapter = CharacterAdapter(
showRanking = false
) {
onCharacterClick(it)
}
val recyclerView = binding.rvNewCharacters
recyclerView.layoutManager = LinearLayoutManager(
requireContext(),
LinearLayoutManager.HORIZONTAL,
false
)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.left = 0
outRect.right = 8f.dpToPx().toInt()
}
newCharacterAdapter.itemCount - 1 -> {
outRect.left = 8f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 8f.dpToPx().toInt()
outRect.right = 8f.dpToPx().toInt()
}
}
}
})
recyclerView.adapter = newCharacterAdapter
binding.tvNewCharacterAll.setOnClickListener {
ensureLoginAndAuth {
startActivity(
Intent(
requireContext(),
NewCharactersAllActivity::class.java
)
)
}
}
// 신규 캐릭터 LiveData 구독
viewModel.newCharacters.observe(viewLifecycleOwner) {
if (it.isNotEmpty()) {
binding.llNewCharacters.visibility = View.VISIBLE
newCharacterAdapter.updateCharacters(it)
} else {
binding.llNewCharacters.visibility = View.GONE
}
}
}
private fun setupCurationSectionsRecyclerView() {
// 큐레이션 섹션 RecyclerView 설정
curationSectionAdapter = CurationSectionAdapter {
onCharacterClick(it)
}
val recyclerView = binding.rvCurationSections
recyclerView.layoutManager = LinearLayoutManager(
requireContext(),
LinearLayoutManager.VERTICAL,
false
)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.top = 0
outRect.bottom = 24f.dpToPx().toInt()
}
curationSectionAdapter.itemCount - 1 -> {
outRect.top = 24f.dpToPx().toInt()
outRect.bottom = 0
}
else -> {
outRect.top = 24f.dpToPx().toInt()
outRect.bottom = 24f.dpToPx().toInt()
}
}
}
})
recyclerView.adapter = curationSectionAdapter
// 큐레이션 섹션 LiveData 구독
viewModel.curationSections.observe(viewLifecycleOwner) {
if (it.isNotEmpty()) {
recyclerView.visibility = View.VISIBLE
curationSectionAdapter.updateSections(it)
} else {
recyclerView.visibility = View.GONE
}
}
}
private fun ensureLoginAndAuth(onAuthed: () -> Unit) {
if (SharedPreferenceManager.token.isBlank()) {
(requireActivity() as MainActivity).showLoginActivity()
return
}
if (!SharedPreferenceManager.isAuth) {
SodaDialog(
activity = requireActivity(),
layoutInflater = layoutInflater,
title = "본인인증",
desc = "보이스온의 오픈월드 캐릭터톡은\n청소년 보호를 위해 본인인증한\n성인만 이용이 가능합니다.\n" +
"캐릭터톡 서비스를 이용하시려면\n본인인증을 하고 이용해주세요.",
confirmButtonTitle = "본인인증 하러가기",
confirmButtonClick = { startAuthFlow() },
cancelButtonTitle = "취소",
cancelButtonClick = {},
descGravity = Gravity.CENTER
).show(screenWidth)
return
}
onAuthed()
}
private fun startAuthFlow() {
Auth.auth(requireActivity(), requireContext()) { json ->
val bootpayResponse = Gson().fromJson(
json,
kr.co.vividnext.sodalive.mypage.auth.BootpayResponse::class.java
)
val request = AuthVerifyRequest(receiptId = bootpayResponse.data.receiptId)
requireActivity().runOnUiThread {
myPageViewModel.authVerify(request) {
startActivity(
Intent(
requireContext(),
SplashActivity::class.java
).apply {
addFlags(
Intent.FLAG_ACTIVITY_CLEAR_TASK or
Intent.FLAG_ACTIVITY_NEW_TASK
)
}
)
requireActivity().finish()
}
}
}
}
private fun observeViewModel() {
viewModel.isLoading.observe(viewLifecycleOwner) {
if (it) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
viewModel.toastLiveData.observe(viewLifecycleOwner) {
it?.let { Toast.makeText(requireActivity(), it, Toast.LENGTH_LONG).show() }
}
}
private fun onCharacterClick(characterId: Long) {
ensureLoginAndAuth {
startActivity(
Intent(requireContext(), CharacterDetailActivity::class.java).apply {
putExtra(EXTRA_CHARACTER_ID, characterId)
}
)
}
}
}

View File

@@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.chat.character
class CharacterTabRepository(private val api: CharacterApi) {
fun getCharacterMain(
token: String
) = api.getCharacterMain(authHeader = token)
}

View File

@@ -0,0 +1,78 @@
package kr.co.vividnext.sodalive.chat.character
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.chat.character.curation.CurationSection
import kr.co.vividnext.sodalive.chat.character.recent.RecentCharacter
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
class CharacterTabViewModel(
private val repository: CharacterTabRepository
) : BaseViewModel() {
private var _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private var _bannerListLiveData = MutableLiveData<List<CharacterBannerResponse>>()
val bannerListLiveData: LiveData<List<CharacterBannerResponse>>
get() = _bannerListLiveData
// 최근 대화한 캐릭터 LiveData
private val _recentCharacters = MutableLiveData<List<RecentCharacter>>(emptyList())
val recentCharacters: LiveData<List<RecentCharacter>>
get() = _recentCharacters
// 인기 캐릭터 LiveData
private val _popularCharacters = MutableLiveData<List<Character>>(emptyList())
val popularCharacters: LiveData<List<Character>>
get() = _popularCharacters
// 신규 캐릭터 LiveData
private val _newCharacters = MutableLiveData<List<Character>>(emptyList())
val newCharacters: LiveData<List<Character>>
get() = _newCharacters
// 큐레이션 섹션 LiveData
private val _curationSections = MutableLiveData<List<CurationSection>>(emptyList())
val curationSections: LiveData<List<CurationSection>>
get() = _curationSections
fun fetchData() {
_isLoading.value = true
compositeDisposable.add(
repository.getCharacterMain(token = "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
val data = it.data
if (it.success && data != null) {
_bannerListLiveData.value = data.banners
_recentCharacters.value = data.recentCharacters
_popularCharacters.value = data.popularCharacters
_newCharacters.value = data.newCharacters
_curationSections.value = data.curationSections
} else {
_toastLiveData.value =
it.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
}
_isLoading.value = false
},
{
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
}
)
)
}
}

View File

@@ -0,0 +1,60 @@
package kr.co.vividnext.sodalive.chat.character.comment
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface CharacterCommentApi {
@POST("/api/chat/character/{characterId}/comments")
fun createComment(
@Path("characterId") characterId: Long,
@Body request: CreateCharacterCommentRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@POST("/api/chat/character/{characterId}/comments/{commentId}/replies")
fun createReply(
@Path("characterId") characterId: Long,
@Path("commentId") commentId: Long,
@Body request: CreateCharacterCommentRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@GET("/api/chat/character/{characterId}/comments")
fun listComments(
@Path("characterId") characterId: Long,
@Query("limit") limit: Int = 20,
@Query("cursor") cursor: Long?,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CharacterCommentListResponse>>
@GET("/api/chat/character/{characterId}/comments/{commentId}/replies")
fun listReplies(
@Path("characterId") characterId: Long,
@Path("commentId") commentId: Long,
@Query("limit") limit: Int = 20,
@Query("cursor") cursor: Long?,
@Header("Authorization") authHeader: String
): Single<ApiResponse<CharacterCommentRepliesResponse>>
@DELETE("/api/chat/character/{characterId}/comments/{commentId}")
fun deleteComment(
@Path("characterId") characterId: Long,
@Path("commentId") commentId: Long,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
@POST("/api/chat/character/{characterId}/comments/{commentId}/reports")
fun reportComment(
@Path("characterId") characterId: Long,
@Path("commentId") commentId: Long,
@Body request: ReportCharacterCommentRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<Any>>
}

View File

@@ -0,0 +1,69 @@
package kr.co.vividnext.sodalive.chat.character.comment
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
// Request DTOs
@Keep
data class CreateCharacterCommentRequest(
@SerializedName("comment") val comment: String
)
// Response DTOs
// 댓글 Response
// - 댓글 ID
// - 댓글 쓴 Member 프로필 이미지
// - 댓글 쓴 Member 닉네임
// - 댓글 쓴 시간 timestamp(long)
// - 답글 수
@Keep
data class CharacterCommentResponse(
@SerializedName("commentId") val commentId: Long,
@SerializedName("memberId") val memberId: Long,
@SerializedName("memberProfileImage") val memberProfileImage: String,
@SerializedName("memberNickname") val memberNickname: String,
@SerializedName("createdAt") val createdAt: Long,
@SerializedName("replyCount") val replyCount: Int,
@SerializedName("comment") val comment: String
)
// 답글 Response 단건(목록 원소)
// - 답글 ID
// - 답글 쓴 Member 프로필 이미지
// - 답글 쓴 Member 닉네임
// - 답글 쓴 시간 timestamp(long)
@Keep
data class CharacterReplyResponse(
@SerializedName("replyId") val replyId: Long,
@SerializedName("memberId") val memberId: Long,
@SerializedName("memberProfileImage") val memberProfileImage: String,
@SerializedName("memberNickname") val memberNickname: String,
@SerializedName("createdAt") val createdAt: Long,
@SerializedName("comment") val comment: String
)
// 댓글의 답글 조회 Response 컨테이너
// - 원본 댓글 Response
// - 답글 목록(위 사양의 필드 포함)
@Keep
data class CharacterCommentRepliesResponse(
@SerializedName("original") val original: CharacterCommentResponse,
@SerializedName("replies") val replies: List<CharacterReplyResponse>,
@SerializedName("cursor") val cursor: Long?
)
// 댓글 리스트 조회 Response 컨테이너
// - 전체 댓글 개수(totalCount)
// - 댓글 목록(comments)
@Keep
data class CharacterCommentListResponse(
@SerializedName("totalCount") val totalCount: Int,
@SerializedName("comments") val comments: List<CharacterCommentResponse>,
@SerializedName("cursor") val cursor: Long?
)
// 신고 Request
@Keep
data class ReportCharacterCommentRequest(
@SerializedName("content") val content: String
)

View File

@@ -0,0 +1,64 @@
package kr.co.vividnext.sodalive.chat.character.comment
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.fragment.app.Fragment
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.DialogCharacterCommentBinding
/**
* 캐릭터 댓글 리스트 BottomSheet 컨테이너
* 내부에 CharacterCommentListFragment를 호스팅합니다.
*/
class CharacterCommentListBottomSheet(
private val characterId: Long
) : BottomSheetDialogFragment() {
private lateinit var binding: DialogCharacterCommentBinding
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.setOnShowListener {
val d = it as BottomSheetDialog
val bottomSheet = d.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.let { bs ->
BottomSheetBehavior.from(bs).state = BottomSheetBehavior.STATE_EXPANDED
}
}
return dialog
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DialogCharacterCommentBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val tag = "CHARACTER_COMMENT_LIST"
val fragment: Fragment = CharacterCommentListFragment.newInstance(characterId)
childFragmentManager.beginTransaction()
.replace(R.id.fl_container, fragment, tag)
.commit()
}
fun openReply(original: CharacterCommentResponse) {
val tag = "CHARACTER_COMMENT_REPLY"
val fragment = CharacterCommentReplyFragment.newInstance(characterId, original)
childFragmentManager.beginTransaction()
.add(R.id.fl_container, fragment, tag)
.addToBackStack(tag)
.commit()
}
}

View File

@@ -0,0 +1,208 @@
package kr.co.vividnext.sodalive.chat.character.comment
import android.annotation.SuppressLint
import android.app.Service
import android.graphics.Rect
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentCharacterCommentListBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
class CharacterCommentListFragment : BaseFragment<FragmentCharacterCommentListBinding>(
FragmentCharacterCommentListBinding::inflate
) {
private val viewModel: CharacterCommentListViewModel by inject()
private lateinit var imm: InputMethodManager
private lateinit var loadingDialog: LoadingDialog
private lateinit var adapter: CharacterCommentsAdapter
private var characterId: Long = 0
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
characterId = arguments?.getLong(EXTRA_CHARACTER_ID) ?: 0
return super.onCreateView(inflater, container, savedInstanceState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
imm = requireContext()
.getSystemService(Service.INPUT_METHOD_SERVICE) as InputMethodManager
setupView()
bindData()
// 초기 로드
viewModel.reset(characterId)
}
private fun hideDialog() {
(parentFragment as? BottomSheetDialogFragment)?.dismiss()
}
private fun setupView() {
binding.ivClose.setOnClickListener { hideDialog() }
binding.ivCommentProfile.load(SharedPreferenceManager.profileImage) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
binding.ivCommentSend.setOnClickListener {
hideKeyboard()
val comment = binding.etComment.text.toString()
if (comment.isBlank()) return@setOnClickListener
viewModel.createComment(characterId, comment)
binding.etComment.setText("")
}
adapter = CharacterCommentsAdapter(
currentUserId = SharedPreferenceManager.userId,
onClickMore = { item, isOwner, anchor ->
CharacterCommentMoreBottomSheet.newInstance(isOwner).apply {
onReport = {
val reportSheet = CharacterCommentReportBottomSheet.newInstance()
reportSheet.onSubmit = { reason ->
viewModel.reportComment(characterId, item.commentId, reason)
}
reportSheet.show(parentFragmentManager, "comment_report")
}
onDelete = {
// 삭제 확인 팝업
AlertDialog.Builder(requireContext())
.setTitle(getString(R.string.confirm_delete_title))
.setMessage(getString(R.string.confirm_delete_message))
.setPositiveButton(getString(R.string.confirm)) { _, _ ->
viewModel.deleteComment(
characterId = characterId,
commentId = item.commentId
)
}
.setNegativeButton(getString(R.string.cancel), null)
.show()
}
}.show(childFragmentManager, "comment_more")
},
onClickItem = { item ->
(parentFragment as? CharacterCommentListBottomSheet)?.openReply(item)
}
)
val recyclerView = binding.rvComment
recyclerView.setHasFixedSize(true)
recyclerView.layoutManager = LinearLayoutManager(
activity,
LinearLayoutManager.VERTICAL,
false
)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.left = 24f.dpToPx().toInt()
outRect.right = 24f.dpToPx().toInt()
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.top = 24f.dpToPx().toInt()
outRect.bottom = 12f.dpToPx().toInt()
}
adapter.itemCount - 1 -> {
outRect.top = 12f.dpToPx().toInt()
outRect.bottom = 24f.dpToPx().toInt()
}
else -> {
outRect.top = 24f.dpToPx().toInt()
outRect.bottom = 24f.dpToPx().toInt()
}
}
}
})
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val lastVisible = (recyclerView.layoutManager as LinearLayoutManager)
.findLastCompletelyVisibleItemPosition()
val total = recyclerView.adapter?.itemCount ?: 0
if (!recyclerView.canScrollVertically(1) && lastVisible == total - 1) {
viewModel.getCommentList(characterId)
}
}
})
recyclerView.adapter = adapter
}
@SuppressLint("NotifyDataSetChanged")
private fun bindData() {
viewModel.isLoading.observe(viewLifecycleOwner) {
if (it) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
viewModel.toastLiveData.observe(viewLifecycleOwner) { msg ->
msg?.let { showToast(it) }
}
viewModel.totalCommentCount.observe(viewLifecycleOwner) { count ->
binding.tvCommentCount.text = "$count"
}
viewModel.commentList.observe(viewLifecycleOwner) { items ->
if (viewModel.page - 1 == 1) {
adapter.items.clear()
binding.rvComment.scrollToPosition(0)
}
adapter.items.addAll(items)
adapter.notifyDataSetChanged()
}
}
private fun hideKeyboard() {
imm.hideSoftInputFromWindow(view?.windowToken, 0)
}
companion object {
private const val EXTRA_CHARACTER_ID = "extra_character_id"
fun newInstance(characterId: Long): CharacterCommentListFragment {
val args = Bundle().apply { putLong(EXTRA_CHARACTER_ID, characterId) }
val f = CharacterCommentListFragment()
f.arguments = args
return f
}
}
}

View File

@@ -0,0 +1,197 @@
package kr.co.vividnext.sodalive.chat.character.comment
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.SharedPreferenceManager
class CharacterCommentListViewModel(
private val repository: CharacterCommentRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?>
get() = _toastLiveData
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _commentList = MutableLiveData<List<CharacterCommentResponse>>()
val commentList: LiveData<List<CharacterCommentResponse>>
get() = _commentList
private val _totalCommentCount = MutableLiveData(0)
val totalCommentCount: LiveData<Int>
get() = _totalCommentCount
// 페이지네이션 상태 (cursor 기반이지만 UI에선 1페이지 초기화 판단을 위해 page 인덱스 유지)
var page: Int = 1
private set
private var isLast: Boolean = false
private val size: Int = 20
private var cursor: Long? = null
fun reset(characterId: Long) {
page = 1
isLast = false
cursor = null
getCommentList(characterId)
}
fun getCommentList(characterId: Long, onFailure: (() -> Unit)? = null) {
// 로딩 중이면 차단
if (_isLoading.value == true) return
// 이슈 요구사항: 초기 1회 로드 허용, 이후엔 cursor != null일 때만 추가 로드
if (page > 1 && cursor == null) return
// 이미 마지막이면 차단 (보조 안전장치)
if (isLast) return
_isLoading.value = true
val token = "Bearer ${SharedPreferenceManager.token}"
compositeDisposable.add(
repository.listComments(
characterId = characterId,
limit = size,
cursor = cursor,
token = token
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
_isLoading.value = false
if (resp.success && resp.data != null) {
// total count 업데이트
_totalCommentCount.postValue(resp.data.totalCount)
// 다음 페이지 커서 및 마지막 여부 갱신
val nextCursor = resp.data.cursor
cursor = nextCursor
isLast = (nextCursor == null)
// 페이지 인덱스 증가 (UI에서 초기화 판단용)
page += 1
val items = resp.data.comments
// 응답 아이템 전달 (비어있어도 전달) — UI는 addAll 처리
_commentList.postValue(items)
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
_toastLiveData.postValue(message)
onFailure?.invoke()
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character comments load failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
onFailure?.invoke()
})
)
}
fun createComment(characterId: Long, comment: String) {
if (comment.isBlank()) {
_toastLiveData.postValue("내용을 입력하세요")
return
}
if (_isLoading.value == true) return
_isLoading.value = true
val token = "Bearer ${SharedPreferenceManager.token}"
compositeDisposable.add(
repository.createComment(
characterId = characterId,
comment = comment,
token = token
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
_isLoading.value = false
if (resp.success) {
// 목록 초기화 후 재조회
page = 1
isLast = false
cursor = null
getCommentList(characterId)
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
_toastLiveData.postValue(message)
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character comment create failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
})
)
}
fun deleteComment(characterId: Long, commentId: Long) {
if (_isLoading.value == true) return
_isLoading.value = true
val token = "Bearer ${SharedPreferenceManager.token}"
compositeDisposable.add(
repository.deleteComment(
characterId = characterId,
commentId = commentId,
token = token
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
_isLoading.value = false
if (resp.success) {
// 간단하게 전체를 새로고침
page = 1
isLast = false
cursor = null
getCommentList(characterId)
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
_toastLiveData.postValue(message)
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character comment delete failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
})
)
}
fun reportComment(characterId: Long, commentId: Long, reason: String) {
if (reason.isBlank()) {
_toastLiveData.postValue("신고 사유를 입력하세요")
return
}
if (_isLoading.value == true) return
_isLoading.value = true
val token = "Bearer ${SharedPreferenceManager.token}"
compositeDisposable.add(
repository.reportComment(
characterId = characterId,
commentId = commentId,
reason = reason,
token = token
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
_isLoading.value = false
if (resp.success) {
_toastLiveData.postValue("신고가 접수되었습니다.")
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
_toastLiveData.postValue(message)
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character comment report failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
})
)
}
}

View File

@@ -0,0 +1,61 @@
package kr.co.vividnext.sodalive.chat.character.comment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kr.co.vividnext.sodalive.R
class CharacterCommentMoreBottomSheet : BottomSheetDialogFragment() {
var onReport: (() -> Unit)? = null
var onDelete: (() -> Unit)? = null
private var isOwner: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
isOwner = arguments?.getBoolean(ARG_IS_OWNER) ?: false
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = inflater.inflate(R.layout.dialog_character_comment_more, container, false)
val tvReport = view.findViewById<TextView>(R.id.tv_report)
val tvDelete = view.findViewById<TextView>(R.id.tv_delete)
// 공통 리스너 설정
tvReport.setOnClickListener {
dismiss()
onReport?.invoke()
}
// 요구사항: 내가 쓴 댓글은 '삭제'만, 남이 쓴 댓글은 '신고'만 노출
if (isOwner) {
tvReport.visibility = View.GONE
tvDelete.visibility = View.VISIBLE
tvDelete.setOnClickListener {
dismiss()
onDelete?.invoke()
}
} else {
tvReport.visibility = View.VISIBLE
tvDelete.visibility = View.GONE
}
return view
}
companion object {
private const val ARG_IS_OWNER = "arg_is_owner"
fun newInstance(isOwner: Boolean): CharacterCommentMoreBottomSheet {
val f = CharacterCommentMoreBottomSheet()
f.arguments = Bundle().apply { putBoolean(ARG_IS_OWNER, isOwner) }
return f
}
}
}

View File

@@ -0,0 +1,125 @@
package kr.co.vividnext.sodalive.chat.character.comment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import coil.load
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCharacterCommentBinding
import kr.co.vividnext.sodalive.databinding.ItemCharacterCommentReplyBinding
class CharacterCommentReplyAdapter(
private val currentUserId: Long,
private val onMore: (data: CharacterReplyResponse, isOwner: Boolean) -> Unit
) : RecyclerView.Adapter<CharacterReplyVH>() {
// 첫 번째 아이템은 항상 원본 댓글
val items = mutableListOf<Any>() // [CharacterCommentResponse] + List<CharacterReplyResponse>
override fun getItemViewType(position: Int): Int = if (position == 0) 0 else 1
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CharacterReplyVH {
return if (viewType == 0) {
CharacterReplyHeaderVH(
ItemCharacterCommentBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
} else {
CharacterReplyItemVH(
binding = ItemCharacterCommentReplyBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
),
currentUserId = currentUserId,
onMoreCallback = onMore
)
}
}
override fun onBindViewHolder(holder: CharacterReplyVH, position: Int) {
val item = items[position]
holder.bind(item)
}
override fun getItemCount(): Int = items.size
}
abstract class CharacterReplyVH(binding: ViewBinding) : RecyclerView.ViewHolder(binding.root) {
abstract fun bind(item: Any)
}
class CharacterReplyHeaderVH(
private val binding: ItemCharacterCommentBinding
) : CharacterReplyVH(binding) {
override fun bind(item: Any) {
val data = item as CharacterCommentResponse
if (data.memberProfileImage.isNotBlank()) {
binding.ivCommentProfile.load(data.memberProfileImage) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
} else {
binding.ivCommentProfile.setImageResource(R.drawable.ic_placeholder_profile)
}
binding.tvCommentNickname.text = data.memberNickname
binding.tvCommentDate.text = timeAgo(data.createdAt)
binding.tvComment.text = data.comment
binding.tvWriteReply.visibility = View.GONE
binding.ivMenu.visibility = View.GONE
}
}
class CharacterReplyItemVH(
private val binding: ItemCharacterCommentReplyBinding,
private val currentUserId: Long,
private val onMoreCallback: (data: CharacterReplyResponse, isOwner: Boolean) -> Unit
) : CharacterReplyVH(binding) {
override fun bind(item: Any) {
val data = item as CharacterReplyResponse
if (data.memberProfileImage.isNotBlank()) {
binding.ivCommentProfile.load(data.memberProfileImage) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
} else {
binding.ivCommentProfile.setImageResource(R.drawable.ic_placeholder_profile)
}
binding.tvCommentNickname.text = data.memberNickname
binding.tvCommentDate.text = timeAgo(data.createdAt)
binding.tvComment.text = data.comment
val isOwner = data.memberId == currentUserId
binding.ivMenu.visibility = View.VISIBLE
binding.ivMenu.setOnClickListener {
// 답글의 더보기는 PopupMenu 대신 BottomSheet를 사용하기 위해 Fragment 측 콜백으로 위임
onMoreCallback(data, isOwner)
}
}
}
private fun timeAgo(createdAtMillis: Long): String {
val now = System.currentTimeMillis()
val diff = (now - createdAtMillis).coerceAtLeast(0)
val minutes = diff / 60_000
if (minutes < 1) return "방금전"
if (minutes < 60) return "${minutes}분전"
val hours = minutes / 60
if (hours < 24) return "${hours}시간전"
val days = hours / 24
if (days < 365) return "${days}일전"
val years = days / 365
return "${years}년전"
}

View File

@@ -0,0 +1,226 @@
package kr.co.vividnext.sodalive.chat.character.comment
import android.app.Service
import android.graphics.Rect
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentCharacterCommentReplyBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
/**
* 캐릭터 댓글 답글 페이지
* - 상단: 뒤로가기(텍스트 + ic_back), 닫기(X)
* - 입력 폼, divider, 원본 댓글, 답글 목록(들여쓰기)
* - 스크롤 하단 도달 시 무한 스크롤 로드 (초기 1회 호출 이후 cursor != null 일 때만 추가 로드)
*/
class CharacterCommentReplyFragment : BaseFragment<FragmentCharacterCommentReplyBinding>(
FragmentCharacterCommentReplyBinding::inflate
) {
private val viewModel: CharacterCommentReplyViewModel by inject()
private lateinit var imm: InputMethodManager
private lateinit var loadingDialog: LoadingDialog
private lateinit var adapter: CharacterCommentReplyAdapter
private var original: CharacterCommentResponse? = null
private var characterId: Long = 0
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
characterId = arguments?.getLong(EXTRA_CHARACTER_ID) ?: 0
original = arguments?.let {
val cid = it.getLong(EXTRA_ORIGINAL_COMMENT_ID, -1)
if (cid == -1L) null else CharacterCommentResponse(
commentId = cid,
memberId = it.getLong(EXTRA_ORIGINAL_MEMBER_ID),
memberProfileImage = it.getString(EXTRA_ORIGINAL_MEMBER_PROFILE_IMAGE) ?: "",
memberNickname = it.getString(EXTRA_ORIGINAL_MEMBER_NICKNAME) ?: "",
createdAt = it.getLong(EXTRA_ORIGINAL_CREATED_AT),
replyCount = it.getInt(EXTRA_ORIGINAL_REPLY_COUNT),
comment = it.getString(EXTRA_ORIGINAL_COMMENT_TEXT) ?: ""
)
}
return super.onCreateView(inflater, container, savedInstanceState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (original == null) {
parentFragmentManager.popBackStack()
return
}
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
imm = requireContext().getSystemService(Service.INPUT_METHOD_SERVICE) as InputMethodManager
setupView()
bindData()
viewModel.init(original!!)
viewModel.loadReplies(characterId)
}
private fun setupView() {
binding.tvBack.setOnClickListener { parentFragmentManager.popBackStack() }
binding.ivClose.setOnClickListener { (parentFragment as? CharacterCommentListBottomSheet)?.dismiss() }
binding.ivCommentProfile.load(SharedPreferenceManager.profileImage) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
binding.ivCommentSend.setOnClickListener {
hideKeyboard()
val text = binding.etComment.text.toString()
if (text.isBlank()) return@setOnClickListener
viewModel.createReply(characterId, text)
binding.etComment.setText("")
}
adapter = CharacterCommentReplyAdapter(
currentUserId = SharedPreferenceManager.userId,
onMore = { reply, isOwner ->
CharacterCommentMoreBottomSheet.newInstance(isOwner).apply {
onReport = {
val reportSheet = CharacterCommentReportBottomSheet.newInstance()
reportSheet.onSubmit = { reason ->
viewModel.reportReply(characterId, reply.replyId, reason)
}
reportSheet.show(parentFragmentManager, "reply_report")
}
onDelete = {
AlertDialog.Builder(requireContext())
.setTitle(getString(R.string.confirm_delete_title))
.setMessage(getString(R.string.confirm_delete_message))
.setPositiveButton(getString(R.string.confirm)) { _, _ ->
viewModel.deleteReply(characterId, reply.replyId)
}
.setNegativeButton(getString(R.string.cancel), null)
.show()
}
}.show(childFragmentManager, "reply_more")
}
).apply {
items.clear()
items.add(original!!) // 헤더: 원본 댓글
}
val recyclerView = binding.rvCommentReply
recyclerView.setHasFixedSize(true)
recyclerView.layoutManager =
LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
outRect.left = 24f.dpToPx().toInt()
outRect.right = 24f.dpToPx().toInt()
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.top = 24f.dpToPx().toInt();
outRect.bottom = 12f.dpToPx().toInt()
}
adapter.itemCount - 1 -> {
outRect.top = 12f.dpToPx().toInt()
outRect.bottom = 24f.dpToPx().toInt()
}
else -> {
outRect.top = 12f.dpToPx().toInt()
outRect.bottom = 12f.dpToPx().toInt()
}
}
}
})
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val lm = recyclerView.layoutManager as LinearLayoutManager
val last = lm.findLastCompletelyVisibleItemPosition()
val total = (recyclerView.adapter?.itemCount ?: 1) - 1
if (!recyclerView.canScrollVertically(1) && last == total) {
viewModel.loadReplies(characterId)
}
}
})
recyclerView.adapter = adapter
}
private fun hideKeyboard() {
imm.hideSoftInputFromWindow(view?.windowToken, 0)
}
private fun bindData() {
viewModel.isLoading.observe(viewLifecycleOwner) { loading ->
if (loading) loadingDialog.show(screenWidth) else loadingDialog.dismiss()
}
viewModel.toastLiveData.observe(viewLifecycleOwner) { msg ->
msg?.let { showToast(it) }
}
viewModel.replies.observe(viewLifecycleOwner) { list ->
// 헤더(원본 댓글)는 index 0에 유지, 나머지를 교체
val header = if (adapter.items.isNotEmpty()) adapter.items.first() else original
adapter.items.clear()
header?.let { adapter.items.add(it) }
adapter.items.addAll(list)
adapter.notifyDataSetChanged()
// 스크롤을 하단으로 이동 (신규 추가 시 사용자에게 피드백)
if (adapter.itemCount > 0) {
binding.rvCommentReply.scrollToPosition(adapter.itemCount - 1)
}
}
}
companion object {
private const val EXTRA_CHARACTER_ID = "extra_character_id"
private const val EXTRA_ORIGINAL_COMMENT_ID = "extra_original_comment_id"
private const val EXTRA_ORIGINAL_MEMBER_ID = "extra_original_member_id"
private const val EXTRA_ORIGINAL_MEMBER_PROFILE_IMAGE =
"extra_original_member_profile_image"
private const val EXTRA_ORIGINAL_MEMBER_NICKNAME = "extra_original_member_nickname"
private const val EXTRA_ORIGINAL_CREATED_AT = "extra_original_created_at"
private const val EXTRA_ORIGINAL_REPLY_COUNT = "extra_original_reply_count"
private const val EXTRA_ORIGINAL_COMMENT_TEXT = "extra_original_comment_text"
fun newInstance(
characterId: Long,
original: CharacterCommentResponse
): CharacterCommentReplyFragment {
val args = Bundle().apply {
putLong(EXTRA_CHARACTER_ID, characterId)
putLong(EXTRA_ORIGINAL_COMMENT_ID, original.commentId)
putLong(EXTRA_ORIGINAL_MEMBER_ID, original.memberId)
putString(EXTRA_ORIGINAL_MEMBER_PROFILE_IMAGE, original.memberProfileImage)
putString(EXTRA_ORIGINAL_MEMBER_NICKNAME, original.memberNickname)
putLong(EXTRA_ORIGINAL_CREATED_AT, original.createdAt)
putInt(EXTRA_ORIGINAL_REPLY_COUNT, original.replyCount)
putString(EXTRA_ORIGINAL_COMMENT_TEXT, original.comment)
}
return CharacterCommentReplyFragment().apply { arguments = args }
}
}
}

View File

@@ -0,0 +1,185 @@
package kr.co.vividnext.sodalive.chat.character.comment
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.SharedPreferenceManager
class CharacterCommentReplyViewModel(
private val repository: CharacterCommentRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?> get() = _toastLiveData
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean> get() = _isLoading
private val _original = MutableLiveData<CharacterCommentResponse?>()
val original: LiveData<CharacterCommentResponse?> get() = _original
private val _replies = MutableLiveData<List<CharacterReplyResponse>>(emptyList())
val replies: LiveData<List<CharacterReplyResponse>> get() = _replies
private var cursor: Long? = null
private var page: Int = 1
fun init(original: CharacterCommentResponse) {
_original.value = original
reset()
}
private fun reset() {
cursor = null
page = 1
_replies.value = emptyList()
}
fun loadReplies(characterId: Long) {
val originalId = _original.value?.commentId ?: return
if (_isLoading.value == true) return
val onlyHeader = (_replies.value?.isEmpty() ?: true)
if (!onlyHeader && cursor == null) return
_isLoading.value = true
val token = "Bearer ${SharedPreferenceManager.token}"
compositeDisposable.add(
repository.listReplies(
characterId = characterId,
commentId = originalId,
limit = 20,
cursor = cursor,
token = token
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
_isLoading.value = false
if (resp.success && resp.data != null) {
val newReplies = resp.data.replies
val current = _replies.value ?: emptyList()
_replies.postValue(current + newReplies)
cursor = resp.data.cursor
page += 1
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
_toastLiveData.postValue(message)
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character replies load failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
})
)
}
fun createReply(characterId: Long, comment: String) {
if (comment.isBlank()) {
_toastLiveData.postValue("내용을 입력하세요")
return
}
val originalId = _original.value?.commentId ?: return
if (_isLoading.value == true) return
_isLoading.value = true
val token = "Bearer ${SharedPreferenceManager.token}"
compositeDisposable.add(
repository.createReply(
characterId = characterId,
commentId = originalId,
comment = comment,
token = token
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
_isLoading.value = false
if (resp.success) {
// 낙관적 추가
val me = CharacterReplyResponse(
replyId = System.currentTimeMillis(),
memberId = SharedPreferenceManager.userId,
memberProfileImage = SharedPreferenceManager.profileImage,
memberNickname = SharedPreferenceManager.nickname,
createdAt = System.currentTimeMillis(),
comment = comment
)
val current = _replies.value ?: emptyList()
_replies.postValue(current + listOf(me))
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
_toastLiveData.postValue(message)
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character reply create failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
})
)
}
fun deleteReply(characterId: Long, replyId: Long) {
if (_isLoading.value == true) return
_isLoading.value = true
val token = "Bearer ${SharedPreferenceManager.token}"
compositeDisposable.add(
repository.deleteComment(
characterId = characterId,
commentId = replyId,
token = token
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
_isLoading.value = false
if (resp.success) {
val current = _replies.value ?: emptyList()
_replies.postValue(current.filterNot { it.replyId == replyId })
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
_toastLiveData.postValue(message)
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character reply delete failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
})
)
}
fun reportReply(characterId: Long, replyId: Long, reason: String) {
if (reason.isBlank()) {
_toastLiveData.postValue("신고 사유를 입력하세요")
return
}
if (_isLoading.value == true) return
_isLoading.value = true
val token = "Bearer ${SharedPreferenceManager.token}"
compositeDisposable.add(
repository.reportComment(
characterId = characterId,
commentId = replyId,
reason = reason,
token = token
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ resp ->
_isLoading.value = false
if (resp.success) {
_toastLiveData.postValue("신고가 접수되었습니다.")
} else {
val message = resp.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
_toastLiveData.postValue(message)
}
}, { e ->
_isLoading.value = false
Logger.e(e, "Character reply report failed")
_toastLiveData.postValue("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
})
)
}
}

View File

@@ -0,0 +1,125 @@
package kr.co.vividnext.sodalive.chat.character.comment
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ImageView
import android.widget.RadioButton
import android.widget.RadioGroup
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.core.os.bundleOf
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kr.co.vividnext.sodalive.R
/**
* 댓글/답글 신고 BottomSheet (Stub)
* - 제목: 신고
* - 신고 이유 단일 선택 목록(String List 주입 가능, 미주입 시 기본 목록 사용)
* - 최하단 신고 버튼(선택 전 비활성화, 선택 후 활성화)
* - 신고 버튼 클릭 시 onSubmit(reason) 콜백 호출 후 닫기 (API 스텁 호출은 콜백 쪽에서 처리)
*/
class CharacterCommentReportBottomSheet : BottomSheetDialogFragment() {
var onSubmit: ((String) -> Unit)? = null
private var reasons: ArrayList<String>? = null
private var selectedIndex: Int = -1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
reasons = arguments?.getStringArrayList(ARG_REASONS) ?: DEFAULT_REASONS
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = inflater.inflate(R.layout.dialog_character_comment_report, container, false)
val tvTitle = view.findViewById<TextView>(R.id.tv_title)
val rgList = view.findViewById<RadioGroup>(R.id.rg_reason_list)
val btnReport = view.findViewById<Button>(R.id.btn_report)
val ivClose = view.findViewById<ImageView>(R.id.iv_close)
tvTitle.text = getString(R.string.report_title)
setReportEnabled(btnReport, false)
val items = reasons ?: DEFAULT_REASONS
val textColor = ContextCompat.getColor(requireContext(), R.color.white)
// RadioButton 동적 생성 및 단일 선택 처리
items.forEachIndexed { index, text ->
val rb = RadioButton(requireContext()).apply {
id = View.generateViewId()
tag = index
this.text = text
// 텍스트 색: 흰색
setTextColor(textColor)
// 텍스트 크기: 기존 15sp의 1.3배 -> 19.5sp
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
// 폰트: pretendard_regular
try {
typeface = ResourcesCompat.getFont(context, R.font.pretendard_regular)
} catch (_: Exception) { /* 폰트 미존재 대비 안전 처리 */ }
// 항목 간 간격: 기존 paddingVertical 12dp의 1.3배 -> 15.6dp
val vPadPx = (14f * resources.displayMetrics.density).toInt()
setPadding(paddingLeft, vPadPx, paddingRight, vPadPx)
// 버튼 틴트는 시스템 기본 사용, 필요 시 색상 리소스 적용 가능
}
// 항목 좌우 여백은 유지, 필요 시 LayoutParams로 마진 조정 가능
rgList.addView(rb)
}
rgList.setOnCheckedChangeListener { group, checkedId ->
if (checkedId != -1) {
val selected = group.findViewById<RadioButton>(checkedId)
selectedIndex = (selected.tag as? Int) ?: -1
setReportEnabled(btnReport, true)
} else {
selectedIndex = -1
setReportEnabled(btnReport, false)
}
}
ivClose.setOnClickListener { dismiss() }
btnReport.setOnClickListener {
val idx = selectedIndex
if (idx in items.indices) {
onSubmit?.invoke(items[idx])
dismiss()
}
}
return view
}
private fun setReportEnabled(button: Button, enabled: Boolean) {
button.isEnabled = enabled
button.alpha = if (enabled) 1.0f else 0.4f
}
companion object {
private const val ARG_REASONS = "arg_reasons"
private val DEFAULT_REASONS = arrayListOf(
"원치 않는 상업성 콘텐츠 또는 스팸",
"아동 학대",
"증오시 표현 또는 노골적인 폭력",
"테러 조장",
"희롱 또는 괴롭힘",
"자살 또는 자해",
"잘못된 정보"
)
fun newInstance(reasons: ArrayList<String>? = null): CharacterCommentReportBottomSheet {
return CharacterCommentReportBottomSheet().apply {
arguments = bundleOf(ARG_REASONS to reasons)
}
}
}
}

View File

@@ -0,0 +1,73 @@
package kr.co.vividnext.sodalive.chat.character.comment
class CharacterCommentRepository(private val api: CharacterCommentApi) {
fun createComment(
characterId: Long,
comment: String,
token: String
) = api.createComment(
characterId = characterId,
request = CreateCharacterCommentRequest(comment = comment),
authHeader = token
)
fun createReply(
characterId: Long,
commentId: Long,
comment: String,
token: String
) = api.createReply(
characterId = characterId,
commentId = commentId,
request = CreateCharacterCommentRequest(comment = comment),
authHeader = token
)
fun listComments(
characterId: Long,
limit: Int,
cursor: Long?,
token: String
) = api.listComments(
characterId = characterId,
limit = limit,
cursor = cursor,
authHeader = token
)
fun listReplies(
characterId: Long,
commentId: Long,
limit: Int,
cursor: Long?,
token: String
) = api.listReplies(
characterId = characterId,
commentId = commentId,
limit = limit,
cursor = cursor,
authHeader = token
)
fun deleteComment(
characterId: Long,
commentId: Long,
token: String
) = api.deleteComment(
characterId = characterId,
commentId = commentId,
authHeader = token
)
fun reportComment(
characterId: Long,
commentId: Long,
reason: String,
token: String
) = api.reportComment(
characterId = characterId,
commentId = commentId,
request = ReportCharacterCommentRequest(content = reason),
authHeader = token
)
}

View File

@@ -0,0 +1,77 @@
package kr.co.vividnext.sodalive.chat.character.comment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemCharacterCommentBinding
class CharacterCommentsAdapter(
private val currentUserId: Long,
private val onClickMore: (item: CharacterCommentResponse, isOwner: Boolean, anchor: View) -> Unit,
private val onClickItem: (CharacterCommentResponse) -> Unit
) : RecyclerView.Adapter<CharacterCommentsAdapter.VH>() {
val items = mutableListOf<CharacterCommentResponse>()
inner class VH(private val binding: ItemCharacterCommentBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: CharacterCommentResponse) {
if (item.memberProfileImage.isNotBlank()) {
binding.ivCommentProfile.load(item.memberProfileImage) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
} else {
binding.ivCommentProfile.setImageResource(R.drawable.ic_placeholder_profile)
}
binding.tvCommentNickname.text = item.memberNickname
binding.tvCommentDate.text = timeAgo(item.createdAt)
binding.tvComment.text = item.comment
binding.tvWriteReply.text = if (item.replyCount > 0) {
"답글 ${item.replyCount}"
} else {
"답글 쓰기"
}
val isOwner = item.memberId == currentUserId
binding.ivMenu.visibility = View.VISIBLE
binding.ivMenu.setOnClickListener { onClickMore(item, isOwner, it) }
// 전체영역 터치 시: 답글 보기로 이동(콜백)
binding.root.setOnClickListener { onClickItem(item) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val binding =
ItemCharacterCommentBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
}
private fun timeAgo(createdAtMillis: Long): String {
val now = System.currentTimeMillis()
val diff = (now - createdAtMillis).coerceAtLeast(0)
val minutes = diff / 60_000
if (minutes < 1) return "방금전"
if (minutes < 60) return "${minutes}분전"
val hours = minutes / 60
if (hours < 24) return "${hours}시간전"
val days = hours / 24
if (days < 365) return "${days}일전"
val years = days / 365
return "${years}년전"
}

View File

@@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.chat.character.curation
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.chat.character.Character
@Keep
data class CurationSection(
@SerializedName("characterCurationId") val characterCurationId: Long,
@SerializedName("title") val title: String,
@SerializedName("characters") val characters: List<Character>
)

View File

@@ -0,0 +1,94 @@
package kr.co.vividnext.sodalive.chat.character.curation
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Rect
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.chat.character.CharacterAdapter
import kr.co.vividnext.sodalive.databinding.ItemCurationSectionBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
class CurationSectionAdapter(
private var sections: List<CurationSection> = emptyList(),
private val onCharacterClick: (Long) -> Unit = {}
) : RecyclerView.Adapter<CurationSectionAdapter.ViewHolder>() {
inner class ViewHolder(
private val context: Context,
private val binding: ItemCurationSectionBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(section: CurationSection) {
binding.tvSectionTitle.text = section.title
// 캐릭터 리스트 설정
val characterAdapter = CharacterAdapter(
characters = section.characters,
showRanking = false,
onCharacterClick = onCharacterClick
)
val recyclerView = binding.rvCharacters
recyclerView.layoutManager = LinearLayoutManager(
context,
LinearLayoutManager.HORIZONTAL,
false
)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.left = 0
outRect.right = 8f.dpToPx().toInt()
}
characterAdapter.itemCount - 1 -> {
outRect.left = 8f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 8f.dpToPx().toInt()
outRect.right = 8f.dpToPx().toInt()
}
}
}
})
recyclerView.adapter = characterAdapter
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
parent.context,
ItemCurationSectionBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(sections[position])
}
override fun getItemCount(): Int = sections.size
@SuppressLint("NotifyDataSetChanged")
fun updateSections(newSections: List<CurationSection>) {
sections = newSections
notifyDataSetChanged()
}
}

View File

@@ -0,0 +1,97 @@
package kr.co.vividnext.sodalive.chat.character.detail
import android.os.Bundle
import androidx.fragment.app.Fragment
import com.google.android.material.tabs.TabLayout
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterDetailFragment
import kr.co.vividnext.sodalive.chat.character.detail.gallery.CharacterGalleryFragment
import kr.co.vividnext.sodalive.databinding.ActivityCharacterDetailBinding
class CharacterDetailActivity : BaseActivity<ActivityCharacterDetailBinding>(
ActivityCharacterDetailBinding::inflate
) {
override fun onCreate(savedInstanceState: Bundle?) {
val characterId = intent.getLongExtra(EXTRA_CHARACTER_ID, 0)
if (characterId <= 0) {
showToast("잘못된 접근 입니다.")
finish()
return
}
super.onCreate(savedInstanceState)
}
override fun setupView() {
// 뒤로 가기
binding.detailToolbar.tvBack.setOnClickListener { finish() }
binding.detailToolbar.tvBack.text = "캐릭터 정보"
// 탭 구성: 상세, 갤러리
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("상세"))
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("갤러리"))
val characterId = intent.getLongExtra(EXTRA_CHARACTER_ID, 0)
// 기존 프래그먼트 복원/재사용
var detail = supportFragmentManager.findFragmentByTag(TAG_DETAIL)
var gallery = supportFragmentManager.findFragmentByTag(TAG_GALLERY)
val transaction = supportFragmentManager.beginTransaction()
if (detail == null) {
detail = CharacterDetailFragment.newInstance(characterId)
transaction.add(R.id.fl_container, detail, TAG_DETAIL)
}
if (gallery == null) {
gallery = CharacterGalleryFragment()
transaction.add(R.id.fl_container, gallery, TAG_GALLERY)
transaction.hide(gallery)
}
transaction.show(detail).commit()
binding.tabLayout
.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
showTab(tab.position)
}
override fun onTabUnselected(tab: TabLayout.Tab) {}
override fun onTabReselected(tab: TabLayout.Tab) {}
})
}
private fun showTab(position: Int) {
val detail = supportFragmentManager.findFragmentByTag(TAG_DETAIL)
val gallery = supportFragmentManager.findFragmentByTag(TAG_GALLERY)
val transaction = supportFragmentManager.beginTransaction()
fun Fragment?.hideIfExists() {
if (this != null && !this.isHidden) transaction.hide(this)
}
// 모두 숨김
detail.hideIfExists()
gallery.hideIfExists()
// 포지션에 맞게 표시
val toShow: Fragment? = when (position) {
0 -> detail
else -> gallery
}
if (toShow != null) transaction.show(toShow)
transaction.commit()
}
fun setTitle(title: String) {
binding.detailToolbar.tvBack.text = title
}
companion object {
const val EXTRA_CHARACTER_ID = "extra_character_id"
private const val TAG_DETAIL = "tag_character_detail"
private const val TAG_GALLERY = "tag_character_gallery"
}
}

View File

@@ -0,0 +1,432 @@
package kr.co.vividnext.sodalive.chat.character.detail.detail
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Rect
import android.os.Bundle
import android.text.TextUtils
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.CircleCropTransformation
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.BaseFragment
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentListBottomSheet
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity.Companion.EXTRA_CHARACTER_ID
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentCharacterDetailBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
/**
* 캐릭터 상세 - 상세 탭
*/
class CharacterDetailFragment : BaseFragment<FragmentCharacterDetailBinding>(
FragmentCharacterDetailBinding::inflate
) {
companion object {
private const val ARG_CHARACTER_ID = "arg_character_id"
fun newInstance(characterId: Long): CharacterDetailFragment =
CharacterDetailFragment().apply {
arguments = Bundle().apply { putLong(ARG_CHARACTER_ID, characterId) }
}
}
private val viewModel: CharacterDetailViewModel by viewModel()
private val commentRepository: CharacterCommentRepository by inject()
private lateinit var loadingDialog: LoadingDialog
private val characterId: Long by lazy {
arguments?.getLong(ARG_CHARACTER_ID)
?: requireActivity().intent.getLongExtra(EXTRA_CHARACTER_ID, 0L)
}
private val adapter by lazy {
OtherCharacterAdapter(
onItemClick = { item ->
startActivity(
Intent(
requireActivity(),
CharacterDetailActivity::class.java
).apply {
putExtra(EXTRA_CHARACTER_ID, item.characterId)
}
)
}
)
}
private var isWorldviewExpanded = false
private var isPersonalityExpanded = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupView()
bindObservers()
viewModel.load(characterId)
}
@SuppressLint("SetTextI18n")
private fun bindObservers() {
viewModel.uiState.observe(viewLifecycleOwner) { state ->
// 1) 로딩 상태 처리
if (state.isLoading) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
// 2) 에러 토스트 처리
state.error?.let { errorMsg ->
if (errorMsg.isNotBlank()) {
showToast(errorMsg)
}
}
// 2-1) 채팅방 생성 성공 처리 (이벤트)
state.chatRoomId?.let { roomId ->
startActivity(
ChatRoomActivity.newIntent(
requireActivity(),
roomId
)
)
viewModel.consumeChatRoomCreated()
}
// 3) 상세 데이터가 있을 경우에만 기존 UI 바인딩 수행
val detail = state.detail ?: return@observe
// 배경 이미지
binding.ivCharacterBackground.load(detail.imageUrl) { crossfade(true) }
// 기본 정보
if (detail.gender != null) {
binding.tvGender.visibility = View.VISIBLE
binding.tvGender.text = detail.gender
if (detail.gender == "남성") {
binding.tvGender.setTextColor(
ContextCompat.getColor(
requireContext(),
R.color.color_3bb9f1
)
)
binding.tvGender.setBackgroundResource(R.drawable.bg_character_gender_male)
} else {
binding.tvGender.setTextColor(
ContextCompat.getColor(
requireContext(),
R.color.color_ff5c49
)
)
binding.tvGender.setBackgroundResource(R.drawable.bg_character_gender_female)
}
} else {
binding.tvGender.visibility = View.GONE
}
if (detail.age != null) {
binding.tvAge.visibility = View.VISIBLE
binding.tvAge.text = "${detail.age}"
} else {
binding.tvAge.visibility = View.GONE
}
if (detail.mbti != null) {
binding.tvMbti.visibility = View.VISIBLE
binding.tvMbti.text = detail.mbti
} else {
binding.tvMbti.visibility = View.GONE
}
binding.llGenderAgeMbti.visibility = if (
detail.mbti == null &&
detail.age == null &&
detail.gender == null
) {
View.GONE
} else {
View.VISIBLE
}
binding.tvCharacterName.text = detail.name
binding.tvCharacterStatus.text = when (detail.characterType) {
CharacterType.CLONE -> "Clone"
CharacterType.CHARACTER -> "Character"
}
// 캐릭터 타입에 따른 배경 설정
binding.tvCharacterStatus.setBackgroundResource(
when (detail.characterType) {
CharacterType.CLONE -> R.drawable.bg_character_status_clone
CharacterType.CHARACTER -> R.drawable.bg_character_status_character
}
)
binding.tvCharacterDescription.text = detail.description
binding.tvCharacterTags.text = detail.tags
// 세계관 내용과 버튼 가시성 초기화
val worldviewText = detail.backgrounds?.description.orEmpty()
binding.tvWorldviewContent.text = worldviewText
// 먼저 전체 줄 수를 측정한 뒤 접힘 레이아웃 적용
binding.tvWorldviewContent.post {
val totalLines = binding.tvWorldviewContent.layout?.lineCount
?: binding.tvWorldviewContent.lineCount
val needExpand = totalLines > 3
binding.llWorldviewExpand.visibility = if (needExpand) View.VISIBLE else View.GONE
// 표시 상태는 항상 접힘 상태로 시작
applyWorldviewCollapsedLayout()
isWorldviewExpanded = false
binding.tvWorldviewExpand.text = "더보기"
binding.ivWorldviewExpand.setImageResource(R.drawable.ic_chevron_down)
}
// 성격 내용과 버튼 가시성 초기화
val personalityText = detail.personalities?.description.orEmpty()
binding.tvPersonalityContent.text = personalityText
// 먼저 전체 줄 수를 측정한 뒤 접힘 레이아웃 적용
binding.tvPersonalityContent.post {
val totalLines = binding.tvPersonalityContent.layout?.lineCount
?: binding.tvPersonalityContent.lineCount
val needExpand = totalLines > 3
binding.llPersonalityExpand.visibility = if (needExpand) View.VISIBLE else View.GONE
applyPersonalityCollapsedLayout()
isPersonalityExpanded = false
binding.tvPersonalityExpand.text = "더보기"
binding.ivPersonalityExpand.setImageResource(R.drawable.ic_chevron_down)
}
// 원작 섹션 표시/숨김
if (detail.originalTitle.isNullOrBlank() || detail.originalLink.isNullOrBlank()) {
binding.llOriginalSection.visibility = View.GONE
} else {
binding.llOriginalSection.visibility = View.VISIBLE
binding.tvOriginalContent.text = detail.originalTitle
binding.tvOriginalLink.setOnClickListener {
runCatching {
startActivity(Intent(Intent.ACTION_VIEW, detail.originalLink.toUri()))
}
}
}
// 다른 캐릭터 리스트
if (detail.others.isEmpty()) {
binding.llOtherCharactersSection.visibility = View.GONE
} else {
binding.llOtherCharactersSection.visibility = View.VISIBLE
adapter.submitList(detail.others)
}
// 댓글 섹션 바인딩
binding.tvCommentsCount.text = "${detail.totalComments}"
// 댓글 섹션 터치 시 리스트 BottomSheet 열기 (댓글 1개 이상일 때)
binding.llCommentsSection.setOnClickListener(null)
if (detail.totalComments > 0) {
binding.llCommentsSection.setOnClickListener {
val sheet = CharacterCommentListBottomSheet(detail.characterId)
sheet.show(requireActivity().supportFragmentManager, "character_comments")
}
}
if (
detail.totalComments > 0 &&
detail.latestComment != null &&
detail.latestComment.comment.isNotBlank()
) {
binding.llLatestComment.visibility = View.VISIBLE
binding.llNoComment.visibility = View.GONE
val latest = detail.latestComment
val profileUrl = latest.memberProfileImage
if (profileUrl.isNotBlank()) {
binding.ivCommentProfile.load(profileUrl) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
} else {
binding.ivMyProfile.load(R.drawable.ic_placeholder_profile) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
}
binding.tvLatestComment.text = latest.comment.ifBlank {
latest.memberNickname
}
} else {
binding.llLatestComment.visibility = View.GONE
binding.llNoComment.visibility = View.VISIBLE
// 내 프로필 이미지는 SharedPreference의 profileImage 사용 (fallback: placeholder)
val myProfileUrl = SharedPreferenceManager.profileImage
if (myProfileUrl.isNotBlank()) {
binding.ivMyProfile.load(myProfileUrl) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
} else {
binding.ivMyProfile.load(R.drawable.ic_placeholder_profile) {
crossfade(true)
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
}
binding.ivSendComment.setOnClickListener {
val text = binding.etCommentInput.text?.toString()?.trim().orEmpty()
if (text.isBlank()) return@setOnClickListener
val idFromState = viewModel.uiState.value?.detail?.characterId ?: 0L
val targetCharacterId = if (idFromState > 0) idFromState else characterId
if (targetCharacterId <= 0) {
showToast("잘못된 접근 입니다.")
return@setOnClickListener
}
val token = "Bearer ${SharedPreferenceManager.token}"
loadingDialog.show(screenWidth)
val d = commentRepository.createComment(targetCharacterId, text, token)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doFinally { loadingDialog.dismiss() }
.subscribe({ resp ->
if (resp.success) {
binding.etCommentInput.setText("")
showToast("등록되었습니다.")
viewModel.load(targetCharacterId)
} else {
showToast(resp.message ?: "요청 중 오류가 발생했습니다")
}
}, { e ->
showToast(e.message ?: "요청 중 오류가 발생했습니다")
})
compositeDisposable.add(d)
}
}
}
}
private fun setupView() {
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
// 다른 캐릭터 리스트: 가로 스크롤
val recyclerView = binding.rvOtherCharacters
recyclerView.layoutManager = LinearLayoutManager(
requireContext(),
LinearLayoutManager.HORIZONTAL,
false
)
recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
when (parent.getChildAdapterPosition(view)) {
0 -> {
outRect.left = 0
outRect.right = 8f.dpToPx().toInt()
}
adapter.itemCount - 1 -> {
outRect.left = 8f.dpToPx().toInt()
outRect.right = 0
}
else -> {
outRect.left = 8f.dpToPx().toInt()
outRect.right = 8f.dpToPx().toInt()
}
}
}
})
recyclerView.adapter = adapter
// 세계관 전체보기 토글 클릭 리스너
binding.llWorldviewExpand.setOnClickListener {
toggleWorldviewExpand()
}
// 성격 전체보기 토글 클릭 리스너
binding.llPersonalityExpand.setOnClickListener {
togglePersonalityExpand()
}
// 대화하기 버튼 클릭: 채팅방 생성 API 호출
binding.btnChat.setOnClickListener {
val idFromState = viewModel.uiState.value?.detail?.characterId ?: 0L
val targetId = if (idFromState > 0) idFromState else characterId
if (targetId > 0) {
viewModel.createChatRoom(targetId)
} else {
showToast("잘못된 접근 입니다.")
}
}
}
private fun toggleWorldviewExpand() {
isWorldviewExpanded = !isWorldviewExpanded
if (isWorldviewExpanded) {
// 확장 상태
binding.tvWorldviewContent.maxLines = Integer.MAX_VALUE
binding.tvWorldviewContent.ellipsize = null
binding.tvWorldviewExpand.text = "간략히"
binding.ivWorldviewExpand.setImageResource(R.drawable.ic_chevron_up)
} else {
// 접힘 상태 (3줄)
applyWorldviewCollapsedLayout()
binding.tvWorldviewExpand.text = "더보기"
binding.ivWorldviewExpand.setImageResource(R.drawable.ic_chevron_down)
}
}
private fun applyWorldviewCollapsedLayout() {
binding.tvWorldviewContent.maxLines = 3
binding.tvWorldviewContent.ellipsize = TextUtils.TruncateAt.END
}
private fun togglePersonalityExpand() {
isPersonalityExpanded = !isPersonalityExpanded
if (isPersonalityExpanded) {
binding.tvPersonalityContent.maxLines = Integer.MAX_VALUE
binding.tvPersonalityContent.ellipsize = null
binding.tvPersonalityExpand.text = "간략히"
binding.ivPersonalityExpand.setImageResource(R.drawable.ic_chevron_up)
} else {
applyPersonalityCollapsedLayout()
binding.tvPersonalityExpand.text = "더보기"
binding.ivPersonalityExpand.setImageResource(R.drawable.ic_chevron_down)
}
}
private fun applyPersonalityCollapsedLayout() {
binding.tvPersonalityContent.maxLines = 3
binding.tvPersonalityContent.ellipsize = TextUtils.TruncateAt.END
}
}

View File

@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.chat.character.detail.detail
import kr.co.vividnext.sodalive.chat.character.CharacterApi
import kr.co.vividnext.sodalive.chat.talk.TalkApi
import kr.co.vividnext.sodalive.chat.talk.room.CreateChatRoomRequest
class CharacterDetailRepository(
private val characterApi: CharacterApi,
private val talkApi: TalkApi
) {
fun getCharacterDetail(token: String, characterId: Long) =
characterApi.getCharacterDetail(authHeader = token, characterId = characterId)
fun createChatRoom(token: String, request: CreateChatRoomRequest) =
talkApi.createChatRoom(authHeader = token, request = request)
}

View File

@@ -0,0 +1,53 @@
package kr.co.vividnext.sodalive.chat.character.detail.detail
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse
@Keep
data class CharacterDetailResponse(
@SerializedName("characterId") val characterId: Long,
@SerializedName("name") val name: String,
@SerializedName("description") val description: String,
@SerializedName("mbti") val mbti: String?,
@SerializedName("gender") val gender: String?,
@SerializedName("age") val age: Int?,
@SerializedName("imageUrl") val imageUrl: String,
@SerializedName("personalities") val personalities: CharacterPersonalityResponse?,
@SerializedName("backgrounds") val backgrounds: CharacterBackgroundResponse?,
@SerializedName("tags") val tags: String,
@SerializedName("originalTitle") val originalTitle: String?,
@SerializedName("originalLink") val originalLink: String?,
@SerializedName("characterType") val characterType: CharacterType,
@SerializedName("others") val others: List<OtherCharacter>,
@SerializedName("latestComment") val latestComment: CharacterCommentResponse?,
@SerializedName("totalComments") val totalComments: Int
)
@Keep
enum class CharacterType {
@SerializedName("Clone")
CLONE,
@SerializedName("Character")
CHARACTER
}
@Keep
data class OtherCharacter(
@SerializedName("characterId") val characterId: Long,
@SerializedName("name") val name: String,
@SerializedName("imageUrl") val imageUrl: String,
@SerializedName("tags") val tags: String
)
@Keep
data class CharacterPersonalityResponse(
@SerializedName("trait") val trait: String,
@SerializedName("description") val description: String
)
@Keep
data class CharacterBackgroundResponse(
@SerializedName("topic") val topic: String,
@SerializedName("description") val description: String
)

View File

@@ -0,0 +1,110 @@
package kr.co.vividnext.sodalive.chat.character.detail.detail
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.chat.talk.room.CreateChatRoomRequest
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
/**
* 캐릭터 상세 화면에서 사용하는 ViewModel.
* - 캐릭터 명과 상태
* - 캐릭터 소개
* - 태그 문자열 (예: "#태그1 #태그2")
* - 세계관 내용 (3줄 이상일 경우 전체보기 토글)
* - 원작 섹션 (빈 값이면 UI에서 숨김)
* - 다른 캐릭터 목록 (이미지, 캐릭터 명, 태그)
*/
class CharacterDetailViewModel(
private val repository: CharacterDetailRepository
) : BaseViewModel() {
data class UiState(
val detail: CharacterDetailResponse? = null,
val isLoading: Boolean = false,
val error: String? = null,
val chatRoomId: Long? = null
)
private val _uiState = MutableLiveData(UiState())
val uiState: LiveData<UiState> get() = _uiState
fun load(characterId: Long) {
_uiState.value = _uiState.value?.copy(isLoading = true, error = null)
val token = "Bearer ${SharedPreferenceManager.token}"
compositeDisposable.add(
repository.getCharacterDetail(token = token, characterId = characterId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
val success = response.success
val data = response.data
if (success && data != null) {
_uiState.value = UiState(detail = data, isLoading = false, error = null)
} else {
_uiState.value = UiState(
detail = null,
isLoading = false,
error = response.message ?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
},
{ throwable ->
Logger.e(throwable, throwable.message ?: "")
_uiState.value = UiState(
detail = null,
isLoading = false,
error = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
)
}
)
)
}
fun createChatRoom(characterId: Long) {
// 기존 상태 유지하면서 로딩/에러/이벤트만 변경
val current = _uiState.value
_uiState.value = current?.copy(isLoading = true, error = null, chatRoomId = null)
val token = "Bearer ${SharedPreferenceManager.token}"
val request = CreateChatRoomRequest(characterId)
compositeDisposable.add(
repository.createChatRoom(token = token, request = request)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
val success = response.success
val data = response.data
if (success && data != null) {
_uiState.value = _uiState.value?.copy(
isLoading = false,
chatRoomId = data.chatRoomId
)
} else {
_uiState.value = _uiState.value?.copy(
isLoading = false,
error = response.message ?: "채팅방 생성에 실패했습니다. 다시 시도해 주세요."
)
}
},
{ throwable ->
Logger.e(throwable, throwable.message ?: "")
_uiState.value = _uiState.value?.copy(
isLoading = false,
error = "채팅방 생성 중 오류가 발생했습니다. 다시 시도해 주세요."
)
}
)
)
}
fun consumeChatRoomCreated() {
_uiState.value = _uiState.value?.copy(chatRoomId = null)
}
}

View File

@@ -0,0 +1,58 @@
package kr.co.vividnext.sodalive.chat.character.detail.detail
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.RoundedCornersTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemOtherCharacterBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
class OtherCharacterAdapter(
private var items: List<OtherCharacter> = emptyList(),
private val onItemClick: ((OtherCharacter) -> Unit)? = null
) : RecyclerView.Adapter<OtherCharacterAdapter.OtherCharacterViewHolder>() {
@SuppressLint("NotifyDataSetChanged")
fun submitList(newItems: List<OtherCharacter>) {
items = newItems
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): OtherCharacterViewHolder {
return OtherCharacterViewHolder(
ItemOtherCharacterBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: OtherCharacterViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
inner class OtherCharacterViewHolder(
private val binding: ItemOtherCharacterBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: OtherCharacter) {
binding.tvName.text = item.name
binding.tvTags.text = item.tags
binding.ivThumb.load(item.imageUrl) {
crossfade(true)
placeholder(R.drawable.ic_place_holder)
transformations(RoundedCornersTransformation(16f.dpToPx()))
}
binding.root.setOnClickListener {
onItemClick?.invoke(item)
}
}
}
}

View File

@@ -0,0 +1,62 @@
package kr.co.vividnext.sodalive.chat.character.detail.gallery
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import kr.co.vividnext.sodalive.databinding.ItemCharacterGalleryBinding
class CharacterGalleryAdapter(
private var items: List<CharacterImageListItemResponse> = emptyList(),
private val onClickBuy: (item: CharacterImageListItemResponse, position: Int) -> Unit = { _, _ -> },
private val onClickOwned: (item: CharacterImageListItemResponse, position: Int) -> Unit = { _, _ -> }
) : RecyclerView.Adapter<CharacterGalleryAdapter.ViewHolder>() {
inner class ViewHolder(
private val binding: ItemCharacterGalleryBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CharacterImageListItemResponse) {
Glide.with(binding.ivImage)
.load(item.imageUrl)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.into(binding.ivImage)
if (item.isOwned) {
binding.llLock.visibility = View.GONE
binding.btnBuy.setOnClickListener(null)
binding.root.setOnClickListener {
onClickOwned(item, bindingAdapterPosition)
}
} else {
binding.llLock.visibility = View.VISIBLE
binding.tvPrice.text = item.imagePriceCan.toString()
binding.btnBuy.setOnClickListener {
onClickBuy(item, bindingAdapterPosition)
}
// 잠금 상태에서는 아이템 클릭 시 아무 동작 없음 (구매 버튼만 활성)
binding.root.setOnClickListener(null)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding =
ItemCharacterGalleryBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
@SuppressLint("NotifyDataSetChanged")
fun submitItems(newItems: List<CharacterImageListItemResponse>) {
items = newItems
notifyDataSetChanged()
}
}

View File

@@ -0,0 +1,161 @@
package kr.co.vividnext.sodalive.chat.character.detail.gallery
import android.annotation.SuppressLint
import android.os.Bundle
import android.text.style.ForegroundColorSpan
import android.view.View
import androidx.core.graphics.toColorInt
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity.Companion.EXTRA_CHARACTER_ID
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.FragmentCharacterGalleryBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.androidx.viewmodel.ext.android.viewModel
/**
* 캐릭터 상세 - 갤러리 탭
*/
class CharacterGalleryFragment : BaseFragment<FragmentCharacterGalleryBinding>(
FragmentCharacterGalleryBinding::inflate
) {
private val viewModel: CharacterGalleryViewModel by viewModel()
private lateinit var adapter: CharacterGalleryAdapter
private lateinit var loadingDialog: LoadingDialog
private var latestItems: List<CharacterImageListItemResponse> = emptyList()
private val characterId: Long by lazy {
arguments?.getLong("arg_character_id")
?: requireActivity().intent.getLongExtra(EXTRA_CHARACTER_ID, 0L)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
setupRecyclerView()
observeState()
viewModel.loadInitial(characterId)
}
private fun setupRecyclerView() {
val layoutManager = GridLayoutManager(requireContext(), 3)
binding.rvGallery.layoutManager = layoutManager
if (binding.rvGallery.itemDecorationCount == 0) {
binding.rvGallery.addItemDecoration(
GridSpacingItemDecoration(3, 2f.dpToPx().toInt(), true)
)
}
adapter = CharacterGalleryAdapter(
onClickBuy = { item, position ->
showPurchaseDialog(item, position)
},
onClickOwned = { item, position ->
// 구매된 항목만 전체화면 뷰어로 진입
val ownedItems = latestItems.filter { it.isOwned }
if (ownedItems.isEmpty()) return@CharacterGalleryAdapter
val startIndex = ownedItems.indexOfFirst {
it.id == item.id
}.coerceAtLeast(0)
val urls = ownedItems.map { it.imageUrl }
val dialog = CharacterGalleryViewerDialogFragment.newInstance(urls, startIndex)
if (!dialog.isAdded) {
dialog.show(parentFragmentManager, "CharacterGalleryViewerDialog")
}
}
)
binding.rvGallery.adapter = adapter
binding.rvGallery.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (dy <= 0) return
val totalItemCount = layoutManager.itemCount
val lastVisible = layoutManager.findLastVisibleItemPosition()
if (lastVisible >= totalItemCount - 6) {
viewModel.loadNext()
}
}
})
}
@SuppressLint("SetTextI18n")
private fun observeState() {
viewModel.uiState.observe(viewLifecycleOwner) { state ->
binding.tvEmptyGallery.visibility = if (state.items.isEmpty() && !state.isLoading) {
View.VISIBLE
} else {
View.GONE
}
// 로딩 다이얼로그 표시/해제
if (state.isLoading) {
loadingDialog.show(screenWidth)
} else {
hideLoadingDialog()
}
if (state.items.isNotEmpty() && !state.isLoading) {
binding.rvGallery.visibility = View.VISIBLE
binding.clRatio.visibility = View.VISIBLE
val percent = (state.ratio * 100).toInt()
binding.tvRatioLeft.text = "$percent% 보유중"
val ownedStr = state.ownedCount.toString()
val totalStr = state.totalCount.toString()
val fullText = "$ownedStr / ${totalStr}"
val spannable = android.text.SpannableString(fullText)
val ownedColor = "#FDD453".toColorInt()
spannable.setSpan(
ForegroundColorSpan(ownedColor),
/* start */ 0,
/* end */ ownedStr.length,
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
// 나머지는 TextView의 기본 색상(white)을 사용
binding.tvRatioRight.text = spannable
// 슬라이더(ProgressBar) 값 설정: 0~100
binding.progressRatio.progress = percent
latestItems = state.items
adapter.submitItems(state.items)
} else {
binding.rvGallery.visibility = View.VISIBLE
binding.clRatio.visibility = View.GONE
}
state.error?.let { showToast(it) }
}
}
private fun showPurchaseDialog(item: CharacterImageListItemResponse, position: Int) {
SodaDialog(
activity = requireActivity(),
layoutInflater = this.layoutInflater,
title = "구매 확인",
desc = "선택한 이미지를 구매하시겠습니까?",
confirmButtonTitle = "${item.imagePriceCan}캔으로 구매",
confirmButtonClick = {
viewModel.purchaseImage(item.id, position)
},
cancelButtonTitle = "취소"
).show(screenWidth)
}
private fun hideLoadingDialog() {
loadingDialog.dismiss()
}
override fun onDestroyView() {
hideLoadingDialog()
super.onDestroyView()
}
}

View File

@@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.chat.character.detail.gallery
import kr.co.vividnext.sodalive.chat.character.CharacterApi
class CharacterGalleryRepository(
private val characterApi: CharacterApi
) {
fun getCharacterImageList(token: String, characterId: Long, page: Int, size: Int) =
characterApi.getCharacterImageList(authHeader = token, characterId = characterId, page = page, size = size)
fun getMyCharacterImageList(token: String, characterId: Long, page: Int, size: Int) =
characterApi.getMyCharacterImageList(authHeader = token, characterId = characterId, page = page, size = size)
fun purchaseCharacterImage(token: String, imageId: Long) =
characterApi.purchaseCharacterImage(
authHeader = token,
request = CharacterImagePurchaseRequest(imageId = imageId)
)
}

View File

@@ -0,0 +1,171 @@
package kr.co.vividnext.sodalive.chat.character.detail.gallery
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.SharedPreferenceManager
class CharacterGalleryViewModel(
private val repository: CharacterGalleryRepository
) : BaseViewModel() {
data class UiState(
val totalCount: Long = 0L,
val ownedCount: Long = 0L,
val ratio: Float = 0f, // 0.0 ~ 1.0
val items: List<CharacterImageListItemResponse> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
private val _uiState = MutableLiveData(UiState())
val uiState: LiveData<UiState> get() = _uiState
private var characterId: Long = 0L
private var currentPage: Int = 0
private val pageSize: Int = 20
private var isLastPage: Boolean = false
private var isRequesting: Boolean = false
private val accumulatedItems = mutableListOf<CharacterImageListItemResponse>()
private var isPurchasing: Boolean = false
fun loadInitial(characterId: Long) {
// 상태 초기화
this.characterId = characterId
currentPage = 0
isLastPage = false
isRequesting = false
accumulatedItems.clear()
request(page = currentPage)
}
fun loadNext() {
if (isRequesting || isLastPage) return
request(page = currentPage + 1)
}
private fun request(page: Int) {
val token = "Bearer ${SharedPreferenceManager.token}"
isRequesting = true
_uiState.value = _uiState.value?.copy(isLoading = isRequesting || isPurchasing)
compositeDisposable.add(
repository.getCharacterImageList(
token = token,
characterId = characterId,
page = page,
size = pageSize
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ response ->
val success = response.success
val data = response.data
isRequesting = false
if (success && data != null) {
// 누적 처리
val newItems = data.items
if (page == 0) accumulatedItems.clear()
accumulatedItems.addAll(newItems)
val total = data.totalCount
val owned = data.ownedCount
val ratio = if (total > 0) owned.toFloat() / total.toFloat() else 0f
// 마지막 페이지 판단: 새 항목이 pageSize보다 적거나, 누적 >= total
isLastPage =
newItems.size < pageSize || accumulatedItems.size.toLong() >= total
currentPage = page
_uiState.value = UiState(
totalCount = total,
ownedCount = owned,
ratio = ratio,
items = accumulatedItems.toList(),
isLoading = false,
error = null
)
} else {
_uiState.value = _uiState.value?.copy(
isLoading = isRequesting || isPurchasing,
error = response.message ?: "갤러리 정보를 불러오지 못했습니다."
)
}
}, { throwable ->
isRequesting = false
Logger.e(throwable, throwable.message ?: "")
_uiState.value = _uiState.value?.copy(
isLoading = isRequesting || isPurchasing,
error = "네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
)
})
)
}
fun purchaseImage(imageId: Long, position: Int) {
if (isPurchasing) return
if (position < 0 || position >= accumulatedItems.size) return
val target = accumulatedItems[position]
if (target.isOwned) return
val token = "Bearer ${SharedPreferenceManager.token}"
isPurchasing = true
_uiState.value = _uiState.value?.copy(
isLoading = isRequesting || isPurchasing,
)
compositeDisposable.add(
repository.purchaseCharacterImage(token = token, imageId = imageId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
val success = response.success
val data = response.data
isPurchasing = false
if (success && data != null) {
// 응답 imageUrl로 교체, 소유 상태 true로 변경
val updated = target.copy(
imageUrl = data.imageUrl,
isOwned = true
)
accumulatedItems[position] = updated
val total = _uiState.value?.totalCount ?: accumulatedItems.size.toLong()
val ownedBefore = _uiState.value?.ownedCount
?: accumulatedItems.count { it.isOwned }.toLong()
val ownedAfter = ownedBefore + 1
val ratio = if (total > 0) {
ownedAfter.toFloat() / total.toFloat()
} else {
0f
}
_uiState.value = _uiState.value?.copy(
ownedCount = ownedAfter,
ratio = ratio,
items = accumulatedItems.toList(),
isLoading = isRequesting || isPurchasing,
error = null
)
} else {
_uiState.value = _uiState.value?.copy(
error = response.message ?: "구매에 실패했습니다."
)
}
},
{ throwable ->
isPurchasing = false
Logger.e(throwable, throwable.message ?: "")
_uiState.value = _uiState.value?.copy(
isLoading = isRequesting || isPurchasing,
error = "네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
)
}
)
)
}
}

View File

@@ -0,0 +1,89 @@
package kr.co.vividnext.sodalive.chat.character.detail.gallery
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import kr.co.vividnext.sodalive.databinding.FragmentCharacterGalleryViewerBinding
import kr.co.vividnext.sodalive.databinding.ItemFullscreenImageBinding
class CharacterGalleryViewerDialogFragment : DialogFragment() {
private var _binding: FragmentCharacterGalleryViewerBinding? = null
private val binding get() = _binding!!
private val imageUrls: ArrayList<String> by lazy {
arguments?.getStringArrayList(ARG_URLS) ?: arrayListOf()
}
private val startIndex: Int by lazy {
arguments?.getInt(ARG_START_INDEX) ?: 0
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentCharacterGalleryViewerBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.viewPager.adapter = ImagePagerAdapter(imageUrls)
if (startIndex in imageUrls.indices) {
binding.viewPager.setCurrentItem(startIndex, false)
}
binding.btnClose.setOnClickListener { dismissAllowingStateLoss() }
}
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
class ImagePagerAdapter(private val urls: List<String>) : RecyclerView.Adapter<ImageViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder {
val binding = ItemFullscreenImageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ImageViewHolder(binding)
}
override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
holder.bind(urls[position])
}
override fun getItemCount(): Int = urls.size
}
class ImageViewHolder(private val binding: ItemFullscreenImageBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(url: String) {
Glide.with(binding.ivFull)
.load(url)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.into(binding.ivFull)
}
}
companion object {
private const val ARG_URLS = "arg_urls"
private const val ARG_START_INDEX = "arg_start_index"
fun newInstance(urls: List<String>, startIndex: Int): CharacterGalleryViewerDialogFragment {
val fragment = CharacterGalleryViewerDialogFragment()
fragment.arguments = Bundle().apply {
putStringArrayList(ARG_URLS, ArrayList(urls))
putInt(ARG_START_INDEX, startIndex)
}
return fragment
}
}
}

View File

@@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.chat.character.detail.gallery
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class CharacterImageListItemResponse(
@SerializedName("id") val id: Long,
@SerializedName("imageUrl") val imageUrl: String,
@SerializedName("isOwned") val isOwned: Boolean,
@SerializedName("imagePriceCan") val imagePriceCan: Long
)
@Keep
data class CharacterImageListResponse(
@SerializedName("totalCount") val totalCount: Long,
@SerializedName("ownedCount") val ownedCount: Long,
@SerializedName("items") val items: List<CharacterImageListItemResponse>
)

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.chat.character.detail.gallery
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class CharacterImagePurchaseRequest(
@SerializedName("imageId") val imageId: Long,
@SerializedName("container") val container: String = "aos"
)

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.chat.character.detail.gallery
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class CharacterImagePurchaseResponse(
@SerializedName("imageUrl") val imageUrl: String
)

View File

@@ -0,0 +1,92 @@
package kr.co.vividnext.sodalive.chat.character.newcharacters
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity.Companion.EXTRA_CHARACTER_ID
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityNewCharactersAllBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
class NewCharactersAllActivity : BaseActivity<ActivityNewCharactersAllBinding>(
ActivityNewCharactersAllBinding::inflate
) {
private val viewModel: NewCharactersAllViewModel by inject()
private lateinit var adapter: NewCharactersAllAdapter
private lateinit var loadingDialog: LoadingDialog
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setupView()
bindData()
viewModel.loadMore()
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
binding.toolbar.tvBack.text = "신규 캐릭터 전체보기"
binding.toolbar.tvBack.setOnClickListener { finish() }
val spanCount = 2
val spacingPx = 8f.dpToPx().toInt()
adapter = NewCharactersAllAdapter { characterId ->
startActivity(
Intent(this, CharacterDetailActivity::class.java).apply {
putExtra(EXTRA_CHARACTER_ID, characterId)
}
)
}
binding.rvCharacters.layoutManager = GridLayoutManager(this, spanCount)
binding.rvCharacters.addItemDecoration(
GridSpacingItemDecoration(
spanCount,
spacingPx,
false
)
)
binding.rvCharacters.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val lastVisibleItemPosition = (recyclerView.layoutManager as LinearLayoutManager)
.findLastVisibleItemPosition()
val totalItemCount = recyclerView.adapter?.itemCount ?: 0
if (
!recyclerView.canScrollVertically(1) &&
lastVisibleItemPosition >= totalItemCount - 1
) {
viewModel.loadMore()
}
}
})
binding.rvCharacters.adapter = adapter
}
private fun bindData() {
viewModel.isLoading.observe(this) { isLoading ->
if (isLoading) loadingDialog.show(screenWidth) else loadingDialog.dismiss()
}
viewModel.totalCount.observe(this) { count ->
binding.tvTotalCount.text = "$count"
}
viewModel.items.observe(this) { list ->
adapter.addItems(list.drop(adapter.itemCount))
binding.rvCharacters.visibility = if (list.isEmpty()) View.GONE else View.VISIBLE
}
viewModel.toastLiveData.observe(this) { message ->
message?.let { showToast(it) }
}
}
}

View File

@@ -0,0 +1,55 @@
package kr.co.vividnext.sodalive.chat.character.newcharacters
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.RoundedCornersTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.chat.character.Character
import kr.co.vividnext.sodalive.databinding.ItemNewCharacterAllBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
class NewCharactersAllAdapter(
private val onClick: (Long) -> Unit
) : RecyclerView.Adapter<NewCharactersAllAdapter.VH>() {
private val items = mutableListOf<Character>()
inner class VH(val binding: ItemNewCharacterAllBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: Character) {
binding.tvCharacterName.text = item.name
binding.tvCharacterDescription.text = item.description
binding.ivCharacter.load(item.imageUrl) {
crossfade(true)
placeholder(R.drawable.ic_logo_service_center)
transformations(RoundedCornersTransformation(16f.dpToPx()))
}
binding.root.setOnClickListener { onClick(item.characterId) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val binding = ItemNewCharacterAllBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return VH(binding)
}
override fun getItemCount(): Int = items.size
override fun onBindViewHolder(holder: VH, position: Int) {
holder.bind(items[position])
}
fun addItems(newItems: List<Character>) {
val start = items.size
items.addAll(newItems)
notifyItemRangeInserted(start, newItems.size)
}
@SuppressLint("NotifyDataSetChanged")
fun clear() {
items.clear()
notifyDataSetChanged()
}
}

View File

@@ -0,0 +1,68 @@
package kr.co.vividnext.sodalive.chat.character.newcharacters
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.chat.character.Character
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
class NewCharactersAllViewModel(
private val repository: NewCharactersRepository
) : BaseViewModel() {
private val _toastLiveData = MutableLiveData<String?>()
val toastLiveData: LiveData<String?> get() = _toastLiveData
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean> get() = _isLoading
private val _totalCount = MutableLiveData<Long>(0)
val totalCount: LiveData<Long> get() = _totalCount
private val _items = MutableLiveData<List<Character>>(emptyList())
val items: LiveData<List<Character>> get() = _items
private var page = 0
private val size = 20
private var isLast = false
fun loadMore() {
if (_isLoading.value == true || isLast) return
_isLoading.value = true
compositeDisposable.add(
repository.getRecentCharacters(
token = "Bearer ${SharedPreferenceManager.token}",
page = page,
size = size
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ response ->
val data = response.data
if (response.success && data != null) {
val current = _items.value ?: emptyList()
val next = current + data.content
_items.value = next
_totalCount.value = data.totalCount
if (data.content.isNotEmpty()) {
page += 1
} else {
isLast = true
}
} else {
_toastLiveData.value = response.message
?: "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
}
_isLoading.value = false
}, { e ->
_isLoading.value = false
e.message?.let { Logger.e(it) }
_toastLiveData.value = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
})
)
}
}

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.chat.character.newcharacters
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.chat.character.CharacterApi
import kr.co.vividnext.sodalive.common.ApiResponse
class NewCharactersRepository(
private val api: CharacterApi
) {
fun getRecentCharacters(
token: String,
page: Int,
size: Int
): Single<ApiResponse<RecentCharactersResponse>> {
return api.getRecentCharacters(
authHeader = token,
page = page,
size = size
)
}
}

View File

@@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.chat.character.newcharacters
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.chat.character.Character
@Keep
data class RecentCharactersResponse(
@SerializedName("totalCount") val totalCount: Long,
@SerializedName("content") val content: List<Character>
)

View File

@@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.chat.character.recent
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class RecentCharacter(
@SerializedName("characterId") val characterId: Long,
@SerializedName("name") val name: String,
@SerializedName("imageUrl") val imageUrl: String
)

View File

@@ -0,0 +1,58 @@
package kr.co.vividnext.sodalive.chat.character.recent
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.CircleCrop
import com.bumptech.glide.request.RequestOptions
import kr.co.vividnext.sodalive.databinding.ItemRecentCharacterBinding
class RecentCharacterAdapter(
private var characters: List<RecentCharacter> = emptyList(),
private val onCharacterClick: (Long) -> Unit = {}
) : RecyclerView.Adapter<RecentCharacterAdapter.ViewHolder>() {
inner class ViewHolder(
private val context: Context,
private val binding: ItemRecentCharacterBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(character: RecentCharacter) {
binding.tvName.text = character.name
Glide.with(context)
.load(character.imageUrl)
.apply(
RequestOptions()
.transform(
CircleCrop()
)
)
.into(binding.ivProfile)
binding.root.setOnClickListener { onCharacterClick(character.characterId) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
parent.context,
ItemRecentCharacterBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(characters[position])
}
override fun getItemCount(): Int = characters.size
@SuppressLint("NotifyDataSetChanged")
fun updateCharacters(newCharacters: List<RecentCharacter>) {
characters = newCharacters
notifyDataSetChanged()
}
}

View File

@@ -0,0 +1,157 @@
package kr.co.vividnext.sodalive.chat.original
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.chat.original.detail.OriginalWorkDetailActivity
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentOriginalTabBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.main.MainActivity
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.splash.SplashActivity
import org.koin.android.ext.android.inject
@OptIn(UnstableApi::class)
class OriginalTabFragment :
BaseFragment<FragmentOriginalTabBinding>(FragmentOriginalTabBinding::inflate) {
private val viewModel: OriginalWorkViewModel by inject()
private val myPageViewModel: MyPageViewModel by inject()
private lateinit var adapter: OriginalWorkListAdapter
private lateinit var loadingDialog: LoadingDialog
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
setupRecycler()
bind()
viewModel.loadMore()
}
private fun setupRecycler() {
val spanCount = 3
val spacingPx = 16f.dpToPx().toInt()
adapter = OriginalWorkListAdapter { id ->
ensureLoginAndAuth {
startActivity(
Intent(
requireContext(),
OriginalWorkDetailActivity::class.java
).apply {
this.putExtra(OriginalWorkDetailActivity.EXTRA_ORIGINAL_ID, id)
}
)
}
}
binding.rvOriginal.layoutManager = GridLayoutManager(requireContext(), spanCount)
binding.rvOriginal.addItemDecoration(
GridSpacingItemDecoration(
spanCount,
spacingPx,
true
)
)
binding.rvOriginal.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val lastVisibleItemPosition = (recyclerView.layoutManager as LinearLayoutManager)
.findLastVisibleItemPosition()
val totalItemCount = recyclerView.adapter?.itemCount ?: 0
if (!recyclerView.canScrollVertically(1) && lastVisibleItemPosition >= totalItemCount - 1) {
viewModel.loadMore()
}
}
})
binding.rvOriginal.adapter = adapter
}
private fun bind() {
viewModel.items.observe(viewLifecycleOwner) { list ->
// 누적 리스트를 어댑터에 추가
adapter.addItems(list.drop(adapter.itemCount))
}
viewModel.isLoading.observe(viewLifecycleOwner) {
if (it) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
viewModel.toast.observe(viewLifecycleOwner) {
it?.let { Toast.makeText(requireActivity(), it, Toast.LENGTH_LONG).show() }
}
}
private fun ensureLoginAndAuth(onAuthed: () -> Unit) {
if (SharedPreferenceManager.token.isBlank()) {
(requireActivity() as MainActivity).showLoginActivity()
return
}
if (!SharedPreferenceManager.isAuth) {
SodaDialog(
activity = requireActivity(),
layoutInflater = layoutInflater,
title = "본인인증",
desc = "보이스온의 오픈월드 캐릭터톡은\n청소년 보호를 위해 본인인증한\n성인만 이용이 가능합니다.\n" +
"캐릭터톡 서비스를 이용하시려면\n본인인증을 하고 이용해주세요.",
confirmButtonTitle = "본인인증 하러가기",
confirmButtonClick = { startAuthFlow() },
cancelButtonTitle = "취소",
cancelButtonClick = {},
descGravity = Gravity.CENTER
).show(screenWidth)
return
}
onAuthed()
}
private fun startAuthFlow() {
Auth.auth(requireActivity(), requireContext()) { json ->
val bootpayResponse = Gson().fromJson(
json,
BootpayResponse::class.java
)
val request = AuthVerifyRequest(receiptId = bootpayResponse.data.receiptId)
requireActivity().runOnUiThread {
myPageViewModel.authVerify(request) {
startActivity(
Intent(
requireContext(),
SplashActivity::class.java
).apply {
addFlags(
Intent.FLAG_ACTIVITY_CLEAR_TASK or
Intent.FLAG_ACTIVITY_NEW_TASK
)
}
)
requireActivity().finish()
}
}
}
}
}

View File

@@ -0,0 +1,23 @@
package kr.co.vividnext.sodalive.chat.original
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Path
import retrofit2.http.Query
interface OriginalWorkApi {
@GET("/api/chat/original/list")
fun getOriginalWorkList(
@Header("Authorization") authHeader: String,
@Query("page") page: Int,
@Query("size") size: Int
): Single<ApiResponse<OriginalWorkListResponse>>
@GET("/api/chat/original/{id}")
fun getOriginalWorkDetail(
@Header("Authorization") authHeader: String,
@Path("id") id: Long
): Single<ApiResponse<OriginalWorkDetailResponse>>
}

View File

@@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.chat.original
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.chat.character.Character
@Keep
data class OriginalWorkCharactersPageResponse(
@SerializedName("totalCount") val totalCount: Long,
@SerializedName("content") val content: List<Character>
)

View File

@@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.chat.original
import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
import kr.co.vividnext.sodalive.chat.character.Character
@Parcelize
@Keep
data class OriginalWorkDetailResponse(
@SerializedName("imageUrl") val imageUrl: String?,
@SerializedName("title") val title: String,
@SerializedName("contentType") val contentType: String,
@SerializedName("category") val category: String,
@SerializedName("isAdult") val isAdult: Boolean,
@SerializedName("description") val description: String,
@SerializedName("originalWork") val originalWork: String?,
@SerializedName("originalLink") val originalLink: String?,
@SerializedName("writer") val writer: String?,
@SerializedName("studio") val studio: String?,
@SerializedName("originalLinks") val originalLinks: List<String>,
@SerializedName("tags") val tags: List<String>,
@SerializedName("characters") val characters: List<Character>
) : Parcelable

View File

@@ -0,0 +1,61 @@
package kr.co.vividnext.sodalive.chat.original
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.RoundedCornersTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.databinding.ItemOriginalWorkBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
class OriginalWorkListAdapter(
private val onClick: (Long) -> Unit
) : RecyclerView.Adapter<OriginalWorkListAdapter.VH>() {
private val items = mutableListOf<OriginalWorkListItemResponse>()
inner class VH(val binding: ItemOriginalWorkBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: OriginalWorkListItemResponse) {
binding.tvTitle.text = item.title
binding.tvContentType.text = item.contentType
binding.ivCover.load(item.imageUrl) {
crossfade(true)
placeholder(R.drawable.ic_logo_service_center)
transformations(RoundedCornersTransformation(16f.dpToPx()))
}
binding.root.setOnClickListener { onClick(item.id) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val binding = ItemOriginalWorkBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return VH(binding)
}
override fun getItemCount(): Int = items.size
override fun onBindViewHolder(holder: VH, position: Int) {
holder.bind(items[position])
}
@SuppressLint("NotifyDataSetChanged")
fun submitList(newItems: List<OriginalWorkListItemResponse>) {
items.clear()
items.addAll(newItems)
notifyDataSetChanged()
}
fun addItems(newItems: List<OriginalWorkListItemResponse>) {
val start = items.size
items.addAll(newItems)
notifyItemRangeInserted(start, newItems.size)
}
@SuppressLint("NotifyDataSetChanged")
fun clear() {
items.clear()
notifyDataSetChanged()
}
}

View File

@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.chat.original
import androidx.annotation.Keep
@Keep
data class OriginalWorkListResponse(
val totalCount: Long,
val content: List<OriginalWorkListItemResponse>
)
@Keep
data class OriginalWorkListItemResponse(
val id: Long,
val imageUrl: String?,
val title: String,
val contentType: String
)

View File

@@ -0,0 +1,23 @@
package kr.co.vividnext.sodalive.chat.original
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
class OriginalWorkRepository(
private val api: OriginalWorkApi
) {
fun getOriginalWorks(
token: String,
page: Int,
size: Int
): Single<ApiResponse<OriginalWorkListResponse>> {
return api.getOriginalWorkList(token, page, size)
}
fun getOriginalDetail(
token: String,
id: Long
): Single<ApiResponse<OriginalWorkDetailResponse>> {
return api.getOriginalWorkDetail(token, id)
}
}

View File

@@ -0,0 +1,71 @@
package kr.co.vividnext.sodalive.chat.original
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.SharedPreferenceManager
class OriginalWorkViewModel(
private val repository: OriginalWorkRepository
) : BaseViewModel() {
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean> get() = _isLoading
private val _toast = MutableLiveData<String?>(null)
val toast: LiveData<String?> get() = _toast
private val _totalCount = MutableLiveData<Long>(0)
val totalCount: LiveData<Long> get() = _totalCount
private val _items = MutableLiveData<List<OriginalWorkListItemResponse>>(emptyList())
val items: LiveData<List<OriginalWorkListItemResponse>> get() = _items
private var page = 0
private val size = 20
private var isLast = false
fun loadMore() {
if (_isLoading.value == true || isLast) return
_isLoading.value = true
compositeDisposable.add(
repository.getOriginalWorks(
token = "Bearer ${SharedPreferenceManager.token}",
page = page,
size = size
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ response ->
val data = response.data
if (response.success && data != null) {
val current = _items.value ?: emptyList()
val next = current + data.content
_items.value = next
_totalCount.value = data.totalCount
if (data.content.isNotEmpty()) {
page += 1
} else {
isLast = true
}
} else {
_toast.value = response.message ?: "알 수 없는 오류가 발생했습니다."
}
_isLoading.value = false
}, { e ->
_isLoading.value = false
_toast.value = e.message ?: "알 수 없는 오류가 발생했습니다."
})
)
}
fun refresh() {
page = 0
isLast = false
_items.value = emptyList()
loadMore()
}
}

View File

@@ -0,0 +1,77 @@
package kr.co.vividnext.sodalive.chat.original.detail
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.GridLayoutManager
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity
import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity.Companion.EXTRA_CHARACTER_ID
import kr.co.vividnext.sodalive.chat.original.OriginalWorkDetailResponse
import kr.co.vividnext.sodalive.common.GridSpacingItemDecoration
import kr.co.vividnext.sodalive.databinding.FragmentOriginalWorkCharacterBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
class OriginalWorkCharacterFragment : BaseFragment<FragmentOriginalWorkCharacterBinding>(
FragmentOriginalWorkCharacterBinding::inflate
) {
private var originalWorkDetailResponse: OriginalWorkDetailResponse? = null
private lateinit var adapter: OriginalWorkDetailAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (arguments != null) {
originalWorkDetailResponse =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requireArguments().getParcelable(
OriginalWorkDetailActivity.EXTRA_ORIGINAL_WORK_DETAIL,
OriginalWorkDetailResponse::class.java
)
} else {
requireArguments().getParcelable(
OriginalWorkDetailActivity.EXTRA_ORIGINAL_WORK_DETAIL
)
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (originalWorkDetailResponse != null) {
setupRecycler()
adapter.setItems(originalWorkDetailResponse!!.characters)
}
}
private fun setupRecycler() {
adapter = OriginalWorkDetailAdapter(
onClickCharacter = { characterId ->
startActivity(
Intent(
requireContext(),
CharacterDetailActivity::class.java
).apply {
putExtra(EXTRA_CHARACTER_ID, characterId)
}
)
}
)
val spanCount = 2
val spacingPx = 16f.dpToPx().toInt()
binding.rvCharacter.layoutManager = GridLayoutManager(requireContext(), spanCount)
binding.rvCharacter.addItemDecoration(
GridSpacingItemDecoration(
spanCount,
spacingPx,
true
)
)
binding.rvCharacter.adapter = adapter
}
}

View File

@@ -0,0 +1,161 @@
package kr.co.vividnext.sodalive.chat.original.detail
import android.os.Bundle
import android.view.View
import android.widget.Toast
import coil.load
import coil.size.Scale
import coil.transform.BlurTransformation
import coil.transform.RoundedCornersTransformation
import com.google.android.material.tabs.TabLayout
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.ActivityOriginalWorkDetailBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject
class OriginalWorkDetailActivity : BaseActivity<ActivityOriginalWorkDetailBinding>(
ActivityOriginalWorkDetailBinding::inflate
) {
companion object {
const val EXTRA_ORIGINAL_ID = "extra_original_id"
const val EXTRA_ORIGINAL_WORK_DETAIL = "extra_original_work_detail"
}
private val viewModel: OriginalWorkDetailViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val originalId = intent.getLongExtra(EXTRA_ORIGINAL_ID, -1)
if (originalId <= 0) {
Toast.makeText(applicationContext, "잘못된 요청입니다.", Toast.LENGTH_LONG).show()
finish()
}
bind()
viewModel.loadDetail(originalId)
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
// 배경 이미지 높이를 화면 너비 비율에 맞게 설정(306:160)
// => 160 = (432 / 2) - 56(toolbar 높이)
binding.ivBg.post {
val width = binding.ivBg.width.takeIf { it > 0 } ?: resources.displayMetrics.widthPixels
val height = width * 160 / 306
val lp = binding.ivBg.layoutParams
lp.height = height
binding.ivBg.layoutParams = lp
}
// Toolbar back
binding.ivBack.setOnClickListener { finish() }
setupTabs()
}
private fun setupTabs() {
val tabs = binding.tabs
tabs.addTab(tabs.newTab().setText("캐릭터").setTag("character"))
tabs.addTab(tabs.newTab().setText("작품정보").setTag("info"))
tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
val tag = tab.tag as String
changeFragment(tag)
}
override fun onTabUnselected(tab: TabLayout.Tab) {
}
override fun onTabReselected(tab: TabLayout.Tab) {
}
})
}
private fun changeFragment(tag: String) {
val fragmentManager = supportFragmentManager
val fragmentTransaction = fragmentManager.beginTransaction()
val fragment = when (tag) {
"info" -> OriginalWorkInfoFragment()
else -> OriginalWorkCharacterFragment()
}
val bundle = Bundle()
bundle.putParcelable(EXTRA_ORIGINAL_WORK_DETAIL, viewModel.detailResponse)
fragment.arguments = bundle
fragmentTransaction.replace(R.id.container, fragment, tag)
fragmentTransaction.setPrimaryNavigationFragment(fragment)
fragmentTransaction.setReorderingAllowed(true)
fragmentTransaction.commitNow()
}
private fun bind() {
viewModel.toast.observe(this) {
it?.let { Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() }
}
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth, "")
} else {
loadingDialog.dismiss()
}
}
viewModel.detail.observe(this) { data ->
if (data != null) {
// 배경 이미지 Blur 처리 및 채우기
val imageUrl = data.imageUrl
if (!imageUrl.isNullOrBlank()) {
binding.ivBg.load(imageUrl) {
transformations(
BlurTransformation(
this@OriginalWorkDetailActivity,
25f,
2.5f
)
)
scale(Scale.FILL)
}
} else {
binding.ivBg.setImageResource(R.drawable.bg_placeholder)
}
binding.ivCover.load(data.imageUrl) {
crossfade(true)
placeholder(R.drawable.bg_placeholder)
transformations(RoundedCornersTransformation(16f.dpToPx()))
}
binding.tvTitle.text = data.title
binding.tvContentType.text = data.contentType
binding.tvCategory.text = data.category
binding.tvTags.text = data.tags.joinToString(" ") {
if (it.startsWith("#")) {
it
} else {
"#$it"
}
}
binding.tvAdult.visibility = if (data.isAdult) {
View.VISIBLE
} else {
View.GONE
}
changeFragment("character")
}
}
}
}

View File

@@ -0,0 +1,54 @@
package kr.co.vividnext.sodalive.chat.original.detail
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.RoundedCornersTransformation
import com.orhanobut.logger.Logger
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.chat.character.Character
import kr.co.vividnext.sodalive.databinding.ItemOriginalDetailCharacterBinding
import kr.co.vividnext.sodalive.extensions.dpToPx
class OriginalWorkDetailAdapter(
private var items: List<Character> = emptyList(),
private val onClickCharacter: (Long) -> Unit
) : RecyclerView.Adapter<OriginalWorkDetailAdapter.ItemVH>() {
inner class ItemVH(
private val binding: ItemOriginalDetailCharacterBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: Character) {
binding.tvCharacterName.text = item.name
binding.tvCharacterDescription.text = item.description
binding.ivCharacter.load(item.imageUrl) {
crossfade(true)
placeholder(R.drawable.ic_logo_service_center)
transformations(RoundedCornersTransformation(16f.dpToPx()))
}
binding.root.setOnClickListener { onClickCharacter(item.characterId) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ItemVH(
ItemOriginalDetailCharacterBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun onBindViewHolder(holder: ItemVH, position: Int) {
holder.bind(items[position])
Logger.d("onBindViewHolder: $position")
}
override fun getItemCount(): Int = items.size
@SuppressLint("NotifyDataSetChanged")
fun setItems(chars: List<Character>) {
items = chars
notifyDataSetChanged()
}
}

View File

@@ -0,0 +1,53 @@
package kr.co.vividnext.sodalive.chat.original.detail
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.chat.character.Character
import kr.co.vividnext.sodalive.chat.original.OriginalWorkDetailResponse
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
class OriginalWorkDetailViewModel(
private val repository: OriginalWorkRepository
) : BaseViewModel() {
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean> get() = _isLoading
private val _toast = MutableLiveData<String?>(null)
val toast: LiveData<String?> get() = _toast
private val _detail = MutableLiveData<OriginalWorkDetailResponse?>(null)
val detail: LiveData<OriginalWorkDetailResponse?> get() = _detail
lateinit var detailResponse: OriginalWorkDetailResponse
fun loadDetail(id: Long) {
if (_isLoading.value == true) return
_isLoading.value = true
compositeDisposable.add(
repository.getOriginalDetail(
token = "Bearer ${SharedPreferenceManager.token}",
id = id
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ response ->
val data = response.data
if (response.success && data != null) {
detailResponse = data
_detail.value = detailResponse
} else {
_toast.value = response.message ?: "알 수 없는 오류가 발생했습니다."
}
_isLoading.value = false
}, { e ->
_isLoading.value = false
_toast.value = e.message ?: "알 수 없는 오류가 발생했습니다."
})
)
}
}

View File

@@ -0,0 +1,171 @@
package kr.co.vividnext.sodalive.chat.original.detail
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.net.toUri
import androidx.core.view.isGone
import androidx.core.view.isVisible
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.chat.original.OriginalWorkDetailResponse
import kr.co.vividnext.sodalive.databinding.FragmentOriginalWorkInfoBinding
class OriginalWorkInfoFragment : BaseFragment<FragmentOriginalWorkInfoBinding>(
FragmentOriginalWorkInfoBinding::inflate
) {
private var originalWorkDetailResponse: OriginalWorkDetailResponse? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (arguments != null) {
originalWorkDetailResponse =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requireArguments().getParcelable(
OriginalWorkDetailActivity.EXTRA_ORIGINAL_WORK_DETAIL,
OriginalWorkDetailResponse::class.java
)
} else {
@Suppress("DEPRECATION")
requireArguments().getParcelable(
OriginalWorkDetailActivity.EXTRA_ORIGINAL_WORK_DETAIL
)
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val data = originalWorkDetailResponse ?: return
// 1. 작품 소개
binding.tvDesc.text = data.description
// 2-3. 원작 보러 가기 섹션
val links = data.originalLinks
if (links.isEmpty()) {
binding.llOriginalLink.isGone = true
} else {
binding.llOriginalLink.isVisible = true
binding.llOriginalLinks.removeAllViews()
links.forEachIndexed { index, url ->
val tv = createLinkTextView(url, index)
binding.llOriginalLinks.addView(tv)
}
}
// 4. 상세 정보 - 작가
val writer = data.writer
if (writer.isNullOrBlank()) {
binding.tvLabelWriter.isGone = true
binding.tvWriter.isGone = true
} else {
binding.tvLabelWriter.isVisible = true
binding.tvWriter.isVisible = true
binding.tvWriter.text = writer
}
// 4. 상세 정보 - 제작사
val studio = data.studio
if (studio.isNullOrBlank()) {
binding.tvLabelStudio.isGone = true
binding.tvStudio.isGone = true
} else {
binding.tvLabelStudio.isVisible = true
binding.tvStudio.isVisible = true
binding.tvStudio.text = studio
}
// 4. 상세 정보 - 원작 (원작명 + 링크)
val originalWork = data.originalWork
val originalLink = data.originalLink
if (originalWork.isNullOrBlank()) {
binding.tvLabelOriginal.isGone = true
binding.tvOriginalWork.isGone = true
} else {
binding.tvLabelOriginal.isVisible = true
binding.tvOriginalWork.isVisible = true
binding.tvOriginalWork.text = originalWork
if (!originalLink.isNullOrBlank()) {
binding.tvOriginalWork.isClickable = true
// 밑줄 표시로 링크 가능함을 시각적으로 안내
binding.tvOriginalWork.paintFlags =
binding.tvOriginalWork.paintFlags or android.graphics.Paint.UNDERLINE_TEXT_FLAG
// Ripple 효과 추가로 터치 피드백 제공
runCatching {
val outValue = android.util.TypedValue()
requireContext().theme.resolveAttribute(
android.R.attr.selectableItemBackground,
outValue,
true
)
binding.tvOriginalWork.setBackgroundResource(outValue.resourceId)
}
// 접근성 설명
binding.tvOriginalWork.contentDescription = "원작 $originalWork 링크 열기"
binding.tvOriginalWork.setOnClickListener {
openUrl(originalLink)
}
} else {
binding.tvOriginalWork.isClickable = false
// 링크가 없을 경우 밑줄/리플 제거
binding.tvOriginalWork.paintFlags =
binding.tvOriginalWork.paintFlags and android.graphics.Paint.UNDERLINE_TEXT_FLAG.inv()
binding.tvOriginalWork.setBackgroundResource(0)
binding.tvOriginalWork.contentDescription = originalWork
binding.tvOriginalWork.setOnClickListener(null)
}
}
}
private fun createLinkTextView(url: String, index: Int): TextView {
val tv = TextView(requireContext())
tv.text = extractDisplayText(url, index)
tv.setTextColor(requireContext().getColor(android.R.color.white))
tv.textSize = 14f
tv.isClickable = true
tv.setOnClickListener { openUrl(url) }
val lp = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
lp.rightMargin = (8 * resources.displayMetrics.density).toInt()
lp.topMargin = (4 * resources.displayMetrics.density).toInt()
tv.layoutParams = lp
tv.setPadding(
(12 * resources.displayMetrics.density).toInt(),
(6 * resources.displayMetrics.density).toInt(),
(12 * resources.displayMetrics.density).toInt(),
(6 * resources.displayMetrics.density).toInt()
)
// Chip 같은 느낌의 배경이 프로젝트에 없을 수 있어 기본 투명 배경 유지
return tv
}
private fun extractDisplayText(url: String, index: Int): String {
return try {
val uri = url.toUri()
val host = uri.host
if (!host.isNullOrBlank()) host else url
} catch (_: Exception) {
// 파싱 실패 시 간단한 레이블 제공
"링크 ${index + 1}"
}
}
private fun openUrl(url: String) {
try {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(intent)
} catch (_: Exception) {
// 안전상 silently ignore 또는 토스트 노출이 가능 하다면 추가
}
}
}

View File

@@ -0,0 +1,92 @@
package kr.co.vividnext.sodalive.chat.talk
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.chat.talk.room.ChatMessagePurchaseRequest
import kr.co.vividnext.sodalive.chat.talk.room.ChatMessagesResponse
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomEnterResponse
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomResetRequest
import kr.co.vividnext.sodalive.chat.talk.room.CreateChatRoomRequest
import kr.co.vividnext.sodalive.chat.talk.room.CreateChatRoomResponse
import kr.co.vividnext.sodalive.chat.talk.room.SendChatMessageResponse
import kr.co.vividnext.sodalive.chat.talk.room.SendMessageRequest
import kr.co.vividnext.sodalive.chat.talk.room.ServerChatMessage
import kr.co.vividnext.sodalive.chat.talk.room.quota.ChatQuotaPurchaseRequest
import kr.co.vividnext.sodalive.chat.talk.room.quota.ChatQuotaStatusResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface TalkApi {
@GET("/api/chat/room/list")
fun getTalkRooms(
@Header("Authorization") authHeader: String,
@Query("page") page: Int
): Single<ApiResponse<List<TalkRoom>>>
@POST("/api/chat/room/create")
fun createChatRoom(
@Header("Authorization") authHeader: String,
@Body request: CreateChatRoomRequest
): Single<ApiResponse<CreateChatRoomResponse>>
// 채팅방 초기화 API (채팅방 생성 응답과 동일 구조 반환)
@POST("/api/chat/room/{roomId}/reset")
fun resetChatRoom(
@Header("Authorization") authHeader: String,
@Path("roomId") roomId: Long,
@Body request: ChatRoomResetRequest
): Single<ApiResponse<CreateChatRoomResponse>>
// 통합 채팅방 입장 API
@GET("/api/chat/room/{roomId}/enter")
fun enterChatRoom(
@Header("Authorization") authHeader: String,
@Path("roomId") roomId: Long,
@Query("characterImageId") characterImageId: Long?
): Single<ApiResponse<ChatRoomEnterResponse>>
// 메시지 전송 API
@POST("/api/chat/room/{roomId}/send")
fun sendMessage(
@Header("Authorization") authHeader: String,
@Path("roomId") roomId: Long,
@Body request: SendMessageRequest
): Single<ApiResponse<SendChatMessageResponse>>
// 점진적 메시지 로딩 API
@GET("/api/chat/room/{roomId}/messages")
fun getChatRoomMessages(
@Header("Authorization") authHeader: String,
@Path("roomId") roomId: Long,
@Query("cursor") cursor: Long?,
@Query("limit") limit: Int = 20
): Single<ApiResponse<ChatMessagesResponse>>
// 유료 메시지 구매 API
@POST("/api/chat/room/{roomId}/messages/{messageId}/purchase")
fun purchaseMessage(
@Header("Authorization") authHeader: String,
@Path("roomId") roomId: Long,
@Path("messageId") messageId: Long,
@Body request: ChatMessagePurchaseRequest
): Single<ApiResponse<ServerChatMessage>>
// 채팅 쿼터 상태 조회
@GET("/api/chat/rooms/{roomId}/quota/me")
fun getChatQuotaStatus(
@Path("roomId") roomId: Long,
@Header("Authorization") authHeader: String
): Single<ApiResponse<ChatQuotaStatusResponse>>
// 채팅 쿼터 구매
@POST("/api/chat/rooms/{roomId}/quota/purchase")
fun purchaseChatQuota(
@Path("roomId") roomId: Long,
@Body request: ChatQuotaPurchaseRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<ChatQuotaStatusResponse>>
}

View File

@@ -0,0 +1,14 @@
package kr.co.vividnext.sodalive.chat.talk
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class TalkRoom(
@SerializedName("chatRoomId") val chatRoomId: Long,
@SerializedName("title") val title: String,
@SerializedName("imageUrl") val imageUrl: String,
@SerializedName("opponentType") val opponentType: String,
@SerializedName("lastMessagePreview") val lastMessagePreview: String?,
@SerializedName("lastMessageTimeLabel") val lastMessageTimeLabel: String
)

View File

@@ -0,0 +1,80 @@
package kr.co.vividnext.sodalive.chat.talk
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.CircleCrop
import com.bumptech.glide.request.RequestOptions
import com.orhanobut.logger.Logger
import kr.co.vividnext.sodalive.databinding.ItemTalkBinding
class TalkTabAdapter(
private val onItemClick: (TalkRoom) -> Unit
) : ListAdapter<TalkRoom, TalkTabAdapter.TalkViewHolder>(TalkDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TalkViewHolder {
val binding = ItemTalkBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return TalkViewHolder(binding)
}
override fun onBindViewHolder(holder: TalkViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class TalkViewHolder(
private val binding: ItemTalkBinding
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.root.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
onItemClick(getItem(position))
}
}
}
fun bind(talkRoom: TalkRoom) {
binding.apply {
Logger.d("bind talkRoom: $talkRoom")
// 프로필 이미지 로드
Glide.with(ivProfile.context)
.load(talkRoom.imageUrl)
.apply(
RequestOptions().transform(
CircleCrop()
)
)
.into(ivProfile)
// 텍스트 설정
tvCharacterName.text = talkRoom.title
tvCharacterType.text = talkRoom.opponentType
tvLastTime.text = talkRoom.lastMessageTimeLabel
tvLastMessage.text = talkRoom.lastMessagePreview ?: ""
// 캐릭터 유형에 따른 배경 설정
val backgroundResId = when (talkRoom.opponentType.lowercase()) {
"character" -> kr.co.vividnext.sodalive.R.drawable.bg_character_type_badge_character
"clone" -> kr.co.vividnext.sodalive.R.drawable.bg_character_type_badge_clone
"creator" -> kr.co.vividnext.sodalive.R.drawable.bg_character_type_badge_creator
else -> kr.co.vividnext.sodalive.R.drawable.bg_character_type_badge_character
}
tvCharacterType.setBackgroundResource(backgroundResId)
}
}
}
private class TalkDiffCallback : DiffUtil.ItemCallback<TalkRoom>() {
override fun areItemsTheSame(oldItem: TalkRoom, newItem: TalkRoom): Boolean {
return oldItem.chatRoomId == newItem.chatRoomId
}
override fun areContentsTheSame(oldItem: TalkRoom, newItem: TalkRoom): Boolean {
return oldItem == newItem
}
}
}

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