Compare commits
234 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 742b8b25e8 | |||
| af3b7e7bb9 | |||
| b11910d8a9 | |||
| 80ac0090bd | |||
| 1e3e417035 | |||
| ca8b7b3c53 | |||
| e33c1e8aef | |||
| fcce1f863b | |||
| 04974b0a01 | |||
| 672dc1859f | |||
| 90e47fc3ce | |||
| 084eb7bb52 | |||
| 8937ccbf56 | |||
| fe78b5192b | |||
| cfe5ce8102 | |||
| 05615ce5dc | |||
| 3d64cc5929 | |||
| e59d4283ca | |||
| 5b9487b60d | |||
| 0a1601c16c | |||
| c94fcd493e | |||
| 357790d794 | |||
| 6d18e13826 | |||
| 9c592ee62b | |||
| f238a2e83f | |||
| 882de043ee | |||
| 1a0ed2da19 | |||
| 958d1c16be | |||
| e686bc3b52 | |||
| bd5079806b | |||
| 787c5dc574 | |||
| f89564e60a | |||
| 778c06342a | |||
| 2f602e37b1 | |||
| 58b008db91 | |||
| 2173347d96 | |||
| 6a299a8a2c | |||
| cf62a61195 | |||
| 5d5c65a5cb | |||
| e797ac93a7 | |||
| bec33c165e | |||
| 2f8cc8832a | |||
| 8e1d39d049 | |||
| f981efd58d | |||
| 6d38b3c549 | |||
| aa8ef1faaf | |||
| 238b7dfb75 | |||
| eb58263ca8 | |||
| a9687d5be8 | |||
| 578a5b0de6 | |||
| dbaa2f4c22 | |||
| 82b991b91e | |||
| 96d804b4c7 | |||
| 83ef3e8dce | |||
| 689a4761ce | |||
| c5e6843b35 | |||
| 1953553277 | |||
| fe09463416 | |||
| 842a02c63a | |||
| 2f08a7b2b6 | |||
| d8ae9186d8 | |||
| bc647119df | |||
| 3a92c588c3 | |||
| 88968c7437 | |||
| bf48ccdb82 | |||
| afc3570fea | |||
| bb9b262219 | |||
| 464d0adb19 | |||
| 24393025bb | |||
| fbc4184ed0 | |||
| 941cede86c | |||
| ca16d54823 | |||
| 4135583758 | |||
| 03fa627798 | |||
| 6ba3e5c4b3 | |||
| 1996a9ca5a | |||
| 9e463bf096 | |||
| 28c3e286ba | |||
| b69c607fe5 | |||
| 0e5b2ee22e | |||
| 45c5aa29b0 | |||
| 8d5e9f7201 | |||
| 9a2de74b22 | |||
| 71718ee9c9 | |||
| 58944bd091 | |||
| e524b28eab | |||
| 4e5ddabde5 | |||
| 791f5c4c96 | |||
| 9a9d497fa8 | |||
| 6f1b911625 | |||
| 904cda3ae8 | |||
| 7195f74ed8 | |||
| 7c5ee2d15f | |||
| 9f2dcffe90 | |||
| 0540293bff | |||
| a6af38a6ca | |||
| f63b421915 | |||
| e01b2d9e8f | |||
| 784064b386 | |||
| 803b14139f | |||
| 2f41c61b7e | |||
| 1b70cb5cdb | |||
| dba0ffd826 | |||
| 6516f5e75d | |||
| 9d9d0297c8 | |||
| 4ee048199b | |||
| 4f84eed876 | |||
| 5a71c8f363 | |||
| 14b4e4c54b | |||
| 4184c23f86 | |||
| 76b238f180 | |||
| 6b5f831271 | |||
| 234587afc9 | |||
| b855acd8d3 | |||
| 391f841f45 | |||
| 2ed5639cbc | |||
| 2cbd2a975f | |||
| 6590b09300 | |||
| 30563a75f3 | |||
| aac3220a69 | |||
| e2de134180 | |||
| 7f1be94896 | |||
| 1c048fd9c0 | |||
| 2613d2e801 | |||
| c100a8ceef | |||
| f86b5e1d39 | |||
| 75eb38b188 | |||
| 4f588483c0 | |||
| 0bc442762d | |||
| 397ac6a9ee | |||
| 784f87dc39 | |||
| e714f567b9 | |||
| 703beb8d43 | |||
| 2b30beb367 | |||
| 6fffa0447e | |||
| 2a9d6a2f6b | |||
| cc12a08472 | |||
| 513897499d | |||
| baa6f284bd | |||
| 2ba4c2ec02 | |||
| 28061617da | |||
| c9e411c4ad | |||
| 995e061b6f | |||
| 9c9eb66b71 | |||
| f90bfbfa0f | |||
| 9ea03cee34 | |||
| 1de8bb825c | |||
| 721b7aa9ab | |||
| f16be90cc3 | |||
| 4a1c15747c | |||
| 0bbfd9b739 | |||
| cff6b78c30 | |||
| 13593212df | |||
| 56f225702e | |||
| 0a5b81cc5c | |||
| 10fc40373d | |||
| f18ee9e360 | |||
| 9262288bc9 | |||
| 3273b17d15 | |||
| 4ef5a94d46 | |||
| 4a684886fa | |||
| d7f87c7c55 | |||
| 00933dadb8 | |||
| d5cc186e27 | |||
| 90156745ad | |||
| 7095832722 | |||
| 658b337d22 | |||
| f6a760371a | |||
| 13ed16078b | |||
| 2a5174cbb6 | |||
| ea26cb40a0 | |||
| eb8119b775 | |||
| 66da741eda | |||
| 18dd52e193 | |||
| f839a793a3 | |||
| 54df6c088b | |||
| 28fb94a824 | |||
| 4bdbbb0231 | |||
| 58a2013a8f | |||
| 2f2da0a159 | |||
| 4ffaf3c3a8 | |||
| 29490d288b | |||
| a99ab30c4e | |||
| 0442925ae9 | |||
| 9d3d13a22d | |||
| f0a9704e2d | |||
| 7f2c103ada | |||
| bd01ae39d0 | |||
| d94e3b5c20 | |||
| 44cc76d2e3 | |||
| fac6f23356 | |||
| 39928abc46 | |||
| 4d0d7004b0 | |||
| 28a6e3fef3 | |||
| b275d88ef7 | |||
| 595ef7f942 | |||
| 1202b55c74 | |||
| 074009d256 | |||
| ad1de9e3f7 | |||
| 359bcfdfd7 | |||
| 1901fddb2e | |||
| b96ae94bdb | |||
| dedd356896 | |||
| bb9fda75ae | |||
| ea911f113b | |||
| 88f379fe5b | |||
| 1a24136c35 | |||
| 742410223c | |||
| bd5aff7564 | |||
| b43c1585c4 | |||
| cb582393f1 | |||
| a200d00587 | |||
| 6d2133545f | |||
| 2aad126010 | |||
| b7b777d2d0 | |||
| e804c8be0c | |||
| 228a74695e | |||
| 41a51b85da | |||
| e74e8615a5 | |||
| 349d39daf2 | |||
| eca85c8377 | |||
| 8154a0ddc4 | |||
| f8be622ba6 | |||
| f3c841779b | |||
| 57e4614ce8 | |||
| 922d6e72d6 | |||
| c41c097d41 | |||
| 5218ca7046 | |||
| ce6ee7bf82 | |||
| e00deb5661 | |||
| 95d6522a54 | |||
| d231f3678c | |||
| cd35562244 | |||
| 21cb512237 |
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="2025-09-08T06:52:32.669239Z">
|
||||
<DropdownSelection timestamp="2025-11-11T06:03:31.167121900Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/liudikang/.android/avd/Pixel_8_API_30.avd" />
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=f800b364" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
|
||||
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -13,7 +13,6 @@
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveExternalAnnotations" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.10" />
|
||||
<option name="version" value="2.2.21" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,19 +1,22 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
id("com.google.gms.google-services")
|
||||
id("com.google.firebase.crashlytics")
|
||||
id("com.google.firebase.firebase-perf")
|
||||
id("org.jetbrains.kotlin.kapt")
|
||||
alias(libs.plugins.ksp)
|
||||
|
||||
}
|
||||
android {
|
||||
namespace = "com.aiosman.ravenow"
|
||||
compileSdk = 34
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.aiosman.ravenow"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
targetSdk = 35
|
||||
versionCode = 1000019
|
||||
versionName = "1.0.000.19"
|
||||
|
||||
@@ -32,6 +35,9 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
isDebuggable = true
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
@@ -41,17 +47,15 @@ android {
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.3"
|
||||
buildConfig = true
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
@@ -93,11 +97,13 @@ dependencies {
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
implementation(libs.androidx.animation)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
implementation(libs.play.services.auth)
|
||||
implementation(libs.kotlin.faker)
|
||||
implementation(libs.androidx.material)
|
||||
implementation(libs.androidx.material.icons.extended)
|
||||
implementation(libs.zoomable)
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.converter.gson)
|
||||
@@ -121,7 +127,18 @@ dependencies {
|
||||
// 添加 lifecycle-runtime-ktx 依赖
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx.v262)
|
||||
implementation (libs.eventbus)
|
||||
implementation(libs.lottie)
|
||||
|
||||
// CameraX + ML Kit(版本在 libs.versions.toml)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
implementation(libs.mlkit.barcode.scanning)
|
||||
|
||||
// Room 持久化
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
}
|
||||
|
||||
|
||||
20
app/proguard-rules.pro
vendored
20
app/proguard-rules.pro
vendored
@@ -20,3 +20,23 @@
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
# OpenIM SDK ProGuard rules
|
||||
-keep class io.openim.android.sdk.** { *; }
|
||||
-keep class io.openim.core.** { *; }
|
||||
-keepclassmembers class io.openim.android.sdk.** { *; }
|
||||
-keepclassmembers class io.openim.core.** { *; }
|
||||
|
||||
# Keep OpenIM models and listeners
|
||||
-keep class io.openim.android.sdk.models.** { *; }
|
||||
-keep class io.openim.android.sdk.listener.** { *; }
|
||||
-keep class io.openim.android.sdk.enums.** { *; }
|
||||
|
||||
# Keep OpenIM Client and managers
|
||||
-keep class io.openim.android.sdk.OpenIMClient { *; }
|
||||
-keep class io.openim.android.sdk.manager.** { *; }
|
||||
|
||||
# Prevent obfuscation of callback methods
|
||||
-keepclassmembers class * implements io.openim.android.sdk.listener.** {
|
||||
public *;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,18 +7,21 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".RaveNowApplication"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/rider_pro_logo_next"
|
||||
android:icon="@mipmap/invalid_name"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/rider_pro_logo_next_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.RaveNow"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
tools:targetApi="31">
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
@@ -50,7 +53,8 @@
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.App.Starting"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="fontScale|orientation|screenSize|keyboardHidden|uiMode">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
BIN
app/src/main/assets/loading.lottie
Normal file
BIN
app/src/main/assets/loading.lottie
Normal file
Binary file not shown.
BIN
app/src/main/assets/login.lottie
Normal file
BIN
app/src/main/assets/login.lottie
Normal file
Binary file not shown.
BIN
app/src/main/assets/login_light.lottie
Normal file
BIN
app/src/main/assets/login_light.lottie
Normal file
Binary file not shown.
BIN
app/src/main/assets/star_Loader.lottie
Normal file
BIN
app/src/main/assets/star_Loader.lottie
Normal file
Binary file not shown.
@@ -12,6 +12,7 @@ import com.aiosman.ravenow.data.AccountService
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.data.DictService
|
||||
import com.aiosman.ravenow.data.DictServiceImpl
|
||||
import com.aiosman.ravenow.data.PointService
|
||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
||||
import com.aiosman.ravenow.ui.favourite.FavouriteListViewModel
|
||||
import com.aiosman.ravenow.ui.favourite.FavouriteNoticeViewModel
|
||||
@@ -31,6 +32,7 @@ import com.aiosman.ravenow.ui.index.tabs.ai.tabs.mine.MineAgentViewModel
|
||||
import com.aiosman.ravenow.ui.like.LikeNoticeViewModel
|
||||
import com.aiosman.ravenow.utils.Utils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import com.aiosman.ravenow.im.OpenIMManager
|
||||
import io.openim.android.sdk.OpenIMClient
|
||||
@@ -45,10 +47,12 @@ object AppState {
|
||||
var googleClientId: String? = null
|
||||
var enableGoogleLogin: Boolean = false
|
||||
var enableChat = false
|
||||
var agentCreatedSuccess by mutableStateOf(false)
|
||||
var chatBackgroundUrl by mutableStateOf<String?>(null)
|
||||
suspend fun initWithAccount(scope: CoroutineScope, context: Context) {
|
||||
// 如果是游客模式,使用简化的初始化流程
|
||||
if (AppStore.isGuest) {
|
||||
initWithGuestAccount()
|
||||
initWithGuestAccount(scope)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -67,25 +71,70 @@ object AppState {
|
||||
)
|
||||
// 设置当前登录用户 ID
|
||||
UserId = resp.id
|
||||
try {
|
||||
var profileResult = accountService.getMyAccountProfile()
|
||||
profile = profileResult
|
||||
|
||||
} catch (e:Exception) {
|
||||
Log.e("AppState", "getMyAccountProfile Error:"+ e.message )
|
||||
}
|
||||
// 获取当前用户资料
|
||||
|
||||
// 注册 JPush
|
||||
Messaging.registerDevice(scope, context)
|
||||
initChat(context)
|
||||
|
||||
// 设置当前用户并刷新积分信息(完成登录态初始化后)
|
||||
PointService.setCurrentUser(UserId)
|
||||
try {
|
||||
PointService.refreshMyPointsBalance()
|
||||
} catch (e: Exception) {
|
||||
Log.e("AppState", "刷新积分失败: ${e.message}")
|
||||
}
|
||||
|
||||
// 并行加载积分规则和房间规则配置(不阻塞主流程)
|
||||
scope.launch {
|
||||
try {
|
||||
PointService.refreshPointsRules()
|
||||
} catch (e: Exception) {
|
||||
Log.e("AppState", "加载积分规则失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
PointService.refreshRoomMaxMembers()
|
||||
} catch (e: Exception) {
|
||||
Log.e("AppState", "加载房间规则失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 游客模式的简化初始化
|
||||
*/
|
||||
private fun initWithGuestAccount() {
|
||||
private fun initWithGuestAccount(scope: CoroutineScope) {
|
||||
// 游客模式下,不初始化推送和TRTC
|
||||
// 设置默认的用户信息
|
||||
UserId = 0
|
||||
profile = null
|
||||
enableChat = false
|
||||
Log.d("AppState", "Guest mode initialized without push notifications and TRTC")
|
||||
|
||||
// 游客模式下也加载规则配置(用于查看费用信息)
|
||||
scope.launch {
|
||||
try {
|
||||
PointService.refreshPointsRules()
|
||||
} catch (e: Exception) {
|
||||
Log.e("AppState", "加载积分规则失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
PointService.refreshRoomMaxMembers()
|
||||
} catch (e: Exception) {
|
||||
Log.e("AppState", "加载房间规则失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun initChat(context: Context){
|
||||
@@ -101,10 +150,11 @@ object AppState {
|
||||
|
||||
val initConfig = InitConfig(
|
||||
"https://im.ravenow.ai/api",//SDK api地址
|
||||
"wss:///im.ravenow.ai/msg_gateway",//SDK WebSocket地址
|
||||
"wss://im.ravenow.ai/msg_gateway",//SDK WebSocket地址
|
||||
OpenIMManager.getStorageDir(context),//SDK数据库存储目录
|
||||
)
|
||||
|
||||
// initConfig.isLogStandardOutput = true;
|
||||
// initConfig.logLevel = 6
|
||||
// 使用 OpenIMManager 初始化 SDK
|
||||
OpenIMManager.initSDK(context, initConfig)
|
||||
|
||||
@@ -220,6 +270,8 @@ object AppState {
|
||||
AgentViewModel.ResetModel()
|
||||
MineAgentViewModel.ResetModel()
|
||||
UserId = null
|
||||
// 清空积分全局状态,避免用户切换串号
|
||||
PointService.clear()
|
||||
|
||||
// 清除游客状态
|
||||
AppStore.isGuest = false
|
||||
|
||||
@@ -111,7 +111,7 @@ class DarkThemeColors : AppThemeData(
|
||||
chatActionColor = Color(0xFF3D3D3D),
|
||||
brandColorsColor = Color(0xffD80264),
|
||||
tabSelectedBackground = Color(0xffffffff),
|
||||
tabUnselectedBackground = Color(0x2E7C7480),
|
||||
tabUnselectedBackground = Color(0xFF1C1C1C),
|
||||
tabSelectedText = Color(0xff000000),
|
||||
tabUnselectedText = Color(0xffffffff),
|
||||
bubbleBackground = Color(0xff2d2c2e),
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.aiosman.ravenow
|
||||
|
||||
object ConstVars {
|
||||
// api 地址
|
||||
// const val BASE_SERVER = "http://192.168.31.131:8088"
|
||||
// const val BASE_SERVER = "http://192.168.142.141:8088"
|
||||
// const val BASE_SERVER = "http://192.168.0.228:8088"
|
||||
const val BASE_SERVER = "https://rider-pro.aiosman.com/beta_api"
|
||||
// api 地址 - 根据构建类型自动选择
|
||||
// Debug: http://192.168.0.201:8088
|
||||
// Release: https://rider-pro.aiosman.com/beta_api
|
||||
val BASE_SERVER = if (BuildConfig.DEBUG) {
|
||||
// "http://47.109.137.67:6363" // Debug环境
|
||||
"https://rider-pro.aiosman.com/beta_api" // Release环境
|
||||
} else {
|
||||
"https://rider-pro.aiosman.com/beta_api" // Release环境
|
||||
}
|
||||
|
||||
const val MOMENT_LIKE_CHANNEL_ID = "moment_like"
|
||||
const val MOMENT_LIKE_CHANNEL_NAME = "Moment Like"
|
||||
|
||||
@@ -7,11 +7,13 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
import coil.ImageLoader
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import coil3.ImageLoader
|
||||
import coil3.compose.rememberAsyncImagePainter
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import okio.Path.Companion.toPath
|
||||
|
||||
data class ImageItem(val url: String)
|
||||
|
||||
@@ -53,14 +55,15 @@ fun ImageItem(item: ImageItem, imageLoader: ImageLoader, context: Context) { //
|
||||
fun getImageLoader(context: Context): ImageLoader {
|
||||
return ImageLoader.Builder(context)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(context)
|
||||
.maxSizePercent(0.25) // 设置内存缓存大小为可用内存的 25%
|
||||
MemoryCache.Builder()
|
||||
.maxSizePercent(context,0.25) // 设置内存缓存大小为可用内存的 25%
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
val cacheDir = context.cacheDir.resolve("image_cache")
|
||||
DiskCache.Builder()
|
||||
.directory(context.cacheDir.resolve("image_cache"))
|
||||
.maxSizePercent(0.02) // 设置磁盘缓存大小为可用存储空间的 2%
|
||||
.directory(cacheDir.absolutePath.toPath())
|
||||
.maxSizeBytes(250L * 1024 * 1024) // 250MB
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -22,6 +23,8 @@ import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
@@ -37,13 +40,19 @@ import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.dialogs.CheckUpdateDialog
|
||||
import com.aiosman.ravenow.ui.navigateToPost
|
||||
import com.aiosman.ravenow.ui.post.NewPostViewModel
|
||||
import com.aiosman.ravenow.ui.points.PointsBottomSheetHost
|
||||
import com.google.firebase.Firebase
|
||||
import com.google.firebase.analytics.FirebaseAnalytics
|
||||
import com.google.firebase.analytics.analytics
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.aiosman.ravenow.ui.splash.SplashScreen
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
// Firebase Analytics
|
||||
@@ -51,6 +60,25 @@ class MainActivity : ComponentActivity() {
|
||||
private val scope = CoroutineScope(Dispatchers.Main)
|
||||
val context = this
|
||||
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
// 禁用字体缩放,固定字体大小为系统默认大小
|
||||
val configuration = Configuration(newBase.resources.configuration)
|
||||
configuration.fontScale = 1.0f
|
||||
val context = newBase.createConfigurationContext(configuration)
|
||||
super.attachBaseContext(context)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
// 确保配置变化时字体缩放保持为 1.0
|
||||
val config = Configuration(newConfig)
|
||||
config.fontScale = 1.0f
|
||||
super.onConfigurationChanged(config)
|
||||
val isNightMode = (newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||
if (AppState.darkMode != isNightMode) {
|
||||
syncDarkModeWithSystem(isNightMode)
|
||||
}
|
||||
}
|
||||
|
||||
// 请求通知权限
|
||||
private val requestPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
@@ -105,9 +133,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
JPushInterface.init(this)
|
||||
|
||||
if (AppState.darkMode) {
|
||||
window.decorView.setBackgroundColor(android.graphics.Color.BLACK)
|
||||
}
|
||||
updateWindowBackground(AppState.darkMode)
|
||||
enableEdgeToEdge()
|
||||
|
||||
scope.launch {
|
||||
@@ -122,11 +148,34 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
setContent {
|
||||
// 强制字体缩放为 1.0 - 通过覆盖 Density 来实现
|
||||
val density = LocalDensity.current
|
||||
val fixedDensity = remember {
|
||||
androidx.compose.ui.unit.Density(
|
||||
density = density.density,
|
||||
fontScale = 1.0f
|
||||
)
|
||||
}
|
||||
|
||||
var showSplash by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(2000)
|
||||
showSplash = false
|
||||
}
|
||||
|
||||
if (showSplash) {
|
||||
SplashScreen()
|
||||
} else {
|
||||
CompositionLocalProvider(
|
||||
LocalAppTheme provides AppState.appTheme
|
||||
LocalAppTheme provides AppState.appTheme,
|
||||
LocalDensity provides fixedDensity
|
||||
) {
|
||||
CheckUpdateDialog()
|
||||
// 全局挂载积分底部弹窗 Host
|
||||
PointsBottomSheetHost()
|
||||
Navigation(startDestination) { navController ->
|
||||
|
||||
// 处理带有 postId 的通知点击
|
||||
val postId = intent.getStringExtra("POST_ID")
|
||||
var commentId = intent.getStringExtra("COMMENT_ID")
|
||||
@@ -179,17 +228,15 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
NewPostViewModel.asNewPostWithImageUris(imageUris!!.map { it.toString() })
|
||||
navController.navigate(NavigationRoute.NewPost.route)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 请求通知权限
|
||||
@@ -224,8 +271,22 @@ class MainActivity : ComponentActivity() {
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncDarkModeWithSystem(isNightMode: Boolean) {
|
||||
AppState.darkMode = isNightMode
|
||||
AppState.appTheme = if (isNightMode) DarkThemeColors() else LightThemeColors()
|
||||
AppStore.saveDarkMode(isNightMode)
|
||||
updateWindowBackground(isNightMode)
|
||||
}
|
||||
|
||||
private fun updateWindowBackground(isDarkMode: Boolean) {
|
||||
window.decorView.setBackgroundColor(
|
||||
if (isDarkMode) android.graphics.Color.BLACK else android.graphics.Color.WHITE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val LocalNavController = compositionLocalOf<NavHostController> {
|
||||
error("NavController not provided")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.aiosman.ravenow
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.perf.FirebasePerformance
|
||||
@@ -11,6 +12,14 @@ import com.google.firebase.perf.FirebasePerformance
|
||||
*/
|
||||
class RaveNowApplication : Application() {
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
// 禁用字体缩放,固定字体大小为系统默认大小
|
||||
val configuration = Configuration(base.resources.configuration)
|
||||
configuration.fontScale = 1.0f
|
||||
val context = base.createConfigurationContext(configuration)
|
||||
super.attachBaseContext(context)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
|
||||
@@ -64,6 +64,15 @@ data class AccountProfile(
|
||||
val aiAccount: Boolean,
|
||||
|
||||
val chatAIId: String,
|
||||
|
||||
// AI角色背景图
|
||||
val aiRoleAvatar: String? = null,
|
||||
val aiRoleAvatarMedium: String? = null,
|
||||
val aiRoleAvatarLarge: String? = null,
|
||||
|
||||
// 创建者信息(仅AI账号有)
|
||||
@SerializedName("creatorProfile")
|
||||
val creatorProfile: com.aiosman.ravenow.data.CreatorProfile? = null,
|
||||
) {
|
||||
/**
|
||||
* 转换为Entity
|
||||
@@ -89,7 +98,17 @@ data class AccountProfile(
|
||||
chatToken = openImToken,
|
||||
aiAccount = aiAccount,
|
||||
rawAvatar = avatar,
|
||||
chatAIId = chatAIId
|
||||
chatAIId = chatAIId,
|
||||
aiRoleAvatar = aiRoleAvatar?.let {
|
||||
if (it.isNotEmpty()) "${ApiClient.BASE_SERVER}$it" else null
|
||||
},
|
||||
aiRoleAvatarMedium = aiRoleAvatarMedium?.let {
|
||||
if (it.isNotEmpty()) "${ApiClient.BASE_SERVER}$it" else null
|
||||
},
|
||||
aiRoleAvatarLarge = aiRoleAvatarLarge?.let {
|
||||
if (it.isNotEmpty()) "${ApiClient.BASE_SERVER}$it" else null
|
||||
},
|
||||
creatorProfile = creatorProfile?.toCreatorProfileEntity()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -417,22 +436,31 @@ interface AccountService {
|
||||
* @param page 页码
|
||||
* @param pageSize 每页数量
|
||||
*/
|
||||
suspend fun getAgent(page: Int, pageSize: Int): retrofit2.Response<DataContainer<ListContainer<Agent>>>
|
||||
suspend fun getAgent(page: Int, pageSize: Int, excludeRoomId: Int? = null, title: String? = null, desc: String? = null): retrofit2.Response<DataContainer<ListContainer<Agent>>>
|
||||
|
||||
/**
|
||||
* 创建群聊
|
||||
* @param name 群聊名称
|
||||
* @param userIds 用户ID列表
|
||||
* @param promptIds AI智能体ID列表
|
||||
* @param roomId 房间ID,如果提供则添加成员到现有群聊,否则创建新群聊
|
||||
*/
|
||||
suspend fun createGroupChat(name: String, userIds: List<String>, promptIds: List<String>): retrofit2.Response<DataContainer<Unit>>
|
||||
suspend fun createGroupChat(name: String, userIds: List<String>, promptIds: List<String>, roomId: Int? = null): retrofit2.Response<DataContainer<Unit>>
|
||||
}
|
||||
|
||||
class AccountServiceImpl : AccountService {
|
||||
override suspend fun getMyAccountProfile(): AccountProfileEntity {
|
||||
// 如果已有缓存,直接返回缓存结果
|
||||
AppState.profile?.let { return it }
|
||||
|
||||
// 第一次调用,获取数据并缓存
|
||||
val resp = ApiClient.api.getMyAccount()
|
||||
val body = resp.body() ?: throw ServiceException("Failed to get account")
|
||||
return body.data.toAccountProfileEntity()
|
||||
val profile = body.data.toAccountProfileEntity()
|
||||
|
||||
// 缓存结果到共享状态
|
||||
AppState.profile = profile
|
||||
return profile
|
||||
}
|
||||
|
||||
override suspend fun getMyAccount(): UserAuth {
|
||||
@@ -517,7 +545,13 @@ class AccountServiceImpl : AccountService {
|
||||
val bannerField: MultipartBody.Part? = banner?.let {
|
||||
createMultipartBody(it.file, it.filename, "banner")
|
||||
}
|
||||
ApiClient.api.updateProfile(avatarField, bannerField, nicknameField, bioField)
|
||||
val resp = ApiClient.api.updateProfile(avatarField, bannerField, nicknameField, bioField)
|
||||
if (!resp.isSuccessful) {
|
||||
parseErrorResponse(resp.errorBody())?.let {
|
||||
throw it.toServiceException()
|
||||
}
|
||||
throw ServiceException("Failed to update profile")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun registerUserWithPassword(loginName: String, password: String) {
|
||||
@@ -622,15 +656,15 @@ class AccountServiceImpl : AccountService {
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAgent(page: Int, pageSize: Int): retrofit2.Response<DataContainer<ListContainer<Agent>>> {
|
||||
return ApiClient.api.getAgent(page, pageSize)
|
||||
override suspend fun getAgent(page: Int, pageSize: Int, excludeRoomId: Int?, title: String?, desc: String?): retrofit2.Response<DataContainer<ListContainer<Agent>>> {
|
||||
return ApiClient.api.getAgent(page, pageSize, excludeRoomId = excludeRoomId, title = title, desc = desc)
|
||||
}
|
||||
|
||||
override suspend fun createGroupChat(name: String, userIds: List<String>, promptIds: List<String>): retrofit2.Response<DataContainer<Unit>> {
|
||||
override suspend fun createGroupChat(name: String, userIds: List<String>, promptIds: List<String>, roomId: Int?): retrofit2.Response<DataContainer<Unit>> {
|
||||
val requestBody = com.aiosman.ravenow.data.api.CreateGroupChatRequestBody(
|
||||
name = name,
|
||||
userIds = userIds,
|
||||
promptIds = promptIds
|
||||
promptIds = promptIds,
|
||||
)
|
||||
return ApiClient.api.createGroupChat(requestBody)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,16 @@ package com.aiosman.ravenow.data
|
||||
|
||||
import com.aiosman.ravenow.AppStore
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import com.aiosman.ravenow.data.api.AgentRule
|
||||
import com.aiosman.ravenow.data.api.AgentRuleListResponse
|
||||
import com.aiosman.ravenow.data.api.AgentRuleQuota
|
||||
import com.aiosman.ravenow.data.api.AgentRuleAgent
|
||||
import com.aiosman.ravenow.data.api.CreateAgentRuleRequestBody
|
||||
import com.aiosman.ravenow.data.api.InsufficientBalanceError
|
||||
import com.aiosman.ravenow.data.api.UpdateAgentRuleRequestBody
|
||||
import com.aiosman.ravenow.entity.AgentEntity
|
||||
import com.aiosman.ravenow.entity.ProfileEntity
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class Agent(
|
||||
@@ -65,7 +73,7 @@ data class Profile(
|
||||
@SerializedName("nickname")
|
||||
val nickname: String,
|
||||
@SerializedName("trtcUserId")
|
||||
val trtcUserId: String,
|
||||
val trtcUserId: String? = null,
|
||||
@SerializedName("username")
|
||||
val username: String
|
||||
){
|
||||
@@ -77,22 +85,241 @@ data class Profile(
|
||||
avatar = "${ApiClient.BASE_SERVER}$avatar",
|
||||
bio = bio,
|
||||
banner = "${ApiClient.BASE_SERVER}$banner",
|
||||
trtcUserId = trtcUserId,
|
||||
trtcUserId = trtcUserId ?: "",
|
||||
chatAIId = chatAIId,
|
||||
aiAccount = aiAccount
|
||||
)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 智能体服务
|
||||
*/
|
||||
interface AgentService {
|
||||
/**
|
||||
* 获取智能体列表
|
||||
* @param pageNumber 页码
|
||||
* @param pageSize 每页数量,默认 20
|
||||
* @param authorId 作者ID,可选参数,用于筛选特定作者的智能体
|
||||
* @return 智能体列表容器,包含分页信息和智能体列表,失败时返回 null
|
||||
*/
|
||||
suspend fun getAgent(
|
||||
pageNumber: Int,
|
||||
pageSize: Int = 20,
|
||||
authorId: Int? = null
|
||||
): ListContainer<AgentEntity>
|
||||
): ListContainer<AgentEntity>?
|
||||
|
||||
/**
|
||||
* 根据标题关键字搜索智能体
|
||||
*/
|
||||
suspend fun searchAgentByTitle(
|
||||
pageNumber: Int,
|
||||
pageSize: Int = 20,
|
||||
title: String
|
||||
): ListContainer<AgentEntity>?
|
||||
|
||||
}
|
||||
|
||||
|
||||
// ========== Agent 规则 - 领域实体 ==========
|
||||
|
||||
data class AgentRuleAgentInfo(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val avatar: String,
|
||||
)
|
||||
|
||||
data class AgentRuleEntity(
|
||||
val id: Int,
|
||||
val rule: String,
|
||||
val creator: String,
|
||||
val creatorType: String,
|
||||
val scope: String,
|
||||
val agent: AgentRuleAgentInfo,
|
||||
val createdAt: String,
|
||||
val updatedAt: String,
|
||||
)
|
||||
|
||||
data class AgentRuleListResult(
|
||||
val page: Int,
|
||||
val pageSize: Int,
|
||||
val total: Int,
|
||||
val list: List<AgentRuleEntity>,
|
||||
)
|
||||
|
||||
data class AgentRuleQuotaEntity(
|
||||
val agentId: Int,
|
||||
val agentTitle: String,
|
||||
val baseMaxCount: Int,
|
||||
val purchasedCount: Int,
|
||||
val totalMaxCount: Int,
|
||||
val currentCount: Int,
|
||||
val remainingCount: Int,
|
||||
val usagePercent: Double,
|
||||
)
|
||||
|
||||
// ========== Agent 规则 - Service 接口 ==========
|
||||
|
||||
/**
|
||||
* Agent 规则服务
|
||||
*/
|
||||
interface AgentRuleService {
|
||||
/**
|
||||
* 根据 OpenId 创建 Agent 规则
|
||||
* @param openId Agent 的 OpenId
|
||||
* @param rule 规则内容,不能为空
|
||||
* @throws ServiceException 创建失败时抛出异常(包括余额不足等情况)
|
||||
*/
|
||||
suspend fun createAgentRuleByOpenId(openId: String, rule: String)
|
||||
|
||||
/**
|
||||
* 修改 Agent 规则
|
||||
* @param id 规则ID
|
||||
* @param rule 新的规则内容,不能为空
|
||||
* @param openId Agent 的 OpenId,可选参数
|
||||
* @throws ServiceException 修改失败时抛出异常(包括余额不足等情况)
|
||||
*/
|
||||
suspend fun updateAgentRule(id: Int, rule: String, openId: String? = null)
|
||||
|
||||
/**
|
||||
* 删除 Agent 规则
|
||||
* @param id 规则ID
|
||||
* @throws ServiceException 删除失败时抛出异常
|
||||
*/
|
||||
suspend fun deleteAgentRule(id: Int)
|
||||
|
||||
/**
|
||||
* 查询 Agent 规则列表
|
||||
* @param openId Agent 的 OpenId
|
||||
* @param keyword 关键词搜索,可选参数
|
||||
* @param page 页码,默认 1
|
||||
* @param pageSize 每页数量,默认 10
|
||||
* @return 规则列表响应,包含分页信息和规则列表
|
||||
* @throws ServiceException 查询失败时抛出异常
|
||||
*/
|
||||
suspend fun getAgentRuleList(
|
||||
openId: String,
|
||||
keyword: String? = null,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 10
|
||||
): AgentRuleListResult
|
||||
|
||||
/**
|
||||
* 查询 Agent 规则配额使用情况
|
||||
* @param openId Agent 的 OpenId
|
||||
* @return 规则配额信息,包含基础配额、已购买配额、当前使用量等
|
||||
* @throws ServiceException 查询失败时抛出异常
|
||||
*/
|
||||
suspend fun getAgentRuleQuota(openId: String): AgentRuleQuotaEntity
|
||||
}
|
||||
|
||||
class AgentRuleServiceImpl : AgentRuleService {
|
||||
private val gson = Gson()
|
||||
|
||||
override suspend fun createAgentRuleByOpenId(openId: String, rule: String) {
|
||||
val body = CreateAgentRuleRequestBody(
|
||||
rule = rule,
|
||||
promptId = null,
|
||||
openId = openId
|
||||
)
|
||||
val resp = ApiClient.api.createAgentRule(body)
|
||||
if (!resp.isSuccessful) {
|
||||
val errorText = resp.errorBody()?.string()
|
||||
try {
|
||||
val err = gson.fromJson(errorText, InsufficientBalanceError::class.java)
|
||||
if (err != null && err.code == 35600) {
|
||||
throw ServiceException(err.message)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// ignore parse error
|
||||
}
|
||||
throw ServiceException("创建 Agent 规则失败: HTTP ${resp.code()}")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateAgentRule(id: Int, rule: String, openId: String?) {
|
||||
val body = UpdateAgentRuleRequestBody(
|
||||
id = id,
|
||||
rule = rule,
|
||||
promptId = null,
|
||||
openId = openId
|
||||
)
|
||||
val resp = ApiClient.api.updateAgentRule(body)
|
||||
if (!resp.isSuccessful) {
|
||||
val errorText = resp.errorBody()?.string()
|
||||
try {
|
||||
val err = gson.fromJson(errorText, InsufficientBalanceError::class.java)
|
||||
if (err != null && err.code == 35600) {
|
||||
throw ServiceException(err.message)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// ignore parse error
|
||||
}
|
||||
throw ServiceException("更新 Agent 规则失败: HTTP ${resp.code()}")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteAgentRule(id: Int) {
|
||||
val resp = ApiClient.api.deleteAgentRule(id)
|
||||
if (!resp.isSuccessful) {
|
||||
throw ServiceException("删除 Agent 规则失败: HTTP ${resp.code()}")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAgentRuleList(
|
||||
openId: String,
|
||||
keyword: String?,
|
||||
page: Int,
|
||||
pageSize: Int
|
||||
): AgentRuleListResult {
|
||||
val resp = ApiClient.api.getAgentRuleList(
|
||||
promptId = openId,
|
||||
rule = keyword,
|
||||
page = page,
|
||||
pageSize = pageSize
|
||||
)
|
||||
val data = resp.body()?.data ?: throw ServiceException("获取 Agent 规则列表失败")
|
||||
return data.toEntity()
|
||||
}
|
||||
|
||||
override suspend fun getAgentRuleQuota(openId: String): AgentRuleQuotaEntity {
|
||||
val resp = ApiClient.api.getAgentRuleQuota(openId)
|
||||
val data = resp.body()?.data ?: throw ServiceException("获取 Agent 规则配额失败")
|
||||
return data.toEntity()
|
||||
}
|
||||
|
||||
private fun AgentRuleListResponse.toEntity(): AgentRuleListResult = AgentRuleListResult(
|
||||
page = page,
|
||||
pageSize = pageSize,
|
||||
total = total,
|
||||
list = list.map { it.toEntity() }
|
||||
)
|
||||
|
||||
private fun AgentRule.toEntity(): AgentRuleEntity = AgentRuleEntity(
|
||||
id = id,
|
||||
rule = rule,
|
||||
creator = creator,
|
||||
creatorType = creatorType,
|
||||
scope = scope,
|
||||
agent = prompt.toEntity(),
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
|
||||
private fun AgentRuleAgent.toEntity(): AgentRuleAgentInfo = AgentRuleAgentInfo(
|
||||
id = id,
|
||||
title = title,
|
||||
avatar = avatar,
|
||||
)
|
||||
|
||||
private fun AgentRuleQuota.toEntity(): AgentRuleQuotaEntity = AgentRuleQuotaEntity(
|
||||
agentId = promptId,
|
||||
agentTitle = promptTitle,
|
||||
baseMaxCount = baseMaxCount,
|
||||
purchasedCount = purchasedCount,
|
||||
totalMaxCount = totalMaxCount,
|
||||
currentCount = currentCount,
|
||||
remainingCount = remainingCount,
|
||||
usagePercent = usagePercent,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -123,12 +123,16 @@ data class Comment(
|
||||
fun toCommentEntity(): CommentEntity {
|
||||
return CommentEntity(
|
||||
id = id,
|
||||
name = user.nickName,
|
||||
name = user.nickName ?: "未知用户",
|
||||
comment = content,
|
||||
date = ApiClient.dateFromApiString(createdAt),
|
||||
likes = likeCount,
|
||||
postId = postId,
|
||||
avatar = "${ApiClient.BASE_SERVER}${user.avatar}",
|
||||
avatar = if (user.avatar != null && user.avatar.isNotEmpty()) {
|
||||
"${ApiClient.BASE_SERVER}${user.avatar}"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
author = user.id,
|
||||
liked = isLiked,
|
||||
unread = isUnread,
|
||||
|
||||
@@ -14,6 +14,16 @@ interface DictService {
|
||||
* 获取字典列表
|
||||
*/
|
||||
suspend fun getDistList(keys: List<String>): List<DictItem>
|
||||
|
||||
/**
|
||||
* 获取外部字典项
|
||||
*/
|
||||
suspend fun getOutsideDictByKey(key: String): DictItem
|
||||
|
||||
/**
|
||||
* 获取外部字典列表
|
||||
*/
|
||||
suspend fun getOutsideDistList(keys: List<String>): List<DictItem>
|
||||
}
|
||||
|
||||
class DictServiceImpl : DictService {
|
||||
@@ -26,4 +36,13 @@ class DictServiceImpl : DictService {
|
||||
val resp = ApiClient.api.getDicts(keys.joinToString(","))
|
||||
return resp.body()?.list ?: throw Exception("failed to get dict list")
|
||||
}
|
||||
override suspend fun getOutsideDictByKey(key: String): DictItem {
|
||||
val resp = ApiClient.api.getOutsideDict(key)
|
||||
return resp.body()?.data ?: throw Exception("failed to get outside dict")
|
||||
}
|
||||
|
||||
override suspend fun getOutsideDistList(keys: List<String>): List<DictItem> {
|
||||
val resp = ApiClient.api.getOutsideDicts(keys.joinToString(","))
|
||||
return resp.body()?.list ?: throw Exception("failed to get outside dict list")
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import com.google.gson.annotations.SerializedName
|
||||
data class ListContainer<T>(
|
||||
// 总数
|
||||
@SerializedName("total")
|
||||
val total: Int,
|
||||
val total: Long,
|
||||
// 当前页
|
||||
@SerializedName("page")
|
||||
val page: Int,
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import com.aiosman.ravenow.entity.MomentEntity
|
||||
import com.aiosman.ravenow.entity.MomentImageEntity
|
||||
import com.aiosman.ravenow.entity.MomentVideoEntity
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.io.File
|
||||
|
||||
@@ -12,8 +13,12 @@ data class Moment(
|
||||
val id: Long,
|
||||
@SerializedName("textContent")
|
||||
val textContent: String,
|
||||
@SerializedName("url")
|
||||
val url: String? = null,
|
||||
@SerializedName("images")
|
||||
val images: List<Image>,
|
||||
val images: List<Image>? = null,
|
||||
@SerializedName("videos")
|
||||
val videos: List<Video>? = null,
|
||||
@SerializedName("user")
|
||||
val user: User,
|
||||
@SerializedName("likeCount")
|
||||
@@ -24,22 +29,53 @@ data class Moment(
|
||||
val favoriteCount: Long,
|
||||
@SerializedName("isFavorite")
|
||||
val isFavorite: Boolean,
|
||||
@SerializedName("shareCount")
|
||||
@SerializedName("isCommented")
|
||||
val isCommented: Boolean,
|
||||
@SerializedName("commentCount")
|
||||
val commentCount: Long,
|
||||
@SerializedName("time")
|
||||
val time: String,
|
||||
val time: String?,
|
||||
@SerializedName("isFollowed")
|
||||
val isFollowed: Boolean,
|
||||
// 新闻相关字段
|
||||
@SerializedName("isNews")
|
||||
val isNews: Boolean = false,
|
||||
@SerializedName("newsTitle")
|
||||
val newsTitle: String? = null,
|
||||
@SerializedName("newsUrl")
|
||||
val newsUrl: String? = null,
|
||||
@SerializedName("newsSource")
|
||||
val newsSource: String? = null,
|
||||
@SerializedName("newsCategory")
|
||||
val newsCategory: String? = null,
|
||||
@SerializedName("newsLanguage")
|
||||
val newsLanguage: String? = null,
|
||||
@SerializedName("newsContent")
|
||||
val newsContent: String? = null,
|
||||
@SerializedName("hasFullText")
|
||||
val hasFullText: Boolean = false,
|
||||
@SerializedName("summary")
|
||||
val summary: String? = null,
|
||||
@SerializedName("publishedAt")
|
||||
val publishedAt: String? = null,
|
||||
@SerializedName("imageCached")
|
||||
val imageCached: Boolean = false
|
||||
) {
|
||||
fun toMomentItem(): MomentEntity {
|
||||
return MomentEntity(
|
||||
id = id.toInt(),
|
||||
avatar = "${ApiClient.BASE_SERVER}${user.avatar}",
|
||||
nickname = user.nickName,
|
||||
avatar = if (user.avatar != null && user.avatar.isNotEmpty()) {
|
||||
"${ApiClient.BASE_SERVER}${user.avatar}"
|
||||
} else {
|
||||
"" // 如果头像为空,使用空字符串
|
||||
},
|
||||
nickname = user.nickName ?: "未知用户", // 如果昵称为空,使用默认值
|
||||
location = "Worldwide",
|
||||
time = ApiClient.dateFromApiString(time),
|
||||
time = if (time != null && time.isNotEmpty()) {
|
||||
ApiClient.dateFromApiString(time)
|
||||
} else {
|
||||
java.util.Date() // 如果时间为空,使用当前时间作为默认值
|
||||
},
|
||||
followStatus = isFollowed,
|
||||
momentTextContent = textContent,
|
||||
momentPicture = R.drawable.default_moment_img,
|
||||
@@ -47,19 +83,58 @@ data class Moment(
|
||||
commentCount = commentCount.toInt(),
|
||||
shareCount = 0,
|
||||
favoriteCount = favoriteCount.toInt(),
|
||||
images = images.map {
|
||||
images = images?.map {
|
||||
MomentImageEntity(
|
||||
url = "${ApiClient.BASE_SERVER}${it.url}",
|
||||
thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}",
|
||||
id = it.id,
|
||||
url = "${ApiClient.BASE_SERVER}${it.url}",
|
||||
originalUrl = it.originalUrl,
|
||||
directUrl = it.directUrl,
|
||||
thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}",
|
||||
thumbnailDirectUrl = it.thumbnailDirectUrl,
|
||||
small = it.small,
|
||||
smallDirectUrl = it.smallDirectUrl,
|
||||
medium = it.medium,
|
||||
mediumDirectUrl = it.mediumDirectUrl,
|
||||
large = it.large,
|
||||
largeDirectUrl = it.largeDirectUrl,
|
||||
blurHash = it.blurHash,
|
||||
width = it.width,
|
||||
height = it.height
|
||||
)
|
||||
},
|
||||
} ?: emptyList(),
|
||||
authorId = user.id.toInt(),
|
||||
liked = isLiked,
|
||||
isFavorite = isFavorite,
|
||||
url = url,
|
||||
videos = videos?.map {
|
||||
MomentVideoEntity(
|
||||
id = it.id,
|
||||
url = "${ApiClient.BASE_SERVER}${it.url}",
|
||||
originalUrl = it.originalUrl,
|
||||
directUrl = it.directUrl,
|
||||
thumbnailUrl = it.thumbnailUrl?.let { thumb -> "${ApiClient.BASE_SERVER}$thumb" },
|
||||
thumbnailDirectUrl = it.thumbnailDirectUrl,
|
||||
duration = it.duration,
|
||||
width = it.width,
|
||||
height = it.height,
|
||||
size = it.size,
|
||||
format = it.format,
|
||||
bitrate = it.bitrate,
|
||||
frameRate = it.frameRate
|
||||
)
|
||||
},
|
||||
// 新闻相关字段
|
||||
isNews = isNews,
|
||||
newsTitle = newsTitle ?: "",
|
||||
newsUrl = newsUrl ?: "",
|
||||
newsSource = newsSource ?: "",
|
||||
newsCategory = newsCategory ?: "",
|
||||
newsLanguage = newsLanguage ?: "",
|
||||
newsContent = newsContent ?: "",
|
||||
hasFullText = hasFullText,
|
||||
summary = summary,
|
||||
publishedAt = publishedAt,
|
||||
imageCached = imageCached
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -69,8 +144,26 @@ data class Image(
|
||||
val id: Long,
|
||||
@SerializedName("url")
|
||||
val url: String,
|
||||
@SerializedName("original_url")
|
||||
val originalUrl: String? = null,
|
||||
@SerializedName("directUrl")
|
||||
val directUrl: String? = null,
|
||||
@SerializedName("thumbnail")
|
||||
val thumbnail: String,
|
||||
@SerializedName("thumbnailDirectUrl")
|
||||
val thumbnailDirectUrl: String? = null,
|
||||
@SerializedName("small")
|
||||
val small: String? = null,
|
||||
@SerializedName("smallDirectUrl")
|
||||
val smallDirectUrl: String? = null,
|
||||
@SerializedName("medium")
|
||||
val medium: String? = null,
|
||||
@SerializedName("mediumDirectUrl")
|
||||
val mediumDirectUrl: String? = null,
|
||||
@SerializedName("large")
|
||||
val large: String? = null,
|
||||
@SerializedName("largeDirectUrl")
|
||||
val largeDirectUrl: String? = null,
|
||||
@SerializedName("blurHash")
|
||||
val blurHash: String?,
|
||||
@SerializedName("width")
|
||||
@@ -79,13 +172,68 @@ data class Image(
|
||||
val height: Int?
|
||||
)
|
||||
|
||||
data class Video(
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
@SerializedName("url")
|
||||
val url: String,
|
||||
@SerializedName("original_url")
|
||||
val originalUrl: String? = null,
|
||||
@SerializedName("directUrl")
|
||||
val directUrl: String? = null,
|
||||
@SerializedName("thumbnailUrl")
|
||||
val thumbnailUrl: String? = null,
|
||||
@SerializedName("thumbnailDirectUrl")
|
||||
val thumbnailDirectUrl: String? = null,
|
||||
@SerializedName("duration")
|
||||
val duration: Int? = null,
|
||||
@SerializedName("width")
|
||||
val width: Int? = null,
|
||||
@SerializedName("height")
|
||||
val height: Int? = null,
|
||||
@SerializedName("size")
|
||||
val size: Long? = null,
|
||||
@SerializedName("format")
|
||||
val format: String? = null,
|
||||
@SerializedName("bitrate")
|
||||
val bitrate: Int? = null,
|
||||
@SerializedName("frameRate")
|
||||
val frameRate: String? = null
|
||||
)
|
||||
|
||||
data class User(
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
@SerializedName("nickName")
|
||||
val nickName: String,
|
||||
val nickName: String?,
|
||||
@SerializedName("avatar")
|
||||
val avatar: String
|
||||
val avatar: String?,
|
||||
@SerializedName("avatarMedium")
|
||||
val avatarMedium: String? = null,
|
||||
@SerializedName("avatarLarge")
|
||||
val avatarLarge: String? = null,
|
||||
@SerializedName("originAvatar")
|
||||
val originAvatar: String? = null,
|
||||
@SerializedName("avatarDirectUrl")
|
||||
val avatarDirectUrl: String? = null,
|
||||
@SerializedName("avatarMediumDirectUrl")
|
||||
val avatarMediumDirectUrl: String? = null,
|
||||
@SerializedName("avatarLargeDirectUrl")
|
||||
val avatarLargeDirectUrl: String? = null,
|
||||
@SerializedName("aiAccount")
|
||||
val aiAccount: Boolean = false,
|
||||
@SerializedName("aiRoleAvatar")
|
||||
val aiRoleAvatar: String? = null,
|
||||
@SerializedName("aiRoleAvatarMedium")
|
||||
val aiRoleAvatarMedium: String? = null,
|
||||
@SerializedName("aiRoleAvatarLarge")
|
||||
val aiRoleAvatarLarge: String? = null,
|
||||
@SerializedName("aiRoleAvatarDirectUrl")
|
||||
val aiRoleAvatarDirectUrl: String? = null,
|
||||
@SerializedName("aiRoleAvatarMediumDirectUrl")
|
||||
val aiRoleAvatarMediumDirectUrl: String? = null,
|
||||
@SerializedName("aiRoleAvatarLargeDirectUrl")
|
||||
val aiRoleAvatarLargeDirectUrl: String? = null
|
||||
)
|
||||
|
||||
data class UploadImage(
|
||||
|
||||
414
app/src/main/java/com/aiosman/ravenow/data/PointService.kt
Normal file
414
app/src/main/java/com/aiosman/ravenow/data/PointService.kt
Normal file
@@ -0,0 +1,414 @@
|
||||
package com.aiosman.ravenow.data
|
||||
|
||||
import android.content.Context
|
||||
import com.aiosman.ravenow.AppStore
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import com.aiosman.ravenow.data.api.DictItem
|
||||
import com.aiosman.ravenow.data.api.PointsBalance
|
||||
import com.aiosman.ravenow.data.api.PointsChangeLogsResponse
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 积分服务
|
||||
*
|
||||
* 提供积分余额查询和积分变更日志查询功能
|
||||
*/
|
||||
object PointService {
|
||||
|
||||
// ========== 定价表(字典 points_rules)相关 ==========
|
||||
private val dictService: DictService = DictServiceImpl()
|
||||
private val gson = Gson()
|
||||
|
||||
/**
|
||||
* 积分规则key常量
|
||||
* 对应积分规则JSON中的key值
|
||||
*/
|
||||
object PointsRuleKey {
|
||||
// 获得积分类型(add)
|
||||
/** 每日登录奖励 */
|
||||
const val DAILY_LOGIN = "daily_login"
|
||||
/** 用户注册奖励 */
|
||||
const val USER_REGISTER = "user_register"
|
||||
|
||||
// 消费积分类型(sub)
|
||||
/** 添加Agent记忆 */
|
||||
const val ADD_AGENT_MEMORY = "add_agent_memory"
|
||||
/** 增加房间容量 */
|
||||
const val ADD_ROOM_CAP = "add_room_cap"
|
||||
/** 创建房间 */
|
||||
const val CREATE_ROOM = "create_room"
|
||||
/** 创建定时事件 */
|
||||
const val CREATE_SCHEDULE_EVENT = "create_schedule_event"
|
||||
/** 房间私密模式 */
|
||||
const val ROOM_PRIVATE = "room_private"
|
||||
/** Agent私密模式 */
|
||||
const val SPEND_AGENT_PRIVATE = "spend_agent_private"
|
||||
/** 自定义聊天背景 */
|
||||
const val SPEND_CHAT_BACKGROUND = "spend_chat_background"
|
||||
/** 房间记忆添加 */
|
||||
const val SPEND_ROOM_MEMORY = "spend_room_memory"
|
||||
}
|
||||
|
||||
sealed class RuleAmount {
|
||||
data class Fixed(val value: Int) : RuleAmount()
|
||||
data class Range(val min: Int, val max: Int) : RuleAmount()
|
||||
}
|
||||
data class PointsRules(
|
||||
val add: Map<String, RuleAmount>,
|
||||
val sub: Map<String, RuleAmount>
|
||||
)
|
||||
|
||||
private val _pointsRules = MutableStateFlow<PointsRules?>(null)
|
||||
val pointsRules: StateFlow<PointsRules?> = _pointsRules.asStateFlow()
|
||||
|
||||
suspend fun refreshPointsRules(key: String = "points_rules") {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val dict = dictService.getDictByKey(key)
|
||||
val rules = parsePointsRules(dict)
|
||||
_pointsRules.value = rules
|
||||
} catch (_: Exception) {
|
||||
_pointsRules.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 群聊人数限制(字典 points-rule)相关 ==========
|
||||
/**
|
||||
* 群聊人数限制配置
|
||||
* @param defaultMaxTotal 初始最大人数(默认值)
|
||||
* @param maxTotal 最大人数(上限)
|
||||
*/
|
||||
data class RoomMaxMembers(
|
||||
val defaultMaxTotal: Int,
|
||||
val maxTotal: Int
|
||||
)
|
||||
|
||||
private val _roomMaxMembers = MutableStateFlow<RoomMaxMembers?>(null)
|
||||
val roomMaxMembers: StateFlow<RoomMaxMembers?> = _roomMaxMembers.asStateFlow()
|
||||
|
||||
/**
|
||||
* 刷新群聊人数限制配置(从外部字典表加载 points-rule)
|
||||
* 加载时机与 refreshPointsRules 一致
|
||||
*/
|
||||
suspend fun refreshRoomMaxMembers(key: String = "points-rule") {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val dict = dictService.getOutsideDictByKey(key)
|
||||
val config = parseRoomMaxMembers(dict)
|
||||
_roomMaxMembers.value = config
|
||||
} catch (_: Exception) {
|
||||
_roomMaxMembers.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析群聊人数限制配置
|
||||
* 解析格式:{"room":{"default":{"max-total":5},"max":{"max-total":200}}}
|
||||
*/
|
||||
private fun parseRoomMaxMembers(dict: DictItem): RoomMaxMembers? {
|
||||
val raw = dict.value
|
||||
val jsonStr = when (raw) {
|
||||
is String -> raw
|
||||
else -> gson.toJson(raw)
|
||||
}
|
||||
return try {
|
||||
val root = JsonParser.parseString(jsonStr).asJsonObject
|
||||
val roomObj = root.getAsJsonObject("room")
|
||||
val defaultObj = roomObj?.getAsJsonObject("default")
|
||||
val maxObj = roomObj?.getAsJsonObject("max")
|
||||
|
||||
val defaultMaxTotal = defaultObj?.get("max-total")?.takeIf { it.isJsonPrimitive }?.asInt ?: 5
|
||||
val maxTotal = maxObj?.get("max-total")?.takeIf { it.isJsonPrimitive }?.asInt ?: 200
|
||||
|
||||
RoomMaxMembers(
|
||||
defaultMaxTotal = defaultMaxTotal,
|
||||
maxTotal = maxTotal
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parsePointsRules(dict: DictItem): PointsRules? {
|
||||
val raw = dict.value
|
||||
val jsonStr = when (raw) {
|
||||
is String -> raw
|
||||
else -> gson.toJson(raw)
|
||||
}
|
||||
return try {
|
||||
val root = JsonParser.parseString(jsonStr).asJsonObject
|
||||
fun parseBlock(block: JsonObject?): Map<String, RuleAmount> {
|
||||
if (block == null) return emptyMap()
|
||||
return block.entrySet().associate { entry ->
|
||||
val key = entry.key
|
||||
val v: JsonElement = entry.value
|
||||
val amount: RuleAmount = when {
|
||||
v.isJsonPrimitive && v.asJsonPrimitive.isNumber -> {
|
||||
RuleAmount.Fixed(v.asInt)
|
||||
}
|
||||
v.isJsonPrimitive && v.asJsonPrimitive.isString -> {
|
||||
val s = v.asString.trim()
|
||||
if (s.contains("-")) {
|
||||
val parts = s.split("-")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
val min = parts.getOrNull(0)?.toIntOrNull()
|
||||
val max = parts.getOrNull(1)?.toIntOrNull()
|
||||
when {
|
||||
min != null && max != null -> RuleAmount.Range(min, max)
|
||||
min != null -> RuleAmount.Fixed(min)
|
||||
else -> RuleAmount.Fixed(0)
|
||||
}
|
||||
} else {
|
||||
RuleAmount.Fixed(s.toIntOrNull() ?: 0)
|
||||
}
|
||||
}
|
||||
v.isJsonObject -> {
|
||||
val obj = v.asJsonObject
|
||||
val min = obj.get("min")?.takeIf { it.isJsonPrimitive }?.asInt
|
||||
val max = obj.get("max")?.takeIf { it.isJsonPrimitive }?.asInt
|
||||
val value = obj.get("value")?.takeIf { it.isJsonPrimitive }?.asInt
|
||||
when {
|
||||
min != null && max != null -> RuleAmount.Range(min, max)
|
||||
value != null -> RuleAmount.Fixed(value)
|
||||
else -> RuleAmount.Fixed(0)
|
||||
}
|
||||
}
|
||||
else -> RuleAmount.Fixed(0)
|
||||
}
|
||||
key to amount
|
||||
}
|
||||
}
|
||||
val addObj = root.getAsJsonObject("add")
|
||||
val subObj = root.getAsJsonObject("sub")
|
||||
PointsRules(
|
||||
add = parseBlock(addObj),
|
||||
sub = parseBlock(subObj)
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// 全局可观察的积分余额(仅内存,不落盘)
|
||||
private val _pointsBalance = MutableStateFlow<PointsBalance?>(null)
|
||||
val pointsBalance: StateFlow<PointsBalance?> = _pointsBalance.asStateFlow()
|
||||
|
||||
// 当前已加载的用户ID,用于处理用户切换
|
||||
@Volatile private var currentUserId: Int? = null
|
||||
|
||||
/** 设置当前用户ID;当用户切换时,会清空旧的积分数据以避免串号 */
|
||||
fun setCurrentUser(userId: Int?) {
|
||||
if (currentUserId != userId) {
|
||||
currentUserId = userId
|
||||
_pointsBalance.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空内存中的积分状态(用于登出或用户切换) */
|
||||
fun clear() {
|
||||
_pointsBalance.value = null
|
||||
currentUserId = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新当前用户的积分余额(进入应用并完成登录态初始化后调用)
|
||||
* - 若为游客或无 token,则清空并返回
|
||||
*/
|
||||
suspend fun refreshMyPointsBalance(includeStatistics: Boolean = true) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (AppStore.isGuest || AppStore.token == null) {
|
||||
clear()
|
||||
return@withContext
|
||||
}
|
||||
val balance = getMyPointsBalance(includeStatistics)
|
||||
_pointsBalance.value = balance
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户积分余额
|
||||
*
|
||||
* @param includeStatistics 是否包含统计信息(累计获得和累计消费),默认 true
|
||||
* @return 积分余额信息,包含当前余额和可选的统计数据
|
||||
* @throws Exception 网络请求失败或服务器返回错误
|
||||
*
|
||||
* 示例:
|
||||
* ```kotlin
|
||||
* try {
|
||||
* // 获取包含统计信息的积分余额
|
||||
* val balance = PointService.getMyPointsBalance()
|
||||
* println("当前余额: ${balance.balance}")
|
||||
* println("累计获得: ${balance.totalEarned}")
|
||||
* println("累计消费: ${balance.totalSpent}")
|
||||
*
|
||||
* // 仅获取当前余额
|
||||
* val simpleBalance = PointService.getMyPointsBalance(includeStatistics = false)
|
||||
* println("当前余额: ${simpleBalance.balance}")
|
||||
* } catch (e: Exception) {
|
||||
* println("获取积分余额失败: ${e.message}")
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
suspend fun getMyPointsBalance(includeStatistics: Boolean = true): PointsBalance {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val response = ApiClient.api.getMyPointsBalance(includeStatistics)
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.data ?: throw Exception("响应数据为空")
|
||||
} else {
|
||||
throw Exception("获取积分余额失败: ${response.code()} ${response.message()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户积分变更日志列表
|
||||
*
|
||||
* @param page 页码,默认 1
|
||||
* @param pageSize 每页数量,默认 20
|
||||
* @param changeType 变更类型筛选("add": 增加, "subtract": 减少, "adjust": 调整),null 表示不筛选
|
||||
* @param startTime 开始时间,格式:YYYY-MM-DD,null 表示不限制
|
||||
* @param endTime 结束时间,格式:YYYY-MM-DD,null 表示不限制
|
||||
* @return 积分变更日志列表响应,包含日志列表和分页信息
|
||||
* @throws Exception 网络请求失败或服务器返回错误
|
||||
*
|
||||
* 示例:
|
||||
* ```kotlin
|
||||
* try {
|
||||
* // 获取最近的积分变更日志
|
||||
* val logs = PointService.getMyPointsChangeLogs(page = 1, pageSize = 20)
|
||||
* println("总记录数: ${logs.total}")
|
||||
* logs.list.forEach { log ->
|
||||
* println("${log.createdAt}: ${log.changeType} ${log.amount} (${log.reason})")
|
||||
* }
|
||||
*
|
||||
* // 筛选积分增加记录
|
||||
* val earnLogs = PointService.getMyPointsChangeLogs(changeType = "add")
|
||||
*
|
||||
* // 查询指定时间范围的记录
|
||||
* val rangeLogs = PointService.getMyPointsChangeLogs(
|
||||
* startTime = "2024-01-01",
|
||||
* endTime = "2024-01-31"
|
||||
* )
|
||||
* } catch (e: Exception) {
|
||||
* println("获取积分变更日志失败: ${e.message}")
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
suspend fun getMyPointsChangeLogs(
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20,
|
||||
changeType: String? = null,
|
||||
startTime: String? = null,
|
||||
endTime: String? = null
|
||||
): PointsChangeLogsResponse {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val response =
|
||||
ApiClient.api.getMyPointsChangeLogs(
|
||||
page = page,
|
||||
pageSize = pageSize,
|
||||
changeType = changeType,
|
||||
startTime = startTime,
|
||||
endTime = endTime
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
response.body() ?: throw Exception("响应数据为空")
|
||||
} else {
|
||||
throw Exception("获取积分变更日志失败: ${response.code()} ${response.message()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 积分变更类型常量 */
|
||||
object ChangeType {
|
||||
/** 积分增加 */
|
||||
const val ADD = "add"
|
||||
/** 积分减少 */
|
||||
const val SUBTRACT = "subtract"
|
||||
/** 积分调整 */
|
||||
const val ADJUST = "adjust"
|
||||
}
|
||||
|
||||
/** 积分变更原因常量 */
|
||||
object ChangeReason {
|
||||
// 获得积分类型
|
||||
/** 新用户注册奖励 */
|
||||
const val EARN_REGISTER = "earn_register"
|
||||
/** 每日签到奖励 */
|
||||
const val EARN_DAILY = "earn_daily"
|
||||
/** 任务完成奖励 */
|
||||
const val EARN_TASK = "earn_task"
|
||||
/** 邀请好友奖励 */
|
||||
const val EARN_INVITE = "earn_invite"
|
||||
/** 充值获得 */
|
||||
const val EARN_RECHARGE = "earn_recharge"
|
||||
|
||||
// 消费积分类型
|
||||
/** 创建群聊 */
|
||||
const val SPEND_GROUP_CREATE = "spend_group_create"
|
||||
/** 扩容群聊 */
|
||||
const val SPEND_GROUP_EXPAND = "spend_group_expand"
|
||||
/** Agent 私密模式 */
|
||||
const val SPEND_AGENT_PRIVATE = "spend_agent_private"
|
||||
/** Agent 记忆添加 */
|
||||
const val SPEND_AGENT_MEMORY = "spend_agent_memory"
|
||||
/** 房间记忆添加 */
|
||||
const val SPEND_ROOM_MEMORY = "spend_room_memory"
|
||||
/** 自定义聊天背景 */
|
||||
const val SPEND_CHAT_BACKGROUND = "spend_chat_background"
|
||||
/** 定时事件解锁 */
|
||||
const val SPEND_SCHEDULE_EVENT = "spend_schedule_event"
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取变更原因的描述(支持多语言)
|
||||
*
|
||||
* @param context Context 用于获取资源
|
||||
* @param reason 变更原因代码
|
||||
* @return 本地化描述
|
||||
*/
|
||||
fun getReasonDescription(context: Context, reason: String): String {
|
||||
val resourceId = when (reason) {
|
||||
ChangeReason.EARN_REGISTER -> R.string.earn_register
|
||||
ChangeReason.EARN_DAILY -> R.string.earn_daily
|
||||
ChangeReason.EARN_TASK -> R.string.earn_task
|
||||
ChangeReason.EARN_INVITE -> R.string.earn_invite
|
||||
ChangeReason.EARN_RECHARGE -> R.string.earn_recharge
|
||||
ChangeReason.SPEND_GROUP_CREATE -> R.string.spend_group_create
|
||||
ChangeReason.SPEND_GROUP_EXPAND -> R.string.spend_group_expand
|
||||
ChangeReason.SPEND_AGENT_PRIVATE -> R.string.spend_agent_private
|
||||
ChangeReason.SPEND_AGENT_MEMORY -> R.string.spend_agent_memory
|
||||
ChangeReason.SPEND_ROOM_MEMORY -> R.string.spend_room_memory
|
||||
ChangeReason.SPEND_CHAT_BACKGROUND -> R.string.spend_chat_background
|
||||
ChangeReason.SPEND_SCHEDULE_EVENT -> R.string.spend_schedule_event
|
||||
else -> null
|
||||
}
|
||||
return resourceId?.let { context.getString(it) } ?: reason // 未知原因,返回原始代码
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取变更类型的中文描述
|
||||
*
|
||||
* @param changeType 变更类型代码
|
||||
* @return 中文描述
|
||||
*/
|
||||
fun getChangeTypeDescription(changeType: String): String {
|
||||
return when (changeType) {
|
||||
ChangeType.ADD -> "增加"
|
||||
ChangeType.SUBTRACT -> "减少"
|
||||
ChangeType.ADJUST -> "调整"
|
||||
else -> changeType
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package com.aiosman.ravenow.data
|
||||
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* 推荐接口响应
|
||||
*/
|
||||
data class RecommendationsResponse(
|
||||
@SerializedName("success")
|
||||
val success: Boolean,
|
||||
@SerializedName("data")
|
||||
val data: List<RecommendationItem>
|
||||
)
|
||||
|
||||
/**
|
||||
* 推荐项
|
||||
*/
|
||||
data class RecommendationItem(
|
||||
@SerializedName("type")
|
||||
val type: String,
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
@SerializedName("data")
|
||||
val data: Any // 根据type字段动态解析为不同类型
|
||||
) {
|
||||
/**
|
||||
* 将data字段转换为PromptRecommendationData
|
||||
*/
|
||||
fun toPromptData(): PromptRecommendationData? {
|
||||
if (type != "prompt") return null
|
||||
return try {
|
||||
val gson = Gson()
|
||||
val jsonString = gson.toJson(data)
|
||||
gson.fromJson(jsonString, PromptRecommendationData::class.java)
|
||||
} catch (e: JsonSyntaxException) {
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将data字段转换为PostRecommendationData (Moment)
|
||||
*/
|
||||
fun toPostData(): PostRecommendationData? {
|
||||
if (type !in listOf("post_normal", "post_video", "post_news", "post_music")) return null
|
||||
return try {
|
||||
val gson = Gson()
|
||||
val jsonString = gson.toJson(data)
|
||||
gson.fromJson(jsonString, PostRecommendationData::class.java)
|
||||
} catch (e: JsonSyntaxException) {
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将data字段转换为RoomRecommendationData
|
||||
*/
|
||||
fun toRoomData(): RoomRecommendationData? {
|
||||
if (type != "room") return null
|
||||
return try {
|
||||
val gson = Gson()
|
||||
val jsonString = gson.toJson(data)
|
||||
gson.fromJson(jsonString, RoomRecommendationData::class.java)
|
||||
} catch (e: JsonSyntaxException) {
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt类型推荐数据
|
||||
*/
|
||||
data class PromptRecommendationData(
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
@SerializedName("title")
|
||||
val title: String,
|
||||
@SerializedName("desc")
|
||||
val desc: String,
|
||||
@SerializedName("createdAt")
|
||||
val createdAt: String,
|
||||
@SerializedName("updatedAt")
|
||||
val updatedAt: String,
|
||||
@SerializedName("avatar")
|
||||
val avatar: String? = null,
|
||||
@SerializedName("avatarDirectUrl")
|
||||
val avatarDirectUrl: String? = null,
|
||||
@SerializedName("author")
|
||||
val author: String? = null,
|
||||
@SerializedName("isPublic")
|
||||
val isPublic: Boolean = false,
|
||||
@SerializedName("openId")
|
||||
val openId: String,
|
||||
@SerializedName("breakMode")
|
||||
val breakMode: Boolean = false,
|
||||
@SerializedName("useCount")
|
||||
val useCount: Int? = null,
|
||||
@SerializedName("translations")
|
||||
val translations: Map<String, Map<String, String>>? = null,
|
||||
@SerializedName("translation")
|
||||
val translation: Map<String, String>? = null,
|
||||
@SerializedName("details")
|
||||
val details: PromptDetails? = null,
|
||||
@SerializedName("aiUserProfile")
|
||||
val aiUserProfile: AIUserProfile? = null,
|
||||
@SerializedName("creatorProfile")
|
||||
val creatorProfile: CreatorProfile? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Prompt详细信息
|
||||
*/
|
||||
data class PromptDetails(
|
||||
@SerializedName("gender")
|
||||
val gender: String? = null,
|
||||
@SerializedName("age")
|
||||
val age: Int? = null,
|
||||
@SerializedName("mbti")
|
||||
val mbti: String? = null,
|
||||
@SerializedName("constellation")
|
||||
val constellation: String? = null,
|
||||
@SerializedName("nickname")
|
||||
val nickname: String? = null,
|
||||
@SerializedName("birthday")
|
||||
val birthday: String? = null,
|
||||
@SerializedName("signature")
|
||||
val signature: String? = null,
|
||||
@SerializedName("nationality")
|
||||
val nationality: String? = null,
|
||||
@SerializedName("mainLanguage")
|
||||
val mainLanguage: String? = null,
|
||||
@SerializedName("worldview")
|
||||
val worldview: String? = null,
|
||||
@SerializedName("habits")
|
||||
val habits: String? = null,
|
||||
@SerializedName("hobbies")
|
||||
val hobbies: String? = null,
|
||||
@SerializedName("occupation")
|
||||
val occupation: String? = null,
|
||||
@SerializedName("expertise")
|
||||
val expertise: String? = null,
|
||||
@SerializedName("socialActivityLvl")
|
||||
val socialActivityLvl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* AI用户资料
|
||||
*/
|
||||
data class AIUserProfile(
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
@SerializedName("username")
|
||||
val username: String? = null,
|
||||
@SerializedName("nickname")
|
||||
val nickname: String,
|
||||
@SerializedName("avatar")
|
||||
val avatar: String? = null,
|
||||
@SerializedName("avatarMedium")
|
||||
val avatarMedium: String? = null,
|
||||
@SerializedName("avatarLarge")
|
||||
val avatarLarge: String? = null,
|
||||
@SerializedName("avatarDirectUrl")
|
||||
val avatarDirectUrl: String? = null,
|
||||
@SerializedName("avatarMediumDirectUrl")
|
||||
val avatarMediumDirectUrl: String? = null,
|
||||
@SerializedName("avatarLargeDirectUrl")
|
||||
val avatarLargeDirectUrl: String? = null,
|
||||
@SerializedName("bio")
|
||||
val bio: String? = null,
|
||||
@SerializedName("trtcUserId")
|
||||
val trtcUserId: String? = null,
|
||||
@SerializedName("chatAIId")
|
||||
val chatAIId: String? = null,
|
||||
@SerializedName("aiAccount")
|
||||
val aiAccount: Boolean = true,
|
||||
@SerializedName("aiRoleAvatar")
|
||||
val aiRoleAvatar: String? = null,
|
||||
@SerializedName("aiRoleAvatarMedium")
|
||||
val aiRoleAvatarMedium: String? = null,
|
||||
@SerializedName("aiRoleAvatarLarge")
|
||||
val aiRoleAvatarLarge: String? = null,
|
||||
@SerializedName("aiRoleAvatarDirectUrl")
|
||||
val aiRoleAvatarDirectUrl: String? = null,
|
||||
@SerializedName("aiRoleAvatarMediumDirectUrl")
|
||||
val aiRoleAvatarMediumDirectUrl: String? = null,
|
||||
@SerializedName("aiRoleAvatarLargeDirectUrl")
|
||||
val aiRoleAvatarLargeDirectUrl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 创建者资料
|
||||
*/
|
||||
data class CreatorProfile(
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
@SerializedName("username")
|
||||
val username: String? = null,
|
||||
@SerializedName("nickname")
|
||||
val nickname: String,
|
||||
@SerializedName("avatar")
|
||||
val avatar: String? = null,
|
||||
@SerializedName("avatarMedium")
|
||||
val avatarMedium: String? = null,
|
||||
@SerializedName("avatarLarge")
|
||||
val avatarLarge: String? = null,
|
||||
@SerializedName("avatarDirectUrl")
|
||||
val avatarDirectUrl: String? = null,
|
||||
@SerializedName("avatarMediumDirectUrl")
|
||||
val avatarMediumDirectUrl: String? = null,
|
||||
@SerializedName("avatarLargeDirectUrl")
|
||||
val avatarLargeDirectUrl: String? = null,
|
||||
@SerializedName("bio")
|
||||
val bio: String? = null,
|
||||
@SerializedName("trtcUserId")
|
||||
val trtcUserId: String? = null,
|
||||
@SerializedName("chatAIId")
|
||||
val chatAIId: String? = null,
|
||||
@SerializedName("aiAccount")
|
||||
val aiAccount: Boolean = false,
|
||||
@SerializedName("aiRoleAvatar")
|
||||
val aiRoleAvatar: String? = null,
|
||||
@SerializedName("aiRoleAvatarMedium")
|
||||
val aiRoleAvatarMedium: String? = null,
|
||||
@SerializedName("aiRoleAvatarLarge")
|
||||
val aiRoleAvatarLarge: String? = null,
|
||||
@SerializedName("aiRoleAvatarDirectUrl")
|
||||
val aiRoleAvatarDirectUrl: String? = null,
|
||||
@SerializedName("aiRoleAvatarMediumDirectUrl")
|
||||
val aiRoleAvatarMediumDirectUrl: String? = null,
|
||||
@SerializedName("aiRoleAvatarLargeDirectUrl")
|
||||
val aiRoleAvatarLargeDirectUrl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Post类型推荐数据(复用Moment中的结构)
|
||||
* 支持 post_normal, post_video, post_news, post_music
|
||||
*/
|
||||
typealias PostRecommendationData = Moment
|
||||
|
||||
/**
|
||||
* Room类型推荐数据
|
||||
*/
|
||||
data class RoomRecommendationData(
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
@SerializedName("name")
|
||||
val name: String,
|
||||
@SerializedName("description")
|
||||
val description: String,
|
||||
@SerializedName("createdAt")
|
||||
val createdAt: String? = null,
|
||||
@SerializedName("updatedAt")
|
||||
val updatedAt: String? = null,
|
||||
@SerializedName("cover")
|
||||
val cover: String? = null,
|
||||
@SerializedName("coverDirectUrl")
|
||||
val coverDirectUrl: String? = null,
|
||||
@SerializedName("avatar")
|
||||
val avatar: String? = null,
|
||||
@SerializedName("avatarDirectUrl")
|
||||
val avatarDirectUrl: String? = null,
|
||||
@SerializedName("recommendBanner")
|
||||
val recommendBanner: String? = null,
|
||||
@SerializedName("trtcRoomId")
|
||||
val trtcRoomId: String? = null,
|
||||
@SerializedName("trtcType")
|
||||
val trtcType: String? = null,
|
||||
@SerializedName("isRecommended")
|
||||
val isRecommended: Boolean = false,
|
||||
@SerializedName("allowInHot")
|
||||
val allowInHot: Boolean = false,
|
||||
@SerializedName("language")
|
||||
val language: String? = null,
|
||||
@SerializedName("maxTotal")
|
||||
val maxTotal: Int? = null,
|
||||
@SerializedName("privateFeePaid")
|
||||
val privateFeePaid: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* 推荐Service接口
|
||||
*/
|
||||
interface RecommendationService {
|
||||
/**
|
||||
* 获取推荐列表
|
||||
* @param pool 推荐池名称(可选)
|
||||
* @param count 返回数量(可选,默认10,最大50)
|
||||
* @param lang 语言代码(可选)
|
||||
* @param promptReplaceTrans 是否用翻译覆盖原字段(可选,仅对prompt类型生效)
|
||||
* @return 推荐项列表
|
||||
*/
|
||||
suspend fun getRecommendations(
|
||||
pool: String? = null,
|
||||
count: Int? = null,
|
||||
lang: String? = null,
|
||||
promptReplaceTrans: Boolean? = null
|
||||
): List<RecommendationItem>
|
||||
}
|
||||
|
||||
/**
|
||||
* 推荐Service实现
|
||||
*/
|
||||
class RecommendationServiceImpl : RecommendationService {
|
||||
override suspend fun getRecommendations(
|
||||
pool: String?,
|
||||
count: Int?,
|
||||
lang: String?,
|
||||
promptReplaceTrans: Boolean?
|
||||
): List<RecommendationItem> {
|
||||
val resp = ApiClient.api.getRecommendations(
|
||||
pool = pool,
|
||||
count = count,
|
||||
lang = lang,
|
||||
promptReplaceTrans = promptReplaceTrans
|
||||
)
|
||||
if (resp.isSuccessful) {
|
||||
val body = resp.body()
|
||||
if (body != null && body.success) {
|
||||
return body.data
|
||||
}
|
||||
}
|
||||
throw ServiceException("Failed to get recommendations")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CreatorProfile 扩展函数,转换为 CreatorProfileEntity
|
||||
*/
|
||||
fun CreatorProfile.toCreatorProfileEntity(): com.aiosman.ravenow.entity.CreatorProfileEntity {
|
||||
return com.aiosman.ravenow.entity.CreatorProfileEntity(
|
||||
id = id,
|
||||
username = username,
|
||||
nickname = nickname,
|
||||
avatar = avatar?.let {
|
||||
if (it.isNotEmpty()) "${ApiClient.BASE_SERVER}$it" else null
|
||||
},
|
||||
bio = bio,
|
||||
trtcUserId = trtcUserId,
|
||||
chatAIId = chatAIId,
|
||||
aiAccount = aiAccount
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,47 @@
|
||||
package com.aiosman.ravenow.data
|
||||
|
||||
import com.aiosman.ravenow.AppStore
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import com.aiosman.ravenow.entity.AgentEntity
|
||||
import com.aiosman.ravenow.data.api.CreateRoomRuleRequestBody
|
||||
import com.aiosman.ravenow.data.api.UpdateRoomRuleRequestBody
|
||||
import com.aiosman.ravenow.data.api.RoomRuleQuota
|
||||
import com.aiosman.ravenow.data.api.RoomRule
|
||||
import com.aiosman.ravenow.data.api.RoomRuleCreator
|
||||
import com.aiosman.ravenow.entity.AddAgentToRoomFailedItemEntity
|
||||
import com.aiosman.ravenow.entity.AddAgentToRoomItemEntity
|
||||
import com.aiosman.ravenow.entity.AddAgentToRoomResultEntity
|
||||
import com.aiosman.ravenow.entity.AddUserToRoomFailedItemEntity
|
||||
import com.aiosman.ravenow.entity.AddUserToRoomItemEntity
|
||||
import com.aiosman.ravenow.entity.AddUserToRoomResultEntity
|
||||
import com.aiosman.ravenow.entity.CreatorEntity
|
||||
import com.aiosman.ravenow.entity.ProfileEntity
|
||||
import com.aiosman.ravenow.entity.RemoveAgentFromRoomFailedItemEntity
|
||||
import com.aiosman.ravenow.entity.RemoveAgentFromRoomItemEntity
|
||||
import com.aiosman.ravenow.entity.RemoveAgentFromRoomResultEntity
|
||||
import com.aiosman.ravenow.entity.RemoveUserFromRoomFailedItemEntity
|
||||
import com.aiosman.ravenow.entity.RemoveUserFromRoomItemEntity
|
||||
import com.aiosman.ravenow.entity.RemoveUserFromRoomResultEntity
|
||||
import com.aiosman.ravenow.entity.RoomEntity
|
||||
import com.aiosman.ravenow.entity.RoomRuleEntity
|
||||
import com.aiosman.ravenow.entity.RoomRuleCreatorEntity
|
||||
import com.aiosman.ravenow.entity.RoomRuleQuotaEntity
|
||||
import com.aiosman.ravenow.entity.UsersEntity
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* 房间内的智能体信息(PromptTemplate)
|
||||
*/
|
||||
data class PromptTemplate(
|
||||
@SerializedName("id")
|
||||
val id: Int,
|
||||
@SerializedName("openId")
|
||||
val openId: String,
|
||||
@SerializedName("title")
|
||||
val title: String,
|
||||
@SerializedName("desc")
|
||||
val desc: String,
|
||||
@SerializedName("avatar")
|
||||
val avatar: String
|
||||
)
|
||||
|
||||
data class Room(
|
||||
@SerializedName("id")
|
||||
val id: Int,
|
||||
@@ -34,12 +67,26 @@ data class Room(
|
||||
val creator: Creator,
|
||||
@SerializedName("userCount")
|
||||
val userCount: Int,
|
||||
@SerializedName("totalMemberCount")
|
||||
val totalMemberCount: Int? = null,
|
||||
@SerializedName("maxMemberLimit")
|
||||
val maxMemberLimit: Int,
|
||||
@SerializedName("maxTotal")
|
||||
val maxTotal: Int? = null,
|
||||
@SerializedName("systemMaxTotal")
|
||||
val systemMaxTotal: Int? = null,
|
||||
@SerializedName("canJoin")
|
||||
val canJoin: Boolean,
|
||||
@SerializedName("canJoinCode")
|
||||
val canJoinCode: Int,
|
||||
@SerializedName("privateFeePaid")
|
||||
val privateFeePaid: Boolean? = null,
|
||||
@SerializedName("prompts")
|
||||
val prompts: List<PromptTemplate>? = null,
|
||||
@SerializedName("createdAt")
|
||||
val createdAt: String? = null,
|
||||
@SerializedName("updatedAt")
|
||||
val updatedAt: String? = null,
|
||||
@SerializedName("users")
|
||||
val users: List<Users>
|
||||
|
||||
@@ -58,9 +105,24 @@ data class Room(
|
||||
allowInHot = allowInHot,
|
||||
creator = creator.toCreatorEntity(),
|
||||
userCount = userCount,
|
||||
totalMemberCount = totalMemberCount,
|
||||
maxMemberLimit = maxMemberLimit,
|
||||
maxTotal = maxTotal,
|
||||
systemMaxTotal = systemMaxTotal,
|
||||
canJoin = canJoin,
|
||||
canJoinCode = canJoinCode,
|
||||
privateFeePaid = privateFeePaid ?: false,
|
||||
prompts = prompts?.map {
|
||||
com.aiosman.ravenow.entity.PromptTemplateEntity(
|
||||
id = it.id,
|
||||
openId = it.openId,
|
||||
title = it.title,
|
||||
desc = it.desc,
|
||||
avatar = it.avatar
|
||||
)
|
||||
} ?: emptyList(),
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt,
|
||||
users = users.map { it.toUsersEntity() }
|
||||
)
|
||||
}
|
||||
@@ -73,7 +135,7 @@ data class Creator(
|
||||
@SerializedName("userId")
|
||||
val userId: String,
|
||||
@SerializedName("trtcUserId")
|
||||
val trtcUserId: String,
|
||||
val trtcUserId: String? = null,
|
||||
@SerializedName("profile")
|
||||
val profile: Profile
|
||||
){
|
||||
@@ -81,7 +143,7 @@ data class Creator(
|
||||
return CreatorEntity(
|
||||
id = id,
|
||||
userId = userId,
|
||||
trtcUserId = trtcUserId,
|
||||
trtcUserId = trtcUserId ?: "",
|
||||
profile = profile.toProfileEntity()
|
||||
)
|
||||
}
|
||||
@@ -93,7 +155,7 @@ data class Users(
|
||||
@SerializedName("userId")
|
||||
val userId: String,
|
||||
@SerializedName("trtcUserId")
|
||||
val trtcUserId: String,
|
||||
val trtcUserId: String? = null,
|
||||
@SerializedName("profile")
|
||||
val profile: Profile
|
||||
){
|
||||
@@ -106,6 +168,451 @@ data class Users(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 房间规则相关服务
|
||||
*/
|
||||
interface RoomService {
|
||||
/**
|
||||
* 创建房间规则
|
||||
* @param rule 规则内容,不能为空
|
||||
* @param roomId 房间ID,与 trtcId 二选一
|
||||
* @param trtcId TRTC房间ID,与 roomId 二选一
|
||||
* @throws ServiceException 创建失败时抛出异常
|
||||
*/
|
||||
suspend fun createRoomRule(
|
||||
rule: String,
|
||||
roomId: Int? = null,
|
||||
trtcId: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 修改房间规则
|
||||
* @param id 规则ID
|
||||
* @param rule 新的规则内容,不能为空
|
||||
* @throws ServiceException 修改失败时抛出异常
|
||||
*/
|
||||
suspend fun updateRoomRule(
|
||||
id: Int,
|
||||
rule: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 删除房间规则
|
||||
* @param id 规则ID
|
||||
* @throws ServiceException 删除失败时抛出异常
|
||||
*/
|
||||
suspend fun deleteRoomRule(id: Int)
|
||||
|
||||
/**
|
||||
* 查询房间规则列表
|
||||
* @param roomId 房间ID,与 trtcId 二选一
|
||||
* @param trtcId TRTC房间ID,与 roomId 二选一
|
||||
* @param page 页码,默认 1
|
||||
* @param pageSize 每页数量,默认 10
|
||||
* @return 规则列表响应,包含分页信息和规则列表
|
||||
* @throws ServiceException 查询失败时抛出异常
|
||||
*/
|
||||
suspend fun getRoomRuleList(
|
||||
roomId: Int? = null,
|
||||
trtcId: String? = null,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 10
|
||||
): ListContainer<RoomRuleEntity>
|
||||
|
||||
/**
|
||||
* 查询规则配额使用情况
|
||||
* @param roomId 房间ID,与 trtcId 二选一
|
||||
* @param trtcId TRTC房间ID,与 roomId 二选一
|
||||
* @return 规则配额信息
|
||||
* @throws ServiceException 查询失败时抛出异常
|
||||
*/
|
||||
suspend fun getRoomRuleQuota(
|
||||
roomId: Int? = null,
|
||||
trtcId: String? = null
|
||||
): RoomRuleQuotaEntity
|
||||
|
||||
// ========== Room Member Management ==========
|
||||
|
||||
/**
|
||||
* 添加用户到房间
|
||||
*
|
||||
* @param roomId 房间ID,与 trtcId 二选一
|
||||
* @param trtcId TRTC群组ID,与 roomId 二选一
|
||||
* @param openIds 要添加的用户OpenID列表
|
||||
* @return 添加结果实体
|
||||
* @throws ServiceException 添加失败时抛出异常
|
||||
*/
|
||||
suspend fun addUserToRoom(
|
||||
roomId: Int? = null,
|
||||
trtcId: String? = null,
|
||||
openIds: List<String>
|
||||
): AddUserToRoomResultEntity
|
||||
|
||||
/**
|
||||
* 添加智能体到房间
|
||||
*
|
||||
* @param roomId 房间ID,与 trtcId 二选一
|
||||
* @param trtcId TRTC群组ID,与 roomId 二选一
|
||||
* @param agentOpenIds 要添加的智能体OpenID列表
|
||||
* @return 添加结果实体
|
||||
* @throws ServiceException 添加失败时抛出异常
|
||||
*/
|
||||
suspend fun addAgentToRoom(
|
||||
roomId: Int? = null,
|
||||
trtcId: String? = null,
|
||||
agentOpenIds: List<String>
|
||||
): AddAgentToRoomResultEntity
|
||||
|
||||
/**
|
||||
* 从房间移除智能体
|
||||
*
|
||||
* @param roomId 房间ID,与 trtcId 二选一
|
||||
* @param trtcId TRTC群组ID,与 roomId 二选一
|
||||
* @param agentOpenIds 要移除的智能体OpenID列表
|
||||
* @return 移除结果实体
|
||||
* @throws ServiceException 移除失败时抛出异常
|
||||
*/
|
||||
suspend fun removeAgentFromRoom(
|
||||
roomId: Int? = null,
|
||||
trtcId: String? = null,
|
||||
agentOpenIds: List<String>
|
||||
): RemoveAgentFromRoomResultEntity
|
||||
|
||||
/**
|
||||
* 从房间移除用户
|
||||
*
|
||||
* @param roomId 房间ID,与 trtcId 二选一
|
||||
* @param trtcId TRTC群组ID,与 roomId 二选一
|
||||
* @param userIds 要移除的用户ID列表(OpenID)
|
||||
* @return 移除结果实体
|
||||
* @throws ServiceException 移除失败时抛出异常
|
||||
*/
|
||||
suspend fun removeUserFromRoom(
|
||||
roomId: Int? = null,
|
||||
trtcId: String? = null,
|
||||
userIds: List<String>
|
||||
): RemoveUserFromRoomResultEntity
|
||||
}
|
||||
|
||||
/**
|
||||
* 房间规则服务实现类
|
||||
*/
|
||||
class RoomServiceImpl : RoomService {
|
||||
override suspend fun createRoomRule(
|
||||
rule: String,
|
||||
roomId: Int?,
|
||||
trtcId: String?
|
||||
) {
|
||||
val resp = ApiClient.api.createRoomRule(
|
||||
CreateRoomRuleRequestBody(
|
||||
rule = rule,
|
||||
roomId = roomId,
|
||||
trtcId = trtcId
|
||||
)
|
||||
)
|
||||
if (!resp.isSuccessful) {
|
||||
throw ServiceException("创建房间规则失败")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateRoomRule(
|
||||
id: Int,
|
||||
rule: String
|
||||
) {
|
||||
val resp = ApiClient.api.updateRoomRule(
|
||||
UpdateRoomRuleRequestBody(
|
||||
id = id,
|
||||
rule = rule
|
||||
)
|
||||
)
|
||||
if (!resp.isSuccessful) {
|
||||
throw ServiceException("修改房间规则失败")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteRoomRule(id: Int) {
|
||||
val resp = ApiClient.api.deleteRoomRule(id)
|
||||
if (!resp.isSuccessful) {
|
||||
throw ServiceException("删除房间规则失败")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getRoomRuleList(
|
||||
roomId: Int?,
|
||||
trtcId: String?,
|
||||
page: Int,
|
||||
pageSize: Int
|
||||
): ListContainer<RoomRuleEntity> {
|
||||
val resp = ApiClient.api.getRoomRuleList(
|
||||
roomId = roomId,
|
||||
trtcId = trtcId,
|
||||
page = page,
|
||||
pageSize = pageSize
|
||||
)
|
||||
val body = resp.body() ?: throw ServiceException("获取房间规则列表失败")
|
||||
|
||||
return ListContainer(
|
||||
list = body.list.map { it.toRoomRuleEntity() },
|
||||
page = body.page,
|
||||
total = body.total,
|
||||
pageSize = body.pageSize
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getRoomRuleQuota(
|
||||
roomId: Int?,
|
||||
trtcId: String?
|
||||
): RoomRuleQuotaEntity {
|
||||
val resp = ApiClient.api.getRoomRuleQuota(
|
||||
roomId = roomId,
|
||||
trtcId = trtcId
|
||||
)
|
||||
val body = resp.body() ?: throw ServiceException("获取规则配额信息失败")
|
||||
val data = body.data ?: throw ServiceException("规则配额数据为空")
|
||||
|
||||
return data.toRoomRuleQuotaEntity()
|
||||
}
|
||||
|
||||
override suspend fun addUserToRoom(
|
||||
roomId: Int?,
|
||||
trtcId: String?,
|
||||
openIds: List<String>
|
||||
): AddUserToRoomResultEntity {
|
||||
val resp = ApiClient.api.addUserToRoom(
|
||||
com.aiosman.ravenow.data.api.AddUserToRoomRequestBody(
|
||||
roomId = roomId,
|
||||
trtcId = trtcId,
|
||||
openIds = openIds
|
||||
)
|
||||
)
|
||||
val body = resp.body() ?: throw ServiceException("添加用户到房间失败")
|
||||
val data = body.data ?: throw ServiceException("添加用户响应数据为空")
|
||||
|
||||
return data.result.toAddUserToRoomResultEntity()
|
||||
}
|
||||
|
||||
override suspend fun addAgentToRoom(
|
||||
roomId: Int?,
|
||||
trtcId: String?,
|
||||
agentOpenIds: List<String>
|
||||
): AddAgentToRoomResultEntity {
|
||||
val resp = ApiClient.api.addAgentToRoom(
|
||||
com.aiosman.ravenow.data.api.AddAgentToRoomRequestBody(
|
||||
roomId = roomId,
|
||||
trtcId = trtcId,
|
||||
agentOpenIds = agentOpenIds
|
||||
)
|
||||
)
|
||||
val body = resp.body() ?: throw ServiceException("添加智能体到房间失败")
|
||||
val data = body.data ?: throw ServiceException("添加智能体响应数据为空")
|
||||
|
||||
return data.result.toAddAgentToRoomResultEntity()
|
||||
}
|
||||
|
||||
override suspend fun removeAgentFromRoom(
|
||||
roomId: Int?,
|
||||
trtcId: String?,
|
||||
agentOpenIds: List<String>
|
||||
): RemoveAgentFromRoomResultEntity {
|
||||
val resp = ApiClient.api.removeAgentFromRoom(
|
||||
com.aiosman.ravenow.data.api.RemoveAgentFromRoomRequestBody(
|
||||
roomId = roomId,
|
||||
trtcId = trtcId,
|
||||
agentOpenIds = agentOpenIds
|
||||
)
|
||||
)
|
||||
val body = resp.body() ?: throw ServiceException("从房间移除智能体失败")
|
||||
val data = body.data ?: throw ServiceException("移除智能体响应数据为空")
|
||||
|
||||
return data.toRemoveAgentFromRoomResultEntity()
|
||||
}
|
||||
|
||||
override suspend fun removeUserFromRoom(
|
||||
roomId: Int?,
|
||||
trtcId: String?,
|
||||
userIds: List<String>
|
||||
): RemoveUserFromRoomResultEntity {
|
||||
val resp = ApiClient.api.removeUserFromRoom(
|
||||
com.aiosman.ravenow.data.api.RemoveUserFromRoomRequestBody(
|
||||
roomId = roomId,
|
||||
trtcId = trtcId,
|
||||
userIds = userIds
|
||||
)
|
||||
)
|
||||
val body = resp.body() ?: throw ServiceException("从房间移除用户失败")
|
||||
val data = body.data ?: throw ServiceException("移除用户响应数据为空")
|
||||
|
||||
return data.toRemoveUserFromRoomResultEntity()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RoomRule 扩展函数,转换为 RoomRuleEntity
|
||||
*/
|
||||
fun RoomRule.toRoomRuleEntity(): RoomRuleEntity {
|
||||
return RoomRuleEntity(
|
||||
id = id,
|
||||
rule = rule,
|
||||
creator = creator?.toRoomRuleCreatorEntity(),
|
||||
creatorType = creatorType,
|
||||
roomId = roomId,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* RoomRuleCreator 扩展函数,转换为 RoomRuleCreatorEntity
|
||||
*/
|
||||
fun RoomRuleCreator.toRoomRuleCreatorEntity(): RoomRuleCreatorEntity {
|
||||
return RoomRuleCreatorEntity(
|
||||
id = id,
|
||||
nickname = nickname,
|
||||
avatar = avatar,
|
||||
avatarMedium = avatarMedium,
|
||||
avatarLarge = avatarLarge,
|
||||
avatarDirectUrl = avatarDirectUrl,
|
||||
avatarMediumDirectUrl = avatarMediumDirectUrl,
|
||||
avatarLargeDirectUrl = avatarLargeDirectUrl
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* RoomRuleQuota 扩展函数,转换为 RoomRuleQuotaEntity
|
||||
*/
|
||||
fun RoomRuleQuota.toRoomRuleQuotaEntity(): RoomRuleQuotaEntity {
|
||||
return RoomRuleQuotaEntity(
|
||||
baseMaxCount = baseMaxCount,
|
||||
purchasedCount = purchasedCount,
|
||||
totalMaxCount = totalMaxCount,
|
||||
currentCount = currentCount,
|
||||
remainingCount = remainingCount,
|
||||
usagePercent = usagePercent
|
||||
)
|
||||
}
|
||||
|
||||
// ========== Room Member Management 扩展函数 ==========
|
||||
|
||||
/**
|
||||
* AddUserToRoomResult 扩展函数,转换为 AddUserToRoomResultEntity
|
||||
*/
|
||||
fun com.aiosman.ravenow.data.api.AddUserToRoomResult.toAddUserToRoomResultEntity(): AddUserToRoomResultEntity {
|
||||
return AddUserToRoomResultEntity(
|
||||
totalCount = totalCount,
|
||||
successCount = successCount,
|
||||
failedCount = failedCount,
|
||||
skippedCount = skippedCount,
|
||||
successItems = successItems.map { it.toAddUserToRoomItemEntity() },
|
||||
failedItems = failedItems.map { it.toAddUserToRoomFailedItemEntity() },
|
||||
skippedItems = skippedItems.map { it.toAddUserToRoomItemEntity() }
|
||||
)
|
||||
}
|
||||
|
||||
fun com.aiosman.ravenow.data.api.AddUserToRoomItem.toAddUserToRoomItemEntity(): AddUserToRoomItemEntity {
|
||||
return AddUserToRoomItemEntity(
|
||||
userId = userId,
|
||||
type = type
|
||||
)
|
||||
}
|
||||
|
||||
fun com.aiosman.ravenow.data.api.AddUserToRoomFailedItem.toAddUserToRoomFailedItemEntity(): AddUserToRoomFailedItemEntity {
|
||||
return AddUserToRoomFailedItemEntity(
|
||||
userId = userId,
|
||||
type = type,
|
||||
error = error
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* AddAgentToRoomResult 扩展函数,转换为 AddAgentToRoomResultEntity
|
||||
*/
|
||||
fun com.aiosman.ravenow.data.api.AddAgentToRoomResult.toAddAgentToRoomResultEntity(): AddAgentToRoomResultEntity {
|
||||
return AddAgentToRoomResultEntity(
|
||||
totalCount = totalCount,
|
||||
successCount = successCount,
|
||||
failedCount = failedCount,
|
||||
skippedCount = skippedCount,
|
||||
successItems = successItems.map { it.toAddAgentToRoomItemEntity() },
|
||||
failedItems = failedItems.map { it.toAddAgentToRoomFailedItemEntity() },
|
||||
skippedItems = skippedItems.map { it.toAddAgentToRoomItemEntity() }
|
||||
)
|
||||
}
|
||||
|
||||
fun com.aiosman.ravenow.data.api.AddAgentToRoomItem.toAddAgentToRoomItemEntity(): AddAgentToRoomItemEntity {
|
||||
return AddAgentToRoomItemEntity(
|
||||
agentOpenId = agentOpenId,
|
||||
type = type
|
||||
)
|
||||
}
|
||||
|
||||
fun com.aiosman.ravenow.data.api.AddAgentToRoomFailedItem.toAddAgentToRoomFailedItemEntity(): AddAgentToRoomFailedItemEntity {
|
||||
return AddAgentToRoomFailedItemEntity(
|
||||
agentOpenId = agentOpenId,
|
||||
type = type,
|
||||
error = error
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* RemoveAgentFromRoomResult 扩展函数,转换为 RemoveAgentFromRoomResultEntity
|
||||
*/
|
||||
fun com.aiosman.ravenow.data.api.RemoveAgentFromRoomResult.toRemoveAgentFromRoomResultEntity(): RemoveAgentFromRoomResultEntity {
|
||||
return RemoveAgentFromRoomResultEntity(
|
||||
totalCount = totalCount,
|
||||
successCount = successCount,
|
||||
failedCount = failedCount,
|
||||
skippedCount = skippedCount,
|
||||
successItems = successItems.map { it.toRemoveAgentFromRoomItemEntity() },
|
||||
failedItems = failedItems.map { it.toRemoveAgentFromRoomFailedItemEntity() },
|
||||
skippedItems = skippedItems.map { it.toRemoveAgentFromRoomItemEntity() }
|
||||
)
|
||||
}
|
||||
|
||||
fun com.aiosman.ravenow.data.api.RemoveAgentFromRoomItem.toRemoveAgentFromRoomItemEntity(): RemoveAgentFromRoomItemEntity {
|
||||
return RemoveAgentFromRoomItemEntity(
|
||||
agentOpenId = agentOpenId,
|
||||
type = type
|
||||
)
|
||||
}
|
||||
|
||||
fun com.aiosman.ravenow.data.api.RemoveAgentFromRoomFailedItem.toRemoveAgentFromRoomFailedItemEntity(): RemoveAgentFromRoomFailedItemEntity {
|
||||
return RemoveAgentFromRoomFailedItemEntity(
|
||||
agentOpenId = agentOpenId,
|
||||
type = type,
|
||||
error = error
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* RemoveUserFromRoomResult 扩展函数,转换为 RemoveUserFromRoomResultEntity
|
||||
*/
|
||||
fun com.aiosman.ravenow.data.api.RemoveUserFromRoomResult.toRemoveUserFromRoomResultEntity(): RemoveUserFromRoomResultEntity {
|
||||
return RemoveUserFromRoomResultEntity(
|
||||
totalCount = totalCount,
|
||||
successCount = successCount,
|
||||
failedCount = failedCount,
|
||||
skippedCount = skippedCount,
|
||||
successItems = successItems.map { it.toRemoveUserFromRoomItemEntity() },
|
||||
failedItems = failedItems.map { it.toRemoveUserFromRoomFailedItemEntity() },
|
||||
skippedItems = skippedItems.map { it.toRemoveUserFromRoomItemEntity() }
|
||||
)
|
||||
}
|
||||
|
||||
fun com.aiosman.ravenow.data.api.RemoveUserFromRoomItem.toRemoveUserFromRoomItemEntity(): RemoveUserFromRoomItemEntity {
|
||||
return RemoveUserFromRoomItemEntity(
|
||||
userId = userId,
|
||||
type = type
|
||||
)
|
||||
}
|
||||
|
||||
fun com.aiosman.ravenow.data.api.RemoveUserFromRoomFailedItem.toRemoveUserFromRoomFailedItemEntity(): RemoveUserFromRoomFailedItemEntity {
|
||||
return RemoveUserFromRoomFailedItemEntity(
|
||||
userId = userId,
|
||||
type = type,
|
||||
error = error
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.aiosman.ravenow.data
|
||||
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import com.aiosman.ravenow.data.api.BatchTrtcUserIdRequestBody
|
||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
||||
|
||||
data class UserAuth(
|
||||
@@ -46,7 +47,8 @@ interface UserService {
|
||||
page: Int = 1,
|
||||
nickname: String? = null,
|
||||
followerId: Int? = null,
|
||||
followingId: Int? = null
|
||||
followingId: Int? = null,
|
||||
roomId: Int? = null
|
||||
): ListContainer<AccountProfileEntity>
|
||||
|
||||
|
||||
@@ -66,6 +68,16 @@ interface UserService {
|
||||
|
||||
suspend fun getUserProfileByOpenId(id: String):AccountProfileEntity
|
||||
|
||||
/**
|
||||
* 批量通过 TRTC 用户ID 获取用户信息列表
|
||||
* @param ids TRTC 用户ID列表(最多100个)
|
||||
* @param includeAI 是否包含AI账号,默认 false
|
||||
* @return 用户信息实体列表
|
||||
*/
|
||||
suspend fun getUserProfilesByTrtcUserIds(
|
||||
ids: List<String>,
|
||||
includeAI: Boolean = false
|
||||
): List<AccountProfileEntity>
|
||||
}
|
||||
|
||||
class UserServiceImpl : UserService {
|
||||
@@ -90,14 +102,16 @@ class UserServiceImpl : UserService {
|
||||
page: Int,
|
||||
nickname: String?,
|
||||
followerId: Int?,
|
||||
followingId: Int?
|
||||
followingId: Int?,
|
||||
roomId: Int?
|
||||
): ListContainer<AccountProfileEntity> {
|
||||
val resp = ApiClient.api.getUsers(
|
||||
page = page,
|
||||
pageSize = pageSize,
|
||||
search = nickname,
|
||||
followerId = followerId,
|
||||
followingId = followingId
|
||||
followingId = followingId,
|
||||
includeAI = true,
|
||||
)
|
||||
val body = resp.body() ?: throw ServiceException("Failed to get account")
|
||||
return ListContainer<AccountProfileEntity>(
|
||||
@@ -119,4 +133,18 @@ class UserServiceImpl : UserService {
|
||||
val body = resp.body() ?: throw ServiceException("Failed to get account")
|
||||
return body.data.toAccountProfileEntity()
|
||||
}
|
||||
|
||||
override suspend fun getUserProfilesByTrtcUserIds(
|
||||
ids: List<String>,
|
||||
includeAI: Boolean
|
||||
): List<AccountProfileEntity> {
|
||||
val resp = ApiClient.api.getAccountProfilesByTrtcBatch(
|
||||
BatchTrtcUserIdRequestBody(
|
||||
trtcUserIds = ids,
|
||||
includeAI = includeAI
|
||||
)
|
||||
)
|
||||
val body = resp.body() ?: throw ServiceException("Failed to get accounts")
|
||||
return body.data.map { it.toAccountProfileEntity() }
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ class AuthInterceptor() : Interceptor {
|
||||
}
|
||||
|
||||
requestBuilder.addHeader("Authorization", "Bearer ${AppStore.token}")
|
||||
requestBuilder.addHeader("DEVICE-OS", "Android")
|
||||
|
||||
val response = chain.proceed(requestBuilder.build())
|
||||
return response
|
||||
@@ -69,9 +70,9 @@ class AuthInterceptor() : Interceptor {
|
||||
}
|
||||
|
||||
object ApiClient {
|
||||
const val BASE_SERVER = ConstVars.BASE_SERVER
|
||||
const val BASE_API_URL = "${BASE_SERVER}/api/v1"
|
||||
const val RETROFIT_URL = "${BASE_API_URL}/"
|
||||
val BASE_SERVER = ConstVars.BASE_SERVER
|
||||
val BASE_API_URL = "${BASE_SERVER}/api/v1"
|
||||
val RETROFIT_URL = "${BASE_API_URL}/"
|
||||
const val TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"
|
||||
private val okHttpClient: OkHttpClient by lazy {
|
||||
getSafeOkHttpClient(authInterceptor = AuthInterceptor())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
package com.aiosman.ravenow.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Database
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.Upsert
|
||||
|
||||
@Entity(tableName = "trtc_participant_cache")
|
||||
data class TrtcParticipantCache(
|
||||
@PrimaryKey val trtcId: String,
|
||||
val isAI: Boolean,
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface TrtcParticipantCacheDao {
|
||||
@Query("SELECT * FROM trtc_participant_cache WHERE trtcId = :trtcId LIMIT 1")
|
||||
suspend fun get(trtcId: String): TrtcParticipantCache?
|
||||
|
||||
@Query("SELECT * FROM trtc_participant_cache WHERE trtcId IN (:ids)")
|
||||
suspend fun getMany(ids: List<String>): List<TrtcParticipantCache>
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(items: List<TrtcParticipantCache>)
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [TrtcParticipantCache::class],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class MessageCacheDatabase : RoomDatabase() {
|
||||
abstract fun trtcParticipantCacheDao(): TrtcParticipantCacheDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: MessageCacheDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): MessageCacheDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
MessageCacheDatabase::class.java,
|
||||
"message_cache.db"
|
||||
).fallbackToDestructiveMigration()
|
||||
.build()
|
||||
.also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.aiosman.ravenow.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.aiosman.ravenow.data.UserService
|
||||
import com.aiosman.ravenow.data.UserServiceImpl
|
||||
import com.aiosman.ravenow.data.db.MessageCacheDatabase
|
||||
import com.aiosman.ravenow.data.db.TrtcParticipantCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object TrtcUserTypeRepository {
|
||||
private const val TAG = "TrtcUserTypeRepo"
|
||||
private const val MAX_BATCH = 100
|
||||
private const val TTL_MS: Long = 5L * 24 * 60 * 60 * 1000 // 5 天
|
||||
|
||||
private val memoryCache = ConcurrentHashMap<String, CacheEntry>()
|
||||
private var initialized = false
|
||||
private lateinit var userService: UserService
|
||||
private lateinit var db: com.aiosman.ravenow.data.db.MessageCacheDatabase
|
||||
|
||||
data class CacheEntry(
|
||||
val isAI: Boolean,
|
||||
val updatedAt: Long
|
||||
) {
|
||||
fun isExpired(now: Long): Boolean = now - updatedAt > TTL_MS
|
||||
}
|
||||
|
||||
private fun ensureInit(context: Context) {
|
||||
if (!initialized) {
|
||||
db = MessageCacheDatabase.getInstance(context)
|
||||
userService = UserServiceImpl()
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
fun getCachedType(trtcId: String): Boolean? {
|
||||
val entry = memoryCache[trtcId] ?: return null
|
||||
return if (!entry.isExpired(System.currentTimeMillis())) entry.isAI else null
|
||||
}
|
||||
|
||||
suspend fun getType(context: Context, trtcId: String): Boolean? {
|
||||
ensureInit(context)
|
||||
val now = System.currentTimeMillis()
|
||||
val mem = memoryCache[trtcId]
|
||||
if (mem != null && !mem.isExpired(now)) return mem.isAI
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val dao = db.trtcParticipantCacheDao()
|
||||
val entity = dao.get(trtcId)
|
||||
if (entity != null && now - entity.updatedAt <= TTL_MS) {
|
||||
memoryCache[trtcId] = CacheEntry(entity.isAI, entity.updatedAt)
|
||||
entity.isAI
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ensureTypes(context: Context, trtcIds: List<String>) {
|
||||
ensureInit(context)
|
||||
if (trtcIds.isEmpty()) return
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
// 从内存/本地命中
|
||||
val toFetch = withContext(Dispatchers.IO) {
|
||||
val dao = db.trtcParticipantCacheDao()
|
||||
val result = mutableSetOf<String>()
|
||||
val needFromDb = mutableListOf<String>()
|
||||
|
||||
trtcIds.forEach { id ->
|
||||
val mem = memoryCache[id]
|
||||
if (mem == null || mem.isExpired(now)) {
|
||||
needFromDb.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (needFromDb.isNotEmpty()) {
|
||||
val entities = dao.getMany(needFromDb)
|
||||
val fromDbSet = entities.toMutableList()
|
||||
// 写回内存并决定是否需要网络
|
||||
val dbValid = mutableSetOf<String>()
|
||||
fromDbSet.forEach { e ->
|
||||
if (now - e.updatedAt <= TTL_MS) {
|
||||
memoryCache[e.trtcId] = CacheEntry(e.isAI, e.updatedAt)
|
||||
dbValid.add(e.trtcId)
|
||||
}
|
||||
}
|
||||
needFromDb.forEach { id ->
|
||||
if (!dbValid.contains(id)) {
|
||||
result.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
result.toList()
|
||||
}
|
||||
|
||||
if (toFetch.isEmpty()) return
|
||||
|
||||
// 批量分片请求
|
||||
val chunks = toFetch.chunked(MAX_BATCH)
|
||||
for (chunk in chunks) {
|
||||
try {
|
||||
val profiles = withContext(Dispatchers.IO) {
|
||||
userService.getUserProfilesByTrtcUserIds(chunk, includeAI = true)
|
||||
}
|
||||
// 将返回的 profile 按 trtcUserId -> isAI 映射
|
||||
val upserts = profiles.mapNotNull { profile ->
|
||||
val id = profile.trtcUserId
|
||||
if (id.isNullOrEmpty()) null
|
||||
else TrtcParticipantCache(
|
||||
trtcId = id,
|
||||
isAI = profile.aiAccount,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
// 落库 + 内存
|
||||
withContext(Dispatchers.IO) {
|
||||
db.trtcParticipantCacheDao().upsertAll(upserts)
|
||||
}
|
||||
upserts.forEach { e ->
|
||||
memoryCache[e.trtcId] = CacheEntry(e.isAI, e.updatedAt)
|
||||
}
|
||||
Log.d(TAG, "Fetched types: size=${upserts.size}, batch=${chunk.size}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "ensureTypes fetch failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,14 @@ data class AccountProfileEntity(
|
||||
val rawAvatar: String,
|
||||
|
||||
val chatAIId: String,
|
||||
|
||||
// AI角色背景图
|
||||
val aiRoleAvatar: String? = null,
|
||||
val aiRoleAvatarMedium: String? = null,
|
||||
val aiRoleAvatarLarge: String? = null,
|
||||
|
||||
// 创建者信息(仅AI账号有)
|
||||
val creatorProfile: CreatorProfileEntity? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -110,6 +118,28 @@ data class NoticeUserEntity(
|
||||
val avatar: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 创建者信息
|
||||
*/
|
||||
data class CreatorProfileEntity(
|
||||
// 用户ID
|
||||
val id: Long,
|
||||
// 用户名
|
||||
val username: String? = null,
|
||||
// 昵称
|
||||
val nickname: String,
|
||||
// 头像
|
||||
val avatar: String? = null,
|
||||
// 个人简介
|
||||
val bio: String? = null,
|
||||
// trtcUserId
|
||||
val trtcUserId: String? = null,
|
||||
// chatAIId
|
||||
val chatAIId: String? = null,
|
||||
// 是否为AI账号
|
||||
val aiAccount: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* 用户点赞消息分页数据加载器
|
||||
*/
|
||||
|
||||
@@ -7,18 +7,13 @@ import com.aiosman.ravenow.data.Agent
|
||||
import com.aiosman.ravenow.data.ListContainer
|
||||
import com.aiosman.ravenow.data.AgentService
|
||||
import com.aiosman.ravenow.data.DataContainer
|
||||
import com.aiosman.ravenow.data.MomentService
|
||||
import com.aiosman.ravenow.data.ServiceException
|
||||
import com.aiosman.ravenow.data.UploadImage
|
||||
import com.aiosman.ravenow.data.UserService
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import retrofit2.http.Part
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
@@ -80,9 +75,9 @@ class AgentPagingSource(
|
||||
authorId = authorId
|
||||
)
|
||||
LoadResult.Page(
|
||||
data = users.list,
|
||||
data = users?.list ?: listOf(),
|
||||
prevKey = if (currentPage == 1) null else currentPage - 1,
|
||||
nextKey = if (users.list.isEmpty()) null else users.page + 1
|
||||
nextKey = if (users?.list?.isNotEmpty() == true) users.page + 1 else null
|
||||
)
|
||||
} catch (exception: IOException) {
|
||||
return LoadResult.Error(exception)
|
||||
@@ -95,6 +90,35 @@ class AgentPagingSource(
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能体搜索分页加载器(按标题关键字)
|
||||
*/
|
||||
class AgentSearchPagingSource(
|
||||
private val agentRemoteDataSource: AgentRemoteDataSource,
|
||||
private val keyword: String,
|
||||
) : PagingSource<Int, AgentEntity>() {
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, AgentEntity> {
|
||||
return try {
|
||||
val currentPage = params.key ?: 1
|
||||
val agents = agentRemoteDataSource.searchAgentByTitle(
|
||||
pageNumber = currentPage,
|
||||
title = keyword
|
||||
)
|
||||
LoadResult.Page(
|
||||
data = agents?.list ?: listOf(),
|
||||
prevKey = if (currentPage == 1) null else currentPage - 1,
|
||||
nextKey = if (agents?.list?.isNotEmpty() == true) currentPage + 1 else null
|
||||
)
|
||||
} catch (exception: IOException) {
|
||||
LoadResult.Error(exception)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Int, AgentEntity>): Int? {
|
||||
return state.anchorPosition
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AgentRemoteDataSource(
|
||||
private val agentService: AgentService,
|
||||
@@ -102,12 +126,22 @@ class AgentRemoteDataSource(
|
||||
suspend fun getAgent(
|
||||
pageNumber: Int,
|
||||
authorId: Int? = null
|
||||
): ListContainer<AgentEntity> {
|
||||
): ListContainer<AgentEntity>? {
|
||||
return agentService.getAgent(
|
||||
pageNumber = pageNumber,
|
||||
authorId = authorId
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun searchAgentByTitle(
|
||||
pageNumber: Int,
|
||||
title: String
|
||||
): ListContainer<AgentEntity>? {
|
||||
return agentService.searchAgentByTitle(
|
||||
pageNumber = pageNumber,
|
||||
title = title
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class AgentServiceImpl() : AgentService {
|
||||
@@ -117,12 +151,23 @@ class AgentServiceImpl() : AgentService {
|
||||
pageNumber: Int,
|
||||
pageSize: Int,
|
||||
authorId: Int?
|
||||
): ListContainer<AgentEntity> {
|
||||
): ListContainer<AgentEntity>? {
|
||||
return agentBackend.getAgent(
|
||||
pageNumber = pageNumber,
|
||||
authorId = authorId
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun searchAgentByTitle(
|
||||
pageNumber: Int,
|
||||
pageSize: Int,
|
||||
title: String
|
||||
): ListContainer<AgentEntity>? {
|
||||
return agentBackend.searchAgentByTitle(
|
||||
pageNumber = pageNumber,
|
||||
title = title
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class AgentBackend {
|
||||
@@ -130,7 +175,7 @@ class AgentBackend {
|
||||
suspend fun getAgent(
|
||||
pageNumber: Int,
|
||||
authorId: Int? = null
|
||||
): ListContainer<AgentEntity> {
|
||||
): ListContainer<AgentEntity>? {
|
||||
// 如果是游客模式且获取我的Agent(authorId为null),返回空列表
|
||||
if (authorId == null && AppStore.isGuest) {
|
||||
return ListContainer(
|
||||
@@ -154,13 +199,13 @@ class AgentBackend {
|
||||
)
|
||||
}
|
||||
|
||||
val body = resp.body() ?: throw ServiceException("Failed to get agents")
|
||||
val body = resp.body() ?: return null
|
||||
|
||||
// 处理不同的返回类型
|
||||
return if (authorId != null) {
|
||||
// getAgent 返回 DataContainer<ListContainer<Agent>>
|
||||
val dataContainer =
|
||||
body as com.aiosman.ravenow.data.DataContainer<com.aiosman.ravenow.data.ListContainer<com.aiosman.ravenow.data.Agent>>
|
||||
body as DataContainer<ListContainer<Agent>>
|
||||
val listContainer = dataContainer.data
|
||||
ListContainer(
|
||||
total = listContainer.total,
|
||||
@@ -171,7 +216,7 @@ class AgentBackend {
|
||||
} else {
|
||||
// getMyAgent 返回 ListContainer<Agent>
|
||||
val listContainer =
|
||||
body as com.aiosman.ravenow.data.ListContainer<com.aiosman.ravenow.data.Agent>
|
||||
body as ListContainer<Agent>
|
||||
ListContainer(
|
||||
total = listContainer.total,
|
||||
page = pageNumber,
|
||||
@@ -180,6 +225,27 @@ class AgentBackend {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchAgentByTitle(
|
||||
pageNumber: Int,
|
||||
title: String
|
||||
): ListContainer<AgentEntity>? {
|
||||
val resp = ApiClient.api.getAgent(
|
||||
page = pageNumber,
|
||||
pageSize = DataBatchSize,
|
||||
withWorkflow = 1,
|
||||
title = title
|
||||
)
|
||||
val body = resp.body() ?: return null
|
||||
val dataContainer = body as DataContainer<ListContainer<Agent>>
|
||||
val listContainer = dataContainer.data
|
||||
return ListContainer(
|
||||
total = listContainer.total,
|
||||
page = pageNumber,
|
||||
pageSize = DataBatchSize,
|
||||
list = listContainer.list.map { it.toAgentEntity() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AgentEntity(
|
||||
@@ -251,7 +317,7 @@ class AgentLoader : DataLoader<AgentEntity, AgentLoaderExtraArgs>() {
|
||||
} else {
|
||||
// getMyAgent 返回 ListContainer<Agent>
|
||||
val listContainer =
|
||||
body as com.aiosman.ravenow.data.ListContainer<com.aiosman.ravenow.data.Agent>
|
||||
body as ListContainer<Agent>
|
||||
ListContainer(
|
||||
list = listContainer.list.map { it.toAgentEntity() },
|
||||
total = listContainer.total,
|
||||
|
||||
@@ -13,4 +13,6 @@ data class GroupInfo(
|
||||
val groupAvatar: String,
|
||||
val memberCount: Int,
|
||||
val isCreator: Boolean = false,
|
||||
val trtcType: String = "Public",
|
||||
val privateFeePaid: Boolean = false,
|
||||
)
|
||||
@@ -9,7 +9,7 @@ import com.aiosman.ravenow.data.ListContainer
|
||||
abstract class DataLoader<T,ET> {
|
||||
var list: MutableList<T> = mutableListOf()
|
||||
var page by mutableStateOf(1)
|
||||
var total by mutableStateOf(0)
|
||||
var total by mutableStateOf(0L)
|
||||
var pageSize by mutableStateOf(10)
|
||||
var hasNext by mutableStateOf(true)
|
||||
var isLoading by mutableStateOf(false)
|
||||
|
||||
@@ -250,8 +250,26 @@ data class MomentImageEntity(
|
||||
val id: Long,
|
||||
// 图片URL
|
||||
val url: String,
|
||||
// 原始图片URL
|
||||
val originalUrl: String? = null,
|
||||
// 直接访问URL
|
||||
val directUrl: String? = null,
|
||||
// 缩略图URL
|
||||
val thumbnail: String,
|
||||
// 缩略图直接访问URL
|
||||
val thumbnailDirectUrl: String? = null,
|
||||
// 小尺寸图片URL
|
||||
val small: String? = null,
|
||||
// 小尺寸图片直接访问URL
|
||||
val smallDirectUrl: String? = null,
|
||||
// 中尺寸图片URL
|
||||
val medium: String? = null,
|
||||
// 中尺寸图片直接访问URL
|
||||
val mediumDirectUrl: String? = null,
|
||||
// 大尺寸图片URL
|
||||
val large: String? = null,
|
||||
// 大尺寸图片直接访问URL
|
||||
val largeDirectUrl: String? = null,
|
||||
// 图片BlurHash
|
||||
val blurHash: String? = null,
|
||||
// 宽度
|
||||
@@ -260,6 +278,38 @@ data class MomentImageEntity(
|
||||
var height: Int? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 动态视频
|
||||
*/
|
||||
data class MomentVideoEntity(
|
||||
// 视频ID
|
||||
val id: Long,
|
||||
// 视频URL
|
||||
val url: String,
|
||||
// 原始文件名
|
||||
val originalUrl: String? = null,
|
||||
// 直接访问URL
|
||||
val directUrl: String? = null,
|
||||
// 视频缩略图URL
|
||||
val thumbnailUrl: String? = null,
|
||||
// 视频缩略图直接访问URL
|
||||
val thumbnailDirectUrl: String? = null,
|
||||
// 视频时长(秒)
|
||||
val duration: Int? = null,
|
||||
// 宽度
|
||||
val width: Int? = null,
|
||||
// 高度
|
||||
val height: Int? = null,
|
||||
// 文件大小(字节)
|
||||
val size: Long? = null,
|
||||
// 视频格式
|
||||
val format: String? = null,
|
||||
// 视频比特率(kbps)
|
||||
val bitrate: Int? = null,
|
||||
// 帧率
|
||||
val frameRate: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 动态
|
||||
*/
|
||||
@@ -277,7 +327,7 @@ data class MomentEntity(
|
||||
// 是否关注
|
||||
val followStatus: Boolean,
|
||||
// 动态内容
|
||||
val momentTextContent: String,
|
||||
val momentTextContent: String?,
|
||||
// 动态图片
|
||||
@DrawableRes val momentPicture: Int,
|
||||
// 点赞数
|
||||
@@ -299,12 +349,34 @@ data class MomentEntity(
|
||||
// 关联动态
|
||||
var relMoment: MomentEntity? = null,
|
||||
// 是否收藏
|
||||
var isFavorite: Boolean = false
|
||||
var isFavorite: Boolean = false,
|
||||
// 外部链接
|
||||
val url: String? = null,
|
||||
// 动态视频列表
|
||||
val videos: List<MomentVideoEntity>? = null,
|
||||
// 新闻相关字段
|
||||
val isNews: Boolean = false,
|
||||
val newsTitle: String = "",
|
||||
val newsUrl: String = "",
|
||||
val newsSource: String = "",
|
||||
val newsCategory: String = "",
|
||||
val newsLanguage: String = "",
|
||||
val newsContent: String = "",
|
||||
// 是否已获取完整正文
|
||||
val hasFullText: Boolean = false,
|
||||
// 新闻摘要
|
||||
val summary: String? = null,
|
||||
// 新闻发布时间
|
||||
val publishedAt: String? = null,
|
||||
// 是否已缓存图片
|
||||
val imageCached: Boolean = false
|
||||
)
|
||||
class MomentLoaderExtraArgs(
|
||||
val explore: Boolean? = false,
|
||||
val timelineId: Int? = null,
|
||||
val authorId : Int? = null
|
||||
val authorId : Int? = null,
|
||||
val newsOnly: Boolean? = null,
|
||||
val videoOnly: Boolean? = null
|
||||
)
|
||||
class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
|
||||
override suspend fun fetchData(
|
||||
@@ -317,7 +389,9 @@ class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
|
||||
pageSize = pageSize,
|
||||
explore = if (extra.explore == true) "true" else "",
|
||||
timelineId = extra.timelineId,
|
||||
authorId = extra.authorId
|
||||
authorId = extra.authorId,
|
||||
newsFilter = if (extra.newsOnly == true) "news_only" else "",
|
||||
videoFilter = if (extra.videoOnly == true) "video_only" else ""
|
||||
)
|
||||
val data = result.body()?.let {
|
||||
ListContainer(
|
||||
@@ -336,7 +410,16 @@ class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
|
||||
fun updateMomentLike(id: Int,isLike:Boolean) {
|
||||
this.list = this.list.map { momentItem ->
|
||||
if (momentItem.id == id) {
|
||||
momentItem.copy(likeCount = momentItem.likeCount + if (isLike) 1 else -1, liked = isLike)
|
||||
// 只有当状态发生变化时才更新计数,避免重复更新
|
||||
val countDelta = if (momentItem.liked != isLike) {
|
||||
if (isLike) 1 else -1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
momentItem.copy(
|
||||
likeCount = (momentItem.likeCount + countDelta).coerceAtLeast(0),
|
||||
liked = isLike
|
||||
)
|
||||
} else {
|
||||
momentItem
|
||||
}
|
||||
@@ -347,7 +430,28 @@ class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
|
||||
fun updateFavoriteCount(id: Int,isFavorite:Boolean) {
|
||||
this.list = this.list.map { momentItem ->
|
||||
if (momentItem.id == id) {
|
||||
momentItem.copy(favoriteCount = momentItem.favoriteCount + if (isFavorite) 1 else -1, isFavorite = isFavorite)
|
||||
// 只有当状态发生变化时才更新计数,避免重复更新
|
||||
val countDelta = if (momentItem.isFavorite != isFavorite) {
|
||||
if (isFavorite) 1 else -1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
momentItem.copy(
|
||||
favoriteCount = (momentItem.favoriteCount + countDelta).coerceAtLeast(0),
|
||||
isFavorite = isFavorite
|
||||
)
|
||||
} else {
|
||||
momentItem
|
||||
}
|
||||
}.toMutableList()
|
||||
onListChanged?.invoke(this.list)
|
||||
}
|
||||
|
||||
fun updateCommentCount(id: Int, delta: Int) {
|
||||
this.list = this.list.map { momentItem ->
|
||||
if (momentItem.id == id) {
|
||||
val newCount = (momentItem.commentCount + delta).coerceAtLeast(0)
|
||||
momentItem.copy(commentCount = newCount)
|
||||
} else {
|
||||
momentItem
|
||||
}
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
package com.aiosman.ravenow.entity
|
||||
|
||||
import android.util.Log
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import com.aiosman.ravenow.data.ListContainer
|
||||
import com.aiosman.ravenow.data.Room
|
||||
import com.aiosman.ravenow.data.ServiceException
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* 群聊房间
|
||||
*/
|
||||
|
||||
/**
|
||||
* 房间内的智能体信息实体
|
||||
*/
|
||||
data class PromptTemplateEntity(
|
||||
val id: Int,
|
||||
val openId: String,
|
||||
val title: String,
|
||||
val desc: String,
|
||||
val avatar: String
|
||||
)
|
||||
|
||||
data class RoomEntity(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
@@ -25,9 +37,16 @@ data class RoomEntity(
|
||||
val allowInHot: Boolean,
|
||||
val creator: CreatorEntity,
|
||||
val userCount: Int,
|
||||
val totalMemberCount: Int? = null,
|
||||
val maxMemberLimit: Int,
|
||||
val maxTotal: Int? = null,
|
||||
val systemMaxTotal: Int? = null,
|
||||
val canJoin: Boolean,
|
||||
val canJoinCode: Int,
|
||||
val privateFeePaid: Boolean = false,
|
||||
val prompts: List<PromptTemplateEntity> = emptyList(),
|
||||
val createdAt: String? = null,
|
||||
val updatedAt: String? = null,
|
||||
val users: List<UsersEntity>,
|
||||
)
|
||||
|
||||
@@ -56,6 +75,167 @@ data class ProfileEntity(
|
||||
val aiAccount: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* 房间规则创建者信息
|
||||
*/
|
||||
data class RoomRuleCreatorEntity(
|
||||
val id: Int,
|
||||
val nickname: String,
|
||||
val avatar: String,
|
||||
val avatarMedium: String? = null,
|
||||
val avatarLarge: String? = null,
|
||||
val avatarDirectUrl: String? = null,
|
||||
val avatarMediumDirectUrl: String? = null,
|
||||
val avatarLargeDirectUrl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 房间规则详情
|
||||
*/
|
||||
data class RoomRuleEntity(
|
||||
val id: Int,
|
||||
val rule: String,
|
||||
val creator: RoomRuleCreatorEntity?,
|
||||
val creatorType: String,
|
||||
val roomId: Int,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 房间规则配额信息
|
||||
*/
|
||||
data class RoomRuleQuotaEntity(
|
||||
val baseMaxCount: Int,
|
||||
val purchasedCount: Int,
|
||||
val totalMaxCount: Int,
|
||||
val currentCount: Int,
|
||||
val remainingCount: Int,
|
||||
val usagePercent: Double
|
||||
)
|
||||
|
||||
// ========== Room Member Management 实体类 ==========
|
||||
|
||||
/**
|
||||
* 添加用户成功项目
|
||||
*/
|
||||
data class AddUserToRoomItemEntity(
|
||||
val userId: String,
|
||||
val type: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加用户失败项目
|
||||
*/
|
||||
data class AddUserToRoomFailedItemEntity(
|
||||
val userId: String,
|
||||
val type: String,
|
||||
val error: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加用户到房间的结果
|
||||
*/
|
||||
data class AddUserToRoomResultEntity(
|
||||
val totalCount: Int,
|
||||
val successCount: Int,
|
||||
val failedCount: Int,
|
||||
val skippedCount: Int,
|
||||
val successItems: List<AddUserToRoomItemEntity>,
|
||||
val failedItems: List<AddUserToRoomFailedItemEntity>,
|
||||
val skippedItems: List<AddUserToRoomItemEntity>
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加智能体成功项目
|
||||
*/
|
||||
data class AddAgentToRoomItemEntity(
|
||||
val agentOpenId: String,
|
||||
val type: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加智能体失败项目
|
||||
*/
|
||||
data class AddAgentToRoomFailedItemEntity(
|
||||
val agentOpenId: String,
|
||||
val type: String,
|
||||
val error: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加智能体到房间的结果
|
||||
*/
|
||||
data class AddAgentToRoomResultEntity(
|
||||
val totalCount: Int,
|
||||
val successCount: Int,
|
||||
val failedCount: Int,
|
||||
val skippedCount: Int,
|
||||
val successItems: List<AddAgentToRoomItemEntity>,
|
||||
val failedItems: List<AddAgentToRoomFailedItemEntity>,
|
||||
val skippedItems: List<AddAgentToRoomItemEntity>
|
||||
)
|
||||
|
||||
/**
|
||||
* 移除智能体成功项目
|
||||
*/
|
||||
data class RemoveAgentFromRoomItemEntity(
|
||||
val agentOpenId: String,
|
||||
val type: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 移除智能体失败项目
|
||||
*/
|
||||
data class RemoveAgentFromRoomFailedItemEntity(
|
||||
val agentOpenId: String,
|
||||
val type: String,
|
||||
val error: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 从房间移除智能体的结果
|
||||
*/
|
||||
data class RemoveAgentFromRoomResultEntity(
|
||||
val totalCount: Int,
|
||||
val successCount: Int,
|
||||
val failedCount: Int,
|
||||
val skippedCount: Int,
|
||||
val successItems: List<RemoveAgentFromRoomItemEntity>,
|
||||
val failedItems: List<RemoveAgentFromRoomFailedItemEntity>,
|
||||
val skippedItems: List<RemoveAgentFromRoomItemEntity>
|
||||
)
|
||||
|
||||
/**
|
||||
* 移除用户成功项目
|
||||
*/
|
||||
data class RemoveUserFromRoomItemEntity(
|
||||
val userId: String,
|
||||
val type: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 移除用户失败项目
|
||||
*/
|
||||
data class RemoveUserFromRoomFailedItemEntity(
|
||||
val userId: String,
|
||||
val type: String,
|
||||
val error: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 从房间移除用户的结果
|
||||
*/
|
||||
data class RemoveUserFromRoomResultEntity(
|
||||
val totalCount: Int,
|
||||
val successCount: Int,
|
||||
val failedCount: Int,
|
||||
val skippedCount: Int,
|
||||
val successItems: List<RemoveUserFromRoomItemEntity>,
|
||||
val failedItems: List<RemoveUserFromRoomFailedItemEntity>,
|
||||
val skippedItems: List<RemoveUserFromRoomItemEntity>
|
||||
)
|
||||
|
||||
class RoomLoader : DataLoader<AgentEntity,AgentLoaderExtraArgs>() {
|
||||
override suspend fun fetchData(
|
||||
page: Int,
|
||||
@@ -83,3 +263,94 @@ class RoomLoader : DataLoader<AgentEntity,AgentLoaderExtraArgs>() {
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 房间远程数据源
|
||||
*/
|
||||
class RoomRemoteDataSource {
|
||||
suspend fun searchRooms(
|
||||
pageNumber: Int,
|
||||
pageSize: Int = 20,
|
||||
search: String
|
||||
): ListContainer<RoomEntity>? {
|
||||
return try {
|
||||
val resp = ApiClient.api.getRooms(
|
||||
page = pageNumber,
|
||||
pageSize = pageSize,
|
||||
search = search,
|
||||
roomType = "public" // 搜索时只显示公有房间
|
||||
)
|
||||
if (!resp.isSuccessful) {
|
||||
// API 调用失败,返回 null
|
||||
return null
|
||||
}
|
||||
val body = resp.body() ?: return null
|
||||
|
||||
// 安全地转换数据,过滤掉转换失败的项目
|
||||
val roomList = body.list.mapNotNull { room ->
|
||||
try {
|
||||
room.toRoomtEntity()
|
||||
} catch (e: Exception) {
|
||||
// 如果某个房间数据转换失败,记录错误但继续处理其他房间
|
||||
Log.e("RoomRemoteDataSource", "Failed to convert room: ${room.id}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
ListContainer(
|
||||
total = body.total,
|
||||
page = pageNumber,
|
||||
pageSize = pageSize,
|
||||
list = roomList
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// 捕获所有异常,返回 null 让 PagingSource 处理
|
||||
Log.e("RoomRemoteDataSource", "searchRooms error", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 房间搜索分页加载器
|
||||
*/
|
||||
class RoomSearchPagingSource(
|
||||
private val roomRemoteDataSource: RoomRemoteDataSource,
|
||||
private val keyword: String,
|
||||
) : PagingSource<Int, RoomEntity>() {
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, RoomEntity> {
|
||||
return try {
|
||||
val currentPage = params.key ?: 1
|
||||
val rooms = roomRemoteDataSource.searchRooms(
|
||||
pageNumber = currentPage,
|
||||
pageSize = params.loadSize,
|
||||
search = keyword
|
||||
)
|
||||
if (rooms == null) {
|
||||
// API 调用失败,返回空列表
|
||||
LoadResult.Page(
|
||||
data = emptyList(),
|
||||
prevKey = if (currentPage == 1) null else currentPage - 1,
|
||||
nextKey = null
|
||||
)
|
||||
} else {
|
||||
LoadResult.Page(
|
||||
data = rooms.list,
|
||||
prevKey = if (currentPage == 1) null else currentPage - 1,
|
||||
nextKey = if (rooms.list.isNotEmpty() && rooms.list.size >= params.loadSize) currentPage + 1 else null
|
||||
)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
// 捕获所有异常,包括 IOException、ServiceException 等
|
||||
LoadResult.Error(exception)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Int, RoomEntity>): Int? {
|
||||
// 更健壮的实现:根据 anchorPosition 计算刷新键
|
||||
return state.anchorPosition?.let { anchorPosition ->
|
||||
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
|
||||
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ fun Date.formatPostTime(): String {
|
||||
}
|
||||
|
||||
/**
|
||||
* YYYY.DD.MM HH:MM
|
||||
* yyyy-MM-dd HH:mm
|
||||
*/
|
||||
fun Date.formatPostTime2(): String {
|
||||
val calendar = Calendar.getInstance()
|
||||
@@ -58,7 +58,14 @@ fun Date.formatPostTime2(): String {
|
||||
val day = calendar.get(Calendar.DAY_OF_MONTH)
|
||||
val hour = calendar.get(Calendar.HOUR_OF_DAY)
|
||||
val minute = calendar.get(Calendar.MINUTE)
|
||||
return "$year.$month.$day $hour:$minute"
|
||||
|
||||
// 确保两位数
|
||||
val monthStr = String.format("%02d", month)
|
||||
val dayStr = String.format("%02d", day)
|
||||
val hourStr = String.format("%02d", hour)
|
||||
val minuteStr = String.format("%02d", minute)
|
||||
|
||||
return "$year-$monthStr-$dayStr $hourStr:$minuteStr"
|
||||
}
|
||||
|
||||
fun Date.formatChatTime(context: Context): String {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.aiosman.ravenow
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Configuration
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
|
||||
|
||||
/**
|
||||
@@ -24,10 +25,21 @@ object AppStore {
|
||||
.requestEmail()
|
||||
.build()
|
||||
googleSignInOptions = gso
|
||||
// apply dark mode
|
||||
if (sharedPreferences.getBoolean("darkMode", false)) {
|
||||
AppState.darkMode = true
|
||||
AppState.appTheme = DarkThemeColors()
|
||||
// apply dark mode - 如果用户未手动设置,优先跟随系统
|
||||
val hasUserPreference = sharedPreferences.contains("darkMode")
|
||||
val resolvedDarkMode = if (hasUserPreference) {
|
||||
sharedPreferences.getBoolean("darkMode", false)
|
||||
} else {
|
||||
val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||
currentNightMode == Configuration.UI_MODE_NIGHT_YES
|
||||
}
|
||||
AppState.darkMode = resolvedDarkMode
|
||||
AppState.appTheme = if (resolvedDarkMode) DarkThemeColors() else LightThemeColors()
|
||||
|
||||
// load chat background
|
||||
val savedBgUrl = sharedPreferences.getString("chatBackgroundUrl", null)
|
||||
if (savedBgUrl != null) {
|
||||
AppState.chatBackgroundUrl = savedBgUrl
|
||||
}
|
||||
|
||||
}
|
||||
@@ -54,5 +66,40 @@ object AppStore {
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun saveChatBackgroundUrl(url: String?) {
|
||||
sharedPreferences.edit().apply {
|
||||
if (url != null) {
|
||||
putString("chatBackgroundUrl", url)
|
||||
} else {
|
||||
remove("chatBackgroundUrl")
|
||||
}
|
||||
}.apply()
|
||||
AppState.chatBackgroundUrl = url
|
||||
}
|
||||
|
||||
// ===================== 用户本地扩展信息 =====================
|
||||
// 后端暂未提供 MBTI 与星座字段,使用本地持久化按用户维度进行存储
|
||||
private fun mbtiKey(userId: Int) = "mbti_user_$userId"
|
||||
private fun zodiacKey(userId: Int) = "zodiac_user_$userId"
|
||||
|
||||
fun getUserMbti(userId: Int): String? {
|
||||
return sharedPreferences.getString(mbtiKey(userId), null)
|
||||
}
|
||||
|
||||
fun setUserMbti(userId: Int, mbti: String?) {
|
||||
sharedPreferences.edit().apply {
|
||||
if (mbti.isNullOrEmpty()) remove(mbtiKey(userId)) else putString(mbtiKey(userId), mbti)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun getUserZodiac(userId: Int): String? {
|
||||
return sharedPreferences.getString(zodiacKey(userId), null)
|
||||
}
|
||||
|
||||
fun setUserZodiac(userId: Int, zodiac: String?) {
|
||||
sharedPreferences.edit().apply {
|
||||
if (zodiac.isNullOrEmpty()) remove(zodiacKey(userId)) else putString(zodiacKey(userId), zodiac)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -35,16 +35,22 @@ import com.aiosman.ravenow.LocalSharedTransitionScope
|
||||
import com.aiosman.ravenow.ui.about.AboutScreen
|
||||
import com.aiosman.ravenow.ui.account.AccountEditScreen2
|
||||
import com.aiosman.ravenow.ui.account.AccountSetting
|
||||
import com.aiosman.ravenow.ui.account.BlockedUsersScreen
|
||||
import com.aiosman.ravenow.ui.account.MbtiSelectScreen
|
||||
import com.aiosman.ravenow.ui.account.RemoveAccountScreen
|
||||
import com.aiosman.ravenow.ui.account.ResetPasswordScreen
|
||||
import com.aiosman.ravenow.ui.account.ZodiacSelectScreen
|
||||
import com.aiosman.ravenow.ui.agent.AddAgentScreen
|
||||
import com.aiosman.ravenow.ui.agent.AgentImageCropScreen
|
||||
import com.aiosman.ravenow.ui.agent.AiPromptEditScreen
|
||||
import com.aiosman.ravenow.ui.group.CreateGroupChatScreen
|
||||
import com.aiosman.ravenow.ui.chat.ChatAiScreen
|
||||
import com.aiosman.ravenow.ui.chat.ChatSettingScreen
|
||||
import com.aiosman.ravenow.ui.chat.ChatScreen
|
||||
import com.aiosman.ravenow.ui.chat.GroupChatScreen
|
||||
import com.aiosman.ravenow.ui.comment.CommentsScreen
|
||||
import com.aiosman.ravenow.ui.comment.notice.CommentNoticeScreen
|
||||
import com.aiosman.ravenow.ui.composables.AgentCreatedSuccessIndicator
|
||||
import com.aiosman.ravenow.ui.crop.ImageCropScreen
|
||||
import com.aiosman.ravenow.ui.favourite.FavouriteListPage
|
||||
import com.aiosman.ravenow.ui.favourite.FavouriteNoticeScreen
|
||||
@@ -55,6 +61,9 @@ import com.aiosman.ravenow.ui.gallery.OfficialGalleryScreen
|
||||
import com.aiosman.ravenow.ui.gallery.OfficialPhotographerScreen
|
||||
import com.aiosman.ravenow.ui.gallery.ProfileTimelineScreen
|
||||
import com.aiosman.ravenow.ui.group.GroupChatInfoScreen
|
||||
import com.aiosman.ravenow.ui.group.GroupMembersScreen
|
||||
import com.aiosman.ravenow.ui.group.AddGroupMemberScreen
|
||||
import com.aiosman.ravenow.ui.group.GroupProfileSettingsScreen
|
||||
import com.aiosman.ravenow.ui.index.IndexScreen
|
||||
import com.aiosman.ravenow.ui.index.tabs.message.NotificationsScreen
|
||||
import com.aiosman.ravenow.ui.index.tabs.search.SearchScreen
|
||||
@@ -69,7 +78,10 @@ import com.aiosman.ravenow.ui.post.NewPostImageGridScreen
|
||||
import com.aiosman.ravenow.ui.post.NewPostScreen
|
||||
import com.aiosman.ravenow.ui.post.PostScreen
|
||||
import com.aiosman.ravenow.ui.profile.AccountProfileV2
|
||||
import com.aiosman.ravenow.ui.profile.AiProfileWrap
|
||||
import com.aiosman.ravenow.ui.index.tabs.profile.vip.VipSelPage
|
||||
import com.aiosman.ravenow.ui.notification.NotificationScreen
|
||||
import com.aiosman.ravenow.ui.scan.ScanQrScreen
|
||||
|
||||
sealed class NavigationRoute(
|
||||
val route: String,
|
||||
@@ -104,6 +116,7 @@ sealed class NavigationRoute(
|
||||
data object FavouriteList : NavigationRoute("FavouriteList")
|
||||
data object Chat : NavigationRoute("Chat/{id}")
|
||||
data object ChatAi : NavigationRoute("ChatAi/{id}")
|
||||
data object ChatSetting : NavigationRoute("ChatSetting")
|
||||
data object ChatGroup : NavigationRoute("ChatGroup/{id}/{name}/{avatar}")
|
||||
data object CommentNoticeScreen : NavigationRoute("CommentNoticeScreen")
|
||||
data object ImageCrop : NavigationRoute("ImageCrop")
|
||||
@@ -113,8 +126,17 @@ sealed class NavigationRoute(
|
||||
data object AddAgent : NavigationRoute("AddAgent")
|
||||
data object CreateGroupChat : NavigationRoute("CreateGroupChat")
|
||||
data object GroupInfo : NavigationRoute("GroupInfo/{id}")
|
||||
data object GroupMembers : NavigationRoute("GroupMembers/{id}")
|
||||
data object AddGroupMember : NavigationRoute("AddGroupMember/{groupId}/{groupName}")
|
||||
data object GroupProfileSettings : NavigationRoute("GroupProfileSettings/{id}")
|
||||
data object VipSelPage : NavigationRoute("VipSelPage")
|
||||
data object RemoveAccountScreen: NavigationRoute("RemoveAccount")
|
||||
data object NotificationScreen : NavigationRoute("NotificationScreen")
|
||||
data object MbtiSelect : NavigationRoute("MbtiSelect")
|
||||
data object ZodiacSelect : NavigationRoute("ZodiacSelect")
|
||||
data object ScanQr : NavigationRoute("ScanQr")
|
||||
data object AiPromptEdit : NavigationRoute("AiPromptEdit/{chatAIId}")
|
||||
data object BlockedUsersScreen : NavigationRoute("BlockedUsersScreen")
|
||||
}
|
||||
|
||||
|
||||
@@ -328,9 +350,15 @@ fun NavigationController(
|
||||
) {
|
||||
val id = it.arguments?.getString("id")!!
|
||||
val isAiAccount = it.arguments?.getBoolean("isAiAccount") ?: false
|
||||
|
||||
// 根据isAiAccount参数分发到不同的Profile页面
|
||||
if (isAiAccount) {
|
||||
AiProfileWrap(id)
|
||||
} else {
|
||||
AccountProfileV2(id, isAiAccount)
|
||||
}
|
||||
}
|
||||
}
|
||||
composable(
|
||||
route = NavigationRoute.SignUp.route,
|
||||
enterTransition = {
|
||||
@@ -413,9 +441,18 @@ fun NavigationController(
|
||||
composable(route = NavigationRoute.ChangePasswordScreen.route) {
|
||||
ChangePasswordScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.BlockedUsersScreen.route) {
|
||||
BlockedUsersScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.RemoveAccountScreen.route) {
|
||||
RemoveAccountScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.MbtiSelect.route) {
|
||||
MbtiSelectScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.ZodiacSelect.route) {
|
||||
ZodiacSelectScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.VipSelPage.route) {
|
||||
VipSelPage()
|
||||
}
|
||||
@@ -432,6 +469,9 @@ fun NavigationController(
|
||||
SearchScreen()
|
||||
}
|
||||
}
|
||||
composable(route = NavigationRoute.ScanQr.route) {
|
||||
ScanQrScreen()
|
||||
}
|
||||
composable(
|
||||
route = NavigationRoute.FollowerList.route,
|
||||
arguments = listOf(navArgument("id") { type = NavType.IntType })
|
||||
@@ -488,6 +528,14 @@ fun NavigationController(
|
||||
}
|
||||
}
|
||||
|
||||
composable(route = NavigationRoute.ChatSetting.route) {
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
ChatSettingScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composable(
|
||||
route = NavigationRoute.ChatGroup.route,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType },
|
||||
@@ -590,6 +638,70 @@ fun NavigationController(
|
||||
}
|
||||
}
|
||||
|
||||
composable(
|
||||
route = NavigationRoute.GroupMembers.route,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
||||
) {
|
||||
val encodedId = it.arguments?.getString("id")
|
||||
val decodedId = encodedId?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
GroupMembersScreen(decodedId ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
composable(
|
||||
route = NavigationRoute.AddGroupMember.route,
|
||||
arguments = listOf(
|
||||
navArgument("groupId") { type = NavType.StringType },
|
||||
navArgument("groupName") { type = NavType.StringType }
|
||||
)
|
||||
) {
|
||||
val encodedGroupId = it.arguments?.getString("groupId")
|
||||
val decodedGroupId = encodedGroupId?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
||||
val encodedGroupName = it.arguments?.getString("groupName")
|
||||
val decodedGroupName = encodedGroupName?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
AddGroupMemberScreen(decodedGroupId ?: "", decodedGroupName)
|
||||
}
|
||||
}
|
||||
|
||||
composable(
|
||||
route = NavigationRoute.GroupProfileSettings.route,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
||||
) {
|
||||
val encodedId = it.arguments?.getString("id")
|
||||
val decodedId = encodedId?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
GroupProfileSettingsScreen(decodedId ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
composable(route = NavigationRoute.NotificationScreen.route) {
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
NotificationScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composable(
|
||||
route = NavigationRoute.AiPromptEdit.route,
|
||||
arguments = listOf(navArgument("chatAIId") { type = NavType.StringType })
|
||||
) {
|
||||
val chatAIId = it.arguments?.getString("chatAIId") ?: ""
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
AiPromptEditScreen(chatAIId = chatAIId)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -615,6 +727,7 @@ fun Navigation(
|
||||
navController = navController,
|
||||
startDestination = startDestination
|
||||
)
|
||||
AgentCreatedSuccessIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -670,6 +783,34 @@ fun NavHostController.navigateToGroupInfo(id: String) {
|
||||
)
|
||||
}
|
||||
|
||||
fun NavHostController.navigateToGroupMembers(id: String) {
|
||||
val encodedId = java.net.URLEncoder.encode(id, "UTF-8")
|
||||
|
||||
navigate(
|
||||
route = NavigationRoute.GroupMembers.route
|
||||
.replace("{id}", encodedId)
|
||||
)
|
||||
}
|
||||
|
||||
fun NavHostController.navigateToAddGroupMember(groupId: String, groupName: String?) {
|
||||
val encodedGroupId = java.net.URLEncoder.encode(groupId, "UTF-8")
|
||||
val encodedGroupName = java.net.URLEncoder.encode(groupName ?: "", "UTF-8")
|
||||
|
||||
navigate(
|
||||
route = NavigationRoute.AddGroupMember.route
|
||||
.replace("{groupId}", encodedGroupId)
|
||||
.replace("{groupName}", encodedGroupName)
|
||||
)
|
||||
}
|
||||
|
||||
fun NavHostController.navigateToGroupProfileSettings(id: String) {
|
||||
val encodedId = java.net.URLEncoder.encode(id, "UTF-8")
|
||||
navigate(
|
||||
route = NavigationRoute.GroupProfileSettings.route
|
||||
.replace("{id}", encodedId)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.aiosman.ravenow.ui.about
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -40,22 +41,21 @@ fun AboutScreen() {
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
NoticeScreenHeader(
|
||||
title = stringResource(R.string.about_rave_now),
|
||||
title = stringResource(R.string.about_paipai),
|
||||
moreIcon = false
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
// app icon
|
||||
// app icondww
|
||||
Box {
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.rider_pro_color_logo_next),
|
||||
painter = painterResource(id = R.mipmap.invalid_name),
|
||||
contentDescription = "app icon",
|
||||
modifier = Modifier.size(80.dp)
|
||||
)
|
||||
@@ -63,7 +63,7 @@ fun AboutScreen() {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
// app name
|
||||
Text(
|
||||
text = "Rave Now".uppercase(),
|
||||
text = stringResource(R.string.paipai),
|
||||
fontSize = 24.sp,
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.ExtraBold
|
||||
@@ -71,7 +71,7 @@ fun AboutScreen() {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
// app version
|
||||
Text(
|
||||
text = stringResource(R.string.version_text, versionText),
|
||||
text = stringResource(R.string.version_text, versionText ?: ""),
|
||||
fontSize = 16.sp,
|
||||
color = appColors.secondaryText,
|
||||
fontWeight = FontWeight.Normal
|
||||
|
||||
@@ -21,12 +21,22 @@ object AccountEditViewModel : ViewModel() {
|
||||
var name by mutableStateOf("")
|
||||
var bio by mutableStateOf("")
|
||||
var imageUrl by mutableStateOf<Uri?>(null)
|
||||
var bannerImageUrl by mutableStateOf<Uri?>(null)
|
||||
var bannerFile by mutableStateOf<File?>(null)
|
||||
val accountService: AccountService = AccountServiceImpl()
|
||||
var profile by mutableStateOf<AccountProfileEntity?>(null)
|
||||
var croppedBitmap by mutableStateOf<Bitmap?>(null)
|
||||
var isUpdating by mutableStateOf(false)
|
||||
var isLoading by mutableStateOf(false)
|
||||
suspend fun reloadProfile(updateTrtcProfile:Boolean = false) {
|
||||
// 本地扩展字段
|
||||
var mbti by mutableStateOf<String?>(null)
|
||||
var zodiac by mutableStateOf<String?>(null)
|
||||
// 保存原始值,用于取消时恢复
|
||||
private var originalName: String = ""
|
||||
private var originalBio: String = ""
|
||||
private var originalMbti: String? = null
|
||||
private var originalZodiac: String? = null
|
||||
suspend fun reloadProfile(updateTrtcProfile:Boolean = false, clearCroppedBitmap: Boolean = false) {
|
||||
Log.d("AccountEditViewModel", "reloadProfile: 开始加载用户资料")
|
||||
isLoading = true
|
||||
try {
|
||||
@@ -36,8 +46,24 @@ object AccountEditViewModel : ViewModel() {
|
||||
profile = it
|
||||
name = it.nickName
|
||||
bio = it.bio
|
||||
// 清除之前裁剪的图片
|
||||
// 保存原始值,用于取消时恢复
|
||||
originalName = it.nickName
|
||||
originalBio = it.bio
|
||||
// 只在明确要求时清除之前裁剪的图片(例如保存成功后)
|
||||
if (clearCroppedBitmap) {
|
||||
croppedBitmap = null
|
||||
}
|
||||
// 读取本地扩展字段
|
||||
try {
|
||||
val uid = it.id // 使用 profile 的 id,确保非空
|
||||
val loadedMbti = com.aiosman.ravenow.AppStore.getUserMbti(uid)
|
||||
val loadedZodiac = com.aiosman.ravenow.AppStore.getUserZodiac(uid)
|
||||
mbti = loadedMbti
|
||||
zodiac = loadedZodiac
|
||||
// 保存原始值
|
||||
originalMbti = loadedMbti
|
||||
originalZodiac = loadedZodiac
|
||||
} catch (_: Exception) { }
|
||||
if (updateTrtcProfile) {
|
||||
TrtcHelper.updateTrtcProfile(
|
||||
it.nickName,
|
||||
@@ -58,12 +84,15 @@ object AccountEditViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun resetToOriginalData() {
|
||||
profile?.let {
|
||||
name = it.nickName
|
||||
bio = it.bio
|
||||
// 清除之前裁剪的图片
|
||||
// 恢复所有字段到原始值
|
||||
name = originalName
|
||||
bio = originalBio
|
||||
mbti = originalMbti
|
||||
zodiac = originalZodiac
|
||||
// 清除之前裁剪的图片和壁纸
|
||||
croppedBitmap = null
|
||||
}
|
||||
bannerImageUrl = null
|
||||
bannerFile = null
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +102,30 @@ object AccountEditViewModel : ViewModel() {
|
||||
it.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream())
|
||||
UploadImage(file, "avatar.jpg", "", "jpg")
|
||||
}
|
||||
|
||||
// 处理背景图更新
|
||||
val newBanner = bannerImageUrl?.let { uri ->
|
||||
bannerFile?.let { file ->
|
||||
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
||||
var uploadBanner: UploadImage? = null
|
||||
cursor?.use { cur ->
|
||||
val columnIndex = cur.getColumnIndex("_display_name")
|
||||
if (cur.moveToFirst() && columnIndex != -1) {
|
||||
val displayName = cur.getString(columnIndex)
|
||||
val extension = displayName.substringAfterLast(".")
|
||||
Log.d("AccountEditViewModel", "Banner file name: $displayName, extension: $extension")
|
||||
uploadBanner = UploadImage(file, displayName, uri.toString(), extension)
|
||||
} else {
|
||||
// 如果无法获取文件名,使用默认值
|
||||
val displayName = "banner.jpg"
|
||||
val extension = "jpg"
|
||||
uploadBanner = UploadImage(file, displayName, uri.toString(), extension)
|
||||
}
|
||||
}
|
||||
uploadBanner
|
||||
}
|
||||
}
|
||||
|
||||
// 去除换行符,确保昵称和个人简介不包含换行
|
||||
val cleanName = name.trim().replace("\n", "").replace("\r", "")
|
||||
val cleanBio = bio.trim().replace("\n", "").replace("\r", "")
|
||||
@@ -80,12 +133,22 @@ object AccountEditViewModel : ViewModel() {
|
||||
val newName = if (cleanName == profile?.nickName) null else cleanName
|
||||
accountService.updateProfile(
|
||||
avatar = newAvatar,
|
||||
banner = null,
|
||||
banner = newBanner,
|
||||
nickName = newName,
|
||||
bio = cleanBio
|
||||
)
|
||||
// 刷新用户资料
|
||||
reloadProfile()
|
||||
// 保存本地扩展字段
|
||||
try {
|
||||
profile?.id?.let { uid ->
|
||||
com.aiosman.ravenow.AppStore.setUserMbti(uid, mbti)
|
||||
com.aiosman.ravenow.AppStore.setUserZodiac(uid, zodiac)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
// 清除背景图状态
|
||||
bannerImageUrl = null
|
||||
bannerFile = null
|
||||
// 刷新用户资料,保存成功后清除裁剪的图片
|
||||
reloadProfile(clearCroppedBitmap = true)
|
||||
// 刷新个人资料页面的用户资料
|
||||
MyProfileViewModel.loadUserProfile()
|
||||
}
|
||||
@@ -100,6 +163,8 @@ object AccountEditViewModel : ViewModel() {
|
||||
name = ""
|
||||
bio = ""
|
||||
imageUrl = null
|
||||
bannerImageUrl = null
|
||||
bannerFile = null
|
||||
croppedBitmap = null
|
||||
isUpdating = false
|
||||
isLoading = false
|
||||
|
||||
@@ -1,117 +1,175 @@
|
||||
package com.aiosman.ravenow.ui.account
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.AppState
|
||||
import com.aiosman.ravenow.AppStore
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.Messaging
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
||||
import com.aiosman.ravenow.ui.composables.ActionButton
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.index.NavItem
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private object AccountSettingConstants {
|
||||
const val BACK_BUTTON_SIZE = 36
|
||||
const val BACK_BUTTON_ICON_SIZE = 24
|
||||
const val BACK_BUTTON_START_PADDING = 19
|
||||
const val OPTION_ITEM_HEIGHT = 56
|
||||
const val OPTION_ITEM_ICON_SIZE = 24
|
||||
const val OPTION_ITEM_HORIZONTAL_PADDING = 16
|
||||
const val OPTION_ITEM_ICON_TEXT_SPACING = 12
|
||||
const val OPTION_ITEM_TEXT_SIZE = 17
|
||||
const val HEADER_VERTICAL_PADDING = 16
|
||||
const val TITLE_OFFSET_X = 19
|
||||
const val CARD_CORNER_RADIUS = 16
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CircularBackButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val appColors = LocalAppTheme.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(AccountSettingConstants.BACK_BUTTON_SIZE.dp)
|
||||
.background(
|
||||
color = appColors.secondaryBackground,
|
||||
shape = CircleShape
|
||||
)
|
||||
.noRippleClickable { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_back_icon),
|
||||
contentDescription = "返回",
|
||||
modifier = Modifier.size(AccountSettingConstants.BACK_BUTTON_ICON_SIZE.dp),
|
||||
colorFilter = ColorFilter.tint(appColors.text)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecurityOptionItem(
|
||||
iconRes: Int,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
applyColorFilter: Boolean = true
|
||||
) {
|
||||
val appColors = LocalAppTheme.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(AccountSettingConstants.OPTION_ITEM_HEIGHT.dp)
|
||||
.padding(horizontal = AccountSettingConstants.OPTION_ITEM_HORIZONTAL_PADDING.dp)
|
||||
.noRippleClickable { onClick() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(AccountSettingConstants.OPTION_ITEM_ICON_SIZE.dp),
|
||||
colorFilter = if (applyColorFilter) ColorFilter.tint(appColors.text) else null
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier
|
||||
.padding(start = AccountSettingConstants.OPTION_ITEM_ICON_TEXT_SPACING.dp)
|
||||
.weight(1f),
|
||||
color = appColors.text,
|
||||
fontSize = AccountSettingConstants.OPTION_ITEM_TEXT_SIZE.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rave_now_nav_right),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(AccountSettingConstants.OPTION_ITEM_ICON_SIZE.dp),
|
||||
colorFilter = ColorFilter.tint(appColors.secondaryText)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountSetting() {
|
||||
val appColors = LocalAppTheme.current
|
||||
val navController = LocalNavController.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(appColors.background),
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
|
||||
// 顶部标题栏
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = AccountSettingConstants.HEADER_VERTICAL_PADDING.dp)
|
||||
) {
|
||||
NoticeScreenHeader(
|
||||
title = stringResource(R.string.account_and_security),
|
||||
moreIcon = false
|
||||
CircularBackButton(
|
||||
onClick = { navController.navigateUp() },
|
||||
modifier = Modifier.padding(start = AccountSettingConstants.BACK_BUTTON_START_PADDING.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.account_and_security),
|
||||
fontWeight = FontWeight.W800,
|
||||
fontSize = AccountSettingConstants.OPTION_ITEM_TEXT_SIZE.sp,
|
||||
color = appColors.text,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset(x = AccountSettingConstants.TITLE_OFFSET_X.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 安全选项卡片
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 24.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.background(
|
||||
color = appColors.background,
|
||||
shape = RoundedCornerShape(AccountSettingConstants.CARD_CORNER_RADIUS.dp)
|
||||
)
|
||||
) {
|
||||
NavItem(
|
||||
iconRes = R.mipmap.rider_pro_change_password,
|
||||
SecurityOptionItem(
|
||||
iconRes = R.mipmap.icons_padlock,
|
||||
label = stringResource(R.string.change_password),
|
||||
modifier = Modifier.noRippleClickable {
|
||||
navController.navigate(NavigationRoute.ChangePasswordScreen.route)
|
||||
}
|
||||
onClick = { navController.navigate(NavigationRoute.ChangePasswordScreen.route) }
|
||||
)
|
||||
}
|
||||
|
||||
// 分割线
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(appColors.divider)
|
||||
SecurityOptionItem(
|
||||
iconRes = R.mipmap.icons_block,
|
||||
label = stringResource(R.string.blocked_users),
|
||||
onClick = { navController.navigate(NavigationRoute.BlockedUsersScreen.route) }
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
NavItem(
|
||||
iconRes = R.drawable.rider_pro_moment_delete,
|
||||
|
||||
SecurityOptionItem(
|
||||
iconRes = R.mipmap.icons_remove,
|
||||
label = stringResource(R.string.remove_account),
|
||||
modifier = Modifier.noRippleClickable {
|
||||
navController.navigate(NavigationRoute.RemoveAccountScreen.route)
|
||||
}
|
||||
onClick = { navController.navigate(NavigationRoute.RemoveAccountScreen.route) },
|
||||
applyColorFilter = false
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(appColors.divider)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.aiosman.ravenow.ui.account
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
private object BlockedUsersConstants {
|
||||
const val BACK_BUTTON_SIZE = 36
|
||||
const val BACK_BUTTON_ICON_SIZE = 24
|
||||
const val BACK_BUTTON_START_PADDING = 19
|
||||
const val HEADER_VERTICAL_PADDING = 16
|
||||
const val TITLE_OFFSET_X = 19
|
||||
const val TITLE_TEXT_SIZE = 17
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CircularBackButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val appColors = LocalAppTheme.current
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_back_icon),
|
||||
contentDescription = "返回",
|
||||
modifier = modifier
|
||||
.size(BlockedUsersConstants.BACK_BUTTON_ICON_SIZE.dp)
|
||||
.noRippleClickable { onClick() },
|
||||
colorFilter = ColorFilter.tint(appColors.text)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 被屏蔽的用户界面
|
||||
*/
|
||||
@Composable
|
||||
fun BlockedUsersScreen() {
|
||||
val appColors = LocalAppTheme.current
|
||||
val navController = LocalNavController.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(appColors.background),
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
|
||||
// 顶部标题栏
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = BlockedUsersConstants.HEADER_VERTICAL_PADDING.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularBackButton(
|
||||
onClick = { navController.navigateUp() },
|
||||
modifier = Modifier.padding(start = BlockedUsersConstants.BACK_BUTTON_START_PADDING.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.blocked_users),
|
||||
fontWeight = FontWeight.W800,
|
||||
fontSize = BlockedUsersConstants.TITLE_TEXT_SIZE.sp,
|
||||
color = appColors.text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 缺省状态
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 149.dp),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.frame_23),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(181.dp, 153.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(9.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.no_users_isolated_yet),
|
||||
color = appColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.aiosman.ravenow.ui.account
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
||||
@Composable
|
||||
fun MbtiBottomSheetHost() {
|
||||
val show = MbtiSheetManager.visible.collectAsState(false).value
|
||||
if (show) {
|
||||
MbtiSelectBottomSheet(
|
||||
onClose = { MbtiSheetManager.close() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
package com.aiosman.ravenow.ui.account
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.AppState
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
// MBTI类型列表
|
||||
val MBTI_TYPES = listOf(
|
||||
"INTJ", "INTP", "ENTJ", "ENTP",
|
||||
"INFJ", "INFP", "ENFJ", "ENFP",
|
||||
"ISTJ", "ISFJ", "ESTJ", "ESFJ",
|
||||
"ISTP", "ISFP", "ESTP", "ESFP"
|
||||
)
|
||||
|
||||
fun getMbtiImageResId(mbti: String, isDarkMode: Boolean): Int {
|
||||
return when {
|
||||
isDarkMode && mbti == "ENTP" -> R.mipmap.anmbti_entp
|
||||
isDarkMode && mbti == "ESTP" -> R.mipmap.anmbti_estp
|
||||
isDarkMode && mbti == "ENTJ" -> R.mipmap.anmbti_entj
|
||||
else -> when (mbti) {
|
||||
"INTJ" -> R.mipmap.mbti_intj
|
||||
"INTP" -> R.mipmap.mbti_intp
|
||||
"ENTJ" -> R.mipmap.mbti_entj
|
||||
"ENTP" -> R.mipmap.mbti_entp
|
||||
"INFJ" -> R.mipmap.mbti_infj
|
||||
"INFP" -> R.mipmap.mbti_infp
|
||||
"ENFJ" -> R.mipmap.mbti_enfj
|
||||
"ENFP" -> R.mipmap.mbti_enfp
|
||||
"ISTJ" -> R.mipmap.mbti_istj
|
||||
"ISFJ" -> R.mipmap.mbti_isfj
|
||||
"ESTJ" -> R.mipmap.mbti_estj
|
||||
"ESFJ" -> R.mipmap.mbti_esfj
|
||||
"ISTP" -> R.mipmap.mbti_istp
|
||||
"ISFP" -> R.mipmap.mbti_isfp
|
||||
"ESTP" -> R.mipmap.mbti_estp
|
||||
"ESFP" -> R.mipmap.mbti_esfp
|
||||
else -> R.mipmap.xingzuo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MbtiSelectBottomSheet(
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val appColors = LocalAppTheme.current
|
||||
val isDarkMode = AppState.darkMode
|
||||
val model = AccountEditViewModel
|
||||
val currentMbti = model.mbti
|
||||
val sheetBackgroundColor = if (isDarkMode) {
|
||||
appColors.secondaryBackground
|
||||
} else {
|
||||
Color(0xFFFFFFFF)
|
||||
}
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
// 确保弹窗展开
|
||||
LaunchedEffect(Unit) {
|
||||
sheetState.expand()
|
||||
}
|
||||
|
||||
// 监听状态变化,确保弹窗始终展开(防止拖拽关闭和滑动)
|
||||
LaunchedEffect(sheetState.currentValue, sheetState.targetValue, sheetState.isVisible) {
|
||||
// 如果弹窗被拖拽关闭或位置发生变化,立即重新展开
|
||||
if (!sheetState.isVisible || sheetState.targetValue != androidx.compose.material3.SheetValue.Expanded) {
|
||||
kotlinx.coroutines.delay(10) // 短暂延迟确保状态更新
|
||||
sheetState.expand()
|
||||
}
|
||||
}
|
||||
|
||||
val statusBarPadding = WindowInsets.systemBars.asPaddingValues()
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenHeight = configuration.screenHeightDp.dp
|
||||
val offsetY = screenHeight * 0.07f - statusBarPadding.calculateTopPadding()
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onClose,
|
||||
sheetState = sheetState,
|
||||
containerColor = sheetBackgroundColor,
|
||||
dragHandle = null
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.95f)
|
||||
.offset(y = offsetY)
|
||||
.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 8.dp
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
// 头部 - 使用 Box 实现绝对居中布局
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val cancelButtonGradientColors = if (isDarkMode) {
|
||||
listOf(
|
||||
Color(0xFF3A3A3C),
|
||||
Color(0xFF2C2C2E)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Color(0xFFFFFFFF),
|
||||
Color(0xFFF8F8F8)
|
||||
)
|
||||
}
|
||||
val cancelButtonContentColor = if (isDarkMode) Color(0xFFFFFFFF) else Color(0xFF404040)
|
||||
|
||||
// 左上角返回按钮
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.height(36.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = cancelButtonGradientColors
|
||||
)
|
||||
)
|
||||
.noRippleClickable { onClose() }
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 左箭头图标
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_back_icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(17.dp),
|
||||
colorFilter = ColorFilter.tint(cancelButtonContentColor)
|
||||
)
|
||||
|
||||
// "取消" 文字
|
||||
Text(
|
||||
text = "取消",
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = cancelButtonContentColor,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
// 中间标题 - 绝对居中
|
||||
Text(
|
||||
text = stringResource(R.string.choose_mbti),
|
||||
color = appColors.text,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// 创建 NestedScrollConnection 来阻止滚动事件向上传播到 ModalBottomSheet
|
||||
val nestedScrollConnection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
// 不消费任何事件,让 LazyColumn 先处理
|
||||
return Offset.Zero
|
||||
}
|
||||
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
||||
// 消费 LazyColumn 处理后的剩余滚动事件,防止传递到 ModalBottomSheet
|
||||
return available
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
// 不消费惯性滚动,让 LazyColumn 先处理
|
||||
return Velocity.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
// 消费 LazyColumn 处理后的剩余惯性滚动,防止传递到 ModalBottomSheet
|
||||
return available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MBTI解释文字背景色
|
||||
val descriptionBackgroundColor = if (isDarkMode) {
|
||||
Color(0xFF2A2A2A) // 比 secondaryBackground (0xFF1C1C1C) 更亮的灰色
|
||||
} else {
|
||||
Color(0xFFFAF9FB)
|
||||
}
|
||||
|
||||
// 使用LazyColumn包裹解释文字和MBTI类型网格,使它们一起滚动
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.nestedScroll(nestedScrollConnection),
|
||||
contentPadding = PaddingValues(
|
||||
start = 8.dp,
|
||||
top = 0.dp,
|
||||
end = 8.dp,
|
||||
bottom = 8.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// MBTI解释文字 - 作为第一个item
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(descriptionBackgroundColor)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.mbti_description),
|
||||
color = appColors.text,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MBTI类型网格 - 手动创建2列网格布局
|
||||
itemsIndexed(MBTI_TYPES.chunked(2)) { rowIndex, rowItems ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = if (rowIndex < MBTI_TYPES.chunked(2).size - 1) 10.dp else 0.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
rowItems.forEachIndexed { colIndex, mbti ->
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
MbtiItem(
|
||||
mbti = mbti,
|
||||
isSelected = mbti == currentMbti,
|
||||
onClick = {
|
||||
// 保存MBTI类型
|
||||
model.mbti = mbti
|
||||
onClose()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
// 如果这一行只有1个item,添加一个空的Spacer来保持布局
|
||||
if (rowItems.size == 1) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保留原有的 MbtiSelectScreen 用于导航路由(如果需要)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MbtiSelectScreen() {
|
||||
val navController = LocalNavController.current
|
||||
MbtiSelectBottomSheet(
|
||||
onClose = {
|
||||
navController.navigateUp()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MbtiItem(
|
||||
mbti: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val appColors = LocalAppTheme.current
|
||||
val isDarkMode = AppState.darkMode
|
||||
|
||||
// 卡片背景色
|
||||
val cardBackgroundColor = if (isDarkMode) {
|
||||
Color(0xFF2A2A2A) // 比 secondaryBackground (0xFF1C1C1C) 更亮的灰色
|
||||
} else {
|
||||
Color(0xFFFAF9FB)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1.1f)
|
||||
.shadow(
|
||||
elevation = if (isDarkMode) 8.dp else 2.dp,
|
||||
shape = RoundedCornerShape(21.dp),
|
||||
spotColor = if (isDarkMode) Color.Black.copy(alpha = 0.5f) else Color.Black.copy(alpha = 0.1f)
|
||||
)
|
||||
.clip(RoundedCornerShape(21.dp))
|
||||
.background(cardBackgroundColor)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
onClick()
|
||||
}
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// MBTI图标 - 使用占位图片
|
||||
Box(
|
||||
modifier = Modifier.size(100.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = getMbtiImageResId(mbti, isDarkMode)),
|
||||
contentDescription = mbti,
|
||||
modifier = Modifier.size(100.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// MBTI名称 - 使用负间距让文本向上移动,与图标更靠近
|
||||
Text(
|
||||
text = mbti,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = appColors.text,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.offset(y = (-20).dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.aiosman.ravenow.ui.account
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
object MbtiSheetManager {
|
||||
private val _visible = MutableStateFlow(false)
|
||||
val visible: StateFlow<Boolean> = _visible.asStateFlow()
|
||||
|
||||
fun open() {
|
||||
_visible.value = true
|
||||
}
|
||||
|
||||
fun close() {
|
||||
_visible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,14 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.AppState
|
||||
import com.aiosman.ravenow.ConstVars
|
||||
import com.aiosman.ravenow.data.api.ErrorCode
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
@@ -99,10 +101,11 @@ fun ResetPasswordScreen() {
|
||||
if (e.code == ErrorCode.USER_NOT_EXIST.code){
|
||||
usernameError = context.getString(R.string.error_40002_user_not_exist)
|
||||
} else {
|
||||
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
|
||||
// 其他错误,不显示Toast
|
||||
isSendSuccess = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
|
||||
// 异常错误,不显示Toast
|
||||
isSendSuccess = false
|
||||
} finally {
|
||||
isLoading = false
|
||||
@@ -133,12 +136,21 @@ fun ResetPasswordScreen() {
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// 暗色模式下的 hint 文本颜色
|
||||
val isDarkMode = AppState.darkMode
|
||||
val hintColor = if (isDarkMode) {
|
||||
Color(0xFFFFFFFF).copy(alpha = 0.7f)
|
||||
} else {
|
||||
null // 使用默认颜色
|
||||
}
|
||||
|
||||
TextInputField(
|
||||
text = username,
|
||||
onValueChange = { username = it },
|
||||
hint = stringResource(R.string.text_hint_email),
|
||||
enabled = !isLoading && countDown == null,
|
||||
error = usernameError,
|
||||
customHintColor = hintColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Box(
|
||||
@@ -178,9 +190,11 @@ fun ResetPasswordScreen() {
|
||||
} else {
|
||||
stringResource(R.string.recover)
|
||||
},
|
||||
backgroundColor = appColors.main,
|
||||
backgroundColor = Color(0xFF7C45ED), // 紫色背景
|
||||
loadingBackgroundColor = Color(0xFF7C45ED), // loading 时保持紫色
|
||||
disabledBackgroundColor = Color(0xFF7C45ED), // disabled 时保持紫色
|
||||
color = appColors.mainText,
|
||||
isLoading = isLoading,
|
||||
isLoading = isLoading && countDown == null, // 只在未发送成功时显示loading
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
enabled = countDown == null,
|
||||
) {
|
||||
@@ -193,6 +207,8 @@ fun ResetPasswordScreen() {
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
text = stringResource(R.string.back_upper),
|
||||
backgroundColor = Color(0xFF7C45ED), // 紫色背景
|
||||
color = Color.White, // 白色文字
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
navController.navigateUp()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.aiosman.ravenow.ui.account
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
||||
@Composable
|
||||
fun ZodiacBottomSheetHost() {
|
||||
val show = ZodiacSheetManager.visible.collectAsState(false).value
|
||||
if (show) {
|
||||
ZodiacSelectBottomSheet(
|
||||
onClose = { ZodiacSheetManager.close() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
package com.aiosman.ravenow.ui.account
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.AppState
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
// 星座资源ID列表
|
||||
val ZODIAC_SIGN_RES_IDS = listOf(
|
||||
R.string.zodiac_aries,
|
||||
R.string.zodiac_taurus,
|
||||
R.string.zodiac_gemini,
|
||||
R.string.zodiac_cancer,
|
||||
R.string.zodiac_leo,
|
||||
R.string.zodiac_virgo,
|
||||
R.string.zodiac_libra,
|
||||
R.string.zodiac_scorpio,
|
||||
R.string.zodiac_sagittarius,
|
||||
R.string.zodiac_capricorn,
|
||||
R.string.zodiac_aquarius,
|
||||
R.string.zodiac_pisces
|
||||
)
|
||||
|
||||
/**
|
||||
* 根据星座资源ID获取对应的图片资源ID
|
||||
*/
|
||||
fun getZodiacImageResId(zodiacResId: Int): Int {
|
||||
return when (zodiacResId) {
|
||||
R.string.zodiac_aries -> R.mipmap.baiyang
|
||||
R.string.zodiac_taurus -> R.mipmap.jingniu
|
||||
R.string.zodiac_gemini -> R.mipmap.shuangzi
|
||||
R.string.zodiac_cancer -> R.mipmap.juxie
|
||||
R.string.zodiac_leo -> R.mipmap.shizi
|
||||
R.string.zodiac_virgo -> R.mipmap.chunv
|
||||
R.string.zodiac_libra -> R.mipmap.tiancheng
|
||||
R.string.zodiac_scorpio -> R.mipmap.tianxie
|
||||
R.string.zodiac_sagittarius -> R.mipmap.sheshou
|
||||
R.string.zodiac_capricorn -> R.mipmap.moxie
|
||||
R.string.zodiac_aquarius -> R.mipmap.shuiping
|
||||
R.string.zodiac_pisces -> R.mipmap.shuangyu
|
||||
else -> R.mipmap.xingzuo // 默认使用占位图片
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据存储的星座字符串(可能是任何语言)找到对应的资源ID
|
||||
* 如果找不到,返回null
|
||||
*/
|
||||
@Composable
|
||||
fun findZodiacResId(storedZodiac: String?): Int? {
|
||||
if (storedZodiac.isNullOrEmpty()) return null
|
||||
|
||||
// 尝试在所有语言的资源中查找匹配
|
||||
ZODIAC_SIGN_RES_IDS.forEachIndexed { index, resId ->
|
||||
val zodiacText = stringResource(resId)
|
||||
if (zodiacText == storedZodiac) {
|
||||
return resId
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找不到精确匹配,尝试通过资源ID索引查找(兼容旧数据)
|
||||
// 这里可以根据需要添加更多兼容逻辑
|
||||
return null
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ZodiacSelectBottomSheet(
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val appColors = LocalAppTheme.current
|
||||
val isDarkMode = AppState.darkMode
|
||||
val model = AccountEditViewModel
|
||||
val currentZodiacResId = findZodiacResId(model.zodiac)
|
||||
val sheetBackgroundColor = if (isDarkMode) {
|
||||
appColors.secondaryBackground
|
||||
} else {
|
||||
Color(0xFFFFFFFF)
|
||||
}
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
// 确保弹窗展开
|
||||
LaunchedEffect(Unit) {
|
||||
sheetState.expand()
|
||||
}
|
||||
|
||||
// 监听状态变化,确保弹窗始终展开(防止拖拽关闭和滑动)
|
||||
LaunchedEffect(sheetState.currentValue, sheetState.targetValue, sheetState.isVisible) {
|
||||
// 如果弹窗被拖拽关闭或位置发生变化,立即重新展开
|
||||
if (!sheetState.isVisible || sheetState.targetValue != androidx.compose.material3.SheetValue.Expanded) {
|
||||
kotlinx.coroutines.delay(10) // 短暂延迟确保状态更新
|
||||
sheetState.expand()
|
||||
}
|
||||
}
|
||||
|
||||
val statusBarPadding = WindowInsets.systemBars.asPaddingValues()
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenHeight = configuration.screenHeightDp.dp
|
||||
val offsetY = screenHeight * 0.07f - statusBarPadding.calculateTopPadding()
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onClose,
|
||||
sheetState = sheetState,
|
||||
containerColor = sheetBackgroundColor, // 根据主题自适应背景
|
||||
dragHandle = null
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.95f)
|
||||
.offset(y = offsetY)
|
||||
.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 8.dp
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
// 头部 - 使用 Box 实现绝对居中布局
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val cancelButtonGradientColors = if (isDarkMode) {
|
||||
listOf(
|
||||
Color(0xFF3A3A3C),
|
||||
Color(0xFF2C2C2E)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Color(0xFFFFFFFF),
|
||||
Color(0xFFF8F8F8)
|
||||
)
|
||||
}
|
||||
val cancelButtonContentColor = if (isDarkMode) Color(0xFFFFFFFF) else Color(0xFF404040)
|
||||
|
||||
// 左上角返回按钮 - 根据 Swift 代码样式,带淡灰色渐变背景
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.height(36.dp)
|
||||
.clip(RoundedCornerShape(18.dp)) // 圆角 100.0 在 36dp 高度下接近完全圆角
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = cancelButtonGradientColors
|
||||
// 不指定 start 和 end,默认从左上到右下
|
||||
)
|
||||
)
|
||||
.noRippleClickable { onClose() }
|
||||
.padding(horizontal = 8.dp), // 内部 padding 确保内容不贴边
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 左箭头图标
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_back_icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(17.dp),
|
||||
colorFilter = ColorFilter.tint(cancelButtonContentColor)
|
||||
)
|
||||
|
||||
// "取消" 文字
|
||||
Text(
|
||||
text = "取消",
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = cancelButtonContentColor,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
// 中间标题 - 绝对居中
|
||||
Text(
|
||||
text = stringResource(R.string.choose_zodiac),
|
||||
color = appColors.text,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// 创建 NestedScrollConnection 来阻止滚动事件向上传播到 ModalBottomSheet
|
||||
val nestedScrollConnection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
// 不消费任何事件,让 LazyVerticalGrid 先处理
|
||||
return Offset.Zero
|
||||
}
|
||||
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
||||
// 消费 LazyVerticalGrid 处理后的剩余滚动事件,防止传递到 ModalBottomSheet
|
||||
return available
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
// 不消费惯性滚动,让 LazyVerticalGrid 先处理
|
||||
return Velocity.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
// 消费 LazyVerticalGrid 处理后的剩余惯性滚动,防止传递到 ModalBottomSheet
|
||||
return available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 网格列表 - 2列
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.nestedScroll(nestedScrollConnection),
|
||||
contentPadding = PaddingValues(
|
||||
start = 8.dp,
|
||||
top = 8.dp,
|
||||
end = 8.dp,
|
||||
bottom = 8.dp
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
itemsIndexed(ZODIAC_SIGN_RES_IDS) { index, zodiacResId ->
|
||||
val zodiacText = stringResource(zodiacResId)
|
||||
ZodiacItem(
|
||||
zodiac = zodiacText,
|
||||
zodiacResId = zodiacResId,
|
||||
isSelected = zodiacResId == currentZodiacResId,
|
||||
onClick = {
|
||||
// 保存当前语言的星座文本
|
||||
model.zodiac = zodiacText
|
||||
onClose()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保留原有的 ZodiacSelectScreen 用于导航路由(如果需要)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ZodiacSelectScreen() {
|
||||
val navController = com.aiosman.ravenow.LocalNavController.current
|
||||
ZodiacSelectBottomSheet(
|
||||
onClose = {
|
||||
navController.navigateUp()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ZodiacItem(
|
||||
zodiac: String,
|
||||
zodiacResId: Int,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val appColors = LocalAppTheme.current
|
||||
val isDarkMode = AppState.darkMode
|
||||
|
||||
// 卡片背景色:浅灰色 (250, 249, 251)
|
||||
// 暗色模式下使用比背景色更亮的颜色,以形成对比
|
||||
val cardBackgroundColor = if (isDarkMode) {
|
||||
Color(0xFF2A2A2A) // 比 secondaryBackground (0xFF1C1C1C) 更亮的灰色
|
||||
} else {
|
||||
Color(0xFFFAF9FB)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1.1f) // 增加宽高比,使高度相对更低
|
||||
.shadow(
|
||||
elevation = if (isDarkMode) 8.dp else 2.dp, // 深色模式下更强的阴影
|
||||
shape = RoundedCornerShape(21.dp),
|
||||
spotColor = if (isDarkMode) Color.Black.copy(alpha = 0.5f) else Color.Black.copy(alpha = 0.1f)
|
||||
)
|
||||
.clip(RoundedCornerShape(21.dp))
|
||||
.background(cardBackgroundColor)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
onClick()
|
||||
}
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp), // 减小垂直padding,确保文本不被遮挡
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// 星座图标 - 使用对应星座的图片
|
||||
Box(
|
||||
modifier = Modifier.size(100.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = getZodiacImageResId(zodiacResId)),
|
||||
contentDescription = zodiac,
|
||||
modifier = Modifier.size(100.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 星座名称 - 使用负间距让文本向上移动,与图标更靠近
|
||||
Text(
|
||||
text = zodiac,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = appColors.text,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.offset(y = (-20).dp) // 负间距,让文本进一步向上移动
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.aiosman.ravenow.ui.account
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
object ZodiacSheetManager {
|
||||
private val _visible = MutableStateFlow(false)
|
||||
val visible: StateFlow<Boolean> = _visible.asStateFlow()
|
||||
|
||||
fun open() {
|
||||
_visible.value = true
|
||||
}
|
||||
|
||||
fun close() {
|
||||
_visible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,11 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aiosman.ravenow.AppState
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
@@ -68,6 +70,19 @@ fun ChangePasswordScreen() {
|
||||
var confirmPasswordError by remember { mutableStateOf<String?>(null) }
|
||||
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||
val AppColors = LocalAppTheme.current
|
||||
// 暗色模式下的 hint 文本颜色
|
||||
val isDarkMode = AppState.darkMode
|
||||
val hintColor = if (isDarkMode) {
|
||||
Color(0xFFFFFFFF).copy(alpha = 0.7f)
|
||||
} else {
|
||||
null // 使用默认颜色
|
||||
}
|
||||
val labelColor = if (isDarkMode) {
|
||||
Color(0xFFFFFFFF).copy(alpha = 0.7f)
|
||||
} else {
|
||||
null // 使用默认颜色
|
||||
}
|
||||
|
||||
fun validate(): Boolean {
|
||||
// 使用通用密码校验器校验当前密码
|
||||
val currentPasswordValidation = PasswordValidator.validateCurrentPassword(currentPassword, context)
|
||||
@@ -112,7 +127,9 @@ fun ChangePasswordScreen() {
|
||||
password = true,
|
||||
label = stringResource(R.string.current_password),
|
||||
hint = stringResource(R.string.current_password_tip5),
|
||||
error = oldPasswordError
|
||||
error = oldPasswordError,
|
||||
customHintColor = hintColor,
|
||||
customLabelColor = labelColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
TextInputField(
|
||||
@@ -121,7 +138,9 @@ fun ChangePasswordScreen() {
|
||||
password = true,
|
||||
label = stringResource(R.string.new_password),
|
||||
hint = stringResource(R.string.new_password),
|
||||
error = passwordError
|
||||
error = passwordError,
|
||||
customHintColor = hintColor,
|
||||
customLabelColor = labelColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
TextInputField(
|
||||
@@ -130,7 +149,9 @@ fun ChangePasswordScreen() {
|
||||
password = true,
|
||||
label = stringResource(R.string.confirm_new_password_tip1),
|
||||
hint = stringResource(R.string.new_password_tip1),
|
||||
error = confirmPasswordError
|
||||
error = confirmPasswordError,
|
||||
customHintColor = hintColor,
|
||||
customLabelColor = labelColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
ActionButton(
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
package com.aiosman.ravenow.ui.account
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Edit as EditIcon
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -25,10 +35,14 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aiosman.ravenow.AppState
|
||||
import com.aiosman.ravenow.AppStore
|
||||
@@ -36,23 +50,43 @@ import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.composables.form.FormTextInput
|
||||
import com.aiosman.ravenow.ui.composables.debouncedClickable
|
||||
import com.aiosman.ravenow.ui.composables.rememberDebouncedNavigation
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import com.aiosman.ravenow.ConstVars
|
||||
import com.aiosman.ravenow.ui.composables.pickupAndCompressLauncher
|
||||
import android.widget.Toast
|
||||
import java.io.File
|
||||
import androidx.activity.compose.BackHandler
|
||||
import com.aiosman.ravenow.ui.account.ZodiacBottomSheetHost
|
||||
import com.aiosman.ravenow.ui.account.ZodiacSheetManager
|
||||
import com.aiosman.ravenow.ui.account.MbtiBottomSheetHost
|
||||
import com.aiosman.ravenow.ui.account.MbtiSheetManager
|
||||
|
||||
/**
|
||||
* 编辑用户资料界面
|
||||
*/
|
||||
@Composable
|
||||
fun AccountEditScreen2() {
|
||||
fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,) {
|
||||
val model = AccountEditViewModel
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
@@ -61,14 +95,42 @@ fun AccountEditScreen2() {
|
||||
|
||||
// 防抖导航器
|
||||
val debouncedNavigation = rememberDebouncedNavigation()
|
||||
|
||||
// 添加图片选择启动器
|
||||
val scope = rememberCoroutineScope()
|
||||
val pickBannerImageLauncher = pickupAndCompressLauncher(
|
||||
context,
|
||||
scope,
|
||||
maxSize = ConstVars.BANNER_IMAGE_MAX_SIZE,
|
||||
quality = 100
|
||||
) { uri, file ->
|
||||
// 处理选中的图片
|
||||
// 保存到 ViewModel 中,等待保存时一起上传
|
||||
model.bannerImageUrl = uri
|
||||
model.bannerFile = file
|
||||
// 如果提供了回调,也调用它(用于个人主页直接更新)
|
||||
onUpdateBanner?.invoke(uri, file, context)
|
||||
}
|
||||
|
||||
fun onNicknameChange(value: String) {
|
||||
// 去除换行符,确保昵称不包含换行
|
||||
val cleanValue = value.replace("\n", "").replace("\r", "")
|
||||
model.name = cleanValue
|
||||
// 实时验证,但不显示错误(只在保存时显示)
|
||||
usernameError = when {
|
||||
cleanValue.trim().isEmpty() -> "昵称不能为空"
|
||||
cleanValue.length < 3 -> "昵称长度不能小于3"
|
||||
cleanValue.length > 20 -> "昵称长度不能大于20"
|
||||
cleanValue.trim().isEmpty() -> context.getString(R.string.error_nickname_empty)
|
||||
cleanValue.length < 3 -> context.getString(R.string.error_nickname_too_short)
|
||||
cleanValue.length > 20 -> context.getString(R.string.error_nickname_too_long)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun validateNickname(): String? {
|
||||
val cleanValue = model.name.replace("\n", "").replace("\r", "")
|
||||
return when {
|
||||
cleanValue.trim().isEmpty() -> context.getString(R.string.error_nickname_empty)
|
||||
cleanValue.length < 3 -> context.getString(R.string.error_nickname_too_short)
|
||||
cleanValue.length > 20 -> context.getString(R.string.error_nickname_too_long)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -79,8 +141,17 @@ fun AccountEditScreen2() {
|
||||
// 去除换行符,确保个人简介不包含换行
|
||||
val cleanValue = value.replace("\n", "").replace("\r", "")
|
||||
model.bio = cleanValue
|
||||
// 实时验证,但不显示错误(只在保存时显示)
|
||||
bioError = when {
|
||||
cleanValue.length > 100 -> "个人简介长度不能大于100"
|
||||
cleanValue.length > 100 -> context.getString(R.string.error_bio_too_long)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun validateBio(): String? {
|
||||
val cleanValue = model.bio.replace("\n", "").replace("\r", "")
|
||||
return when {
|
||||
cleanValue.length > 100 -> context.getString(R.string.error_bio_too_long)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -106,131 +177,40 @@ fun AccountEditScreen2() {
|
||||
// 确保显示的是当前登录用户的信息,而不是之前用户的缓存数据
|
||||
model.reloadProfile()
|
||||
}
|
||||
StatusBarMaskLayout(
|
||||
modifier = Modifier.background(color = appColors.background).padding(horizontal = 16.dp),
|
||||
darkIcons = !AppState.darkMode,
|
||||
maskBoxBackgroundColor = appColors.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = appColors.background),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
//StatusBarSpacer()
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 0.dp, vertical = 16.dp)
|
||||
) {
|
||||
NoticeScreenHeader(
|
||||
title = stringResource(R.string.edit_profile),
|
||||
moreIcon = false
|
||||
) {
|
||||
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.debouncedClickable(
|
||||
enabled = validate() && !model.isUpdating,
|
||||
debounceTime = 1000L
|
||||
) {
|
||||
if (validate() && !model.isUpdating) {
|
||||
model.viewModelScope.launch {
|
||||
model.isUpdating = true
|
||||
model.updateUserProfile(context)
|
||||
model.viewModelScope.launch(Dispatchers.Main) {
|
||||
debouncedNavigation {
|
||||
// 处理系统返回键
|
||||
BackHandler {
|
||||
// 用户未保存直接返回,恢复所有字段到原始值
|
||||
model.resetToOriginalData()
|
||||
navController.navigateUp()
|
||||
}
|
||||
model.isUpdating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "保存",
|
||||
tint = if (validate() && !model.isUpdating) appColors.text else appColors.nonActiveText
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(44.dp))
|
||||
|
||||
// 显示内容或加载状态
|
||||
Log.d("AccountEditScreen2", "UI状态 - profile: ${model.profile?.nickName}, isLoading: ${model.isLoading}")
|
||||
when {
|
||||
model.profile != null -> {
|
||||
Log.d("AccountEditScreen2", "显示用户资料内容")
|
||||
// 有数据时显示内容
|
||||
val it = model.profile!!
|
||||
Box(
|
||||
modifier = Modifier.size(88.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
// 设置状态栏为透明,根据暗色模式决定图标颜色
|
||||
val systemUiController = rememberSystemUiController()
|
||||
LaunchedEffect(Unit) {
|
||||
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode)
|
||||
}
|
||||
|
||||
StatusBarMaskLayout(
|
||||
modifier = Modifier.background(appColors.background),
|
||||
darkIcons = !AppState.darkMode, // 根据暗色模式决定图标颜色
|
||||
maskBoxBackgroundColor = Color.Transparent
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
model.croppedBitmap ?: it.avatar,
|
||||
modifier = Modifier
|
||||
.size(88.dp)
|
||||
.clip(
|
||||
RoundedCornerShape(88.dp)
|
||||
),
|
||||
contentDescription = "",
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(appColors.main)
|
||||
.align(Alignment.BottomEnd)
|
||||
.debouncedClickable(
|
||||
debounceTime = 800L
|
||||
) {
|
||||
debouncedNavigation {
|
||||
navController.navigate(NavigationRoute.ImageCrop.route)
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Add",
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(58.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
FormTextInput(
|
||||
value = model.name,
|
||||
label = stringResource(R.string.nickname),
|
||||
hint = "Input nickname",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
error = usernameError
|
||||
) { value ->
|
||||
onNicknameChange(value)
|
||||
}
|
||||
FormTextInput(
|
||||
value = model.bio,
|
||||
label = stringResource(R.string.bio),
|
||||
hint = "Input bio",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
error = bioError
|
||||
) { value ->
|
||||
onBioChange(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
model.isLoading -> {
|
||||
Log.d("AccountEditScreen2", "显示加载指示器")
|
||||
// 加载中状态
|
||||
// 挂载星座选择弹窗
|
||||
ZodiacBottomSheetHost()
|
||||
// 挂载MBTI选择弹窗
|
||||
MbtiBottomSheetHost()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
.background(appColors.background)
|
||||
) {
|
||||
when {
|
||||
model.isLoading -> {
|
||||
// 加载中状态
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
@@ -238,24 +218,519 @@ fun AccountEditScreen2() {
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.d("AccountEditScreen2", "显示错误信息 - 没有数据且不在加载中")
|
||||
// 没有数据且不在加载中,显示错误信息
|
||||
model.profile != null -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// 顶部背景区域(圆角在底部,覆盖状态栏)
|
||||
// 优先显示新选择的背景图,如果没有则显示原有的背景图
|
||||
val banner = model.bannerImageUrl?.toString() ?: model.profile?.banner
|
||||
val statusBarPadding = WindowInsets.systemBars.asPaddingValues().calculateTopPadding()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.offset(y = -statusBarPadding)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(402.dp)
|
||||
.height(206.dp)
|
||||
.align(Alignment.TopCenter)
|
||||
.clip(RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp))
|
||||
) {
|
||||
if (banner != null) {
|
||||
CustomAsyncImage(
|
||||
context = context,
|
||||
imageUrl = banner,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentDescription = "Banner",
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
.background(Color.Gray.copy(alpha = 0.2f))
|
||||
)
|
||||
}
|
||||
|
||||
// 更换封面按钮(位于壁纸右下方)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = 20.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0x5C7D7C80)) // RGB(125, 120, 128, 0.36)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.noRippleClickable {
|
||||
Intent(Intent.ACTION_PICK).apply {
|
||||
type = "image/*"
|
||||
pickBannerImageLauncher.launch(this)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
// 更换封面图标
|
||||
Icon(
|
||||
painter = painterResource(id = R.mipmap.fengm),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.change_cover),
|
||||
fontSize = 12.sp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 状态栏区域(时间、信号、电池)
|
||||
// 这里使用系统状态栏,不单独实现
|
||||
|
||||
// 导航栏区域
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(statusBarPadding + 12.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp)
|
||||
) {
|
||||
// 返回按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.3f))
|
||||
.noRippleClickable {
|
||||
// 用户未保存直接返回,恢复所有字段到原始值
|
||||
model.resetToOriginalData()
|
||||
navController.navigateUp()
|
||||
}
|
||||
.align(Alignment.CenterStart),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
androidx.compose.material3.Text(
|
||||
text = "加载用户资料失败,请重试",
|
||||
color = appColors.text
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_back_icon),
|
||||
contentDescription = "Back",
|
||||
modifier = Modifier.size(24.dp),
|
||||
colorFilter = ColorFilter.tint(Color.White)
|
||||
)
|
||||
}
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
text = stringResource(R.string.edit_profile_info),
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用户头像区域(距离顶部-50dp,包含状态栏高度,左右居中)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.offset(y = (-50).dp - statusBarPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val it = model.profile!!
|
||||
Box(
|
||||
modifier = Modifier.size(96.dp),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
// 头像
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
model.croppedBitmap ?: it.avatar,
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.clip(CircleShape)
|
||||
.border(2.4.dp, appColors.background, CircleShape),
|
||||
contentDescription = "",
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
// 编辑头像按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF110C13))
|
||||
.border(2.dp, Color.White, CircleShape)
|
||||
.debouncedClickable(debounceTime = 800L) {
|
||||
debouncedNavigation {
|
||||
navController.navigate(NavigationRoute.ImageCrop.route)
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.mipmap.bi),
|
||||
contentDescription = "Edit Avatar",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp)
|
||||
.offset(y = (-74).dp)//
|
||||
) {
|
||||
// 昵称输入框
|
||||
ProfileInfoCard(
|
||||
label = stringResource(R.string.nickname),
|
||||
value = model.name,
|
||||
placeholder = stringResource(R.string.nickname_placeholder),
|
||||
onValueChange = { onNicknameChange(it) },
|
||||
isMultiline = false
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 个人简介输入框
|
||||
ProfileInfoCard(
|
||||
label = stringResource(R.string.personal_intro),
|
||||
value = model.bio,
|
||||
placeholder = "",
|
||||
onValueChange = { onBioChange(it) },
|
||||
isMultiline = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// MBTI 类型和星座
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(appColors.secondaryBackground)
|
||||
) {
|
||||
// MBTI 类型
|
||||
ProfileSelectItem(
|
||||
label = stringResource(R.string.mbti_type),
|
||||
value = model.mbti ?: "ENFP",
|
||||
iconColor = Color(0xFF7C45ED),
|
||||
iconResDark = null, // TODO: 添加MBTI暗色模式图标
|
||||
iconResLight = null, // TODO: 添加MBTI亮色模式图标
|
||||
onClick = {
|
||||
MbtiSheetManager.open()
|
||||
}
|
||||
)
|
||||
|
||||
// 分隔线
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(0.3.dp)
|
||||
.background(appColors.divider)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
// 星座(使用当前图标)
|
||||
ProfileSelectItem(
|
||||
label = stringResource(R.string.zodiac),
|
||||
value = model.zodiac?.let { storedZodiac ->
|
||||
// 尝试找到对应的资源ID并显示当前语言的文本
|
||||
findZodiacResId(storedZodiac)?.let { resId ->
|
||||
stringResource(resId)
|
||||
} ?: storedZodiac // 如果找不到,显示原始存储的值
|
||||
} ?: stringResource(R.string.zodiac_aries),
|
||||
iconColor = Color(0xFFFFCC00),
|
||||
iconResDark = R.mipmap.frame_4, // 星座暗色模式图标
|
||||
iconResLight = R.mipmap.xingzuo, // 星座亮色模式图标
|
||||
onClick = {
|
||||
ZodiacSheetManager.open()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// 保存按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
.clip(RoundedCornerShape(1000.dp))
|
||||
.background(
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF7C45ED), // RGB(124, 69, 237) - 左侧
|
||||
Color(0xFF7C57EE), // RGB(124, 87, 238) - 中间
|
||||
Color(0xFF7BD8F8) // RGB(123, 216, 248) - 右侧
|
||||
)
|
||||
)
|
||||
)
|
||||
.debouncedClickable(
|
||||
enabled = !model.isUpdating,
|
||||
debounceTime = 1000L
|
||||
) {
|
||||
if (model.isUpdating) return@debouncedClickable
|
||||
|
||||
// 点击保存时重新验证
|
||||
val nicknameErrorMsg = validateNickname()
|
||||
val bioErrorMsg = validateBio()
|
||||
|
||||
// 如果有错误,显示对应的错误提示
|
||||
when {
|
||||
nicknameErrorMsg != null -> {
|
||||
Toast.makeText(context, nicknameErrorMsg, Toast.LENGTH_SHORT).show()
|
||||
return@debouncedClickable
|
||||
}
|
||||
bioErrorMsg != null -> {
|
||||
Toast.makeText(context, bioErrorMsg, Toast.LENGTH_SHORT).show()
|
||||
return@debouncedClickable
|
||||
}
|
||||
}
|
||||
|
||||
// 验证通过,执行保存
|
||||
model.viewModelScope.launch {
|
||||
model.isUpdating = true
|
||||
try {
|
||||
model.updateUserProfile(context)
|
||||
model.viewModelScope.launch(Dispatchers.Main) {
|
||||
debouncedNavigation {
|
||||
navController.navigateUp()
|
||||
}
|
||||
model.isUpdating = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 捕获所有异常,包括网络异常
|
||||
model.viewModelScope.launch(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.network_error_check_network),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
model.isUpdating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.save),
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// 没有数据且不在加载中,显示错误信息
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.error_load_profile_failed),
|
||||
color = appColors.text
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 信息输入卡片组件
|
||||
*/
|
||||
@Composable
|
||||
fun ProfileInfoCard(
|
||||
label: String,
|
||||
value: String,
|
||||
placeholder: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
isMultiline: Boolean = false
|
||||
) {
|
||||
val appColors = LocalAppTheme.current
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
var lineCount by remember { mutableStateOf(1) }
|
||||
|
||||
// 根据行数决定对齐方式:单行时居中,多行时顶部对齐
|
||||
val verticalAlignment = if (isMultiline) {
|
||||
if (lineCount <= 1) Alignment.CenterVertically else Alignment.Top
|
||||
} else {
|
||||
Alignment.CenterVertically
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(if (isMultiline) 66.dp else 56.dp) // 昵称框高度56dp,个人简介66dp
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(appColors.secondaryBackground),
|
||||
contentAlignment = if (isMultiline && lineCount > 1) Alignment.TopStart else Alignment.CenterStart
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(vertical = if (isMultiline && lineCount > 1) 11.dp else 0.dp),
|
||||
verticalAlignment = verticalAlignment
|
||||
) {
|
||||
// 标签
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(100.dp)
|
||||
.height(if (isMultiline) 44.dp else 56.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = appColors.text
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
// 输入框
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
// 对于个人简介(isMultiline = true),当值为空且没有焦点时显示图标
|
||||
if (value.isEmpty() && isMultiline && !isFocused) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(44.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.mipmap.icons_infor_edit),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = appColors.secondaryText
|
||||
)
|
||||
}
|
||||
} else if (value.isEmpty() && !isMultiline && placeholder.isNotEmpty()) {
|
||||
// 对于非多行输入框,仍然显示 placeholder 文字
|
||||
Text(
|
||||
text = placeholder,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = appColors.secondaryText,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focusState ->
|
||||
isFocused = focusState.isFocused
|
||||
},
|
||||
textStyle = androidx.compose.ui.text.TextStyle(
|
||||
fontSize = if (isMultiline) 15.sp else 17.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = appColors.text
|
||||
),
|
||||
cursorBrush = SolidColor(appColors.text),
|
||||
maxLines = if (isMultiline) Int.MAX_VALUE else 1,
|
||||
singleLine = !isMultiline,
|
||||
onTextLayout = { textLayoutResult: TextLayoutResult ->
|
||||
if (isMultiline) {
|
||||
lineCount = textLayoutResult.lineCount
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择项组件(MBTI、星座)
|
||||
*/
|
||||
@Composable
|
||||
fun ProfileSelectItem(
|
||||
label: String,
|
||||
value: String,
|
||||
iconColor: Color,
|
||||
onClick: () -> Unit,
|
||||
iconResDark: Int? = null,
|
||||
iconResLight: Int? = null
|
||||
) {
|
||||
val appColors = LocalAppTheme.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// 自定义图标
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = if (AppState.darkMode) {
|
||||
// 暗色模式下使用和亮色模式一样的图标
|
||||
iconResLight ?: iconResDark ?: R.mipmap.naoz
|
||||
} else {
|
||||
iconResLight ?: R.mipmap.naoz // 使用传入的亮色模式图标,或默认占位
|
||||
}
|
||||
),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = iconColor
|
||||
)
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = appColors.text
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = appColors.secondaryText
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowForward,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(8.dp),
|
||||
tint = appColors.secondaryText
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -50,6 +51,14 @@ fun RemoveAccountScreen() {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
// 暗色模式下的 hint 文本颜色
|
||||
val isDarkMode = AppState.darkMode
|
||||
val hintColor = if (isDarkMode) {
|
||||
Color(0xFFFFFFFF).copy(alpha = 0.7f)
|
||||
} else {
|
||||
null // 使用默认颜色
|
||||
}
|
||||
|
||||
fun removeAccount(password: String) {
|
||||
// 使用通用密码校验器
|
||||
val passwordValidation = PasswordValidator.validateCurrentPassword(password, context)
|
||||
@@ -132,7 +141,8 @@ fun RemoveAccountScreen() {
|
||||
},
|
||||
password = true,
|
||||
hint = stringResource(R.string.remove_account_password_hint),
|
||||
error = passwordError
|
||||
error = passwordError,
|
||||
customHintColor = hintColor
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -29,11 +30,16 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -61,7 +67,28 @@ import com.aiosman.ravenow.ui.composables.form.FormTextInput2
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import com.aiosman.ravenow.AppState
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.StartOffset
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import com.aiosman.ravenow.ui.agent.AddAgentViewModel.showManualCreation
|
||||
import com.airbnb.lottie.compose.LottieAnimation
|
||||
import com.airbnb.lottie.compose.LottieCompositionSpec
|
||||
import com.airbnb.lottie.compose.LottieConstants
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
/**
|
||||
* 添加智能体界面
|
||||
*/
|
||||
@@ -73,8 +100,12 @@ fun AddAgentScreen() {
|
||||
var agnetNameError by remember { mutableStateOf<String?>(null) }
|
||||
var agnetDescError by remember { mutableStateOf<String?>(null) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
|
||||
var isProcessing by remember { mutableStateOf(false) }
|
||||
var showWaveAnimation by remember { mutableStateOf(false) }
|
||||
var isCreatingAgent by remember { mutableStateOf(false) } // 控制是否处于创建状态
|
||||
var showManualCreationForm by remember { mutableStateOf(false) } // 控制是否显示手动创建表单
|
||||
var tempDesc by remember { mutableStateOf("") } // 独立的临时描述变量
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
fun onNameChange(value: String) {
|
||||
model.name = value.trim()
|
||||
@@ -88,15 +119,37 @@ fun AddAgentScreen() {
|
||||
fun onDescChange(value: String) {
|
||||
model.desc = value.trim()
|
||||
agnetDescError = when {
|
||||
value.length > 100 -> "简介长度不能大于100"
|
||||
value.length > 512 -> "简介长度不能大于512"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
fun onTempDescChange(value: String) {
|
||||
tempDesc = value.trim()
|
||||
agnetDescError = when {
|
||||
value.length > 512 -> "简介长度不能大于512"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun validate(): Boolean {
|
||||
return agnetNameError == null && agnetDescError == null
|
||||
}
|
||||
|
||||
// AI文案优化
|
||||
suspend fun optimizeTextWithAI(content: String): String? {
|
||||
return try {
|
||||
val sessionId = ""
|
||||
val response = com.aiosman.ravenow.data.api.ApiClient.api.agentMoment(
|
||||
com.aiosman.ravenow.data.api.AgentMomentRequestBody(
|
||||
generateText = content,
|
||||
sessionId = sessionId
|
||||
)
|
||||
)
|
||||
response.body()?.data
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理系统返回键
|
||||
BackHandler {
|
||||
@@ -110,7 +163,22 @@ fun AddAgentScreen() {
|
||||
// 页面进入时重置头像选择状态
|
||||
LaunchedEffect(Unit) {
|
||||
model.isSelectingAvatar = false
|
||||
// 根据标记恢复相应的状态
|
||||
if (model.isAutoModeManualForm) {
|
||||
// 恢复自动模式下的手动表单状态
|
||||
showManualCreationForm = model.showManualCreationForm
|
||||
isCreatingAgent = model.isCreatingAgent
|
||||
showWaveAnimation = model.showWaveAnimation
|
||||
showManualCreation = model.showManualCreation
|
||||
} else {
|
||||
// 恢复手动模式下的状态
|
||||
showManualCreation = model.showManualCreation
|
||||
showManualCreationForm = model.showManualCreationForm
|
||||
isCreatingAgent = model.isCreatingAgent
|
||||
showWaveAnimation = model.showWaveAnimation
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -118,9 +186,12 @@ fun AddAgentScreen() {
|
||||
.background(color = appColors.decentBackground),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
var showManualCreation by remember {
|
||||
mutableStateOf(model.showManualCreation)
|
||||
}
|
||||
StatusBarSpacer()
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 16.dp)
|
||||
.background(color = appColors.decentBackground)
|
||||
) {
|
||||
// 自定义header,控制返回按钮行为
|
||||
@@ -148,80 +219,298 @@ fun AddAgentScreen() {
|
||||
stringResource(R.string.agent_add),
|
||||
fontWeight = FontWeight.W600,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 17.sp,
|
||||
color = appColors.text
|
||||
)
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Icon(
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(1.dp))
|
||||
|
||||
if (!isCreatingAgent) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.noRippleClickable {
|
||||
// 提交创建智能体的逻辑可以在这里实现
|
||||
},
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Add",
|
||||
tint = appColors.text
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(44.dp))
|
||||
Box(
|
||||
modifier = Modifier.size(88.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
.padding(horizontal = 20.dp),
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
model.croppedBitmap,
|
||||
modifier = Modifier
|
||||
.size(88.dp)
|
||||
.clip(
|
||||
RoundedCornerShape(88.dp)
|
||||
),
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.group_copy),
|
||||
contentDescription = "",
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholderRes = R.mipmap.rider_pro_agent_avatar
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(
|
||||
RoundedCornerShape(48.dp)
|
||||
),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(start = 20.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${AppState.profile?.nickName ?: "User"} ${stringResource(R.string.welcome_1)}",
|
||||
fontSize = 16.sp,
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
}
|
||||
if (!isCreatingAgent) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(start = 20.dp)
|
||||
) {
|
||||
if (!showManualCreation) {
|
||||
Text(
|
||||
text = stringResource(R.string.welcome_2),
|
||||
fontSize = 14.sp,
|
||||
color = appColors.text.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (!showManualCreation) {
|
||||
//自动创造AI界面
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 20.dp)
|
||||
) {
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(appColors.main)
|
||||
.align(Alignment.BottomEnd)
|
||||
.fillMaxWidth()
|
||||
.height(95.dp)
|
||||
.shadow(
|
||||
elevation = 10.dp,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
spotColor = Color(0x33F563FF),
|
||||
ambientColor = Color(0x99F563FF),
|
||||
clip = false
|
||||
)
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
listOf(
|
||||
Color(0xFF6246FF),
|
||||
Color(0xFF7C45ED)
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.padding(0.5.dp)
|
||||
.background(
|
||||
color = appColors.inputBackground2,
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.noRippleClickable {
|
||||
model.viewModelScope.launch {
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = tempDesc,
|
||||
onValueChange = { value -> onTempDescChange(value) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(95.dp)
|
||||
.focusRequester(focusRequester),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.agent_desc_hint_auto),
|
||||
color = Color.Gray
|
||||
)
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
color = LocalAppTheme.current.text,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
focusedContainerColor = appColors.inputBackground2,
|
||||
unfocusedContainerColor = appColors.inputBackground2,
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
supportingText = null,
|
||||
trailingIcon = null,
|
||||
leadingIcon = null
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 12.dp, bottom = 12.dp)
|
||||
.noRippleClickable {
|
||||
// 只有在有内容且未处理中且未创建中时才可点击
|
||||
if (tempDesc.isNotEmpty() && !isProcessing && !isCreatingAgent) {
|
||||
isProcessing = true
|
||||
showWaveAnimation = true // 显示构思动画
|
||||
keyboardController?.hide()
|
||||
model.viewModelScope.launch {
|
||||
try {
|
||||
val optimizedText = optimizeTextWithAI(tempDesc)
|
||||
if (optimizedText != null) {
|
||||
onTempDescChange(optimizedText)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
isProcessing = false
|
||||
showWaveAnimation = false
|
||||
isCreatingAgent = true
|
||||
showManualCreationForm = true
|
||||
onDescChange(tempDesc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.then(
|
||||
if (tempDesc.isEmpty() || isProcessing || isCreatingAgent) {
|
||||
Modifier.alpha(0.5f)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.mipmap.icons_info_magic),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.agent_text_beautify),
|
||||
color = Color(0xFF6246FF),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
if ((isCreatingAgent && showWaveAnimation) || (isProcessing && showWaveAnimation)) {
|
||||
// 显示构思动画
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.padding(start = 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
LottieAnimation(
|
||||
composition = rememberLottieComposition(LottieCompositionSpec.Asset("loading.lottie")).value,
|
||||
iterations = LottieConstants.IterateForever,
|
||||
modifier = Modifier.matchParentSize()
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.ideaing),
|
||||
color = appColors.text.copy(alpha = 0.6f),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
} else if (isCreatingAgent && !showWaveAnimation && showManualCreationForm) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.align(Alignment.Start)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.avatar),
|
||||
fontSize = 12.sp,
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0x777c45ed),
|
||||
Color(0x777c68ef),
|
||||
Color(0x557bd8f8)
|
||||
)
|
||||
)
|
||||
)
|
||||
.align(Alignment.Start)
|
||||
.noRippleClickable {
|
||||
// 保存当前状态
|
||||
model.showManualCreationForm = showManualCreationForm
|
||||
model.isCreatingAgent = isCreatingAgent
|
||||
model.showWaveAnimation = showWaveAnimation
|
||||
model.showManualCreation = showManualCreation
|
||||
model.isAutoModeManualForm = true // 标记为自动模式下的手动表单
|
||||
// 设置正在选择头像的标志
|
||||
model.isSelectingAvatar = true
|
||||
navController.navigate(NavigationRoute.AgentImageCrop.route)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
){
|
||||
// 如果已有裁剪后的头像,则显示头像,否则显示编辑图标
|
||||
if (model.croppedBitmap != null) {
|
||||
Image(
|
||||
bitmap = model.croppedBitmap!!.asImageBitmap(),
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Add",
|
||||
painter = painterResource(id = R.mipmap.icons_infor_edit),
|
||||
contentDescription = "Edit",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
Spacer(modifier = Modifier.height(58.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
// 原版两个输入框
|
||||
Text(
|
||||
text = stringResource(R.string.agent_name),
|
||||
fontSize = 12.sp,
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
FormTextInput(
|
||||
value = model.name,
|
||||
label = stringResource(R.string.agent_name),
|
||||
hint = stringResource(R.string.agent_name_hint),
|
||||
hint = stringResource(R.string.agent_name_hint_1),
|
||||
background = appColors.inputBackground2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { value ->
|
||||
onNameChange(value)
|
||||
}
|
||||
// Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.agent_desc),
|
||||
fontSize = 12.sp,
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
FormTextInput2(
|
||||
value = model.desc,
|
||||
label = stringResource(R.string.agent_desc),
|
||||
hint = stringResource(R.string.agent_desc_hint),
|
||||
background = appColors.inputBackground2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -229,38 +518,260 @@ fun AddAgentScreen() {
|
||||
onDescChange(value)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(58.dp))
|
||||
} else if (!isCreatingAgent && !showWaveAnimation) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.padding(start = 20.dp)
|
||||
.width(136.dp)
|
||||
.height(40.dp)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = Color(0x33858B98),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.background(
|
||||
color = appColors.background,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.noRippleClickable {
|
||||
showManualCreation = true
|
||||
tempDesc = ""
|
||||
agnetDescError = null
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.mipmap.icons_infor_edit),
|
||||
contentDescription = null,
|
||||
tint = appColors.text,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.create_agent_hand),
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W600,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}else {
|
||||
//手动创造AI界面
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.align(Alignment.Start)
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val autoLabel = stringResource(R.string.create_agent_auto)
|
||||
val measuredTextWidth = remember(autoLabel, textMeasurer) {
|
||||
textMeasurer.measure(
|
||||
text = AnnotatedString(autoLabel),
|
||||
style = TextStyle(
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W600,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
).size.width
|
||||
}
|
||||
val textWidthDp = with(density) { measuredTextWidth.toDp() }
|
||||
val contentWidth = 24.dp + 18.dp + 8.dp + textWidthDp
|
||||
val boxWidth = if (contentWidth > 140.dp) 250.dp else 140.dp
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.width(boxWidth)
|
||||
.height(40.dp)
|
||||
.shadow(
|
||||
elevation = 10.dp,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
spotColor = Color(0x33F563FF),
|
||||
ambientColor = Color(0x99F563FF),
|
||||
clip = false
|
||||
)
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
listOf(
|
||||
Color(0xFF6246FF),
|
||||
Color(0xFF7C45ED)
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.padding(0.5.dp)
|
||||
.background(
|
||||
color = appColors.background,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
.noRippleClickable {
|
||||
showManualCreation = false
|
||||
model.name = ""
|
||||
model.desc = ""
|
||||
model.croppedBitmap = null
|
||||
isCreatingAgent = false
|
||||
showManualCreationForm = false
|
||||
showWaveAnimation = false
|
||||
isProcessing = false
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.mipmap.icons_info_magic),
|
||||
contentDescription = null,
|
||||
tint = appColors.text,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = autoLabel,
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W600,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.avatar),
|
||||
fontSize = 12.sp,
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0x777c45ed),
|
||||
Color(0x777c68ef),
|
||||
Color(0x557bd8f8)
|
||||
)
|
||||
)
|
||||
)
|
||||
.align(Alignment.Start)
|
||||
.noRippleClickable {
|
||||
// 保存当前状态
|
||||
model.showManualCreation = showManualCreation
|
||||
model.showManualCreationForm = showManualCreationForm
|
||||
model.isCreatingAgent = isCreatingAgent
|
||||
model.showWaveAnimation = showWaveAnimation
|
||||
model.isAutoModeManualForm = false // 标记为手动模式下的手动表单
|
||||
// 设置正在选择头像的标志
|
||||
model.isSelectingAvatar = true
|
||||
navController.navigate(NavigationRoute.AgentImageCrop.route)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 如果已有裁剪后的头像,则显示头像,否则显示编辑图标
|
||||
if (model.croppedBitmap != null) {
|
||||
Image(
|
||||
bitmap = model.croppedBitmap!!.asImageBitmap(),
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(id = R.mipmap.icons_infor_edit),
|
||||
contentDescription = "Edit",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
// 原版两个输入框
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.agent_name),
|
||||
fontSize = 12.sp,
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
FormTextInput(
|
||||
value = model.name,
|
||||
hint = stringResource(R.string.agent_name_hint_1),
|
||||
background = appColors.inputBackground2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { value ->
|
||||
onNameChange(value)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.agent_desc),
|
||||
fontSize = 12.sp,
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
FormTextInput2(
|
||||
value = model.desc,
|
||||
hint = stringResource(R.string.agent_desc_hint),
|
||||
background = appColors.inputBackground2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { value ->
|
||||
onDescChange(value)
|
||||
}
|
||||
}
|
||||
//手动创造AI界面
|
||||
}
|
||||
|
||||
// 错误信息显示
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
errorMessage?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
color = Color.Red,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
modifier = Modifier
|
||||
.padding(bottom = 20.dp)
|
||||
.align(Alignment.Center),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
}
|
||||
ActionButton(
|
||||
modifier = Modifier
|
||||
.width(345.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 40.dp)
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFEE2A33),
|
||||
Color(0xFFD80264),
|
||||
Color(0xFF8468BC)
|
||||
Color(0x777c45ed),
|
||||
Color(0x777c68ef),
|
||||
Color(0x557bd8f8)
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
),
|
||||
color = Color.White,
|
||||
backgroundColor = Color.Transparent,
|
||||
text = stringResource(R.string.agent_create),
|
||||
text = stringResource(R.string.create_confirm),
|
||||
isLoading = model.isUpdating,
|
||||
loadingText = stringResource(R.string.agent_createing),
|
||||
enabled = !model.isUpdating && validate()
|
||||
) {
|
||||
// 验证输入
|
||||
@@ -268,6 +779,10 @@ fun AddAgentScreen() {
|
||||
if (validationError != null) {
|
||||
// 显示验证错误
|
||||
errorMessage = validationError
|
||||
model.viewModelScope.launch {
|
||||
kotlinx.coroutines.delay(3000)
|
||||
errorMessage = null
|
||||
}
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
@@ -282,11 +797,15 @@ fun AddAgentScreen() {
|
||||
// 创建成功,清空数据并关闭页面
|
||||
model.clearData()
|
||||
navController.popBackStack()
|
||||
AppState.agentCreatedSuccess = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 显示错误信息
|
||||
errorMessage = "创建智能体失败: ${e.message}"
|
||||
e.printStackTrace()
|
||||
// 3秒后清除错误信息
|
||||
kotlinx.coroutines.delay(3000)
|
||||
errorMessage = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,12 @@ object AddAgentViewModel : ViewModel() {
|
||||
var croppedBitmap by mutableStateOf<Bitmap?>(null)
|
||||
var isUpdating by mutableStateOf(false)
|
||||
var isSelectingAvatar by mutableStateOf(false) // 标记是否正在选择头像
|
||||
|
||||
var showManualCreationForm by mutableStateOf(false)
|
||||
var isCreatingAgent by mutableStateOf(false)
|
||||
var showWaveAnimation by mutableStateOf(false)
|
||||
var showManualCreation by mutableStateOf(false)
|
||||
// 添加一个标志来区分两种手动表单状态
|
||||
var isAutoModeManualForm by mutableStateOf(false)
|
||||
suspend fun updateAgentAvatar(context: Context) {
|
||||
croppedBitmap?.let {
|
||||
val file = File(context.cacheDir, "agent_avatar.jpg")
|
||||
@@ -70,7 +75,7 @@ object AddAgentViewModel : ViewModel() {
|
||||
name.length < 2 -> "智能体名称长度不能少于2个字符"
|
||||
name.length > 20 -> "智能体名称长度不能超过20个字符"
|
||||
desc.isEmpty() -> "智能体描述不能为空"
|
||||
desc.length > 100 -> "智能体描述长度不能超过100个字符"
|
||||
desc.length > 512 -> "智能体描述长度不能超过512个字符"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -84,5 +89,10 @@ object AddAgentViewModel : ViewModel() {
|
||||
croppedBitmap = null
|
||||
isUpdating = false
|
||||
isSelectingAvatar = false
|
||||
showManualCreationForm = false
|
||||
isCreatingAgent = false
|
||||
showWaveAnimation = false
|
||||
showManualCreation = false
|
||||
isAutoModeManualForm = false
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ import java.io.InputStream
|
||||
|
||||
/**
|
||||
* 专门用于智能体头像裁剪的页面
|
||||
* 支持创建和编辑两种模式
|
||||
*/
|
||||
@Composable
|
||||
fun AgentImageCropScreen() {
|
||||
@@ -71,6 +72,14 @@ fun AgentImageCropScreen() {
|
||||
val density = LocalDensity.current
|
||||
val navController = LocalNavController.current
|
||||
|
||||
// 检查是否在编辑模式(通过检查是否有编辑ViewModel的实例)
|
||||
val isEditMode = remember {
|
||||
// 通过检查导航栈或使用其他方式判断
|
||||
// 暂时使用一个简单的方法:检查AddAgentViewModel是否正在选择头像
|
||||
// 如果不是,则可能是编辑模式
|
||||
!AddAgentViewModel.isSelectingAvatar
|
||||
}
|
||||
|
||||
val imagePickLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
@@ -84,7 +93,9 @@ fun AgentImageCropScreen() {
|
||||
}
|
||||
if (uri == null) {
|
||||
// 用户取消选择图片,重置标志
|
||||
if (!isEditMode) {
|
||||
AddAgentViewModel.isSelectingAvatar = false
|
||||
}
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
@@ -122,7 +133,9 @@ fun AgentImageCropScreen() {
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clickable {
|
||||
// 用户取消头像选择,重置标志
|
||||
if (!isEditMode) {
|
||||
AddAgentViewModel.isSelectingAvatar = false
|
||||
}
|
||||
navController.popBackStack()
|
||||
},
|
||||
colorFilter = ColorFilter.tint(Color.White)
|
||||
@@ -137,6 +150,12 @@ fun AgentImageCropScreen() {
|
||||
modifier = Modifier.clickable {
|
||||
if (croppedBitmap != null) {
|
||||
// 如果已经有裁剪结果,直接返回
|
||||
if (isEditMode) {
|
||||
// 编辑模式:需要找到当前的编辑ViewModel实例
|
||||
// 由于无法直接访问,我们使用一个全局状态或者通过其他方式传递
|
||||
// 暂时先保存到AddAgentViewModel,编辑页面会检查
|
||||
AddAgentViewModel.croppedBitmap = croppedBitmap
|
||||
} else {
|
||||
AddAgentViewModel.croppedBitmap = croppedBitmap
|
||||
// 重置头像选择标志
|
||||
AddAgentViewModel.isSelectingAvatar = false
|
||||
@@ -144,6 +163,8 @@ fun AgentImageCropScreen() {
|
||||
AddAgentViewModel.updateAgentAvatar(context)
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
navController.popBackStack()
|
||||
} else {
|
||||
// 进行裁剪
|
||||
imageCrop?.let {
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
package com.aiosman.ravenow.ui.agent
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.composables.ActionButton
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.PointsPaymentDialog
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.composables.form.FormTextInput
|
||||
import com.aiosman.ravenow.ui.composables.form.FormTextInput2
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.data.PointService
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* AI Prompt 编辑页面
|
||||
*/
|
||||
@Composable
|
||||
fun AiPromptEditScreen(
|
||||
chatAIId: String,
|
||||
viewModel: AiPromptEditViewModel = viewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.current
|
||||
val appColors = LocalAppTheme.current
|
||||
|
||||
// 加载Prompt详情
|
||||
LaunchedEffect(chatAIId) {
|
||||
viewModel.loadPromptDetail(chatAIId)
|
||||
}
|
||||
|
||||
// 监听头像裁剪结果(从AgentImageCropScreen返回)
|
||||
LaunchedEffect(viewModel.isSelectingAvatar) {
|
||||
if (!viewModel.isSelectingAvatar && AddAgentViewModel.croppedBitmap != null) {
|
||||
// 从裁剪页面返回,检查是否有新的裁剪结果
|
||||
viewModel.croppedBitmap = AddAgentViewModel.croppedBitmap
|
||||
// 清空AddAgentViewModel的裁剪结果,避免影响创建页面
|
||||
AddAgentViewModel.croppedBitmap = null
|
||||
}
|
||||
}
|
||||
|
||||
// 状态
|
||||
var showPrivacyConfirmDialog by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 获取积分规则和余额
|
||||
val pointsRules by PointService.pointsRules.collectAsState(initial = null)
|
||||
val pointsBalance by PointService.pointsBalance.collectAsState(initial = null)
|
||||
|
||||
// 计算是否需要付费
|
||||
val needsPayment = viewModel.needsPrivacyPayment()
|
||||
val privacyCost = viewModel.getPrivacyCost()
|
||||
val currentBalance = viewModel.getCurrentBalance()
|
||||
val balanceAfterCost = viewModel.calculateBalanceAfterCost(privacyCost)
|
||||
val isBalanceSufficient = viewModel.isBalanceSufficient(privacyCost)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = Color(0xFFFAFAFB)),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
|
||||
// 顶部导航栏
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = Color(0xFFFAFAFB))
|
||||
.padding(horizontal = 14.dp, vertical = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_back_icon),
|
||||
contentDescription = "返回",
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
navController.navigateUp()
|
||||
},
|
||||
colorFilter = ColorFilter.tint(appColors.text)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Text(
|
||||
"编辑Ai",
|
||||
fontWeight = FontWeight.W600,
|
||||
modifier = Modifier.weight(1f),
|
||||
fontSize = 17.sp,
|
||||
color = appColors.text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(1.dp))
|
||||
|
||||
// 内容区域
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(Color(0xFFFAFAFB))
|
||||
) {
|
||||
// 头像选择
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 18.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.avatar),
|
||||
fontSize = 12.sp,
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0x777c45ed),
|
||||
Color(0x777c68ef),
|
||||
Color(0x557bd8f8)
|
||||
)
|
||||
)
|
||||
)
|
||||
.noRippleClickable {
|
||||
viewModel.isSelectingAvatar = true
|
||||
// 标记为编辑模式
|
||||
AddAgentViewModel.isSelectingAvatar = false
|
||||
navController.navigate(NavigationRoute.AgentImageCrop.route)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when {
|
||||
viewModel.croppedBitmap != null -> {
|
||||
Image(
|
||||
bitmap = viewModel.croppedBitmap!!.asImageBitmap(),
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
viewModel.avatarUrl != null -> {
|
||||
CustomAsyncImage(
|
||||
context = context,
|
||||
imageUrl = viewModel.avatarUrl!!,
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.icons_infor_edit),
|
||||
contentDescription = "Edit",
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
// 名称输入
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.agent_name),
|
||||
fontSize = 12.sp,
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
FormTextInput(
|
||||
value = viewModel.title,
|
||||
hint = stringResource(R.string.agent_name_hint_1),
|
||||
background = Color.White,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { value ->
|
||||
viewModel.title = value
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
// 描述输入
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.agent_desc),
|
||||
fontSize = 12.sp,
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
FormTextInput2(
|
||||
value = viewModel.desc,
|
||||
hint = stringResource(R.string.agent_desc_hint),
|
||||
background = Color.White,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { value ->
|
||||
viewModel.desc = value
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
// 设定权限区域
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "设定权限",
|
||||
fontSize = 12.sp,
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 公开/私有切换
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(25.dp))
|
||||
.background(Color.White)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = Color(red = 124f / 255f, green = 116f / 255f, blue = 128f / 255f, alpha = 0.08f),
|
||||
shape = RoundedCornerShape(25.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = if (viewModel.isPublic) "公开" else "私有",
|
||||
fontSize = 14.sp,
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
Switch(
|
||||
checked = viewModel.isPublic,
|
||||
onCheckedChange = { checked ->
|
||||
if (!checked && needsPayment && !viewModel.paidForPrivacyEdit) {
|
||||
// 需要付费,显示确认对话框
|
||||
showPrivacyConfirmDialog = true
|
||||
} else {
|
||||
viewModel.isPublic = checked
|
||||
}
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedTrackColor = appColors.brandColorsColor,
|
||||
uncheckedThumbColor = Color.White,
|
||||
uncheckedTrackColor = appColors.brandColorsColor.copy(alpha = 0.5f),
|
||||
uncheckedBorderColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier.size(width = 64.dp, height = 28.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 首次解锁AI权限提示
|
||||
if (needsPayment && !viewModel.paidForPrivacyEdit && privacyCost > 0) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
// 主要内容容器(去掉阴影)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
color = Color(red = 251f / 255f, green = 248f / 255f, blue = 239f / 255f)
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = Color(red = 243f / 255f, green = 234f / 255f, blue = 206f / 255f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 锁图标容器
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.background(
|
||||
color = Color(red = 1f, green = 204f / 255f, blue = 0f, alpha = 0.12f),
|
||||
shape = RoundedCornerShape(10.7.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 锁图标(使用文本代替,实际项目中可以使用图片资源)
|
||||
Text(
|
||||
text = "🔒",
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "首次解锁Ai权限",
|
||||
fontSize = 13.sp,
|
||||
color = Color(red = 172f / 255f, green = 127f / 255f, blue = 94f / 255f),
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "将消耗",
|
||||
fontSize = 12.sp,
|
||||
color = Color(red = 172f / 255f, green = 127f / 255f, blue = 94f / 255f)
|
||||
)
|
||||
Text(
|
||||
text = "$privacyCost",
|
||||
fontSize = 12.sp,
|
||||
color = Color(red = 1f, green = 141f / 255f, blue = 40f / 255f)
|
||||
)
|
||||
// 小硬币图标
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFFFD700),
|
||||
Color(0xFFFFA500)
|
||||
)
|
||||
),
|
||||
shape = CircleShape
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "解锁后可随时切换",
|
||||
fontSize = 12.sp,
|
||||
color = Color(red = 172f / 255f, green = 127f / 255f, blue = 94f / 255f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 底部保存按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = Color(0xFFFAFAFB))
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
ActionButton(
|
||||
text = "保存",
|
||||
enabled = !viewModel.isUpdating && !viewModel.isLoading,
|
||||
isLoading = viewModel.isUpdating,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// 验证输入
|
||||
val validationError = viewModel.validate()
|
||||
if (validationError != null) {
|
||||
errorMessage = validationError
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
// 检查是否需要付费确认
|
||||
if (needsPayment && !viewModel.paidForPrivacyEdit) {
|
||||
showPrivacyConfirmDialog = true
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
// 执行保存
|
||||
scope.launch {
|
||||
try {
|
||||
viewModel.updatePrompt(context)
|
||||
navController.navigateUp()
|
||||
} catch (e: Exception) {
|
||||
errorMessage = e.message ?: "保存失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 隐私权限付费确认对话框
|
||||
if (showPrivacyConfirmDialog) {
|
||||
PointsPaymentDialog(
|
||||
cost = privacyCost,
|
||||
currentBalance = currentBalance,
|
||||
balanceAfterCost = balanceAfterCost,
|
||||
isBalanceSufficient = isBalanceSufficient,
|
||||
onConfirm = {
|
||||
showPrivacyConfirmDialog = false
|
||||
scope.launch {
|
||||
try {
|
||||
viewModel.isPublic = false
|
||||
viewModel.updatePrompt(context)
|
||||
viewModel.paidForPrivacyEdit = true
|
||||
navController.navigateUp()
|
||||
} catch (e: Exception) {
|
||||
errorMessage = e.message ?: "保存失败"
|
||||
}
|
||||
}
|
||||
},
|
||||
onCancel = {
|
||||
showPrivacyConfirmDialog = false
|
||||
},
|
||||
title = "首次解锁AI权限",
|
||||
description = "将消耗 $privacyCost 派币解锁后可随时切换"
|
||||
)
|
||||
}
|
||||
|
||||
// 错误提示
|
||||
errorMessage?.let { error ->
|
||||
LaunchedEffect(error) {
|
||||
kotlinx.coroutines.delay(3000)
|
||||
errorMessage = null
|
||||
}
|
||||
// TODO: 显示Toast或Snackbar
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.aiosman.ravenow.ui.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aiosman.ravenow.data.UploadImage
|
||||
import com.aiosman.ravenow.data.ServiceException
|
||||
import com.aiosman.ravenow.data.PointService
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import com.aiosman.ravenow.entity.AgentEntity
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.File
|
||||
|
||||
class AiPromptEditViewModel : ViewModel() {
|
||||
var chatAIId by mutableStateOf("")
|
||||
var title by mutableStateOf("")
|
||||
var desc by mutableStateOf("")
|
||||
var isPublic by mutableStateOf(true)
|
||||
var originalIsPublic by mutableStateOf(true)
|
||||
var paidForPrivacyEdit by mutableStateOf(false)
|
||||
var avatarUrl by mutableStateOf<String?>(null)
|
||||
var croppedBitmap by mutableStateOf<Bitmap?>(null)
|
||||
var isUpdating by mutableStateOf(false)
|
||||
var isLoading by mutableStateOf(false)
|
||||
var errorMessage by mutableStateOf<String?>(null)
|
||||
var isSelectingAvatar by mutableStateOf(false)
|
||||
|
||||
/**
|
||||
* 加载Prompt详情
|
||||
*/
|
||||
fun loadPromptDetail(chatAIId: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
this@AiPromptEditViewModel.chatAIId = chatAIId
|
||||
|
||||
val response = ApiClient.api.getPromptDetail(chatAIId)
|
||||
val body = response.body()?.data ?: throw ServiceException("Failed to get prompt detail")
|
||||
|
||||
// 填充数据
|
||||
title = body.title
|
||||
desc = body.desc
|
||||
isPublic = body.isPublic
|
||||
originalIsPublic = body.isPublic
|
||||
avatarUrl = "${ApiClient.BASE_API_URL}/outside${body.avatar}?token=${com.aiosman.ravenow.AppStore.token}"
|
||||
|
||||
// 注意:Agent数据模型可能没有paidForPrivacyEdit字段,需要从其他地方获取
|
||||
// 暂时设为false,后续可以根据实际API响应调整
|
||||
paidForPrivacyEdit = false
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("AiPromptEditViewModel", "Error loading prompt detail", e)
|
||||
errorMessage = "加载失败: ${e.message}"
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新Prompt
|
||||
*/
|
||||
suspend fun updatePrompt(context: Context): AgentEntity? {
|
||||
try {
|
||||
isUpdating = true
|
||||
errorMessage = null
|
||||
|
||||
// 准备头像文件
|
||||
val avatarFile = if (croppedBitmap != null) {
|
||||
val file = File(context.cacheDir, "agent_avatar_edit.jpg")
|
||||
croppedBitmap!!.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream())
|
||||
UploadImage(file, "agent_avatar_edit.jpg", "", "jpg")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 准备请求参数
|
||||
val textTitle = title.trim().toRequestBody("text/plain".toMediaTypeOrNull())
|
||||
val textDesc = desc.trim().toRequestBody("text/plain".toMediaTypeOrNull())
|
||||
val textValue = desc.trim().toRequestBody("text/plain".toMediaTypeOrNull()) // value通常和desc相同
|
||||
val isPublicBody = isPublic.toString().toRequestBody("text/plain".toMediaTypeOrNull())
|
||||
|
||||
val avatarPart: MultipartBody.Part? = avatarFile?.let {
|
||||
val requestFile = it.file.asRequestBody("image/*".toMediaTypeOrNull())
|
||||
MultipartBody.Part.createFormData("avatar", it.filename, requestFile)
|
||||
}
|
||||
|
||||
// 调用更新API
|
||||
val response = ApiClient.api.updatePrompt(
|
||||
promptId = chatAIId,
|
||||
avatar = avatarPart,
|
||||
title = textTitle,
|
||||
desc = textDesc,
|
||||
value = textValue,
|
||||
isPublic = isPublicBody
|
||||
)
|
||||
|
||||
val body = response.body()?.data ?: throw ServiceException("Failed to update prompt")
|
||||
|
||||
// 更新本地状态
|
||||
originalIsPublic = isPublic
|
||||
|
||||
return body.toAgentEntity()
|
||||
} catch (e: Exception) {
|
||||
Log.e("AiPromptEditViewModel", "Error updating prompt", e)
|
||||
errorMessage = "更新失败: ${e.message}"
|
||||
throw e
|
||||
} finally {
|
||||
isUpdating = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证输入
|
||||
*/
|
||||
fun validate(): String? {
|
||||
return when {
|
||||
title.trim().isEmpty() -> "智能体名称不能为空"
|
||||
title.trim().length < 2 -> "智能体名称长度不能少于2个字符"
|
||||
title.trim().length > 20 -> "智能体名称长度不能超过20个字符"
|
||||
desc.trim().isEmpty() -> "智能体描述不能为空"
|
||||
desc.trim().length > 512 -> "智能体描述长度不能超过512个字符"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要付费解锁隐私切换
|
||||
*/
|
||||
fun needsPrivacyPayment(): Boolean {
|
||||
// 如果已经解锁过,则不需要付费
|
||||
if (paidForPrivacyEdit) {
|
||||
return false
|
||||
}
|
||||
// 只有从公开(true)切换到私有(false)才需要付费
|
||||
return originalIsPublic == true && isPublic == false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取解锁隐私权限的费用
|
||||
* @return 费用金额,如果无法获取则返回 0
|
||||
*/
|
||||
fun getPrivacyCost(): Int {
|
||||
val rules = PointService.pointsRules.value
|
||||
val costRule = rules?.sub?.get(PointService.PointsRuleKey.SPEND_AGENT_PRIVATE)
|
||||
return when (costRule) {
|
||||
is PointService.RuleAmount.Fixed -> costRule.value
|
||||
is PointService.RuleAmount.Range -> costRule.min // 使用最小值作为默认费用
|
||||
null -> 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前余额
|
||||
* @return 当前余额,如果无法获取则返回 0
|
||||
*/
|
||||
fun getCurrentBalance(): Int {
|
||||
return PointService.pointsBalance.value?.balance ?: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算消耗后余额
|
||||
* @param cost 费用
|
||||
* @return 消耗后余额
|
||||
*/
|
||||
fun calculateBalanceAfterCost(cost: Int): Int {
|
||||
val currentBalance = getCurrentBalance()
|
||||
return (currentBalance - cost).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查余额是否充足
|
||||
* @param cost 费用
|
||||
* @return 是否充足
|
||||
*/
|
||||
fun isBalanceSufficient(cost: Int): Boolean {
|
||||
return getCurrentBalance() >= cost
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空数据
|
||||
*/
|
||||
fun clearData() {
|
||||
chatAIId = ""
|
||||
title = ""
|
||||
desc = ""
|
||||
isPublic = true
|
||||
originalIsPublic = true
|
||||
paidForPrivacyEdit = false
|
||||
avatarUrl = null
|
||||
croppedBitmap = null
|
||||
isUpdating = false
|
||||
isLoading = false
|
||||
errorMessage = null
|
||||
isSelectingAvatar = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
package com.aiosman.ravenow.ui.chat
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aiosman.ravenow.data.AccountService
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.data.UserService
|
||||
import com.aiosman.ravenow.data.UserServiceImpl
|
||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
||||
import com.aiosman.ravenow.entity.ChatItem
|
||||
import io.openim.android.sdk.OpenIMClient
|
||||
import io.openim.android.sdk.enums.ConversationType
|
||||
import io.openim.android.sdk.enums.ViewType
|
||||
import io.openim.android.sdk.listener.OnAdvanceMsgListener
|
||||
import io.openim.android.sdk.listener.OnBase
|
||||
import io.openim.android.sdk.listener.OnMsgSendCallback
|
||||
import io.openim.android.sdk.models.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* 聊天ViewModel基类,包含所有聊天功能的通用实现
|
||||
* 子类需要实现抽象方法来处理特定的聊天类型(单聊/群聊)
|
||||
*/
|
||||
abstract class BaseChatViewModel : ViewModel() {
|
||||
|
||||
// 通用状态属性
|
||||
var chatData by mutableStateOf<List<ChatItem>>(emptyList())
|
||||
var myProfile by mutableStateOf<AccountProfileEntity?>(null)
|
||||
var hasMore by mutableStateOf(true)
|
||||
var isLoading by mutableStateOf(false)
|
||||
var lastMessage: Message? = null
|
||||
val showTimestampMap = mutableMapOf<String, Boolean>()
|
||||
var goToNew by mutableStateOf(false)
|
||||
var conversationID: String = "" // 会话ID,通过getOneConversation初始化
|
||||
|
||||
// 通用服务
|
||||
val userService: UserService = UserServiceImpl()
|
||||
val accountService: AccountService = AccountServiceImpl()
|
||||
var textMessageListener: OnAdvanceMsgListener? = null
|
||||
|
||||
val fetchHistorySize = 20
|
||||
|
||||
/**
|
||||
* 初始化方法,子类需要实现具体的初始化逻辑
|
||||
*/
|
||||
abstract fun init(context: Context)
|
||||
|
||||
/**
|
||||
* 获取日志标签,子类需要实现
|
||||
*/
|
||||
abstract fun getLogTag(): String
|
||||
|
||||
/**
|
||||
* 获取会话参数,子类需要实现
|
||||
* @return Triple(targetId, conversationType, isSingleChat)
|
||||
*/
|
||||
abstract fun getConversationParams(): Triple<String, Int, Boolean>
|
||||
|
||||
/**
|
||||
* 处理接收到的新消息,子类可以重写以添加特定逻辑
|
||||
*/
|
||||
open fun handleNewMessage(message: Message, context: Context): Boolean {
|
||||
return false // 默认不处理,子类重写
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取发送消息时的接收者ID,子类需要实现
|
||||
*/
|
||||
abstract fun getReceiverInfo(): Pair<String?, String?> // (recvID, groupID)
|
||||
|
||||
/**
|
||||
* 发送消息成功后的额外处理,子类可以重写
|
||||
*/
|
||||
open fun onMessageSentSuccess(message: String, sentMessage: Message?) {
|
||||
// 默认无额外处理,子类可以重写
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话信息并初始化conversationID
|
||||
*/
|
||||
fun getOneConversation(onSuccess: (() -> Unit)? = null) {
|
||||
val (targetId, conversationType, isSingleChat) = getConversationParams()
|
||||
|
||||
OpenIMClient.getInstance().conversationManager.getOneConversation(
|
||||
object : OnBase<ConversationInfo> {
|
||||
override fun onError(code: Int, error: String) {
|
||||
Log.e(getLogTag(), "getOneConversation error: $error")
|
||||
}
|
||||
|
||||
override fun onSuccess(data: ConversationInfo) {
|
||||
conversationID = data.conversationID
|
||||
Log.d(getLogTag(), "获取会话信息成功,conversationID: $conversationID")
|
||||
onSuccess?.invoke()
|
||||
}
|
||||
},
|
||||
targetId,
|
||||
conversationType
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册消息监听器
|
||||
*/
|
||||
fun RegistListener(context: Context) {
|
||||
// 检查 OpenIM 是否已登录
|
||||
if (!com.aiosman.ravenow.AppState.enableChat) {
|
||||
Log.w(getLogTag(), "OpenIM 未登录,跳过注册消息监听器")
|
||||
return
|
||||
}
|
||||
|
||||
textMessageListener = object : OnAdvanceMsgListener {
|
||||
override fun onRecvNewMessage(msg: Message?) {
|
||||
msg?.let { message ->
|
||||
if (handleNewMessage(message, context)) {
|
||||
val chatItem = ChatItem.convertToChatItem(message, context, avatar = getMessageAvatar(message))
|
||||
chatItem?.let {
|
||||
chatData = listOf(it) + chatData
|
||||
goToNew = true
|
||||
Log.i(getLogTag(), "收到来自 ${message.sendID} 的消息,更新聊天列表")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OpenIMClient.getInstance().messageManager.setAdvancedMsgListener(textMessageListener)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息头像,子类可以重写
|
||||
*/
|
||||
open fun getMessageAvatar(message: Message): String? {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消注册消息监听器
|
||||
*/
|
||||
fun UnRegistListener() {
|
||||
textMessageListener = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除未读消息
|
||||
*/
|
||||
fun clearUnRead() {
|
||||
if (conversationID.isEmpty()) {
|
||||
Log.w(getLogTag(), "conversationID为空,无法清除未读消息")
|
||||
return
|
||||
}
|
||||
|
||||
OpenIMClient.getInstance().messageManager.markConversationMessageAsRead(
|
||||
conversationID,
|
||||
object : OnBase<String> {
|
||||
override fun onSuccess(data: String?) {
|
||||
Log.i("openim", "清除未读消息成功")
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.i("openim", "清除未读消息失败, code:$code, error:$error")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载更多历史消息
|
||||
*/
|
||||
fun onLoadMore(context: Context) {
|
||||
if (!hasMore || isLoading) {
|
||||
return
|
||||
}
|
||||
loadHistoryMessages(context, isLoadMore = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本消息
|
||||
*/
|
||||
fun sendMessage(message: String, context: Context) {
|
||||
// 检查 OpenIM 是否已登录
|
||||
if (!com.aiosman.ravenow.AppState.enableChat) {
|
||||
Log.w(getLogTag(), "OpenIM 未登录,无法发送消息")
|
||||
return
|
||||
}
|
||||
|
||||
val textMessage = OpenIMClient.getInstance().messageManager.createTextMessage(message)
|
||||
val (recvID, groupID) = getReceiverInfo()
|
||||
|
||||
OpenIMClient.getInstance().messageManager.sendMessage(
|
||||
object : OnMsgSendCallback {
|
||||
override fun onProgress(progress: Long) {
|
||||
// 发送进度
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.e(getLogTag(), "发送消息失败: $error")
|
||||
}
|
||||
|
||||
override fun onSuccess(data: Message?) {
|
||||
Log.d(getLogTag(), "发送消息成功")
|
||||
onMessageSentSuccess(message, data)
|
||||
data?.let { sentMessage ->
|
||||
val chatItem = ChatItem.convertToChatItem(sentMessage, context, avatar = myProfile?.avatar)
|
||||
chatItem?.let {
|
||||
chatData = listOf(it) + chatData
|
||||
goToNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
textMessage,
|
||||
recvID,
|
||||
groupID,
|
||||
OfflinePushInfo()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送图片消息
|
||||
*/
|
||||
fun sendImageMessage(imageUri: Uri, context: Context) {
|
||||
val tempFile = createTempFile(context, imageUri)
|
||||
val imagePath = tempFile?.path
|
||||
if (imagePath != null) {
|
||||
val imageMessage = OpenIMClient.getInstance().messageManager.createImageMessageFromFullPath(imagePath)
|
||||
val (recvID, groupID) = getReceiverInfo()
|
||||
|
||||
OpenIMClient.getInstance().messageManager.sendMessage(
|
||||
object : OnMsgSendCallback {
|
||||
override fun onProgress(progress: Long) {
|
||||
Log.d(getLogTag(), "发送图片消息进度: $progress")
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.e(getLogTag(), "发送图片消息失败: $error")
|
||||
}
|
||||
|
||||
override fun onSuccess(data: Message?) {
|
||||
Log.d(getLogTag(), "发送图片消息成功")
|
||||
data?.let { sentMessage ->
|
||||
val chatItem = ChatItem.convertToChatItem(sentMessage, context, avatar = myProfile?.avatar)
|
||||
chatItem?.let {
|
||||
chatData = listOf(it) + chatData
|
||||
goToNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
imageMessage,
|
||||
recvID,
|
||||
groupID,
|
||||
OfflinePushInfo()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建临时文件
|
||||
*/
|
||||
fun createTempFile(context: Context, uri: Uri): File? {
|
||||
return try {
|
||||
val projection = arrayOf(MediaStore.Images.Media.DATA)
|
||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
||||
val filePath = it.getString(columnIndex)
|
||||
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
|
||||
val mimeType = context.contentResolver.getType(uri)
|
||||
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
|
||||
val tempFile =
|
||||
File.createTempFile("temp_image", ".$extension", context.cacheDir)
|
||||
val outputStream = FileOutputStream(tempFile)
|
||||
|
||||
inputStream?.use { input ->
|
||||
outputStream.use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
tempFile
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史消息
|
||||
*/
|
||||
fun fetchHistoryMessage(context: Context) {
|
||||
loadHistoryMessages(context, isLoadMore = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载历史消息的通用方法
|
||||
* @param context 上下文
|
||||
* @param isLoadMore 是否是加载更多(true:追加到现有数据,false:替换现有数据)
|
||||
*/
|
||||
private fun loadHistoryMessages(context: Context, isLoadMore: Boolean) {
|
||||
if (conversationID.isEmpty()) {
|
||||
Log.w(getLogTag(), "conversationID为空,无法${if (isLoadMore) "加载更多" else "获取"}历史消息")
|
||||
return
|
||||
}
|
||||
|
||||
if (isLoadMore) {
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
OpenIMClient.getInstance().messageManager.getAdvancedHistoryMessageList(
|
||||
object : OnBase<AdvancedMessage> {
|
||||
override fun onSuccess(data: AdvancedMessage?) {
|
||||
val messages = data?.messageList ?: emptyList()
|
||||
val newChatItems = messages.mapNotNull {
|
||||
ChatItem.convertToChatItem(it, context, avatar = getMessageAvatar(it))
|
||||
}.reversed() // 反转顺序,使最新消息在前面
|
||||
|
||||
// 根据是否是加载更多来决定数据处理方式
|
||||
chatData = if (isLoadMore) {
|
||||
chatData + newChatItems // 追加到现有数据
|
||||
} else {
|
||||
newChatItems // 替换现有数据
|
||||
}
|
||||
|
||||
if (messages.size < fetchHistorySize) {
|
||||
hasMore = false
|
||||
}
|
||||
lastMessage = messages.firstOrNull()
|
||||
|
||||
if (isLoadMore) {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
Log.d(getLogTag(), "${if (isLoadMore) "加载更多" else "获取"}历史消息成功")
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.e(getLogTag(), "${if (isLoadMore) "加载更多" else "获取"}历史消息失败: $error")
|
||||
if (isLoadMore) {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
conversationID,
|
||||
if (isLoadMore) lastMessage else null, // 首次加载不传lastMessage
|
||||
fetchHistorySize,
|
||||
ViewType.History
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取显示的聊天列表
|
||||
*/
|
||||
fun getDisplayChatList(): List<ChatItem> {
|
||||
val list = chatData
|
||||
// 更新每条消息的时间戳显示状态
|
||||
for (item in list) {
|
||||
item.showTimestamp = showTimestampMap.getOrDefault(item.msgId, false)
|
||||
}
|
||||
return list
|
||||
}
|
||||
}
|
||||
@@ -79,11 +79,12 @@ import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.ChatItem
|
||||
import com.aiosman.ravenow.exp.formatChatTime
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.DropdownMenu
|
||||
import com.aiosman.ravenow.ui.composables.MenuItem
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.utils.NetworkUtils
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.AppState
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import io.openim.android.sdk.enums.MessageType
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
@@ -91,10 +92,10 @@ import java.util.UUID
|
||||
|
||||
@Composable
|
||||
fun ChatAiScreen(userId: String) {
|
||||
var isMenuExpanded by remember { mutableStateOf(false) }
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalNavController.current.context
|
||||
val AppColors = LocalAppTheme.current
|
||||
val chatBackgroundUrl = AppState.chatBackgroundUrl
|
||||
var goToNewCount by remember { mutableStateOf(0) }
|
||||
val viewModel = viewModel<ChatAiViewModel>(
|
||||
key = "ChatAiViewModel_$userId",
|
||||
@@ -158,14 +159,25 @@ fun ChatAiScreen(userId: String) {
|
||||
}
|
||||
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (chatBackgroundUrl != null && chatBackgroundUrl.isNotEmpty()) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = chatBackgroundUrl,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentDescription = "chat_background",
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
backgroundColor = Color.Transparent,
|
||||
topBar = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(AppColors.background)
|
||||
.background(Color.Transparent)
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
Row(
|
||||
@@ -213,49 +225,24 @@ fun ChatAiScreen(userId: String) {
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.noRippleClickable {
|
||||
isMenuExpanded = true
|
||||
navController.navigate(NavigationRoute.ChatSetting.route)
|
||||
},
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(
|
||||
AppColors.text)
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = isMenuExpanded,
|
||||
onDismissRequest = {
|
||||
isMenuExpanded = false
|
||||
},
|
||||
menuItems = listOf(
|
||||
MenuItem(
|
||||
title = if (viewModel.notificationStrategy == "mute") "Unmute" else "Mute",
|
||||
icon = if (viewModel.notificationStrategy == "mute") R.drawable.rider_pro_notice_mute else R.drawable.rider_pro_notice_active,
|
||||
) {
|
||||
|
||||
isMenuExpanded = false
|
||||
if (NetworkUtils.isNetworkAvailable(context)) {
|
||||
viewModel.viewModelScope.launch {
|
||||
if (viewModel.notificationStrategy == "mute") {
|
||||
viewModel.updateNotificationStrategy("active")
|
||||
} else {
|
||||
viewModel.updateNotificationStrategy("mute")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
android.widget.Toast.makeText(context, "网络连接异常,请检查网络设置", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
),
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
val hasChatBackground = AppState.chatBackgroundUrl != null && AppState.chatBackgroundUrl!!.isNotEmpty()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.imePadding()
|
||||
) {
|
||||
if (!hasChatBackground) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -263,6 +250,7 @@ fun ChatAiScreen(userId: String) {
|
||||
.background(
|
||||
AppColors.decentBackground)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ChatAiInput(
|
||||
onSendImage = {
|
||||
@@ -283,7 +271,7 @@ fun ChatAiScreen(userId: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(AppColors.decentBackground)
|
||||
.background(Color.Transparent)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -346,8 +334,7 @@ fun ChatAiScreen(userId: String) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,7 +558,8 @@ fun ChatAiInput(
|
||||
}
|
||||
Box( modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),){
|
||||
.background(Color.Transparent)
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 45.dp),){
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -629,7 +617,7 @@ fun ChatAiInput(
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
Image(
|
||||
painter = painterResource(R.mipmap.rider_pro_im_send),
|
||||
painter = painterResource(R.mipmap.btn),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.alpha(alpha)
|
||||
|
||||
@@ -1,57 +1,27 @@
|
||||
package com.aiosman.ravenow.ui.chat
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import com.aiosman.ravenow.ChatState
|
||||
import com.aiosman.ravenow.data.AccountService
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.data.UserService
|
||||
import com.aiosman.ravenow.data.UserServiceImpl
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import com.aiosman.ravenow.data.api.SendChatAiRequestBody
|
||||
import com.aiosman.ravenow.data.api.SingleChatRequestBody
|
||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
||||
import com.aiosman.ravenow.entity.ChatItem
|
||||
import com.aiosman.ravenow.entity.ChatNotification
|
||||
import com.aiosman.ravenow.ui.navigateToChatAi
|
||||
// OpenIM SDK 导入
|
||||
import io.openim.android.sdk.OpenIMClient
|
||||
import io.openim.android.sdk.enums.ViewType
|
||||
import io.openim.android.sdk.listener.OnAdvanceMsgListener
|
||||
import io.openim.android.sdk.listener.OnBase
|
||||
import io.openim.android.sdk.listener.OnMsgSendCallback
|
||||
import io.openim.android.sdk.enums.ConversationType
|
||||
import io.openim.android.sdk.models.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
|
||||
class ChatAiViewModel(
|
||||
val userId: String,
|
||||
) : ViewModel() {
|
||||
var chatData by mutableStateOf<List<ChatItem>>(emptyList())
|
||||
) : BaseChatViewModel() {
|
||||
var userProfile by mutableStateOf<AccountProfileEntity?>(null)
|
||||
var myProfile by mutableStateOf<AccountProfileEntity?>(null)
|
||||
val userService: UserService = UserServiceImpl()
|
||||
val accountService: AccountService = AccountServiceImpl()
|
||||
var textMessageListener: OnAdvanceMsgListener? = null
|
||||
var hasMore by mutableStateOf(true)
|
||||
var isLoading by mutableStateOf(false)
|
||||
var lastMessage: Message? = null
|
||||
val showTimestampMap = mutableMapOf<String, Boolean>() // Add this map
|
||||
var chatNotification by mutableStateOf<ChatNotification?>(null)
|
||||
var goToNew by mutableStateOf(false)
|
||||
fun init(context: Context) {
|
||||
override fun init(context: Context) {
|
||||
// 获取用户信息
|
||||
viewModelScope.launch {
|
||||
val resp = userService.getUserProfile(userId)
|
||||
@@ -59,150 +29,55 @@ class ChatAiViewModel(
|
||||
myProfile = accountService.getMyAccountProfile()
|
||||
|
||||
RegistListener(context)
|
||||
|
||||
// 获取会话信息,然后加载历史消息
|
||||
getOneConversation {
|
||||
fetchHistoryMessage(context)
|
||||
}
|
||||
|
||||
// 获取通知信息
|
||||
val notiStrategy = ChatState.getStrategyByTargetTrtcId(resp.trtcUserId)
|
||||
chatNotification = notiStrategy
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun RegistListener(context: Context) {
|
||||
// 检查 OpenIM 是否已登录
|
||||
if (!com.aiosman.ravenow.AppState.enableChat) {
|
||||
android.util.Log.w("ChatAiViewModel", "OpenIM 未登录,跳过注册消息监听器")
|
||||
return
|
||||
override fun getConversationParams(): Triple<String, Int, Boolean> {
|
||||
return Triple(userProfile?.trtcUserId ?: userId, ConversationType.SINGLE_CHAT, true)
|
||||
}
|
||||
|
||||
textMessageListener = object : OnAdvanceMsgListener {
|
||||
override fun onRecvNewMessage(msg: Message?) {
|
||||
msg?.let { message ->
|
||||
override fun getLogTag(): String {
|
||||
return "ChatAiViewModel"
|
||||
}
|
||||
|
||||
override fun handleNewMessage(message: Message, context: Context): Boolean {
|
||||
// 只处理当前聊天对象的消息
|
||||
val currentChatUserId = userProfile?.trtcUserId
|
||||
val currentUserId = com.aiosman.ravenow.AppState.profile?.trtcUserId
|
||||
|
||||
if (currentChatUserId != null && currentUserId != null) {
|
||||
// 检查消息是否来自当前聊天对象,且不是自己发送的消息
|
||||
if ((message.sendID == currentChatUserId || message.sendID == currentUserId) &&
|
||||
message.sendID != currentUserId) {
|
||||
val chatItem = ChatItem.convertToChatItem(message, context, avatar = userProfile?.avatar)
|
||||
chatItem?.let {
|
||||
chatData = listOf(it) + chatData
|
||||
goToNew = true
|
||||
android.util.Log.i("ChatAiViewModel", "收到来自 ${message.sendID} 的消息,更新AI聊天列表")
|
||||
return (message.sendID == currentChatUserId || message.sendID == currentUserId) &&
|
||||
message.sendID != currentUserId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OpenIMClient.getInstance().messageManager.setAdvancedMsgListener(textMessageListener)
|
||||
return false
|
||||
}
|
||||
|
||||
fun UnRegistListener() {
|
||||
// OpenIM SDK 不需要显式移除监听器,只需要设置为 null
|
||||
textMessageListener = null
|
||||
override fun getReceiverInfo(): Pair<String?, String?> {
|
||||
return Pair(userProfile?.trtcUserId, null) // (recvID, groupID)
|
||||
}
|
||||
|
||||
fun clearUnRead() {
|
||||
val conversationID = "single_${userProfile?.trtcUserId}"
|
||||
OpenIMClient.getInstance().messageManager.markConversationMessageAsRead(
|
||||
conversationID,
|
||||
object : OnBase<String> {
|
||||
override fun onSuccess(data: String?) {
|
||||
Log.i("openim", "clear unread success")
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.i("openim", "clear unread failure, code:$code, error:$error")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun onLoadMore(context: Context) {
|
||||
if (!hasMore || isLoading) {
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
viewModelScope.launch {
|
||||
val conversationID = "single_${userProfile?.trtcUserId!!}"
|
||||
// val options = OpenIMClient.getInstance().messageManager.getAdvancedHistoryMessageList() .apply {
|
||||
// conversationID = conversationID
|
||||
// count = 20
|
||||
// lastMinSeq = lastMessage?.seq ?: 0
|
||||
// }
|
||||
OpenIMClient.getInstance().messageManager.getAdvancedHistoryMessageList(
|
||||
object : OnBase<AdvancedMessage> {
|
||||
override fun onSuccess(data: AdvancedMessage?) {
|
||||
val messages = data?.messageList ?: emptyList()
|
||||
chatData = chatData + messages.map {
|
||||
var avatar = userProfile?.avatar
|
||||
if (it.sendID == com.aiosman.ravenow.AppState.profile?.trtcUserId) {
|
||||
avatar = myProfile?.avatar
|
||||
}
|
||||
ChatItem.convertToChatItem(it, context, avatar)
|
||||
}.filterNotNull()
|
||||
|
||||
if (messages.size < 20) {
|
||||
hasMore = false
|
||||
}
|
||||
lastMessage = messages.lastOrNull()
|
||||
isLoading = false
|
||||
Log.d("ChatAiViewModel", "fetch history message success")
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.e("ChatAiViewModel", "fetch history message error: $error")
|
||||
isLoading = false
|
||||
}
|
||||
},
|
||||
conversationID,
|
||||
lastMessage,
|
||||
20,
|
||||
ViewType.History
|
||||
)
|
||||
override fun getMessageAvatar(message: Message): String? {
|
||||
return if (message.sendID == com.aiosman.ravenow.AppState.profile?.trtcUserId) {
|
||||
myProfile?.avatar
|
||||
} else {
|
||||
userProfile?.avatar
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(message: String, context: Context) {
|
||||
// 检查 OpenIM 是否已登录
|
||||
if (!com.aiosman.ravenow.AppState.enableChat) {
|
||||
android.util.Log.w("ChatAiViewModel", "OpenIM 未登录,无法发送消息")
|
||||
return
|
||||
}
|
||||
|
||||
val textMessage = OpenIMClient.getInstance().messageManager.createTextMessage(message)
|
||||
|
||||
OpenIMClient.getInstance().messageManager.sendMessage(
|
||||
object : OnMsgSendCallback {
|
||||
override fun onProgress(progress: Long) {
|
||||
// 发送进度
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.e("ChatAiViewModel", "send message error: $error")
|
||||
}
|
||||
|
||||
override fun onSuccess(data: Message?) {
|
||||
Log.d("ChatAiViewModel", "send message success")
|
||||
override fun onMessageSentSuccess(message: String, sentMessage: Message?) {
|
||||
// AI聊天特有的处理逻辑
|
||||
sendChatAiMessage(myProfile?.trtcUserId!!, userProfile?.trtcUserId!!, message)
|
||||
createGroup2ChatAi(userProfile?.trtcUserId!!, "ai_group")
|
||||
data?.let { sentMessage ->
|
||||
val chatItem = ChatItem.convertToChatItem(sentMessage, context, avatar = myProfile?.avatar)
|
||||
chatItem?.let {
|
||||
chatData = listOf(it) + chatData
|
||||
goToNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
textMessage,
|
||||
userProfile?.trtcUserId!!, // recvID
|
||||
null, // groupID
|
||||
null // offlinePushInfo
|
||||
)
|
||||
}
|
||||
fun createGroup2ChatAi(
|
||||
trtcUserId: String,
|
||||
@@ -212,104 +87,6 @@ class ChatAiViewModel(
|
||||
Log.d("ChatAiViewModel", "OpenIM 不支持会话分组功能")
|
||||
}
|
||||
|
||||
fun sendImageMessage(imageUri: Uri, context: Context) {
|
||||
val tempFile = createTempFile(context, imageUri)
|
||||
val imagePath = tempFile?.path
|
||||
if (imagePath != null) {
|
||||
val imageMessage = OpenIMClient.getInstance().messageManager.createImageMessageFromFullPath(imagePath)
|
||||
|
||||
OpenIMClient.getInstance().messageManager.sendMessage(
|
||||
object : OnMsgSendCallback {
|
||||
override fun onProgress(progress: Long) {
|
||||
Log.d("ChatAiViewModel", "send image message progress: $progress")
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.e("ChatAiViewModel", "send image message error: $error")
|
||||
}
|
||||
|
||||
override fun onSuccess(data: Message?) {
|
||||
Log.d("ChatAiViewModel", "send image message success")
|
||||
data?.let { sentMessage ->
|
||||
val chatItem = ChatItem.convertToChatItem(sentMessage, context, avatar = myProfile?.avatar)
|
||||
chatItem?.let {
|
||||
chatData = listOf(it) + chatData
|
||||
goToNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
imageMessage,
|
||||
userProfile?.trtcUserId!!, // recvID
|
||||
null, // groupID
|
||||
null // offlinePushInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createTempFile(context: Context, uri: Uri): File? {
|
||||
return try {
|
||||
val projection = arrayOf(MediaStore.Images.Media.DATA)
|
||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
||||
val filePath = it.getString(columnIndex)
|
||||
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
|
||||
val mimeType = context.contentResolver.getType(uri)
|
||||
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
|
||||
val tempFile =
|
||||
File.createTempFile("temp_image", ".$extension", context.cacheDir)
|
||||
val outputStream = FileOutputStream(tempFile)
|
||||
|
||||
inputStream?.use { input ->
|
||||
outputStream.use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
tempFile
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchHistoryMessage(context: Context) {
|
||||
val conversationID = "single_${userProfile?.trtcUserId!!}"
|
||||
|
||||
|
||||
OpenIMClient.getInstance().messageManager.getAdvancedHistoryMessageList(
|
||||
object : OnBase<AdvancedMessage> {
|
||||
override fun onSuccess(data: AdvancedMessage?) {
|
||||
val messages = data?.messageList ?: emptyList()
|
||||
chatData = messages.mapNotNull {
|
||||
var avatar = userProfile?.avatar
|
||||
if (it.sendID == com.aiosman.ravenow.AppState.profile?.trtcUserId) {
|
||||
avatar = myProfile?.avatar
|
||||
}
|
||||
ChatItem.convertToChatItem(it, context, avatar)
|
||||
}
|
||||
if (messages.size < 20) {
|
||||
hasMore = false
|
||||
}
|
||||
lastMessage = messages.lastOrNull()
|
||||
Log.d("ChatAiViewModel", "fetch history message success")
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.e("ChatAiViewModel", "fetch history message error: $error")
|
||||
}
|
||||
},
|
||||
conversationID,
|
||||
lastMessage,
|
||||
20,
|
||||
ViewType.History
|
||||
)
|
||||
}
|
||||
fun sendChatAiMessage(
|
||||
fromTrtcUserId: String,
|
||||
toTrtcUserId: String,
|
||||
@@ -318,16 +95,6 @@ class ChatAiViewModel(
|
||||
viewModelScope.launch {
|
||||
val response = ApiClient.api.sendChatAiMessage(SendChatAiRequestBody(fromTrtcUserId = fromTrtcUserId,toTrtcUserId = toTrtcUserId,message = message))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun getDisplayChatList(): List<ChatItem> {
|
||||
val list = chatData
|
||||
// Update showTimestamp for each message
|
||||
for (item in list) {
|
||||
item.showTimestamp = showTimestampMap.getOrDefault(item.msgId, false)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
suspend fun updateNotificationStrategy(strategy: String) {
|
||||
|
||||
@@ -64,6 +64,7 @@ import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.SoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -74,10 +75,12 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.AppState
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.ChatItem
|
||||
import com.aiosman.ravenow.exp.formatChatTime
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.DropdownMenu
|
||||
import com.aiosman.ravenow.ui.composables.MenuItem
|
||||
@@ -91,7 +94,6 @@ import java.util.UUID
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(userId: String) {
|
||||
var isMenuExpanded by remember { mutableStateOf(false) }
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalNavController.current.context
|
||||
val AppColors = LocalAppTheme.current
|
||||
@@ -158,14 +160,37 @@ fun ChatScreen(userId: String) {
|
||||
}
|
||||
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
// 背景图层
|
||||
val bgUrl = AppState.chatBackgroundUrl
|
||||
if (bgUrl != null) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = bgUrl,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentDescription = "chat_background",
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
// 无背景时使用主题背景色
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(AppColors.background)
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
backgroundColor = Color.Transparent,
|
||||
topBar = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(AppColors.background)
|
||||
.background(Color.Transparent)
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
Row(
|
||||
@@ -214,39 +239,12 @@ fun ChatScreen(userId: String) {
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.noRippleClickable {
|
||||
isMenuExpanded = true
|
||||
navController.navigate(NavigationRoute.ChatSetting.route)
|
||||
},
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(
|
||||
AppColors.text)
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = isMenuExpanded,
|
||||
onDismissRequest = {
|
||||
isMenuExpanded = false
|
||||
},
|
||||
menuItems = listOf(
|
||||
MenuItem(
|
||||
title = if (viewModel.notificationStrategy == "mute") "取消静音" else "静音",
|
||||
icon = if (viewModel.notificationStrategy == "mute") R.drawable.rider_pro_notice_mute else R.drawable.rider_pro_notice_active,
|
||||
) {
|
||||
|
||||
isMenuExpanded = false
|
||||
if (NetworkUtils.isNetworkAvailable(context)) {
|
||||
viewModel.viewModelScope.launch {
|
||||
if (viewModel.notificationStrategy == "mute") {
|
||||
viewModel.updateNotificationStrategy("active")
|
||||
} else {
|
||||
viewModel.updateNotificationStrategy("mute")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
android.widget.Toast.makeText(context, "网络连接异常,请检查网络设置", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
),
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,7 +281,7 @@ fun ChatScreen(userId: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(AppColors.background)
|
||||
.background(Color.Transparent)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -349,6 +347,7 @@ fun ChatScreen(userId: String) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatSelfItem(item: ChatItem) {
|
||||
@@ -572,7 +571,7 @@ fun ChatInput(
|
||||
}
|
||||
Box( modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),){
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 45.dp),){
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -651,7 +650,7 @@ fun ChatInput(
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
Image(
|
||||
painter = painterResource(R.mipmap.rider_pro_im_send),
|
||||
painter = painterResource(R.mipmap.btn),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.alpha(alpha)
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
package com.aiosman.ravenow.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
|
||||
@Composable
|
||||
fun ChatSettingScreen() {
|
||||
val appColors = LocalAppTheme.current
|
||||
val navController = LocalNavController.current
|
||||
var showThemeSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(appColors.secondaryBackground)
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
Box(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) {
|
||||
NoticeScreenHeader(title = stringResource(R.string.chat_settings), moreIcon = false)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
SettingCard(
|
||||
title = stringResource(R.string.chat_theme_settings),
|
||||
onClick = { showThemeSheet = true }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
SettingCard(
|
||||
title = stringResource(R.string.report),
|
||||
onClick = { /* TODO: 跳转举报 */ }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showThemeSheet) {
|
||||
ThemePickerSheet(onClose = { showThemeSheet = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingCard(title: String, onClick: () -> Unit) {
|
||||
val appColors = LocalAppTheme.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(appColors.background)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = title,
|
||||
color = appColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.rave_now_nav_right),
|
||||
contentDescription = null,
|
||||
tint = appColors.text,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ThemePickerSheet(onClose: () -> Unit) {
|
||||
val appColors = LocalAppTheme.current
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenHeight = configuration.screenHeightDp.dp
|
||||
val sheetHeight = screenHeight * 0.9f
|
||||
var previewUrl by remember { mutableStateOf<String?>(null) }
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onClose,
|
||||
sheetState = sheetState,
|
||||
containerColor = appColors.secondaryBackground,
|
||||
dragHandle = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(sheetHeight),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.custom_background),
|
||||
color = appColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
modifier = Modifier.weight(1f).padding(start = 90.dp),
|
||||
)
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.rider_pro_close),
|
||||
contentDescription = "close",
|
||||
tint = appColors.text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 从相册选择
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(appColors.background)
|
||||
.clickable { /* TODO: 打开相册选择 */ }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = stringResource(R.string.select_from_gallery), color = appColors.text, fontSize = 15.sp, modifier = Modifier.weight(1f))
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.group_info_edit),
|
||||
contentDescription = null,
|
||||
tint = appColors.text,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(text = stringResource(R.string.featured_backgrounds), color = appColors.text, fontSize = 12.sp)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
val presets = remember {
|
||||
listOf(
|
||||
"https://picsum.photos/seed/ai1/400/600",
|
||||
"https://picsum.photos/seed/ai2/400/600",
|
||||
"https://picsum.photos/seed/ai3/400/600",
|
||||
"https://picsum.photos/seed/ai4/400/600",
|
||||
"https://picsum.photos/seed/ai5/400/600",
|
||||
"https://picsum.photos/seed/ai6/400/600",
|
||||
"https://picsum.photos/seed/ai7/400/600",
|
||||
"https://picsum.photos/seed/ai8/400/600",
|
||||
"https://picsum.photos/seed/ai9/400/600",
|
||||
"https://picsum.photos/seed/ai10/400/600",
|
||||
"https://picsum.photos/seed/ai11/400/600",
|
||||
"https://picsum.photos/seed/ai12/400/600",
|
||||
"https://picsum.photos/seed/ai13/400/600",
|
||||
"https://picsum.photos/seed/ai14/400/600",
|
||||
"https://picsum.photos/seed/ai15/400/600",
|
||||
)
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
items(presets) { url ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { previewUrl = url }
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(appColors.decentBackground)
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = url,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(3f / 4f),
|
||||
contentDescription = "preset",
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Heart Drive",
|
||||
color = appColors.text,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预览自定义背景弹窗
|
||||
if (previewUrl != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { previewUrl = null },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = appColors.secondaryBackground,
|
||||
dragHandle = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(sheetHeight)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = previewUrl!!,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentDescription = "preview_bg",
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Column(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.previewing_custom_background), color = Color.White, fontSize = 15.sp)
|
||||
}
|
||||
|
||||
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Row {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color.White)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.each_theme_unique_experience), color = Color.Black, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color.White)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.select_apply_to_use_theme), color = Color.Black, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color(0xFF7C4DFF))
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.tap_cancel_to_preview_other_themes), color = Color.White, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// 底部按钮
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(bottom = 60.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.White)
|
||||
.clickable { previewUrl = null }
|
||||
.padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = stringResource(R.string.cancel), color = Color.Black, fontSize = 14.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFEE2A33),
|
||||
Color(0xFFD80264),
|
||||
Color(0xFF664C92)
|
||||
)
|
||||
)
|
||||
)
|
||||
.clickable {
|
||||
previewUrl?.let { url ->
|
||||
com.aiosman.ravenow.AppStore.saveChatBackgroundUrl(url)
|
||||
previewUrl = null
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = stringResource(R.string.moment_ai_apply), color = Color.White, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,53 +1,24 @@
|
||||
package com.aiosman.ravenow.ui.chat
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aiosman.ravenow.ChatState
|
||||
import com.aiosman.ravenow.data.AccountService
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.data.UserService
|
||||
import com.aiosman.ravenow.data.UserServiceImpl
|
||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
||||
import com.aiosman.ravenow.entity.ChatItem
|
||||
import com.aiosman.ravenow.entity.ChatNotification
|
||||
// OpenIM SDK 导入
|
||||
import io.openim.android.sdk.OpenIMClient
|
||||
import io.openim.android.sdk.enums.MessageType
|
||||
import io.openim.android.sdk.enums.ViewType
|
||||
import io.openim.android.sdk.listener.OnAdvanceMsgListener
|
||||
import io.openim.android.sdk.listener.OnBase
|
||||
import io.openim.android.sdk.listener.OnMsgSendCallback
|
||||
import io.openim.android.sdk.models.*
|
||||
import io.openim.android.sdk.enums.ConversationType
|
||||
import io.openim.android.sdk.models.Message
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
|
||||
class ChatViewModel(
|
||||
val userId: String,
|
||||
) : ViewModel() {
|
||||
var chatData by mutableStateOf<List<ChatItem>>(emptyList())
|
||||
) : BaseChatViewModel() {
|
||||
var userProfile by mutableStateOf<AccountProfileEntity?>(null)
|
||||
var myProfile by mutableStateOf<AccountProfileEntity?>(null)
|
||||
val userService: UserService = UserServiceImpl()
|
||||
val accountService: AccountService = AccountServiceImpl()
|
||||
var textMessageListener: OnAdvanceMsgListener? = null
|
||||
var hasMore by mutableStateOf(true)
|
||||
var isLoading by mutableStateOf(false)
|
||||
var lastMessage: Message? = null
|
||||
val showTimestampMap = mutableMapOf<String, Boolean>() // Add this map
|
||||
var chatNotification by mutableStateOf<ChatNotification?>(null)
|
||||
var goToNew by mutableStateOf(false)
|
||||
fun init(context: Context) {
|
||||
override fun init(context: Context) {
|
||||
// 获取用户信息
|
||||
viewModelScope.launch {
|
||||
val resp = userService.getUserProfile(userId)
|
||||
@@ -55,252 +26,50 @@ class ChatViewModel(
|
||||
myProfile = accountService.getMyAccountProfile()
|
||||
|
||||
RegistListener(context)
|
||||
|
||||
// 获取会话信息,然后加载历史消息
|
||||
getOneConversation {
|
||||
fetchHistoryMessage(context)
|
||||
}
|
||||
|
||||
// 获取通知信息
|
||||
val notiStrategy = ChatState.getStrategyByTargetTrtcId(resp.trtcUserId)
|
||||
chatNotification = notiStrategy
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun RegistListener(context: Context) {
|
||||
// 检查 OpenIM 是否已登录
|
||||
if (!com.aiosman.ravenow.AppState.enableChat) {
|
||||
android.util.Log.w("ChatViewModel", "OpenIM 未登录,跳过注册消息监听器")
|
||||
return
|
||||
override fun getConversationParams(): Triple<String, Int, Boolean> {
|
||||
return Triple(userProfile?.trtcUserId ?: userId, ConversationType.SINGLE_CHAT, true)
|
||||
}
|
||||
|
||||
textMessageListener = object : OnAdvanceMsgListener {
|
||||
override fun onRecvNewMessage(msg: Message?) {
|
||||
msg?.let { message ->
|
||||
override fun getLogTag(): String {
|
||||
return "ChatViewModel"
|
||||
}
|
||||
|
||||
override fun handleNewMessage(message: Message, context: Context): Boolean {
|
||||
// 只处理当前聊天对象的消息
|
||||
val currentChatUserId = userProfile?.trtcUserId
|
||||
val currentUserId = com.aiosman.ravenow.AppState.profile?.trtcUserId
|
||||
|
||||
if (currentChatUserId != null && currentUserId != null) {
|
||||
// 检查消息是否来自当前聊天对象,且不是自己发送的消息
|
||||
if ((message.sendID == currentChatUserId || message.sendID == currentUserId) &&
|
||||
message.sendID != currentUserId) {
|
||||
val chatItem = ChatItem.convertToChatItem(message, context, avatar = userProfile?.avatar)
|
||||
chatItem?.let {
|
||||
chatData = listOf(it) + chatData
|
||||
goToNew = true
|
||||
android.util.Log.i("ChatViewModel", "收到来自 ${message.sendID} 的消息,更新聊天列表")
|
||||
return (message.sendID == currentChatUserId || message.sendID == currentUserId) &&
|
||||
message.sendID != currentUserId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OpenIMClient.getInstance().messageManager.setAdvancedMsgListener(textMessageListener)
|
||||
return false
|
||||
}
|
||||
|
||||
fun UnRegistListener() {
|
||||
// OpenIM SDK 不需要显式移除监听器,只需要设置为 null
|
||||
textMessageListener = null
|
||||
override fun getReceiverInfo(): Pair<String?, String?> {
|
||||
return Pair(userProfile?.trtcUserId, null) // (recvID, groupID)
|
||||
}
|
||||
|
||||
fun clearUnRead() {
|
||||
val conversationID = "single_${userProfile?.trtcUserId}"
|
||||
OpenIMClient.getInstance().messageManager.markConversationMessageAsRead(
|
||||
conversationID,
|
||||
object : OnBase<String> {
|
||||
override fun onSuccess(data: String?) {
|
||||
Log.i("openim", "clear unread success")
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.i("openim", "clear unread failure, code:$code, error:$error")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun onLoadMore(context: Context) {
|
||||
if (!hasMore || isLoading) {
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
viewModelScope.launch {
|
||||
val conversationID = "single_${userProfile?.trtcUserId!!}"
|
||||
|
||||
OpenIMClient.getInstance().messageManager.getAdvancedHistoryMessageList(
|
||||
object : OnBase<AdvancedMessage> {
|
||||
override fun onSuccess(data: AdvancedMessage?) {
|
||||
val messages = data?.messageList ?: emptyList()
|
||||
chatData = chatData + messages.map {
|
||||
var avatar = userProfile?.avatar
|
||||
if (it.sendID == com.aiosman.ravenow.AppState.profile?.trtcUserId) {
|
||||
avatar = myProfile?.avatar
|
||||
}
|
||||
ChatItem.convertToChatItem(it, context, avatar)
|
||||
}.filterNotNull()
|
||||
|
||||
if (messages.size < 20) {
|
||||
hasMore = false
|
||||
}
|
||||
lastMessage = messages.lastOrNull()
|
||||
isLoading = false
|
||||
Log.d("ChatViewModel", "fetch history message success")
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.e("ChatViewModel", "fetch history message error: $error")
|
||||
isLoading = false
|
||||
}
|
||||
},
|
||||
conversationID,
|
||||
lastMessage,
|
||||
20,
|
||||
ViewType.History
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(message: String, context: Context) {
|
||||
// 检查 OpenIM 是否已登录
|
||||
if (!com.aiosman.ravenow.AppState.enableChat) {
|
||||
android.util.Log.w("ChatViewModel", "OpenIM 未登录,无法发送消息")
|
||||
return
|
||||
}
|
||||
|
||||
val textMessage = OpenIMClient.getInstance().messageManager.createTextMessage(message)
|
||||
|
||||
OpenIMClient.getInstance().messageManager.sendMessage(
|
||||
object : OnMsgSendCallback {
|
||||
override fun onProgress(progress: Long) {
|
||||
// 发送进度
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.e("ChatViewModel", "send message error: $error")
|
||||
}
|
||||
|
||||
override fun onSuccess(data: Message?) {
|
||||
Log.d("ChatViewModel", "send message success")
|
||||
data?.let { sentMessage ->
|
||||
val chatItem = ChatItem.convertToChatItem(sentMessage, context, avatar = myProfile?.avatar)
|
||||
chatItem?.let {
|
||||
chatData = listOf(it) + chatData
|
||||
goToNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
textMessage,
|
||||
userProfile?.trtcUserId!!, // recvID
|
||||
null, // groupID
|
||||
null // offlinePushInfo
|
||||
)
|
||||
}
|
||||
|
||||
fun sendImageMessage(imageUri: Uri, context: Context) {
|
||||
val tempFile = createTempFile(context, imageUri)
|
||||
val imagePath = tempFile?.path
|
||||
if (imagePath != null) {
|
||||
val imageMessage = OpenIMClient.getInstance().messageManager.createImageMessageFromFullPath(imagePath)
|
||||
|
||||
OpenIMClient.getInstance().messageManager.sendMessage(
|
||||
object : OnMsgSendCallback {
|
||||
override fun onProgress(progress: Long) {
|
||||
Log.d("ChatViewModel", "send image message progress: $progress")
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.e("ChatViewModel", "send image message error: $error")
|
||||
}
|
||||
|
||||
override fun onSuccess(data: Message?) {
|
||||
Log.d("ChatViewModel", "send image message success")
|
||||
data?.let { sentMessage ->
|
||||
val chatItem = ChatItem.convertToChatItem(sentMessage, context, avatar = myProfile?.avatar)
|
||||
chatItem?.let {
|
||||
chatData = listOf(it) + chatData
|
||||
goToNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
imageMessage,
|
||||
userProfile?.trtcUserId!!, // recvID
|
||||
null, // groupID
|
||||
null // offlinePushInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createTempFile(context: Context, uri: Uri): File? {
|
||||
return try {
|
||||
val projection = arrayOf(MediaStore.Images.Media.DATA)
|
||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
||||
val filePath = it.getString(columnIndex)
|
||||
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
|
||||
val mimeType = context.contentResolver.getType(uri)
|
||||
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
|
||||
val tempFile =
|
||||
File.createTempFile("temp_image", ".$extension", context.cacheDir)
|
||||
val outputStream = FileOutputStream(tempFile)
|
||||
|
||||
inputStream?.use { input ->
|
||||
outputStream.use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
tempFile
|
||||
override fun getMessageAvatar(message: Message): String? {
|
||||
return if (message.sendID == com.aiosman.ravenow.AppState.profile?.trtcUserId) {
|
||||
myProfile?.avatar
|
||||
} else {
|
||||
null
|
||||
userProfile?.avatar
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchHistoryMessage(context: Context) {
|
||||
val conversationID = "single_${userProfile?.trtcUserId!!}"
|
||||
|
||||
OpenIMClient.getInstance().messageManager.getAdvancedHistoryMessageList(
|
||||
object : OnBase<AdvancedMessage> {
|
||||
override fun onSuccess(data: AdvancedMessage?) {
|
||||
val messages = data?.messageList ?: emptyList()
|
||||
chatData = messages.mapNotNull {
|
||||
var avatar = userProfile?.avatar
|
||||
if (it.sendID == com.aiosman.ravenow.AppState.profile?.trtcUserId) {
|
||||
avatar = myProfile?.avatar
|
||||
}
|
||||
ChatItem.convertToChatItem(it, context, avatar)
|
||||
}
|
||||
if (messages.size < 20) {
|
||||
hasMore = false
|
||||
}
|
||||
lastMessage = messages.lastOrNull()
|
||||
Log.d("ChatViewModel", "fetch history message success")
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.e("ChatViewModel", "fetch history message error: $error")
|
||||
}
|
||||
},
|
||||
conversationID,
|
||||
null,
|
||||
20,
|
||||
ViewType.History
|
||||
)
|
||||
}
|
||||
|
||||
fun getDisplayChatList(): List<ChatItem> {
|
||||
val list = chatData
|
||||
// Update showTimestamp for each message
|
||||
for (item in list) {
|
||||
item.showTimestamp = showTimestampMap.getOrDefault(item.msgId, false)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
suspend fun updateNotificationStrategy(strategy: String) {
|
||||
userProfile?.let {
|
||||
|
||||
@@ -183,45 +183,21 @@ fun GroupChatScreen(groupId: String,name: String,avatar: String,) {
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
if (viewModel.groupAvatar.isNotEmpty()) {
|
||||
if (viewModel.groupInfo?.groupAvatar?.isNotEmpty() == true) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = viewModel.groupAvatar,
|
||||
imageUrl = viewModel.groupInfo!!.groupAvatar,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.noRippleClickable {
|
||||
navController.navigateToGroupInfo(groupId)
|
||||
},
|
||||
.size(35.dp)
|
||||
.clip(RoundedCornerShape(15.dp)),
|
||||
contentDescription = "群聊头像"
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(AppColors.decentBackground)
|
||||
.noRippleClickable {
|
||||
navController.navigateToGroupInfo(groupId)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = viewModel.groupName,
|
||||
style = TextStyle(
|
||||
color = AppColors.text,
|
||||
fontSize = 18.sp,
|
||||
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.W700
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow =TextOverflow.Ellipsis,
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = viewModel.groupName,
|
||||
style = TextStyle(
|
||||
@@ -230,12 +206,10 @@ fun GroupChatScreen(groupId: String,name: String,avatar: String,) {
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow =TextOverflow.Ellipsis,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Box {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.rider_pro_more_horizon),
|
||||
modifier = Modifier
|
||||
@@ -243,12 +217,11 @@ fun GroupChatScreen(groupId: String,name: String,avatar: String,) {
|
||||
.noRippleClickable {
|
||||
navController.navigateToGroupInfo(groupId)
|
||||
},
|
||||
contentDescription = null,
|
||||
contentDescription = "更多",
|
||||
colorFilter = ColorFilter.tint(AppColors.text)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
Column(
|
||||
@@ -677,7 +650,7 @@ fun GroupChatInput(
|
||||
animationSpec = tween(300)
|
||||
)
|
||||
Image(
|
||||
painter = painterResource(R.mipmap.rider_pro_im_send),
|
||||
painter = painterResource(R.mipmap.btn),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.alpha(alpha)
|
||||
|
||||
@@ -1,55 +1,25 @@
|
||||
package com.aiosman.ravenow.ui.chat
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aiosman.ravenow.ChatState
|
||||
import com.aiosman.ravenow.data.AccountService
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.data.UserService
|
||||
import com.aiosman.ravenow.data.UserServiceImpl
|
||||
import com.aiosman.ravenow.AppStore
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import com.aiosman.ravenow.data.api.GroupChatRequestBody
|
||||
import com.aiosman.ravenow.data.api.SendChatAiRequestBody
|
||||
import com.aiosman.ravenow.data.api.SingleChatRequestBody
|
||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
||||
import com.aiosman.ravenow.entity.ChatItem
|
||||
import com.aiosman.ravenow.entity.ChatNotification
|
||||
// OpenIM SDK 导入
|
||||
import io.openim.android.sdk.OpenIMClient
|
||||
import io.openim.android.sdk.enums.ViewType
|
||||
import io.openim.android.sdk.listener.OnAdvanceMsgListener
|
||||
import io.openim.android.sdk.listener.OnBase
|
||||
import io.openim.android.sdk.listener.OnMsgSendCallback
|
||||
import io.openim.android.sdk.enums.ConversationType
|
||||
import io.openim.android.sdk.models.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
class GroupChatViewModel(
|
||||
val groupId: String,
|
||||
val name: String,
|
||||
val avatar: String,
|
||||
) : ViewModel() {
|
||||
var chatData by mutableStateOf<List<ChatItem>>(emptyList())
|
||||
) : BaseChatViewModel() {
|
||||
var groupInfo by mutableStateOf<GroupInfo?>(null)
|
||||
var myProfile by mutableStateOf<AccountProfileEntity?>(null)
|
||||
val userService: UserService = UserServiceImpl()
|
||||
val accountService: AccountService = AccountServiceImpl()
|
||||
var textMessageListener: OnAdvanceMsgListener? = null
|
||||
var hasMore by mutableStateOf(true)
|
||||
var isLoading by mutableStateOf(false)
|
||||
var lastMessage: Message? = null
|
||||
val showTimestampMap = mutableMapOf<String, Boolean>()
|
||||
var goToNew by mutableStateOf(false)
|
||||
|
||||
// 群聊特有属性
|
||||
var memberCount by mutableStateOf(0)
|
||||
@@ -64,13 +34,17 @@ class GroupChatViewModel(
|
||||
val ownerId: String
|
||||
)
|
||||
|
||||
fun init(context: Context) {
|
||||
override fun init(context: Context) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
getGroupInfo()
|
||||
myProfile = accountService.getMyAccountProfile()
|
||||
RegistListener(context)
|
||||
|
||||
// 获取会话信息,然后加载历史消息
|
||||
getOneConversation {
|
||||
fetchHistoryMessage(context)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("GroupChatViewModel", "初始化失败: ${e.message}")
|
||||
}
|
||||
@@ -78,7 +52,34 @@ class GroupChatViewModel(
|
||||
}
|
||||
|
||||
private suspend fun getGroupInfo() {
|
||||
// 简化群组信息获取,使用默认信息
|
||||
try {
|
||||
val response = ApiClient.api.getRoomDetail(trtcId = groupId)
|
||||
val room = response.body()?.data
|
||||
groupInfo = room?.let {
|
||||
GroupInfo(
|
||||
groupId = groupId,
|
||||
groupName = it.name,
|
||||
groupAvatar = if (it.avatar.isNullOrEmpty()) {
|
||||
val groupIdBase64 = Base64.encodeToString(
|
||||
groupId.toByteArray(),
|
||||
Base64.NO_WRAP
|
||||
)
|
||||
"${ApiClient.RETROFIT_URL}group/avatar?groupIdBase64=$groupIdBase64&token=${AppStore.token}"
|
||||
} else {
|
||||
"${ApiClient.BASE_API_URL}/outside${it.avatar}?token=${AppStore.token}"
|
||||
},
|
||||
memberCount = it.userCount,
|
||||
ownerId = it.creator.userId
|
||||
)
|
||||
} ?: GroupInfo(
|
||||
groupId = groupId,
|
||||
groupName = name,
|
||||
groupAvatar = avatar,
|
||||
memberCount = 0,
|
||||
ownerId = ""
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("GroupChatViewModel", "加载群信息失败: ${e.message}", e)
|
||||
groupInfo = GroupInfo(
|
||||
groupId = groupId,
|
||||
groupName = name,
|
||||
@@ -86,125 +87,43 @@ class GroupChatViewModel(
|
||||
memberCount = 0,
|
||||
ownerId = ""
|
||||
)
|
||||
} finally {
|
||||
groupName = groupInfo?.groupName ?: ""
|
||||
groupAvatar = groupInfo?.groupAvatar ?: ""
|
||||
memberCount = groupInfo?.memberCount ?: 0
|
||||
}
|
||||
|
||||
fun RegistListener(context: Context) {
|
||||
// 检查 OpenIM 是否已登录
|
||||
if (!com.aiosman.ravenow.AppState.enableChat) {
|
||||
android.util.Log.w("GroupChatViewModel", "OpenIM 未登录,跳过注册消息监听器")
|
||||
return
|
||||
}
|
||||
|
||||
textMessageListener = object : OnAdvanceMsgListener {
|
||||
override fun onRecvNewMessage(msg: Message?) {
|
||||
msg?.let {
|
||||
override fun getConversationParams(): Triple<String, Int, Boolean> {
|
||||
// 根据群组类型决定ConversationType,这里假设是普通群聊
|
||||
return Triple(groupId, ConversationType.GROUP_CHAT, false)
|
||||
}
|
||||
|
||||
override fun getLogTag(): String {
|
||||
return "GroupChatViewModel"
|
||||
}
|
||||
|
||||
override fun handleNewMessage(message: Message, context: Context): Boolean {
|
||||
// 检查是否是当前群聊的消息
|
||||
if (it.groupID == groupId) {
|
||||
val chatItem = ChatItem.convertToChatItem(msg, context, avatar = null)
|
||||
chatItem?.let {
|
||||
chatData = listOf(it) + chatData
|
||||
goToNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OpenIMClient.getInstance().messageManager.setAdvancedMsgListener(textMessageListener)
|
||||
return message.groupID == groupId
|
||||
}
|
||||
|
||||
fun UnRegistListener() {
|
||||
// OpenIM SDK 不需要显式移除监听器,只需要设置为 null
|
||||
textMessageListener = null
|
||||
override fun getReceiverInfo(): Pair<String?, String?> {
|
||||
return Pair(null, groupId) // (recvID, groupID)
|
||||
}
|
||||
|
||||
fun clearUnRead() {
|
||||
val conversationID = "group_${groupId}"
|
||||
OpenIMClient.getInstance().messageManager.markConversationMessageAsRead(
|
||||
conversationID,
|
||||
object : OnBase<String> {
|
||||
override fun onSuccess(data: String?) {
|
||||
Log.i("openim", "清除群聊未读消息成功")
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.i("openim", "清除群聊未读消息失败, code:$code, error:$error")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun onLoadMore(context: Context) {
|
||||
if (!hasMore || isLoading) return
|
||||
isLoading = true
|
||||
viewModelScope.launch {
|
||||
val conversationID = "group_${groupId}"
|
||||
|
||||
OpenIMClient.getInstance().messageManager.getAdvancedHistoryMessageList(
|
||||
object : OnBase<AdvancedMessage> {
|
||||
override fun onSuccess(data: AdvancedMessage?) {
|
||||
val messages = data?.messageList ?: emptyList()
|
||||
chatData = chatData + messages.map {
|
||||
ChatItem.convertToChatItem(it, context, avatar = null)
|
||||
}.filterNotNull()
|
||||
|
||||
if (messages.size < 20) {
|
||||
hasMore = false
|
||||
}
|
||||
lastMessage = messages.lastOrNull()
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.e("GroupChatViewModel", "获取群聊历史消息失败: $error")
|
||||
isLoading = false
|
||||
}
|
||||
},
|
||||
conversationID,
|
||||
lastMessage,
|
||||
20,
|
||||
ViewType.History
|
||||
)
|
||||
override fun getMessageAvatar(message: Message): String? {
|
||||
// 群聊中,如果是自己发送的消息显示自己的头像,否则为null(由ChatItem处理)
|
||||
return if (message.sendID == com.aiosman.ravenow.AppState.profile?.trtcUserId) {
|
||||
myProfile?.avatar
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(message: String, context: Context) {
|
||||
// 检查 OpenIM 是否已登录
|
||||
if (!com.aiosman.ravenow.AppState.enableChat) {
|
||||
android.util.Log.w("GroupChatViewModel", "OpenIM 未登录,无法发送消息")
|
||||
return
|
||||
}
|
||||
|
||||
val textMessage = OpenIMClient.getInstance().messageManager.createTextMessage(message)
|
||||
|
||||
OpenIMClient.getInstance().messageManager.sendMessage(
|
||||
object : OnMsgSendCallback {
|
||||
override fun onProgress(progress: Long) {
|
||||
// 发送进度
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.e("GroupChatViewModel", "发送群聊消息失败: $error")
|
||||
}
|
||||
|
||||
override fun onSuccess(data: Message?) {
|
||||
override fun onMessageSentSuccess(message: String, sentMessage: Message?) {
|
||||
// 群聊特有的处理逻辑
|
||||
sendChatAiMessage(message = message, trtcGroupId = groupId)
|
||||
data?.let { sentMessage ->
|
||||
val chatItem = ChatItem.convertToChatItem(sentMessage, context, avatar = myProfile?.avatar)
|
||||
chatItem?.let {
|
||||
chatData = listOf(it) + chatData
|
||||
goToNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
textMessage,
|
||||
null, // recvID (群聊为 null)
|
||||
groupId, // groupID
|
||||
null // offlinePushInfo
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -216,107 +135,6 @@ class GroupChatViewModel(
|
||||
viewModelScope.launch {
|
||||
val response = ApiClient.api.sendChatAiMessage(SendChatAiRequestBody(trtcGroupId = trtcGroupId,message = message))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun sendImageMessage(imageUri: Uri, context: Context) {
|
||||
val tempFile = createTempFile(context, imageUri)
|
||||
val imagePath = tempFile?.path
|
||||
if (imagePath != null) {
|
||||
val imageMessage = OpenIMClient.getInstance().messageManager.createImageMessageFromFullPath(imagePath)
|
||||
|
||||
OpenIMClient.getInstance().messageManager.sendMessage(
|
||||
object : OnMsgSendCallback {
|
||||
override fun onProgress(progress: Long) {
|
||||
Log.d("GroupChatViewModel", "发送群聊图片消息进度: $progress")
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.e("GroupChatViewModel", "发送群聊图片消息失败: $error")
|
||||
}
|
||||
|
||||
override fun onSuccess(data: Message?) {
|
||||
data?.let { sentMessage ->
|
||||
val chatItem = ChatItem.convertToChatItem(sentMessage, context, avatar = myProfile?.avatar)
|
||||
chatItem?.let {
|
||||
chatData = listOf(it) + chatData
|
||||
goToNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
imageMessage,
|
||||
null, // recvID (群聊为 null)
|
||||
groupId, // groupID
|
||||
null // offlinePushInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createTempFile(context: Context, uri: Uri): File? {
|
||||
return try {
|
||||
val projection = arrayOf(MediaStore.Images.Media.DATA)
|
||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
||||
val filePath = it.getString(columnIndex)
|
||||
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
|
||||
val mimeType = context.contentResolver.getType(uri)
|
||||
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
|
||||
val tempFile =
|
||||
File.createTempFile("temp_image", ".$extension", context.cacheDir)
|
||||
val outputStream = FileOutputStream(tempFile)
|
||||
|
||||
inputStream?.use { input ->
|
||||
outputStream.use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
tempFile
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchHistoryMessage(context: Context) {
|
||||
val conversationID = "group_${groupId}"
|
||||
|
||||
OpenIMClient.getInstance().messageManager.getAdvancedHistoryMessageList(
|
||||
object : OnBase<AdvancedMessage> {
|
||||
override fun onSuccess(data: AdvancedMessage?) {
|
||||
val messages = data?.messageList ?: emptyList()
|
||||
chatData = messages.mapNotNull {
|
||||
ChatItem.convertToChatItem(it, context, avatar = null)
|
||||
}
|
||||
if (messages.size < 20) {
|
||||
hasMore = false
|
||||
}
|
||||
lastMessage = messages.lastOrNull()
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
Log.e("GroupChatViewModel", "获取群聊历史消息失败: $error")
|
||||
}
|
||||
},
|
||||
conversationID,
|
||||
null,
|
||||
20,
|
||||
ViewType.History
|
||||
)
|
||||
}
|
||||
|
||||
fun getDisplayChatList(): List<ChatItem> {
|
||||
val list = chatData
|
||||
for (item in list) {
|
||||
item.showTimestamp = showTimestampMap.getOrDefault(item.msgId, false)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,11 +8,14 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -29,6 +32,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
@@ -40,6 +44,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.CommentEntity
|
||||
import com.aiosman.ravenow.ui.composables.EditCommentBottomModal
|
||||
@@ -76,8 +81,9 @@ class CommentModalViewModel(
|
||||
fun CommentModalContent(
|
||||
postId: Int? = null,
|
||||
commentCount: Int = 0,
|
||||
onCommentAdded: () -> Unit = {},
|
||||
onDismiss: () -> Unit = {}
|
||||
onDismiss: () -> Unit = {},
|
||||
showTitle: Boolean = true,
|
||||
onCommentAdded: () -> Unit = {}
|
||||
) {
|
||||
val model = viewModel<CommentModalViewModel>(
|
||||
key = "CommentModalViewModel_$postId",
|
||||
@@ -88,6 +94,7 @@ fun CommentModalContent(
|
||||
}
|
||||
)
|
||||
val commentViewModel = model.commentsViewModel
|
||||
val AppColors = LocalAppTheme.current
|
||||
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
LaunchedEffect(Unit) {
|
||||
|
||||
@@ -99,10 +106,24 @@ fun CommentModalContent(
|
||||
var bottomPadding by remember { mutableStateOf(0.dp) }
|
||||
var softwareKeyboardController = LocalSoftwareKeyboardController.current
|
||||
var replyComment by remember { mutableStateOf<CommentEntity?>(null) }
|
||||
var shouldAutoFocus by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(imePadding) {
|
||||
bottomPadding = imePadding.dp
|
||||
}
|
||||
|
||||
// 当设置回复评论时,自动聚焦到输入框
|
||||
LaunchedEffect(replyComment) {
|
||||
if (replyComment != null) {
|
||||
// 延迟一下,确保输入框已经渲染
|
||||
kotlinx.coroutines.delay(100)
|
||||
shouldAutoFocus = true
|
||||
// 请求显示键盘
|
||||
softwareKeyboardController?.show()
|
||||
} else {
|
||||
shouldAutoFocus = false
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
onDismiss()
|
||||
@@ -113,13 +134,12 @@ fun CommentModalContent(
|
||||
onDismissRequest = {
|
||||
showCommentMenu = false
|
||||
},
|
||||
containerColor = Color.White,
|
||||
containerColor = AppColors.background,
|
||||
sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
),
|
||||
dragHandle = {},
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||
windowInsets = WindowInsets(0)
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
|
||||
) {
|
||||
CommentMenuModal(
|
||||
onDeleteClick = {
|
||||
@@ -142,7 +162,25 @@ fun CommentModalContent(
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
// 拖动手柄
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp, bottom = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(40.dp)
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(AppColors.divider)
|
||||
)
|
||||
}
|
||||
|
||||
if (showTitle) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -153,26 +191,26 @@ fun CommentModalContent(
|
||||
stringResource(R.string.comment),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = AppColors.text,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
color = Color(0xFFF7F7F7)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.comment_count, commentCount),
|
||||
fontSize = 14.sp,
|
||||
color = Color(0xff666666)
|
||||
color = AppColors.secondaryText
|
||||
)
|
||||
OrderSelectionComponent {
|
||||
OrderSelectionComponent(
|
||||
selectedOrder = commentViewModel.order
|
||||
) {
|
||||
commentViewModel.order = it
|
||||
commentViewModel.reloadComment()
|
||||
}
|
||||
@@ -180,12 +218,12 @@ fun CommentModalContent(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
item {
|
||||
CommentContent(
|
||||
@@ -194,7 +232,9 @@ fun CommentModalContent(
|
||||
|
||||
},
|
||||
onReply = { parentComment, _, _, _ ->
|
||||
|
||||
// 设置回复的评论,这样 EditCommentBottomModal 会显示回复输入框
|
||||
// CommentContent 内部已经处理了游客模式检查,所以这里直接设置即可
|
||||
replyComment = parentComment
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(72.dp))
|
||||
@@ -205,9 +245,12 @@ fun CommentModalContent(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xfff7f7f7))
|
||||
.background(AppColors.secondaryBackground)
|
||||
) {
|
||||
EditCommentBottomModal(
|
||||
replyComment = replyComment,
|
||||
autoFocus = shouldAutoFocus
|
||||
) {
|
||||
EditCommentBottomModal(replyComment) {
|
||||
commentViewModel.viewModelScope.launch {
|
||||
if (replyComment != null) {
|
||||
if (replyComment?.parentCommentId != null) {
|
||||
@@ -225,6 +268,13 @@ fun CommentModalContent(
|
||||
// 顶级评论
|
||||
commentViewModel.createComment(it)
|
||||
}
|
||||
// 评论创建成功后调用回调
|
||||
onCommentAdded()
|
||||
// 清空回复状态和自动聚焦状态
|
||||
replyComment = null
|
||||
shouldAutoFocus = false
|
||||
// 隐藏键盘
|
||||
softwareKeyboardController?.hide()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -41,15 +41,15 @@ import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.CommentEntity
|
||||
import com.aiosman.ravenow.exp.timeAgo
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.ui.navigateToPost
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
import com.aiosman.ravenow.utils.NetworkUtils
|
||||
import com.aiosman.ravenow.ui.network.NetworkErrorContent
|
||||
@Composable
|
||||
fun CommentNoticeScreen() {
|
||||
fun CommentNoticeScreen(includeStatusBarPadding: Boolean = true){
|
||||
val viewModel = viewModel<CommentNoticeListViewModel>(
|
||||
key = "CommentNotice",
|
||||
factory = object : ViewModelProvider.Factory {
|
||||
@@ -67,18 +67,23 @@ fun CommentNoticeScreen() {
|
||||
val navController = LocalNavController.current
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().background(color = AppColors.background)
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
Box(
|
||||
StatusBarMaskLayout(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.background(color = AppColors.background)
|
||||
.padding(horizontal = 16.dp),
|
||||
darkIcons = !AppState.darkMode,
|
||||
maskBoxBackgroundColor = AppColors.background,
|
||||
includeStatusBarPadding = includeStatusBarPadding
|
||||
) {
|
||||
NoticeScreenHeader(stringResource(R.string.comment), moreIcon = false)
|
||||
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current)
|
||||
|
||||
if (!isNetworkAvailable) {
|
||||
NetworkErrorContent(
|
||||
onReload = {
|
||||
viewModel.initData(context, force = true)
|
||||
}
|
||||
if (comments.itemCount == 0 && comments.loadState.refresh is LoadState.NotLoading) {
|
||||
)
|
||||
} else if (comments.itemCount == 0 && comments.loadState.refresh is LoadState.NotLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -90,32 +95,25 @@ fun CommentNoticeScreen() {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = painterResource(
|
||||
id =if(AppState.darkMode) R.mipmap.qst_pl_qs_as_img
|
||||
else R.mipmap.qst_pl_qs_img),
|
||||
painter = painterResource(id = R.mipmap.invalid_name_5),
|
||||
contentDescription = "No Comment",
|
||||
modifier = Modifier.size(181.dp)
|
||||
modifier = Modifier
|
||||
.size(width = 181.dp, height = 153.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
Spacer(modifier = Modifier.height(9.dp))
|
||||
Text(
|
||||
text = "等一位旅人~",
|
||||
text = stringResource(R.string.no_one_pinged_yet),
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "去发布动态,让更多人参与对话",
|
||||
color = AppColors.text,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.W400
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize().padding(horizontal = 16.dp)
|
||||
.weight(1f)
|
||||
.background(color = AppColors.background)
|
||||
) {
|
||||
items(comments.itemCount) { index ->
|
||||
comments[index]?.let { comment ->
|
||||
@@ -189,17 +187,18 @@ fun CommentNoticeItem(
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
val AppColors = LocalAppTheme.current
|
||||
val commentPrefix = stringResource(R.string.comment_notice)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 20.dp, horizontal = 16.dp)
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
) {
|
||||
Box {
|
||||
// 左侧头像区域
|
||||
CustomAsyncImage(
|
||||
context = context,
|
||||
imageUrl = commentItem.avatar,
|
||||
contentDescription = commentItem.name,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.noRippleClickable {
|
||||
navController.navigate(
|
||||
@@ -210,32 +209,36 @@ fun CommentNoticeItem(
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 右侧内容区域
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp)
|
||||
.padding(start = 8.dp)
|
||||
.noRippleClickable {
|
||||
onPostClick()
|
||||
}
|
||||
) {
|
||||
// 主要信息列
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = commentItem.name,
|
||||
fontSize = 18.sp,
|
||||
modifier = Modifier,
|
||||
color = AppColors.text
|
||||
fontSize = 14.sp,
|
||||
color = AppColors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// 评论内容行
|
||||
Row {
|
||||
var text = commentItem.comment
|
||||
if (commentItem.parentCommentId != null) {
|
||||
text = "Reply you: $text"
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
text = "$commentPrefix $text",
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
color = AppColors.secondaryText,
|
||||
@@ -249,25 +252,20 @@ fun CommentNoticeItem(
|
||||
color = AppColors.secondaryText,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
// 右侧帖子图片
|
||||
commentItem.post?.let {
|
||||
Box {
|
||||
Box(
|
||||
modifier = Modifier.padding(4.dp)
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
context = context,
|
||||
imageUrl = it.images[0].thumbnail,
|
||||
contentDescription = "Post Image",
|
||||
modifier = Modifier
|
||||
.size(48.dp).clip(RoundedCornerShape(8.dp))
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
)
|
||||
// unread indicator
|
||||
|
||||
}
|
||||
|
||||
// 未读指示器
|
||||
if (commentItem.unread) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -277,11 +275,7 @@ fun CommentNoticeItem(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -32,6 +33,7 @@ fun ActionButton(
|
||||
text: String,
|
||||
color: Color? = null,
|
||||
backgroundColor: Color? = null,
|
||||
backgroundBrush: Brush? = null,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
expandText: Boolean = false,
|
||||
contentPadding: PaddingValues = PaddingValues(vertical = 16.dp),
|
||||
@@ -65,7 +67,11 @@ fun ActionButton(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(roundCorner.dp))
|
||||
.background(animatedBackgroundColor)
|
||||
.background(
|
||||
brush = backgroundBrush ?: Brush.linearGradient(
|
||||
colors = listOf(animatedBackgroundColor, animatedBackgroundColor)
|
||||
)
|
||||
)
|
||||
.noRippleClickable {
|
||||
if (enabled && !isLoading) {
|
||||
click()
|
||||
|
||||
@@ -73,7 +73,8 @@ fun AgentCard(
|
||||
agentEntity.avatar,
|
||||
contentDescription = agentEntity.openId,
|
||||
modifier = Modifier.size(40.dp),
|
||||
contentScale = ContentScale.Crop
|
||||
contentScale = ContentScale.Crop,
|
||||
defaultRes = com.aiosman.ravenow.R.mipmap.group_copy
|
||||
)
|
||||
}
|
||||
Column(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.AppState
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.composables.toolbar.CollapsingToolbarScaffoldScopeInstance.align
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun AgentCreatedSuccessIndicator() {
|
||||
val appColors = LocalAppTheme.current
|
||||
if (AppState.agentCreatedSuccess) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = 70.dp),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(150.dp)
|
||||
.height(40.dp)
|
||||
.background(appColors.text.copy(alpha = 0.5f), shape = RoundedCornerShape(15.dp)),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.mipmap.bars_x_buttons_home_n_copy_2),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
Spacer(modifier = Modifier.width(7.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.create_success),
|
||||
color = appColors.background,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(3000)
|
||||
AppState.agentCreatedSuccess = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
|
||||
@@ -36,6 +37,13 @@ fun AnimatedCounter(count: Int, modifier: Modifier = Modifier, fontSize: Int = 2
|
||||
)
|
||||
}
|
||||
) { targetCount ->
|
||||
Text(text = "$targetCount", modifier = modifier, fontSize = fontSize.sp, color = AppColors.text)
|
||||
Text(
|
||||
text = "$targetCount",
|
||||
modifier = modifier,
|
||||
fontSize = fontSize.sp,
|
||||
color = AppColors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -56,15 +56,15 @@ fun AnimatedFavouriteIcon(
|
||||
}) {
|
||||
Image(
|
||||
painter = if (isFavourite) {
|
||||
painterResource(id = R.drawable.rider_pro_favourited)
|
||||
painterResource(id = R.mipmap.icon_variant_2)
|
||||
} else {
|
||||
painterResource(id = R.drawable.rider_pro_favourite)
|
||||
painterResource(id = R.mipmap.icon_collect)
|
||||
},
|
||||
contentDescription = "Favourite",
|
||||
modifier = modifier.graphicsLayer {
|
||||
rotationZ = animatableRotation.value
|
||||
},
|
||||
colorFilter = ColorFilter.tint(AppColors.text)
|
||||
colorFilter = if (!isFavourite) ColorFilter.tint(AppColors.text) else null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import coil3.request.fallback
|
||||
import coil3.request.placeholder
|
||||
import com.aiosman.ravenow.utils.BlurHashDecoder
|
||||
import com.aiosman.ravenow.utils.Utils.getImageLoader
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
@@ -68,7 +69,6 @@ fun Modifier.debouncedClickableWithRipple(
|
||||
clickable(
|
||||
enabled = enabled && isClickable,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = androidx.compose.material.ripple.rememberRipple()
|
||||
) {
|
||||
if (isClickable) {
|
||||
isClickable = false
|
||||
|
||||
@@ -123,7 +123,7 @@ fun LazyGridItemScope.DraggableItem(
|
||||
translationY = dragDropState.previousItemOffset.value.y
|
||||
}
|
||||
} else {
|
||||
Modifier.animateItemPlacement()
|
||||
Modifier
|
||||
}
|
||||
Box(modifier = modifier.then(draggingModifier).clip(RoundedCornerShape(8.dp)), propagateMinConstraints = true) {
|
||||
content(dragging)
|
||||
|
||||
@@ -36,7 +36,9 @@ import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -51,16 +53,24 @@ import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
@Composable
|
||||
fun EditCommentBottomModal(
|
||||
replyComment: CommentEntity? = null,
|
||||
onSend: (String) -> Unit = {}
|
||||
autoFocus: Boolean = false,
|
||||
onSend: (String) -> Unit = {},
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
var text by remember { mutableStateOf("") }
|
||||
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val context = LocalContext.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(autoFocus) {
|
||||
if (autoFocus) {
|
||||
// 延迟一下,确保输入框已经渲染完成
|
||||
kotlinx.coroutines.delay(150)
|
||||
focusRequester.requestFocus()
|
||||
// 显示键盘
|
||||
keyboardController?.show()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -69,72 +79,22 @@ fun EditCommentBottomModal(
|
||||
.background(AppColors.background)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
if (replyComment == null) "Comment" else "Reply",
|
||||
fontWeight = FontWeight.W600,
|
||||
modifier = Modifier.weight(1f),
|
||||
fontSize = 20.sp,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = AppColors.text
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if (replyComment != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
replyComment.avatar,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentDescription = "Avatar",
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
replyComment.name,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = AppColors.text
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
replyComment.comment,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 32.dp),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = AppColors.text
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Box(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color.White)
|
||||
.border(1.dp, Color.Black, RoundedCornerShape(20.dp))
|
||||
.background(AppColors.inputBackground)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
BasicTextField(
|
||||
value = text,
|
||||
@@ -146,33 +106,46 @@ fun EditCommentBottomModal(
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequester),
|
||||
textStyle = TextStyle(
|
||||
color = Color.Black,
|
||||
color = AppColors.text,
|
||||
fontWeight = FontWeight.Normal
|
||||
),
|
||||
minLines = 1
|
||||
decorationBox = { innerTextField ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
innerTextField()
|
||||
if (text.isEmpty()) {
|
||||
Text(
|
||||
text = if (replyComment == null) {
|
||||
stringResource(R.string.post_comment_hint)
|
||||
} else {
|
||||
stringResource(R.string.reply_to_user, replyComment.name ?: "")
|
||||
},
|
||||
color = AppColors.text.copy(alpha = 0.3f), // 30%透明度
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Crossfade(
|
||||
targetState = text.isNotEmpty(), animationSpec = tween(500),
|
||||
label = ""
|
||||
) { isNotEmpty ->
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Icon(
|
||||
painter = painterResource(id = R.mipmap.rider_pro_moment_post),
|
||||
painter = painterResource(id = R.mipmap.btn),
|
||||
contentDescription = "Send",
|
||||
modifier = Modifier
|
||||
.size(25.dp)
|
||||
.align(Alignment.Top)
|
||||
.size(40.dp)
|
||||
.padding(top = 13.dp)
|
||||
.noRippleClickable {
|
||||
if (text.isNotEmpty()) {
|
||||
onSend(text)
|
||||
text = ""
|
||||
}
|
||||
},
|
||||
tint = if (isNotEmpty) AppColors.main else AppColors.nonActive
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Spacer(modifier = Modifier.height(navBarHeight))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -32,7 +33,7 @@ fun FollowButton(
|
||||
.wrapContentWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
color = if (isFollowing) AppColors.main else AppColors.nonActive
|
||||
color = if (isFollowing) AppColors.nonActive else AppColors.nonActive
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.noRippleClickable {
|
||||
@@ -41,11 +42,9 @@ fun FollowButton(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (isFollowing) stringResource(R.string.following_upper) else stringResource(
|
||||
R.string.follow_upper
|
||||
),
|
||||
text = if (isFollowing) stringResource(R.string.follow_upper_had) else stringResource(R.string.follow_upper),
|
||||
fontSize = fontSize,
|
||||
color = if (isFollowing) AppColors.mainText else AppColors.text,
|
||||
color = if (isFollowing) AppColors.text else AppColors.text,
|
||||
style = TextStyle(fontWeight = FontWeight.Bold)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,11 +16,16 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
import coil3.ImageLoader
|
||||
import coil3.asDrawable
|
||||
import coil3.asImage
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.request.crossfade
|
||||
import com.aiosman.ravenow.utils.Utils.getImageLoader
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -59,7 +64,11 @@ fun rememberImageBitmap(imageUrl: String, imageLoader: ImageLoader): Bitmap? {
|
||||
.build()
|
||||
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
(imageLoader.execute(request) as? SuccessResult)?.drawable?.toBitmap()
|
||||
val successResult = imageLoader.execute(request) as? SuccessResult
|
||||
successResult?.let {
|
||||
val drawable = it.image.asDrawable(context.resources)
|
||||
drawable.toBitmap()
|
||||
}
|
||||
}
|
||||
|
||||
bitmap = result
|
||||
@@ -138,25 +147,33 @@ fun CustomAsyncImage(
|
||||
}
|
||||
|
||||
// 处理字符串URL
|
||||
val ctx = context ?: localContext
|
||||
val placeholderImage = remember(placeholderRes, ctx) {
|
||||
placeholderRes?.let { resId ->
|
||||
ContextCompat.getDrawable(ctx, resId)?.asImage()
|
||||
}
|
||||
}
|
||||
val errorImage = remember(errorRes, ctx) {
|
||||
errorRes?.let { resId ->
|
||||
ContextCompat.getDrawable(ctx, resId)?.asImage()
|
||||
}
|
||||
}
|
||||
|
||||
if (showShimmer) {
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context ?: localContext)
|
||||
model = ImageRequest.Builder(ctx)
|
||||
.data(imageUrl)
|
||||
.crossfade(200)
|
||||
.memoryCachePolicy(coil.request.CachePolicy.ENABLED)
|
||||
.diskCachePolicy(coil.request.CachePolicy.ENABLED)
|
||||
.memoryCachePolicy(coil3.request.CachePolicy.ENABLED)
|
||||
.diskCachePolicy(coil3.request.CachePolicy.ENABLED)
|
||||
.apply {
|
||||
// 设置占位符图片
|
||||
if (placeholderRes != null) {
|
||||
placeholder(placeholderRes)
|
||||
}
|
||||
placeholderImage?.let { placeholder(it) }
|
||||
// 设置错误时显示的图片
|
||||
if (errorRes != null) {
|
||||
error(errorRes)
|
||||
}
|
||||
errorImage?.let { error(it) }
|
||||
}
|
||||
.build(),
|
||||
contentDescription = contentDescription,
|
||||
@@ -177,20 +194,16 @@ fun CustomAsyncImage(
|
||||
}
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context ?: localContext)
|
||||
model = ImageRequest.Builder(ctx)
|
||||
.data(imageUrl)
|
||||
.crossfade(200)
|
||||
.memoryCachePolicy(coil.request.CachePolicy.ENABLED)
|
||||
.diskCachePolicy(coil.request.CachePolicy.ENABLED)
|
||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
.diskCachePolicy(CachePolicy.ENABLED)
|
||||
.apply {
|
||||
// 设置占位符图片
|
||||
if (placeholderRes != null) {
|
||||
placeholder(placeholderRes)
|
||||
}
|
||||
placeholderImage?.let { placeholder(it) }
|
||||
// 设置错误时显示的图片
|
||||
if (errorRes != null) {
|
||||
error(errorRes)
|
||||
}
|
||||
errorImage?.let { error(it) }
|
||||
}
|
||||
.build(),
|
||||
contentDescription = contentDescription,
|
||||
|
||||
@@ -92,19 +92,26 @@ fun MomentCard(
|
||||
showFollowButton = showFollowButton
|
||||
)
|
||||
}
|
||||
val lastClickTime = remember { mutableStateOf(0L) }
|
||||
val clickDelay = 500L
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.noRippleClickable {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - lastClickTime.value > clickDelay) {
|
||||
lastClickTime.value = currentTime
|
||||
navController.navigateToPost(
|
||||
momentEntity.id,
|
||||
highlightCommentId = 0,
|
||||
initImagePagerIndex = imageIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
MomentContentGroup(
|
||||
momentEntity = momentEntity,
|
||||
imageIndex = imageIndex,
|
||||
onPageChange = { index -> imageIndex = index }
|
||||
)
|
||||
}
|
||||
@@ -114,7 +121,6 @@ fun MomentCard(
|
||||
onLikeClick = onLikeClick,
|
||||
onAddComment = onAddComment,
|
||||
onFavoriteClick = onFavoriteClick,
|
||||
imageIndex = imageIndex,
|
||||
onCommentClick = {
|
||||
navController.navigateToPost(
|
||||
momentEntity.id,
|
||||
@@ -213,7 +219,6 @@ fun MomentPostLocation(location: String) {
|
||||
text = location,
|
||||
color = AppColors.secondaryText,
|
||||
fontSize = 12.sp,
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -238,6 +243,8 @@ fun MomentTopRowGroup(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
) {
|
||||
val lastClickTime = remember { mutableStateOf(0L) }
|
||||
val clickDelay = 500L
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
momentEntity.avatar,
|
||||
@@ -246,12 +253,16 @@ fun MomentTopRowGroup(
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(40.dp))
|
||||
.noRippleClickable {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - lastClickTime.value > clickDelay) {
|
||||
lastClickTime.value = currentTime
|
||||
navController.navigate(
|
||||
NavigationRoute.AccountProfile.route.replace(
|
||||
"{id}",
|
||||
momentEntity.authorId.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
@@ -267,7 +278,19 @@ fun MomentTopRowGroup(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
MomentName(
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1f)
|
||||
.noRippleClickable {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - lastClickTime.value > clickDelay) {
|
||||
lastClickTime.value = currentTime
|
||||
navController.navigate(
|
||||
NavigationRoute.AccountProfile.route.replace(
|
||||
"{id}",
|
||||
momentEntity.authorId.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
name = momentEntity.nickname
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
@@ -304,6 +327,11 @@ fun PostImageView(
|
||||
images: List<MomentImageEntity>,
|
||||
onPageChange: (Int) -> Unit = {}
|
||||
) {
|
||||
// 如果图片列表为空,不渲染任何内容
|
||||
if (images.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val pagerState = rememberPagerState(pageCount = { images.size })
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
onPageChange(pagerState.currentPage)
|
||||
@@ -338,34 +366,96 @@ fun PostImageView(
|
||||
@Composable
|
||||
fun MomentContentGroup(
|
||||
momentEntity: MomentEntity,
|
||||
imageIndex: Int = 0,
|
||||
onPageChange: (Int) -> Unit = {}
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
if (momentEntity.momentTextContent.isNotEmpty()) {
|
||||
Text(
|
||||
text = momentEntity.momentTextContent,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||
fontSize = 16.sp,
|
||||
color = AppColors.text
|
||||
)
|
||||
}
|
||||
val context = LocalContext.current
|
||||
|
||||
if (momentEntity.relMoment != null) {
|
||||
RelPostCard(
|
||||
momentEntity = momentEntity.relMoment!!,
|
||||
modifier = Modifier.background(Color(0xFFF8F8F8))
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// 优先显示图片,如果没有图片则显示视频缩略图
|
||||
if (momentEntity.images.isNotEmpty()) {
|
||||
PostImageView(
|
||||
images = momentEntity.images,
|
||||
onPageChange = onPageChange
|
||||
)
|
||||
} else if (momentEntity.videos != null && momentEntity.videos.isNotEmpty()) {
|
||||
// 显示视频缩略图
|
||||
val firstVideo = momentEntity.videos.first()
|
||||
val thumbnailUrl = firstVideo.thumbnailUrl ?: firstVideo.thumbnailDirectUrl
|
||||
if (thumbnailUrl != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(
|
||||
if (firstVideo.width != null && firstVideo.height != null && firstVideo.height > 0) {
|
||||
firstVideo.width.toFloat() / firstVideo.height.toFloat()
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
)
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
context = context,
|
||||
imageUrl = thumbnailUrl,
|
||||
contentDescription = "Video thumbnail",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 图片指示器:显示在图片下方、文案上方
|
||||
if (momentEntity.images.size > 1) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
momentEntity.images.forEachIndexed { index, _ ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (imageIndex == index) Color.Red else Color.Gray.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
.padding(1.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!momentEntity.momentTextContent.isNullOrEmpty()) {
|
||||
Text(
|
||||
text = com.aiosman.ravenow.utils.Utils.unescapeHtml(momentEntity.momentTextContent),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp),
|
||||
fontSize = 16.sp,
|
||||
color = AppColors.text
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -380,6 +470,7 @@ fun MomentOperateBtn(@DrawableRes icon: Int, count: String) {
|
||||
contentDescription = "",
|
||||
colorFilter = ColorFilter.tint(AppColors.text)
|
||||
)
|
||||
if (count.isNotEmpty()) {
|
||||
Text(
|
||||
text = count,
|
||||
modifier = Modifier.padding(start = 7.dp),
|
||||
@@ -388,6 +479,7 @@ fun MomentOperateBtn(@DrawableRes icon: Int, count: String) {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentOperateBtn(count: String, content: @Composable () -> Unit) {
|
||||
@@ -401,7 +493,6 @@ fun MomentOperateBtn(count: String, content: @Composable () -> Unit) {
|
||||
fontSize = 14,
|
||||
modifier = Modifier
|
||||
.padding(start = 7.dp)
|
||||
.width(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -413,9 +504,10 @@ fun MomentBottomOperateRowGroup(
|
||||
onAddComment: () -> Unit = {},
|
||||
onCommentClick: () -> Unit = {},
|
||||
onFavoriteClick: () -> Unit = {},
|
||||
momentEntity: MomentEntity,
|
||||
imageIndex: Int = 0
|
||||
momentEntity: MomentEntity
|
||||
) {
|
||||
val lastClickTime = remember { mutableStateOf(0L) }
|
||||
val clickDelay = 500L
|
||||
var showCommentModal by remember { mutableStateOf(false) }
|
||||
if (showCommentModal) {
|
||||
ModalBottomSheet(
|
||||
@@ -424,7 +516,6 @@ fun MomentBottomOperateRowGroup(
|
||||
sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
),
|
||||
windowInsets = WindowInsets(0),
|
||||
dragHandle = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -449,16 +540,19 @@ fun MomentBottomOperateRowGroup(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.padding(start = 16.dp, end = 0.dp)
|
||||
.padding(start = 16.dp, end = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center
|
||||
Row(
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 点赞按钮
|
||||
MomentOperateBtn(count = momentEntity.likeCount.toString()) {
|
||||
AnimatedLikeIcon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
@@ -467,29 +561,38 @@ fun MomentBottomOperateRowGroup(
|
||||
onLikeClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
// 评论按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.noRippleClickable {
|
||||
modifier = Modifier.noRippleClickable {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - lastClickTime.value > clickDelay) {
|
||||
lastClickTime.value = currentTime
|
||||
onCommentClick()
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
}
|
||||
}
|
||||
) {
|
||||
MomentOperateBtn(
|
||||
icon = R.drawable.rider_pro_comment,
|
||||
icon = R.mipmap.icon_comment,
|
||||
count = momentEntity.commentCount.toString()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
// 转发按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
,
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
modifier = Modifier.noRippleClickable {
|
||||
// TODO: 实现转发功能
|
||||
}
|
||||
) {
|
||||
MomentOperateBtn(
|
||||
icon = R.mipmap.icon_share,
|
||||
count = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
// 收藏按钮
|
||||
MomentOperateBtn(count = momentEntity.favoriteCount.toString()) {
|
||||
AnimatedFavouriteIcon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
@@ -500,34 +603,10 @@ fun MomentBottomOperateRowGroup(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (momentEntity.images.size > 1) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
momentEntity.images.forEachIndexed { index, _ ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (imageIndex == index) Color.Red else Color.Gray.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
.padding(1.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentListLoading() {
|
||||
CircularProgressIndicator(
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
/**
|
||||
* 全局付费确认对话框组件
|
||||
* 参考 iOS 版本的 PointsConfirmDialog
|
||||
*
|
||||
* @param cost 需要支付的费用
|
||||
* @param currentBalance 当前余额
|
||||
* @param balanceAfterCost 支付后余额
|
||||
* @param isBalanceSufficient 余额是否充足
|
||||
* @param onConfirm 确认支付回调
|
||||
* @param onCancel 取消回调
|
||||
* @param title 对话框标题
|
||||
* @param description 对话框描述
|
||||
* @param isProcessing 是否正在处理中
|
||||
*/
|
||||
@Composable
|
||||
fun PointsPaymentDialog(
|
||||
cost: Int,
|
||||
currentBalance: Int,
|
||||
balanceAfterCost: Int,
|
||||
isBalanceSufficient: Boolean,
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
title: String,
|
||||
description: String,
|
||||
isProcessing: Boolean = false
|
||||
) {
|
||||
val appColors = LocalAppTheme.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenWidth = configuration.screenWidthDp.dp
|
||||
val dialogWidth = (screenWidth - 48.dp).coerceAtMost(360.dp)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
if (!isProcessing) {
|
||||
onCancel()
|
||||
}
|
||||
},
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = !isProcessing,
|
||||
dismissOnClickOutside = !isProcessing
|
||||
)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.width(dialogWidth)
|
||||
.shadow(
|
||||
elevation = 20.dp,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
spotColor = Color.Black.copy(alpha = 0.2f)
|
||||
),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = appColors.background
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// 顶部图标 - 使用 paip_coin_img
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.paip_coin_img),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
|
||||
// 标题
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = appColors.text,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
// 描述
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = description,
|
||||
fontSize = 14.sp,
|
||||
color = appColors.secondaryText,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 20.dp)
|
||||
)
|
||||
|
||||
// 积分消耗信息区域
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = appColors.inputBackground.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 需要消耗
|
||||
CostInfoRow(
|
||||
label = stringResource(R.string.cost_required),
|
||||
amount = cost,
|
||||
appColors = appColors,
|
||||
amountColor = Color(0xFFFF8C00) // 橙色
|
||||
)
|
||||
|
||||
HorizontalDivider(color = appColors.divider)
|
||||
|
||||
// 当前余额
|
||||
CostInfoRow(
|
||||
label = stringResource(R.string.current_balance),
|
||||
amount = currentBalance,
|
||||
appColors = appColors,
|
||||
amountColor = if (isBalanceSufficient) appColors.text else Color.Red
|
||||
)
|
||||
|
||||
HorizontalDivider(color = appColors.divider)
|
||||
|
||||
// 支付后余额
|
||||
CostInfoRow(
|
||||
label = stringResource(R.string.balance_after),
|
||||
amount = balanceAfterCost,
|
||||
appColors = appColors,
|
||||
amountColor = appColors.text
|
||||
)
|
||||
}
|
||||
|
||||
// 余额不足提示
|
||||
if (!isBalanceSufficient) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFFF8C00), // 橙色
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.insufficient_pai_coin_balance),
|
||||
fontSize = 13.sp,
|
||||
color = Color(0xFFFF8C00), // 橙色
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 取消按钮
|
||||
Button(
|
||||
onClick = {
|
||||
if (!isProcessing) {
|
||||
onCancel()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(50.dp),
|
||||
enabled = !isProcessing,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = appColors.inputBackground,
|
||||
contentColor = appColors.text,
|
||||
disabledContainerColor = appColors.inputBackground,
|
||||
disabledContentColor = appColors.text.copy(alpha = 0.5f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.cancel),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
}
|
||||
|
||||
// 确认按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(50.dp)
|
||||
.background(
|
||||
brush = if (isBalanceSufficient) {
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
appColors.main,
|
||||
appColors.main
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFFF8C00), // 橙色
|
||||
Color.Red
|
||||
)
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.then(
|
||||
if (!isProcessing) {
|
||||
Modifier.noRippleClickable {
|
||||
if (!isBalanceSufficient) {
|
||||
// 积分不足,跳转充值页面
|
||||
onCancel()
|
||||
// 这里可以发送通知或回调来跳转充值页面
|
||||
} else {
|
||||
// 积分充足,确认消费
|
||||
onConfirm()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isProcessing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = if (isBalanceSufficient) {
|
||||
stringResource(R.string.confirm_consumption)
|
||||
} else {
|
||||
stringResource(R.string.go_recharge)
|
||||
},
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用信息行组件
|
||||
*/
|
||||
@Composable
|
||||
private fun CostInfoRow(
|
||||
label: String,
|
||||
amount: Int,
|
||||
appColors: com.aiosman.ravenow.AppThemeData,
|
||||
amountColor: Color? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 14.sp,
|
||||
color = appColors.secondaryText
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
// 星形图标(参考 iOS 版本)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFFFFD700) // 黄色
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "${amount.formatNumber()}",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
color = amountColor ?: appColors.text
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.pai_coin),
|
||||
fontSize = 14.sp,
|
||||
color = appColors.secondaryText
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字,添加千位分隔符
|
||||
*/
|
||||
private fun Int.formatNumber(): String {
|
||||
return this.toString().reversed().chunked(3).joinToString(",").reversed()
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ fun PolicyCheckbox(
|
||||
showModal = false
|
||||
},
|
||||
sheetState = modalSheetState,
|
||||
windowInsets = WindowInsets(0),
|
||||
containerColor = Color.White,
|
||||
) {
|
||||
WebViewDisplay(
|
||||
@@ -98,7 +97,7 @@ fun PolicyCheckbox(
|
||||
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = appColor.main,
|
||||
color = Color(0xFF7C45ED), // 紫色
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
start = template.length + 1,
|
||||
|
||||
@@ -36,6 +36,7 @@ fun StatusBarMaskLayout(
|
||||
modifier: Modifier = Modifier,
|
||||
darkIcons: Boolean = true,
|
||||
useNavigationBarMask: Boolean = true,
|
||||
includeStatusBarPadding: Boolean = true,
|
||||
maskBoxBackgroundColor: Color = Color.Transparent,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
@@ -50,13 +51,13 @@ fun StatusBarMaskLayout(
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
if (includeStatusBarPadding) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(paddingValues.calculateTopPadding())
|
||||
.fillMaxWidth()
|
||||
.background(maskBoxBackgroundColor)
|
||||
) {
|
||||
|
||||
)
|
||||
}
|
||||
content()
|
||||
if (navigationBarPaddings > 24.dp && useNavigationBarMask) {
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
/**
|
||||
* 可复用的标签页组件
|
||||
*/
|
||||
@@ -31,18 +46,19 @@ fun TabItem(
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.wrapContentWidth()
|
||||
.noRippleClickable { onClick() },
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 15.sp,
|
||||
color = if (isSelected) AppColors.tabSelectedText else AppColors.tabUnselectedText,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (isSelected) AppColors.tabSelectedBackground else AppColors.tabUnselectedBackground)
|
||||
.padding(horizontal = 11.dp, vertical = 4.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -54,3 +70,60 @@ fun TabItem(
|
||||
fun TabSpacer() {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun UnderlineTabItem(
|
||||
text: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
// 动画化字体大小和padding
|
||||
val animatedFontSize by animateFloatAsState(
|
||||
targetValue = if (isSelected) 17f else 15f,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "fontSize"
|
||||
)
|
||||
val animatedPadding by animateDpAsState(
|
||||
targetValue = if (isSelected) 20.dp else 16.dp,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "padding"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.noRippleClickable { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 文本层 - 始终居中,不受下划线影响
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = animatedFontSize.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = if (isSelected) AppColors.text else AppColors.text.copy(alpha = 0.6f),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = animatedPadding)
|
||||
)
|
||||
|
||||
// 下划线层 - 固定在底部,不影响文本位置
|
||||
if (isSelected) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.size(24.dp)
|
||||
.offset(y = (15).dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.underline),
|
||||
contentDescription = "selected indicator",
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,10 +39,15 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.AppState
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
private val LabelTextColor = Color(red = 60f / 255f, green = 60f / 255f, blue = 67f / 255f, alpha = 0.6f)
|
||||
private val HintTextColor = Color(red = 60f / 255f, green = 60f / 255f, blue = 67f / 255f, alpha = 0.3f)
|
||||
private val PasswordIconColor = Color(red = 17f / 255f, green = 12f / 255f, blue = 19f / 255f)
|
||||
|
||||
@Composable
|
||||
fun TextInputField(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -52,67 +57,104 @@ fun TextInputField(
|
||||
label: String? = null,
|
||||
hint: String? = null,
|
||||
error: String? = null,
|
||||
enabled: Boolean = true
|
||||
enabled: Boolean = true,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
customBackgroundColor: Color? = null,
|
||||
customHintColor: Color? = null,
|
||||
customLabelColor: Color? = null,
|
||||
customCornerRadius: Float = 24f
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
var showPassword by remember { mutableStateOf(!password) }
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val backgroundColor = customBackgroundColor ?: AppColors.inputBackground
|
||||
val hintColor = customHintColor ?: HintTextColor
|
||||
val labelColor = customLabelColor ?: LabelTextColor
|
||||
Column(modifier = modifier) {
|
||||
label?.let {
|
||||
Text(it, color = AppColors.secondaryText)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = it,
|
||||
color = labelColor,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(start = 8.dp, top = 8.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(AppColors.inputBackground)
|
||||
.clip(RoundedCornerShape(customCornerRadius.dp))
|
||||
.background(backgroundColor)
|
||||
.border(
|
||||
width = 2.dp,
|
||||
color = if (error == null) Color.Transparent else AppColors.error,
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
shape = RoundedCornerShape(customCornerRadius.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically){
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
){
|
||||
leadingIcon?.let {
|
||||
Box(modifier = Modifier.size(24.dp)) {
|
||||
it()
|
||||
}
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focusState ->
|
||||
isFocused = focusState.isFocused
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
fontWeight = FontWeight.W400,
|
||||
color = AppColors.text
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (password) KeyboardType.Password else KeyboardType.Text
|
||||
keyboardType = if (password) KeyboardType.Password else KeyboardType.Email
|
||||
),
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
cursorBrush = SolidColor(AppColors.text),
|
||||
)
|
||||
if (password) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_eye),
|
||||
contentDescription = "Password",
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.noRippleClickable {
|
||||
showPassword = !showPassword
|
||||
},
|
||||
colorFilter = ColorFilter.tint(AppColors.text)
|
||||
if (text.isEmpty() && hint != null) {
|
||||
Text(
|
||||
text = hint,
|
||||
color = hintColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W400
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (text.isEmpty()) {
|
||||
hint?.let {
|
||||
Text(it, modifier = Modifier.padding(start = 5.dp), color = AppColors.inputHint, fontWeight = FontWeight.W600)
|
||||
if (password) {
|
||||
// 暗色模式下图标为白色,否则使用默认颜色
|
||||
val iconColor = if (AppState.darkMode) {
|
||||
Color.White
|
||||
} else {
|
||||
PasswordIconColor
|
||||
}
|
||||
Image(
|
||||
painter = painterResource(
|
||||
id = if (showPassword) {
|
||||
R.drawable.rider_pro_eye
|
||||
} else {
|
||||
R.mipmap.icon_eyes_closed_light
|
||||
}
|
||||
),
|
||||
contentDescription = "Password",
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.noRippleClickable {
|
||||
showPassword = !showPassword
|
||||
},
|
||||
colorFilter = ColorFilter.tint(iconColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,22 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -32,6 +38,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
/**
|
||||
* 水平布局的输入框
|
||||
@@ -47,21 +54,35 @@ fun FormTextInput(
|
||||
onValueChange: (String) -> Unit
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clip(RoundedCornerShape(25.dp))
|
||||
.background(background ?: AppColors.inputBackground)
|
||||
.let {
|
||||
if (error != null) {
|
||||
it.border(1.dp, AppColors.error, RoundedCornerShape(24.dp))
|
||||
} else if (background != null && background == Color.White) {
|
||||
// 如果传入白色背景,添加灰色边框
|
||||
it.border(
|
||||
1.dp,
|
||||
Color(red = 124f / 255f, green = 116f / 255f, blue = 128f / 255f, alpha = 0.08f),
|
||||
RoundedCornerShape(25.dp)
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
.padding(17.dp),
|
||||
.padding(17.dp)
|
||||
.noRippleClickable {
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
label?.let {
|
||||
@@ -79,13 +100,27 @@ fun FormTextInput(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.mipmap.icons_infor_edit),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.align(Alignment.TopStart),
|
||||
tint = AppColors.text.copy(alpha = 0.4f)
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
if (value.isEmpty()) {
|
||||
Text(
|
||||
text = hint ?: "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = AppColors.inputHint
|
||||
)
|
||||
@@ -98,15 +133,18 @@ fun FormTextInput(
|
||||
onValueChange = {
|
||||
onValueChange(it)
|
||||
},
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester),
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = AppColors.text
|
||||
),
|
||||
cursorBrush = SolidColor(AppColors.text),
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,16 +15,22 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -32,6 +38,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
/**
|
||||
* 垂直布局的输入框
|
||||
@@ -44,24 +51,39 @@ fun FormTextInput2(
|
||||
error: String? = null,
|
||||
hint: String? = null,
|
||||
background: Color? = null,
|
||||
focusRequester: FocusRequester? = null,
|
||||
onValueChange: (String) -> Unit
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val localFocusRequester = focusRequester ?: remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
Column(
|
||||
modifier = modifier.height(150.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clip(RoundedCornerShape(25.dp))
|
||||
.background(background ?: AppColors.inputBackground)
|
||||
.let {
|
||||
if (error != null) {
|
||||
it.border(1.dp, AppColors.error, RoundedCornerShape(24.dp))
|
||||
} else if (background != null && background == Color.White) {
|
||||
// 如果传入白色背景,添加灰色边框
|
||||
it.border(
|
||||
1.dp,
|
||||
Color(red = 124f / 255f, green = 116f / 255f, blue = 128f / 255f, alpha = 0.08f),
|
||||
RoundedCornerShape(25.dp)
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
.padding(17.dp),
|
||||
.padding(17.dp)
|
||||
.noRippleClickable {
|
||||
localFocusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
},
|
||||
|
||||
) {
|
||||
label?.let {
|
||||
@@ -79,13 +101,27 @@ fun FormTextInput2(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(top = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.mipmap.icons_infor_edit),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.align(Alignment.TopStart),
|
||||
tint = AppColors.text.copy(alpha = 0.4f)
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
if (value.isEmpty()) {
|
||||
Text(
|
||||
text = hint ?: "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = AppColors.inputHint
|
||||
)
|
||||
@@ -93,20 +129,23 @@ fun FormTextInput2(
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
maxLines = 5,
|
||||
maxLines = 6,
|
||||
value = value,
|
||||
onValueChange = {
|
||||
onValueChange(it)
|
||||
},
|
||||
modifier = Modifier
|
||||
.focusRequester(localFocusRequester),
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = AppColors.text,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
cursorBrush = SolidColor(AppColors.text),
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@ fun ImageCropScreen() {
|
||||
}
|
||||
}
|
||||
if (uri == null) {
|
||||
// 用户取消图片选择,清除已裁剪的图片
|
||||
AccountEditViewModel.croppedBitmap = null
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
@@ -103,6 +105,8 @@ fun ImageCropScreen() {
|
||||
painter = painterResource(R.drawable.rider_pro_back_icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clickable {
|
||||
// 用户取消头像选择,清除已裁剪的图片
|
||||
AccountEditViewModel.croppedBitmap = null
|
||||
navController.popBackStack()
|
||||
},
|
||||
colorFilter = ColorFilter.tint(Color.White)
|
||||
@@ -119,13 +123,11 @@ fun ImageCropScreen() {
|
||||
val bitmap = it.onCrop()
|
||||
|
||||
// 专门处理个人资料头像
|
||||
// 只设置裁剪后的图片,不立即上传,等待用户在编辑资料界面点击保存
|
||||
AccountEditViewModel.croppedBitmap = bitmap
|
||||
AccountEditViewModel.viewModelScope.launch {
|
||||
AccountEditViewModel.updateUserProfile(context)
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Box(
|
||||
|
||||
@@ -4,27 +4,38 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
@@ -35,6 +46,8 @@ import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.favourite.FavouriteListViewModel.refreshPager
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.ui.navigateToPost
|
||||
import com.aiosman.ravenow.ui.network.NetworkErrorContent
|
||||
import com.aiosman.ravenow.utils.NetworkUtils
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
@@ -71,12 +84,60 @@ fun FavouriteListPage() {
|
||||
) {
|
||||
NoticeScreenHeader(stringResource(R.string.favourites_upper), moreIcon = false)
|
||||
}
|
||||
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current)
|
||||
var moments = dataFlow.collectAsLazyPagingItems()
|
||||
|
||||
if (!isNetworkAvailable) {
|
||||
NetworkErrorContent(
|
||||
onReload = {
|
||||
model.refreshPager(force = true)
|
||||
}
|
||||
)
|
||||
} else if(moments.itemCount == 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top=189.dp),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(
|
||||
id = if (com.aiosman.ravenow.AppState.darkMode) R.mipmap.empty_img
|
||||
else R.mipmap.empty_img),
|
||||
contentDescription = "No favourites",
|
||||
modifier = Modifier.size(181.dp, 153.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(9.dp)) // 调整间距为9dp
|
||||
Text(
|
||||
text = stringResource(R.string.favourites_null),
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
}
|
||||
}
|
||||
}else{
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)
|
||||
) {
|
||||
items(moments.itemCount) { idx ->
|
||||
val momentItem = moments[idx] ?: return@items
|
||||
// 获取缩略图URL:优先使用图片,如果没有图片则使用视频缩略图
|
||||
val thumbnailUrl = when {
|
||||
momentItem.images.isNotEmpty() -> momentItem.images[0].thumbnail
|
||||
momentItem.videos != null && momentItem.videos.isNotEmpty() -> {
|
||||
momentItem.videos.first().thumbnailUrl ?: momentItem.videos.first().thumbnailDirectUrl
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (thumbnailUrl == null) return@items
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -91,14 +152,17 @@ fun FavouriteListPage() {
|
||||
}
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = momentItem.images[0].thumbnail,
|
||||
imageUrl = thumbnailUrl,
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
context = context
|
||||
)
|
||||
if (momentItem.images.size > 1) {
|
||||
|
||||
val isVideoMoment = momentItem.images.isEmpty() && !momentItem.videos.isNullOrEmpty()
|
||||
|
||||
if (momentItem.images.size > 1 || (momentItem.videos?.size ?: 0) > 1) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp, end = 8.dp)
|
||||
@@ -112,14 +176,35 @@ fun FavouriteListPage() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
if (isVideoMoment) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp, end = 8.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.background(
|
||||
color = Color.Black.copy(alpha = 0.4f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = "",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
PullRefreshIndicator(
|
||||
FavouriteListViewModel.isLoading,
|
||||
state,
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -19,9 +20,12 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -37,6 +41,8 @@ import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
||||
import kotlinx.coroutines.launch
|
||||
import com.aiosman.ravenow.utils.NetworkUtils
|
||||
import com.aiosman.ravenow.ui.network.NetworkErrorContent
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
@@ -67,7 +73,15 @@ fun FollowerListScreen(userId: Int) {
|
||||
) {
|
||||
NoticeScreenHeader(stringResource(R.string.followers_upper), moreIcon = false)
|
||||
}
|
||||
if (users.itemCount == 0) {
|
||||
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current)
|
||||
|
||||
if (!isNetworkAvailable) {
|
||||
NetworkErrorContent(
|
||||
onReload = {
|
||||
model.loadData(userId, true)
|
||||
}
|
||||
)
|
||||
} else if (users.itemCount == 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -79,25 +93,20 @@ fun FollowerListScreen(userId: Int) {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(
|
||||
id =if(AppState.darkMode) R.mipmap.qst_fs_qs_as_img
|
||||
else R.mipmap.qst_fs_qs_img),
|
||||
painter = painterResource(id = R.mipmap.frame_31),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(181.dp)
|
||||
modifier = Modifier.size(181.dp, 153.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
Spacer(modifier = Modifier.size(9.dp)) // 调整间距为9dp
|
||||
androidx.compose.material.Text(
|
||||
text = "还没有人关注哦",
|
||||
text = stringResource(R.string.awaiting_traveler),
|
||||
color = appColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
androidx.compose.material.Text(
|
||||
text = "去发布动态,吸引更多粉丝~",
|
||||
color = appColors.text,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.W400
|
||||
fontWeight = FontWeight.W600,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -20,10 +21,13 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
@@ -37,36 +41,39 @@ import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.FollowButton
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.ui.network.NetworkErrorContent
|
||||
import kotlinx.coroutines.launch
|
||||
import com.aiosman.ravenow.utils.NetworkUtils
|
||||
|
||||
/**
|
||||
* 关注消息列表
|
||||
*/
|
||||
@Composable
|
||||
fun FollowerNoticeScreen() {
|
||||
fun FollowerNoticeScreen(includeStatusBarPadding: Boolean = true) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val AppColors = LocalAppTheme.current
|
||||
StatusBarMaskLayout(
|
||||
modifier = Modifier.background(color = AppColors.background).padding(horizontal = 16.dp),
|
||||
darkIcons = !AppState.darkMode,
|
||||
maskBoxBackgroundColor = AppColors.background
|
||||
maskBoxBackgroundColor = AppColors.background,
|
||||
includeStatusBarPadding = includeStatusBarPadding
|
||||
) {
|
||||
val model = FollowerNoticeViewModel
|
||||
var dataFlow = model.followerItemsFlow
|
||||
var followers = dataFlow.collectAsLazyPagingItems()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
.background(color = AppColors.background)
|
||||
) {
|
||||
NoticeScreenHeader(stringResource(R.string.followers_upper), moreIcon = false)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
model.reload()
|
||||
model.updateNotice()
|
||||
}
|
||||
if (followers.itemCount == 0) {
|
||||
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current)
|
||||
|
||||
if (!isNetworkAvailable) {
|
||||
NetworkErrorContent(
|
||||
onReload = {
|
||||
model.reload(force = true)
|
||||
}
|
||||
)
|
||||
} else if (followers.itemCount == 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -78,25 +85,21 @@ fun FollowerNoticeScreen() {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(
|
||||
id =if(AppState.darkMode) R.mipmap.qst_fs_qs_as_img
|
||||
else R.mipmap.qst_fs_qs_img),
|
||||
painter = painterResource(id = R.mipmap.invalid_name_5),
|
||||
contentDescription = "No Followers",
|
||||
modifier = Modifier.size(181.dp)
|
||||
modifier = Modifier
|
||||
.size(width = 181.dp, height = 153.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
Spacer(modifier = Modifier.height(9.dp))
|
||||
androidx.compose.material.Text(
|
||||
text = "还没有人关注哦",
|
||||
text = stringResource(R.string.no_one_pinged_yet),
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
androidx.compose.material.Text(
|
||||
text = "去发布动态,吸引更多粉丝~",
|
||||
color = AppColors.text,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.W400
|
||||
fontWeight = FontWeight.W600,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -138,9 +141,22 @@ fun FollowItem(
|
||||
val AppColors = LocalAppTheme.current
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.current
|
||||
Box(
|
||||
val followText = stringResource(R.string.followed_you)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 左侧头像区域
|
||||
CustomAsyncImage(
|
||||
context = context,
|
||||
imageUrl = avatar,
|
||||
contentDescription = nickname,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.noRippleClickable {
|
||||
navController.navigate(
|
||||
NavigationRoute.AccountProfile.route.replace(
|
||||
@@ -149,24 +165,33 @@ fun FollowItem(
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
context = context,
|
||||
imageUrl = avatar,
|
||||
contentDescription = nickname,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
// 右侧内容区域
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(nickname, fontWeight = FontWeight.Bold, fontSize = 16.sp, color = AppColors.text)
|
||||
Text(
|
||||
text = nickname,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
color = AppColors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = followText,
|
||||
fontSize = 14.sp,
|
||||
color = AppColors.secondaryText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
if (!isFollowing && userId != AppState.UserId) {
|
||||
FollowButton(
|
||||
|
||||
@@ -20,9 +20,12 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -37,7 +40,9 @@ import com.aiosman.ravenow.exp.viewModelFactory
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
||||
import com.aiosman.ravenow.ui.network.NetworkErrorContent
|
||||
import kotlinx.coroutines.launch
|
||||
import com.aiosman.ravenow.utils.NetworkUtils
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
@@ -69,7 +74,16 @@ fun FollowingListScreen(userId: Int) {
|
||||
NoticeScreenHeader(stringResource(R.string.following_upper), moreIcon = false)
|
||||
|
||||
}
|
||||
if(users.itemCount == 0) {
|
||||
|
||||
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current)
|
||||
|
||||
if (!isNetworkAvailable) {
|
||||
NetworkErrorContent(
|
||||
onReload = {
|
||||
model.loadData(userId, true)
|
||||
}
|
||||
)
|
||||
} else if(users.itemCount == 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -81,25 +95,20 @@ fun FollowingListScreen(userId: Int) {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(
|
||||
id =if(AppState.darkMode) R.mipmap.qst_gz_qs_as_img_my
|
||||
else R.mipmap.qst_gz_qs_img_my),
|
||||
painter = painterResource(id = R.mipmap.frame_31),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(181.dp)
|
||||
modifier = Modifier.size(181.dp, 153.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
Spacer(modifier = Modifier.size(9.dp)) // 调整间距为9dp
|
||||
androidx.compose.material.Text(
|
||||
text = "没有关注任何灵魂",
|
||||
text = stringResource(R.string.awaiting_traveler),
|
||||
color = appColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
androidx.compose.material.Text(
|
||||
text = "探索一下,总有一个你想靠近的光点 ✨",
|
||||
color = appColors.secondaryText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.W400
|
||||
fontWeight = FontWeight.W600,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
package com.aiosman.ravenow.ui.group
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import com.airbnb.lottie.compose.LottieAnimation
|
||||
import com.airbnb.lottie.compose.LottieCompositionSpec
|
||||
import com.airbnb.lottie.compose.LottieConstants
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.composables.TabItem
|
||||
import com.aiosman.ravenow.ui.composables.TabSpacer
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun AddGroupMemberScreen(groupId: String, groupName: String?) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val navController = LocalNavController.current
|
||||
val systemUiController = rememberSystemUiController()
|
||||
val context = LocalContext.current
|
||||
|
||||
// 状态管理
|
||||
var searchText by remember { mutableStateOf(TextFieldValue("")) }
|
||||
var selectedMembers by remember { mutableStateOf(listOf<GroupMember>()) }
|
||||
var selectedMemberIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var pagerState = rememberPagerState(pageCount = { 2 })
|
||||
var scope = rememberCoroutineScope()
|
||||
|
||||
// LazyRow状态管理
|
||||
val lazyRowState = rememberLazyListState()
|
||||
|
||||
// 清除错误信息
|
||||
LaunchedEffect(searchText.text) {
|
||||
if (AddGroupMemberViewModel.errorMessage != null) {
|
||||
AddGroupMemberViewModel.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听selectedMembers变化,当有新成员添加时自动滚动到最后一个
|
||||
LaunchedEffect(selectedMembers.size) {
|
||||
if (selectedMembers.isNotEmpty()) {
|
||||
kotlinx.coroutines.delay(100)
|
||||
lazyRowState.animateScrollToItem(selectedMembers.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
systemUiController.setNavigationBarColor(Color.Transparent)
|
||||
AddGroupMemberViewModel.groupName = groupName
|
||||
AddGroupMemberViewModel.trtcId = groupId
|
||||
AddGroupMemberViewModel.roomId = null
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(AppColors.background)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
|
||||
// 头部
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 返回按钮
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_back_icon),
|
||||
contentDescription = "back",
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.noRippleClickable {
|
||||
navController.popBackStack()
|
||||
},
|
||||
colorFilter = ColorFilter.tint(AppColors.text)
|
||||
)
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_info_add_member),
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.W700,
|
||||
color = AppColors.text,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 搜索栏(暂时不实现,但保留UI)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.background(
|
||||
color = AppColors.inputBackground,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_nav_search),
|
||||
contentDescription = stringResource(R.string.search),
|
||||
modifier = Modifier.size(16.dp),
|
||||
colorFilter = ColorFilter.tint(AppColors.secondaryText)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
BasicTextField(
|
||||
value = searchText,
|
||||
onValueChange = { searchText = it },
|
||||
textStyle = androidx.compose.ui.text.TextStyle(
|
||||
color = AppColors.text,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(AppColors.text),
|
||||
decorationBox = { innerTextField ->
|
||||
Box {
|
||||
if (searchText.text.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.search),
|
||||
color = AppColors.secondaryText,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 已选成员列表
|
||||
if (selectedMembers.isNotEmpty()) {
|
||||
LazyRow(
|
||||
state = lazyRowState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(selectedMembers) { member ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.width(48.dp)
|
||||
) {
|
||||
Box {
|
||||
CustomAsyncImage(
|
||||
context = context,
|
||||
imageUrl = member.avatar,
|
||||
contentDescription = member.name,
|
||||
defaultRes = R.drawable.default_avatar,
|
||||
placeholderRes = R.drawable.default_avatar,
|
||||
errorRes = R.drawable.default_avatar,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
|
||||
// 删除按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.background(AppColors.error, CircleShape)
|
||||
.align(Alignment.TopEnd)
|
||||
.noRippleClickable {
|
||||
val (newSelectedMemberIds, newSelectedMembers) = AddGroupMemberViewModel.removeSelectedMember(
|
||||
member, selectedMemberIds, selectedMembers
|
||||
)
|
||||
selectedMemberIds = newSelectedMemberIds
|
||||
selectedMembers = newSelectedMembers
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "×",
|
||||
color = AppColors.mainText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 名称显示
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (member.name.length > 5) {
|
||||
member.name.substring(0, 5) + "..."
|
||||
} else {
|
||||
member.name
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = AppColors.text,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.wrapContentWidth(Alignment.CenterHorizontally)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab切换
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
TabItem(
|
||||
text = stringResource(R.string.chat_ai),
|
||||
isSelected = pagerState.currentPage == 0,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(0)
|
||||
}
|
||||
}
|
||||
)
|
||||
TabSpacer()
|
||||
TabItem(
|
||||
text = stringResource(R.string.chat_friend),
|
||||
isSelected = pagerState.currentPage == 1,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(1)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 内容区域 - 自适应填满剩余高度
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
when (it) {
|
||||
0 -> {
|
||||
// AI智能体列表
|
||||
AddMemberAiAgentListScreen(
|
||||
searchText = searchText.text,
|
||||
selectedMemberIds = selectedMemberIds,
|
||||
excludeRoomId = null,
|
||||
onMemberSelect = { member ->
|
||||
val (newSelectedMemberIds, newSelectedMembers) = AddGroupMemberViewModel.toggleMemberSelection(
|
||||
member, selectedMemberIds, selectedMembers
|
||||
)
|
||||
selectedMemberIds = newSelectedMemberIds
|
||||
selectedMembers = newSelectedMembers
|
||||
}
|
||||
)
|
||||
}
|
||||
1 -> {
|
||||
// 朋友列表
|
||||
AddMemberFriendListScreen(
|
||||
searchText = searchText.text,
|
||||
selectedMemberIds = selectedMemberIds,
|
||||
roomId = null,
|
||||
onMemberSelect = { member ->
|
||||
val (newSelectedMemberIds, newSelectedMembers) = AddGroupMemberViewModel.toggleMemberSelection(
|
||||
member, selectedMemberIds, selectedMembers
|
||||
)
|
||||
selectedMemberIds = newSelectedMemberIds
|
||||
selectedMembers = newSelectedMembers
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确认按钮 - 固定在底部
|
||||
Button(
|
||||
onClick = {
|
||||
if (selectedMembers.isNotEmpty()) {
|
||||
scope.launch {
|
||||
val success = AddGroupMemberViewModel.addMembersToGroup(selectedMembers)
|
||||
if (success) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = navigationBarPadding + 16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = AppColors.main,
|
||||
contentColor = AppColors.mainText,
|
||||
disabledContainerColor = AppColors.disabledBackground,
|
||||
disabledContentColor = AppColors.text
|
||||
),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
enabled = selectedMembers.isNotEmpty() && !AddGroupMemberViewModel.isLoading
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.lets_ride_upper),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 居中显示的错误提示弹窗
|
||||
AddGroupMemberViewModel.errorMessage?.let { error ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
androidx.compose.material3.Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.8f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
color = Color.Red,
|
||||
fontSize = 14.sp,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 支持excludeRoomId的AI智能体列表Screen
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun AddMemberAiAgentListScreen(
|
||||
searchText: String,
|
||||
selectedMemberIds: Set<String> = emptySet(),
|
||||
excludeRoomId: Int? = null,
|
||||
onMemberSelect: (GroupMember) -> Unit
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val viewModel = remember(excludeRoomId) { AddMemberAiAgentListViewModel(excludeRoomId) }
|
||||
|
||||
val filteredAgents = viewModel.getFilteredAgents(searchText)
|
||||
|
||||
val pullRefreshState = rememberPullRefreshState(
|
||||
refreshing = viewModel.isRefreshing,
|
||||
onRefresh = {
|
||||
viewModel.refresh(searchText)
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo }
|
||||
.collect { visibleItems ->
|
||||
if (visibleItems.isNotEmpty()) {
|
||||
val lastVisibleItem = visibleItems.last()
|
||||
if (lastVisibleItem.index >= filteredAgents.size - 3 && viewModel.hasMoreData && !viewModel.isLoadingMore && !viewModel.isLoading) {
|
||||
viewModel.loadMore(searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pullRefresh(pullRefreshState)
|
||||
) {
|
||||
if (viewModel.isLoading && filteredAgents.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LottieAnimation(
|
||||
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
|
||||
iterations = LottieConstants.IterateForever,
|
||||
modifier = Modifier.size(80.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(
|
||||
items = filteredAgents,
|
||||
key = { it.id }
|
||||
) { agent ->
|
||||
MemberItem(
|
||||
member = agent,
|
||||
isSelected = selectedMemberIds.contains(agent.id),
|
||||
onSelect = { onMemberSelect(agent) }
|
||||
)
|
||||
}
|
||||
|
||||
if (viewModel.isLoadingMore) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LottieAnimation(
|
||||
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
|
||||
iterations = LottieConstants.IterateForever,
|
||||
modifier = Modifier.size(80.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(
|
||||
refreshing = viewModel.isRefreshing,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
backgroundColor = AppColors.background,
|
||||
contentColor = AppColors.main
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 支持roomId的朋友列表Screen
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun AddMemberFriendListScreen(
|
||||
searchText: String,
|
||||
selectedMemberIds: Set<String> = emptySet(),
|
||||
roomId: Int? = null,
|
||||
onMemberSelect: (GroupMember) -> Unit
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val viewModel = remember(roomId) { AddMemberFriendListViewModel(roomId) }
|
||||
|
||||
val filteredFriends = viewModel.getFilteredFriends(searchText)
|
||||
|
||||
val pullRefreshState = rememberPullRefreshState(
|
||||
refreshing = viewModel.isRefreshing,
|
||||
onRefresh = {
|
||||
viewModel.refresh(searchText)
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo }
|
||||
.collect { visibleItems ->
|
||||
if (visibleItems.isNotEmpty()) {
|
||||
val lastVisibleItem = visibleItems.last()
|
||||
if (lastVisibleItem.index >= filteredFriends.size - 3 && viewModel.hasMoreData && !viewModel.isLoadingMore && !viewModel.isLoading) {
|
||||
viewModel.loadMore(searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pullRefresh(pullRefreshState)
|
||||
) {
|
||||
if (viewModel.isLoading && filteredFriends.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LottieAnimation(
|
||||
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
|
||||
iterations = LottieConstants.IterateForever,
|
||||
modifier = Modifier.size(80.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(
|
||||
items = filteredFriends,
|
||||
key = { it.id }
|
||||
) { friend ->
|
||||
MemberItem(
|
||||
member = friend,
|
||||
isSelected = selectedMemberIds.contains(friend.id),
|
||||
onSelect = { onMemberSelect(friend) }
|
||||
)
|
||||
}
|
||||
|
||||
if (viewModel.isLoadingMore) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LottieAnimation(
|
||||
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
|
||||
iterations = LottieConstants.IterateForever,
|
||||
modifier = Modifier.size(80.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(
|
||||
refreshing = viewModel.isRefreshing,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
backgroundColor = AppColors.background,
|
||||
contentColor = AppColors.main
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package com.aiosman.ravenow.ui.group
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aiosman.ravenow.AppStore
|
||||
import com.aiosman.ravenow.data.AccountService
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.data.RoomService
|
||||
import com.aiosman.ravenow.data.RoomServiceImpl
|
||||
import com.aiosman.ravenow.data.UserService
|
||||
import com.aiosman.ravenow.data.UserServiceImpl
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object AddGroupMemberViewModel : ViewModel() {
|
||||
val accountService: AccountService = AccountServiceImpl()
|
||||
val userService: UserService = UserServiceImpl()
|
||||
val roomService: RoomService = RoomServiceImpl()
|
||||
|
||||
// 状态管理
|
||||
var isLoading by mutableStateOf(false)
|
||||
var errorMessage by mutableStateOf<String?>(null)
|
||||
var groupName: String? = null
|
||||
var trtcId: String? = null
|
||||
var roomId: Int? = null
|
||||
|
||||
// 添加成员到群聊
|
||||
suspend fun addMembersToGroup(
|
||||
selectedMembers: List<GroupMember>
|
||||
): Boolean {
|
||||
return try {
|
||||
isLoading = true
|
||||
|
||||
// 验证房间标识
|
||||
if (trtcId == null && roomId == null) {
|
||||
isLoading = false
|
||||
val errorMsg = "房间标识不能为空"
|
||||
showToast(errorMsg)
|
||||
return false
|
||||
}
|
||||
|
||||
// 根据isAi属性分别获取用户和智能体的OpenID列表
|
||||
val userOpenIds = selectedMembers.filter { !it.isAi }.map { it.id }
|
||||
val agentOpenIds = selectedMembers.filter { it.isAi }.map { it.id }
|
||||
|
||||
var allSuccess = true
|
||||
var errorMessages = mutableListOf<String>()
|
||||
|
||||
// 添加用户到房间
|
||||
if (userOpenIds.isNotEmpty()) {
|
||||
try {
|
||||
val userResult = roomService.addUserToRoom(
|
||||
roomId = roomId,
|
||||
trtcId = trtcId,
|
||||
openIds = userOpenIds
|
||||
)
|
||||
|
||||
// 检查添加结果
|
||||
if (userResult.failedCount > 0) {
|
||||
val failedUsers = userResult.failedItems.joinToString(", ") { it.userId }
|
||||
errorMessages.add("部分用户添加失败: $failedUsers")
|
||||
}
|
||||
|
||||
if (userResult.successCount == 0 && userResult.failedCount > 0) {
|
||||
allSuccess = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
allSuccess = false
|
||||
errorMessages.add("添加用户失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// 添加智能体到房间
|
||||
if (agentOpenIds.isNotEmpty()) {
|
||||
try {
|
||||
val agentResult = roomService.addAgentToRoom(
|
||||
roomId = roomId,
|
||||
trtcId = trtcId,
|
||||
agentOpenIds = agentOpenIds
|
||||
)
|
||||
|
||||
// 检查添加结果
|
||||
if (agentResult.failedCount > 0) {
|
||||
val failedAgents = agentResult.failedItems.joinToString(", ") { it.agentOpenId }
|
||||
errorMessages.add("部分智能体添加失败: $failedAgents")
|
||||
}
|
||||
|
||||
if (agentResult.successCount == 0 && agentResult.failedCount > 0) {
|
||||
allSuccess = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
allSuccess = false
|
||||
errorMessages.add("添加智能体失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
|
||||
if (allSuccess) {
|
||||
if (errorMessages.isNotEmpty()) {
|
||||
// 有部分失败,但至少有一些成功
|
||||
showToast(errorMessages.joinToString("\n"))
|
||||
}
|
||||
true
|
||||
} else {
|
||||
// 全部失败
|
||||
val errorMsg = if (errorMessages.isNotEmpty()) {
|
||||
errorMessages.joinToString("\n")
|
||||
} else {
|
||||
"添加成员失败"
|
||||
}
|
||||
showToast(errorMsg)
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
isLoading = false
|
||||
val errorMsg = "添加成员失败: ${e.message}"
|
||||
showToast(errorMsg)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun showToast(message: String) {
|
||||
Log.w("AddGroupMemberViewModel", message)
|
||||
}
|
||||
|
||||
// 清除错误信息
|
||||
fun clearError() {
|
||||
errorMessage = null
|
||||
}
|
||||
|
||||
// 添加成员到选中列表
|
||||
fun addSelectedMember(member: GroupMember, selectedMemberIds: Set<String>, selectedMembers: List<GroupMember>): Pair<Set<String>, List<GroupMember>> {
|
||||
val newSelectedMemberIds = selectedMemberIds + member.id
|
||||
val newSelectedMembers = if (selectedMembers.none { it.id == member.id }) {
|
||||
selectedMembers + member
|
||||
} else {
|
||||
selectedMembers
|
||||
}
|
||||
return Pair(newSelectedMemberIds, newSelectedMembers)
|
||||
}
|
||||
|
||||
// 从选中列表移除成员
|
||||
fun removeSelectedMember(member: GroupMember, selectedMemberIds: Set<String>, selectedMembers: List<GroupMember>): Pair<Set<String>, List<GroupMember>> {
|
||||
val newSelectedMemberIds = selectedMemberIds - member.id
|
||||
val newSelectedMembers = selectedMembers.filter { it.id != member.id }
|
||||
return Pair(newSelectedMemberIds, newSelectedMembers)
|
||||
}
|
||||
|
||||
// 切换成员选中状态
|
||||
fun toggleMemberSelection(member: GroupMember, selectedMemberIds: Set<String>, selectedMembers: List<GroupMember>): Pair<Set<String>, List<GroupMember>> {
|
||||
return if (selectedMemberIds.contains(member.id)) {
|
||||
removeSelectedMember(member, selectedMemberIds, selectedMembers)
|
||||
} else {
|
||||
addSelectedMember(member, selectedMemberIds, selectedMembers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 支持roomId的AI智能体列表ViewModel
|
||||
class AddMemberAiAgentListViewModel(private val excludeRoomId: Int? = null) : ViewModel() {
|
||||
private val accountService: AccountService = AccountServiceImpl()
|
||||
|
||||
var aiAgents by mutableStateOf<List<GroupMember>>(emptyList())
|
||||
private set
|
||||
|
||||
var isLoading by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var isRefreshing by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var isLoadingMore by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var currentPage by mutableStateOf(1)
|
||||
private set
|
||||
|
||||
var hasMoreData by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
var errorMessage by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
private val pageSize = 20
|
||||
|
||||
init {
|
||||
loadAgents(1)
|
||||
}
|
||||
|
||||
fun loadAgents(page: Int, isRefresh: Boolean = false, searchText: String = "") {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
if (isRefresh) {
|
||||
isRefreshing = true
|
||||
} else if (page == 1) {
|
||||
isLoading = true
|
||||
} else {
|
||||
isLoadingMore = true
|
||||
}
|
||||
|
||||
errorMessage = null
|
||||
|
||||
val response = accountService.getAgent(page, pageSize, excludeRoomId = excludeRoomId, title = if (searchText.isNotEmpty()) searchText else null, desc = if (searchText.isNotEmpty()) searchText else null)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val agentData = response.body()!!
|
||||
val newAgents: List<GroupMember> = agentData.data.list.map { agent ->
|
||||
GroupMember(
|
||||
id = agent.openId,
|
||||
name = agent.title,
|
||||
avatar = "${ApiClient.BASE_API_URL+"/outside"}${agent.avatar}"+"?token="+"${AppStore.token}",
|
||||
isAi = true
|
||||
)
|
||||
}
|
||||
|
||||
if (isRefresh || page == 1) {
|
||||
aiAgents = newAgents
|
||||
currentPage = 1
|
||||
} else {
|
||||
aiAgents = aiAgents + newAgents
|
||||
currentPage = page
|
||||
}
|
||||
|
||||
hasMoreData = newAgents.size >= pageSize
|
||||
} else {
|
||||
errorMessage = "获取AI智能体列表失败: ${response.message()}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
errorMessage = "获取AI智能体列表失败: ${e.message}"
|
||||
} finally {
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
isLoadingMore = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(searchText: String = "") {
|
||||
loadAgents(1, true, searchText)
|
||||
}
|
||||
|
||||
fun loadMore(searchText: String = "") {
|
||||
if (hasMoreData && !isLoadingMore && !isLoading) {
|
||||
loadAgents(currentPage + 1, false, searchText)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
errorMessage = null
|
||||
}
|
||||
|
||||
fun getFilteredAgents(searchText: String): List<GroupMember> {
|
||||
return if (searchText.isEmpty()) {
|
||||
aiAgents
|
||||
} else {
|
||||
aiAgents.filter { it.name.contains(searchText, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 支持roomId的朋友列表ViewModel
|
||||
class AddMemberFriendListViewModel(private val roomId: Int? = null) : ViewModel() {
|
||||
private val userService: UserService = UserServiceImpl()
|
||||
|
||||
var friends by mutableStateOf<List<GroupMember>>(emptyList())
|
||||
private set
|
||||
|
||||
var isLoading by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var isRefreshing by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var isLoadingMore by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var currentPage by mutableStateOf(1)
|
||||
private set
|
||||
|
||||
var hasMoreData by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
var errorMessage by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
private val pageSize = 20
|
||||
|
||||
init {
|
||||
loadFriends(1)
|
||||
}
|
||||
|
||||
fun loadFriends(page: Int, isRefresh: Boolean = false, searchText: String = "") {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
if (isRefresh) {
|
||||
isRefreshing = true
|
||||
} else if (page == 1) {
|
||||
isLoading = true
|
||||
} else {
|
||||
isLoadingMore = true
|
||||
}
|
||||
|
||||
errorMessage = null
|
||||
|
||||
val userData = userService.getUsers(pageSize, page, nickname = if (searchText.isNotEmpty()) searchText else null, roomId = roomId)
|
||||
val newFriends: List<GroupMember> = userData.list.map { user ->
|
||||
GroupMember(
|
||||
id = user.chatAIId,
|
||||
name = user.nickName,
|
||||
avatar = user.avatar,
|
||||
isAi = false
|
||||
)
|
||||
}
|
||||
|
||||
if (isRefresh || page == 1) {
|
||||
friends = newFriends
|
||||
currentPage = 1
|
||||
} else {
|
||||
friends = friends + newFriends
|
||||
currentPage = page
|
||||
}
|
||||
|
||||
hasMoreData = newFriends.size >= pageSize
|
||||
} catch (e: Exception) {
|
||||
errorMessage = "获取朋友列表失败: ${e.message}"
|
||||
} finally {
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
isLoadingMore = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh(searchText: String = "") {
|
||||
loadFriends(1, true, searchText)
|
||||
}
|
||||
|
||||
fun loadMore(searchText: String = "") {
|
||||
if (hasMoreData && !isLoadingMore && !isLoading) {
|
||||
loadFriends(currentPage + 1, false, searchText)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
errorMessage = null
|
||||
}
|
||||
|
||||
fun getFilteredFriends(searchText: String): List<GroupMember> {
|
||||
return if (searchText.isEmpty()) {
|
||||
friends
|
||||
} else {
|
||||
friends.filter { it.name.contains(searchText, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import com.airbnb.lottie.compose.LottieAnimation
|
||||
import com.airbnb.lottie.compose.LottieCompositionSpec
|
||||
import com.airbnb.lottie.compose.LottieConstants
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -78,10 +82,10 @@ fun AiAgentListScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "加载中...",
|
||||
color = AppColors.secondaryText,
|
||||
fontSize = 14.sp
|
||||
LottieAnimation(
|
||||
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
|
||||
iterations = LottieConstants.IterateForever,
|
||||
modifier = Modifier.size(80.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -111,10 +115,10 @@ fun AiAgentListScreen(
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "加载更多...",
|
||||
color = AppColors.secondaryText,
|
||||
fontSize = 14.sp
|
||||
LottieAnimation(
|
||||
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
|
||||
iterations = LottieConstants.IterateForever,
|
||||
modifier = Modifier.size(80.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.aiosman.ravenow.ui.group
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -14,11 +15,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
@@ -27,11 +32,15 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.data.PointService
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.composables.TabItem
|
||||
@@ -90,6 +99,28 @@ fun CreateGroupChatScreen() {
|
||||
}
|
||||
|
||||
val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
var showSelectTipsDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// 自动隐藏“请选择群成员并输入群名称”提示弹窗
|
||||
LaunchedEffect(showSelectTipsDialog) {
|
||||
if (showSelectTipsDialog) {
|
||||
kotlinx.coroutines.delay(2000)
|
||||
showSelectTipsDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取费用和余额信息
|
||||
val pointsRules by PointService.pointsRules.collectAsState(initial = null)
|
||||
val pointsBalance by PointService.pointsBalance.collectAsState(initial = null)
|
||||
val roomMaxMembers by PointService.roomMaxMembers.collectAsState(initial = null)
|
||||
|
||||
val cost = CreateGroupChatViewModel.getCreateRoomCost()
|
||||
val currentBalance = CreateGroupChatViewModel.getCurrentBalance()
|
||||
val balanceAfterCost = CreateGroupChatViewModel.calculateBalanceAfterCost(cost)
|
||||
val isBalanceSufficient = CreateGroupChatViewModel.isBalanceSufficient(cost)
|
||||
|
||||
// 获取群聊初始上限
|
||||
val maxMemberLimit = roomMaxMembers?.defaultMaxTotal ?: 5
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
systemUiController.setNavigationBarColor(Color.Transparent)
|
||||
@@ -367,14 +398,19 @@ fun CreateGroupChatScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// Tab切换
|
||||
// Tab切换和成员数量显示
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Tab左对齐
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.Bottom
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TabItem(
|
||||
text = stringResource(R.string.chat_ai),
|
||||
@@ -397,6 +433,15 @@ fun CreateGroupChatScreen() {
|
||||
)
|
||||
}
|
||||
|
||||
// 成员数量显示右对齐(x/x格式)
|
||||
Text(
|
||||
text = "${selectedMembers.size}/$maxMemberLimit",
|
||||
fontSize = 14.sp,
|
||||
color = if (selectedMembers.size > maxMemberLimit) AppColors.error else AppColors.secondaryText,
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
}
|
||||
|
||||
// 内容区域 - 自适应填满剩余高度
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
@@ -436,11 +481,78 @@ fun CreateGroupChatScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// 创建群聊按钮 - 固定在底部
|
||||
// 余额和扣减积分显示(创建按钮上方)
|
||||
val buttonTopPadding = if (cost > 0) 4.dp else 16.dp
|
||||
if (cost > 0) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "${stringResource(R.string.create_group_chat_current_balance)}: ${currentBalance.formatNumber()} ${stringResource(R.string.pai_coin)}",
|
||||
fontSize = 12.sp,
|
||||
color = AppColors.secondaryText
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(
|
||||
text = "${stringResource(R.string.create_group_chat_required_cost)} ${cost.formatNumber()} ${stringResource(R.string.pai_coin)}",
|
||||
fontSize = 12.sp,
|
||||
color = AppColors.secondaryText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建群聊按钮 - 固定在底部(启用时使用渐变背景)
|
||||
val isCreateEnabled =
|
||||
groupName.text.isNotEmpty() && selectedMembers.isNotEmpty() && !CreateGroupChatViewModel.isLoading
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
top = buttonTopPadding,
|
||||
bottom = navigationBarPadding + 16.dp
|
||||
)
|
||||
.let { baseModifier ->
|
||||
if (isCreateEnabled) {
|
||||
baseModifier.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0x997c45ed),
|
||||
Color(0x997c68ef),
|
||||
Color(0x997bd8f8)
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
} else {
|
||||
baseModifier
|
||||
}
|
||||
}
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
// 创建群聊逻辑
|
||||
if (selectedMembers.isNotEmpty()) {
|
||||
// 检查是否超过上限
|
||||
if (selectedMembers.size > maxMemberLimit) {
|
||||
CreateGroupChatViewModel.showError(
|
||||
context.getString(
|
||||
R.string.create_group_chat_exceed_limit,
|
||||
maxMemberLimit
|
||||
)
|
||||
)
|
||||
return@Button
|
||||
}
|
||||
// 如果费用大于0,显示确认弹窗
|
||||
if (cost > 0) {
|
||||
CreateGroupChatViewModel.showConfirmDialog()
|
||||
} else {
|
||||
// 费用为0,直接创建
|
||||
scope.launch {
|
||||
val success = CreateGroupChatViewModel.createGroupChat(
|
||||
groupName = groupName.text,
|
||||
@@ -452,22 +564,22 @@ fun CreateGroupChatScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = navigationBarPadding + 16.dp),
|
||||
.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = AppColors.main,
|
||||
containerColor = if (isCreateEnabled) Color.Transparent else AppColors.main,
|
||||
contentColor = AppColors.mainText,
|
||||
disabledContainerColor = AppColors.disabledBackground,
|
||||
disabledContentColor = AppColors.text
|
||||
),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
enabled = groupName.text.isNotEmpty() && selectedMembers.isNotEmpty() && !CreateGroupChatViewModel.isLoading
|
||||
enabled = isCreateEnabled
|
||||
) {
|
||||
if (CreateGroupChatViewModel.isLoading) {
|
||||
Text(
|
||||
text = "创建中...",
|
||||
text = stringResource(R.string.agent_createing),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
@@ -480,6 +592,76 @@ fun CreateGroupChatScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用状态下拦截点击并弹出提示
|
||||
if (!isCreateEnabled) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.noRippleClickable {
|
||||
showSelectTipsDialog = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 请选择群成员并输入群名称 提示弹窗
|
||||
if (showSelectTipsDialog) {
|
||||
Dialog(
|
||||
onDismissRequest = { showSelectTipsDialog = false },
|
||||
properties = DialogProperties(dismissOnClickOutside = true, dismissOnBackPress = true)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(color = AppColors.background, shape = RoundedCornerShape(16.dp))
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.create_group_chat_select_members_and_name),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
color = Color(0xFF7C45ED),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 消费确认弹窗
|
||||
if (CreateGroupChatViewModel.showConfirmDialog) {
|
||||
CreateGroupChatConfirmDialog(
|
||||
cost = cost,
|
||||
currentBalance = currentBalance,
|
||||
balanceAfterCost = balanceAfterCost,
|
||||
isBalanceSufficient = isBalanceSufficient,
|
||||
onConfirm = {
|
||||
// 检查是否超过上限
|
||||
if (selectedMembers.size > maxMemberLimit) {
|
||||
CreateGroupChatViewModel.hideConfirmDialog()
|
||||
CreateGroupChatViewModel.showError(context.getString(R.string.create_group_chat_exceed_limit, maxMemberLimit))
|
||||
} else {
|
||||
CreateGroupChatViewModel.hideConfirmDialog()
|
||||
scope.launch {
|
||||
val success = CreateGroupChatViewModel.createGroupChat(
|
||||
groupName = groupName.text,
|
||||
selectedMembers = selectedMembers,
|
||||
context = context
|
||||
)
|
||||
if (success) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onCancel = {
|
||||
CreateGroupChatViewModel.hideConfirmDialog()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 居中显示的错误提示弹窗
|
||||
@@ -496,7 +678,7 @@ fun CreateGroupChatScreen() {
|
||||
horizontalAlignment = Alignment.CenterHorizontally, // 水平居中
|
||||
verticalArrangement = Arrangement.Center // 垂直居中
|
||||
) {
|
||||
androidx.compose.material3.Card(
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.8f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
@@ -508,7 +690,7 @@ fun CreateGroupChatScreen() {
|
||||
.fillMaxWidth(),
|
||||
color = Color.Red,
|
||||
fontSize = 14.sp,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -516,3 +698,219 @@ fun CreateGroupChatScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建群聊消费确认弹窗
|
||||
*/
|
||||
@Composable
|
||||
fun CreateGroupChatConfirmDialog(
|
||||
cost: Int,
|
||||
currentBalance: Int,
|
||||
balanceAfterCost: Int,
|
||||
isBalanceSufficient: Boolean,
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onCancel,
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true
|
||||
)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// 硬币图标(使用文本代替,实际项目中可以使用图片资源)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFFFD700), // 金色
|
||||
Color(0xFFFFA500) // 橙色
|
||||
)
|
||||
),
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "pai",
|
||||
color = Color.White,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
text = stringResource(R.string.create_group_chat_confirm_title),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = AppColors.text
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 需要消耗
|
||||
CostInfoRow(
|
||||
label = stringResource(R.string.create_group_chat_required_cost),
|
||||
amount = cost,
|
||||
AppColors = AppColors
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 当前余额
|
||||
CostInfoRow(
|
||||
label = stringResource(R.string.create_group_chat_current_balance),
|
||||
amount = currentBalance,
|
||||
AppColors = AppColors
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 消耗后余额
|
||||
CostInfoRow(
|
||||
label = stringResource(R.string.create_group_chat_balance_after),
|
||||
amount = balanceAfterCost,
|
||||
AppColors = AppColors
|
||||
)
|
||||
|
||||
// 余额不足提示
|
||||
if (!isBalanceSufficient) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.create_group_chat_insufficient_balance),
|
||||
color = Color.Red,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 按钮行
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 取消按钮
|
||||
OutlinedButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = AppColors.secondaryText.copy(alpha = 0.3f),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = AppColors.text
|
||||
),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.cancel),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
}
|
||||
|
||||
// 确认消耗按钮
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
enabled = isBalanceSufficient,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = AppColors.main,
|
||||
contentColor = AppColors.mainText,
|
||||
disabledContainerColor = AppColors.disabledBackground,
|
||||
disabledContentColor = AppColors.text
|
||||
),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.create_group_chat_confirm_consume),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用信息行组件
|
||||
*/
|
||||
@Composable
|
||||
fun CostInfoRow(
|
||||
label: String,
|
||||
amount: Int,
|
||||
AppColors: com.aiosman.ravenow.AppThemeData
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 14.sp,
|
||||
color = AppColors.text
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
// 小硬币图标(使用简单的圆形)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFFFD700),
|
||||
Color(0xFFFFA500)
|
||||
)
|
||||
),
|
||||
shape = CircleShape
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "${amount.formatNumber()} ${stringResource(R.string.pai_coin)}",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
color = AppColors.text
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字,添加千位分隔符
|
||||
*/
|
||||
fun Int.formatNumber(): String {
|
||||
return this.toString().reversed().chunked(3).joinToString(",").reversed()
|
||||
}
|
||||
|
||||
@@ -16,15 +16,15 @@ import com.aiosman.ravenow.ConstVars
|
||||
import com.aiosman.ravenow.data.AccountNotice
|
||||
import com.aiosman.ravenow.data.AccountService
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.data.PointService
|
||||
import com.aiosman.ravenow.data.UserService
|
||||
import com.aiosman.ravenow.data.UserServiceImpl
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.CommentEntity
|
||||
import com.aiosman.ravenow.exp.formatChatTime
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.navigateToChat
|
||||
import com.aiosman.ravenow.utils.TrtcHelper
|
||||
// 临时兼容层 - TODO: 完成 OpenIM 迁移后删除
|
||||
import com.aiosman.ravenow.compat.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -37,6 +37,7 @@ object CreateGroupChatViewModel : ViewModel() {
|
||||
// 状态管理
|
||||
var isLoading by mutableStateOf(false)
|
||||
var errorMessage by mutableStateOf<String?>(null)
|
||||
var showConfirmDialog by mutableStateOf(false)
|
||||
|
||||
// 创建群聊
|
||||
suspend fun createGroupChat(
|
||||
@@ -57,13 +58,13 @@ object CreateGroupChatViewModel : ViewModel() {
|
||||
true
|
||||
} else {
|
||||
isLoading = false
|
||||
val errorMsg = "创建群聊失败: ${response.message()}"
|
||||
val errorMsg = context.getString(R.string.create_group_chat_failed, response.message() ?: "")
|
||||
showToast(errorMsg)
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
isLoading = false
|
||||
val errorMsg = "创建群聊失败: ${e.message}"
|
||||
val errorMsg = context.getString(R.string.create_group_chat_failed, e.message ?: "")
|
||||
showToast(errorMsg)
|
||||
false
|
||||
}
|
||||
@@ -77,6 +78,11 @@ object CreateGroupChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// 显示错误信息(公开方法)
|
||||
fun showError(message: String) {
|
||||
showToast(message)
|
||||
}
|
||||
|
||||
// 清除错误信息
|
||||
fun clearError() {
|
||||
errorMessage = null
|
||||
@@ -108,4 +114,59 @@ object CreateGroupChatViewModel : ViewModel() {
|
||||
addSelectedMember(member, selectedMemberIds, selectedMembers)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取创建群聊的费用
|
||||
* @return 费用金额,如果无法获取则返回 0
|
||||
*/
|
||||
fun getCreateRoomCost(): Int {
|
||||
val rules = PointService.pointsRules.value
|
||||
val costRule = rules?.sub?.get(PointService.PointsRuleKey.CREATE_ROOM)
|
||||
return when (costRule) {
|
||||
is PointService.RuleAmount.Fixed -> costRule.value
|
||||
is PointService.RuleAmount.Range -> costRule.min // 使用最小值作为默认费用
|
||||
null -> 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前余额
|
||||
* @return 当前余额,如果无法获取则返回 0
|
||||
*/
|
||||
fun getCurrentBalance(): Int {
|
||||
return PointService.pointsBalance.value?.balance ?: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算消耗后余额
|
||||
* @param cost 费用
|
||||
* @return 消耗后余额
|
||||
*/
|
||||
fun calculateBalanceAfterCost(cost: Int): Int {
|
||||
val currentBalance = getCurrentBalance()
|
||||
return (currentBalance - cost).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查余额是否充足
|
||||
* @param cost 费用
|
||||
* @return 是否充足
|
||||
*/
|
||||
fun isBalanceSufficient(cost: Int): Boolean {
|
||||
return getCurrentBalance() >= cost
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示确认弹窗
|
||||
*/
|
||||
fun showConfirmDialog() {
|
||||
showConfirmDialog = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏确认弹窗
|
||||
*/
|
||||
fun hideConfirmDialog() {
|
||||
showConfirmDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import com.airbnb.lottie.compose.LottieAnimation
|
||||
import com.airbnb.lottie.compose.LottieCompositionSpec
|
||||
import com.airbnb.lottie.compose.LottieConstants
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -78,10 +82,10 @@ fun FriendListScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "加载中...",
|
||||
color = AppColors.secondaryText,
|
||||
fontSize = 14.sp
|
||||
LottieAnimation(
|
||||
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
|
||||
iterations = LottieConstants.IterateForever,
|
||||
modifier = Modifier.size(80.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -111,10 +115,10 @@ fun FriendListScreen(
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "加载更多...",
|
||||
color = AppColors.secondaryText,
|
||||
fontSize = 14.sp
|
||||
LottieAnimation(
|
||||
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
|
||||
iterations = LottieConstants.IterateForever,
|
||||
modifier = Modifier.size(80.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
package com.aiosman.ravenow.ui.group
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -8,10 +9,17 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.aiosman.ravenow.AppStore
|
||||
import com.aiosman.ravenow.ChatState
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import com.aiosman.ravenow.data.RoomService
|
||||
import com.aiosman.ravenow.data.RoomServiceImpl
|
||||
import com.aiosman.ravenow.data.parseErrorResponse
|
||||
import com.aiosman.ravenow.data.PointService
|
||||
import com.aiosman.ravenow.entity.RoomRuleEntity
|
||||
import com.aiosman.ravenow.entity.RoomRuleQuotaEntity
|
||||
import com.aiosman.ravenow.entity.ChatNotification
|
||||
import com.aiosman.ravenow.entity.GroupInfo
|
||||
import com.aiosman.ravenow.entity.GroupMember
|
||||
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GroupChatInfoViewModel(
|
||||
@@ -22,9 +30,32 @@ class GroupChatInfoViewModel(
|
||||
var isLoading by mutableStateOf(false)
|
||||
var error by mutableStateOf<String?>(null)
|
||||
var chatNotification by mutableStateOf<ChatNotification?>(null)
|
||||
var isAddingMemory by mutableStateOf(false)
|
||||
var addMemoryError by mutableStateOf<String?>(null)
|
||||
var addMemorySuccess by mutableStateOf(false)
|
||||
val notificationStrategy get() = chatNotification?.strategy ?: "default"
|
||||
|
||||
// 记忆管理相关状态
|
||||
var memoryQuota by mutableStateOf<RoomRuleQuotaEntity?>(null)
|
||||
var memoryList by mutableStateOf<List<RoomRuleEntity>>(emptyList())
|
||||
var isLoadingMemory by mutableStateOf(false)
|
||||
var memoryError by mutableStateOf<String?>(null)
|
||||
|
||||
// 房间规则服务
|
||||
private val roomService: RoomService = RoomServiceImpl()
|
||||
|
||||
// 群可见性相关状态
|
||||
var privateGroupCost by mutableStateOf<Int?>(null)
|
||||
var pointsBalance by mutableStateOf<Int?>(null)
|
||||
var isLoadingVisibility by mutableStateOf(false)
|
||||
var isUpdatingVisibility by mutableStateOf(false)
|
||||
|
||||
// 群记忆相关状态
|
||||
var addMemoryCost by mutableStateOf<Int?>(null)
|
||||
init {
|
||||
loadGroupInfo()
|
||||
loadVisibilityInfo()
|
||||
loadMemoryCost()
|
||||
}
|
||||
suspend fun updateNotificationStrategy(strategy: String) {
|
||||
val result = ChatState.updateChatNotification(groupId.hashCode(), strategy)
|
||||
@@ -60,7 +91,9 @@ class GroupChatInfoViewModel(
|
||||
"${ApiClient.BASE_API_URL+"/outside"}${it.avatar}"+"?token="+"${AppStore.token}"
|
||||
},
|
||||
memberCount = room.userCount,
|
||||
isCreator = room.creator.userId == MyProfileViewModel.profile?.id.toString()
|
||||
isCreator = room.creator.userId == MyProfileViewModel.profile?.id.toString(),
|
||||
trtcType = it.trtcType ?: "Public",
|
||||
privateFeePaid = it.privateFeePaid ?: false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -71,4 +104,204 @@ class GroupChatInfoViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加群记忆(房间规则)
|
||||
* @param memoryText 记忆内容
|
||||
*/
|
||||
fun addGroupMemory(memoryText: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
isAddingMemory = true
|
||||
addMemoryError = null
|
||||
addMemorySuccess = false
|
||||
|
||||
// 使用房间规则接口创建群记忆
|
||||
roomService.createRoomRule(
|
||||
rule = memoryText,
|
||||
trtcId = groupId
|
||||
)
|
||||
|
||||
addMemorySuccess = true
|
||||
Log.d("GroupChatInfoViewModel", "群记忆添加成功")
|
||||
// 刷新记忆列表和配额
|
||||
loadMemoryQuota()
|
||||
loadMemoryList()
|
||||
} catch (e: Exception) {
|
||||
addMemoryError = e.message ?: "添加群记忆失败"
|
||||
Log.e("GroupChatInfoViewModel", "添加群记忆失败: ${e.message}", e)
|
||||
} finally {
|
||||
isAddingMemory = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取记忆配额信息(房间规则配额)
|
||||
*/
|
||||
fun loadMemoryQuota() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
isLoadingMemory = true
|
||||
memoryError = null
|
||||
|
||||
// 使用房间规则接口获取配额
|
||||
memoryQuota = roomService.getRoomRuleQuota(trtcId = groupId)
|
||||
} catch (e: Exception) {
|
||||
memoryError = e.message ?: "获取配额信息失败"
|
||||
Log.e("GroupChatInfoViewModel", "获取配额信息失败: ${e.message}", e)
|
||||
} finally {
|
||||
isLoadingMemory = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取记忆列表(房间规则列表)
|
||||
*/
|
||||
fun loadMemoryList(page: Int = 1, pageSize: Int = 20) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
isLoadingMemory = true
|
||||
memoryError = null
|
||||
|
||||
// 使用房间规则接口获取列表
|
||||
val result = roomService.getRoomRuleList(
|
||||
trtcId = groupId,
|
||||
page = page,
|
||||
pageSize = pageSize
|
||||
)
|
||||
memoryList = result.list
|
||||
} catch (e: Exception) {
|
||||
memoryError = e.message ?: "获取记忆列表失败"
|
||||
Log.e("GroupChatInfoViewModel", "获取记忆列表失败: ${e.message}", e)
|
||||
} finally {
|
||||
isLoadingMemory = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除记忆(房间规则)
|
||||
*/
|
||||
fun deleteMemory(ruleId: Int) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
isLoadingMemory = true
|
||||
memoryError = null
|
||||
|
||||
// 使用房间规则接口删除
|
||||
roomService.deleteRoomRule(ruleId)
|
||||
|
||||
// 刷新记忆列表和配额
|
||||
loadMemoryQuota()
|
||||
loadMemoryList()
|
||||
} catch (e: Exception) {
|
||||
memoryError = e.message ?: "删除记忆失败"
|
||||
Log.e("GroupChatInfoViewModel", "删除记忆失败: ${e.message}", e)
|
||||
} finally {
|
||||
isLoadingMemory = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新记忆(房间规则)
|
||||
*/
|
||||
fun updateMemory(ruleId: Int, newRuleText: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
isLoadingMemory = true
|
||||
memoryError = null
|
||||
|
||||
// 使用房间规则接口更新
|
||||
roomService.updateRoomRule(
|
||||
id = ruleId,
|
||||
rule = newRuleText
|
||||
)
|
||||
|
||||
// 刷新记忆列表和配额
|
||||
loadMemoryQuota()
|
||||
loadMemoryList()
|
||||
} catch (e: Exception) {
|
||||
memoryError = e.message ?: "更新记忆失败"
|
||||
Log.e("GroupChatInfoViewModel", "更新记忆失败: ${e.message}", e)
|
||||
} finally {
|
||||
isLoadingMemory = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载群可见性相关信息(价格和积分余额)
|
||||
*/
|
||||
fun loadVisibilityInfo() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
isLoadingVisibility = true
|
||||
|
||||
// 获取积分规则中的私密群组价格
|
||||
PointService.refreshPointsRules()
|
||||
val rules = PointService.pointsRules.first()
|
||||
val roomPrivateRule = rules?.sub?.get(PointService.PointsRuleKey.ROOM_PRIVATE)
|
||||
privateGroupCost = when (roomPrivateRule) {
|
||||
is PointService.RuleAmount.Fixed -> roomPrivateRule.value
|
||||
is PointService.RuleAmount.Range -> roomPrivateRule.min
|
||||
null -> null
|
||||
}
|
||||
|
||||
// 获取积分余额
|
||||
PointService.refreshMyPointsBalance(includeStatistics = false)
|
||||
val balance = PointService.pointsBalance.first()
|
||||
pointsBalance = balance?.balance
|
||||
} catch (e: Exception) {
|
||||
Log.e("GroupChatInfoViewModel", "加载可见性信息失败: ${e.message}", e)
|
||||
} finally {
|
||||
isLoadingVisibility = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新群可见性
|
||||
* @param isPrivate 是否设置为私密群组
|
||||
*/
|
||||
fun updateVisibility(isPrivate: Boolean) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
isUpdatingVisibility = true
|
||||
// TODO: 实现更新房间可见性的接口调用
|
||||
// 暂时留空
|
||||
|
||||
// 更新成功后刷新群信息和积分余额
|
||||
loadGroupInfo()
|
||||
loadVisibilityInfo()
|
||||
} catch (e: Exception) {
|
||||
Log.e("GroupChatInfoViewModel", "更新可见性失败: ${e.message}", e)
|
||||
} finally {
|
||||
isUpdatingVisibility = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载添加群记忆的价格
|
||||
*/
|
||||
fun loadMemoryCost() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// 获取积分规则中的房间记忆价格(群聊记忆,区别于 Agent 记忆)
|
||||
PointService.refreshPointsRules()
|
||||
val rules = PointService.pointsRules.first()
|
||||
val roomMemoryRule = rules?.sub?.get(PointService.PointsRuleKey.SPEND_ROOM_MEMORY)
|
||||
addMemoryCost = when (roomMemoryRule) {
|
||||
is PointService.RuleAmount.Fixed -> roomMemoryRule.value
|
||||
is PointService.RuleAmount.Range -> roomMemoryRule.min
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("GroupChatInfoViewModel", "加载记忆价格失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user