Compare commits
84 Commits
feat/pr-20
...
atm2
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a76bd11d9 | |||
| f1e91f7639 | |||
| 2f602e37b1 | |||
| 58b008db91 | |||
| 2173347d96 | |||
| 6a299a8a2c | |||
| cf62a61195 | |||
| 4feca77924 | |||
| 5d5c65a5cb | |||
| e797ac93a7 | |||
| bec33c165e | |||
| 2f8cc8832a | |||
| 8e1d39d049 | |||
| f981efd58d | |||
| b12f359da1 | |||
| 8901792561 | |||
| 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 |
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-11-05T12:24:27.034893100Z">
|
||||
<DropdownSelection timestamp="2025-11-11T06:03:31.167121900Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=c328a150" />
|
||||
<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,21 +1,24 @@
|
||||
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
|
||||
versionCode = 1000019
|
||||
versionName = "1.0.000.19"
|
||||
targetSdk = 35
|
||||
versionCode = 1000021
|
||||
versionName = "1.0.000.21"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -44,19 +47,16 @@ 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
|
||||
buildConfig = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.3"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
@@ -97,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)
|
||||
@@ -127,5 +129,19 @@ dependencies {
|
||||
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)
|
||||
|
||||
// Google Play Billing
|
||||
implementation(libs.billing.ktx)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
<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-permission android:name="com.android.vending.BILLING" />
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".RaveNowApplication"
|
||||
@@ -51,7 +54,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">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
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
|
||||
@@ -50,7 +52,7 @@ object AppState {
|
||||
suspend fun initWithAccount(scope: CoroutineScope, context: Context) {
|
||||
// 如果是游客模式,使用简化的初始化流程
|
||||
if (AppStore.isGuest) {
|
||||
initWithGuestAccount()
|
||||
initWithGuestAccount(scope)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,18 +83,58 @@ object AppState {
|
||||
// 注册 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){
|
||||
@@ -228,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),
|
||||
|
||||
@@ -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,6 +40,7 @@ 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
|
||||
@@ -56,6 +60,21 @@ 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)
|
||||
}
|
||||
|
||||
// 请求通知权限
|
||||
private val requestPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
@@ -127,6 +146,15 @@ 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) {
|
||||
@@ -138,9 +166,12 @@ class MainActivity : ComponentActivity() {
|
||||
SplashScreen()
|
||||
} else {
|
||||
CompositionLocalProvider(
|
||||
LocalAppTheme provides AppState.appTheme
|
||||
LocalAppTheme provides AppState.appTheme,
|
||||
LocalDensity provides fixedDensity
|
||||
) {
|
||||
CheckUpdateDialog()
|
||||
// 全局挂载积分底部弹窗 Host
|
||||
PointsBottomSheetHost()
|
||||
Navigation(startDestination) { navController ->
|
||||
|
||||
// 处理带有 postId 的通知点击
|
||||
|
||||
@@ -2,7 +2,12 @@ package com.aiosman.ravenow
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import com.android.billingclient.api.BillingClient
|
||||
import com.android.billingclient.api.BillingClientStateListener
|
||||
import com.android.billingclient.api.BillingResult
|
||||
import com.android.billingclient.api.PurchasesUpdatedListener
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.perf.FirebasePerformance
|
||||
|
||||
@@ -11,6 +16,16 @@ import com.google.firebase.perf.FirebasePerformance
|
||||
*/
|
||||
class RaveNowApplication : Application() {
|
||||
|
||||
private var billingClient: BillingClient? = null
|
||||
|
||||
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()
|
||||
|
||||
@@ -39,6 +54,53 @@ class RaveNowApplication : Application() {
|
||||
} catch (e: Exception) {
|
||||
Log.e("RaveNowApplication", "Firebase初始化失败在进程 $processName", e)
|
||||
}
|
||||
|
||||
// 初始化 Google Play Billing
|
||||
initBillingClient()
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Google Play Billing Client
|
||||
*/
|
||||
private fun initBillingClient() {
|
||||
val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
|
||||
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
|
||||
// 处理购买成功
|
||||
Log.d("RaveNowApplication", "购买成功: ${purchases.size} 个商品")
|
||||
} else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {
|
||||
// 用户取消购买
|
||||
Log.d("RaveNowApplication", "用户取消购买")
|
||||
} else {
|
||||
// 处理其他错误
|
||||
Log.e("RaveNowApplication", "购买失败: ${billingResult.debugMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
billingClient = BillingClient.newBuilder(this)
|
||||
.setListener(purchasesUpdatedListener)
|
||||
.build()
|
||||
|
||||
billingClient?.startConnection(object : BillingClientStateListener {
|
||||
override fun onBillingSetupFinished(billingResult: BillingResult) {
|
||||
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
Log.d("RaveNowApplication", "BillingClient 初始化成功")
|
||||
} else {
|
||||
Log.e("RaveNowApplication", "BillingClient 初始化失败: ${billingResult.debugMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBillingServiceDisconnected() {
|
||||
Log.w("RaveNowApplication", "BillingClient 连接断开,尝试重新连接")
|
||||
// 可以在这里实现重连逻辑
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 BillingClient 实例
|
||||
*/
|
||||
fun getBillingClient(): BillingClient? {
|
||||
return billingClient
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,15 +436,16 @@ 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 {
|
||||
@@ -630,15 +650,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)
|
||||
}
|
||||
|
||||
@@ -73,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
|
||||
){
|
||||
@@ -85,7 +85,7 @@ data class Profile(
|
||||
avatar = "${ApiClient.BASE_SERVER}$avatar",
|
||||
bio = bio,
|
||||
banner = "${ApiClient.BASE_SERVER}$banner",
|
||||
trtcUserId = trtcUserId,
|
||||
trtcUserId = trtcUserId ?: "",
|
||||
chatAIId = chatAIId,
|
||||
aiAccount = aiAccount
|
||||
)
|
||||
@@ -108,6 +108,15 @@ interface AgentService {
|
||||
authorId: Int? = null
|
||||
): ListContainer<AgentEntity>?
|
||||
|
||||
/**
|
||||
* 根据标题关键字搜索智能体
|
||||
*/
|
||||
suspend fun searchAgentByTitle(
|
||||
pageNumber: Int,
|
||||
pageSize: Int = 20,
|
||||
title: String
|
||||
): ListContainer<AgentEntity>?
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,11 @@ data class Moment(
|
||||
@SerializedName("commentCount")
|
||||
val commentCount: Long,
|
||||
@SerializedName("time")
|
||||
val time: String,
|
||||
val time: String?,
|
||||
@SerializedName("createdAt")
|
||||
val createdAt: String? = null,
|
||||
@SerializedName("location")
|
||||
val location: String? = null,
|
||||
@SerializedName("isFollowed")
|
||||
val isFollowed: Boolean,
|
||||
// 新闻相关字段
|
||||
@@ -64,10 +68,18 @@ data class Moment(
|
||||
fun toMomentItem(): MomentEntity {
|
||||
return MomentEntity(
|
||||
id = id.toInt(),
|
||||
avatar = "${ApiClient.BASE_SERVER}${user.avatar}",
|
||||
nickname = user.nickName,
|
||||
location = "Worldwide",
|
||||
time = ApiClient.dateFromApiString(time),
|
||||
avatar = if (user.avatar != null && user.avatar.isNotEmpty()) {
|
||||
"${ApiClient.BASE_SERVER}${user.avatar}"
|
||||
} else {
|
||||
"" // 如果头像为空,使用空字符串
|
||||
},
|
||||
nickname = user.nickName ?: "未知用户", // 如果昵称为空,使用默认值
|
||||
location = location ?: "Worldwide",
|
||||
time = when {
|
||||
createdAt != null && createdAt.isNotEmpty() -> ApiClient.dateFromApiString(createdAt)
|
||||
time != null && time.isNotEmpty() -> ApiClient.dateFromApiString(time)
|
||||
else -> java.util.Date() // 如果时间为空,使用当前时间作为默认值
|
||||
},
|
||||
followStatus = isFollowed,
|
||||
momentTextContent = textContent,
|
||||
momentPicture = R.drawable.default_moment_img,
|
||||
@@ -77,9 +89,18 @@ data class Moment(
|
||||
favoriteCount = favoriteCount.toInt(),
|
||||
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
|
||||
@@ -187,10 +208,10 @@ data class Video(
|
||||
data class User(
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
@SerializedName("nickName")
|
||||
val nickName: String,
|
||||
@SerializedName("nickname")
|
||||
val nickName: String?,
|
||||
@SerializedName("avatar")
|
||||
val avatar: String,
|
||||
val avatar: String?,
|
||||
@SerializedName("avatarMedium")
|
||||
val avatarMedium: String? = null,
|
||||
@SerializedName("avatarLarge")
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,19 @@ 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.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
|
||||
@@ -14,6 +26,22 @@ 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,
|
||||
@@ -39,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>
|
||||
|
||||
@@ -63,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() }
|
||||
)
|
||||
}
|
||||
@@ -78,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
|
||||
){
|
||||
@@ -86,7 +143,7 @@ data class Creator(
|
||||
return CreatorEntity(
|
||||
id = id,
|
||||
userId = userId,
|
||||
trtcUserId = trtcUserId,
|
||||
trtcUserId = trtcUserId ?: "",
|
||||
profile = profile.toProfileEntity()
|
||||
)
|
||||
}
|
||||
@@ -98,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
|
||||
){
|
||||
@@ -173,6 +230,68 @@ interface RoomService {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,6 +372,78 @@ class RoomServiceImpl : RoomService {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -277,7 +468,12 @@ fun RoomRuleCreator.toRoomRuleCreatorEntity(): RoomRuleCreatorEntity {
|
||||
return RoomRuleCreatorEntity(
|
||||
id = id,
|
||||
nickname = nickname,
|
||||
avatar = avatar
|
||||
avatar = avatar,
|
||||
avatarMedium = avatarMedium,
|
||||
avatarLarge = avatarLarge,
|
||||
avatarDirectUrl = avatarDirectUrl,
|
||||
avatarMediumDirectUrl = avatarMediumDirectUrl,
|
||||
avatarLargeDirectUrl = avatarLargeDirectUrl
|
||||
)
|
||||
}
|
||||
|
||||
@@ -295,6 +491,128 @@ fun RoomRuleQuota.toRoomRuleQuotaEntity(): RoomRuleQuotaEntity {
|
||||
)
|
||||
}
|
||||
|
||||
// ========== 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,7 +102,8 @@ class UserServiceImpl : UserService {
|
||||
page: Int,
|
||||
nickname: String?,
|
||||
followerId: Int?,
|
||||
followingId: Int?
|
||||
followingId: Int?,
|
||||
roomId: Int?
|
||||
): ListContainer<AccountProfileEntity> {
|
||||
val resp = ApiClient.api.getUsers(
|
||||
page = page,
|
||||
@@ -98,7 +111,7 @@ class UserServiceImpl : UserService {
|
||||
search = nickname,
|
||||
followerId = followerId,
|
||||
followingId = followingId,
|
||||
includeAI = true
|
||||
includeAI = true,
|
||||
)
|
||||
val body = resp.body() ?: throw ServiceException("Failed to get account")
|
||||
return ListContainer<AccountProfileEntity>(
|
||||
@@ -120,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() }
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import com.aiosman.ravenow.data.Comment
|
||||
import com.aiosman.ravenow.data.DataContainer
|
||||
import com.aiosman.ravenow.data.ListContainer
|
||||
import com.aiosman.ravenow.data.Moment
|
||||
import com.aiosman.ravenow.data.RecommendationsResponse
|
||||
import com.aiosman.ravenow.data.Room
|
||||
import com.aiosman.ravenow.entity.ChatNotification
|
||||
import com.aiosman.ravenow.data.membership.MembershipConfigData
|
||||
@@ -86,6 +87,13 @@ data class JoinGroupChatRequestBody(
|
||||
val roomId: Int? = null,
|
||||
)
|
||||
|
||||
data class BatchTrtcUserIdRequestBody(
|
||||
@SerializedName("trtcUserIds")
|
||||
val trtcUserIds: List<String>,
|
||||
@SerializedName("includeAI")
|
||||
val includeAI: Boolean? = null,
|
||||
)
|
||||
|
||||
data class LoginUserRequestBody(
|
||||
@SerializedName("username")
|
||||
val username: String? = null,
|
||||
@@ -273,6 +281,260 @@ data class RemoveAccountRequestBody(
|
||||
val password: String,
|
||||
)
|
||||
|
||||
// ========== Room Member Management 相关数据类 ==========
|
||||
|
||||
/**
|
||||
* 添加用户到房间请求体
|
||||
* @param roomId 房间ID(与trtcId互斥,二者必须提供其一)
|
||||
* @param trtcId TRTC群组ID(与roomId互斥,二者必须提供其一)
|
||||
* @param openIds 要添加的用户OpenID列表
|
||||
*/
|
||||
data class AddUserToRoomRequestBody(
|
||||
@SerializedName("roomId")
|
||||
val roomId: Int? = null,
|
||||
@SerializedName("trtcId")
|
||||
val trtcId: String? = null,
|
||||
@SerializedName("openIds")
|
||||
val openIds: List<String>
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加智能体到房间请求体
|
||||
* @param roomId 房间ID(与trtcId互斥,二者必须提供其一)
|
||||
* @param trtcId TRTC群组ID(与roomId互斥,二者必须提供其一)
|
||||
* @param agentOpenIds 要添加的智能体OpenID列表
|
||||
*/
|
||||
data class AddAgentToRoomRequestBody(
|
||||
@SerializedName("roomId")
|
||||
val roomId: Int? = null,
|
||||
@SerializedName("trtcId")
|
||||
val trtcId: String? = null,
|
||||
@SerializedName("agentOpenIds")
|
||||
val agentOpenIds: List<String>
|
||||
)
|
||||
|
||||
/**
|
||||
* 从房间移除智能体请求体
|
||||
* @param roomId 房间ID(与trtcId互斥,二者必须提供其一)
|
||||
* @param trtcId TRTC群组ID(与roomId互斥,二者必须提供其一)
|
||||
* @param agentOpenIds 要移除的智能体OpenID列表
|
||||
*/
|
||||
data class RemoveAgentFromRoomRequestBody(
|
||||
@SerializedName("roomId")
|
||||
val roomId: Int? = null,
|
||||
@SerializedName("trtcId")
|
||||
val trtcId: String? = null,
|
||||
@SerializedName("agentOpenIds")
|
||||
val agentOpenIds: List<String>
|
||||
)
|
||||
|
||||
/**
|
||||
* 从房间移除用户请求体
|
||||
* @param roomId 房间ID(与trtcId互斥,二者必须提供其一)
|
||||
* @param trtcId TRTC群组ID(与roomId互斥,二者必须提供其一)
|
||||
* @param userIds 要移除的用户ID列表(OpenID)
|
||||
*/
|
||||
data class RemoveUserFromRoomRequestBody(
|
||||
@SerializedName("roomId")
|
||||
val roomId: Int? = null,
|
||||
@SerializedName("trtcId")
|
||||
val trtcId: String? = null,
|
||||
@SerializedName("userIds")
|
||||
val userIds: List<String>
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加用户成功项目
|
||||
*/
|
||||
data class AddUserToRoomItem(
|
||||
@SerializedName("userId")
|
||||
val userId: String,
|
||||
@SerializedName("type")
|
||||
val type: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加用户失败项目
|
||||
*/
|
||||
data class AddUserToRoomFailedItem(
|
||||
@SerializedName("userId")
|
||||
val userId: String,
|
||||
@SerializedName("type")
|
||||
val type: String,
|
||||
@SerializedName("error")
|
||||
val error: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加用户到房间的结果
|
||||
*/
|
||||
data class AddUserToRoomResult(
|
||||
@SerializedName("totalCount")
|
||||
val totalCount: Int,
|
||||
@SerializedName("successCount")
|
||||
val successCount: Int,
|
||||
@SerializedName("failedCount")
|
||||
val failedCount: Int,
|
||||
@SerializedName("skippedCount")
|
||||
val skippedCount: Int,
|
||||
@SerializedName("successItems")
|
||||
val successItems: List<AddUserToRoomItem>,
|
||||
@SerializedName("failedItems")
|
||||
val failedItems: List<AddUserToRoomFailedItem>,
|
||||
@SerializedName("skippedItems")
|
||||
val skippedItems: List<AddUserToRoomItem>
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加用户到房间响应
|
||||
*/
|
||||
data class AddUserToRoomResponse(
|
||||
@SerializedName("message")
|
||||
val message: String,
|
||||
@SerializedName("operationType")
|
||||
val operationType: String,
|
||||
@SerializedName("result")
|
||||
val result: AddUserToRoomResult
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加智能体成功项目
|
||||
*/
|
||||
data class AddAgentToRoomItem(
|
||||
@SerializedName("agentOpenId")
|
||||
val agentOpenId: String,
|
||||
@SerializedName("type")
|
||||
val type: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加智能体失败项目
|
||||
*/
|
||||
data class AddAgentToRoomFailedItem(
|
||||
@SerializedName("agentOpenId")
|
||||
val agentOpenId: String,
|
||||
@SerializedName("type")
|
||||
val type: String,
|
||||
@SerializedName("error")
|
||||
val error: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加智能体到房间的结果
|
||||
*/
|
||||
data class AddAgentToRoomResult(
|
||||
@SerializedName("totalCount")
|
||||
val totalCount: Int,
|
||||
@SerializedName("successCount")
|
||||
val successCount: Int,
|
||||
@SerializedName("failedCount")
|
||||
val failedCount: Int,
|
||||
@SerializedName("skippedCount")
|
||||
val skippedCount: Int,
|
||||
@SerializedName("successItems")
|
||||
val successItems: List<AddAgentToRoomItem>,
|
||||
@SerializedName("failedItems")
|
||||
val failedItems: List<AddAgentToRoomFailedItem>,
|
||||
@SerializedName("skippedItems")
|
||||
val skippedItems: List<AddAgentToRoomItem>
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加智能体到房间响应
|
||||
*/
|
||||
data class AddAgentToRoomResponse(
|
||||
@SerializedName("message")
|
||||
val message: String,
|
||||
@SerializedName("operationType")
|
||||
val operationType: String,
|
||||
@SerializedName("result")
|
||||
val result: AddAgentToRoomResult
|
||||
)
|
||||
|
||||
/**
|
||||
* 移除智能体成功项目
|
||||
*/
|
||||
data class RemoveAgentFromRoomItem(
|
||||
@SerializedName("agentOpenId")
|
||||
val agentOpenId: String,
|
||||
@SerializedName("type")
|
||||
val type: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 移除智能体失败项目
|
||||
*/
|
||||
data class RemoveAgentFromRoomFailedItem(
|
||||
@SerializedName("agentOpenId")
|
||||
val agentOpenId: String,
|
||||
@SerializedName("type")
|
||||
val type: String,
|
||||
@SerializedName("error")
|
||||
val error: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 从房间移除智能体的结果
|
||||
*/
|
||||
data class RemoveAgentFromRoomResult(
|
||||
@SerializedName("totalCount")
|
||||
val totalCount: Int,
|
||||
@SerializedName("successCount")
|
||||
val successCount: Int,
|
||||
@SerializedName("failedCount")
|
||||
val failedCount: Int,
|
||||
@SerializedName("skippedCount")
|
||||
val skippedCount: Int,
|
||||
@SerializedName("successItems")
|
||||
val successItems: List<RemoveAgentFromRoomItem>,
|
||||
@SerializedName("failedItems")
|
||||
val failedItems: List<RemoveAgentFromRoomFailedItem>,
|
||||
@SerializedName("skippedItems")
|
||||
val skippedItems: List<RemoveAgentFromRoomItem>
|
||||
)
|
||||
|
||||
/**
|
||||
* 移除用户成功项目
|
||||
*/
|
||||
data class RemoveUserFromRoomItem(
|
||||
@SerializedName("userId")
|
||||
val userId: String,
|
||||
@SerializedName("type")
|
||||
val type: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 移除用户失败项目
|
||||
*/
|
||||
data class RemoveUserFromRoomFailedItem(
|
||||
@SerializedName("userId")
|
||||
val userId: String,
|
||||
@SerializedName("type")
|
||||
val type: String,
|
||||
@SerializedName("error")
|
||||
val error: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 从房间移除用户的结果
|
||||
*/
|
||||
data class RemoveUserFromRoomResult(
|
||||
@SerializedName("totalCount")
|
||||
val totalCount: Int,
|
||||
@SerializedName("successCount")
|
||||
val successCount: Int,
|
||||
@SerializedName("failedCount")
|
||||
val failedCount: Int,
|
||||
@SerializedName("skippedCount")
|
||||
val skippedCount: Int,
|
||||
@SerializedName("successItems")
|
||||
val successItems: List<RemoveUserFromRoomItem>,
|
||||
@SerializedName("failedItems")
|
||||
val failedItems: List<RemoveUserFromRoomFailedItem>,
|
||||
@SerializedName("skippedItems")
|
||||
val skippedItems: List<RemoveUserFromRoomItem>
|
||||
)
|
||||
|
||||
// API 错误响应(用于加入房间等接口的错误处理)
|
||||
data class ApiErrorResponse(
|
||||
@SerializedName("err")
|
||||
@@ -651,6 +913,11 @@ data class UpdateRoomRuleRequestBody(
|
||||
* @param id 创建者ID
|
||||
* @param nickname 创建者昵称
|
||||
* @param avatar 创建者头像文件名
|
||||
* @param avatarMedium 中等头像文件名
|
||||
* @param avatarLarge 大头像文件名
|
||||
* @param avatarDirectUrl 小头像直接访问URL
|
||||
* @param avatarMediumDirectUrl 中等头像直接访问URL
|
||||
* @param avatarLargeDirectUrl 大头像直接访问URL
|
||||
*/
|
||||
data class RoomRuleCreator(
|
||||
@SerializedName("id")
|
||||
@@ -658,7 +925,17 @@ data class RoomRuleCreator(
|
||||
@SerializedName("nickname")
|
||||
val nickname: String,
|
||||
@SerializedName("avatar")
|
||||
val avatar: String
|
||||
val avatar: String,
|
||||
@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
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -761,6 +1038,71 @@ data class InsufficientBalanceError(
|
||||
val traceId: String?
|
||||
)
|
||||
|
||||
// ========== Points 相关数据类 ==========
|
||||
|
||||
/**
|
||||
* 积分余额信息
|
||||
* @param balance 当前积分余额
|
||||
* @param totalEarned 累计获得积分(可选)
|
||||
* @param totalSpent 累计消费积分(可选)
|
||||
*/
|
||||
data class PointsBalance(
|
||||
@SerializedName("balance")
|
||||
val balance: Int,
|
||||
@SerializedName("totalEarned")
|
||||
val totalEarned: Int? = null,
|
||||
@SerializedName("totalSpent")
|
||||
val totalSpent: Int? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 积分变更日志
|
||||
* @param id 日志记录ID
|
||||
* @param changeType 变更类型(add: 增加, subtract: 减少, adjust: 调整)
|
||||
* @param before 变更前余额
|
||||
* @param after 变更后余额
|
||||
* @param amount 本次变更数量(增加为正数,减少为负数)
|
||||
* @param reason 变更原因代码
|
||||
* @param createdAt 创建时间,格式:YYYY-MM-DD HH:mm:ss
|
||||
*/
|
||||
data class PointsChangeLog(
|
||||
@SerializedName("id")
|
||||
val id: Int,
|
||||
@SerializedName("changeType")
|
||||
val changeType: String,
|
||||
@SerializedName("before")
|
||||
val before: Int,
|
||||
@SerializedName("after")
|
||||
val after: Int,
|
||||
@SerializedName("amount")
|
||||
val amount: Int,
|
||||
@SerializedName("reason")
|
||||
val reason: String,
|
||||
@SerializedName("createdAt")
|
||||
val createdAt: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 积分变更日志列表响应
|
||||
* @param success 请求是否成功
|
||||
* @param list 积分变更日志列表
|
||||
* @param total 总记录数
|
||||
* @param page 当前页码
|
||||
* @param pageSize 每页数量
|
||||
*/
|
||||
data class PointsChangeLogsResponse(
|
||||
@SerializedName("success")
|
||||
val success: Boolean,
|
||||
@SerializedName("list")
|
||||
val list: List<PointsChangeLog>,
|
||||
@SerializedName("total")
|
||||
val total: Int,
|
||||
@SerializedName("page")
|
||||
val page: Int,
|
||||
@SerializedName("pageSize")
|
||||
val pageSize: Int
|
||||
)
|
||||
|
||||
interface RaveNowAPI {
|
||||
@GET("membership/config")
|
||||
@retrofit2.http.Headers("X-Requires-Auth: true")
|
||||
@@ -938,6 +1280,11 @@ interface RaveNowAPI {
|
||||
@Path("id") id: String
|
||||
): Response<DataContainer<AccountProfile>>
|
||||
|
||||
@POST("profile/trtc/batch")
|
||||
suspend fun getAccountProfilesByTrtcBatch(
|
||||
@Body body: BatchTrtcUserIdRequestBody
|
||||
): Response<DataContainer<List<AccountProfile>>>
|
||||
|
||||
@POST("user/{id}/follow")
|
||||
suspend fun followUser(
|
||||
@Path("id") id: Int
|
||||
@@ -1003,6 +1350,16 @@ interface RaveNowAPI {
|
||||
@Query("keys") keys: String
|
||||
): Response<ListContainer<DictItem>>
|
||||
|
||||
@GET("/outside/dict")
|
||||
suspend fun getOutsideDict(
|
||||
@Query("key") key: String
|
||||
): Response<DataContainer<DictItem>>
|
||||
|
||||
@GET("/outside/dicts")
|
||||
suspend fun getOutsideDicts(
|
||||
@Query("keys") keys: String
|
||||
): Response<ListContainer<DictItem>>
|
||||
|
||||
@POST("captcha/generate")
|
||||
suspend fun generateCaptcha(
|
||||
@Body body: CaptchaRequestBody
|
||||
@@ -1046,6 +1403,9 @@ interface RaveNowAPI {
|
||||
@Query("authorId") authorId: Int? = null,
|
||||
@Query("categoryIds") categoryIds: List<Int>? = null,
|
||||
@Query("random") random: Int? = null,
|
||||
@Query("title") title: String? = null,
|
||||
@Query("desc") desc: String? = null,
|
||||
@Query("excludeRoomId") excludeRoomId: Int? = null,
|
||||
): Response<DataContainer<ListContainer<Agent>>>
|
||||
|
||||
@GET("outside/my/prompts")
|
||||
@@ -1088,11 +1448,39 @@ interface RaveNowAPI {
|
||||
@POST("outside/rooms")
|
||||
suspend fun createGroupChat(@Body body: CreateGroupChatRequestBody): Response<DataContainer<Unit>>
|
||||
|
||||
/**
|
||||
* 获取房间列表
|
||||
*
|
||||
* 支持游客和认证用户访问,根据用户类型返回不同的房间数据。
|
||||
* 游客模式返回公开推荐房间列表,认证用户模式返回用户可访问的群聊列表。
|
||||
*
|
||||
* @param page 页码,默认 1
|
||||
* @param pageSize 每页数量,默认 20(游客模式最大50)
|
||||
* @param roomId 房间ID,用于精确查询特定房间(仅认证用户)
|
||||
* @param includeUsers 是否包含用户列表,默认false(仅认证用户)
|
||||
* @param isRecommended 是否推荐过滤器:1=推荐,0=非推荐,null=不过滤(仅认证用户)
|
||||
* @param roomType 房间类型过滤:all=公有私有都显示, public=只显示公有, private=只显示私有, created=只显示自己创建的, joined=只显示自己加入的(仅认证用户)
|
||||
* @param search 搜索关键字,支持房间名称、描述、智能体名称模糊匹配
|
||||
* @param random 是否随机排序,字符串长度不为0则为true(传任意非空字符串即可)
|
||||
* @param ownerSessionId 创建者用户ID(ChatAIID),用于过滤特定创建者的房间
|
||||
* @param showPublic 是否显示公有房间,只有明确设置为true时才生效(优先级高于roomType,仅认证用户)
|
||||
* @param showCreated 是否显示自己创建的房间,只有明确设置为true时才生效(优先级高于roomType,仅认证用户)
|
||||
* @param showJoined 是否显示自己加入的房间,只有明确设置为true时才生效(优先级高于roomType,仅认证用户)
|
||||
*/
|
||||
@GET("outside/rooms")
|
||||
suspend fun getRooms(@Query("page") page: Int = 1,
|
||||
suspend fun getRooms(
|
||||
@Query("page") page: Int = 1,
|
||||
@Query("pageSize") pageSize: Int = 20,
|
||||
@Query("isRecommended") isRecommended: Int = 1,
|
||||
@Query("random") random: Int? = null,
|
||||
@Query("roomId") roomId: Long? = null,
|
||||
@Query("includeUsers") includeUsers: Boolean? = null,
|
||||
@Query("isRecommended") isRecommended: Int? = null,
|
||||
@Query("roomType") roomType: String? = null,
|
||||
@Query("search") search: String? = null,
|
||||
@Query("random") random: String? = null,
|
||||
@Query("ownerSessionId") ownerSessionId: String? = null,
|
||||
@Query("showPublic") showPublic: Boolean? = null,
|
||||
@Query("showCreated") showCreated: Boolean? = null,
|
||||
@Query("showJoined") showJoined: Boolean? = null,
|
||||
): Response<ListContainer<Room>>
|
||||
|
||||
@GET("outside/rooms/detail")
|
||||
@@ -1137,6 +1525,35 @@ interface RaveNowAPI {
|
||||
@Query("pageSize") pageSize: Int? = null
|
||||
): Response<ListContainer<Agent>>
|
||||
|
||||
/**
|
||||
* 获取Prompt详情(支持ID或OpenId)
|
||||
* @param promptId Prompt ID或OpenId(UUID格式)
|
||||
*/
|
||||
@GET("outside/prompt/{promptId}")
|
||||
suspend fun getPromptDetail(
|
||||
@Path("promptId") promptId: String
|
||||
): Response<DataContainer<Agent>>
|
||||
|
||||
/**
|
||||
* 更新Prompt(支持ID或OpenId)
|
||||
* @param promptId Prompt ID或OpenId(UUID格式)
|
||||
* @param avatar 头像文件(可选)
|
||||
* @param title 标题(可选)
|
||||
* @param desc 描述(可选)
|
||||
* @param value 内容(可选)
|
||||
* @param isPublic 是否公开(可选)
|
||||
*/
|
||||
@Multipart
|
||||
@PATCH("outside/prompt/{promptId}")
|
||||
suspend fun updatePrompt(
|
||||
@Path("promptId") promptId: String,
|
||||
@Part avatar: MultipartBody.Part?,
|
||||
@Part("title") title: RequestBody?,
|
||||
@Part("desc") desc: RequestBody?,
|
||||
@Part("value") value: RequestBody?,
|
||||
@Part("public") isPublic: RequestBody?,
|
||||
): Response<DataContainer<Agent>>
|
||||
|
||||
// ========== Agent Rule API ==========
|
||||
|
||||
/**
|
||||
@@ -1593,5 +2010,217 @@ interface RaveNowAPI {
|
||||
|
||||
|
||||
|
||||
@GET("recommendations")
|
||||
suspend fun getRecommendations(
|
||||
@Query("pool") pool: String? = null,
|
||||
@Query("count") count: Int? = null,
|
||||
@Query("lang") lang: String? = null,
|
||||
@Query("promptReplaceTrans") promptReplaceTrans: Boolean? = null
|
||||
): Response<RecommendationsResponse>
|
||||
|
||||
// ========== Points API ==========
|
||||
|
||||
/**
|
||||
* 获取我的积分余额
|
||||
*
|
||||
* 功能说明:
|
||||
* - 获取当前登录用户的积分余额
|
||||
* - 可选返回累计获得和累计消费统计信息
|
||||
*
|
||||
* @param includeStatistics 是否包含统计信息(累计获得和累计消费),默认 true
|
||||
*
|
||||
* @return 返回积分余额和统计信息
|
||||
*
|
||||
* 响应数据说明:
|
||||
* - balance: 当前积分余额
|
||||
* - totalEarned: 累计获得积分(仅当 includeStatistics 为 true 时返回)
|
||||
* - totalSpent: 累计消费积分(仅当 includeStatistics 为 true 时返回)
|
||||
*
|
||||
* 示例:
|
||||
* ```kotlin
|
||||
* // 获取包含统计信息的积分余额
|
||||
* val response1 = api.getMyPointsBalance()
|
||||
*
|
||||
* // 仅获取当前余额
|
||||
* val response2 = api.getMyPointsBalance(includeStatistics = false)
|
||||
* ```
|
||||
*/
|
||||
@GET("account/my/points")
|
||||
suspend fun getMyPointsBalance(
|
||||
@Query("includeStatistics") includeStatistics: Boolean? = null
|
||||
): Response<DataContainer<PointsBalance>>
|
||||
|
||||
/**
|
||||
* 获取我的积分变更日志
|
||||
*
|
||||
* 功能说明:
|
||||
* - 获取当前登录用户的积分变更日志列表
|
||||
* - 支持分页、时间范围筛选和变更类型筛选
|
||||
*
|
||||
* @param page 页码,默认 1
|
||||
* @param pageSize 每页数量,默认 20
|
||||
* @param changeType 变更类型筛选(add: 增加, subtract: 减少, adjust: 调整)
|
||||
* @param startTime 开始时间,格式:YYYY-MM-DD
|
||||
* @param endTime 结束时间,格式:YYYY-MM-DD
|
||||
*
|
||||
* @return 返回分页的积分变更日志列表
|
||||
*
|
||||
* 响应数据说明:
|
||||
* - list: 积分变更日志列表
|
||||
* - total: 总记录数
|
||||
* - page: 当前页码
|
||||
* - pageSize: 每页数量
|
||||
*
|
||||
* 示例:
|
||||
* ```kotlin
|
||||
* // 获取最近的积分变更日志
|
||||
* val response1 = api.getMyPointsChangeLogs(page = 1, pageSize = 20)
|
||||
*
|
||||
* // 筛选积分增加记录
|
||||
* val response2 = api.getMyPointsChangeLogs(changeType = "add")
|
||||
*
|
||||
* // 查询指定时间范围的记录
|
||||
* val response3 = api.getMyPointsChangeLogs(
|
||||
* startTime = "2024-01-01",
|
||||
* endTime = "2024-01-31"
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
@GET("account/my/points/logs")
|
||||
suspend fun getMyPointsChangeLogs(
|
||||
@Query("page") page: Int = 1,
|
||||
@Query("pageSize") pageSize: Int = 20,
|
||||
@Query("changeType") changeType: String? = null,
|
||||
@Query("startTime") startTime: String? = null,
|
||||
@Query("endTime") endTime: String? = null
|
||||
): Response<PointsChangeLogsResponse>
|
||||
|
||||
// ========== Room Member Management API ==========
|
||||
|
||||
/**
|
||||
* 添加用户到房间
|
||||
*
|
||||
* 功能说明:
|
||||
* - 向房间批量添加用户
|
||||
* - 支持通过房间ID或TRTC群组ID添加
|
||||
* - 只有房间创建者可以使用此接口
|
||||
* - 添加数量受房间容量限制
|
||||
* - 成功添加后会自动扣除房间创建者的积分用于扩容
|
||||
*
|
||||
* @param body 添加用户请求体
|
||||
* - roomId: 房间ID(与 trtcId 二选一)
|
||||
* - trtcId: TRTC群组ID(与 roomId 二选一)
|
||||
* - openIds: 要添加的用户OpenID列表
|
||||
*
|
||||
* @return 成功时返回操作结果,包含成功、失败、跳过的用户列表
|
||||
*
|
||||
* 示例:
|
||||
* ```kotlin
|
||||
* val request = AddUserToRoomRequestBody(
|
||||
* roomId = 123,
|
||||
* openIds = listOf("user_openid_1", "user_openid_2")
|
||||
* )
|
||||
* val response = api.addUserToRoom(request)
|
||||
* ```
|
||||
*/
|
||||
@POST("outside/rooms/add-user")
|
||||
suspend fun addUserToRoom(
|
||||
@Body body: AddUserToRoomRequestBody
|
||||
): Response<DataContainer<AddUserToRoomResponse>>
|
||||
|
||||
/**
|
||||
* 添加智能体到房间
|
||||
*
|
||||
* 功能说明:
|
||||
* - 向房间批量添加智能体(Agent)
|
||||
* - 支持通过房间ID或TRTC群组ID添加
|
||||
* - 房间创建者可以使用此接口
|
||||
* - 当容量不足时会自动扩容并扣除房间创建者的积分
|
||||
* - 如果积分不足以支付扩容费用,将返回错误
|
||||
*
|
||||
* @param body 添加智能体请求体
|
||||
* - roomId: 房间ID(与 trtcId 二选一)
|
||||
* - trtcId: TRTC群组ID(与 roomId 二选一)
|
||||
* - agentOpenIds: 要添加的智能体OpenID列表
|
||||
*
|
||||
* @return 成功时返回操作结果,包含成功、失败、跳过的智能体列表
|
||||
*
|
||||
* 示例:
|
||||
* ```kotlin
|
||||
* val request = AddAgentToRoomRequestBody(
|
||||
* roomId = 123,
|
||||
* agentOpenIds = listOf("agent_openid_1", "agent_openid_2")
|
||||
* )
|
||||
* val response = api.addAgentToRoom(request)
|
||||
* ```
|
||||
*/
|
||||
@POST("outside/rooms/add-agent")
|
||||
suspend fun addAgentToRoom(
|
||||
@Body body: AddAgentToRoomRequestBody
|
||||
): Response<DataContainer<AddAgentToRoomResponse>>
|
||||
|
||||
/**
|
||||
* 从房间移除智能体
|
||||
*
|
||||
* 功能说明:
|
||||
* - 从房间批量移除智能体(Agent)
|
||||
* - 支持通过房间ID或TRTC群组ID操作
|
||||
* - 只有房间创建者可以使用此接口
|
||||
* - 单聊房间不支持移除智能体
|
||||
* - 移除操作会同步到OpenIM系统
|
||||
*
|
||||
* @param body 移除智能体请求体
|
||||
* - roomId: 房间ID(与 trtcId 二选一)
|
||||
* - trtcId: TRTC群组ID(与 roomId 二选一)
|
||||
* - agentOpenIds: 要移除的智能体OpenID列表
|
||||
*
|
||||
* @return 成功时返回操作结果,包含成功、失败、跳过的智能体列表
|
||||
*
|
||||
* 示例:
|
||||
* ```kotlin
|
||||
* val request = RemoveAgentFromRoomRequestBody(
|
||||
* roomId = 123,
|
||||
* agentOpenIds = listOf("agent_openid_1", "agent_openid_2")
|
||||
* )
|
||||
* val response = api.removeAgentFromRoom(request)
|
||||
* ```
|
||||
*/
|
||||
@POST("outside/rooms/remove-agent")
|
||||
suspend fun removeAgentFromRoom(
|
||||
@Body body: RemoveAgentFromRoomRequestBody
|
||||
): Response<DataContainer<RemoveAgentFromRoomResult>>
|
||||
|
||||
/**
|
||||
* 从房间移除用户
|
||||
*
|
||||
* 功能说明:
|
||||
* - 从房间批量移除用户
|
||||
* - 支持通过房间ID或TRTC群组ID操作
|
||||
* - 只有房间创建者可以使用此接口
|
||||
* - 单聊房间不支持移除用户
|
||||
* - 群主不能移除自己
|
||||
* - 移除操作会同步到OpenIM系统
|
||||
*
|
||||
* @param body 移除用户请求体
|
||||
* - roomId: 房间ID(与 trtcId 二选一)
|
||||
* - trtcId: TRTC群组ID(与 roomId 二选一)
|
||||
* - userIds: 要移除的用户ID列表(OpenID)
|
||||
*
|
||||
* @return 成功时返回操作结果,包含成功、失败、跳过的用户列表
|
||||
*
|
||||
* 示例:
|
||||
* ```kotlin
|
||||
* val request = RemoveUserFromRoomRequestBody(
|
||||
* roomId = 123,
|
||||
* userIds = listOf("user_openid_1", "user_openid_2")
|
||||
* )
|
||||
* val response = api.removeUserFromRoom(request)
|
||||
* ```
|
||||
*/
|
||||
@POST("outside/rooms/remove-user")
|
||||
suspend fun removeUserFromRoom(
|
||||
@Body body: RemoveUserFromRoomRequestBody
|
||||
): Response<DataContainer<RemoveUserFromRoomResult>>
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
* 用户点赞消息分页数据加载器
|
||||
*/
|
||||
|
||||
@@ -90,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,
|
||||
@@ -103,6 +132,16 @@ class AgentRemoteDataSource(
|
||||
authorId = authorId
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun searchAgentByTitle(
|
||||
pageNumber: Int,
|
||||
title: String
|
||||
): ListContainer<AgentEntity>? {
|
||||
return agentService.searchAgentByTitle(
|
||||
pageNumber = pageNumber,
|
||||
title = title
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class AgentServiceImpl() : AgentService {
|
||||
@@ -118,6 +157,17 @@ class AgentServiceImpl() : AgentService {
|
||||
authorId = authorId
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun searchAgentByTitle(
|
||||
pageNumber: Int,
|
||||
pageSize: Int,
|
||||
title: String
|
||||
): ListContainer<AgentEntity>? {
|
||||
return agentBackend.searchAgentByTitle(
|
||||
pageNumber = pageNumber,
|
||||
title = title
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class AgentBackend {
|
||||
@@ -175,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(
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.icu.util.Calendar
|
||||
import com.aiosman.ravenow.ConstVars
|
||||
import com.aiosman.ravenow.exp.formatChatTime
|
||||
import com.aiosman.ravenow.utils.NotificationMessageHelper
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import io.openim.android.sdk.models.Message
|
||||
import io.openim.android.sdk.models.PictureElem
|
||||
@@ -21,7 +22,8 @@ data class ChatItem(
|
||||
val textDisplay: String = "",
|
||||
val msgId: String, // Add this property
|
||||
var showTimestamp: Boolean = false,
|
||||
var showTimeDivider: Boolean = false
|
||||
var showTimeDivider: Boolean = false,
|
||||
val isNotification: Boolean = false // 标识是否为通知类型消息
|
||||
) {
|
||||
companion object {
|
||||
// OpenIM 消息类型常量
|
||||
@@ -36,6 +38,32 @@ data class ChatItem(
|
||||
val calendar = Calendar.getInstance()
|
||||
calendar.timeInMillis = timestamp
|
||||
|
||||
// 检查是否为通知类型消息
|
||||
// 1. 检查消息类型是否为通知类型
|
||||
// 2. 检查发送者ID是否为系统账户(如 "imAdmin"、"administrator" 等)
|
||||
val sendID = message.sendID ?: ""
|
||||
val isSystemAccount = sendID == "imAdmin" || sendID == "administrator" || sendID.isEmpty()
|
||||
val isNotificationType = OpenIMMessageType.isNotification(message.contentType)
|
||||
val isNotification = isNotificationType || isSystemAccount
|
||||
|
||||
// 如果是通知类型消息,使用特殊处理
|
||||
if (isNotification) {
|
||||
val notificationText = NotificationMessageHelper.getNotificationText(message)
|
||||
return ChatItem(
|
||||
message = notificationText,
|
||||
avatar = "", // 通知消息不显示头像
|
||||
time = calendar.time.formatChatTime(context),
|
||||
userId = sendID.ifEmpty { "system" },
|
||||
nickname = "", // 通知消息不显示昵称
|
||||
timestamp = timestamp,
|
||||
imageList = emptyList<PictureInfo>().toMutableList(),
|
||||
messageType = message.contentType,
|
||||
textDisplay = notificationText,
|
||||
msgId = message.clientMsgID,
|
||||
isNotification = true
|
||||
)
|
||||
}
|
||||
|
||||
var faceAvatar = avatar
|
||||
if (faceAvatar == null) {
|
||||
faceAvatar = "${ConstVars.BASE_SERVER}${message.senderFaceUrl}"
|
||||
@@ -62,7 +90,8 @@ data class ChatItem(
|
||||
).toMutableList(),
|
||||
messageType = MESSAGE_TYPE_IMAGE,
|
||||
textDisplay = "Image",
|
||||
msgId = message.clientMsgID
|
||||
msgId = message.clientMsgID,
|
||||
isNotification = false
|
||||
)
|
||||
}
|
||||
return null
|
||||
@@ -79,7 +108,8 @@ data class ChatItem(
|
||||
imageList = emptyList<PictureInfo>().toMutableList(),
|
||||
messageType = MESSAGE_TYPE_TEXT,
|
||||
textDisplay = message.textElem?.content ?: "Unsupported message type",
|
||||
msgId = message.clientMsgID
|
||||
msgId = message.clientMsgID,
|
||||
isNotification = false
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
// 宽度
|
||||
@@ -309,7 +327,7 @@ data class MomentEntity(
|
||||
// 是否关注
|
||||
val followStatus: Boolean,
|
||||
// 动态内容
|
||||
val momentTextContent: String,
|
||||
val momentTextContent: String?,
|
||||
// 动态图片
|
||||
@DrawableRes val momentPicture: Int,
|
||||
// 点赞数
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.aiosman.ravenow.entity
|
||||
|
||||
/**
|
||||
* OpenIM 消息类型常量
|
||||
* 对应 OpenIM SDK 的 ContentType 枚举值
|
||||
*/
|
||||
object OpenIMMessageType {
|
||||
|
||||
// ========== 基础消息类型 ==========
|
||||
|
||||
/** 文本消息 */
|
||||
const val TEXT = 101
|
||||
|
||||
/** 图片消息 */
|
||||
const val IMAGE = 102
|
||||
|
||||
/** 语音消息 */
|
||||
const val VOICE = 103
|
||||
|
||||
/** 视频消息 */
|
||||
const val VIDEO = 104
|
||||
|
||||
/** 文件消息 */
|
||||
const val FILE = 105
|
||||
|
||||
/** @消息 */
|
||||
const val AT = 106
|
||||
|
||||
/** 合并消息 */
|
||||
const val MERGE = 107
|
||||
|
||||
/** 名片消息 */
|
||||
const val CARD = 108
|
||||
|
||||
/** 位置消息 */
|
||||
const val LOCATION = 109
|
||||
|
||||
/** 自定义消息 */
|
||||
const val CUSTOM = 110
|
||||
|
||||
/** 输入状态 */
|
||||
const val TYPING = 113
|
||||
|
||||
/** 引用消息 */
|
||||
const val QUOTE = 114
|
||||
|
||||
/** 表情消息 */
|
||||
const val EMOJI = 115
|
||||
|
||||
// ========== 通知消息类型 ==========
|
||||
|
||||
/** 双方成为好友通知 */
|
||||
const val FRIEND_ADDED = 1201
|
||||
|
||||
/** 系统通知 */
|
||||
const val SYSTEM_NOTIFICATION = 1400
|
||||
|
||||
// ========== 群通知消息类型 ==========
|
||||
|
||||
/** 群创建通知 */
|
||||
const val GROUP_CREATED = 1501
|
||||
|
||||
/** 群信息改变通知 */
|
||||
const val GROUP_INFO_CHANGED = 1502
|
||||
|
||||
/** 群成员退出通知 */
|
||||
const val GROUP_MEMBER_QUIT = 1504
|
||||
|
||||
/** 群主更换通知 */
|
||||
const val GROUP_OWNER_CHANGED = 1507
|
||||
|
||||
/** 群成员被踢通知 */
|
||||
const val GROUP_MEMBER_KICKED = 1508
|
||||
|
||||
/** 邀请群成员通知 */
|
||||
const val GROUP_MEMBER_INVITED = 1509
|
||||
|
||||
/** 群成员进群通知 */
|
||||
const val GROUP_MEMBER_JOINED = 1510
|
||||
|
||||
/** 解散群通知 */
|
||||
const val GROUP_DISMISSED = 1511
|
||||
|
||||
/** 群成员禁言通知 */
|
||||
const val GROUP_MEMBER_MUTED = 1512
|
||||
|
||||
/** 取消群成员禁言通知 */
|
||||
const val GROUP_MEMBER_UNMUTED = 1513
|
||||
|
||||
/** 群禁言通知 */
|
||||
const val GROUP_MUTED = 1514
|
||||
|
||||
/** 取消群禁言通知 */
|
||||
const val GROUP_UNMUTED = 1515
|
||||
|
||||
/** 群公告改变通知 */
|
||||
const val GROUP_ANNOUNCEMENT_CHANGED = 1519
|
||||
|
||||
/** 群名称改变通知 */
|
||||
const val GROUP_NAME_CHANGED = 1520
|
||||
|
||||
// ========== 其他通知类型 ==========
|
||||
|
||||
/** 阅后即焚开启或关闭通知 */
|
||||
const val SNAPCHAT_TOGGLE = 1701
|
||||
|
||||
/** 撤回消息通知 */
|
||||
const val MESSAGE_REVOKED = 2101
|
||||
|
||||
/**
|
||||
* 获取消息类型的描述
|
||||
*/
|
||||
fun getDescription(type: Int): String {
|
||||
return when (type) {
|
||||
TEXT -> "文本消息"
|
||||
IMAGE -> "图片消息"
|
||||
VOICE -> "语音消息"
|
||||
VIDEO -> "视频消息"
|
||||
FILE -> "文件消息"
|
||||
AT -> "@消息"
|
||||
MERGE -> "合并消息"
|
||||
CARD -> "名片消息"
|
||||
LOCATION -> "位置消息"
|
||||
CUSTOM -> "自定义消息"
|
||||
TYPING -> "输入状态"
|
||||
QUOTE -> "引用消息"
|
||||
EMOJI -> "表情消息"
|
||||
FRIEND_ADDED -> "双方成为好友通知"
|
||||
SYSTEM_NOTIFICATION -> "系统通知"
|
||||
GROUP_CREATED -> "群创建通知"
|
||||
GROUP_INFO_CHANGED -> "群信息改变通知"
|
||||
GROUP_MEMBER_QUIT -> "群成员退出通知"
|
||||
GROUP_OWNER_CHANGED -> "群主更换通知"
|
||||
GROUP_MEMBER_KICKED -> "群成员被踢通知"
|
||||
GROUP_MEMBER_INVITED -> "邀请群成员通知"
|
||||
GROUP_MEMBER_JOINED -> "群成员进群通知"
|
||||
GROUP_DISMISSED -> "解散群通知"
|
||||
GROUP_MEMBER_MUTED -> "群成员禁言通知"
|
||||
GROUP_MEMBER_UNMUTED -> "取消群成员禁言通知"
|
||||
GROUP_MUTED -> "群禁言通知"
|
||||
GROUP_UNMUTED -> "取消群禁言通知"
|
||||
GROUP_ANNOUNCEMENT_CHANGED -> "群公告改变通知"
|
||||
GROUP_NAME_CHANGED -> "群名称改变通知"
|
||||
SNAPCHAT_TOGGLE -> "阅后即焚开启或关闭通知"
|
||||
MESSAGE_REVOKED -> "撤回消息通知"
|
||||
else -> "未知消息类型($type)"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为通知类型消息
|
||||
*/
|
||||
fun isNotification(type: Int): Boolean {
|
||||
return type in listOf(
|
||||
FRIEND_ADDED,
|
||||
SYSTEM_NOTIFICATION,
|
||||
GROUP_CREATED,
|
||||
GROUP_INFO_CHANGED,
|
||||
GROUP_MEMBER_QUIT,
|
||||
GROUP_OWNER_CHANGED,
|
||||
GROUP_MEMBER_KICKED,
|
||||
GROUP_MEMBER_INVITED,
|
||||
GROUP_MEMBER_JOINED,
|
||||
GROUP_DISMISSED,
|
||||
GROUP_MEMBER_MUTED,
|
||||
GROUP_MEMBER_UNMUTED,
|
||||
GROUP_MUTED,
|
||||
GROUP_UNMUTED,
|
||||
GROUP_ANNOUNCEMENT_CHANGED,
|
||||
GROUP_NAME_CHANGED,
|
||||
SNAPCHAT_TOGGLE,
|
||||
MESSAGE_REVOKED
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为群通知类型消息
|
||||
*/
|
||||
fun isGroupNotification(type: Int): Boolean {
|
||||
return type in listOf(
|
||||
GROUP_CREATED,
|
||||
GROUP_INFO_CHANGED,
|
||||
GROUP_MEMBER_QUIT,
|
||||
GROUP_OWNER_CHANGED,
|
||||
GROUP_MEMBER_KICKED,
|
||||
GROUP_MEMBER_INVITED,
|
||||
GROUP_MEMBER_JOINED,
|
||||
GROUP_DISMISSED,
|
||||
GROUP_MEMBER_MUTED,
|
||||
GROUP_MEMBER_UNMUTED,
|
||||
GROUP_MUTED,
|
||||
GROUP_UNMUTED,
|
||||
GROUP_ANNOUNCEMENT_CHANGED,
|
||||
GROUP_NAME_CHANGED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
package com.aiosman.ravenow.entity
|
||||
|
||||
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 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,
|
||||
@@ -21,9 +36,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>,
|
||||
)
|
||||
|
||||
@@ -58,7 +80,12 @@ data class ProfileEntity(
|
||||
data class RoomRuleCreatorEntity(
|
||||
val id: Int,
|
||||
val nickname: String,
|
||||
val avatar: String
|
||||
val avatar: String,
|
||||
val avatarMedium: String? = null,
|
||||
val avatarLarge: String? = null,
|
||||
val avatarDirectUrl: String? = null,
|
||||
val avatarMediumDirectUrl: String? = null,
|
||||
val avatarLargeDirectUrl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -86,6 +113,128 @@ data class RoomRuleQuotaEntity(
|
||||
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,
|
||||
@@ -113,3 +262,58 @@ class RoomLoader : DataLoader<AgentEntity,AgentLoaderExtraArgs>() {
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 房间远程数据源
|
||||
*/
|
||||
class RoomRemoteDataSource {
|
||||
suspend fun searchRooms(
|
||||
pageNumber: Int,
|
||||
pageSize: Int = 20,
|
||||
search: String
|
||||
): ListContainer<RoomEntity>? {
|
||||
val resp = ApiClient.api.getRooms(
|
||||
page = pageNumber,
|
||||
pageSize = pageSize,
|
||||
search = search,
|
||||
roomType = "public" // 搜索时只显示公有房间
|
||||
)
|
||||
val body = resp.body() ?: return null
|
||||
return ListContainer(
|
||||
total = body.total,
|
||||
page = pageNumber,
|
||||
pageSize = pageSize,
|
||||
list = body.list.map { it.toRoomtEntity() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 房间搜索分页加载器
|
||||
*/
|
||||
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
|
||||
)
|
||||
LoadResult.Page(
|
||||
data = rooms?.list ?: listOf(),
|
||||
prevKey = if (currentPage == 1) null else currentPage - 1,
|
||||
nextKey = if (rooms?.list?.isNotEmpty() == true) currentPage + 1 else null
|
||||
)
|
||||
} catch (exception: IOException) {
|
||||
LoadResult.Error(exception)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Int, RoomEntity>): Int? {
|
||||
return state.anchorPosition
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -35,12 +35,14 @@ 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
|
||||
@@ -59,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
|
||||
@@ -73,8 +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,
|
||||
@@ -119,11 +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")
|
||||
}
|
||||
|
||||
|
||||
@@ -337,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 = {
|
||||
@@ -422,6 +441,9 @@ fun NavigationController(
|
||||
composable(route = NavigationRoute.ChangePasswordScreen.route) {
|
||||
ChangePasswordScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.BlockedUsersScreen.route) {
|
||||
BlockedUsersScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.RemoveAccountScreen.route) {
|
||||
RemoveAccountScreen()
|
||||
}
|
||||
@@ -447,6 +469,9 @@ fun NavigationController(
|
||||
SearchScreen()
|
||||
}
|
||||
}
|
||||
composable(route = NavigationRoute.ScanQr.route) {
|
||||
ScanQrScreen()
|
||||
}
|
||||
composable(
|
||||
route = NavigationRoute.FollowerList.route,
|
||||
arguments = listOf(navArgument("id") { type = NavType.IntType })
|
||||
@@ -613,6 +638,51 @@ 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,
|
||||
@@ -621,6 +691,18 @@ fun NavigationController(
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -701,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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,7 +31,12 @@ object AccountEditViewModel : ViewModel() {
|
||||
// 本地扩展字段
|
||||
var mbti by mutableStateOf<String?>(null)
|
||||
var zodiac by mutableStateOf<String?>(null)
|
||||
suspend fun reloadProfile(updateTrtcProfile:Boolean = false) {
|
||||
// 保存原始值,用于取消时恢复
|
||||
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 {
|
||||
@@ -41,13 +46,23 @@ 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,确保非空
|
||||
mbti = com.aiosman.ravenow.AppStore.getUserMbti(uid)
|
||||
zodiac = com.aiosman.ravenow.AppStore.getUserZodiac(uid)
|
||||
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(
|
||||
@@ -69,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
|
||||
}
|
||||
|
||||
|
||||
@@ -129,8 +147,8 @@ object AccountEditViewModel : ViewModel() {
|
||||
// 清除背景图状态
|
||||
bannerImageUrl = null
|
||||
bannerFile = null
|
||||
// 刷新用户资料
|
||||
reloadProfile()
|
||||
// 刷新用户资料,保存成功后清除裁剪的图片
|
||||
reloadProfile(clearCroppedBitmap = true)
|
||||
// 刷新个人资料页面的用户资料
|
||||
MyProfileViewModel.loadUserProfile()
|
||||
}
|
||||
|
||||
@@ -161,9 +161,7 @@ fun AccountSetting() {
|
||||
SecurityOptionItem(
|
||||
iconRes = R.mipmap.icons_block,
|
||||
label = stringResource(R.string.blocked_users),
|
||||
onClick = {
|
||||
// TODO: 导航到屏蔽用户页面
|
||||
}
|
||||
onClick = { navController.navigate(NavigationRoute.BlockedUsersScreen.route) }
|
||||
)
|
||||
|
||||
SecurityOptionItem(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,64 @@
|
||||
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.fillMaxSize
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.Icon
|
||||
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 androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
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.comment.NoticeScreenHeader
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
// 星座资源ID列表
|
||||
val ZODIAC_SIGN_RES_IDS = listOf(
|
||||
@@ -51,6 +76,27 @@ val ZODIAC_SIGN_RES_IDS = listOf(
|
||||
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
|
||||
@@ -72,37 +118,173 @@ fun findZodiacResId(storedZodiac: String?): Int? {
|
||||
return null
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ZodiacSelectScreen() {
|
||||
val navController = LocalNavController.current
|
||||
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)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(appColors.profileBackground)
|
||||
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()
|
||||
.padding(vertical = 16.dp)
|
||||
.fillMaxHeight(0.95f)
|
||||
.offset(y = offsetY)
|
||||
.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 8.dp
|
||||
)
|
||||
) {
|
||||
NoticeScreenHeader(
|
||||
title = stringResource(R.string.choose_zodiac),
|
||||
moreIcon = false
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
// 列表
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 8.dp)
|
||||
// 中间标题 - 绝对居中
|
||||
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)
|
||||
) {
|
||||
items(ZODIAC_SIGN_RES_IDS.size) { index ->
|
||||
val zodiacResId = ZODIAC_SIGN_RES_IDS[index]
|
||||
itemsIndexed(ZODIAC_SIGN_RES_IDS) { index, zodiacResId ->
|
||||
val zodiacText = stringResource(zodiacResId)
|
||||
ZodiacItem(
|
||||
zodiac = zodiacText,
|
||||
@@ -115,13 +297,26 @@ fun ZodiacSelectScreen() {
|
||||
AppState.UserId?.let { uid ->
|
||||
com.aiosman.ravenow.AppStore.setUserZodiac(uid, zodiacText)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保留原有的 ZodiacSelectScreen 用于导航路由(如果需要)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ZodiacSelectScreen() {
|
||||
val navController = com.aiosman.ravenow.LocalNavController.current
|
||||
ZodiacSelectBottomSheet(
|
||||
onClose = {
|
||||
navController.navigateUp()
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -132,41 +327,58 @@ fun ZodiacItem(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val appColors = LocalAppTheme.current
|
||||
val isDarkMode = AppState.darkMode
|
||||
|
||||
Box(
|
||||
// 卡片背景色:浅灰色 (250, 249, 251)
|
||||
// 暗色模式下使用比背景色更亮的颜色,以形成对比
|
||||
val cardBackgroundColor = if (isDarkMode) {
|
||||
Color(0xFF2A2A2A) // 比 secondaryBackground (0xFF1C1C1C) 更亮的灰色
|
||||
} else {
|
||||
Color(0xFFFAF9FB)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(if (isSelected) appColors.main.copy(alpha = 0.1f) else Color.White)
|
||||
.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(16.dp)
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp), // 减小垂直padding,确保文本不被遮挡
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
// 星座图标 - 使用对应星座的图片
|
||||
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 = 17.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = if (isSelected) appColors.main else appColors.text,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Selected",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = appColors.main
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,9 @@ 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
|
||||
|
||||
/**
|
||||
* 编辑用户资料界面
|
||||
@@ -171,6 +174,13 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
|
||||
model.reloadProfile()
|
||||
}
|
||||
|
||||
// 处理系统返回键
|
||||
BackHandler {
|
||||
// 用户未保存直接返回,恢复所有字段到原始值
|
||||
model.resetToOriginalData()
|
||||
navController.navigateUp()
|
||||
}
|
||||
|
||||
// 设置状态栏为透明,根据暗色模式决定图标颜色
|
||||
val systemUiController = rememberSystemUiController()
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -182,6 +192,9 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
|
||||
darkIcons = !AppState.darkMode, // 根据暗色模式决定图标颜色
|
||||
maskBoxBackgroundColor = Color.Transparent
|
||||
) {
|
||||
// 挂载星座选择弹窗
|
||||
ZodiacBottomSheetHost()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -291,6 +304,8 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.3f))
|
||||
.noRippleClickable {
|
||||
// 用户未保存直接返回,恢复所有字段到原始值
|
||||
model.resetToOriginalData()
|
||||
navController.navigateUp()
|
||||
}
|
||||
.align(Alignment.CenterStart),
|
||||
@@ -438,9 +453,7 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
|
||||
iconResDark = R.mipmap.frame_4, // 星座暗色模式图标
|
||||
iconResLight = R.mipmap.xingzuo, // 星座亮色模式图标
|
||||
onClick = {
|
||||
debouncedNavigation {
|
||||
navController.navigate(NavigationRoute.ZodiacSelect.route)
|
||||
}
|
||||
ZodiacSheetManager.open()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -34,8 +35,11 @@ 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
|
||||
@@ -565,10 +569,27 @@ fun AddAgentScreen() {
|
||||
.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(140.dp)
|
||||
.width(boxWidth)
|
||||
.height(40.dp)
|
||||
.shadow(
|
||||
elevation = 10.dp,
|
||||
@@ -616,7 +637,7 @@ fun AddAgentScreen() {
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.create_agent_auto),
|
||||
text = autoLabel,
|
||||
color = appColors.text,
|
||||
fontWeight = FontWeight.W600,
|
||||
fontSize = 14.sp
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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.im.OpenIMManager
|
||||
import io.openim.android.sdk.OpenIMClient
|
||||
import io.openim.android.sdk.enums.ConversationType
|
||||
import io.openim.android.sdk.enums.ViewType
|
||||
@@ -100,6 +101,10 @@ abstract class BaseChatViewModel : ViewModel() {
|
||||
|
||||
override fun onSuccess(data: ConversationInfo) {
|
||||
conversationID = data.conversationID
|
||||
// 如果是群组的会话id,应该加上s修正,不知道是不是openIm的bug
|
||||
if (data.conversationType == 2) {
|
||||
conversationID = "s${conversationID}"
|
||||
}
|
||||
Log.d(getLogTag(), "获取会话信息成功,conversationID: $conversationID")
|
||||
onSuccess?.invoke()
|
||||
}
|
||||
@@ -324,6 +329,7 @@ abstract class BaseChatViewModel : ViewModel() {
|
||||
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))
|
||||
|
||||
@@ -504,6 +504,12 @@ fun ChatAiOtherItem(item: ChatItem) {
|
||||
|
||||
@Composable
|
||||
fun ChatAiItem(item: ChatItem, currentUserId: String) {
|
||||
// 通知消息显示特殊布局
|
||||
if (item.isNotification) {
|
||||
NotificationMessageItem(item)
|
||||
return
|
||||
}
|
||||
|
||||
val isCurrentUser = item.userId == currentUserId
|
||||
if (isCurrentUser) {
|
||||
ChatAiSelfItem(item)
|
||||
|
||||
@@ -516,6 +516,12 @@ fun ChatOtherItem(item: ChatItem) {
|
||||
|
||||
@Composable
|
||||
fun ChatItem(item: ChatItem, currentUserId: String) {
|
||||
// 通知消息显示特殊布局
|
||||
if (item.isNotification) {
|
||||
NotificationMessageItem(item)
|
||||
return
|
||||
}
|
||||
|
||||
val isCurrentUser = item.userId == currentUserId
|
||||
if (isCurrentUser) {
|
||||
ChatSelfItem(item)
|
||||
|
||||
@@ -310,8 +310,13 @@ fun GroupChatScreen(groupId: String,name: String,avatar: String,) {
|
||||
)
|
||||
}
|
||||
// 获取上一个item的userId,用于判断是否显示头像和昵称
|
||||
// 通知消息不参与判断逻辑
|
||||
val previousItem = if (index < chatList.size - 1) chatList[index + 1] else null
|
||||
val showAvatarAndNickname = previousItem?.userId != item.userId
|
||||
val showAvatarAndNickname = if (item.isNotification || previousItem?.isNotification == true) {
|
||||
true // 通知消息前后都显示头像和昵称
|
||||
} else {
|
||||
previousItem?.userId != item.userId
|
||||
}
|
||||
GroupChatItem(
|
||||
item = item,
|
||||
currentUserId = viewModel.myProfile?.trtcUserId!!,
|
||||
@@ -528,14 +533,14 @@ fun GroupChatOtherItem(item: ChatItem, showAvatarAndNickname: Boolean = true) {
|
||||
|
||||
@Composable
|
||||
fun GroupChatItem(item: ChatItem, currentUserId: String, showAvatarAndNickname: Boolean = true) {
|
||||
val isCurrentUser = item.userId == currentUserId
|
||||
|
||||
// 管理员消息显示特殊布局
|
||||
if (item.userId == "administrator") {
|
||||
GroupChatAdminItem(item)
|
||||
// 通知消息显示特殊布局(包括系统账户发送的消息)
|
||||
if (item.isNotification) {
|
||||
NotificationMessageItem(item)
|
||||
return
|
||||
}
|
||||
|
||||
val isCurrentUser = item.userId == currentUserId
|
||||
|
||||
// 根据是否是当前用户显示不同样式
|
||||
when (item.userId) {
|
||||
currentUserId -> GroupChatSelfItem(item)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.aiosman.ravenow.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.entity.ChatItem
|
||||
|
||||
/**
|
||||
* 通知消息显示组件
|
||||
* 参考 iOS 的 tipsView 样式,用于显示通知类型的消息
|
||||
*/
|
||||
@Composable
|
||||
fun NotificationMessageItem(item: ChatItem) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
// 参考 iOS: HStack { Text(...) } .padding(.vertical, 8)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
// 参考 iOS: Text(tips)
|
||||
// .font(.caption2) - 12sp
|
||||
// .foregroundColor(Color.textMain) - 主文本颜色
|
||||
// .padding(.vertical, 8) .padding(.horizontal, 12)
|
||||
// .background(Color.background.opacity(0.2))
|
||||
// .background(.ultraThinMaterial.opacity(0.3))
|
||||
// .cornerRadius(12)
|
||||
Text(
|
||||
text = item.message,
|
||||
style = TextStyle(
|
||||
color = AppColors.text, // 使用主文本颜色,不是次要文本颜色
|
||||
fontSize = 12.sp, // .caption2 对应 12sp
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp)) // 圆角 12,不是 8
|
||||
.background(
|
||||
// 参考 iOS: Color.background.opacity(0.2) + .ultraThinMaterial.opacity(0.3)
|
||||
// Android 使用半透明背景色模拟毛玻璃效果
|
||||
AppColors.background.copy(alpha = 0.2f)
|
||||
)
|
||||
.padding(vertical = 8.dp, horizontal = 12.dp), // horizontal 12,不是 16
|
||||
maxLines = Int.MAX_VALUE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ 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
|
||||
@@ -40,6 +42,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
|
||||
@@ -88,6 +91,7 @@ fun CommentModalContent(
|
||||
}
|
||||
)
|
||||
val commentViewModel = model.commentsViewModel
|
||||
val AppColors = LocalAppTheme.current
|
||||
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
LaunchedEffect(Unit) {
|
||||
|
||||
@@ -99,10 +103,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 +131,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,6 +159,7 @@ fun CommentModalContent(
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -153,12 +171,13 @@ fun CommentModalContent(
|
||||
stringResource(R.string.comment),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = AppColors.text,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
color = Color(0xFFF7F7F7)
|
||||
color = AppColors.divider
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -170,7 +189,7 @@ fun CommentModalContent(
|
||||
Text(
|
||||
text = stringResource(id = R.string.comment_count, commentCount),
|
||||
fontSize = 14.sp,
|
||||
color = Color(0xff666666)
|
||||
color = AppColors.secondaryText
|
||||
)
|
||||
OrderSelectionComponent {
|
||||
commentViewModel.order = it
|
||||
@@ -180,12 +199,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 +213,9 @@ fun CommentModalContent(
|
||||
|
||||
},
|
||||
onReply = { parentComment, _, _, _ ->
|
||||
|
||||
// 设置回复的评论,这样 EditCommentBottomModal 会显示回复输入框
|
||||
// CommentContent 内部已经处理了游客模式检查,所以这里直接设置即可
|
||||
replyComment = parentComment
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(72.dp))
|
||||
@@ -205,9 +226,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 +249,13 @@ fun CommentModalContent(
|
||||
// 顶级评论
|
||||
commentViewModel.createComment(it)
|
||||
}
|
||||
// 评论创建成功后调用回调
|
||||
onCommentAdded()
|
||||
// 清空回复状态和自动聚焦状态
|
||||
replyComment = null
|
||||
shouldAutoFocus = false
|
||||
// 隐藏键盘
|
||||
softwareKeyboardController?.hide()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -124,27 +124,18 @@ fun CommentNoticeScreen() {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = painterResource(
|
||||
id = if(AppState.darkMode) R.mipmap.tietie_dark
|
||||
else R.mipmap.invalid_name_11),
|
||||
painter = painterResource(id = R.mipmap.invalid_name_5),
|
||||
contentDescription = "No Comment",
|
||||
modifier = Modifier
|
||||
.size(width = 181.dp, height = 153.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -64,6 +64,7 @@ fun AnimatedFavouriteIcon(
|
||||
modifier = modifier.graphicsLayer {
|
||||
rotationZ = animatableRotation.value
|
||||
},
|
||||
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
|
||||
@@ -59,10 +61,15 @@ fun EditCommentBottomModal(
|
||||
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val context = LocalContext.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(autoFocus) {
|
||||
if (autoFocus) {
|
||||
// 延迟一下,确保输入框已经渲染完成
|
||||
kotlinx.coroutines.delay(150)
|
||||
focusRequester.requestFocus()
|
||||
// 显示键盘
|
||||
keyboardController?.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +89,7 @@ fun EditCommentBottomModal(
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color.Gray.copy(alpha = 0.1f))
|
||||
.background(AppColors.inputBackground)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
@@ -99,7 +106,7 @@ fun EditCommentBottomModal(
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequester),
|
||||
textStyle = TextStyle(
|
||||
color = Color.Black,
|
||||
color = AppColors.text,
|
||||
fontWeight = FontWeight.Normal
|
||||
),
|
||||
decorationBox = { innerTextField ->
|
||||
@@ -110,7 +117,11 @@ fun EditCommentBottomModal(
|
||||
innerTextField()
|
||||
if (text.isEmpty()) {
|
||||
Text(
|
||||
text = if (replyComment == null) "快来互动吧..." else "回复@${replyComment.name}",
|
||||
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%透明度
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -111,6 +111,7 @@ fun MomentCard(
|
||||
) {
|
||||
MomentContentGroup(
|
||||
momentEntity = momentEntity,
|
||||
imageIndex = imageIndex,
|
||||
onPageChange = { index -> imageIndex = index }
|
||||
)
|
||||
}
|
||||
@@ -120,7 +121,6 @@ fun MomentCard(
|
||||
onLikeClick = onLikeClick,
|
||||
onAddComment = onAddComment,
|
||||
onFavoriteClick = onFavoriteClick,
|
||||
imageIndex = imageIndex,
|
||||
onCommentClick = {
|
||||
navController.navigateToPost(
|
||||
momentEntity.id,
|
||||
@@ -327,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)
|
||||
@@ -361,27 +366,88 @@ fun PostImageView(
|
||||
@Composable
|
||||
fun MomentContentGroup(
|
||||
momentEntity: MomentEntity,
|
||||
imageIndex: Int = 0,
|
||||
onPageChange: (Int) -> Unit = {}
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
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.momentTextContent.isNotEmpty()) {
|
||||
}
|
||||
}
|
||||
|
||||
// 图片指示器:显示在图片下方、文案上方
|
||||
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 = momentEntity.momentTextContent,
|
||||
text = com.aiosman.ravenow.utils.Utils.unescapeHtml(momentEntity.momentTextContent),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp),
|
||||
@@ -402,6 +468,7 @@ fun MomentOperateBtn(@DrawableRes icon: Int, count: String) {
|
||||
.size(width = 24.dp, height = 24.dp),
|
||||
painter = painterResource(id = icon),
|
||||
contentDescription = "",
|
||||
colorFilter = ColorFilter.tint(AppColors.text)
|
||||
)
|
||||
if (count.isNotEmpty()) {
|
||||
Text(
|
||||
@@ -437,8 +504,7 @@ fun MomentBottomOperateRowGroup(
|
||||
onAddComment: () -> Unit = {},
|
||||
onCommentClick: () -> Unit = {},
|
||||
onFavoriteClick: () -> Unit = {},
|
||||
momentEntity: MomentEntity,
|
||||
imageIndex: Int = 0
|
||||
momentEntity: MomentEntity
|
||||
) {
|
||||
val lastClickTime = remember { mutableStateOf(0L) }
|
||||
val clickDelay = 500L
|
||||
@@ -450,7 +516,6 @@ fun MomentBottomOperateRowGroup(
|
||||
sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
),
|
||||
windowInsets = WindowInsets(0),
|
||||
dragHandle = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -475,40 +540,12 @@ fun MomentBottomOperateRowGroup(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.padding(start = 16.dp, end = 0.dp)
|
||||
.padding(start = 16.dp, end = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
if (momentEntity.images.size > 1) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
.fillMaxHeight(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
@@ -524,7 +561,7 @@ fun MomentBottomOperateRowGroup(
|
||||
onLikeClick()
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
// 评论按钮
|
||||
Box(
|
||||
modifier = Modifier.noRippleClickable {
|
||||
@@ -540,7 +577,7 @@ fun MomentBottomOperateRowGroup(
|
||||
count = momentEntity.commentCount.toString()
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(28.dp))
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
// 转发按钮
|
||||
Box(
|
||||
modifier = Modifier.noRippleClickable {
|
||||
@@ -554,6 +591,7 @@ fun MomentBottomOperateRowGroup(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
// 收藏按钮
|
||||
MomentOperateBtn(count = momentEntity.favoriteCount.toString()) {
|
||||
AnimatedFavouriteIcon(
|
||||
@@ -565,7 +603,6 @@ fun MomentBottomOperateRowGroup(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,18 +6,25 @@ 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
|
||||
@@ -39,6 +46,7 @@ fun TabItem(
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.wrapContentWidth()
|
||||
.noRippleClickable { onClick() },
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
@@ -73,30 +81,47 @@ fun UnderlineTabItem(
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.noRippleClickable { onClick() },
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = if (isSelected) AppColors.text else AppColors.text.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(horizontal = 16.dp).padding(top = 13.dp)
|
||||
// 动画化字体大小和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.size(24.dp),
|
||||
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,6 +39,7 @@ 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
|
||||
@@ -59,17 +60,21 @@ fun TextInputField(
|
||||
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(
|
||||
text = it,
|
||||
color = LabelTextColor,
|
||||
color = labelColor,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(start = 8.dp, top = 8.dp, bottom = 8.dp)
|
||||
)
|
||||
@@ -121,13 +126,19 @@ fun TextInputField(
|
||||
if (text.isEmpty() && hint != null) {
|
||||
Text(
|
||||
text = hint,
|
||||
color = HintTextColor,
|
||||
color = hintColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W400
|
||||
)
|
||||
}
|
||||
}
|
||||
if (password) {
|
||||
// 暗色模式下图标为白色,否则使用默认颜色
|
||||
val iconColor = if (AppState.darkMode) {
|
||||
Color.White
|
||||
} else {
|
||||
PasswordIconColor
|
||||
}
|
||||
Image(
|
||||
painter = painterResource(
|
||||
id = if (showPassword) {
|
||||
@@ -142,7 +153,7 @@ fun TextInputField(
|
||||
.noRippleClickable {
|
||||
showPassword = !showPassword
|
||||
},
|
||||
colorFilter = ColorFilter.tint(PasswordIconColor)
|
||||
colorFilter = ColorFilter.tint(iconColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,13 @@ fun FormTextInput(
|
||||
.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
|
||||
}
|
||||
|
||||
@@ -68,6 +68,13 @@ fun FormTextInput2(
|
||||
.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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -134,8 +134,8 @@ fun FavouriteListPage() {
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(
|
||||
id = if (com.aiosman.ravenow.AppState.darkMode) R.mipmap.invalid_dark
|
||||
else R.mipmap.invalid_name_1),
|
||||
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)
|
||||
)
|
||||
@@ -155,6 +155,17 @@ fun FavouriteListPage() {
|
||||
) {
|
||||
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()
|
||||
@@ -169,7 +180,7 @@ fun FavouriteListPage() {
|
||||
}
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = momentItem.images[0].thumbnail,
|
||||
imageUrl = thumbnailUrl,
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
||||
@@ -125,15 +125,13 @@ fun FollowerListScreen(userId: Int) {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(
|
||||
id = if(AppState.darkMode) R.mipmap.frame_4
|
||||
else R.mipmap.invalid_name_8),
|
||||
painter = painterResource(id = R.mipmap.frame_31),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(181.dp, 153.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(9.dp)) // 调整间距为9dp
|
||||
androidx.compose.material.Text(
|
||||
text = stringResource(R.string.follower_empty_title),
|
||||
text = stringResource(R.string.awaiting_traveler),
|
||||
color = appColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
@@ -142,17 +140,6 @@ fun FollowerListScreen(userId: Int) {
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
androidx.compose.material.Text(
|
||||
text = stringResource(R.string.follower_empty_subtitle),
|
||||
color = appColors.text,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.W400,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -115,16 +115,14 @@ fun FollowerNoticeScreen() {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(
|
||||
id = if(AppState.darkMode) R.mipmap.frame_4
|
||||
else R.mipmap.invalid_name_8),
|
||||
painter = painterResource(id = R.mipmap.invalid_name_5),
|
||||
contentDescription = "No Followers",
|
||||
modifier = Modifier
|
||||
.size(width = 181.dp, height = 153.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(if (AppState.darkMode) 9.dp else 24.dp))
|
||||
Spacer(modifier = Modifier.height(9.dp))
|
||||
androidx.compose.material.Text(
|
||||
text = stringResource(R.string.follower_empty_title),
|
||||
text = stringResource(R.string.no_one_pinged_yet),
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
@@ -133,17 +131,6 @@ fun FollowerNoticeScreen() {
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
androidx.compose.material.Text(
|
||||
text = stringResource(R.string.follower_empty_subtitle),
|
||||
color = AppColors.text,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.W400,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}else{
|
||||
|
||||
@@ -127,15 +127,13 @@ fun FollowingListScreen(userId: Int) {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(
|
||||
id = if(AppState.darkMode) R.mipmap.frame_3
|
||||
else R.mipmap.invalid_name_9),
|
||||
painter = painterResource(id = R.mipmap.frame_31),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(181.dp, 153.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(9.dp)) // 调整间距为9dp
|
||||
androidx.compose.material.Text(
|
||||
text = stringResource(R.string.following_empty_title),
|
||||
text = stringResource(R.string.awaiting_traveler),
|
||||
color = appColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
@@ -144,17 +142,6 @@ fun FollowingListScreen(userId: Int) {
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
androidx.compose.material.Text(
|
||||
text = stringResource(R.string.following_empty_subtitle),
|
||||
color = appColors.secondaryText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.W400,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}else{
|
||||
|
||||
@@ -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
|
||||
@@ -91,6 +100,19 @@ fun CreateGroupChatScreen() {
|
||||
|
||||
val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
|
||||
// 获取费用和余额信息
|
||||
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 +389,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 +424,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 +472,44 @@ 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建群聊按钮 - 固定在底部
|
||||
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,10 +521,11 @@ fun CreateGroupChatScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = navigationBarPadding + 16.dp),
|
||||
.padding(start = 16.dp, end = 16.dp, top = buttonTopPadding, bottom = navigationBarPadding + 16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = AppColors.main,
|
||||
contentColor = AppColors.mainText,
|
||||
@@ -467,7 +537,7 @@ fun CreateGroupChatScreen() {
|
||||
) {
|
||||
if (CreateGroupChatViewModel.isLoading) {
|
||||
Text(
|
||||
text = "创建中...",
|
||||
text = stringResource(R.string.agent_createing),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
@@ -482,6 +552,38 @@ fun CreateGroupChatScreen() {
|
||||
|
||||
}
|
||||
|
||||
// 消费确认弹窗
|
||||
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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 居中显示的错误提示弹窗
|
||||
CreateGroupChatViewModel.errorMessage?.let { error ->
|
||||
Box(
|
||||
@@ -496,7 +598,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 +610,7 @@ fun CreateGroupChatScreen() {
|
||||
.fillMaxWidth(),
|
||||
color = Color.Red,
|
||||
fontSize = 14.sp,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -516,3 +618,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,8 +16,10 @@ 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
|
||||
@@ -35,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(
|
||||
@@ -55,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
|
||||
}
|
||||
@@ -75,6 +78,11 @@ object CreateGroupChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// 显示错误信息(公开方法)
|
||||
fun showError(message: String) {
|
||||
showToast(message)
|
||||
}
|
||||
|
||||
// 清除错误信息
|
||||
fun clearError() {
|
||||
errorMessage = null
|
||||
@@ -106,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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,12 @@ 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.PointsPaymentDialog
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.index.NavItem
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.ui.navigateToGroupMembers
|
||||
import com.aiosman.ravenow.ui.navigateToGroupProfileSettings
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -64,6 +67,8 @@ fun GroupChatInfoScreen(groupId: String) {
|
||||
var showAddMemoryDialog by remember { mutableStateOf(false) }
|
||||
var showMemoryManageDialog by remember { mutableStateOf(false) }
|
||||
var showVisibilityDialog by remember { mutableStateOf(false) }
|
||||
var showVisibilityPaymentDialog by remember { mutableStateOf(false) }
|
||||
var pendingIsPrivate by remember { mutableStateOf(false) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val memoryManageSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
@@ -285,27 +290,6 @@ fun GroupChatInfoScreen(groupId: String) {
|
||||
}
|
||||
}
|
||||
|
||||
// 解锁群扩展 横幅
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(AppColors.decentBackground.copy(alpha = 0.35f))
|
||||
.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_info_unlock_extension),
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
color = AppColors.main,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 群记忆 卡片
|
||||
item {
|
||||
@@ -395,14 +379,14 @@ fun GroupChatInfoScreen(groupId: String) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(13.dp))
|
||||
|
||||
// 设置聊天主题
|
||||
// 群资料设置
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.padding(12.dp)
|
||||
.noRippleClickable {
|
||||
// TODO: 实现设置聊天主题功能
|
||||
navController.navigateToGroupProfileSettings(groupId)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -455,6 +439,8 @@ fun GroupChatInfoScreen(groupId: String) {
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
// 未解锁时才显示"待解锁"
|
||||
if (viewModel.groupInfo?.privateFeePaid != true) {
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_info_locked),
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
@@ -462,6 +448,7 @@ fun GroupChatInfoScreen(groupId: String) {
|
||||
fontSize = 11.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
Image(
|
||||
painter = painterResource(R.drawable.rave_now_nav_right),
|
||||
modifier = Modifier.size(16.dp),
|
||||
@@ -476,7 +463,7 @@ fun GroupChatInfoScreen(groupId: String) {
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.padding(12.dp)
|
||||
.noRippleClickable {
|
||||
// 静态占位
|
||||
navController.navigateToGroupMembers(groupId)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -590,11 +577,50 @@ fun GroupChatInfoScreen(groupId: String) {
|
||||
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
|
||||
) {
|
||||
GroupVisibilityDialog(
|
||||
onDismiss = { showVisibilityDialog = false }
|
||||
viewModel = viewModel,
|
||||
onDismiss = { showVisibilityDialog = false },
|
||||
onConfirmPrivate = { isPrivate ->
|
||||
// 如果选择私密群组且未解锁,显示付费确认弹框
|
||||
if (isPrivate && (viewModel.groupInfo?.privateFeePaid != true)) {
|
||||
pendingIsPrivate = true
|
||||
showVisibilityDialog = false
|
||||
showVisibilityPaymentDialog = true
|
||||
} else {
|
||||
// 直接更新可见性
|
||||
viewModel.updateVisibility(isPrivate)
|
||||
showVisibilityDialog = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 付费确认弹框
|
||||
if (showVisibilityPaymentDialog) {
|
||||
val cost = viewModel.privateGroupCost ?: 0
|
||||
val currentBalance = viewModel.pointsBalance ?: 0
|
||||
val balanceAfterCost = (currentBalance - cost).coerceAtLeast(0)
|
||||
val isBalanceSufficient = currentBalance >= cost
|
||||
|
||||
PointsPaymentDialog(
|
||||
cost = cost,
|
||||
currentBalance = currentBalance,
|
||||
balanceAfterCost = balanceAfterCost,
|
||||
isBalanceSufficient = isBalanceSufficient,
|
||||
onConfirm = {
|
||||
// 确认支付,更新可见性
|
||||
viewModel.updateVisibility(pendingIsPrivate)
|
||||
showVisibilityPaymentDialog = false
|
||||
},
|
||||
onCancel = {
|
||||
showVisibilityPaymentDialog = false
|
||||
},
|
||||
title = stringResource(R.string.group_chat_info_private_group),
|
||||
description = stringResource(R.string.group_chat_info_private_group_desc),
|
||||
isProcessing = viewModel.isUpdatingVisibility
|
||||
)
|
||||
}
|
||||
|
||||
// 添加群记忆弹窗
|
||||
if (showAddMemoryDialog) {
|
||||
ModalBottomSheet(
|
||||
@@ -646,6 +672,10 @@ fun GroupChatInfoScreen(groupId: String) {
|
||||
},
|
||||
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
|
||||
) {
|
||||
// 立即展开到全屏,避免逐渐变高的动画
|
||||
LaunchedEffect(Unit) {
|
||||
memoryManageSheetState.expand()
|
||||
}
|
||||
GroupMemoryManageContent(
|
||||
groupId = groupId,
|
||||
viewModel = viewModel,
|
||||
@@ -878,8 +908,13 @@ fun AddGroupMemoryDialog(
|
||||
text = "⭐",
|
||||
fontSize = 13.sp
|
||||
)
|
||||
val memoryCost = viewModel.addMemoryCost
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_info_memory_cost),
|
||||
text = if (memoryCost != null && memoryCost > 0) {
|
||||
"添加记忆需消耗 ${memoryCost}派币"
|
||||
} else {
|
||||
stringResource(R.string.group_chat_info_memory_cost)
|
||||
},
|
||||
style = TextStyle(
|
||||
fontSize = 13.sp,
|
||||
color = Color.Black
|
||||
@@ -978,12 +1013,16 @@ fun AddGroupMemoryDialog(
|
||||
|
||||
@Composable
|
||||
fun GroupVisibilityDialog(
|
||||
onDismiss: () -> Unit
|
||||
viewModel: GroupChatInfoViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirmPrivate: (Boolean) -> Unit
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
var isPrivate by remember { mutableStateOf(false) }
|
||||
val balance = 482
|
||||
val unlockCost = 500
|
||||
val currentTrtcType = viewModel.groupInfo?.trtcType ?: "Public"
|
||||
val isPrivateFeePaid = viewModel.groupInfo?.privateFeePaid == true
|
||||
var isPrivate by remember { mutableStateOf(currentTrtcType == "Private") }
|
||||
val balance = viewModel.pointsBalance ?: 0
|
||||
val unlockCost = viewModel.privateGroupCost ?: 0
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -1038,14 +1077,15 @@ fun GroupVisibilityDialog(
|
||||
VisibilityOptionItem(
|
||||
title = stringResource(R.string.group_chat_info_private_group),
|
||||
desc = stringResource(R.string.group_chat_info_private_group_desc),
|
||||
badge = stringResource(R.string.group_chat_info_private_group_cost),
|
||||
badge = if (!isPrivateFeePaid && unlockCost > 0) "${unlockCost}派币" else null,
|
||||
selected = isPrivate,
|
||||
onClick = { isPrivate = true }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 余额与费用
|
||||
// 余额与费用(仅未解锁时显示)
|
||||
if (!isPrivateFeePaid) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -1068,6 +1108,7 @@ fun GroupVisibilityDialog(
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// 完成按钮
|
||||
Box(
|
||||
@@ -1084,7 +1125,9 @@ fun GroupVisibilityDialog(
|
||||
)
|
||||
)
|
||||
)
|
||||
.noRippleClickable { onDismiss() },
|
||||
.noRippleClickable {
|
||||
onConfirmPrivate(isPrivate)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
@@ -1093,6 +1136,8 @@ fun GroupVisibilityDialog(
|
||||
)
|
||||
}
|
||||
|
||||
// 仅未解锁时显示充值提示
|
||||
if (!isPrivateFeePaid) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_info_recharge_hint),
|
||||
@@ -1101,6 +1146,7 @@ fun GroupVisibilityDialog(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VisibilityOptionItem(
|
||||
|
||||
@@ -9,15 +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.api.AgentRule
|
||||
import com.aiosman.ravenow.data.api.AgentRuleQuota
|
||||
import com.aiosman.ravenow.data.api.CreateAgentRuleRequestBody
|
||||
import com.aiosman.ravenow.data.api.UpdateAgentRuleRequestBody
|
||||
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(
|
||||
@@ -34,30 +36,26 @@ class GroupChatInfoViewModel(
|
||||
val notificationStrategy get() = chatNotification?.strategy ?: "default"
|
||||
|
||||
// 记忆管理相关状态
|
||||
var memoryQuota by mutableStateOf<AgentRuleQuota?>(null)
|
||||
var memoryList by mutableStateOf<List<AgentRule>>(emptyList())
|
||||
var memoryQuota by mutableStateOf<RoomRuleQuotaEntity?>(null)
|
||||
var memoryList by mutableStateOf<List<RoomRuleEntity>>(emptyList())
|
||||
var isLoadingMemory by mutableStateOf(false)
|
||||
var memoryError by mutableStateOf<String?>(null)
|
||||
var promptOpenId 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()
|
||||
loadPromptOpenId()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取群聊中智能体的 OpenID
|
||||
*/
|
||||
private fun loadPromptOpenId() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = ApiClient.api.createGroupChatAi(trtcGroupId = groupId)
|
||||
val groupChatResponse = response.body()?.data
|
||||
val prompts = groupChatResponse?.prompts
|
||||
promptOpenId = prompts?.firstOrNull()?.openId
|
||||
} catch (e: Exception) {
|
||||
Log.e("GroupChatInfoViewModel", "获取智能体OpenID失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
loadVisibilityInfo()
|
||||
loadMemoryCost()
|
||||
}
|
||||
suspend fun updateNotificationStrategy(strategy: String) {
|
||||
val result = ChatState.updateChatNotification(groupId.hashCode(), strategy)
|
||||
@@ -93,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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,57 +106,27 @@ class GroupChatInfoViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加群记忆
|
||||
* 添加群记忆(房间规则)
|
||||
* @param memoryText 记忆内容
|
||||
* @param promptOpenId 智能体的 OpenID(可选),如果不提供则从群聊信息中获取
|
||||
*/
|
||||
fun addGroupMemory(memoryText: String, promptOpenId: String? = null) {
|
||||
fun addGroupMemory(memoryText: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
isAddingMemory = true
|
||||
addMemoryError = null
|
||||
addMemorySuccess = false
|
||||
|
||||
// 如果没有提供 promptOpenId,需要先获取群聊的智能体信息
|
||||
val openId = promptOpenId ?: run {
|
||||
// 通过 createGroupChatAi 接口获取群聊详细信息(包含 prompts)
|
||||
val response = ApiClient.api.createGroupChatAi(trtcGroupId = groupId)
|
||||
val groupChatResponse = response.body()?.data
|
||||
val prompts = groupChatResponse?.prompts
|
||||
|
||||
if (prompts.isNullOrEmpty()) {
|
||||
throw Exception("群聊中没有找到智能体,无法添加记忆")
|
||||
}
|
||||
|
||||
// 使用第一个智能体的 openId
|
||||
prompts.firstOrNull()?.openId
|
||||
?: throw Exception("无法获取智能体信息")
|
||||
}
|
||||
|
||||
if (openId.isBlank()) {
|
||||
throw Exception("智能体ID不能为空")
|
||||
}
|
||||
|
||||
// 创建智能体规则(群记忆)
|
||||
val requestBody = CreateAgentRuleRequestBody(
|
||||
// 使用房间规则接口创建群记忆
|
||||
roomService.createRoomRule(
|
||||
rule = memoryText,
|
||||
openId = openId
|
||||
trtcId = groupId
|
||||
)
|
||||
|
||||
val response = ApiClient.api.createAgentRule(requestBody)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
addMemorySuccess = true
|
||||
Log.d("GroupChatInfoViewModel", "群记忆添加成功")
|
||||
// 刷新记忆列表和配额
|
||||
loadMemoryQuota(openId)
|
||||
loadMemoryList(openId)
|
||||
} else {
|
||||
val errorResponse = parseErrorResponse(response.errorBody())
|
||||
val errorMessage = errorResponse?.toServiceException()?.message
|
||||
?: "添加群记忆失败: ${response.code()}"
|
||||
throw Exception(errorMessage)
|
||||
}
|
||||
loadMemoryQuota()
|
||||
loadMemoryList()
|
||||
} catch (e: Exception) {
|
||||
addMemoryError = e.message ?: "添加群记忆失败"
|
||||
Log.e("GroupChatInfoViewModel", "添加群记忆失败: ${e.message}", e)
|
||||
@@ -167,38 +137,16 @@ class GroupChatInfoViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取记忆配额信息
|
||||
* 获取记忆配额信息(房间规则配额)
|
||||
*/
|
||||
fun loadMemoryQuota(openId: String? = null) {
|
||||
fun loadMemoryQuota() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
isLoadingMemory = true
|
||||
memoryError = null
|
||||
|
||||
val targetOpenId = openId ?: promptOpenId
|
||||
if (targetOpenId.isNullOrBlank()) {
|
||||
// 如果还没有获取到 openId,先获取
|
||||
val response = ApiClient.api.createGroupChatAi(trtcGroupId = groupId)
|
||||
val groupChatResponse = response.body()?.data
|
||||
val prompts = groupChatResponse?.prompts
|
||||
val fetchedOpenId = prompts?.firstOrNull()?.openId
|
||||
?: throw Exception("无法获取智能体信息")
|
||||
|
||||
promptOpenId = fetchedOpenId
|
||||
val quotaResponse = ApiClient.api.getAgentRuleQuota(fetchedOpenId)
|
||||
if (quotaResponse.isSuccessful) {
|
||||
memoryQuota = quotaResponse.body()?.data
|
||||
} else {
|
||||
throw Exception("获取配额信息失败: ${quotaResponse.code()}")
|
||||
}
|
||||
} else {
|
||||
val quotaResponse = ApiClient.api.getAgentRuleQuota(targetOpenId)
|
||||
if (quotaResponse.isSuccessful) {
|
||||
memoryQuota = quotaResponse.body()?.data
|
||||
} else {
|
||||
throw Exception("获取配额信息失败: ${quotaResponse.code()}")
|
||||
}
|
||||
}
|
||||
// 使用房间规则接口获取配额
|
||||
memoryQuota = roomService.getRoomRuleQuota(trtcId = groupId)
|
||||
} catch (e: Exception) {
|
||||
memoryError = e.message ?: "获取配额信息失败"
|
||||
Log.e("GroupChatInfoViewModel", "获取配额信息失败: ${e.message}", e)
|
||||
@@ -209,38 +157,21 @@ class GroupChatInfoViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取记忆列表
|
||||
* 获取记忆列表(房间规则列表)
|
||||
*/
|
||||
fun loadMemoryList(openId: String? = null, page: Int = 1, pageSize: Int = 20) {
|
||||
fun loadMemoryList(page: Int = 1, pageSize: Int = 20) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
isLoadingMemory = true
|
||||
memoryError = null
|
||||
|
||||
val targetOpenId = openId ?: promptOpenId
|
||||
if (targetOpenId.isNullOrBlank()) {
|
||||
// 如果还没有获取到 openId,先获取
|
||||
val response = ApiClient.api.createGroupChatAi(trtcGroupId = groupId)
|
||||
val groupChatResponse = response.body()?.data
|
||||
val prompts = groupChatResponse?.prompts
|
||||
val fetchedOpenId = prompts?.firstOrNull()?.openId
|
||||
?: throw Exception("无法获取智能体信息")
|
||||
|
||||
promptOpenId = fetchedOpenId
|
||||
val listResponse = ApiClient.api.getAgentRuleList(fetchedOpenId, page = page, pageSize = pageSize)
|
||||
if (listResponse.isSuccessful) {
|
||||
memoryList = listResponse.body()?.data?.list ?: emptyList()
|
||||
} else {
|
||||
throw Exception("获取记忆列表失败: ${listResponse.code()}")
|
||||
}
|
||||
} else {
|
||||
val listResponse = ApiClient.api.getAgentRuleList(targetOpenId, page = page, pageSize = pageSize)
|
||||
if (listResponse.isSuccessful) {
|
||||
memoryList = listResponse.body()?.data?.list ?: emptyList()
|
||||
} else {
|
||||
throw Exception("获取记忆列表失败: ${listResponse.code()}")
|
||||
}
|
||||
}
|
||||
// 使用房间规则接口获取列表
|
||||
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)
|
||||
@@ -251,7 +182,7 @@ class GroupChatInfoViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除记忆
|
||||
* 删除记忆(房间规则)
|
||||
*/
|
||||
fun deleteMemory(ruleId: Int) {
|
||||
viewModelScope.launch {
|
||||
@@ -259,19 +190,12 @@ class GroupChatInfoViewModel(
|
||||
isLoadingMemory = true
|
||||
memoryError = null
|
||||
|
||||
val response = ApiClient.api.deleteAgentRule(ruleId)
|
||||
if (response.isSuccessful) {
|
||||
// 使用房间规则接口删除
|
||||
roomService.deleteRoomRule(ruleId)
|
||||
|
||||
// 刷新记忆列表和配额
|
||||
promptOpenId?.let { openId ->
|
||||
loadMemoryQuota(openId)
|
||||
loadMemoryList(openId)
|
||||
}
|
||||
} else {
|
||||
val errorResponse = parseErrorResponse(response.errorBody())
|
||||
val errorMessage = errorResponse?.toServiceException()?.message
|
||||
?: "删除记忆失败: ${response.code()}"
|
||||
throw Exception(errorMessage)
|
||||
}
|
||||
loadMemoryQuota()
|
||||
loadMemoryList()
|
||||
} catch (e: Exception) {
|
||||
memoryError = e.message ?: "删除记忆失败"
|
||||
Log.e("GroupChatInfoViewModel", "删除记忆失败: ${e.message}", e)
|
||||
@@ -282,34 +206,23 @@ class GroupChatInfoViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新记忆
|
||||
* 更新记忆(房间规则)
|
||||
*/
|
||||
fun updateMemory(ruleId: Int, newRuleText: String, targetOpenId: String? = null) {
|
||||
fun updateMemory(ruleId: Int, newRuleText: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
isLoadingMemory = true
|
||||
memoryError = null
|
||||
|
||||
val openId = targetOpenId ?: promptOpenId
|
||||
?: throw Exception("无法获取智能体ID")
|
||||
|
||||
val requestBody = UpdateAgentRuleRequestBody(
|
||||
// 使用房间规则接口更新
|
||||
roomService.updateRoomRule(
|
||||
id = ruleId,
|
||||
rule = newRuleText,
|
||||
openId = openId
|
||||
rule = newRuleText
|
||||
)
|
||||
|
||||
val response = ApiClient.api.updateAgentRule(requestBody)
|
||||
if (response.isSuccessful) {
|
||||
// 刷新记忆列表和配额
|
||||
loadMemoryQuota(openId)
|
||||
loadMemoryList(openId)
|
||||
} else {
|
||||
val errorResponse = parseErrorResponse(response.errorBody())
|
||||
val errorMessage = errorResponse?.toServiceException()?.message
|
||||
?: "更新记忆失败: ${response.code()}"
|
||||
throw Exception(errorMessage)
|
||||
}
|
||||
loadMemoryQuota()
|
||||
loadMemoryList()
|
||||
} catch (e: Exception) {
|
||||
memoryError = e.message ?: "更新记忆失败"
|
||||
Log.e("GroupChatInfoViewModel", "更新记忆失败: ${e.message}", e)
|
||||
@@ -318,4 +231,77 @@ class GroupChatInfoViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载群可见性相关信息(价格和积分余额)
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package com.aiosman.ravenow.ui.group
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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.draw.rotate
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.zIndex
|
||||
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.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.GroupMember
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.ui.navigateToAddGroupMember
|
||||
|
||||
@Composable
|
||||
fun GroupMembersScreen(groupId: String) {
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
val viewModel = viewModel<GroupMembersViewModel>(
|
||||
key = "GroupMembersViewModel_$groupId",
|
||||
factory = object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return GroupMembersViewModel(groupId) as T
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
var selectedMemberPosition by remember { mutableStateOf<Pair<Offset, Float>?>(null) }
|
||||
var selectedMemberId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(AppColors.background)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// 顶部导航栏
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(AppColors.background)
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp, horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = painterResource(R.drawable.rider_pro_back_icon),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(AppColors.text),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.noRippleClickable {
|
||||
navController.navigateUp()
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.group_members_title),
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
color = AppColors.text,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
androidx.compose.foundation.Image(
|
||||
painter = painterResource(R.drawable.rider_pro_add_other),
|
||||
contentDescription = stringResource(R.string.group_chat_info_add_member),
|
||||
colorFilter = ColorFilter.tint(AppColors.text),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.noRippleClickable {
|
||||
navController.navigateToAddGroupMember(groupId, viewModel.groupInfo?.groupName)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 内容区域
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
// 当前用户信息
|
||||
item {
|
||||
if (viewModel.currentUserMember != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.group_members_you),
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
color = AppColors.text.copy(alpha = 0.6f),
|
||||
fontSize = 13.sp
|
||||
),
|
||||
modifier = Modifier.padding(start = 12.dp, bottom = 8.dp)
|
||||
)
|
||||
CurrentUserItem(
|
||||
member = viewModel.currentUserMember!!,
|
||||
isAdmin = viewModel.groupInfo?.isCreator == true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// 群成员列表
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.group_members_list,
|
||||
viewModel.members.size + (if (viewModel.currentUserMember != null) 1 else 0)
|
||||
),
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
color = AppColors.text.copy(alpha = 0.6f),
|
||||
fontSize = 13.sp
|
||||
),
|
||||
modifier = Modifier.padding()
|
||||
)
|
||||
}
|
||||
|
||||
items(viewModel.members) { member ->
|
||||
MemberItem(
|
||||
member = member,
|
||||
isAdmin = viewModel.groupInfo?.isCreator == true,
|
||||
onSendMessage = {
|
||||
// TODO: 实现发消息功能
|
||||
},
|
||||
onMenuClick = { position, height ->
|
||||
if (selectedMemberId == member.userId) {
|
||||
selectedMemberId = null
|
||||
selectedMemberPosition = null
|
||||
} else {
|
||||
selectedMemberId = member.userId
|
||||
selectedMemberPosition = Pair(position, height)
|
||||
}
|
||||
},
|
||||
onDeleteMember = {
|
||||
viewModel.deleteMember(member.userId)
|
||||
selectedMemberId = null
|
||||
selectedMemberPosition = null
|
||||
},
|
||||
isMenuVisible = selectedMemberId == member.userId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗 - 显示在最上层
|
||||
selectedMemberPosition?.let { (position, height) ->
|
||||
val configuration = LocalConfiguration.current
|
||||
val density = LocalDensity.current
|
||||
val screenWidth = with(density) { configuration.screenWidthDp.dp }
|
||||
val horizontalOffset = (screenWidth - 238.dp) / 2
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(238.dp)
|
||||
.height(60.dp)
|
||||
.zIndex(1000f)
|
||||
.offset {
|
||||
val xOffset = with(density) { horizontalOffset.toPx().toInt() }
|
||||
val yOffset = (position.y + height + with(density) { 4.dp.toPx() }).toInt()
|
||||
IntOffset(x = xOffset, y = yOffset)
|
||||
}
|
||||
.shadow(
|
||||
elevation = 8.dp,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
spotColor = Color.Black.copy(alpha = 0.15f),
|
||||
ambientColor = Color.Black.copy(alpha = 0.08f)
|
||||
)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(
|
||||
brush = androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF262626).copy(alpha = 0.4f),
|
||||
Color(0xFFF5F5F5).copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.clickable {
|
||||
selectedMemberId?.let { memberId ->
|
||||
viewModel.deleteMember(memberId)
|
||||
selectedMemberId = null
|
||||
selectedMemberPosition = null
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.group_members_delete_member),
|
||||
color = Color(0xFFFF3B30),
|
||||
fontSize = 15.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CurrentUserItem(
|
||||
member: GroupMember,
|
||||
isAdmin: Boolean
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val context = LocalContext.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 头像
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (member.avatar.isNotEmpty()) Color.Transparent
|
||||
else AppColors.decentBackground
|
||||
)
|
||||
) {
|
||||
if (member.avatar.isNotEmpty()) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = member.avatar,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape),
|
||||
contentDescription = member.nickname
|
||||
)
|
||||
} else {
|
||||
// 默认头像占位
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFF0EEF1)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.default_avatar),
|
||||
contentDescription = null,
|
||||
tint = AppColors.text.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// 名称和身份
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = member.nickname,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
color = AppColors.text,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
)
|
||||
if (isAdmin) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.group_members_admin),
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
color = AppColors.text,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberItem(
|
||||
member: GroupMember,
|
||||
isAdmin: Boolean,
|
||||
onSendMessage: () -> Unit,
|
||||
onMenuClick: (Offset, Float) -> Unit,
|
||||
onDeleteMember: () -> Unit,
|
||||
isMenuVisible: Boolean
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
var itemPosition by remember { mutableStateOf(Offset.Zero) }
|
||||
var itemHeight by remember { mutableStateOf(0f) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
itemPosition = coordinates.positionInRoot()
|
||||
itemHeight = coordinates.size.height.toFloat()
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 头像
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (member.avatar.isNotEmpty()) Color.Transparent
|
||||
else AppColors.decentBackground
|
||||
)
|
||||
) {
|
||||
if (member.avatar.isNotEmpty()) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = member.avatar,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape),
|
||||
contentDescription = member.nickname
|
||||
)
|
||||
} else {
|
||||
// 默认头像占位
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFF0EEF1)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.mipmap.group_copy),
|
||||
contentDescription = "默认头像",
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// 名称
|
||||
Text(
|
||||
text = member.nickname,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
color = AppColors.text,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// 菜单按钮
|
||||
|
||||
IconButton(
|
||||
onClick = { onMenuClick(itemPosition, itemHeight) },
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = painterResource(R.drawable.rider_pro_more_horizon),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(AppColors.text),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
|
||||
// 发消息按钮 - 右对齐到与Switch相同的位置
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(AppColors.decentBackground)
|
||||
.clickable { onSendMessage() }
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.group_members_send_message),
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
color = AppColors.text,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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.api.ApiClient
|
||||
import com.aiosman.ravenow.entity.GroupInfo
|
||||
import com.aiosman.ravenow.entity.GroupMember
|
||||
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GroupMembersViewModel(
|
||||
private val groupId: String
|
||||
) : ViewModel() {
|
||||
|
||||
var groupInfo by mutableStateOf<GroupInfo?>(null)
|
||||
var currentUserMember by mutableStateOf<GroupMember?>(null)
|
||||
var members by mutableStateOf<List<GroupMember>>(emptyList())
|
||||
var isLoading by mutableStateOf(false)
|
||||
var error by mutableStateOf<String?>(null)
|
||||
var requireApproval by mutableStateOf(true) // 默认开启
|
||||
var roomId by mutableStateOf<Int?>(null)
|
||||
|
||||
init {
|
||||
loadGroupMembers()
|
||||
}
|
||||
|
||||
private fun loadGroupMembers() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
isLoading = true
|
||||
error = null
|
||||
|
||||
// 调用接口获取群聊详情
|
||||
val response = ApiClient.api.getRoomDetail(trtcId = groupId)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val room = response.body()!!.data
|
||||
|
||||
// 保存roomId
|
||||
roomId = room.id
|
||||
|
||||
// 设置群信息
|
||||
groupInfo = GroupInfo(
|
||||
groupId = groupId,
|
||||
groupName = room.name,
|
||||
groupAvatar = if (room.avatar.isNullOrEmpty()) {
|
||||
val groupIdBase64 = android.util.Base64.encodeToString(
|
||||
groupId.toByteArray(),
|
||||
android.util.Base64.NO_WRAP
|
||||
)
|
||||
"${ApiClient.RETROFIT_URL}group/avatar?groupIdBase64=$groupIdBase64&token=${AppStore.token}"
|
||||
} else {
|
||||
"${ApiClient.BASE_API_URL}/outside${room.avatar}?token=${AppStore.token}"
|
||||
},
|
||||
memberCount = room.userCount,
|
||||
isCreator = room.creator.userId == MyProfileViewModel.profile?.id.toString()
|
||||
)
|
||||
|
||||
// 获取当前用户ID
|
||||
val currentUserId = MyProfileViewModel.profile?.id.toString()
|
||||
|
||||
// 转换成员列表
|
||||
val allMembers = room.users.map { user ->
|
||||
val avatarUrl = if (user.profile.avatar.isNullOrEmpty()) {
|
||||
""
|
||||
} else {
|
||||
// 与动态列表和关注列表一致,使用 BASE_SERVER 构建头像URL,不需要token
|
||||
"${ApiClient.BASE_SERVER}${user.profile.avatar}"
|
||||
}
|
||||
|
||||
GroupMember(
|
||||
userId = user.userId,
|
||||
nickname = user.profile.nickname.ifEmpty { user.profile.username },
|
||||
avatar = avatarUrl,
|
||||
isOwner = user.userId == room.creator.userId
|
||||
)
|
||||
}
|
||||
|
||||
// 分离当前用户和其他成员
|
||||
currentUserMember = allMembers.find { it.userId == currentUserId }
|
||||
members = allMembers.filter { it.userId != currentUserId }
|
||||
|
||||
} else {
|
||||
error = "获取群成员列表失败"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: "加载失败"
|
||||
Log.e("GroupMembersViewModel", "加载群成员失败", e)
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
loadGroupMembers()
|
||||
}
|
||||
|
||||
fun deleteMember(memberId: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// TODO: 实现删除成员的API调用
|
||||
// 删除成功后刷新列表
|
||||
loadGroupMembers()
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: "删除成员失败"
|
||||
Log.e("GroupMembersViewModel", "删除成员失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import android.widget.Toast
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
@Composable
|
||||
fun GroupMemoryManageContent(
|
||||
@@ -48,9 +51,6 @@ fun GroupMemoryManageContent(
|
||||
onDismiss: () -> Unit = {}
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenHeight = configuration.screenHeightDp.dp
|
||||
val sheetHeight = screenHeight * 0.95f
|
||||
val context = LocalContext.current
|
||||
|
||||
// 编辑记忆的状态 - 存储正在编辑的记忆ID
|
||||
@@ -68,8 +68,7 @@ fun GroupMemoryManageContent(
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(sheetHeight)
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFFAF9FB))
|
||||
) {
|
||||
// 顶部栏:返回按钮 + 标题 + 加号按钮
|
||||
@@ -81,7 +80,7 @@ fun GroupMemoryManageContent(
|
||||
) {
|
||||
// 中间标题 - 绝对居中,不受其他组件影响
|
||||
Text(
|
||||
text = "记忆管理",
|
||||
text = stringResource(R.string.group_chat_info_memory_manage2),
|
||||
style = TextStyle(
|
||||
color = Color.Black,
|
||||
fontSize = 17.sp,
|
||||
@@ -109,7 +108,7 @@ fun GroupMemoryManageContent(
|
||||
colorFilter = ColorFilter.tint(Color.Black)
|
||||
)
|
||||
Text(
|
||||
text = "返回",
|
||||
text = stringResource(R.string.back_upper),
|
||||
style = TextStyle(
|
||||
color = Color.Black,
|
||||
fontSize = 15.sp,
|
||||
@@ -150,7 +149,7 @@ fun GroupMemoryManageContent(
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("已付费:", style = TextStyle(color = Color(0x993C3C43), fontSize = 13.sp))
|
||||
Text(stringResource(R.string.memory_paid), style = TextStyle(color = Color(0x993C3C43), fontSize = 13.sp))
|
||||
Spacer(Modifier.width(3.dp))
|
||||
Text(
|
||||
"${quota?.purchasedCount ?: 0}",
|
||||
@@ -158,7 +157,7 @@ fun GroupMemoryManageContent(
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("已使用:", style = TextStyle(color = Color(0x993C3C43), fontSize = 13.sp))
|
||||
Text(stringResource(R.string.memory_used), style = TextStyle(color = Color(0x993C3C43), fontSize = 13.sp))
|
||||
Spacer(Modifier.width(3.dp))
|
||||
Text(
|
||||
"${quota?.currentCount ?: 0}",
|
||||
@@ -167,7 +166,7 @@ fun GroupMemoryManageContent(
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("可用上限:", style = TextStyle(color = Color(0x993C3C43), fontSize = 13.sp))
|
||||
Text(stringResource(R.string.upper_limit), style = TextStyle(color = Color(0x993C3C43), fontSize = 13.sp))
|
||||
Spacer(Modifier.width(3.dp))
|
||||
Text(
|
||||
"50",
|
||||
@@ -237,12 +236,12 @@ fun GroupMemoryManageContent(
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "暂无记忆",
|
||||
text = stringResource(R.string.no_memory),
|
||||
style = TextStyle(color = Color.Black, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "点击上方按钮添加群记忆",
|
||||
text = stringResource(R.string.add_memory),
|
||||
style = TextStyle(color = Color.Black, fontSize = 14.sp, fontWeight = FontWeight.Normal)
|
||||
)
|
||||
}
|
||||
@@ -257,7 +256,7 @@ fun GroupMemoryManageContent(
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditGroupMemoryDialog(
|
||||
memory: com.aiosman.ravenow.data.api.AgentRule,
|
||||
memory: com.aiosman.ravenow.entity.RoomRuleEntity,
|
||||
viewModel: GroupChatInfoViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onUpdateMemory: (String) -> Unit
|
||||
@@ -403,7 +402,7 @@ fun EditGroupMemoryDialog(
|
||||
*/
|
||||
@Composable
|
||||
fun MemoryItem(
|
||||
memory: com.aiosman.ravenow.data.api.AgentRule,
|
||||
memory: com.aiosman.ravenow.entity.RoomRuleEntity,
|
||||
isEditing: Boolean = false,
|
||||
onEdit: () -> Unit = {},
|
||||
onCancel: () -> Unit = {},
|
||||
@@ -579,20 +578,44 @@ fun MemoryItem(
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 底部行:日期 + 编辑删除按钮
|
||||
// 底部行:创建者信息 + 编辑删除按钮
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Bottom
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 日期文本 - 左侧
|
||||
// 创建者信息 - 左侧:头像 + 用户名 · 时间
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 圆形头像
|
||||
val avatarUrl = memory.creator?.avatarDirectUrl?.takeIf { it.isNotBlank() }
|
||||
CustomAsyncImage(
|
||||
imageUrl = avatarUrl,
|
||||
contentDescription = "创建者头像",
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clip(CircleShape),
|
||||
defaultRes = R.drawable.default_avatar,
|
||||
placeholderRes = R.drawable.default_avatar,
|
||||
errorRes = R.drawable.default_avatar,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
// 用户名 · 时间
|
||||
Text(
|
||||
text = formattedDate,
|
||||
text = buildString {
|
||||
append(memory.creator?.nickname ?: "未知用户")
|
||||
append(" · ")
|
||||
append(formattedDate)
|
||||
},
|
||||
style = TextStyle(
|
||||
color = Color(0x993C3C43),
|
||||
fontSize = 11.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 编辑和删除图标 - 右侧
|
||||
Row(
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
package com.aiosman.ravenow.ui.group
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Icon
|
||||
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.SolidColor
|
||||
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 androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
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.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.modifiers.noRippleClickable
|
||||
|
||||
@Composable
|
||||
fun GroupProfileSettingsScreen(groupId: String) {
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
val viewModel = viewModel<GroupProfileSettingsViewModel>(
|
||||
key = "GroupProfileSettingsViewModel_$groupId",
|
||||
factory = object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return GroupProfileSettingsViewModel(groupId) as T
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 群图标选择器
|
||||
val groupIconPicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
// 直接转换为 Bitmap 并显示,不进行裁剪
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||
val bitmap = uriToBitmap(context, it)
|
||||
bitmap?.let { bmp ->
|
||||
withContext(Dispatchers.Main) {
|
||||
viewModel.setGroupIcon(bmp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 群头像选择器
|
||||
val groupAvatarPicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri ->
|
||||
uri?.let {
|
||||
// 直接转换为 Bitmap 并显示,不进行裁剪
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||
val bitmap = uriToBitmap(context, it)
|
||||
bitmap?.let { bmp ->
|
||||
withContext(Dispatchers.Main) {
|
||||
viewModel.setGroupAvatar(bmp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(AppColors.background)
|
||||
) {
|
||||
// 群默认图标图片区域(渐变背景)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(249.dp)
|
||||
.background(
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF7C45ED),
|
||||
Color(0xFFE91E63)
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||
)
|
||||
.noRippleClickable {
|
||||
groupIconPicker.launch("image/*")
|
||||
}
|
||||
) {
|
||||
// 显示选中的图标或默认渐变
|
||||
viewModel.groupIconBitmap?.let { bitmap ->
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 顶部导航栏(显示在渐变背景上层)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.zIndex(1f)
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp, horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.rider_pro_back_icon),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.noRippleClickable {
|
||||
navController.navigateUp()
|
||||
},
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(Color.White)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = stringResource(R.string.group_info_edit),
|
||||
style = TextStyle(
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
// 群头像区域
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 270.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(70.dp)
|
||||
) {
|
||||
// 群头像显示
|
||||
if (viewModel.groupInfo?.groupAvatar?.isNotEmpty() == true) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = viewModel.groupInfo!!.groupAvatar,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
contentDescription = "群聊头像"
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(AppColors.decentBackground),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = viewModel.groupInfo?.groupName?.firstOrNull()?.toString() ?: "",
|
||||
style = TextStyle(
|
||||
color = AppColors.text,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 右下角加号按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF7C45ED),
|
||||
Color(0xFF7BD8F8)
|
||||
)
|
||||
)
|
||||
)
|
||||
.noRippleClickable {
|
||||
groupAvatarPicker.launch("image/*")
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "+",
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 群聊名称编辑框
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(AppColors.decentBackground)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.group_name)+":",
|
||||
style = TextStyle(
|
||||
color = AppColors.text.copy(alpha = 0.7f),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
)
|
||||
BasicTextField(
|
||||
value = viewModel.groupName,
|
||||
onValueChange = { viewModel.updateGroupName(it) },
|
||||
textStyle = TextStyle(
|
||||
color = AppColors.text,
|
||||
fontSize = 15.sp
|
||||
),
|
||||
cursorBrush = SolidColor(AppColors.text),
|
||||
modifier = Modifier.padding(start = 70.dp),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// 保存按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 24.dp)
|
||||
.height(50.dp)
|
||||
.clip(RoundedCornerShape(25.dp))
|
||||
.background(
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF7C45ED),
|
||||
Color(0xFF7BD8F8)
|
||||
)
|
||||
)
|
||||
)
|
||||
.noRippleClickable {
|
||||
// TODO: 实现保存功能
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.save),
|
||||
style = TextStyle(
|
||||
color = Color.White,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 将 Uri 转换为 Bitmap 的辅助函数
|
||||
fun uriToBitmap(context: android.content.Context, uri: Uri): Bitmap? {
|
||||
return try {
|
||||
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
|
||||
BitmapFactory.decodeStream(inputStream)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.aiosman.ravenow.ui.group
|
||||
|
||||
import android.graphics.Bitmap
|
||||
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.entity.GroupInfo
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GroupProfileSettingsViewModel(
|
||||
private val groupId: String
|
||||
) : ViewModel() {
|
||||
|
||||
var groupInfo by mutableStateOf<GroupInfo?>(null)
|
||||
var groupName by mutableStateOf("")
|
||||
var groupIconBitmap by mutableStateOf<Bitmap?>(null)
|
||||
var groupAvatarBitmap by mutableStateOf<Bitmap?>(null)
|
||||
var isLoading by mutableStateOf(false)
|
||||
var error by mutableStateOf<String?>(null)
|
||||
|
||||
init {
|
||||
loadGroupInfo()
|
||||
}
|
||||
|
||||
fun loadGroupInfo() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
isLoading = true
|
||||
error = null
|
||||
// 直接调用 API 加载群信息
|
||||
val response = com.aiosman.ravenow.data.api.ApiClient.api.getRoomDetail(trtcId = groupId)
|
||||
val room = response.body()?.data
|
||||
groupInfo = room?.let {
|
||||
com.aiosman.ravenow.entity.GroupInfo(
|
||||
groupId = groupId,
|
||||
groupName = it.name,
|
||||
groupAvatar = if (it.avatar.isNullOrEmpty()) {
|
||||
val groupIdBase64 = android.util.Base64.encodeToString(
|
||||
groupId.toByteArray(),
|
||||
android.util.Base64.NO_WRAP
|
||||
)
|
||||
"${com.aiosman.ravenow.data.api.ApiClient.RETROFIT_URL}group/avatar?groupIdBase64=$groupIdBase64&token=${com.aiosman.ravenow.AppStore.token}"
|
||||
} else {
|
||||
"${com.aiosman.ravenow.data.api.ApiClient.BASE_API_URL}/outside${it.avatar}?token=${com.aiosman.ravenow.AppStore.token}"
|
||||
},
|
||||
memberCount = room.userCount,
|
||||
isCreator = room.creator.userId == com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel.profile?.id.toString()
|
||||
)
|
||||
}
|
||||
groupInfo?.let {
|
||||
groupName = it.groupName
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error = e.message ?: "加载失败"
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateGroupName(newName: String) {
|
||||
groupName = newName
|
||||
}
|
||||
|
||||
fun setGroupIcon(bitmap: Bitmap) {
|
||||
groupIconBitmap = bitmap
|
||||
}
|
||||
|
||||
fun setGroupAvatar(bitmap: Bitmap) {
|
||||
groupAvatarBitmap = bitmap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ fun CreateBottomSheet(
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
windowInsets = BottomSheetDefaults.windowInsets,
|
||||
containerColor = appColors.background,
|
||||
dragHandle = null,
|
||||
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
|
||||
|
||||
@@ -70,6 +70,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
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.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -265,12 +266,11 @@ fun IndexScreen() {
|
||||
modifier = Modifier
|
||||
.background(AppColors.background)
|
||||
.padding(0.dp),
|
||||
beyondBoundsPageCount = 4,
|
||||
userScrollEnabled = false
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> Agent()
|
||||
1 -> Home()
|
||||
1 -> Home(isPageVisible = pagerState.currentPage == 1)
|
||||
2 -> Add()
|
||||
3 -> Notifications()
|
||||
4 -> Profile()
|
||||
@@ -333,7 +333,9 @@ fun IndexScreen() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Home() {
|
||||
fun Home(
|
||||
isPageVisible: Boolean = true
|
||||
) {
|
||||
val systemUiController = rememberSystemUiController()
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -349,7 +351,7 @@ fun Home() {
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
MomentsList()
|
||||
MomentsList(isPageVisible = isPageVisible)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,13 +425,6 @@ fun Profile() {
|
||||
systemUiController.setStatusBarColor(Color.Transparent, !AppState.darkMode)
|
||||
}
|
||||
|
||||
// 页面退出时清理个人资料相关资源
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
ResourceCleanupManager.cleanupPageResources("profile")
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
@@ -580,34 +575,33 @@ fun SideMenuContent(
|
||||
// 顶部状态栏间距
|
||||
val statusBarHeight = WindowInsets.systemBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
// 扫一扫功能入口 - 右边距离右边66pt
|
||||
// 扫一扫功能入口
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = (-112).dp, y = 88.dp)
|
||||
.offset(x = (-60).dp, y = 88.dp)
|
||||
.noRippleClickable {
|
||||
// TODO: 实现扫一扫功能
|
||||
coroutineScope.launch {
|
||||
onClose()
|
||||
navController.navigate(NavigationRoute.ScanQr.route)
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 扫一扫图标(使用现有图标或占位)
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.sao),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
colorFilter = ColorFilter.tint(iconColor)
|
||||
)
|
||||
}
|
||||
// 绝对定位的"扫一扫"文字:上方71.5dp,右侧66dp
|
||||
Text(
|
||||
text = stringResource(R.string.scan_qr),
|
||||
fontSize = 14.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = (-66).dp, y = 91.5.dp)
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
// QR码图标 - 右边距离右边112dp,上边距离上边68pt
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.qr_code_icon),
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
@@ -24,22 +23,18 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
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
|
||||
@@ -54,7 +49,6 @@ 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
|
||||
import androidx.navigation.NavHostController
|
||||
import com.aiosman.ravenow.AppStore
|
||||
import com.aiosman.ravenow.GuestLoginCheckOut
|
||||
@@ -64,30 +58,29 @@ import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.index.tabs.ai.tabs.mine.MineAgent
|
||||
import com.aiosman.ravenow.ui.index.tabs.ai.tabs.hot.HotAgent
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.ui.composables.TabItem
|
||||
import com.aiosman.ravenow.ui.composables.TabSpacer
|
||||
import com.aiosman.ravenow.ui.index.tabs.moment.CustomTabItem
|
||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.AgentItem
|
||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.ExploreViewModel
|
||||
import com.aiosman.ravenow.utils.DebounceUtils
|
||||
import com.aiosman.ravenow.utils.ResourceCleanupManager
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.foundation.lazy.grid.items as gridItems
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import com.airbnb.lottie.compose.LottieAnimation
|
||||
import com.airbnb.lottie.compose.LottieCompositionSpec
|
||||
import com.airbnb.lottie.compose.LottieConstants
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
|
||||
// 检测是否接近列表底部的扩展函数
|
||||
fun LazyListState.isScrolledToEnd(buffer: Int = 3): Boolean {
|
||||
@@ -106,28 +99,14 @@ fun Agent() {
|
||||
val navigationBarPaddings =
|
||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 48.dp
|
||||
val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues()
|
||||
// 游客模式下只显示热门Agent,正常用户显示我的Agent和热门Agent
|
||||
val tabCount = if (AppStore.isGuest) 1 else 2
|
||||
var pagerState = rememberPagerState { tabCount }
|
||||
var scope = rememberCoroutineScope()
|
||||
|
||||
val viewModel: AgentViewModel = viewModel()
|
||||
val viewModel: AgentViewModel = AgentViewModel
|
||||
|
||||
// 确保推荐Agent数据已加载
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.ensureDataLoaded()
|
||||
}
|
||||
|
||||
// 防抖状态
|
||||
var lastClickTime by remember { mutableStateOf(0L) }
|
||||
|
||||
// 页面退出时只清理必要的资源,不清理推荐Agent数据
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
// 只清理子页面的资源,保留推荐Agent数据
|
||||
// ResourceCleanupManager.cleanupPageResources("ai")
|
||||
}
|
||||
}
|
||||
|
||||
val agentItems = viewModel.agentItems
|
||||
var selectedTabIndex by remember { mutableStateOf(0) }
|
||||
@@ -260,11 +239,19 @@ fun Agent() {
|
||||
) {
|
||||
when {
|
||||
selectedTabIndex == 0 -> {
|
||||
AgentViewPagerSection(agentItems = viewModel.agentItems.take(15), viewModel)
|
||||
AgentViewPagerSection(
|
||||
agentItems = viewModel.agentItems.take(15),
|
||||
viewModel
|
||||
)
|
||||
}
|
||||
|
||||
selectedTabIndex in 1..viewModel.categories.size -> {
|
||||
AgentViewPagerSection(agentItems = viewModel.agentItems.take(15), viewModel)
|
||||
AgentViewPagerSection(
|
||||
agentItems = viewModel.agentItems.take(15),
|
||||
viewModel
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val shuffledAgents = viewModel.agentItems.shuffled().take(15)
|
||||
AgentViewPagerSection(agentItems = shuffledAgents, viewModel)
|
||||
@@ -273,6 +260,7 @@ fun Agent() {
|
||||
}
|
||||
}
|
||||
|
||||
if (viewModel.chatRooms.isNotEmpty()) {
|
||||
// 热门聊天室
|
||||
stickyHeader(key = "hot_rooms_header") {
|
||||
Row(
|
||||
@@ -318,7 +306,9 @@ fun Agent() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当热门聊天室有数据时,才展示“发现更多”区域
|
||||
item { Spacer(modifier = Modifier.height(20.dp)) }
|
||||
|
||||
// "发现更多" 标题 - 吸顶
|
||||
@@ -375,81 +365,25 @@ fun Agent() {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多指示器
|
||||
// 加载更多指示器(仅在展示"发现更多"时显示)
|
||||
if (viewModel.isLoadingMore) {
|
||||
item {
|
||||
Row(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 24.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = AppColors.text,
|
||||
strokeWidth = 2.dp
|
||||
LottieAnimation(
|
||||
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
|
||||
iterations = LottieConstants.IterateForever,
|
||||
modifier = Modifier.size(80.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
androidx.compose.material3.Text(
|
||||
text = "加载中...",
|
||||
color = AppColors.secondaryText,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AgentGridLayout(
|
||||
agentItems: List<AgentItem>,
|
||||
viewModel: AgentViewModel,
|
||||
navController: NavHostController
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// 将agentItems按两列分组
|
||||
agentItems.chunked(2).forEachIndexed { rowIndex, rowItems ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
top = if (rowIndex == 0) 30.dp else 20.dp, // 第一行添加更多顶部间距
|
||||
bottom = 20.dp
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// 第一列
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
AgentCardSquare(
|
||||
agentItem = rowItems[0],
|
||||
viewModel = viewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
// 第二列(如果存在)
|
||||
if (rowItems.size > 1) {
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
AgentCardSquare(
|
||||
agentItem = rowItems[1],
|
||||
viewModel = viewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 如果只有一列,添加空白占位
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,8 +395,7 @@ fun AgentCardSquare(
|
||||
navController: NavHostController
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val cardHeight = 180.dp
|
||||
val avatarSize = cardHeight / 3 // 头像大小为方块高度的三分之一
|
||||
val cardHeight = 210.dp
|
||||
|
||||
// 防抖状态
|
||||
var lastClickTime by remember { mutableStateOf(0L) }
|
||||
@@ -471,96 +404,76 @@ fun AgentCardSquare(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(cardHeight)
|
||||
.background(AppColors.secondaryBackground, RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.noRippleClickable {
|
||||
if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) {
|
||||
viewModel.goToProfile(agentItem.openId, navController)
|
||||
}) {
|
||||
lastClickTime = System.currentTimeMillis()
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.TopCenter
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(y = 4.dp)
|
||||
.size(avatarSize)
|
||||
.background(AppColors.background, RoundedCornerShape(avatarSize / 2))
|
||||
.clip(RoundedCornerShape(avatarSize / 2)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.mipmap.group_copy),
|
||||
contentDescription = "默认头像",
|
||||
modifier = Modifier.size(avatarSize),
|
||||
)
|
||||
if (agentItem.avatar.isNotEmpty()) {
|
||||
// 背景大图
|
||||
CustomAsyncImage(
|
||||
imageUrl = agentItem.avatar,
|
||||
contentDescription = "Agent头像",
|
||||
modifier = Modifier
|
||||
.size(avatarSize)
|
||||
.clip(RoundedCornerShape(avatarSize / 2)),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop
|
||||
contentDescription = agentItem.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
|
||||
defaultRes = R.mipmap.rider_pro_agent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 内容区域(名称和描述)
|
||||
// 底部渐变与文字
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
1f to Color(0xB2000000)
|
||||
)
|
||||
)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp + avatarSize + 8.dp, start = 8.dp, end = 8.dp, bottom = 48.dp), // 为底部聊天按钮留出空间
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
.padding(bottom = 40.dp) // 为底部聊天按钮预留空间
|
||||
) {
|
||||
androidx.compose.material3.Text(
|
||||
text = agentItem.title,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.W600,
|
||||
color = AppColors.text,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.W700,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
androidx.compose.material3.Text(
|
||||
text = agentItem.desc,
|
||||
fontSize = 12.sp,
|
||||
color = AppColors.secondaryText,
|
||||
color = Color.White.copy(alpha = 0.92f),
|
||||
fontSize = 11.sp,
|
||||
maxLines = 2,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天按钮
|
||||
// 底部居中 Chat 按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 12.dp)
|
||||
.width(60.dp)
|
||||
.width(70.dp)
|
||||
.height(32.dp)
|
||||
.background(
|
||||
color = AppColors.text,
|
||||
shape = RoundedCornerShape(
|
||||
topStart = 14.dp,
|
||||
topEnd = 14.dp,
|
||||
bottomStart = 0.dp,
|
||||
bottomEnd = 14.dp
|
||||
)
|
||||
)
|
||||
.clickable {
|
||||
.background(AppColors.text, RoundedCornerShape(16.dp))
|
||||
.noRippleClickable {
|
||||
if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) {
|
||||
// 检查游客模式,如果是游客则跳转登录
|
||||
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) {
|
||||
navController.navigate(NavigationRoute.Login.route)
|
||||
} else {
|
||||
viewModel.createSingleChat(agentItem.openId)
|
||||
viewModel.goToChatAi(
|
||||
agentItem.openId,
|
||||
navController = navController
|
||||
)
|
||||
viewModel.goToChatAi(agentItem.openId, navController)
|
||||
}
|
||||
}) {
|
||||
lastClickTime = System.currentTimeMillis()
|
||||
@@ -570,204 +483,146 @@ fun AgentCardSquare(
|
||||
) {
|
||||
androidx.compose.material3.Text(
|
||||
text = stringResource(R.string.chat),
|
||||
fontSize = 15.sp,
|
||||
color = AppColors.background,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.W500
|
||||
fontSize = 13.sp,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.W600
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun AgentViewPagerSection(agentItems: List<AgentItem>, viewModel: AgentViewModel) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
if (agentItems.isEmpty()) return
|
||||
|
||||
// 每页显示5个agent
|
||||
val itemsPerPage = 5
|
||||
val totalPages = (agentItems.size + itemsPerPage - 1) / itemsPerPage
|
||||
|
||||
if (totalPages > 0) {
|
||||
val pagerState = rememberPagerState(pageCount = { totalPages })
|
||||
val pagerState = rememberPagerState(pageCount = { agentItems.size })
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val cardAspect = 1133.5f / 846.4f
|
||||
// 外层 LazyColumn 左右各 8dp + Pager contentPadding 左右各 20dp
|
||||
val horizontalPaddings = 56.dp
|
||||
val pagerHeight = (screenWidth - horizontalPaddings) * cardAspect
|
||||
|
||||
Column {
|
||||
// Agent内容
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(310.dp)
|
||||
.height(pagerHeight)
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp),
|
||||
pageSpacing = 0.dp
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 20.dp),
|
||||
pageSpacing = 12.dp
|
||||
) { page ->
|
||||
// 计算当前页面的偏移量
|
||||
// 缩放效果
|
||||
val pageOffset = (
|
||||
(pagerState.currentPage - page) + pagerState
|
||||
.currentPageOffsetFraction
|
||||
).coerceIn(-1f, 1f)
|
||||
val scale = 1f - (0.06f * kotlin.math.abs(pageOffset))
|
||||
|
||||
// 根据偏移量计算缩放比例
|
||||
val scale = 1f - (0.1f * kotlin.math.abs(pageOffset))
|
||||
|
||||
AgentPage(
|
||||
AgentLargeCard(
|
||||
agentItem = agentItems[page],
|
||||
viewModel = viewModel,
|
||||
agentItems = agentItems.drop(page * itemsPerPage).take(itemsPerPage),
|
||||
page = page,
|
||||
navController = LocalNavController.current,
|
||||
modifier = Modifier
|
||||
.height(310.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
},
|
||||
navController = LocalNavController.current,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 指示器
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(30.dp)
|
||||
.padding(top = 12.dp),
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center
|
||||
) {
|
||||
repeat(totalPages) { index ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.size(3.dp)
|
||||
.background(
|
||||
color = if (pagerState.currentPage == index) AppColors.text else AppColors.secondaryText.copy(
|
||||
alpha = 0.3f
|
||||
),
|
||||
shape = androidx.compose.foundation.shape.CircleShape
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AgentPage(viewModel: AgentViewModel,agentItems: List<AgentItem>, page: Int, modifier: Modifier = Modifier,navController: NavHostController) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 0.dp)
|
||||
) {
|
||||
// 显示3个agent
|
||||
agentItems.forEachIndexed { index, agentItem ->
|
||||
AgentCard2(agentItem = agentItem, viewModel = viewModel, navController = LocalNavController.current)
|
||||
if (index < agentItems.size - 1) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SuspiciousIndentation")
|
||||
@Composable
|
||||
fun AgentCard2(viewModel: AgentViewModel,agentItem: AgentItem,navController: NavHostController) {
|
||||
fun AgentLargeCard(
|
||||
agentItem: AgentItem,
|
||||
viewModel: AgentViewModel,
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
// 防抖状态
|
||||
var lastClickTime by remember { mutableStateOf(0L) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 左侧头像
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(Color(0x00F5F5F5), RoundedCornerShape(24.dp))
|
||||
.clickable {
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(846.4f / 1133.5f)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.noRippleClickable {
|
||||
if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) {
|
||||
viewModel.goToProfile(agentItem.openId, navController)
|
||||
}) {
|
||||
lastClickTime = System.currentTimeMillis()
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
}
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.mipmap.group_copy),
|
||||
contentDescription = "默认头像",
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
|
||||
if (agentItem.avatar.isNotEmpty()) {
|
||||
// 背景大图
|
||||
CustomAsyncImage(
|
||||
imageUrl = agentItem.avatar,
|
||||
contentDescription = "Agent头像",
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(24.dp)),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// 中间文字内容
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp)
|
||||
) {
|
||||
// 标题
|
||||
androidx.compose.material3.Text(
|
||||
text = agentItem.title,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.W600,
|
||||
color = AppColors.text,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
||||
contentDescription = agentItem.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
|
||||
defaultRes = R.mipmap.rider_pro_agent
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 描述
|
||||
androidx.compose.material3.Text(
|
||||
text = agentItem.desc,
|
||||
fontSize = 12.sp,
|
||||
color = AppColors.secondaryText,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
// 右侧聊天按钮
|
||||
// 底部渐变与文字
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 60.dp, height = 32.dp)
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = Color(0X147c7480),
|
||||
shape = RoundedCornerShape(
|
||||
topStart = 14.dp,
|
||||
topEnd = 14.dp,
|
||||
bottomStart = 0.dp,
|
||||
bottomEnd = 14.dp
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
1f to Color(0xB2000000)
|
||||
)
|
||||
)
|
||||
.clickable {
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 56.dp) // 为底部聊天按钮预留空间
|
||||
) {
|
||||
androidx.compose.material3.Text(
|
||||
text = agentItem.title,
|
||||
color = Color.White,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.W700,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
androidx.compose.material3.Text(
|
||||
text = agentItem.desc,
|
||||
color = Color.White.copy(alpha = 0.92f),
|
||||
fontSize = 14.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 底部居中 Chat 按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 16.dp)
|
||||
.widthIn(min = 180.dp)
|
||||
.fillMaxWidth(0.65f)
|
||||
.height(44.dp)
|
||||
.background(AppColors.text, RoundedCornerShape(22.dp))
|
||||
.noRippleClickable {
|
||||
if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) {
|
||||
// 检查游客模式,如果是游客则跳转登录
|
||||
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) {
|
||||
navController.navigate(NavigationRoute.Login.route)
|
||||
} else {
|
||||
viewModel.createSingleChat(agentItem.openId)
|
||||
viewModel.goToChatAi(
|
||||
agentItem.openId,
|
||||
navController = navController
|
||||
)
|
||||
viewModel.goToChatAi(agentItem.openId, navController)
|
||||
}
|
||||
}) {
|
||||
lastClickTime = System.currentTimeMillis()
|
||||
@@ -777,64 +632,11 @@ fun AgentCard2(viewModel: AgentViewModel,agentItem: AgentItem,navController: Nav
|
||||
) {
|
||||
androidx.compose.material3.Text(
|
||||
text = stringResource(R.string.chat),
|
||||
fontSize = 12.sp,
|
||||
color = AppColors.text,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.W500
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun ChatRoomsSection(
|
||||
chatRooms: List<ChatRoom>,
|
||||
navController: NavHostController
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// 标题
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.mipmap.rider_pro_hot_room),
|
||||
contentDescription = "chat room",
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
androidx.compose.material3.Text(
|
||||
text = stringResource(R.string.hot_rooms),
|
||||
color = AppColors.background,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.W900,
|
||||
color = AppColors.text
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.W600
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
chatRooms.chunked(2).forEach { rowRooms ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
rowRooms.forEach { chatRoom ->
|
||||
ChatRoomCard(
|
||||
chatRoom = chatRoom,
|
||||
navController = navController,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,7 +648,7 @@ fun ChatRoomCard(
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val cardSize = 180.dp
|
||||
val viewModel: AgentViewModel = viewModel()
|
||||
val viewModel: AgentViewModel = AgentViewModel
|
||||
val context = LocalContext.current
|
||||
|
||||
// 防抖状态
|
||||
@@ -865,26 +667,16 @@ fun ChatRoomCard(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.background(
|
||||
color = AppColors.background,
|
||||
color = Color.Transparent,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
color = AppColors.main
|
||||
LottieAnimation(
|
||||
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
|
||||
iterations = LottieConstants.IterateForever,
|
||||
modifier = Modifier.size(96.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
androidx.compose.material3.Text(
|
||||
text = "加入中...",
|
||||
fontSize = 14.sp,
|
||||
color = AppColors.text
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -895,7 +687,10 @@ fun ChatRoomCard(
|
||||
.size(cardSize)
|
||||
.background(AppColors.tabUnselectedBackground, RoundedCornerShape(12.dp))
|
||||
.clickable(enabled = !viewModel.isJoiningRoom) {
|
||||
if (!viewModel.isJoiningRoom && DebounceUtils.simpleDebounceClick(lastClickTime, 500L) {
|
||||
if (!viewModel.isJoiningRoom && DebounceUtils.simpleDebounceClick(
|
||||
lastClickTime,
|
||||
500L
|
||||
) {
|
||||
// 加入群聊房间
|
||||
viewModel.joinRoom(
|
||||
id = chatRoom.id,
|
||||
@@ -910,7 +705,8 @@ fun ChatRoomCard(
|
||||
// 处理错误,可以显示Toast或其他提示
|
||||
}
|
||||
)
|
||||
}) {
|
||||
}
|
||||
) {
|
||||
lastClickTime = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
@@ -918,29 +714,23 @@ fun ChatRoomCard(
|
||||
// 优先显示banner,如果没有banner则显示头像
|
||||
val imageUrl = if (chatRoom.banner.isNotEmpty()) chatRoom.banner else chatRoom.avatar
|
||||
|
||||
if (imageUrl.isNotEmpty()) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = imageUrl,
|
||||
contentDescription = if (chatRoom.banner.isNotEmpty()) "房间banner" else "房间头像",
|
||||
modifier = Modifier
|
||||
.width(cardSize)
|
||||
.height(120.dp)
|
||||
.clip(RoundedCornerShape(
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart = 12.dp,
|
||||
topEnd = 12.dp,
|
||||
bottomStart = 0.dp,
|
||||
bottomEnd = 0.dp)),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop
|
||||
bottomEnd = 0.dp
|
||||
)
|
||||
} else {
|
||||
// 默认房间图标
|
||||
Image(
|
||||
painter = painterResource(R.mipmap.rider_pro_agent),
|
||||
contentDescription = "默认房间图标",
|
||||
modifier = Modifier.size(cardSize * 0.4f),
|
||||
colorFilter = ColorFilter.tint(AppColors.secondaryText)
|
||||
),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
|
||||
defaultRes = R.mipmap.rider_pro_agent
|
||||
)
|
||||
}
|
||||
|
||||
// 房间名称,重叠在底部
|
||||
Box(
|
||||
|
||||
@@ -134,7 +134,7 @@ object AgentViewModel: ViewModel() {
|
||||
pageSize = pageSize,
|
||||
withWorkflow = 1,
|
||||
categoryIds = listOf(categoryId),
|
||||
random = 1
|
||||
// random = 1
|
||||
)
|
||||
} else {
|
||||
// 获取推荐智能体,使用random=1
|
||||
@@ -143,7 +143,7 @@ object AgentViewModel: ViewModel() {
|
||||
pageSize = pageSize,
|
||||
withWorkflow = 1,
|
||||
categoryIds = null,
|
||||
random = 1
|
||||
// random = 1
|
||||
)
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ object AgentViewModel: ViewModel() {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
isRecommended = 1,
|
||||
random = 1
|
||||
// random = "1"
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
val allRooms = response.body()?.list ?: emptyList()
|
||||
@@ -332,18 +332,17 @@ object AgentViewModel: ViewModel() {
|
||||
openId: String,
|
||||
navController: NavHostController
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
// 直接使用openId导航,页面内的AiProfileViewModel会处理数据加载
|
||||
// 避免重复请求,因为AiProfileViewModel.loadProfile已经支持通过openId加载
|
||||
try {
|
||||
val profile = userService.getUserProfileByOpenId(openId)
|
||||
// 从Agent列表点击进去的一定是智能体,直接传递isAiAccount = true
|
||||
navController.navigate(
|
||||
NavigationRoute.AccountProfile.route
|
||||
.replace("{id}", profile.id.toString())
|
||||
.replace("{id}", openId)
|
||||
.replace("{isAiAccount}", "true")
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// swallow error to avoid crash on navigation attempt failures
|
||||
}
|
||||
Log.e("AgentViewModel", "Navigation failed", e)
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -156,10 +156,10 @@ object HotAgentViewModel : ViewModel() {
|
||||
try {
|
||||
// 预加载头像图片到缓存
|
||||
com.aiosman.ravenow.utils.Utils.getImageLoader(context).enqueue(
|
||||
coil.request.ImageRequest.Builder(context)
|
||||
coil3.request.ImageRequest.Builder(context)
|
||||
.data(agent.avatar)
|
||||
.memoryCachePolicy(coil.request.CachePolicy.ENABLED)
|
||||
.diskCachePolicy(coil.request.CachePolicy.ENABLED)
|
||||
.memoryCachePolicy(coil3.request.CachePolicy.ENABLED)
|
||||
.diskCachePolicy(coil3.request.CachePolicy.ENABLED)
|
||||
.build()
|
||||
)
|
||||
preloadedImageIds.add(agent.id)
|
||||
|
||||
@@ -163,7 +163,7 @@ fun NotificationsScreen() {
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.noRippleClickable {
|
||||
// TODO: 实现搜索功能
|
||||
navController.navigate(NavigationRoute.Search.route)
|
||||
},
|
||||
colorFilter = ColorFilter.tint(AppColors.text)
|
||||
)
|
||||
|
||||
@@ -30,6 +30,7 @@ import io.openim.android.sdk.models.ConversationInfo
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import com.aiosman.ravenow.data.repo.TrtcUserTypeRepository
|
||||
|
||||
data class Conversation(
|
||||
val id: String,
|
||||
@@ -76,6 +77,13 @@ object MessageListViewModel : ViewModel() {
|
||||
// noticeInfo = info
|
||||
//
|
||||
// isLoading = false
|
||||
if (loadChat && com.aiosman.ravenow.AppState.enableChat) {
|
||||
// 预热 trtcId -> isAI 缓存,避免首次进入 Agent/Friends 列表时阻塞
|
||||
try {
|
||||
prewarmChatTypes(context)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -163,5 +171,23 @@ object MessageListViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun prewarmChatTypes(context: Context) {
|
||||
val result = suspendCoroutine { continuation ->
|
||||
OpenIMClient.getInstance().conversationManager.getAllConversationList(
|
||||
object : OnBase<List<ConversationInfo>> {
|
||||
override fun onSuccess(data: List<ConversationInfo>?) {
|
||||
continuation.resumeWith(Result.success(data ?: emptyList()))
|
||||
}
|
||||
override fun onError(code: Int, error: String?) {
|
||||
continuation.resumeWith(Result.failure(Exception("Error $code: $error")))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
val trtcIds = result.filter { it.conversationType == 1 }
|
||||
.mapNotNull { it.userID }
|
||||
.distinct()
|
||||
TrtcUserTypeRepository.ensureTypes(context, trtcIds)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -48,6 +48,11 @@ import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.rememberDebouncer
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.utils.NetworkUtils
|
||||
import com.aiosman.ravenow.ui.network.ReloadButton
|
||||
import com.airbnb.lottie.compose.LottieAnimation
|
||||
import com.airbnb.lottie.compose.LottieCompositionSpec
|
||||
import com.airbnb.lottie.compose.LottieConstants
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
|
||||
/**
|
||||
* 智能体聊天列表页面
|
||||
@@ -97,16 +102,15 @@ fun AgentChatListScreen() {
|
||||
if (isNetworkAvailable) {
|
||||
Spacer(modifier = Modifier.height(39.dp))
|
||||
Image(
|
||||
painter = painterResource(
|
||||
id = if(AppState.darkMode) R.mipmap.juhao_dark
|
||||
else R.mipmap.invalid_name_5),
|
||||
painter = painterResource(id = R.mipmap.invalid_name_3),
|
||||
contentDescription = "null data",
|
||||
modifier = Modifier
|
||||
.size(width = 181.dp, height = 153.dp)
|
||||
.width(181.dp)
|
||||
.height(153.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(if (AppState.darkMode) 9.dp else 24.dp))
|
||||
Spacer(modifier = Modifier.height(9.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.agent_chat_empty_title),
|
||||
text = stringResource(R.string.no_one_knocked_yet),
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
@@ -115,16 +119,6 @@ fun AgentChatListScreen() {
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.agent_chat_empty_subtitle),
|
||||
color = AppColors.secondaryText,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
else {
|
||||
Spacer(modifier = Modifier.height(39.dp))
|
||||
@@ -197,9 +191,10 @@ fun AgentChatListScreen() {
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = AppColors.main
|
||||
LottieAnimation(
|
||||
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
|
||||
iterations = LottieConstants.IterateForever,
|
||||
modifier = Modifier.size(80.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.aiosman.ravenow.ui.index.tabs.message.tab
|
||||
|
||||
import android.content.Context
|
||||
import android.icu.util.Calendar
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -28,6 +29,7 @@ import io.openim.android.sdk.models.Message
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import com.aiosman.ravenow.utils.MessageParser
|
||||
import com.aiosman.ravenow.data.repo.TrtcUserTypeRepository
|
||||
|
||||
data class AgentConversation(
|
||||
val id: String,
|
||||
@@ -53,7 +55,7 @@ data class AgentConversation(
|
||||
nickname = conversation.showName ?: "",
|
||||
lastMessage = displayText, // 使用解析后的显示文本
|
||||
lastMessageTime = lastMessage.time.formatChatTime(context),
|
||||
avatar = "${ApiClient.BASE_API_URL+"/"}${conversation.faceURL}"+"?token="+"${AppStore.token}".replace("storage/avatars/", "/avatar/"),
|
||||
avatar = "${ApiClient.BASE_SERVER}${conversation.faceURL}",
|
||||
unreadCount = conversation.unreadCount,
|
||||
trtcUserId = conversation.userID ?: "",
|
||||
displayText = displayText,
|
||||
@@ -127,15 +129,7 @@ object AgentChatListViewModel : ViewModel() {
|
||||
OpenIMClient.getInstance().conversationManager.getAllConversationList(
|
||||
object : OnBase<List<ConversationInfo>> {
|
||||
override fun onSuccess(data: List<ConversationInfo>?) {
|
||||
// 过滤出智能体会话(单聊类型,且可能有特定标识)
|
||||
val agentConversations = data?.filter { conversation ->
|
||||
// 这里需要根据实际业务逻辑来过滤智能体会话
|
||||
// 可能通过会话类型、用户ID前缀、或其他标识来判断
|
||||
conversation.conversationType == 1 // 1 表示单聊
|
||||
// 可以添加更多过滤条件,比如:
|
||||
// && conversation.userID?.startsWith("ai_") == true
|
||||
} ?: emptyList()
|
||||
continuation.resumeWith(Result.success(agentConversations))
|
||||
continuation.resumeWith(Result.success(data ?: emptyList()))
|
||||
}
|
||||
|
||||
override fun onError(code: Int, error: String?) {
|
||||
@@ -145,7 +139,22 @@ object AgentChatListViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
|
||||
agentChatList = result.map { conversation ->
|
||||
// 仅单聊
|
||||
val singleChats = result.filter { it.conversationType == 1 }
|
||||
val trtcIds = singleChats.mapNotNull { it.userID }.distinct()
|
||||
// 预热缓存(包含AI)
|
||||
try {
|
||||
TrtcUserTypeRepository.ensureTypes(context, trtcIds)
|
||||
} catch (e: Exception) {
|
||||
Log.w("AgentChatListViewModel", "ensureTypes failed: ${e.message}")
|
||||
}
|
||||
// 过滤出 AI 会话
|
||||
val filtered = singleChats.filter { conv ->
|
||||
val id = conv.userID ?: return@filter false
|
||||
TrtcUserTypeRepository.getCachedType(id) == true
|
||||
}
|
||||
|
||||
agentChatList = filtered.map { conversation ->
|
||||
AgentConversation.convertToAgentConversation(conversation, context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.rememberDebouncer
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.utils.NetworkUtils
|
||||
import com.aiosman.ravenow.ui.network.ReloadButton
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -121,35 +122,88 @@ fun AllChatListScreen() {
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// 监听各个 ViewModel 的列表变化
|
||||
val agentChatList = AgentChatListViewModel.agentChatList
|
||||
val groupChatList = GroupChatListViewModel.groupChatList
|
||||
val friendChatList = FriendChatListViewModel.friendChatList
|
||||
|
||||
// 当列表变化时,自动更新合并列表
|
||||
LaunchedEffect(agentChatList, groupChatList, friendChatList) {
|
||||
val combinedList = mutableListOf<CombinedConversation>()
|
||||
|
||||
agentChatList.forEach { agent ->
|
||||
combinedList.add(CombinedConversation(type = "agent", agentConversation = agent))
|
||||
}
|
||||
|
||||
groupChatList.forEach { group ->
|
||||
combinedList.add(CombinedConversation(type = "group", groupConversation = group))
|
||||
}
|
||||
|
||||
friendChatList.forEach { friend ->
|
||||
val isDuplicate = combinedList.any {
|
||||
it.type == "agent" && it.agentConversation?.trtcUserId == friend.trtcUserId
|
||||
}
|
||||
|
||||
if (!isDuplicate) {
|
||||
combinedList.add(CombinedConversation(type = "friend", friendConversation = friend))
|
||||
}
|
||||
}
|
||||
|
||||
// 按最后消息时间排序
|
||||
val sortedList = combinedList.sortedByDescending {
|
||||
it.lastMessageTime
|
||||
}
|
||||
|
||||
allConversations = sortedList
|
||||
}
|
||||
|
||||
// 监听加载状态
|
||||
val isAnyLoading = AgentChatListViewModel.isLoading ||
|
||||
GroupChatListViewModel.isLoading ||
|
||||
FriendChatListViewModel.isLoading
|
||||
|
||||
// 当加载状态变化时,更新 isLoading
|
||||
LaunchedEffect(isAnyLoading) {
|
||||
if (isAnyLoading) {
|
||||
// 如果有任何数据正在加载,确保显示加载状态
|
||||
if (!isLoading) {
|
||||
isLoading = true
|
||||
}
|
||||
} else {
|
||||
// 所有数据加载完成
|
||||
if (isLoading) {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val state = rememberPullRefreshState(
|
||||
refreshing = refreshing,
|
||||
onRefresh = {
|
||||
refreshing = true
|
||||
refreshAllData(context,
|
||||
onSuccess = { conversations ->
|
||||
allConversations = conversations
|
||||
refreshing = false
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
error = errorMsg
|
||||
refreshing = false
|
||||
}
|
||||
)
|
||||
// 刷新所有类型的数据
|
||||
AgentChatListViewModel.refreshPager(pullRefresh = true, context = context)
|
||||
GroupChatListViewModel.refreshPager(pullRefresh = true, context = context)
|
||||
FriendChatListViewModel.refreshPager(pullRefresh = true, context = context)
|
||||
}
|
||||
)
|
||||
|
||||
// 监听刷新状态
|
||||
LaunchedEffect(AgentChatListViewModel.refreshing, GroupChatListViewModel.refreshing, FriendChatListViewModel.refreshing) {
|
||||
val isAnyRefreshing = AgentChatListViewModel.refreshing ||
|
||||
GroupChatListViewModel.refreshing ||
|
||||
FriendChatListViewModel.refreshing
|
||||
if (!isAnyRefreshing && refreshing) {
|
||||
refreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
isLoading = true
|
||||
refreshAllData(context,
|
||||
onSuccess = { conversations ->
|
||||
allConversations = conversations
|
||||
isLoading = false
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
error = errorMsg
|
||||
isLoading = false
|
||||
}
|
||||
)
|
||||
// 初始化加载所有类型的数据
|
||||
AgentChatListViewModel.refreshPager(context = context)
|
||||
GroupChatListViewModel.refreshPager(context = context)
|
||||
FriendChatListViewModel.refreshPager(context = context)
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -174,16 +228,15 @@ fun AllChatListScreen() {
|
||||
if (isNetworkAvailable) {
|
||||
Spacer(modifier = Modifier.height(39.dp))
|
||||
Image(
|
||||
painter = painterResource(
|
||||
id = if(AppState.darkMode) R.mipmap.piao_dark
|
||||
else R.mipmap.invalid_name_2),
|
||||
painter = painterResource(id = R.mipmap.invalid_name_3),
|
||||
contentDescription = "null data",
|
||||
modifier = Modifier
|
||||
.size(width = 181.dp, height = 153.dp)
|
||||
.width(181.dp)
|
||||
.height(153.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(if (AppState.darkMode) 9.dp else 24.dp))
|
||||
Spacer(modifier = Modifier.height(9.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.friend_chat_empty_title),
|
||||
text = stringResource(R.string.no_one_knocked_yet),
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
@@ -192,16 +245,6 @@ fun AllChatListScreen() {
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.friend_chat_empty_subtitle),
|
||||
color = AppColors.secondaryText,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(39.dp))
|
||||
Image(
|
||||
@@ -235,16 +278,10 @@ fun AllChatListScreen() {
|
||||
ReloadButton(
|
||||
onClick = {
|
||||
isLoading = true
|
||||
refreshAllData(context,
|
||||
onSuccess = { conversations ->
|
||||
allConversations = conversations
|
||||
isLoading = false
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
error = errorMsg
|
||||
isLoading = false
|
||||
}
|
||||
)
|
||||
// 重新加载所有类型的数据
|
||||
AgentChatListViewModel.refreshPager(context = context)
|
||||
GroupChatListViewModel.refreshPager(context = context)
|
||||
FriendChatListViewModel.refreshPager(context = context)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -363,44 +400,3 @@ fun AllChatListScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshAllData(
|
||||
context: android.content.Context,
|
||||
onSuccess: (List<CombinedConversation>) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
try {
|
||||
// 同时刷新所有类型的数据
|
||||
AgentChatListViewModel.refreshPager(context = context)
|
||||
GroupChatListViewModel.refreshPager(context = context)
|
||||
FriendChatListViewModel.refreshPager(context = context)
|
||||
|
||||
val combinedList = mutableListOf<CombinedConversation>()
|
||||
|
||||
AgentChatListViewModel.agentChatList.forEach { agent ->
|
||||
combinedList.add(CombinedConversation(type = "agent", agentConversation = agent))
|
||||
}
|
||||
|
||||
GroupChatListViewModel.groupChatList.forEach { group ->
|
||||
combinedList.add(CombinedConversation(type = "group", groupConversation = group))
|
||||
}
|
||||
|
||||
FriendChatListViewModel.friendChatList.forEach { friend ->
|
||||
val isDuplicate = combinedList.any {//判断重复
|
||||
it.type == "agent" && it.agentConversation?.trtcUserId == friend.trtcUserId
|
||||
}
|
||||
|
||||
if (!isDuplicate) {
|
||||
combinedList.add(CombinedConversation(type = "friend", friendConversation = friend))
|
||||
}
|
||||
}
|
||||
|
||||
// 按最后消息时间排序
|
||||
val sortedList = combinedList.sortedByDescending {
|
||||
it.lastMessageTime
|
||||
}
|
||||
|
||||
onSuccess(sortedList)
|
||||
} catch (e: Exception) {
|
||||
onError("刷新数据失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,16 +84,15 @@ fun FriendChatListScreen() {
|
||||
if (isNetworkAvailable) {
|
||||
Spacer(modifier = Modifier.height(39.dp))
|
||||
Image(
|
||||
painter = painterResource(
|
||||
id = if(AppState.darkMode) R.mipmap.piao_dark
|
||||
else R.mipmap.invalid_name_2),
|
||||
painter = painterResource(id = R.mipmap.invalid_name_3),
|
||||
contentDescription = "null data",
|
||||
modifier = Modifier
|
||||
.size(width = 181.dp, height = 153.dp)
|
||||
.width(181.dp)
|
||||
.height(153.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(if (AppState.darkMode) 9.dp else 24.dp))
|
||||
Spacer(modifier = Modifier.height(9.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.friend_chat_empty_title),
|
||||
text = stringResource(R.string.no_one_knocked_yet),
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
@@ -102,16 +101,6 @@ fun FriendChatListScreen() {
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.friend_chat_empty_subtitle),
|
||||
color = AppColors.secondaryText,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}else {
|
||||
Spacer(modifier = Modifier.height(39.dp))
|
||||
Image(
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.aiosman.ravenow.ui.index.tabs.message.tab
|
||||
|
||||
import android.content.Context
|
||||
import android.icu.util.Calendar
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -25,6 +26,7 @@ import io.openim.android.sdk.models.Message
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import com.aiosman.ravenow.utils.MessageParser
|
||||
import com.aiosman.ravenow.data.repo.TrtcUserTypeRepository
|
||||
|
||||
data class FriendConversation(
|
||||
val id: String,
|
||||
@@ -135,13 +137,19 @@ object FriendChatListViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
|
||||
// 过滤出朋友会话(单聊类型,且排除 AI 智能体)
|
||||
val filteredConversations = result.filter { conversation ->
|
||||
// 1 表示单聊,排除 AI 智能体会话
|
||||
conversation.conversationType == 1 &&
|
||||
// 可以根据实际业务逻辑添加更多过滤条件
|
||||
// 比如排除 AI 智能体的 userID 前缀或标识
|
||||
!(conversation.userID?.startsWith("ai_") == true)
|
||||
// 仅单聊
|
||||
val singleChats = result.filter { it.conversationType == 1 }
|
||||
val trtcIds = singleChats.mapNotNull { it.userID }.distinct()
|
||||
// 预热缓存(包含AI)
|
||||
try {
|
||||
TrtcUserTypeRepository.ensureTypes(context, trtcIds)
|
||||
} catch (e: Exception) {
|
||||
Log.w("FriendChatListViewModel", "ensureTypes failed: ${e.message}")
|
||||
}
|
||||
// 过滤出普通人会话(未知也归入普通人以回退)
|
||||
val filteredConversations = singleChats.filter { conversation ->
|
||||
val id = conversation.userID ?: return@filter true
|
||||
TrtcUserTypeRepository.getCachedType(id) != true
|
||||
}
|
||||
|
||||
friendChatList = filteredConversations.map { conversation ->
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -27,6 +26,7 @@ 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.compose.ui.graphics.Color
|
||||
import com.aiosman.ravenow.AppState
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
@@ -35,6 +35,7 @@ import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.rememberDebouncer
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.utils.NetworkUtils
|
||||
import com.aiosman.ravenow.ui.network.ReloadButton
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
@@ -77,16 +78,15 @@ fun GroupChatListScreen() {
|
||||
if (isNetworkAvailable) {
|
||||
Spacer(modifier = Modifier.height(39.dp))
|
||||
Image(
|
||||
painter = painterResource(
|
||||
id = if(AppState.darkMode) R.mipmap.fei_dark
|
||||
else R.mipmap.invalid_name_12),
|
||||
painter = painterResource(id = R.mipmap.invalid_name_3),
|
||||
contentDescription = "null data",
|
||||
modifier = Modifier
|
||||
.size(width = 181.dp, height = 153.dp)
|
||||
.width(181.dp)
|
||||
.height(153.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(if (AppState.darkMode) 9.dp else 24.dp))
|
||||
Spacer(modifier = Modifier.height(9.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_empty),
|
||||
text = stringResource(R.string.no_one_knocked_yet),
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600,
|
||||
@@ -95,16 +95,6 @@ fun GroupChatListScreen() {
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.group_chat_empty_join),
|
||||
color = AppColors.secondaryText,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}else {
|
||||
Spacer(modifier = Modifier.height(39.dp))
|
||||
Image(
|
||||
@@ -163,13 +153,6 @@ fun GroupChatListScreen() {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (index < GroupChatListViewModel.groupChatList.size - 1) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
color = AppColors.divider
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (GroupChatListViewModel.isLoading && GroupChatListViewModel.groupChatList.isNotEmpty()) {
|
||||
@@ -223,16 +206,16 @@ fun GroupChatItem(
|
||||
val AppColors = LocalAppTheme.current
|
||||
val chatDebouncer = rememberDebouncer()
|
||||
val avatarDebouncer = rememberDebouncer()
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.noRippleClickable {
|
||||
chatDebouncer {
|
||||
onChatClick(conversation)
|
||||
}
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box {
|
||||
CustomAsyncImage(
|
||||
@@ -252,9 +235,9 @@ fun GroupChatItem(
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp)
|
||||
.padding(start = 12.dp, top = 2.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -262,22 +245,22 @@ fun GroupChatItem(
|
||||
) {
|
||||
Text(
|
||||
text = conversation.groupName,
|
||||
fontSize = 16.sp,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = AppColors.text,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
|
||||
Text(
|
||||
text = conversation.lastMessageTime,
|
||||
fontSize = 12.sp,
|
||||
fontSize = 11.sp,
|
||||
color = AppColors.secondaryText
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -285,29 +268,29 @@ fun GroupChatItem(
|
||||
) {
|
||||
Text(
|
||||
text = "${if (conversation.isSelf) stringResource(R.string.friend_chat_me_prefix) else ""}${conversation.displayText}",
|
||||
fontSize = 14.sp,
|
||||
fontSize = 12.sp,
|
||||
color = AppColors.secondaryText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
if (conversation.unreadCount > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (conversation.unreadCount > 99) 24.dp else 20.dp)
|
||||
.background(
|
||||
color = AppColors.main,
|
||||
color = Color(0xFFFF3B30),
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (conversation.unreadCount > 99) "99+" else conversation.unreadCount.toString(),
|
||||
color = AppColors.mainText,
|
||||
fontSize = if (conversation.unreadCount > 99) 9.sp else 10.sp,
|
||||
color = Color.White,
|
||||
fontSize = if (conversation.unreadCount > 99) 11.sp else 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,8 +136,8 @@ object GroupChatListViewModel : ViewModel() {
|
||||
|
||||
private suspend fun loadGroupChatList(context: Context) {
|
||||
// 检查 OpenIM 是否已登录
|
||||
if (!com.aiosman.ravenow.AppState.enableChat) {
|
||||
android.util.Log.w("GroupChatListViewModel", "OpenIM 未登录,跳过加载群聊列表")
|
||||
if (!AppState.enableChat) {
|
||||
Log.w("GroupChatListViewModel", "OpenIM 未登录,跳过加载群聊列表")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -158,8 +158,8 @@ object GroupChatListViewModel : ViewModel() {
|
||||
|
||||
// 过滤出群聊会话(群聊类型)
|
||||
val filteredConversations = result.filter { conversation ->
|
||||
// 2 表示群聊类型
|
||||
conversation.conversationType == 2
|
||||
// 3 表示群聊类型
|
||||
conversation.conversationType == 3
|
||||
}
|
||||
|
||||
groupChatList = filteredConversations.map { conversation ->
|
||||
|
||||
@@ -29,9 +29,12 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
@@ -48,6 +51,7 @@ import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.Explore
|
||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.hot.HotMomentsList
|
||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline.TimelineMomentsList
|
||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.news.NewsScreen
|
||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend.RecommendScreen
|
||||
import com.aiosman.ravenow.ui.index.tabs.search.SearchViewModel
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -56,6 +60,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import com.aiosman.ravenow.ui.composables.TabItem
|
||||
import com.aiosman.ravenow.ui.composables.UnderlineTabItem
|
||||
import com.aiosman.ravenow.ui.composables.rememberDebouncer
|
||||
@@ -66,7 +71,9 @@ import com.aiosman.ravenow.ui.index.tabs.moment.tabs.shorts.ShortVideoScreen
|
||||
*/
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MomentsList() {
|
||||
fun MomentsList(
|
||||
isPageVisible: Boolean = true
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val navController = LocalNavController.current
|
||||
val navigationBarPaddings =
|
||||
@@ -76,6 +83,14 @@ fun MomentsList() {
|
||||
val tabCount = if (AppStore.isGuest) 5 else 6
|
||||
var pagerState = rememberPagerState { tabCount }
|
||||
var scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val density = LocalDensity.current
|
||||
// 存储每个标签的位置和宽度信息 (页面索引, 位置, 宽度)
|
||||
val tabPositions = remember { mutableStateOf<List<Triple<Int, Float, Float>>>(emptyList()) }
|
||||
// 存储容器宽度
|
||||
val containerWidth = remember { mutableStateOf(0f) }
|
||||
// 记录上一次的页面索引
|
||||
val previousPage = remember { mutableStateOf(pagerState.currentPage) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -89,22 +104,44 @@ fun MomentsList() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(44.dp)
|
||||
.height(54.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 可滚动的标签页行
|
||||
Row(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
.onGloballyPositioned { coordinates ->
|
||||
containerWidth.value = with(density) { coordinates.size.width.toDp().toPx() }
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(scrollState),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val tabDebouncer = rememberDebouncer()
|
||||
|
||||
// 推荐标签
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
val position = with(density) { coordinates.positionInParent().x.toDp().toPx() }
|
||||
val width = with(density) { coordinates.size.width.toDp().toPx() }
|
||||
val currentPositions = tabPositions.value.toMutableList()
|
||||
val existingIndex = currentPositions.indexOfFirst { it.first == 0 }
|
||||
if (existingIndex == -1) {
|
||||
currentPositions.add(Triple(0, position, width))
|
||||
tabPositions.value = currentPositions.sortedBy { it.first }
|
||||
} else {
|
||||
currentPositions[existingIndex] = Triple(0, position, width)
|
||||
tabPositions.value = currentPositions
|
||||
}
|
||||
}
|
||||
) {
|
||||
UnderlineTabItem(
|
||||
text = stringResource(R.string.tab_recommend),
|
||||
isSelected = pagerState.currentPage == 0,
|
||||
@@ -116,9 +153,25 @@ fun MomentsList() {
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 短视频标签
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
val position = with(density) { coordinates.positionInParent().x.toDp().toPx() }
|
||||
val width = with(density) { coordinates.size.width.toDp().toPx() }
|
||||
val currentPositions = tabPositions.value.toMutableList()
|
||||
val existingIndex = currentPositions.indexOfFirst { it.first == 1 }
|
||||
if (existingIndex == -1) {
|
||||
currentPositions.add(Triple(1, position, width))
|
||||
tabPositions.value = currentPositions.sortedBy { it.first }
|
||||
} else {
|
||||
currentPositions[existingIndex] = Triple(1, position, width)
|
||||
tabPositions.value = currentPositions
|
||||
}
|
||||
}
|
||||
) {
|
||||
UnderlineTabItem(
|
||||
text = stringResource(R.string.tab_short_video),
|
||||
isSelected = pagerState.currentPage == 1,
|
||||
@@ -130,38 +183,110 @@ fun MomentsList() {
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 动态标签
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
val position = with(density) { coordinates.positionInParent().x.toDp().toPx() }
|
||||
val width = with(density) { coordinates.size.width.toDp().toPx() }
|
||||
val currentPositions = tabPositions.value.toMutableList()
|
||||
val existingIndex = currentPositions.indexOfFirst { it.first == 2 }
|
||||
if (existingIndex == -1) {
|
||||
currentPositions.add(Triple(2, position, width))
|
||||
tabPositions.value = currentPositions.sortedBy { it.first }
|
||||
} else {
|
||||
currentPositions[existingIndex] = Triple(2, position, width)
|
||||
tabPositions.value = currentPositions
|
||||
}
|
||||
}
|
||||
) {
|
||||
UnderlineTabItem(
|
||||
text = stringResource(R.string.moment),
|
||||
isSelected = pagerState.currentPage == 2,
|
||||
onClick = {
|
||||
tabDebouncer {
|
||||
scope.launch {
|
||||
// 如果当前在动态标签右边的标签页,立即向左滚动显示推荐标签
|
||||
if (pagerState.currentPage > 2 && containerWidth.value > 0) {
|
||||
val recommendTab = tabPositions.value.find { it.first == 0 }
|
||||
if (recommendTab != null) {
|
||||
val (_, recommendPosition, _) = recommendTab
|
||||
val currentScroll = scrollState.value.toFloat()
|
||||
val scrollToPosition = recommendPosition.coerceAtLeast(0f)
|
||||
if (kotlin.math.abs(scrollToPosition - currentScroll) > 1f) {
|
||||
scrollState.animateScrollTo(scrollToPosition.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
pagerState.animateScrollToPage(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 只有非游客用户才显示"关注"tab
|
||||
if (!AppStore.isGuest) {
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
val position = with(density) { coordinates.positionInParent().x.toDp().toPx() }
|
||||
val width = with(density) { coordinates.size.width.toDp().toPx() }
|
||||
val currentPositions = tabPositions.value.toMutableList()
|
||||
val existingIndex = currentPositions.indexOfFirst { it.first == 3 }
|
||||
if (existingIndex == -1) {
|
||||
currentPositions.add(Triple(3, position, width))
|
||||
tabPositions.value = currentPositions.sortedBy { it.first }
|
||||
} else {
|
||||
currentPositions[existingIndex] = Triple(3, position, width)
|
||||
tabPositions.value = currentPositions
|
||||
}
|
||||
}
|
||||
) {
|
||||
UnderlineTabItem(
|
||||
text = stringResource(R.string.index_following),
|
||||
isSelected = pagerState.currentPage == 3,
|
||||
onClick = {
|
||||
tabDebouncer {
|
||||
scope.launch {
|
||||
// 如果当前在关注标签左边的标签页,立即向右滚动显示新闻标签
|
||||
if (pagerState.currentPage < 3 && containerWidth.value > 0) {
|
||||
val newsTab = tabPositions.value.find { it.first == 5 }
|
||||
if (newsTab != null) {
|
||||
val (_, newsPosition, newsWidth) = newsTab
|
||||
val currentScroll = scrollState.value.toFloat()
|
||||
val scrollToPosition = (newsPosition + newsWidth - containerWidth.value).coerceAtLeast(0f)
|
||||
if (kotlin.math.abs(scrollToPosition - currentScroll) > 1f) {
|
||||
scrollState.animateScrollTo(scrollToPosition.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
pagerState.animateScrollToPage(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 热门标签
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
val position = with(density) { coordinates.positionInParent().x.toDp().toPx() }
|
||||
val width = with(density) { coordinates.size.width.toDp().toPx() }
|
||||
val currentPositions = tabPositions.value.toMutableList()
|
||||
val existingIndex = currentPositions.indexOfFirst { it.first == 4 }
|
||||
if (existingIndex == -1) {
|
||||
currentPositions.add(Triple(4, position, width))
|
||||
tabPositions.value = currentPositions.sortedBy { it.first }
|
||||
} else {
|
||||
currentPositions[existingIndex] = Triple(4, position, width)
|
||||
tabPositions.value = currentPositions
|
||||
}
|
||||
}
|
||||
) {
|
||||
UnderlineTabItem(
|
||||
text = stringResource(R.string.index_hot),
|
||||
isSelected = pagerState.currentPage == 4,
|
||||
@@ -173,8 +298,24 @@ fun MomentsList() {
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 热门标签 (游客模式) - 在游客模式下,热门标签对应第3页
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
val position = with(density) { coordinates.positionInParent().x.toDp().toPx() }
|
||||
val width = with(density) { coordinates.size.width.toDp().toPx() }
|
||||
val currentPositions = tabPositions.value.toMutableList()
|
||||
val existingIndex = currentPositions.indexOfFirst { it.first == 3 }
|
||||
if (existingIndex == -1) {
|
||||
currentPositions.add(Triple(3, position, width))
|
||||
tabPositions.value = currentPositions.sortedBy { it.first }
|
||||
} else {
|
||||
currentPositions[existingIndex] = Triple(3, position, width)
|
||||
tabPositions.value = currentPositions
|
||||
}
|
||||
}
|
||||
) {
|
||||
UnderlineTabItem(
|
||||
text = stringResource(R.string.index_hot),
|
||||
isSelected = pagerState.currentPage == 3,
|
||||
@@ -187,10 +328,26 @@ fun MomentsList() {
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 新闻标签 - 在游客模式下对应第4页,非游客模式下对应第5页
|
||||
val newsPageIndex = if (AppStore.isGuest) 4 else 5
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
val position = with(density) { coordinates.positionInParent().x.toDp().toPx() }
|
||||
val width = with(density) { coordinates.size.width.toDp().toPx() }
|
||||
val currentPositions = tabPositions.value.toMutableList()
|
||||
val existingIndex = currentPositions.indexOfFirst { it.first == newsPageIndex }
|
||||
if (existingIndex == -1) {
|
||||
currentPositions.add(Triple(newsPageIndex, position, width))
|
||||
tabPositions.value = currentPositions.sortedBy { it.first }
|
||||
} else {
|
||||
currentPositions[existingIndex] = Triple(newsPageIndex, position, width)
|
||||
tabPositions.value = currentPositions
|
||||
}
|
||||
}
|
||||
) {
|
||||
UnderlineTabItem(
|
||||
text = stringResource(R.string.tab_news),
|
||||
isSelected = pagerState.currentPage == newsPageIndex,
|
||||
@@ -203,6 +360,8 @@ fun MomentsList() {
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索按钮
|
||||
val lastClickTime = remember { mutableStateOf(0L) }
|
||||
@@ -223,6 +382,50 @@ fun MomentsList() {
|
||||
)
|
||||
}
|
||||
|
||||
// 监听页面变化,实现自动滚动
|
||||
LaunchedEffect(pagerState.currentPage, pagerState.currentPageOffsetFraction) {
|
||||
val currentPage = pagerState.currentPage
|
||||
val offsetFraction = pagerState.currentPageOffsetFraction
|
||||
val prevPage = previousPage.value
|
||||
|
||||
// 只在页面切换接近完成时执行(避免滑动过程中频繁触发)
|
||||
val absOffset = kotlin.math.abs(offsetFraction)
|
||||
if (absOffset < 0.1f && containerWidth.value > 0) {
|
||||
// 情况1:从关注标签页左边的标签页(0,1,2)滑动到关注标签页(3)时,向右滚动显示新闻标签(5)
|
||||
if (currentPage == 3 && prevPage < 3 && !AppStore.isGuest) {
|
||||
val newsTab = tabPositions.value.find { it.first == 5 }
|
||||
if (newsTab != null) {
|
||||
val (_, newsPosition, newsWidth) = newsTab
|
||||
val currentScroll = scrollState.value.toFloat()
|
||||
// 计算滚动位置,使新闻标签可见(确保新闻标签的结束位置在可见区域内)
|
||||
val scrollToPosition = (newsPosition + newsWidth - containerWidth.value).coerceAtLeast(0f)
|
||||
if (kotlin.math.abs(scrollToPosition - currentScroll) > 1f) {
|
||||
scrollState.animateScrollTo(scrollToPosition.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 情况2:从动态标签页右边的标签页(3,4,5)滑动到动态标签页(2)时,向左滚动显示推荐标签(0)
|
||||
if (currentPage == 2 && prevPage > 2) {
|
||||
val recommendTab = tabPositions.value.find { it.first == 0 }
|
||||
if (recommendTab != null) {
|
||||
val (_, recommendPosition, _) = recommendTab
|
||||
val currentScroll = scrollState.value.toFloat()
|
||||
// 计算滚动位置,使推荐标签可见(确保推荐标签的起始位置在可见区域内)
|
||||
val scrollToPosition = recommendPosition.coerceAtLeast(0f)
|
||||
if (kotlin.math.abs(scrollToPosition - currentScroll) > 1f) {
|
||||
scrollState.animateScrollTo(scrollToPosition.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新上一次的页面索引
|
||||
if (currentPage != prevPage) {
|
||||
previousPage.value = currentPage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier
|
||||
@@ -232,11 +435,11 @@ fun MomentsList() {
|
||||
when (it) {
|
||||
0 -> {
|
||||
// 推荐页面
|
||||
NewsScreen()
|
||||
RecommendScreen()
|
||||
}
|
||||
1 -> {
|
||||
// 短视频页面
|
||||
ShortVideoScreen()
|
||||
ShortVideoScreen(isPageVisible = pagerState.currentPage == 1 && isPageVisible)
|
||||
}
|
||||
2 -> {
|
||||
// 动态页面 - 暂时显示时间线内容
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user