Compare commits
246 Commits
1720173a16
...
194c4bad84
| Author | SHA1 | Date | |
|---|---|---|---|
| 194c4bad84 | |||
| 1b7ba7825e | |||
| 5689dd10a5 | |||
| 648064eac7 | |||
| 1ca6d068d0 | |||
| f08c481807 | |||
| f64b28af1b | |||
| 2a50d0f5a0 | |||
| 149d7358f0 | |||
| a86e55eeae | |||
| 3979d37e76 | |||
| d8d05b57cb | |||
| f1d718a45f | |||
| d33ab59378 | |||
| f8e4a4fd45 | |||
| 6d099e0aab | |||
| c5eb9767aa | |||
| 24672b7cf2 | |||
| db6de22273 | |||
| 8cdb82765f | |||
| 172d7c0b80 | |||
| cf86dd3f30 | |||
| 23c05b91d5 | |||
| 7ff3d7f1e5 | |||
| 912518c1ae | |||
| 9b825ee244 | |||
| bc581d763b | |||
| dd236d8f19 | |||
| ff236ee6a1 | |||
| 66a6f992eb | |||
| c6438bef67 | |||
| ee5490939b | |||
| 65a2b47045 | |||
| a56c21f856 | |||
| 7e501c794d | |||
| c07fb33968 | |||
| 7ecb36a7be | |||
| 1cec07f8c5 | |||
| ddcf191ade | |||
| 945e3bd239 | |||
| 09ed73300d | |||
| 83fa3b870c | |||
| cb67787925 | |||
| ad053ef889 | |||
| ae92921b7b | |||
| 9ba053b807 | |||
| 2b8b581082 | |||
| 0b775ed380 | |||
| a90f4b1c5a | |||
| 5bc2b385fa | |||
| 21f57444c8 | |||
| 662f18bceb | |||
| 2635b7d3c3 | |||
| aac3910b43 | |||
| 0319981650 | |||
| 44e209d7b1 | |||
| 0f170c6daa | |||
| 67109bfe3c | |||
| d22907c7d5 | |||
| 02155065f7 | |||
| 3c21b36e88 | |||
| 93fa042522 | |||
| dcde2b125e | |||
| f15c6be1a4 | |||
| 05208d3031 | |||
| 2b892fe783 | |||
| c3c19db730 | |||
| b70c8058e8 | |||
| cdc59d0877 | |||
| 88d13ce77a | |||
| f830c98b8e | |||
| 8de0dc2242 | |||
| 56e99912d4 | |||
| 9ed3c046b3 | |||
| 65791c55ca | |||
| 0422746267 | |||
| cc3aca34f5 | |||
| e39bdb6b03 | |||
| 27a36d2d44 | |||
| 60b7bb7e7e | |||
| 8ebaaefd6f | |||
| 201ab488b2 | |||
| 8b241709e1 | |||
| d9cb12e882 | |||
| 5c78c567ca | |||
| e3bcc6d3a6 | |||
| 05e8874d81 | |||
| 88e3ae7b51 | |||
| 02df0b6774 | |||
| a941d0bfab | |||
| 2e837bec5d | |||
| 9b1a83bd69 | |||
| b3553f80c6 | |||
| 5d76ff1590 | |||
| 6c57c5a98a | |||
| 770c4179a3 | |||
| 9164942395 | |||
| e3ed816fb3 | |||
| 13ee098cfc | |||
| f917eb8c93 | |||
| 989a0f361b | |||
| 52c1f61109 | |||
| 7dd6d46a5f | |||
| 3a1943ba87 | |||
| ab1dd04a60 | |||
| ccd88dad47 | |||
| fdc9ba80e0 | |||
| d1c62fd2b6 | |||
| 3e2cdd502c | |||
| c78aed2551 | |||
| e881178f2a | |||
| b995a0b151 | |||
| ec315c4747 | |||
| 52ff0c82cb | |||
| d4ec2fbdef | |||
| a9742a07c0 | |||
| df1746976c | |||
| 61cfbe249c | |||
| f9b50089dd | |||
| 95983dcf5b | |||
| 16e8941c15 | |||
| cd4a098bff | |||
| 4a0940ad26 | |||
| dd7251f18b | |||
| 3d727f07fa | |||
| 92883ee577 | |||
| 2790bea1d8 | |||
| 3f87b35816 | |||
| bd86d1610a | |||
| 7f1b1b1ed3 | |||
| 09b8979ba0 | |||
| 02747c539b | |||
| c1012586ce | |||
| c9b6623eac | |||
| d662bd0b65 | |||
| ec60d4f143 | |||
| 373752f592 | |||
| 933e650183 | |||
| 6a6aa271ef | |||
| 012437e599 | |||
| d3a64d8359 | |||
| 7451fccff9 | |||
| 1882139fac | |||
| 7fc72da905 | |||
| 9fa270da10 | |||
| 637595e8cd | |||
| ceae25ea06 | |||
| 0cf0d2e790 | |||
| 45b76da1e8 | |||
| 9bb8dcd881 | |||
| 760cbb8228 | |||
| 4a214523c6 | |||
| 6345b1dbee | |||
| 228acadf5a | |||
| 6388895e6e | |||
| 725c4335e1 | |||
| 64deadda0b | |||
| 558f74d861 | |||
| 4eedecd1ce | |||
| 08f9d398c4 | |||
| f102c84ea6 | |||
| 0c3bca0f9e | |||
| ff1e134fe4 | |||
| d8b48fe362 | |||
| ac2482a645 | |||
| 5090809be8 | |||
| 80c593bc11 | |||
| 18b61ab74f | |||
| ea22c7244c | |||
| b1c9c3e124 | |||
| 93fc837b7a | |||
| f0eda41c7c | |||
| 47717002e8 | |||
| 7b7513561d | |||
| 33bdaa7dbd | |||
| b919691689 | |||
| e90222e8db | |||
| 3cf57c1f91 | |||
| f6e7229246 | |||
| f55e74c8dc | |||
| e25276658d | |||
| d088c6f6b3 | |||
| 9361610647 | |||
| 7ed5e921bd | |||
| 39be49b481 | |||
| 3b7b5f98bd | |||
| 9be1b86c5d | |||
| cfe9d3ab11 | |||
| accb413636 | |||
| bdac7b7899 | |||
| 58bc42cc0f | |||
| 44d7ce65ae | |||
| c55cc68f5c | |||
| d7cc874684 | |||
| f1164bbd30 | |||
| 5f6d26c83e | |||
| fcd341a1f4 | |||
| 6e5a4cff45 | |||
| 45fd75ab36 | |||
| 2f9bace3de | |||
| 964f697466 | |||
| bb23f9cf93 | |||
| 440104a7d1 | |||
| 0c7c7946c6 | |||
| 386f9aae32 | |||
| b5d0309f2b | |||
| 3e525b05a5 | |||
| 141e7fe416 | |||
| db2e3bc8f2 | |||
| 66a6f4bbab | |||
| a328ea9c3c | |||
| 76b8b74d41 | |||
| 5c4141dad9 | |||
| e787872cc5 | |||
| af818bda93 | |||
| ccc774da0d | |||
| 32d61d9808 | |||
| 83a30fa088 | |||
| f24cd97afa | |||
| 388770889f | |||
| e3121fc49b | |||
| f1958995f6 | |||
| ba7b681e48 | |||
| e4012a1301 | |||
| 6ff0d8bd61 | |||
| 898afc78ef | |||
| c527f55721 | |||
| 89277c5668 | |||
| 28388497b8 | |||
| 09a2a96596 | |||
| d3f6a02be2 | |||
| c8cc0457e4 | |||
| 4d9e68d60b | |||
| 74585bfb7f | |||
| ea766afba9 | |||
| f10d848797 | |||
| 3bda97b0a7 | |||
| 19c39f636d | |||
| 8b7894a370 | |||
| d1056bda99 | |||
| 5dbf9bd987 | |||
| 23494d0936 | |||
| 116d4b3ecf | |||
| 8b8f5b80b8 | |||
| 0b9abf39f1 | |||
| 9260d271a7 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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
|
||||
|
||||
35
.idea/codeStyles/Project.xml
generated
35
.idea/codeStyles/Project.xml
generated
@@ -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>
|
||||
|
||||
4
.idea/deploymentTargetSelector.xml
generated
4
.idea/deploymentTargetSelector.xml
generated
@@ -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>
|
||||
|
||||
148
app/build.gradle
148
app/build.gradle
@@ -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,17 +82,18 @@ 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",
|
||||
FACEBOOK_APP_ID : "612448298237287",
|
||||
FACEBOOK_CLIENT_TOKEN: "32af760f4a7b7cb7e3b1e7ffd0b0da70",
|
||||
KAKAO_APP_KEY: "231cf78acfa8252fca38b9eedf87c5cb"
|
||||
KAKAO_APP_KEY : "231cf78acfa8252fca38b9eedf87c5cb"
|
||||
]
|
||||
}
|
||||
|
||||
debug {
|
||||
minifyEnabled true
|
||||
minifyEnabled false
|
||||
debuggable true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
applicationIdSuffix '.debug'
|
||||
@@ -79,12 +108,13 @@ 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",
|
||||
FACEBOOK_APP_ID : "608674328645232",
|
||||
FACEBOOK_CLIENT_TOKEN: "3775e6ea83236a685d264b6c5a1bbb4d",
|
||||
KAKAO_APP_KEY: "20cf19413d63bfdfd30e8e6dff933d33"
|
||||
KAKAO_APP_KEY : "20cf19413d63bfdfd30e8e6dff933d33"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
6
app/proguard-rules.pro
vendored
6
app/proguard-rules.pro
vendored
@@ -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
1
app/schemas/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Keep schemas directory under version control
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
25
app/src/main/assets/payverse_starter.html
Normal file
25
app/src/main/assets/payverse_starter.html
Normal 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>
|
||||
25
app/src/main/assets/payverse_starter_debug.html
Normal file
25
app/src/main/assets/payverse_starter_debug.html
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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아래 금액이 차감됩니다."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}년전"
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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}년전"
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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 = "네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 = "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 ?: "알 수 없는 오류가 발생했습니다."
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 또는 토스트 노출이 가능 하다면 추가
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user