Compare commits
2 Commits
main
...
launch-spe
| Author | SHA1 | Date | |
|---|---|---|---|
| efa96c8e74 | |||
| dbcb4025a6 |
2
.idea/.name
generated
2
.idea/.name
generated
@@ -1 +1 @@
|
|||||||
RaveNow
|
RiderPro
|
||||||
2
.idea/compiler.xml
generated
2
.idea/compiler.xml
generated
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="CompilerConfiguration">
|
<component name="CompilerConfiguration">
|
||||||
<bytecodeTargetLevel target="21" />
|
<bytecodeTargetLevel target="17" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
4
.idea/deploymentTargetSelector.xml
generated
4
.idea/deploymentTargetSelector.xml
generated
@@ -4,10 +4,10 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2025-11-11T06:03:31.167121900Z">
|
<DropdownSelection timestamp="2024-08-11T15:57:44.196893Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=f800b364" />
|
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/kevinlinpr/.android/avd/Pixel_8_Pro_API_34.avd" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
|
|||||||
2
.idea/gradle.xml
generated
2
.idea/gradle.xml
generated
@@ -4,7 +4,6 @@
|
|||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
@@ -13,6 +12,7 @@
|
|||||||
<option value="$PROJECT_DIR$/app" />
|
<option value="$PROJECT_DIR$/app" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
|
<option name="resolveExternalAnnotations" value="false" />
|
||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
4
.idea/inspectionProfiles/Project_Default.xml
generated
4
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -65,10 +65,6 @@
|
|||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
|
||||||
<option name="composableFile" value="true" />
|
|
||||||
<option name="previewFile" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
|
|||||||
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="KotlinJpsPluginSettings">
|
<component name="KotlinJpsPluginSettings">
|
||||||
<option name="version" value="2.2.21" />
|
<option name="version" value="1.9.0" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
3
.idea/misc.xml
generated
3
.idea/misc.xml
generated
@@ -1,6 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
|
||||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectType">
|
<component name="ProjectType">
|
||||||
|
|||||||
4
.idea/runConfigurations.xml
generated
4
.idea/runConfigurations.xml
generated
@@ -5,12 +5,8 @@
|
|||||||
<set>
|
<set>
|
||||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
|
||||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
|
||||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
|
||||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
# 腾讯云 IM SDK 到 OpenIM 迁移指南
|
|
||||||
|
|
||||||
## 迁移状态
|
|
||||||
|
|
||||||
### ✅ 已完成的工作
|
|
||||||
|
|
||||||
1. **依赖项迁移**
|
|
||||||
- 从 `build.gradle.kts` 中移除了腾讯云 IM SDK 依赖 (`libs.imsdk.plus`)
|
|
||||||
- 从 `gradle/libs.versions.toml` 中移除了相关版本定义
|
|
||||||
- 保留了 OpenIM SDK 依赖 (`io.openim:android-sdk`, `io.openim:core-sdk`)
|
|
||||||
|
|
||||||
2. **核心组件迁移**
|
|
||||||
- ✅ `TrtcHelper.kt` - 完全迁移到 OpenIM API
|
|
||||||
- ✅ `Chat.kt` 实体类 - 更新为 OpenIM 消息模型
|
|
||||||
- ✅ `AgentChatListViewModel.kt` - 部分迁移到 OpenIM API
|
|
||||||
- ✅ `OpenIMManager.kt` - 完整的 OpenIM 管理器
|
|
||||||
- ✅ `OpenIMService.kt` - OpenIM 后台服务
|
|
||||||
- ✅ `AppState.kt` - 已使用 OpenIM 进行初始化和登录
|
|
||||||
|
|
||||||
3. **兼容层创建**
|
|
||||||
- 创建了 `TencentIMCompat.kt` 兼容层,避免编译错误
|
|
||||||
- 所有使用腾讯云 IM 的文件都已添加兼容层导入
|
|
||||||
|
|
||||||
4. **配置清理**
|
|
||||||
- AndroidManifest.xml 已经是干净的,无需额外清理
|
|
||||||
|
|
||||||
### 🔄 需要进一步完成的工作
|
|
||||||
|
|
||||||
#### 1. 完整的 ViewModel 迁移
|
|
||||||
以下文件仍在使用兼容层,需要完全迁移到 OpenIM:
|
|
||||||
|
|
||||||
- `ChatViewModel.kt` - 聊天功能核心
|
|
||||||
- `ChatAiViewModel.kt` - AI 聊天功能
|
|
||||||
- `GroupChatViewModel.kt` - 群聊功能
|
|
||||||
- `FriendChatListViewModel.kt` - 好友聊天列表
|
|
||||||
- `GroupChatListViewModel.kt` - 群聊列表
|
|
||||||
- `MessageListViewModel.kt` - 消息列表
|
|
||||||
- `MineAgentViewModel.kt` - 我的智能体
|
|
||||||
- `CreateGroupChatViewModel.kt` - 创建群聊
|
|
||||||
|
|
||||||
#### 2. UI 组件迁移
|
|
||||||
以下 Screen 文件需要更新以使用新的数据模型:
|
|
||||||
|
|
||||||
- `ChatScreen.kt`
|
|
||||||
- `ChatAiScreen.kt`
|
|
||||||
- `GroupChatScreen.kt`
|
|
||||||
|
|
||||||
#### 3. 消息类型映射
|
|
||||||
需要完善 OpenIM 消息类型到应用内部类型的映射:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// OpenIM 消息类型
|
|
||||||
101 -> TEXT
|
|
||||||
102 -> IMAGE
|
|
||||||
103 -> AUDIO
|
|
||||||
104 -> VIDEO
|
|
||||||
105 -> FILE
|
|
||||||
```
|
|
||||||
|
|
||||||
## OpenIM 集成状态
|
|
||||||
|
|
||||||
### ✅ 已集成的功能
|
|
||||||
|
|
||||||
1. **SDK 初始化** - `OpenIMManager.initSDK()`
|
|
||||||
2. **用户登录** - `AppState.loginToOpenIM()`
|
|
||||||
3. **连接监听** - 连接状态、踢下线、token 过期
|
|
||||||
4. **消息监听** - 新消息、消息撤回、已读回执等
|
|
||||||
5. **会话管理** - 会话变化、未读数统计
|
|
||||||
6. **用户信息管理** - 用户资料更新
|
|
||||||
7. **好友关系管理** - 好友申请、添加、删除等
|
|
||||||
8. **群组管理** - 群信息变更、成员管理等
|
|
||||||
|
|
||||||
### 🔧 需要实现的功能
|
|
||||||
|
|
||||||
1. **消息发送**
|
|
||||||
```kotlin
|
|
||||||
// 需要实现
|
|
||||||
OpenIMClient.getInstance().messageManager.sendMessage(...)
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **历史消息获取**
|
|
||||||
```kotlin
|
|
||||||
// 需要实现
|
|
||||||
OpenIMClient.getInstance().messageManager.getAdvancedHistoryMessageList(...)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **会话列表获取**
|
|
||||||
```kotlin
|
|
||||||
// 已在 AgentChatListViewModel 中部分实现
|
|
||||||
OpenIMClient.getInstance().conversationManager.getAllConversationList(...)
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **图片消息处理**
|
|
||||||
- 需要适配 OpenIM 的 `PictureElem` 结构
|
|
||||||
- 更新图片显示逻辑
|
|
||||||
|
|
||||||
## 迁移步骤建议
|
|
||||||
|
|
||||||
### 第一阶段:核心聊天功能
|
|
||||||
1. 完成 `ChatViewModel.kt` 的完整迁移
|
|
||||||
2. 实现消息发送和接收
|
|
||||||
3. 实现历史消息加载
|
|
||||||
4. 测试基本聊天功能
|
|
||||||
|
|
||||||
### 第二阶段:会话管理
|
|
||||||
1. 完成各种 ChatListViewModel 的迁移
|
|
||||||
2. 实现会话列表的正确显示
|
|
||||||
3. 实现未读消息统计
|
|
||||||
|
|
||||||
### 第三阶段:高级功能
|
|
||||||
1. 群聊功能迁移
|
|
||||||
2. 文件、图片等多媒体消息
|
|
||||||
3. 消息状态和已读回执
|
|
||||||
|
|
||||||
### 第四阶段:清理和优化
|
|
||||||
1. 删除兼容层 `TencentIMCompat.kt`
|
|
||||||
2. 清理所有临时代码
|
|
||||||
3. 性能优化和测试
|
|
||||||
|
|
||||||
## 关键 API 对比
|
|
||||||
|
|
||||||
### 消息发送
|
|
||||||
```kotlin
|
|
||||||
// 腾讯云 IM
|
|
||||||
V2TIMManager.getMessageManager().sendMessage(message, receiver, null, ...)
|
|
||||||
|
|
||||||
// OpenIM
|
|
||||||
OpenIMClient.getInstance().messageManager.sendMessage(message, receiver, ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 获取会话列表
|
|
||||||
```kotlin
|
|
||||||
// 腾讯云 IM
|
|
||||||
V2TIMManager.getConversationManager().getConversationList(...)
|
|
||||||
|
|
||||||
// OpenIM
|
|
||||||
OpenIMClient.getInstance().conversationManager.getAllConversationList(...)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 消息监听
|
|
||||||
```kotlin
|
|
||||||
// 腾讯云 IM
|
|
||||||
V2TIMManager.getMessageManager().addAdvancedMsgListener(listener)
|
|
||||||
|
|
||||||
// OpenIM
|
|
||||||
OpenIMClient.getInstance().messageManager.setAdvancedMsgListener(listener)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **数据结构差异**:OpenIM 和腾讯云 IM 的数据结构有所不同,需要仔细映射
|
|
||||||
2. **回调机制**:OpenIM 使用不同的回调接口
|
|
||||||
3. **消息 ID**:OpenIM 使用 `clientMsgID`,腾讯云使用 `msgID`
|
|
||||||
4. **时间戳**:注意时间戳的单位和格式差异
|
|
||||||
5. **用户 ID**:确保用户 ID 在两个系统中的一致性
|
|
||||||
|
|
||||||
## 测试建议
|
|
||||||
|
|
||||||
1. **单元测试**:为每个迁移的组件编写测试
|
|
||||||
2. **集成测试**:测试完整的聊天流程
|
|
||||||
3. **兼容性测试**:确保与现有数据的兼容性
|
|
||||||
4. **性能测试**:对比迁移前后的性能表现
|
|
||||||
|
|
||||||
## 删除兼容层的时机
|
|
||||||
|
|
||||||
当以下条件都满足时,可以安全删除 `TencentIMCompat.kt`:
|
|
||||||
|
|
||||||
1. 所有 ViewModel 都已完全迁移到 OpenIM
|
|
||||||
2. 所有功能都已测试通过
|
|
||||||
3. 没有编译错误
|
|
||||||
4. 应用运行正常
|
|
||||||
|
|
||||||
删除步骤:
|
|
||||||
1. 删除 `app/src/main/java/com/aiosman/ravenow/compat/TencentIMCompat.kt`
|
|
||||||
2. 从所有文件中移除 `import com.aiosman.ravenow.compat.*`
|
|
||||||
3. 清理所有相关的临时注释
|
|
||||||
@@ -1,24 +1,21 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.jetbrains.kotlin.android)
|
alias(libs.plugins.jetbrains.kotlin.android)
|
||||||
alias(libs.plugins.compose.compiler)
|
|
||||||
id("com.google.gms.google-services")
|
id("com.google.gms.google-services")
|
||||||
id("com.google.firebase.crashlytics")
|
id("com.google.firebase.crashlytics")
|
||||||
id("com.google.firebase.firebase-perf")
|
id("com.google.firebase.firebase-perf")
|
||||||
id("org.jetbrains.kotlin.kapt")
|
|
||||||
alias(libs.plugins.ksp)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
android {
|
android {
|
||||||
namespace = "com.aiosman.ravenow"
|
namespace = "com.aiosman.riderpro"
|
||||||
compileSdk = 35
|
compileSdk = 34
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.aiosman.ravenow"
|
applicationId = "com.aiosman.riderpro"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 34
|
||||||
versionCode = 1000019
|
versionCode = 1000014
|
||||||
versionName = "1.0.000.19"
|
versionName = "1.0.000.14"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@@ -27,7 +24,7 @@ android {
|
|||||||
addManifestPlaceholders(
|
addManifestPlaceholders(
|
||||||
mapOf(
|
mapOf(
|
||||||
"JPUSH_PKGNAME " to applicationId!!,
|
"JPUSH_PKGNAME " to applicationId!!,
|
||||||
"JPUSH_APPKEY" to "cbd968cae60346065e03f9d7",
|
"JPUSH_APPKEY" to "ad805ee9f2760376f4f47178",
|
||||||
"JPUSH_CHANNEL" to "developer-default",
|
"JPUSH_CHANNEL" to "developer-default",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,9 +32,6 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
|
||||||
isDebuggable = true
|
|
||||||
}
|
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
@@ -47,15 +41,17 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "17"
|
jvmTarget = "1.8"
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
buildConfig = true
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.1"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
@@ -97,48 +93,27 @@ dependencies {
|
|||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
implementation(libs.androidx.animation)
|
implementation(libs.androidx.animation)
|
||||||
implementation(libs.coil)
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||||
implementation(libs.coil.compose)
|
implementation("io.coil-kt:coil:2.7.0")
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation("com.google.android.gms:play-services-auth:21.2.0")
|
||||||
implementation(libs.play.services.auth)
|
implementation("io.github.serpro69:kotlin-faker:2.0.0-rc.5")
|
||||||
implementation(libs.kotlin.faker)
|
implementation("androidx.compose.material:material:1.6.8")
|
||||||
implementation(libs.androidx.material)
|
implementation("net.engawapg.lib:zoomable:1.6.1")
|
||||||
implementation(libs.androidx.material.icons.extended)
|
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
||||||
implementation(libs.zoomable)
|
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
|
||||||
implementation(libs.retrofit)
|
implementation("androidx.credentials:credentials:1.2.2")
|
||||||
implementation(libs.converter.gson)
|
implementation("androidx.credentials:credentials-play-services-auth:1.2.2")
|
||||||
implementation(libs.androidx.credentials)
|
implementation("com.auth0.android:jwtdecode:2.0.2")
|
||||||
implementation(libs.androidx.credentials.play.services.auth)
|
|
||||||
implementation(libs.jwtdecode)
|
|
||||||
|
|
||||||
implementation(platform(libs.firebase.bom))
|
implementation(platform("com.google.firebase:firebase-bom:33.2.0"))
|
||||||
implementation(libs.firebase.crashlytics)
|
implementation("com.google.firebase:firebase-crashlytics")
|
||||||
implementation(libs.firebase.analytics)
|
implementation("com.google.firebase:firebase-analytics")
|
||||||
implementation(libs.firebase.perf)
|
implementation("com.google.firebase:firebase-perf")
|
||||||
|
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||||
implementation(libs.firebase.messaging.ktx)
|
implementation ("cn.jiguang.sdk:jpush-google:5.4.0")
|
||||||
|
api ("com.tencent.imsdk:imsdk-plus:8.1.6116")
|
||||||
implementation (libs.jpush.google)
|
implementation("io.github.rroohit:ImageCropView:3.0.1")
|
||||||
implementation (libs.im.sdk)
|
implementation("androidx.core:core-splashscreen:1.0.1") // 添加 SplashScreen 依赖
|
||||||
implementation (libs.im.core.sdk)
|
|
||||||
implementation (libs.gson)
|
|
||||||
implementation(libs.imagecropview)
|
|
||||||
implementation(libs.androidx.core.splashscreen) // 添加 SplashScreen 依赖
|
|
||||||
// 添加 lifecycle-runtime-ktx 依赖
|
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx.v262)
|
|
||||||
implementation (libs.eventbus)
|
|
||||||
implementation(libs.lottie)
|
|
||||||
|
|
||||||
// CameraX + ML Kit(版本在 libs.versions.toml)
|
|
||||||
implementation(libs.androidx.camera.camera2)
|
|
||||||
implementation(libs.androidx.camera.lifecycle)
|
|
||||||
implementation(libs.androidx.camera.view)
|
|
||||||
implementation(libs.mlkit.barcode.scanning)
|
|
||||||
|
|
||||||
// Room 持久化
|
|
||||||
implementation(libs.androidx.room.runtime)
|
|
||||||
implementation(libs.androidx.room.ktx)
|
|
||||||
ksp(libs.androidx.room.compiler)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"client_info": {
|
"client_info": {
|
||||||
"mobilesdk_app_id": "1:987156664714:android:2c29c11b9cd8be78b9f873",
|
"mobilesdk_app_id": "1:987156664714:android:2c29c11b9cd8be78b9f873",
|
||||||
"android_client_info": {
|
"android_client_info": {
|
||||||
"package_name": "com.aiosman.ravenow"
|
"package_name": "com.aiosman.riderpro"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [],
|
"oauth_client": [],
|
||||||
|
|||||||
21
app/proguard-rules.pro
vendored
21
app/proguard-rules.pro
vendored
@@ -20,23 +20,4 @@
|
|||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-renamesourcefileattribute SourceFile
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
||||||
# OpenIM SDK ProGuard rules
|
-keep class com.tencent.imsdk.** { *; }
|
||||||
-keep class io.openim.android.sdk.** { *; }
|
|
||||||
-keep class io.openim.core.** { *; }
|
|
||||||
-keepclassmembers class io.openim.android.sdk.** { *; }
|
|
||||||
-keepclassmembers class io.openim.core.** { *; }
|
|
||||||
|
|
||||||
# Keep OpenIM models and listeners
|
|
||||||
-keep class io.openim.android.sdk.models.** { *; }
|
|
||||||
-keep class io.openim.android.sdk.listener.** { *; }
|
|
||||||
-keep class io.openim.android.sdk.enums.** { *; }
|
|
||||||
|
|
||||||
# Keep OpenIM Client and managers
|
|
||||||
-keep class io.openim.android.sdk.OpenIMClient { *; }
|
|
||||||
-keep class io.openim.android.sdk.manager.** { *; }
|
|
||||||
|
|
||||||
# Prevent obfuscation of callback methods
|
|
||||||
-keepclassmembers class * implements io.openim.android.sdk.listener.** {
|
|
||||||
public *;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.aiosman.ravenow
|
package com.aiosman.riderpro
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
|
|||||||
fun useAppContext() {
|
fun useAppContext() {
|
||||||
// Context of the app under test.
|
// Context of the app under test.
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
assertEquals("com.aiosman.ravenow", appContext.packageName)
|
assertEquals("com.aiosman.riderpro", appContext.packageName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,24 +4,19 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
|
||||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".RaveNowApplication"
|
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/invalid_name"
|
android:icon="@mipmap/rider_pro_logo_red"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/rider_pro_logo_next_round"
|
android:roundIcon="@mipmap/rider_pro_logo_red_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.RaveNow"
|
android:theme="@style/Theme.RiderPro"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.geo.API_KEY"
|
android:name="com.google.android.geo.API_KEY"
|
||||||
@@ -39,22 +34,12 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||||
android:value="Default Message" />
|
android:value="Default Message" />
|
||||||
<meta-data android:name="com.google.ar.core" android:value="optional" />
|
|
||||||
<!-- Firebase Performance 配置:禁用自动网络请求监控 -->
|
|
||||||
<meta-data
|
|
||||||
android:name="firebase_performance_collection_enabled"
|
|
||||||
android:value="true" />
|
|
||||||
<meta-data
|
|
||||||
android:name="firebase_performance_logcat_enabled"
|
|
||||||
android:value="false" />
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/Theme.App.Starting"
|
android:theme="@style/Theme.RiderPro"
|
||||||
android:screenOrientation="portrait"
|
android:windowSoftInputMode="adjustResize">
|
||||||
android:windowSoftInputMode="adjustResize"
|
|
||||||
android:configChanges="fontScale|orientation|screenSize|keyboardHidden|uiMode">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
@@ -67,13 +52,6 @@
|
|||||||
<data android:mimeType="image/*" />
|
<data android:mimeType="image/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<receiver
|
|
||||||
android:name=".model.ApkInstallReceiver"
|
|
||||||
android:exported="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".MyFirebaseMessagingService"
|
android:name=".MyFirebaseMessagingService"
|
||||||
@@ -92,7 +70,7 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
<service
|
<service
|
||||||
android:name=".OpenIMService"
|
android:name=".TrtcService"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
@@ -101,14 +79,14 @@
|
|||||||
android:exported="false">
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="cn.jpush.android.intent.RECEIVER_MESSAGE" />
|
<action android:name="cn.jpush.android.intent.RECEIVER_MESSAGE" />
|
||||||
<category android:name="com.aiosman.ravenow" />
|
<category android:name="com.aiosman.riderpro" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="com.aiosman.ravenow.fileprovider"
|
android:authorities="com.aiosman.riderpro.fileprovider"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:grantUriPermissions="true">
|
android:grantUriPermissions="true">
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,282 +0,0 @@
|
|||||||
package com.aiosman.ravenow
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.icu.util.Calendar
|
|
||||||
import android.icu.util.TimeZone
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
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
|
|
||||||
import com.aiosman.ravenow.ui.follower.FollowerNoticeViewModel
|
|
||||||
import com.aiosman.ravenow.ui.index.IndexViewModel
|
|
||||||
import com.aiosman.ravenow.ui.index.tabs.message.MessageListViewModel
|
|
||||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.dynamic.DynamicViewModel
|
|
||||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.Explore
|
|
||||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.hot.HotMomentViewModel
|
|
||||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline.TimelineMomentViewModel
|
|
||||||
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
|
|
||||||
import com.aiosman.ravenow.ui.account.AccountEditViewModel
|
|
||||||
import com.aiosman.ravenow.ui.index.tabs.search.DiscoverViewModel
|
|
||||||
import com.aiosman.ravenow.ui.index.tabs.search.SearchViewModel
|
|
||||||
import com.aiosman.ravenow.ui.index.tabs.ai.AgentViewModel
|
|
||||||
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
|
|
||||||
import io.openim.android.sdk.models.InitConfig
|
|
||||||
|
|
||||||
|
|
||||||
object AppState {
|
|
||||||
var UserId: Int? = null
|
|
||||||
var profile: AccountProfileEntity? = null
|
|
||||||
var darkMode by mutableStateOf(false)
|
|
||||||
var appTheme by mutableStateOf<AppThemeData>(LightThemeColors())
|
|
||||||
var googleClientId: String? = null
|
|
||||||
var enableGoogleLogin: Boolean = false
|
|
||||||
var enableChat = false
|
|
||||||
var agentCreatedSuccess by mutableStateOf(false)
|
|
||||||
var chatBackgroundUrl by mutableStateOf<String?>(null)
|
|
||||||
suspend fun initWithAccount(scope: CoroutineScope, context: Context) {
|
|
||||||
// 如果是游客模式,使用简化的初始化流程
|
|
||||||
if (AppStore.isGuest) {
|
|
||||||
initWithGuestAccount(scope)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val accountService: AccountService = AccountServiceImpl()
|
|
||||||
// 获取用户认证信息
|
|
||||||
val resp = accountService.getMyAccount()
|
|
||||||
// 更新必要的用户信息
|
|
||||||
val calendar: Calendar = Calendar.getInstance()
|
|
||||||
val tz: TimeZone = calendar.timeZone
|
|
||||||
val offsetInMillis: Int = tz.rawOffset
|
|
||||||
accountService.updateUserExtra(
|
|
||||||
Utils.getCurrentLanguage(),
|
|
||||||
// 时区偏移量单位是秒
|
|
||||||
offsetInMillis / 1000,
|
|
||||||
tz.displayName
|
|
||||||
)
|
|
||||||
// 设置当前登录用户 ID
|
|
||||||
UserId = resp.id
|
|
||||||
try {
|
|
||||||
var profileResult = accountService.getMyAccountProfile()
|
|
||||||
profile = profileResult
|
|
||||||
|
|
||||||
} catch (e:Exception) {
|
|
||||||
Log.e("AppState", "getMyAccountProfile Error:"+ e.message )
|
|
||||||
}
|
|
||||||
// 获取当前用户资料
|
|
||||||
|
|
||||||
// 注册 JPush
|
|
||||||
Messaging.registerDevice(scope, context)
|
|
||||||
initChat(context)
|
|
||||||
|
|
||||||
// 设置当前用户并刷新积分信息(完成登录态初始化后)
|
|
||||||
PointService.setCurrentUser(UserId)
|
|
||||||
try {
|
|
||||||
PointService.refreshMyPointsBalance()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("AppState", "刷新积分失败: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 并行加载积分规则和房间规则配置(不阻塞主流程)
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
PointService.refreshPointsRules()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("AppState", "加载积分规则失败: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
PointService.refreshRoomMaxMembers()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("AppState", "加载房间规则失败: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 游客模式的简化初始化
|
|
||||||
*/
|
|
||||||
private fun initWithGuestAccount(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){
|
|
||||||
val dictService :DictService = DictServiceImpl()
|
|
||||||
val enableItem = dictService.getDictByKey(ConstVars.DICT_KEY_ENABLE_TRTC)
|
|
||||||
val isEnableTrtc = enableItem.value as? Boolean
|
|
||||||
if (isEnableTrtc != true) {
|
|
||||||
enableChat = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val accountService: AccountService = AccountServiceImpl()
|
|
||||||
|
|
||||||
|
|
||||||
val initConfig = InitConfig(
|
|
||||||
"https://im.ravenow.ai/api",//SDK api地址
|
|
||||||
"wss://im.ravenow.ai/msg_gateway",//SDK WebSocket地址
|
|
||||||
OpenIMManager.getStorageDir(context),//SDK数据库存储目录
|
|
||||||
)
|
|
||||||
// initConfig.isLogStandardOutput = true;
|
|
||||||
// initConfig.logLevel = 6
|
|
||||||
// 使用 OpenIMManager 初始化 SDK
|
|
||||||
OpenIMManager.initSDK(context, initConfig)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (profile?.chatToken != null && profile?.trtcUserId != null) {
|
|
||||||
loginToOpenIM(profile!!.trtcUserId, profile!!.chatToken!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.startService(Intent(context, OpenIMService::class.java))
|
|
||||||
|
|
||||||
enableChat = true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
enableChat = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun loginToOpenIM(userId: String, imToken: String): Boolean {
|
|
||||||
return suspendCoroutine { continuation ->
|
|
||||||
OpenIMClient.getInstance().login(object : io.openim.android.sdk.listener.OnBase<String> {
|
|
||||||
override fun onError(code: Int, error: String?) {
|
|
||||||
Log.e("AppState", "OpenIM 登录失败: code=$code, error=$error")
|
|
||||||
continuation.resumeWith(Result.failure(Exception("OpenIM Login failed: $code, $error")))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSuccess(data: String?) {
|
|
||||||
Log.d("AppState", "OpenIM 登录成功: $data")
|
|
||||||
//其他api调用必须保证登录回调成功后操作
|
|
||||||
continuation.resumeWith(Result.success(true))
|
|
||||||
}
|
|
||||||
}, userId, imToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// suspend fun updateTrtcUserProfile() {
|
|
||||||
// val accountService: AccountService = AccountServiceImpl()
|
|
||||||
// val profile = accountService.getMyAccountProfile()
|
|
||||||
// val info = V2TIMUserFullInfo()
|
|
||||||
// info.setNickname(profile.nickName)
|
|
||||||
// info.faceUrl = profile.rawAvatar
|
|
||||||
// info.selfSignature = profile.bio
|
|
||||||
// return suspendCoroutine { continuation ->
|
|
||||||
// V2TIMManager.getInstance().setSelfInfo(info, object : V2TIMCallback {
|
|
||||||
// override fun onError(code: Int, desc: String?) {
|
|
||||||
// continuation.resumeWith(Result.failure(Exception("Update user profile failed: $code, $desc")))
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onSuccess() {
|
|
||||||
// continuation.resumeWith(Result.success(Unit))
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
fun switchTheme() {
|
|
||||||
darkMode = !darkMode
|
|
||||||
appTheme = if (darkMode) {
|
|
||||||
DarkThemeColors()
|
|
||||||
} else {
|
|
||||||
LightThemeColors()
|
|
||||||
}
|
|
||||||
AppStore.saveDarkMode(darkMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否是游客模式,并且是否需要登录
|
|
||||||
* @return true 如果是游客模式
|
|
||||||
*/
|
|
||||||
fun isGuestMode(): Boolean {
|
|
||||||
return AppStore.isGuest
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查游客模式并提示登录
|
|
||||||
* @param onGuestMode 当是游客模式时的回调
|
|
||||||
* @return true 如果是游客模式
|
|
||||||
*/
|
|
||||||
fun checkGuestModeAndPromptLogin(onGuestMode: (() -> Unit)? = null): Boolean {
|
|
||||||
if (AppStore.isGuest) {
|
|
||||||
onGuestMode?.invoke()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ReloadAppState(context: Context) {
|
|
||||||
// 重置动态列表页面
|
|
||||||
TimelineMomentViewModel.ResetModel()
|
|
||||||
DynamicViewModel.ResetModel()
|
|
||||||
HotMomentViewModel.resetModel()
|
|
||||||
|
|
||||||
// 重置我的页面
|
|
||||||
MyProfileViewModel.ResetModel()
|
|
||||||
// 重置编辑资料页面 - 暂时注释掉看是否是这里导致的问题
|
|
||||||
// AccountEditViewModel.ResetModel()
|
|
||||||
// 重置发现页面
|
|
||||||
DiscoverViewModel.ResetModel()
|
|
||||||
// 重置搜索页面
|
|
||||||
SearchViewModel.ResetModel()
|
|
||||||
// 重置消息页面
|
|
||||||
MessageListViewModel.ResetModel()
|
|
||||||
// 重置点赞通知页面
|
|
||||||
LikeNoticeViewModel.ResetModel()
|
|
||||||
// 重置收藏页面
|
|
||||||
FavouriteListViewModel.ResetModel()
|
|
||||||
// 重置收藏通知页面
|
|
||||||
FavouriteNoticeViewModel.ResetModel()
|
|
||||||
// 重置粉丝通知页面
|
|
||||||
FollowerNoticeViewModel.ResetModel()
|
|
||||||
// 重置关注通知页面
|
|
||||||
IndexViewModel.ResetModel()
|
|
||||||
// 重置AI Agent相关页面
|
|
||||||
AgentViewModel.ResetModel()
|
|
||||||
MineAgentViewModel.ResetModel()
|
|
||||||
UserId = null
|
|
||||||
// 清空积分全局状态,避免用户切换串号
|
|
||||||
PointService.clear()
|
|
||||||
|
|
||||||
// 清除游客状态
|
|
||||||
AppStore.isGuest = false
|
|
||||||
|
|
||||||
context.stopService(Intent(context, OpenIMService::class.java))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package com.aiosman.ravenow
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
//var AppColors = LightThemeColors()
|
|
||||||
//var AppColors = if (AppState.darkMode) DarkThemeColors() else LightThemeColors()
|
|
||||||
|
|
||||||
open class AppThemeData(
|
|
||||||
var main: Color,
|
|
||||||
var mainText: Color,
|
|
||||||
var basicMain: Color,
|
|
||||||
var nonActive: Color,
|
|
||||||
var text: Color,
|
|
||||||
var nonActiveText: Color,
|
|
||||||
var secondaryText: Color,
|
|
||||||
var loadingMain: Color,
|
|
||||||
var loadingText: Color,
|
|
||||||
var disabledBackground: Color,
|
|
||||||
var background: Color,
|
|
||||||
var secondaryBackground: Color,
|
|
||||||
var decentBackground: Color,
|
|
||||||
var divider: Color,
|
|
||||||
var inputBackground: Color,
|
|
||||||
var inputBackground2: Color,
|
|
||||||
var inputHint: Color,
|
|
||||||
var error: Color,
|
|
||||||
var checkedBackground: Color,
|
|
||||||
var unCheckedBackground: Color,
|
|
||||||
var checkedText: Color,
|
|
||||||
var chatActionColor: Color,
|
|
||||||
var brandColorsColor: Color,
|
|
||||||
var tabSelectedBackground: Color,
|
|
||||||
var tabUnselectedBackground: Color,
|
|
||||||
var tabSelectedText: Color,
|
|
||||||
var tabUnselectedText: Color,
|
|
||||||
var bubbleBackground: Color,
|
|
||||||
var profileBackground:Color,
|
|
||||||
// Premium 按钮相关颜色
|
|
||||||
var premiumText: Color,
|
|
||||||
var premiumBackground: Color,
|
|
||||||
// VIP 权益强调色(用于 2X / 勾选高亮)
|
|
||||||
var vipHave: Color,
|
|
||||||
// 价格卡片颜色
|
|
||||||
var priceCardSelectedBorder: Color,
|
|
||||||
var priceCardSelectedBackground: Color,
|
|
||||||
var priceCardUnselectedBorder: Color,
|
|
||||||
var priceCardUnselectedBackground: Color,
|
|
||||||
)
|
|
||||||
|
|
||||||
class LightThemeColors : AppThemeData(
|
|
||||||
main = Color(0xffD80264),
|
|
||||||
mainText = Color(0xffffffff),
|
|
||||||
basicMain = Color(0xfff0f0f0),
|
|
||||||
nonActive = Color(0xfff5f5f5),
|
|
||||||
text = Color(0xff333333),
|
|
||||||
nonActiveText = Color(0xff3C3C43),
|
|
||||||
secondaryText = Color(0x99000000),
|
|
||||||
loadingMain = Color(0xFFD95757),
|
|
||||||
loadingText = Color(0xffffffff),
|
|
||||||
disabledBackground = Color(0xFFD0D0D0),
|
|
||||||
background = Color(0xFFFFFFFF),
|
|
||||||
secondaryBackground = Color(0xFFF7f7f7),
|
|
||||||
divider = Color(0xFFEbEbEb),
|
|
||||||
inputBackground = Color(0xFFF7f7f7),
|
|
||||||
inputBackground2 = Color(0xFFFFFFFF),
|
|
||||||
inputHint = Color(0xffdadada),
|
|
||||||
error = Color(0xffFF0000),
|
|
||||||
checkedBackground = Color(0xff000000),
|
|
||||||
unCheckedBackground = Color(0xFFECEAEC),
|
|
||||||
checkedText = Color(0xffFFFFFF),
|
|
||||||
decentBackground = Color(0xfff5f5f5),
|
|
||||||
chatActionColor = Color(0xffe0e0e0),
|
|
||||||
brandColorsColor = Color(0xffD80264),
|
|
||||||
tabSelectedBackground = Color(0xff110C13),
|
|
||||||
tabUnselectedBackground = Color(0xfffaf9fb),
|
|
||||||
tabSelectedText = Color(0xffffffff),
|
|
||||||
tabUnselectedText = Color(0xff000000),
|
|
||||||
bubbleBackground = Color(0xfff5f5f5),
|
|
||||||
profileBackground = Color(0xffffffff),
|
|
||||||
premiumText = Color(0xFFCD7B00),
|
|
||||||
premiumBackground = Color(0xFFFFF5D4),
|
|
||||||
vipHave = Color(0xFFFAAD14),
|
|
||||||
priceCardSelectedBorder = Color(0xFF000000),
|
|
||||||
priceCardSelectedBackground = Color(0xFFFFF5D4),
|
|
||||||
priceCardUnselectedBorder = Color(0xFFF0EEF1),
|
|
||||||
priceCardUnselectedBackground = Color(0xFFFAF9FB),
|
|
||||||
)
|
|
||||||
|
|
||||||
class DarkThemeColors : AppThemeData(
|
|
||||||
main = Color(0xffda3832),
|
|
||||||
mainText = Color(0xffffffff),
|
|
||||||
basicMain = Color(0xFF1C1C1C),
|
|
||||||
nonActive = Color(0xff1f1f1f),
|
|
||||||
text = Color(0xffffffff),
|
|
||||||
nonActiveText = Color(0xff888888),
|
|
||||||
secondaryText = Color(0x99ffffff),
|
|
||||||
loadingMain = Color(0xFFD95757),
|
|
||||||
loadingText = Color(0xff000000),
|
|
||||||
disabledBackground = Color(0xFF3A3A3A),
|
|
||||||
background = Color(0xFF121212),
|
|
||||||
secondaryBackground = Color(0xFF1C1C1C),
|
|
||||||
divider = Color(0xFF282828),
|
|
||||||
inputBackground = Color(0xFF1C1C1C),
|
|
||||||
inputBackground2 = Color(0xFF1C1C1C),
|
|
||||||
inputHint = Color(0xff888888),
|
|
||||||
error = Color(0xffFF0000),
|
|
||||||
checkedBackground = Color(0xffffffff),
|
|
||||||
unCheckedBackground = Color(0xFF7C7480),
|
|
||||||
checkedText = Color(0xff000000),
|
|
||||||
decentBackground = Color(0xFF171717),
|
|
||||||
chatActionColor = Color(0xFF3D3D3D),
|
|
||||||
brandColorsColor = Color(0xffD80264),
|
|
||||||
tabSelectedBackground = Color(0xffffffff),
|
|
||||||
tabUnselectedBackground = Color(0xFF1C1C1C),
|
|
||||||
tabSelectedText = Color(0xff000000),
|
|
||||||
tabUnselectedText = Color(0xffffffff),
|
|
||||||
bubbleBackground = Color(0xff2d2c2e),
|
|
||||||
profileBackground = Color(0xff100c12),
|
|
||||||
// 暗色模式下的Premium按钮颜色 - 使用更暗的黄色调
|
|
||||||
premiumText = Color(0xFF000000),
|
|
||||||
premiumBackground = Color(0xFFFAAD14),
|
|
||||||
// VIP权益强调色 - 保持金黄色但调整透明度
|
|
||||||
vipHave = Color(0xFFFAAD14),
|
|
||||||
// 暗色模式下的价格卡片颜色
|
|
||||||
priceCardSelectedBorder = Color(0xFFFAAD14),
|
|
||||||
priceCardSelectedBackground = Color(0xFF2A2A2A),
|
|
||||||
priceCardUnselectedBorder = Color(0xFF3A3A3A),
|
|
||||||
priceCardUnselectedBackground = Color(0xFF1C1C1C),
|
|
||||||
|
|
||||||
)
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package com.aiosman.ravenow
|
|
||||||
|
|
||||||
object ConstVars {
|
|
||||||
// api 地址 - 根据构建类型自动选择
|
|
||||||
// Debug: http://192.168.0.201:8088
|
|
||||||
// Release: https://rider-pro.aiosman.com/beta_api
|
|
||||||
val BASE_SERVER = if (BuildConfig.DEBUG) {
|
|
||||||
// "http://47.109.137.67:6363" // Debug环境
|
|
||||||
"https://rider-pro.aiosman.com/beta_api" // Release环境
|
|
||||||
} else {
|
|
||||||
"https://rider-pro.aiosman.com/beta_api" // Release环境
|
|
||||||
}
|
|
||||||
|
|
||||||
const val MOMENT_LIKE_CHANNEL_ID = "moment_like"
|
|
||||||
const val MOMENT_LIKE_CHANNEL_NAME = "Moment Like"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传头像图片大小限制
|
|
||||||
* 10M
|
|
||||||
*/
|
|
||||||
const val AVATAR_FILE_SIZE_LIMIT = 1024 * 1024 * 10
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传头像图片压缩时最大的尺寸
|
|
||||||
* 512
|
|
||||||
*/
|
|
||||||
const val AVATAR_IMAGE_MAX_SIZE = 512
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传 banner 图片大小限制
|
|
||||||
*/
|
|
||||||
const val BANNER_IMAGE_MAX_SIZE = 2048
|
|
||||||
|
|
||||||
// 用户协议地址
|
|
||||||
const val DICT_KEY_PRIVATE_POLICY_URL = "private_policy"
|
|
||||||
// 重置邮箱间隔
|
|
||||||
const val DIC_KEY_RESET_EMAIL_INTERVAL = "send_reset_password_timeout"
|
|
||||||
// 开启google登录
|
|
||||||
const val DICT_KEY_ENABLE_GOOGLE_LOGIN = "enable_google_login"
|
|
||||||
// google登录clientid
|
|
||||||
const val DICT_KEY_GOOGLE_LOGIN_CLIENT_ID = "google_login_client_id"
|
|
||||||
// trtc功能开启
|
|
||||||
const val DICT_KEY_ENABLE_TRTC = "enable_chat"
|
|
||||||
// 举报选项
|
|
||||||
const val DICT_KEY_REPORT_OPTIONS = "report_reasons"
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class GuestLoginCheckOutScene {
|
|
||||||
CREATE_POST,
|
|
||||||
CREATE_AGENT,
|
|
||||||
VIEW_MESSAGES,
|
|
||||||
VIEW_PROFILE,
|
|
||||||
JOIN_GROUP_CHAT,
|
|
||||||
CHAT_WITH_AGENT,
|
|
||||||
LIKE_MOMENT,
|
|
||||||
COMMENT_MOMENT,
|
|
||||||
FOLLOW_USER,
|
|
||||||
REPORT_CONTENT
|
|
||||||
}
|
|
||||||
|
|
||||||
object GuestLoginCheckOut {
|
|
||||||
var NeedLoginScene = listOf<GuestLoginCheckOutScene>(
|
|
||||||
GuestLoginCheckOutScene.CREATE_POST,
|
|
||||||
GuestLoginCheckOutScene.CREATE_AGENT,
|
|
||||||
GuestLoginCheckOutScene.VIEW_MESSAGES,
|
|
||||||
GuestLoginCheckOutScene.VIEW_PROFILE,
|
|
||||||
GuestLoginCheckOutScene.JOIN_GROUP_CHAT,
|
|
||||||
GuestLoginCheckOutScene.CHAT_WITH_AGENT,
|
|
||||||
GuestLoginCheckOutScene.LIKE_MOMENT,
|
|
||||||
GuestLoginCheckOutScene.COMMENT_MOMENT,
|
|
||||||
GuestLoginCheckOutScene.FOLLOW_USER,
|
|
||||||
GuestLoginCheckOutScene.REPORT_CONTENT
|
|
||||||
)
|
|
||||||
fun needLogin(scene: GuestLoginCheckOutScene): Boolean {
|
|
||||||
return AppStore.isGuest && NeedLoginScene.contains(scene)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
package com.aiosman.ravenow
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
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
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.compose.animation.AnimatedContentScope
|
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
|
||||||
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
|
|
||||||
import androidx.navigation.NavHostController
|
|
||||||
import cn.jiguang.api.utils.JCollectionAuth
|
|
||||||
import cn.jpush.android.api.JPushInterface
|
|
||||||
import com.aiosman.ravenow.data.AccountService
|
|
||||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
|
||||||
import com.aiosman.ravenow.data.UserService
|
|
||||||
import com.aiosman.ravenow.data.UserServiceImpl
|
|
||||||
import com.aiosman.ravenow.ui.Navigation
|
|
||||||
import com.aiosman.ravenow.ui.NavigationRoute
|
|
||||||
import com.aiosman.ravenow.ui.dialogs.CheckUpdateDialog
|
|
||||||
import com.aiosman.ravenow.ui.navigateToPost
|
|
||||||
import com.aiosman.ravenow.ui.post.NewPostViewModel
|
|
||||||
import com.aiosman.ravenow.ui.points.PointsBottomSheetHost
|
|
||||||
import com.google.firebase.Firebase
|
|
||||||
import com.google.firebase.analytics.FirebaseAnalytics
|
|
||||||
import com.google.firebase.analytics.analytics
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import com.aiosman.ravenow.ui.splash.SplashScreen
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
|
||||||
// Firebase Analytics
|
|
||||||
private lateinit var analytics: FirebaseAnalytics
|
|
||||||
private val scope = CoroutineScope(Dispatchers.Main)
|
|
||||||
val context = this
|
|
||||||
|
|
||||||
override fun attachBaseContext(newBase: Context) {
|
|
||||||
// 禁用字体缩放,固定字体大小为系统默认大小
|
|
||||||
val configuration = Configuration(newBase.resources.configuration)
|
|
||||||
configuration.fontScale = 1.0f
|
|
||||||
val context = newBase.createConfigurationContext(configuration)
|
|
||||||
super.attachBaseContext(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
|
||||||
// 确保配置变化时字体缩放保持为 1.0
|
|
||||||
val config = Configuration(newConfig)
|
|
||||||
config.fontScale = 1.0f
|
|
||||||
super.onConfigurationChanged(config)
|
|
||||||
val isNightMode = (newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
|
||||||
if (AppState.darkMode != isNightMode) {
|
|
||||||
syncDarkModeWithSystem(isNightMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 请求通知权限
|
|
||||||
private val requestPermissionLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission(),
|
|
||||||
) { isGranted: Boolean ->
|
|
||||||
if (isGranted) {
|
|
||||||
// FCM SDK (and your app) can post notifications.
|
|
||||||
} else {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取账号信息
|
|
||||||
*/
|
|
||||||
private suspend fun getAccount(): Boolean {
|
|
||||||
val accountService: AccountService = AccountServiceImpl()
|
|
||||||
try {
|
|
||||||
val resp = accountService.getMyAccount()
|
|
||||||
return true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@RequiresApi(Build.VERSION_CODES.P)
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
// 设置屏幕方向为竖屏
|
|
||||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
|
||||||
|
|
||||||
// 监听应用生命周期
|
|
||||||
ProcessLifecycleOwner.get().lifecycle.addObserver(MainActivityLifecycleObserver())
|
|
||||||
// 创建通知渠道
|
|
||||||
createNotificationChannel()
|
|
||||||
// 沉浸式状态栏
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
|
||||||
// 初始化 Places SDK
|
|
||||||
|
|
||||||
// 初始化 Firebase Analytics
|
|
||||||
analytics = Firebase.analytics
|
|
||||||
// 请求通知权限
|
|
||||||
askNotificationPermission()
|
|
||||||
// 加载一些本地化的配置
|
|
||||||
AppStore.init(this)
|
|
||||||
|
|
||||||
JPushInterface.setDebugMode(true);
|
|
||||||
|
|
||||||
// 调整点一:初始化代码前增加setAuth调用
|
|
||||||
JCollectionAuth.setAuth(this, true)
|
|
||||||
|
|
||||||
JPushInterface.init(this)
|
|
||||||
|
|
||||||
updateWindowBackground(AppState.darkMode)
|
|
||||||
enableEdgeToEdge()
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
// 检查是否有登录态
|
|
||||||
val isAccountValidate = getAccount()
|
|
||||||
var startDestination = NavigationRoute.Login.route
|
|
||||||
// 如果有登录态,且记住登录状态,且账号有效,则初始化应用状态,下一步进入首页
|
|
||||||
if (AppStore.token != null && AppStore.rememberMe && (isAccountValidate || AppStore.isGuest)) {
|
|
||||||
// 根据用户类型进行相应的初始化(游客模式会跳过推送和TRTC初始化)
|
|
||||||
AppState.initWithAccount(scope, this@MainActivity)
|
|
||||||
startDestination = NavigationRoute.Index.route
|
|
||||||
}
|
|
||||||
|
|
||||||
setContent {
|
|
||||||
// 强制字体缩放为 1.0 - 通过覆盖 Density 来实现
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val fixedDensity = remember {
|
|
||||||
androidx.compose.ui.unit.Density(
|
|
||||||
density = density.density,
|
|
||||||
fontScale = 1.0f
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var showSplash by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
kotlinx.coroutines.delay(2000)
|
|
||||||
showSplash = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showSplash) {
|
|
||||||
SplashScreen()
|
|
||||||
} else {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAppTheme provides AppState.appTheme,
|
|
||||||
LocalDensity provides fixedDensity
|
|
||||||
) {
|
|
||||||
CheckUpdateDialog()
|
|
||||||
// 全局挂载积分底部弹窗 Host
|
|
||||||
PointsBottomSheetHost()
|
|
||||||
Navigation(startDestination) { navController ->
|
|
||||||
|
|
||||||
// 处理带有 postId 的通知点击
|
|
||||||
val postId = intent.getStringExtra("POST_ID")
|
|
||||||
var commentId = intent.getStringExtra("COMMENT_ID")
|
|
||||||
val action = intent.getStringExtra("ACTION")
|
|
||||||
if (action == "newFollow") {
|
|
||||||
navController.navigate(NavigationRoute.Followers.route)
|
|
||||||
return@Navigation
|
|
||||||
}
|
|
||||||
if (action == "followCount") {
|
|
||||||
navController.navigate(NavigationRoute.Followers.route)
|
|
||||||
return@Navigation
|
|
||||||
}
|
|
||||||
if (action == "TRTC_NEW_MESSAGE") {
|
|
||||||
val userService:UserService = UserServiceImpl()
|
|
||||||
val sender = intent.getStringExtra("SENDER")
|
|
||||||
sender?.let {
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val profile = userService.getUserProfileByTrtcUserId(it,0)
|
|
||||||
navController.navigate(NavigationRoute.Chat.route.replace(
|
|
||||||
"{id}",
|
|
||||||
profile.id.toString()
|
|
||||||
))
|
|
||||||
}catch (e:Exception){
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return@Navigation
|
|
||||||
}
|
|
||||||
|
|
||||||
if (commentId == null) {
|
|
||||||
commentId = "0"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (postId != null) {
|
|
||||||
Log.d("MainActivity", "Navigation to Post$postId")
|
|
||||||
navController.navigateToPost(
|
|
||||||
id = postId.toInt(),
|
|
||||||
highlightCommentId = commentId.toInt(),
|
|
||||||
initImagePagerIndex = 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// 处理分享过来的图片
|
|
||||||
if (intent?.action == Intent.ACTION_SEND || intent?.action == Intent.ACTION_SEND_MULTIPLE) {
|
|
||||||
val imageUris: List<Uri>? = if (intent.action == Intent.ACTION_SEND) {
|
|
||||||
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM)!!)
|
|
||||||
} else {
|
|
||||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
|
|
||||||
}
|
|
||||||
NewPostViewModel.asNewPostWithImageUris(imageUris!!.map { it.toString() })
|
|
||||||
navController.navigate(NavigationRoute.NewPost.route)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 请求通知权限
|
|
||||||
*/
|
|
||||||
private fun askNotificationPermission() {
|
|
||||||
// This is only necessary for API level >= 33 (TIRAMISU)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
// FCM SDK (and your app) can post notifications.
|
|
||||||
} else if (shouldShowRequestPermissionRationale(android.Manifest.permission.POST_NOTIFICATIONS)) {
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Directly ask for the permission
|
|
||||||
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建通知渠道
|
|
||||||
*/
|
|
||||||
private fun createNotificationChannel() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val channelId = ConstVars.MOMENT_LIKE_CHANNEL_ID
|
|
||||||
val channelName = ConstVars.MOMENT_LIKE_CHANNEL_NAME
|
|
||||||
val channel =
|
|
||||||
NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT)
|
|
||||||
val notificationManager =
|
|
||||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
notificationManager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun syncDarkModeWithSystem(isNightMode: Boolean) {
|
|
||||||
AppState.darkMode = isNightMode
|
|
||||||
AppState.appTheme = if (isNightMode) DarkThemeColors() else LightThemeColors()
|
|
||||||
AppStore.saveDarkMode(isNightMode)
|
|
||||||
updateWindowBackground(isNightMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateWindowBackground(isDarkMode: Boolean) {
|
|
||||||
window.decorView.setBackgroundColor(
|
|
||||||
if (isDarkMode) android.graphics.Color.BLACK else android.graphics.Color.WHITE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
val LocalNavController = compositionLocalOf<NavHostController> {
|
|
||||||
error("NavController not provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
|
||||||
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> {
|
|
||||||
error("SharedTransitionScope not provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
val LocalAnimatedContentScope = compositionLocalOf<AnimatedContentScope> {
|
|
||||||
error("AnimatedContentScope not provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
val LocalAppTheme = compositionLocalOf<AppThemeData> {
|
|
||||||
error("AppThemeData not provided")
|
|
||||||
}
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
package com.aiosman.ravenow
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.ActivityCompat
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import com.aiosman.ravenow.entity.ChatItem
|
|
||||||
import io.openim.android.sdk.OpenIMClient
|
|
||||||
import io.openim.android.sdk.listener.OnAdvanceMsgListener
|
|
||||||
import io.openim.android.sdk.models.*
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OpenIM 服务
|
|
||||||
* 负责处理 OpenIM 的后台消息监听和通知
|
|
||||||
*/
|
|
||||||
class OpenIMService : Service() {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "OpenIMService"
|
|
||||||
private const val CHANNEL_ID = "openim_notification"
|
|
||||||
private const val NOTIFICATION_ID = 1001
|
|
||||||
}
|
|
||||||
|
|
||||||
private var openIMMessageListener: OnAdvanceMsgListener? = null
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
Log.d(TAG, "OpenIMService onStartCommand")
|
|
||||||
createNotificationChannel()
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
registerMessageListener(applicationContext)
|
|
||||||
}
|
|
||||||
return START_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
Log.d(TAG, "OpenIMService onDestroy")
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
unRegisterMessageListener()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册 OpenIM 消息监听器
|
|
||||||
*/
|
|
||||||
private fun registerMessageListener(context: Context) {
|
|
||||||
val scope = CoroutineScope(Dispatchers.IO)
|
|
||||||
|
|
||||||
openIMMessageListener = object : OnAdvanceMsgListener {
|
|
||||||
override fun onRecvNewMessage(msg: Message?) {
|
|
||||||
Log.d(TAG, "收到新消息: ${msg?.toString()}")
|
|
||||||
msg?.let {
|
|
||||||
// 如果应用在前台,不显示通知
|
|
||||||
if (MainActivityLifecycle.isForeground) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
// 检查通知策略
|
|
||||||
val shouldNotify = shouldShowNotification(it)
|
|
||||||
if (shouldNotify) {
|
|
||||||
sendNotification(context, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvC2CReadReceipt(list: List<C2CReadReceiptInfo>?) {
|
|
||||||
Log.d(TAG, "收到C2C已读回执,数量: ${list?.size}")
|
|
||||||
// 处理已读回执,可以更新消息状态
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvGroupMessageReadReceipt(groupMessageReceipt: GroupMessageReceipt?) {
|
|
||||||
Log.d(TAG, "收到群组消息已读回执")
|
|
||||||
// 处理群组消息已读回执
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvMessageRevokedV2(info: RevokedInfo?) {
|
|
||||||
Log.d(TAG, "消息被撤回: ${info?.clientMsgID}")
|
|
||||||
// 处理消息撤回,可以更新UI
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvMessageExtensionsChanged(msgID: String?, list: List<KeyValue>?) {
|
|
||||||
Log.d(TAG, "消息扩展信息变更: $msgID")
|
|
||||||
// 处理消息扩展信息变更
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvMessageExtensionsDeleted(msgID: String?, list: List<String>?) {
|
|
||||||
Log.d(TAG, "消息扩展信息删除: $msgID")
|
|
||||||
// 处理消息扩展信息删除
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvMessageExtensionsAdded(msgID: String?, list: List<KeyValue>?) {
|
|
||||||
Log.d(TAG, "消息扩展信息添加: $msgID")
|
|
||||||
// 处理消息扩展信息添加
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMsgDeleted(message: Message?) {
|
|
||||||
Log.d(TAG, "消息被删除: ${message?.clientMsgID}")
|
|
||||||
// 处理消息删除
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvOfflineNewMessage(msg: List<Message>?) {
|
|
||||||
Log.d(TAG, "收到离线新消息,数量: ${msg?.size}")
|
|
||||||
// 处理离线新消息
|
|
||||||
msg?.forEach { message ->
|
|
||||||
// 为离线消息也可以发送通知
|
|
||||||
if (!MainActivityLifecycle.isForeground) {
|
|
||||||
scope.launch {
|
|
||||||
val shouldNotify = shouldShowNotification(message)
|
|
||||||
if (shouldNotify) {
|
|
||||||
sendNotification(context, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvOnlineOnlyMessage(s: String?) {
|
|
||||||
Log.d(TAG, "收到仅在线消息: $s")
|
|
||||||
// 处理仅在线消息
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加消息监听器
|
|
||||||
OpenIMClient.getInstance().messageManager.setAdvancedMsgListener(openIMMessageListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取消注册消息监听器
|
|
||||||
*/
|
|
||||||
private fun unRegisterMessageListener() {
|
|
||||||
openIMMessageListener?.let {
|
|
||||||
// OpenIM SDK 可能需要不同的方法来移除监听器
|
|
||||||
// 这里假设有类似的方法,具体需要根据SDK文档调整
|
|
||||||
Log.d(TAG, "取消注册消息监听器")
|
|
||||||
}
|
|
||||||
openIMMessageListener = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断是否应该显示通知
|
|
||||||
* @param message 消息对象
|
|
||||||
* @return 是否显示通知
|
|
||||||
*/
|
|
||||||
private suspend fun shouldShowNotification(message: Message): Boolean {
|
|
||||||
// 这里可以根据用户设置、消息类型等判断是否显示通知
|
|
||||||
// 类似于 TrtcService 中的策略检查
|
|
||||||
|
|
||||||
// 示例:检查是否是系统消息或者用户设置了免打扰
|
|
||||||
return try {
|
|
||||||
// 可以根据发送者ID或会话ID检查通知策略
|
|
||||||
val senderId = message.sendID
|
|
||||||
|
|
||||||
// 这里可以调用类似 ChatState.getStrategyByTargetTrtcId 的方法
|
|
||||||
// 暂时返回 true,表示默认显示通知
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "检查通知策略失败", e)
|
|
||||||
true // 默认显示通知
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建通知渠道
|
|
||||||
*/
|
|
||||||
private fun createNotificationChannel() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val name = "OpenIM 消息通知"
|
|
||||||
val descriptionText = "OpenIM 即时消息通知"
|
|
||||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
|
||||||
val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
|
|
||||||
description = descriptionText
|
|
||||||
enableVibration(true)
|
|
||||||
enableLights(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
val notificationManager: NotificationManager =
|
|
||||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
notificationManager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送通知
|
|
||||||
* @param context 上下文
|
|
||||||
* @param message OpenIM 消息对象
|
|
||||||
*/
|
|
||||||
private fun sendNotification(context: Context, message: Message) {
|
|
||||||
try {
|
|
||||||
// 创建点击通知后的意图
|
|
||||||
val intent = Intent(context, MainActivity::class.java).apply {
|
|
||||||
putExtra("ACTION", "OPENIM_NEW_MESSAGE")
|
|
||||||
putExtra("SENDER_ID", message.sendID)
|
|
||||||
putExtra("CONVERSATION_ID", message.sessionType)
|
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
|
||||||
}
|
|
||||||
|
|
||||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
|
||||||
context,
|
|
||||||
0,
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
// 构建通知内容
|
|
||||||
val senderName = getSenderDisplayName(message)
|
|
||||||
val messageContent = getMessageDisplayContent(message)
|
|
||||||
|
|
||||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.mipmap.rider_pro_log_round)
|
|
||||||
.setContentTitle(senderName)
|
|
||||||
.setContentText(messageContent)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setVibrate(longArrayOf(0, 300, 300, 300))
|
|
||||||
.setLights(0xFF0000FF.toInt(), 300, 300)
|
|
||||||
|
|
||||||
// 发送通知
|
|
||||||
with(NotificationManagerCompat.from(context)) {
|
|
||||||
if (ActivityCompat.checkSelfPermission(
|
|
||||||
context,
|
|
||||||
Manifest.permission.POST_NOTIFICATIONS
|
|
||||||
) != PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
Log.w(TAG, "没有通知权限,无法发送通知")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用消息ID的哈希值作为通知ID,确保每条消息都有唯一的通知
|
|
||||||
val notificationId = message.clientMsgID?.hashCode() ?: NOTIFICATION_ID
|
|
||||||
notify(notificationId, builder.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "发送通知成功: $senderName - $messageContent")
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "发送通知失败", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取发送者显示名称
|
|
||||||
* @param message 消息对象
|
|
||||||
* @return 发送者名称
|
|
||||||
*/
|
|
||||||
private fun getSenderDisplayName(message: Message): String {
|
|
||||||
return try {
|
|
||||||
// 尝试获取发送者的昵称或显示名
|
|
||||||
message.senderNickname?.takeIf { it.isNotEmpty() }
|
|
||||||
?: message.sendID
|
|
||||||
?: "未知用户"
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "获取发送者名称失败", e)
|
|
||||||
"未知用户"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取消息显示内容
|
|
||||||
* @param message 消息对象
|
|
||||||
* @return 消息内容
|
|
||||||
*/
|
|
||||||
private fun getMessageDisplayContent(message: Message): String {
|
|
||||||
return try {
|
|
||||||
when (message.contentType) {
|
|
||||||
101 -> message.textElem.content ?: "[文本消息]" // 文本消息
|
|
||||||
102 -> "[图片]"
|
|
||||||
103 -> "[语音]"
|
|
||||||
104 -> "[视频]"
|
|
||||||
105 -> "[文件]"
|
|
||||||
106 -> "[位置]"
|
|
||||||
107 -> "[自定义消息]"
|
|
||||||
108 -> "[合并消息]"
|
|
||||||
109 -> "[名片]"
|
|
||||||
110 -> "[引用消息]"
|
|
||||||
else -> "[消息]"
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "获取消息内容失败", e)
|
|
||||||
"[消息]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package com.aiosman.ravenow
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.util.Log
|
|
||||||
import com.google.firebase.FirebaseApp
|
|
||||||
import com.google.firebase.perf.FirebasePerformance
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自定义Application类,用于处理多进程中的Firebase初始化
|
|
||||||
*/
|
|
||||||
class RaveNowApplication : Application() {
|
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context) {
|
|
||||||
// 禁用字体缩放,固定字体大小为系统默认大小
|
|
||||||
val configuration = Configuration(base.resources.configuration)
|
|
||||||
configuration.fontScale = 1.0f
|
|
||||||
val context = base.createConfigurationContext(configuration)
|
|
||||||
super.attachBaseContext(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
|
|
||||||
// 获取当前进程名
|
|
||||||
val processName = getCurrentProcessName()
|
|
||||||
Log.d("RaveNowApplication", "当前进程: $processName")
|
|
||||||
|
|
||||||
// 在所有进程中初始化Firebase
|
|
||||||
try {
|
|
||||||
if (FirebaseApp.getApps(this).isEmpty()) {
|
|
||||||
FirebaseApp.initializeApp(this)
|
|
||||||
Log.d("RaveNowApplication", "Firebase已在进程 $processName 中初始化")
|
|
||||||
|
|
||||||
// 如果是pushcore进程,禁用Firebase Performance监控
|
|
||||||
if (processName.contains(":pushcore")) {
|
|
||||||
try {
|
|
||||||
FirebasePerformance.getInstance().isPerformanceCollectionEnabled = false
|
|
||||||
Log.d("RaveNowApplication", "已在pushcore进程中禁用Firebase Performance监控")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w("RaveNowApplication", "禁用Firebase Performance监控失败", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.d("RaveNowApplication", "Firebase已在进程 $processName 中存在")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("RaveNowApplication", "Firebase初始化失败在进程 $processName", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前进程名
|
|
||||||
*/
|
|
||||||
private fun getCurrentProcessName(): String {
|
|
||||||
return try {
|
|
||||||
val pid = android.os.Process.myPid()
|
|
||||||
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager
|
|
||||||
val processes = activityManager.runningAppProcesses
|
|
||||||
processes?.find { it.pid == pid }?.processName ?: "unknown"
|
|
||||||
} catch (e: Exception) {
|
|
||||||
"unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
package com.aiosman.ravenow.data
|
|
||||||
|
|
||||||
import com.aiosman.ravenow.AppStore
|
|
||||||
import com.aiosman.ravenow.data.api.ApiClient
|
|
||||||
import com.aiosman.ravenow.data.api.AgentRule
|
|
||||||
import com.aiosman.ravenow.data.api.AgentRuleListResponse
|
|
||||||
import com.aiosman.ravenow.data.api.AgentRuleQuota
|
|
||||||
import com.aiosman.ravenow.data.api.AgentRuleAgent
|
|
||||||
import com.aiosman.ravenow.data.api.CreateAgentRuleRequestBody
|
|
||||||
import com.aiosman.ravenow.data.api.InsufficientBalanceError
|
|
||||||
import com.aiosman.ravenow.data.api.UpdateAgentRuleRequestBody
|
|
||||||
import com.aiosman.ravenow.entity.AgentEntity
|
|
||||||
import com.aiosman.ravenow.entity.ProfileEntity
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.google.gson.annotations.SerializedName
|
|
||||||
|
|
||||||
data class Agent(
|
|
||||||
/*@SerializedName("author")
|
|
||||||
val author: String,*/
|
|
||||||
@SerializedName("avatar")
|
|
||||||
val avatar: String,
|
|
||||||
@SerializedName("breakMode")
|
|
||||||
val breakMode: Boolean,
|
|
||||||
@SerializedName("createdAt")
|
|
||||||
val createdAt: String,
|
|
||||||
@SerializedName("desc")
|
|
||||||
val desc: String,
|
|
||||||
@SerializedName("id")
|
|
||||||
val id: Int,
|
|
||||||
@SerializedName("isPublic")
|
|
||||||
val isPublic: Boolean,
|
|
||||||
@SerializedName("openId")
|
|
||||||
val openId: String,
|
|
||||||
@SerializedName("title")
|
|
||||||
val title: String,
|
|
||||||
@SerializedName("updatedAt")
|
|
||||||
val updatedAt: String,
|
|
||||||
@SerializedName("useCount")
|
|
||||||
val useCount: Int
|
|
||||||
|
|
||||||
) {
|
|
||||||
fun toAgentEntity(): AgentEntity {
|
|
||||||
return AgentEntity(
|
|
||||||
id = id,
|
|
||||||
title = title,
|
|
||||||
desc = desc,
|
|
||||||
createdAt = createdAt,
|
|
||||||
updatedAt = updatedAt,
|
|
||||||
avatar = "${ApiClient.BASE_API_URL+"/outside"}$avatar"+"?token="+"${AppStore.token}",
|
|
||||||
//author = author,
|
|
||||||
isPublic = isPublic,
|
|
||||||
openId = openId,
|
|
||||||
breakMode = breakMode,
|
|
||||||
useCount = useCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
data class Profile(
|
|
||||||
@SerializedName("aiAccount")
|
|
||||||
val aiAccount: Boolean,
|
|
||||||
@SerializedName("avatar")
|
|
||||||
val avatar: String,
|
|
||||||
@SerializedName("banner")
|
|
||||||
val banner: String,
|
|
||||||
@SerializedName("bio")
|
|
||||||
val bio: String,
|
|
||||||
@SerializedName("chatAIId")
|
|
||||||
val chatAIId: String,
|
|
||||||
@SerializedName("id")
|
|
||||||
val id: Int,
|
|
||||||
@SerializedName("nickname")
|
|
||||||
val nickname: String,
|
|
||||||
@SerializedName("trtcUserId")
|
|
||||||
val trtcUserId: String? = null,
|
|
||||||
@SerializedName("username")
|
|
||||||
val username: String
|
|
||||||
){
|
|
||||||
fun toProfileEntity(): ProfileEntity {
|
|
||||||
return ProfileEntity(
|
|
||||||
id = id,
|
|
||||||
username = username,
|
|
||||||
nickname = nickname,
|
|
||||||
avatar = "${ApiClient.BASE_SERVER}$avatar",
|
|
||||||
bio = bio,
|
|
||||||
banner = "${ApiClient.BASE_SERVER}$banner",
|
|
||||||
trtcUserId = trtcUserId ?: "",
|
|
||||||
chatAIId = chatAIId,
|
|
||||||
aiAccount = aiAccount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* 智能体服务
|
|
||||||
*/
|
|
||||||
interface AgentService {
|
|
||||||
/**
|
|
||||||
* 获取智能体列表
|
|
||||||
* @param pageNumber 页码
|
|
||||||
* @param pageSize 每页数量,默认 20
|
|
||||||
* @param authorId 作者ID,可选参数,用于筛选特定作者的智能体
|
|
||||||
* @return 智能体列表容器,包含分页信息和智能体列表,失败时返回 null
|
|
||||||
*/
|
|
||||||
suspend fun getAgent(
|
|
||||||
pageNumber: Int,
|
|
||||||
pageSize: Int = 20,
|
|
||||||
authorId: Int? = null
|
|
||||||
): ListContainer<AgentEntity>?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据标题关键字搜索智能体
|
|
||||||
*/
|
|
||||||
suspend fun searchAgentByTitle(
|
|
||||||
pageNumber: Int,
|
|
||||||
pageSize: Int = 20,
|
|
||||||
title: String
|
|
||||||
): ListContainer<AgentEntity>?
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========== Agent 规则 - 领域实体 ==========
|
|
||||||
|
|
||||||
data class AgentRuleAgentInfo(
|
|
||||||
val id: Int,
|
|
||||||
val title: String,
|
|
||||||
val avatar: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AgentRuleEntity(
|
|
||||||
val id: Int,
|
|
||||||
val rule: String,
|
|
||||||
val creator: String,
|
|
||||||
val creatorType: String,
|
|
||||||
val scope: String,
|
|
||||||
val agent: AgentRuleAgentInfo,
|
|
||||||
val createdAt: String,
|
|
||||||
val updatedAt: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AgentRuleListResult(
|
|
||||||
val page: Int,
|
|
||||||
val pageSize: Int,
|
|
||||||
val total: Int,
|
|
||||||
val list: List<AgentRuleEntity>,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AgentRuleQuotaEntity(
|
|
||||||
val agentId: Int,
|
|
||||||
val agentTitle: String,
|
|
||||||
val baseMaxCount: Int,
|
|
||||||
val purchasedCount: Int,
|
|
||||||
val totalMaxCount: Int,
|
|
||||||
val currentCount: Int,
|
|
||||||
val remainingCount: Int,
|
|
||||||
val usagePercent: Double,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========== Agent 规则 - Service 接口 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Agent 规则服务
|
|
||||||
*/
|
|
||||||
interface AgentRuleService {
|
|
||||||
/**
|
|
||||||
* 根据 OpenId 创建 Agent 规则
|
|
||||||
* @param openId Agent 的 OpenId
|
|
||||||
* @param rule 规则内容,不能为空
|
|
||||||
* @throws ServiceException 创建失败时抛出异常(包括余额不足等情况)
|
|
||||||
*/
|
|
||||||
suspend fun createAgentRuleByOpenId(openId: String, rule: String)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 修改 Agent 规则
|
|
||||||
* @param id 规则ID
|
|
||||||
* @param rule 新的规则内容,不能为空
|
|
||||||
* @param openId Agent 的 OpenId,可选参数
|
|
||||||
* @throws ServiceException 修改失败时抛出异常(包括余额不足等情况)
|
|
||||||
*/
|
|
||||||
suspend fun updateAgentRule(id: Int, rule: String, openId: String? = null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除 Agent 规则
|
|
||||||
* @param id 规则ID
|
|
||||||
* @throws ServiceException 删除失败时抛出异常
|
|
||||||
*/
|
|
||||||
suspend fun deleteAgentRule(id: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询 Agent 规则列表
|
|
||||||
* @param openId Agent 的 OpenId
|
|
||||||
* @param keyword 关键词搜索,可选参数
|
|
||||||
* @param page 页码,默认 1
|
|
||||||
* @param pageSize 每页数量,默认 10
|
|
||||||
* @return 规则列表响应,包含分页信息和规则列表
|
|
||||||
* @throws ServiceException 查询失败时抛出异常
|
|
||||||
*/
|
|
||||||
suspend fun getAgentRuleList(
|
|
||||||
openId: String,
|
|
||||||
keyword: String? = null,
|
|
||||||
page: Int = 1,
|
|
||||||
pageSize: Int = 10
|
|
||||||
): AgentRuleListResult
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询 Agent 规则配额使用情况
|
|
||||||
* @param openId Agent 的 OpenId
|
|
||||||
* @return 规则配额信息,包含基础配额、已购买配额、当前使用量等
|
|
||||||
* @throws ServiceException 查询失败时抛出异常
|
|
||||||
*/
|
|
||||||
suspend fun getAgentRuleQuota(openId: String): AgentRuleQuotaEntity
|
|
||||||
}
|
|
||||||
|
|
||||||
class AgentRuleServiceImpl : AgentRuleService {
|
|
||||||
private val gson = Gson()
|
|
||||||
|
|
||||||
override suspend fun createAgentRuleByOpenId(openId: String, rule: String) {
|
|
||||||
val body = CreateAgentRuleRequestBody(
|
|
||||||
rule = rule,
|
|
||||||
promptId = null,
|
|
||||||
openId = openId
|
|
||||||
)
|
|
||||||
val resp = ApiClient.api.createAgentRule(body)
|
|
||||||
if (!resp.isSuccessful) {
|
|
||||||
val errorText = resp.errorBody()?.string()
|
|
||||||
try {
|
|
||||||
val err = gson.fromJson(errorText, InsufficientBalanceError::class.java)
|
|
||||||
if (err != null && err.code == 35600) {
|
|
||||||
throw ServiceException(err.message)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// ignore parse error
|
|
||||||
}
|
|
||||||
throw ServiceException("创建 Agent 规则失败: HTTP ${resp.code()}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateAgentRule(id: Int, rule: String, openId: String?) {
|
|
||||||
val body = UpdateAgentRuleRequestBody(
|
|
||||||
id = id,
|
|
||||||
rule = rule,
|
|
||||||
promptId = null,
|
|
||||||
openId = openId
|
|
||||||
)
|
|
||||||
val resp = ApiClient.api.updateAgentRule(body)
|
|
||||||
if (!resp.isSuccessful) {
|
|
||||||
val errorText = resp.errorBody()?.string()
|
|
||||||
try {
|
|
||||||
val err = gson.fromJson(errorText, InsufficientBalanceError::class.java)
|
|
||||||
if (err != null && err.code == 35600) {
|
|
||||||
throw ServiceException(err.message)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// ignore parse error
|
|
||||||
}
|
|
||||||
throw ServiceException("更新 Agent 规则失败: HTTP ${resp.code()}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteAgentRule(id: Int) {
|
|
||||||
val resp = ApiClient.api.deleteAgentRule(id)
|
|
||||||
if (!resp.isSuccessful) {
|
|
||||||
throw ServiceException("删除 Agent 规则失败: HTTP ${resp.code()}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getAgentRuleList(
|
|
||||||
openId: String,
|
|
||||||
keyword: String?,
|
|
||||||
page: Int,
|
|
||||||
pageSize: Int
|
|
||||||
): AgentRuleListResult {
|
|
||||||
val resp = ApiClient.api.getAgentRuleList(
|
|
||||||
promptId = openId,
|
|
||||||
rule = keyword,
|
|
||||||
page = page,
|
|
||||||
pageSize = pageSize
|
|
||||||
)
|
|
||||||
val data = resp.body()?.data ?: throw ServiceException("获取 Agent 规则列表失败")
|
|
||||||
return data.toEntity()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getAgentRuleQuota(openId: String): AgentRuleQuotaEntity {
|
|
||||||
val resp = ApiClient.api.getAgentRuleQuota(openId)
|
|
||||||
val data = resp.body()?.data ?: throw ServiceException("获取 Agent 规则配额失败")
|
|
||||||
return data.toEntity()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun AgentRuleListResponse.toEntity(): AgentRuleListResult = AgentRuleListResult(
|
|
||||||
page = page,
|
|
||||||
pageSize = pageSize,
|
|
||||||
total = total,
|
|
||||||
list = list.map { it.toEntity() }
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun AgentRule.toEntity(): AgentRuleEntity = AgentRuleEntity(
|
|
||||||
id = id,
|
|
||||||
rule = rule,
|
|
||||||
creator = creator,
|
|
||||||
creatorType = creatorType,
|
|
||||||
scope = scope,
|
|
||||||
agent = prompt.toEntity(),
|
|
||||||
createdAt = createdAt,
|
|
||||||
updatedAt = updatedAt,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun AgentRuleAgent.toEntity(): AgentRuleAgentInfo = AgentRuleAgentInfo(
|
|
||||||
id = id,
|
|
||||||
title = title,
|
|
||||||
avatar = avatar,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun AgentRuleQuota.toEntity(): AgentRuleQuotaEntity = AgentRuleQuotaEntity(
|
|
||||||
agentId = promptId,
|
|
||||||
agentTitle = promptTitle,
|
|
||||||
baseMaxCount = baseMaxCount,
|
|
||||||
purchasedCount = purchasedCount,
|
|
||||||
totalMaxCount = totalMaxCount,
|
|
||||||
currentCount = currentCount,
|
|
||||||
remainingCount = remainingCount,
|
|
||||||
usagePercent = usagePercent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package com.aiosman.ravenow.data
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.aiosman.ravenow.ConstVars
|
|
||||||
import com.aiosman.ravenow.data.api.ApiClient
|
|
||||||
import com.aiosman.ravenow.data.api.CreateReportRequestBody
|
|
||||||
import com.aiosman.ravenow.entity.ReportReasons
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.google.gson.annotations.SerializedName
|
|
||||||
|
|
||||||
data class ReportReasonList(
|
|
||||||
@SerializedName("reasons") var reasons: ArrayList<ReportReasons>
|
|
||||||
)
|
|
||||||
|
|
||||||
interface CommonService {
|
|
||||||
suspend fun getReportReasons(): ReportReasonList
|
|
||||||
suspend fun createReport(
|
|
||||||
reportReasonId: Int,
|
|
||||||
reportType: String,
|
|
||||||
reportId: Int,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
class CommonServiceImpl : CommonService {
|
|
||||||
private val dictService: DictService = DictServiceImpl()
|
|
||||||
override suspend fun getReportReasons(): ReportReasonList {
|
|
||||||
val dictItem = dictService.getDictByKey(ConstVars.DICT_KEY_REPORT_OPTIONS)
|
|
||||||
val rawJson: String = dictItem.value as? String ?: throw Exception("parse report reasons error")
|
|
||||||
val gson = Gson()
|
|
||||||
val list = gson.fromJson(rawJson, ReportReasonList::class.java)
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun createReport(
|
|
||||||
reportReasonId: Int,
|
|
||||||
reportType: String,
|
|
||||||
reportId: Int,
|
|
||||||
) {
|
|
||||||
ApiClient.api.createReport(
|
|
||||||
CreateReportRequestBody(
|
|
||||||
reportType = reportType,
|
|
||||||
reportId = reportId,
|
|
||||||
reason = reportReasonId,
|
|
||||||
extra = "",
|
|
||||||
base64Images = emptyList()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package com.aiosman.ravenow.data
|
|
||||||
|
|
||||||
import com.aiosman.ravenow.data.api.ApiClient
|
|
||||||
import com.aiosman.ravenow.data.api.DictItem
|
|
||||||
|
|
||||||
|
|
||||||
interface DictService {
|
|
||||||
/**
|
|
||||||
* 获取字典项
|
|
||||||
*/
|
|
||||||
suspend fun getDictByKey(key: String): DictItem
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取字典列表
|
|
||||||
*/
|
|
||||||
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 {
|
|
||||||
override suspend fun getDictByKey(key: String): DictItem {
|
|
||||||
val resp = ApiClient.api.getDict(key)
|
|
||||||
return resp.body()?.data ?: throw Exception("failed to get dict")
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getDistList(keys: List<String>): List<DictItem> {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
package com.aiosman.ravenow.data
|
|
||||||
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.data.api.ApiClient
|
|
||||||
import com.aiosman.ravenow.entity.MomentEntity
|
|
||||||
import com.aiosman.ravenow.entity.MomentImageEntity
|
|
||||||
import com.aiosman.ravenow.entity.MomentVideoEntity
|
|
||||||
import com.google.gson.annotations.SerializedName
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
data class Moment(
|
|
||||||
@SerializedName("id")
|
|
||||||
val id: Long,
|
|
||||||
@SerializedName("textContent")
|
|
||||||
val textContent: String,
|
|
||||||
@SerializedName("url")
|
|
||||||
val url: String? = null,
|
|
||||||
@SerializedName("images")
|
|
||||||
val images: List<Image>? = null,
|
|
||||||
@SerializedName("videos")
|
|
||||||
val videos: List<Video>? = null,
|
|
||||||
@SerializedName("user")
|
|
||||||
val user: User,
|
|
||||||
@SerializedName("likeCount")
|
|
||||||
val likeCount: Long,
|
|
||||||
@SerializedName("isLiked")
|
|
||||||
val isLiked: Boolean,
|
|
||||||
@SerializedName("favoriteCount")
|
|
||||||
val favoriteCount: Long,
|
|
||||||
@SerializedName("isFavorite")
|
|
||||||
val isFavorite: Boolean,
|
|
||||||
@SerializedName("isCommented")
|
|
||||||
val isCommented: Boolean,
|
|
||||||
@SerializedName("commentCount")
|
|
||||||
val commentCount: Long,
|
|
||||||
@SerializedName("time")
|
|
||||||
val time: String?,
|
|
||||||
@SerializedName("isFollowed")
|
|
||||||
val isFollowed: Boolean,
|
|
||||||
// 新闻相关字段
|
|
||||||
@SerializedName("isNews")
|
|
||||||
val isNews: Boolean = false,
|
|
||||||
@SerializedName("newsTitle")
|
|
||||||
val newsTitle: String? = null,
|
|
||||||
@SerializedName("newsUrl")
|
|
||||||
val newsUrl: String? = null,
|
|
||||||
@SerializedName("newsSource")
|
|
||||||
val newsSource: String? = null,
|
|
||||||
@SerializedName("newsCategory")
|
|
||||||
val newsCategory: String? = null,
|
|
||||||
@SerializedName("newsLanguage")
|
|
||||||
val newsLanguage: String? = null,
|
|
||||||
@SerializedName("newsContent")
|
|
||||||
val newsContent: String? = null,
|
|
||||||
@SerializedName("hasFullText")
|
|
||||||
val hasFullText: Boolean = false,
|
|
||||||
@SerializedName("summary")
|
|
||||||
val summary: String? = null,
|
|
||||||
@SerializedName("publishedAt")
|
|
||||||
val publishedAt: String? = null,
|
|
||||||
@SerializedName("imageCached")
|
|
||||||
val imageCached: Boolean = false
|
|
||||||
) {
|
|
||||||
fun toMomentItem(): MomentEntity {
|
|
||||||
return MomentEntity(
|
|
||||||
id = id.toInt(),
|
|
||||||
avatar = if (user.avatar != null && user.avatar.isNotEmpty()) {
|
|
||||||
"${ApiClient.BASE_SERVER}${user.avatar}"
|
|
||||||
} else {
|
|
||||||
"" // 如果头像为空,使用空字符串
|
|
||||||
},
|
|
||||||
nickname = user.nickName ?: "未知用户", // 如果昵称为空,使用默认值
|
|
||||||
location = "Worldwide",
|
|
||||||
time = if (time != null && time.isNotEmpty()) {
|
|
||||||
ApiClient.dateFromApiString(time)
|
|
||||||
} else {
|
|
||||||
java.util.Date() // 如果时间为空,使用当前时间作为默认值
|
|
||||||
},
|
|
||||||
followStatus = isFollowed,
|
|
||||||
momentTextContent = textContent,
|
|
||||||
momentPicture = R.drawable.default_moment_img,
|
|
||||||
likeCount = likeCount.toInt(),
|
|
||||||
commentCount = commentCount.toInt(),
|
|
||||||
shareCount = 0,
|
|
||||||
favoriteCount = favoriteCount.toInt(),
|
|
||||||
images = images?.map {
|
|
||||||
MomentImageEntity(
|
|
||||||
id = it.id,
|
|
||||||
url = "${ApiClient.BASE_SERVER}${it.url}",
|
|
||||||
originalUrl = it.originalUrl,
|
|
||||||
directUrl = it.directUrl,
|
|
||||||
thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}",
|
|
||||||
thumbnailDirectUrl = it.thumbnailDirectUrl,
|
|
||||||
small = it.small,
|
|
||||||
smallDirectUrl = it.smallDirectUrl,
|
|
||||||
medium = it.medium,
|
|
||||||
mediumDirectUrl = it.mediumDirectUrl,
|
|
||||||
large = it.large,
|
|
||||||
largeDirectUrl = it.largeDirectUrl,
|
|
||||||
blurHash = it.blurHash,
|
|
||||||
width = it.width,
|
|
||||||
height = it.height
|
|
||||||
)
|
|
||||||
} ?: emptyList(),
|
|
||||||
authorId = user.id.toInt(),
|
|
||||||
liked = isLiked,
|
|
||||||
isFavorite = isFavorite,
|
|
||||||
url = url,
|
|
||||||
videos = videos?.map {
|
|
||||||
MomentVideoEntity(
|
|
||||||
id = it.id,
|
|
||||||
url = "${ApiClient.BASE_SERVER}${it.url}",
|
|
||||||
originalUrl = it.originalUrl,
|
|
||||||
directUrl = it.directUrl,
|
|
||||||
thumbnailUrl = it.thumbnailUrl?.let { thumb -> "${ApiClient.BASE_SERVER}$thumb" },
|
|
||||||
thumbnailDirectUrl = it.thumbnailDirectUrl,
|
|
||||||
duration = it.duration,
|
|
||||||
width = it.width,
|
|
||||||
height = it.height,
|
|
||||||
size = it.size,
|
|
||||||
format = it.format,
|
|
||||||
bitrate = it.bitrate,
|
|
||||||
frameRate = it.frameRate
|
|
||||||
)
|
|
||||||
},
|
|
||||||
// 新闻相关字段
|
|
||||||
isNews = isNews,
|
|
||||||
newsTitle = newsTitle ?: "",
|
|
||||||
newsUrl = newsUrl ?: "",
|
|
||||||
newsSource = newsSource ?: "",
|
|
||||||
newsCategory = newsCategory ?: "",
|
|
||||||
newsLanguage = newsLanguage ?: "",
|
|
||||||
newsContent = newsContent ?: "",
|
|
||||||
hasFullText = hasFullText,
|
|
||||||
summary = summary,
|
|
||||||
publishedAt = publishedAt,
|
|
||||||
imageCached = imageCached
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Image(
|
|
||||||
@SerializedName("id")
|
|
||||||
val id: Long,
|
|
||||||
@SerializedName("url")
|
|
||||||
val url: String,
|
|
||||||
@SerializedName("original_url")
|
|
||||||
val originalUrl: String? = null,
|
|
||||||
@SerializedName("directUrl")
|
|
||||||
val directUrl: String? = null,
|
|
||||||
@SerializedName("thumbnail")
|
|
||||||
val thumbnail: String,
|
|
||||||
@SerializedName("thumbnailDirectUrl")
|
|
||||||
val thumbnailDirectUrl: String? = null,
|
|
||||||
@SerializedName("small")
|
|
||||||
val small: String? = null,
|
|
||||||
@SerializedName("smallDirectUrl")
|
|
||||||
val smallDirectUrl: String? = null,
|
|
||||||
@SerializedName("medium")
|
|
||||||
val medium: String? = null,
|
|
||||||
@SerializedName("mediumDirectUrl")
|
|
||||||
val mediumDirectUrl: String? = null,
|
|
||||||
@SerializedName("large")
|
|
||||||
val large: String? = null,
|
|
||||||
@SerializedName("largeDirectUrl")
|
|
||||||
val largeDirectUrl: String? = null,
|
|
||||||
@SerializedName("blurHash")
|
|
||||||
val blurHash: String?,
|
|
||||||
@SerializedName("width")
|
|
||||||
val width: Int?,
|
|
||||||
@SerializedName("height")
|
|
||||||
val height: Int?
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Video(
|
|
||||||
@SerializedName("id")
|
|
||||||
val id: Long,
|
|
||||||
@SerializedName("url")
|
|
||||||
val url: String,
|
|
||||||
@SerializedName("original_url")
|
|
||||||
val originalUrl: String? = null,
|
|
||||||
@SerializedName("directUrl")
|
|
||||||
val directUrl: String? = null,
|
|
||||||
@SerializedName("thumbnailUrl")
|
|
||||||
val thumbnailUrl: String? = null,
|
|
||||||
@SerializedName("thumbnailDirectUrl")
|
|
||||||
val thumbnailDirectUrl: String? = null,
|
|
||||||
@SerializedName("duration")
|
|
||||||
val duration: Int? = null,
|
|
||||||
@SerializedName("width")
|
|
||||||
val width: Int? = null,
|
|
||||||
@SerializedName("height")
|
|
||||||
val height: Int? = null,
|
|
||||||
@SerializedName("size")
|
|
||||||
val size: Long? = null,
|
|
||||||
@SerializedName("format")
|
|
||||||
val format: String? = null,
|
|
||||||
@SerializedName("bitrate")
|
|
||||||
val bitrate: Int? = null,
|
|
||||||
@SerializedName("frameRate")
|
|
||||||
val frameRate: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class User(
|
|
||||||
@SerializedName("id")
|
|
||||||
val id: Long,
|
|
||||||
@SerializedName("nickName")
|
|
||||||
val nickName: String?,
|
|
||||||
@SerializedName("avatar")
|
|
||||||
val avatar: String?,
|
|
||||||
@SerializedName("avatarMedium")
|
|
||||||
val avatarMedium: String? = null,
|
|
||||||
@SerializedName("avatarLarge")
|
|
||||||
val avatarLarge: String? = null,
|
|
||||||
@SerializedName("originAvatar")
|
|
||||||
val originAvatar: String? = null,
|
|
||||||
@SerializedName("avatarDirectUrl")
|
|
||||||
val avatarDirectUrl: String? = null,
|
|
||||||
@SerializedName("avatarMediumDirectUrl")
|
|
||||||
val avatarMediumDirectUrl: String? = null,
|
|
||||||
@SerializedName("avatarLargeDirectUrl")
|
|
||||||
val avatarLargeDirectUrl: String? = null,
|
|
||||||
@SerializedName("aiAccount")
|
|
||||||
val aiAccount: Boolean = false,
|
|
||||||
@SerializedName("aiRoleAvatar")
|
|
||||||
val aiRoleAvatar: String? = null,
|
|
||||||
@SerializedName("aiRoleAvatarMedium")
|
|
||||||
val aiRoleAvatarMedium: String? = null,
|
|
||||||
@SerializedName("aiRoleAvatarLarge")
|
|
||||||
val aiRoleAvatarLarge: String? = null,
|
|
||||||
@SerializedName("aiRoleAvatarDirectUrl")
|
|
||||||
val aiRoleAvatarDirectUrl: String? = null,
|
|
||||||
@SerializedName("aiRoleAvatarMediumDirectUrl")
|
|
||||||
val aiRoleAvatarMediumDirectUrl: String? = null,
|
|
||||||
@SerializedName("aiRoleAvatarLargeDirectUrl")
|
|
||||||
val aiRoleAvatarLargeDirectUrl: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UploadImage(
|
|
||||||
val file: File,
|
|
||||||
val filename: String,
|
|
||||||
val url: String,
|
|
||||||
val ext: String
|
|
||||||
)
|
|
||||||
|
|
||||||
interface MomentService {
|
|
||||||
/**
|
|
||||||
* 获取动态详情
|
|
||||||
* @param id 动态ID
|
|
||||||
*/
|
|
||||||
suspend fun getMomentById(id: Int): MomentEntity
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 点赞动态
|
|
||||||
* @param id 动态ID
|
|
||||||
*/
|
|
||||||
suspend fun likeMoment(id: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取消点赞动态
|
|
||||||
* @param id 动态ID
|
|
||||||
*/
|
|
||||||
suspend fun dislikeMoment(id: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取动态列表
|
|
||||||
* @param pageNumber 页码
|
|
||||||
* @param author 作者ID,过滤条件
|
|
||||||
* @param timelineId 用户时间线ID,指定用户 ID 的时间线
|
|
||||||
* @param contentSearch 内容搜索,过滤条件
|
|
||||||
* @param trend 是否趋势动态
|
|
||||||
* @param explore 是否探索动态
|
|
||||||
* @return 动态列表
|
|
||||||
*/
|
|
||||||
suspend fun getMoments(
|
|
||||||
pageNumber: Int,
|
|
||||||
author: Int? = null,
|
|
||||||
timelineId: Int? = null,
|
|
||||||
contentSearch: String? = null,
|
|
||||||
trend: Boolean? = false,
|
|
||||||
explore: Boolean? = false,
|
|
||||||
favoriteUserId: Int? = null
|
|
||||||
): ListContainer<MomentEntity>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建动态
|
|
||||||
* @param content 动态内容
|
|
||||||
* @param authorId 作者ID
|
|
||||||
* @param images 图片列表
|
|
||||||
* @param relPostId 关联动态ID
|
|
||||||
*/
|
|
||||||
suspend fun createMoment(
|
|
||||||
content: String,
|
|
||||||
authorId: Int,
|
|
||||||
images: List<UploadImage>,
|
|
||||||
relPostId: Int? = null
|
|
||||||
): MomentEntity
|
|
||||||
|
|
||||||
suspend fun agentMoment(
|
|
||||||
content: String,
|
|
||||||
): String
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 收藏动态
|
|
||||||
* @param id 动态ID
|
|
||||||
*/
|
|
||||||
suspend fun favoriteMoment(id: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取消收藏动态
|
|
||||||
* @param id 动态ID
|
|
||||||
*/
|
|
||||||
suspend fun unfavoriteMoment(id: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除动态
|
|
||||||
*/
|
|
||||||
suspend fun deleteMoment(id: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
package com.aiosman.ravenow.data
|
|
||||||
|
|
||||||
import com.aiosman.ravenow.data.api.ApiClient
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.google.gson.JsonSyntaxException
|
|
||||||
import com.google.gson.annotations.SerializedName
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 推荐接口响应
|
|
||||||
*/
|
|
||||||
data class RecommendationsResponse(
|
|
||||||
@SerializedName("success")
|
|
||||||
val success: Boolean,
|
|
||||||
@SerializedName("data")
|
|
||||||
val data: List<RecommendationItem>
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 推荐项
|
|
||||||
*/
|
|
||||||
data class RecommendationItem(
|
|
||||||
@SerializedName("type")
|
|
||||||
val type: String,
|
|
||||||
@SerializedName("id")
|
|
||||||
val id: Long,
|
|
||||||
@SerializedName("data")
|
|
||||||
val data: Any // 根据type字段动态解析为不同类型
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* 将data字段转换为PromptRecommendationData
|
|
||||||
*/
|
|
||||||
fun toPromptData(): PromptRecommendationData? {
|
|
||||||
if (type != "prompt") return null
|
|
||||||
return try {
|
|
||||||
val gson = Gson()
|
|
||||||
val jsonString = gson.toJson(data)
|
|
||||||
gson.fromJson(jsonString, PromptRecommendationData::class.java)
|
|
||||||
} catch (e: JsonSyntaxException) {
|
|
||||||
null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将data字段转换为PostRecommendationData (Moment)
|
|
||||||
*/
|
|
||||||
fun toPostData(): PostRecommendationData? {
|
|
||||||
if (type !in listOf("post_normal", "post_video", "post_news", "post_music")) return null
|
|
||||||
return try {
|
|
||||||
val gson = Gson()
|
|
||||||
val jsonString = gson.toJson(data)
|
|
||||||
gson.fromJson(jsonString, PostRecommendationData::class.java)
|
|
||||||
} catch (e: JsonSyntaxException) {
|
|
||||||
null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将data字段转换为RoomRecommendationData
|
|
||||||
*/
|
|
||||||
fun toRoomData(): RoomRecommendationData? {
|
|
||||||
if (type != "room") return null
|
|
||||||
return try {
|
|
||||||
val gson = Gson()
|
|
||||||
val jsonString = gson.toJson(data)
|
|
||||||
gson.fromJson(jsonString, RoomRecommendationData::class.java)
|
|
||||||
} catch (e: JsonSyntaxException) {
|
|
||||||
null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prompt类型推荐数据
|
|
||||||
*/
|
|
||||||
data class PromptRecommendationData(
|
|
||||||
@SerializedName("id")
|
|
||||||
val id: Long,
|
|
||||||
@SerializedName("title")
|
|
||||||
val title: String,
|
|
||||||
@SerializedName("desc")
|
|
||||||
val desc: String,
|
|
||||||
@SerializedName("createdAt")
|
|
||||||
val createdAt: String,
|
|
||||||
@SerializedName("updatedAt")
|
|
||||||
val updatedAt: String,
|
|
||||||
@SerializedName("avatar")
|
|
||||||
val avatar: String? = null,
|
|
||||||
@SerializedName("avatarDirectUrl")
|
|
||||||
val avatarDirectUrl: String? = null,
|
|
||||||
@SerializedName("author")
|
|
||||||
val author: String? = null,
|
|
||||||
@SerializedName("isPublic")
|
|
||||||
val isPublic: Boolean = false,
|
|
||||||
@SerializedName("openId")
|
|
||||||
val openId: String,
|
|
||||||
@SerializedName("breakMode")
|
|
||||||
val breakMode: Boolean = false,
|
|
||||||
@SerializedName("useCount")
|
|
||||||
val useCount: Int? = null,
|
|
||||||
@SerializedName("translations")
|
|
||||||
val translations: Map<String, Map<String, String>>? = null,
|
|
||||||
@SerializedName("translation")
|
|
||||||
val translation: Map<String, String>? = null,
|
|
||||||
@SerializedName("details")
|
|
||||||
val details: PromptDetails? = null,
|
|
||||||
@SerializedName("aiUserProfile")
|
|
||||||
val aiUserProfile: AIUserProfile? = null,
|
|
||||||
@SerializedName("creatorProfile")
|
|
||||||
val creatorProfile: CreatorProfile? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prompt详细信息
|
|
||||||
*/
|
|
||||||
data class PromptDetails(
|
|
||||||
@SerializedName("gender")
|
|
||||||
val gender: String? = null,
|
|
||||||
@SerializedName("age")
|
|
||||||
val age: Int? = null,
|
|
||||||
@SerializedName("mbti")
|
|
||||||
val mbti: String? = null,
|
|
||||||
@SerializedName("constellation")
|
|
||||||
val constellation: String? = null,
|
|
||||||
@SerializedName("nickname")
|
|
||||||
val nickname: String? = null,
|
|
||||||
@SerializedName("birthday")
|
|
||||||
val birthday: String? = null,
|
|
||||||
@SerializedName("signature")
|
|
||||||
val signature: String? = null,
|
|
||||||
@SerializedName("nationality")
|
|
||||||
val nationality: String? = null,
|
|
||||||
@SerializedName("mainLanguage")
|
|
||||||
val mainLanguage: String? = null,
|
|
||||||
@SerializedName("worldview")
|
|
||||||
val worldview: String? = null,
|
|
||||||
@SerializedName("habits")
|
|
||||||
val habits: String? = null,
|
|
||||||
@SerializedName("hobbies")
|
|
||||||
val hobbies: String? = null,
|
|
||||||
@SerializedName("occupation")
|
|
||||||
val occupation: String? = null,
|
|
||||||
@SerializedName("expertise")
|
|
||||||
val expertise: String? = null,
|
|
||||||
@SerializedName("socialActivityLvl")
|
|
||||||
val socialActivityLvl: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AI用户资料
|
|
||||||
*/
|
|
||||||
data class AIUserProfile(
|
|
||||||
@SerializedName("id")
|
|
||||||
val id: Long,
|
|
||||||
@SerializedName("username")
|
|
||||||
val username: String? = null,
|
|
||||||
@SerializedName("nickname")
|
|
||||||
val nickname: String,
|
|
||||||
@SerializedName("avatar")
|
|
||||||
val avatar: String? = null,
|
|
||||||
@SerializedName("avatarMedium")
|
|
||||||
val avatarMedium: String? = null,
|
|
||||||
@SerializedName("avatarLarge")
|
|
||||||
val avatarLarge: String? = null,
|
|
||||||
@SerializedName("avatarDirectUrl")
|
|
||||||
val avatarDirectUrl: String? = null,
|
|
||||||
@SerializedName("avatarMediumDirectUrl")
|
|
||||||
val avatarMediumDirectUrl: String? = null,
|
|
||||||
@SerializedName("avatarLargeDirectUrl")
|
|
||||||
val avatarLargeDirectUrl: String? = null,
|
|
||||||
@SerializedName("bio")
|
|
||||||
val bio: String? = null,
|
|
||||||
@SerializedName("trtcUserId")
|
|
||||||
val trtcUserId: String? = null,
|
|
||||||
@SerializedName("chatAIId")
|
|
||||||
val chatAIId: String? = null,
|
|
||||||
@SerializedName("aiAccount")
|
|
||||||
val aiAccount: Boolean = true,
|
|
||||||
@SerializedName("aiRoleAvatar")
|
|
||||||
val aiRoleAvatar: String? = null,
|
|
||||||
@SerializedName("aiRoleAvatarMedium")
|
|
||||||
val aiRoleAvatarMedium: String? = null,
|
|
||||||
@SerializedName("aiRoleAvatarLarge")
|
|
||||||
val aiRoleAvatarLarge: String? = null,
|
|
||||||
@SerializedName("aiRoleAvatarDirectUrl")
|
|
||||||
val aiRoleAvatarDirectUrl: String? = null,
|
|
||||||
@SerializedName("aiRoleAvatarMediumDirectUrl")
|
|
||||||
val aiRoleAvatarMediumDirectUrl: String? = null,
|
|
||||||
@SerializedName("aiRoleAvatarLargeDirectUrl")
|
|
||||||
val aiRoleAvatarLargeDirectUrl: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建者资料
|
|
||||||
*/
|
|
||||||
data class CreatorProfile(
|
|
||||||
@SerializedName("id")
|
|
||||||
val id: Long,
|
|
||||||
@SerializedName("username")
|
|
||||||
val username: String? = null,
|
|
||||||
@SerializedName("nickname")
|
|
||||||
val nickname: String,
|
|
||||||
@SerializedName("avatar")
|
|
||||||
val avatar: String? = null,
|
|
||||||
@SerializedName("avatarMedium")
|
|
||||||
val avatarMedium: String? = null,
|
|
||||||
@SerializedName("avatarLarge")
|
|
||||||
val avatarLarge: String? = null,
|
|
||||||
@SerializedName("avatarDirectUrl")
|
|
||||||
val avatarDirectUrl: String? = null,
|
|
||||||
@SerializedName("avatarMediumDirectUrl")
|
|
||||||
val avatarMediumDirectUrl: String? = null,
|
|
||||||
@SerializedName("avatarLargeDirectUrl")
|
|
||||||
val avatarLargeDirectUrl: String? = null,
|
|
||||||
@SerializedName("bio")
|
|
||||||
val bio: String? = null,
|
|
||||||
@SerializedName("trtcUserId")
|
|
||||||
val trtcUserId: String? = null,
|
|
||||||
@SerializedName("chatAIId")
|
|
||||||
val chatAIId: String? = null,
|
|
||||||
@SerializedName("aiAccount")
|
|
||||||
val aiAccount: Boolean = false,
|
|
||||||
@SerializedName("aiRoleAvatar")
|
|
||||||
val aiRoleAvatar: String? = null,
|
|
||||||
@SerializedName("aiRoleAvatarMedium")
|
|
||||||
val aiRoleAvatarMedium: String? = null,
|
|
||||||
@SerializedName("aiRoleAvatarLarge")
|
|
||||||
val aiRoleAvatarLarge: String? = null,
|
|
||||||
@SerializedName("aiRoleAvatarDirectUrl")
|
|
||||||
val aiRoleAvatarDirectUrl: String? = null,
|
|
||||||
@SerializedName("aiRoleAvatarMediumDirectUrl")
|
|
||||||
val aiRoleAvatarMediumDirectUrl: String? = null,
|
|
||||||
@SerializedName("aiRoleAvatarLargeDirectUrl")
|
|
||||||
val aiRoleAvatarLargeDirectUrl: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Post类型推荐数据(复用Moment中的结构)
|
|
||||||
* 支持 post_normal, post_video, post_news, post_music
|
|
||||||
*/
|
|
||||||
typealias PostRecommendationData = Moment
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Room类型推荐数据
|
|
||||||
*/
|
|
||||||
data class RoomRecommendationData(
|
|
||||||
@SerializedName("id")
|
|
||||||
val id: Long,
|
|
||||||
@SerializedName("name")
|
|
||||||
val name: String,
|
|
||||||
@SerializedName("description")
|
|
||||||
val description: String,
|
|
||||||
@SerializedName("createdAt")
|
|
||||||
val createdAt: String? = null,
|
|
||||||
@SerializedName("updatedAt")
|
|
||||||
val updatedAt: String? = null,
|
|
||||||
@SerializedName("cover")
|
|
||||||
val cover: String? = null,
|
|
||||||
@SerializedName("coverDirectUrl")
|
|
||||||
val coverDirectUrl: String? = null,
|
|
||||||
@SerializedName("avatar")
|
|
||||||
val avatar: String? = null,
|
|
||||||
@SerializedName("avatarDirectUrl")
|
|
||||||
val avatarDirectUrl: String? = null,
|
|
||||||
@SerializedName("recommendBanner")
|
|
||||||
val recommendBanner: String? = null,
|
|
||||||
@SerializedName("trtcRoomId")
|
|
||||||
val trtcRoomId: String? = null,
|
|
||||||
@SerializedName("trtcType")
|
|
||||||
val trtcType: String? = null,
|
|
||||||
@SerializedName("isRecommended")
|
|
||||||
val isRecommended: Boolean = false,
|
|
||||||
@SerializedName("allowInHot")
|
|
||||||
val allowInHot: Boolean = false,
|
|
||||||
@SerializedName("language")
|
|
||||||
val language: String? = null,
|
|
||||||
@SerializedName("maxTotal")
|
|
||||||
val maxTotal: Int? = null,
|
|
||||||
@SerializedName("privateFeePaid")
|
|
||||||
val privateFeePaid: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 推荐Service接口
|
|
||||||
*/
|
|
||||||
interface RecommendationService {
|
|
||||||
/**
|
|
||||||
* 获取推荐列表
|
|
||||||
* @param pool 推荐池名称(可选)
|
|
||||||
* @param count 返回数量(可选,默认10,最大50)
|
|
||||||
* @param lang 语言代码(可选)
|
|
||||||
* @param promptReplaceTrans 是否用翻译覆盖原字段(可选,仅对prompt类型生效)
|
|
||||||
* @return 推荐项列表
|
|
||||||
*/
|
|
||||||
suspend fun getRecommendations(
|
|
||||||
pool: String? = null,
|
|
||||||
count: Int? = null,
|
|
||||||
lang: String? = null,
|
|
||||||
promptReplaceTrans: Boolean? = null
|
|
||||||
): List<RecommendationItem>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 推荐Service实现
|
|
||||||
*/
|
|
||||||
class RecommendationServiceImpl : RecommendationService {
|
|
||||||
override suspend fun getRecommendations(
|
|
||||||
pool: String?,
|
|
||||||
count: Int?,
|
|
||||||
lang: String?,
|
|
||||||
promptReplaceTrans: Boolean?
|
|
||||||
): List<RecommendationItem> {
|
|
||||||
val resp = ApiClient.api.getRecommendations(
|
|
||||||
pool = pool,
|
|
||||||
count = count,
|
|
||||||
lang = lang,
|
|
||||||
promptReplaceTrans = promptReplaceTrans
|
|
||||||
)
|
|
||||||
if (resp.isSuccessful) {
|
|
||||||
val body = resp.body()
|
|
||||||
if (body != null && body.success) {
|
|
||||||
return body.data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw ServiceException("Failed to get recommendations")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CreatorProfile 扩展函数,转换为 CreatorProfileEntity
|
|
||||||
*/
|
|
||||||
fun CreatorProfile.toCreatorProfileEntity(): com.aiosman.ravenow.entity.CreatorProfileEntity {
|
|
||||||
return com.aiosman.ravenow.entity.CreatorProfileEntity(
|
|
||||||
id = id,
|
|
||||||
username = username,
|
|
||||||
nickname = nickname,
|
|
||||||
avatar = avatar?.let {
|
|
||||||
if (it.isNotEmpty()) "${ApiClient.BASE_SERVER}$it" else null
|
|
||||||
},
|
|
||||||
bio = bio,
|
|
||||||
trtcUserId = trtcUserId,
|
|
||||||
chatAIId = chatAIId,
|
|
||||||
aiAccount = aiAccount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,618 +0,0 @@
|
|||||||
package com.aiosman.ravenow.data
|
|
||||||
|
|
||||||
import com.aiosman.ravenow.data.api.ApiClient
|
|
||||||
import com.aiosman.ravenow.data.api.CreateRoomRuleRequestBody
|
|
||||||
import com.aiosman.ravenow.data.api.UpdateRoomRuleRequestBody
|
|
||||||
import com.aiosman.ravenow.data.api.RoomRuleQuota
|
|
||||||
import com.aiosman.ravenow.data.api.RoomRule
|
|
||||||
import com.aiosman.ravenow.data.api.RoomRuleCreator
|
|
||||||
import com.aiosman.ravenow.entity.AddAgentToRoomFailedItemEntity
|
|
||||||
import com.aiosman.ravenow.entity.AddAgentToRoomItemEntity
|
|
||||||
import com.aiosman.ravenow.entity.AddAgentToRoomResultEntity
|
|
||||||
import com.aiosman.ravenow.entity.AddUserToRoomFailedItemEntity
|
|
||||||
import com.aiosman.ravenow.entity.AddUserToRoomItemEntity
|
|
||||||
import com.aiosman.ravenow.entity.AddUserToRoomResultEntity
|
|
||||||
import com.aiosman.ravenow.entity.CreatorEntity
|
|
||||||
import com.aiosman.ravenow.entity.RemoveAgentFromRoomFailedItemEntity
|
|
||||||
import com.aiosman.ravenow.entity.RemoveAgentFromRoomItemEntity
|
|
||||||
import com.aiosman.ravenow.entity.RemoveAgentFromRoomResultEntity
|
|
||||||
import com.aiosman.ravenow.entity.RemoveUserFromRoomFailedItemEntity
|
|
||||||
import com.aiosman.ravenow.entity.RemoveUserFromRoomItemEntity
|
|
||||||
import com.aiosman.ravenow.entity.RemoveUserFromRoomResultEntity
|
|
||||||
import com.aiosman.ravenow.entity.RoomEntity
|
|
||||||
import com.aiosman.ravenow.entity.RoomRuleEntity
|
|
||||||
import com.aiosman.ravenow.entity.RoomRuleCreatorEntity
|
|
||||||
import com.aiosman.ravenow.entity.RoomRuleQuotaEntity
|
|
||||||
import com.aiosman.ravenow.entity.UsersEntity
|
|
||||||
import com.google.gson.annotations.SerializedName
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 房间内的智能体信息(PromptTemplate)
|
|
||||||
*/
|
|
||||||
data class PromptTemplate(
|
|
||||||
@SerializedName("id")
|
|
||||||
val id: Int,
|
|
||||||
@SerializedName("openId")
|
|
||||||
val openId: String,
|
|
||||||
@SerializedName("title")
|
|
||||||
val title: String,
|
|
||||||
@SerializedName("desc")
|
|
||||||
val desc: String,
|
|
||||||
@SerializedName("avatar")
|
|
||||||
val avatar: String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Room(
|
|
||||||
@SerializedName("id")
|
|
||||||
val id: Int,
|
|
||||||
@SerializedName("name")
|
|
||||||
val name: String,
|
|
||||||
@SerializedName("description")
|
|
||||||
val description: String,
|
|
||||||
@SerializedName("trtcRoomId")
|
|
||||||
val trtcRoomId: String,
|
|
||||||
@SerializedName("trtcType")
|
|
||||||
val trtcType: String,
|
|
||||||
@SerializedName("cover")
|
|
||||||
val cover: String,
|
|
||||||
@SerializedName("avatar")
|
|
||||||
val avatar: String,
|
|
||||||
@SerializedName("recommendBanner")
|
|
||||||
val recommendBanner: String,
|
|
||||||
@SerializedName("isRecommended")
|
|
||||||
val isRecommended: Boolean,
|
|
||||||
@SerializedName("allowInHot")
|
|
||||||
val allowInHot: Boolean,
|
|
||||||
@SerializedName("creator")
|
|
||||||
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>
|
|
||||||
|
|
||||||
) {
|
|
||||||
fun toRoomtEntity(): RoomEntity {
|
|
||||||
return RoomEntity(
|
|
||||||
id= id,
|
|
||||||
name = name,
|
|
||||||
description = description ,
|
|
||||||
trtcRoomId = trtcRoomId,
|
|
||||||
trtcType = trtcType,
|
|
||||||
cover = cover,
|
|
||||||
avatar = avatar,
|
|
||||||
recommendBanner = recommendBanner,
|
|
||||||
isRecommended = isRecommended,
|
|
||||||
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() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
data class Creator(
|
|
||||||
@SerializedName("id")
|
|
||||||
val id: Int,
|
|
||||||
@SerializedName("userId")
|
|
||||||
val userId: String,
|
|
||||||
@SerializedName("trtcUserId")
|
|
||||||
val trtcUserId: String? = null,
|
|
||||||
@SerializedName("profile")
|
|
||||||
val profile: Profile
|
|
||||||
){
|
|
||||||
fun toCreatorEntity(): CreatorEntity {
|
|
||||||
return CreatorEntity(
|
|
||||||
id = id,
|
|
||||||
userId = userId,
|
|
||||||
trtcUserId = trtcUserId ?: "",
|
|
||||||
profile = profile.toProfileEntity()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Users(
|
|
||||||
@SerializedName("id")
|
|
||||||
val id: Int,
|
|
||||||
@SerializedName("userId")
|
|
||||||
val userId: String,
|
|
||||||
@SerializedName("trtcUserId")
|
|
||||||
val trtcUserId: String? = null,
|
|
||||||
@SerializedName("profile")
|
|
||||||
val profile: Profile
|
|
||||||
){
|
|
||||||
fun toUsersEntity(): UsersEntity {
|
|
||||||
return UsersEntity(
|
|
||||||
id = id,
|
|
||||||
userId = userId,
|
|
||||||
profile = profile.toProfileEntity()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 房间规则相关服务
|
|
||||||
*/
|
|
||||||
interface RoomService {
|
|
||||||
/**
|
|
||||||
* 创建房间规则
|
|
||||||
* @param rule 规则内容,不能为空
|
|
||||||
* @param roomId 房间ID,与 trtcId 二选一
|
|
||||||
* @param trtcId TRTC房间ID,与 roomId 二选一
|
|
||||||
* @throws ServiceException 创建失败时抛出异常
|
|
||||||
*/
|
|
||||||
suspend fun createRoomRule(
|
|
||||||
rule: String,
|
|
||||||
roomId: Int? = null,
|
|
||||||
trtcId: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 修改房间规则
|
|
||||||
* @param id 规则ID
|
|
||||||
* @param rule 新的规则内容,不能为空
|
|
||||||
* @throws ServiceException 修改失败时抛出异常
|
|
||||||
*/
|
|
||||||
suspend fun updateRoomRule(
|
|
||||||
id: Int,
|
|
||||||
rule: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除房间规则
|
|
||||||
* @param id 规则ID
|
|
||||||
* @throws ServiceException 删除失败时抛出异常
|
|
||||||
*/
|
|
||||||
suspend fun deleteRoomRule(id: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询房间规则列表
|
|
||||||
* @param roomId 房间ID,与 trtcId 二选一
|
|
||||||
* @param trtcId TRTC房间ID,与 roomId 二选一
|
|
||||||
* @param page 页码,默认 1
|
|
||||||
* @param pageSize 每页数量,默认 10
|
|
||||||
* @return 规则列表响应,包含分页信息和规则列表
|
|
||||||
* @throws ServiceException 查询失败时抛出异常
|
|
||||||
*/
|
|
||||||
suspend fun getRoomRuleList(
|
|
||||||
roomId: Int? = null,
|
|
||||||
trtcId: String? = null,
|
|
||||||
page: Int = 1,
|
|
||||||
pageSize: Int = 10
|
|
||||||
): ListContainer<RoomRuleEntity>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询规则配额使用情况
|
|
||||||
* @param roomId 房间ID,与 trtcId 二选一
|
|
||||||
* @param trtcId TRTC房间ID,与 roomId 二选一
|
|
||||||
* @return 规则配额信息
|
|
||||||
* @throws ServiceException 查询失败时抛出异常
|
|
||||||
*/
|
|
||||||
suspend fun getRoomRuleQuota(
|
|
||||||
roomId: Int? = null,
|
|
||||||
trtcId: String? = null
|
|
||||||
): RoomRuleQuotaEntity
|
|
||||||
|
|
||||||
// ========== Room Member Management ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加用户到房间
|
|
||||||
*
|
|
||||||
* @param roomId 房间ID,与 trtcId 二选一
|
|
||||||
* @param trtcId TRTC群组ID,与 roomId 二选一
|
|
||||||
* @param openIds 要添加的用户OpenID列表
|
|
||||||
* @return 添加结果实体
|
|
||||||
* @throws ServiceException 添加失败时抛出异常
|
|
||||||
*/
|
|
||||||
suspend fun addUserToRoom(
|
|
||||||
roomId: Int? = null,
|
|
||||||
trtcId: String? = null,
|
|
||||||
openIds: List<String>
|
|
||||||
): AddUserToRoomResultEntity
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加智能体到房间
|
|
||||||
*
|
|
||||||
* @param roomId 房间ID,与 trtcId 二选一
|
|
||||||
* @param trtcId TRTC群组ID,与 roomId 二选一
|
|
||||||
* @param agentOpenIds 要添加的智能体OpenID列表
|
|
||||||
* @return 添加结果实体
|
|
||||||
* @throws ServiceException 添加失败时抛出异常
|
|
||||||
*/
|
|
||||||
suspend fun addAgentToRoom(
|
|
||||||
roomId: Int? = null,
|
|
||||||
trtcId: String? = null,
|
|
||||||
agentOpenIds: List<String>
|
|
||||||
): AddAgentToRoomResultEntity
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从房间移除智能体
|
|
||||||
*
|
|
||||||
* @param roomId 房间ID,与 trtcId 二选一
|
|
||||||
* @param trtcId TRTC群组ID,与 roomId 二选一
|
|
||||||
* @param agentOpenIds 要移除的智能体OpenID列表
|
|
||||||
* @return 移除结果实体
|
|
||||||
* @throws ServiceException 移除失败时抛出异常
|
|
||||||
*/
|
|
||||||
suspend fun removeAgentFromRoom(
|
|
||||||
roomId: Int? = null,
|
|
||||||
trtcId: String? = null,
|
|
||||||
agentOpenIds: List<String>
|
|
||||||
): RemoveAgentFromRoomResultEntity
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从房间移除用户
|
|
||||||
*
|
|
||||||
* @param roomId 房间ID,与 trtcId 二选一
|
|
||||||
* @param trtcId TRTC群组ID,与 roomId 二选一
|
|
||||||
* @param userIds 要移除的用户ID列表(OpenID)
|
|
||||||
* @return 移除结果实体
|
|
||||||
* @throws ServiceException 移除失败时抛出异常
|
|
||||||
*/
|
|
||||||
suspend fun removeUserFromRoom(
|
|
||||||
roomId: Int? = null,
|
|
||||||
trtcId: String? = null,
|
|
||||||
userIds: List<String>
|
|
||||||
): RemoveUserFromRoomResultEntity
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 房间规则服务实现类
|
|
||||||
*/
|
|
||||||
class RoomServiceImpl : RoomService {
|
|
||||||
override suspend fun createRoomRule(
|
|
||||||
rule: String,
|
|
||||||
roomId: Int?,
|
|
||||||
trtcId: String?
|
|
||||||
) {
|
|
||||||
val resp = ApiClient.api.createRoomRule(
|
|
||||||
CreateRoomRuleRequestBody(
|
|
||||||
rule = rule,
|
|
||||||
roomId = roomId,
|
|
||||||
trtcId = trtcId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (!resp.isSuccessful) {
|
|
||||||
throw ServiceException("创建房间规则失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateRoomRule(
|
|
||||||
id: Int,
|
|
||||||
rule: String
|
|
||||||
) {
|
|
||||||
val resp = ApiClient.api.updateRoomRule(
|
|
||||||
UpdateRoomRuleRequestBody(
|
|
||||||
id = id,
|
|
||||||
rule = rule
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (!resp.isSuccessful) {
|
|
||||||
throw ServiceException("修改房间规则失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteRoomRule(id: Int) {
|
|
||||||
val resp = ApiClient.api.deleteRoomRule(id)
|
|
||||||
if (!resp.isSuccessful) {
|
|
||||||
throw ServiceException("删除房间规则失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getRoomRuleList(
|
|
||||||
roomId: Int?,
|
|
||||||
trtcId: String?,
|
|
||||||
page: Int,
|
|
||||||
pageSize: Int
|
|
||||||
): ListContainer<RoomRuleEntity> {
|
|
||||||
val resp = ApiClient.api.getRoomRuleList(
|
|
||||||
roomId = roomId,
|
|
||||||
trtcId = trtcId,
|
|
||||||
page = page,
|
|
||||||
pageSize = pageSize
|
|
||||||
)
|
|
||||||
val body = resp.body() ?: throw ServiceException("获取房间规则列表失败")
|
|
||||||
|
|
||||||
return ListContainer(
|
|
||||||
list = body.list.map { it.toRoomRuleEntity() },
|
|
||||||
page = body.page,
|
|
||||||
total = body.total,
|
|
||||||
pageSize = body.pageSize
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getRoomRuleQuota(
|
|
||||||
roomId: Int?,
|
|
||||||
trtcId: String?
|
|
||||||
): RoomRuleQuotaEntity {
|
|
||||||
val resp = ApiClient.api.getRoomRuleQuota(
|
|
||||||
roomId = roomId,
|
|
||||||
trtcId = trtcId
|
|
||||||
)
|
|
||||||
val body = resp.body() ?: throw ServiceException("获取规则配额信息失败")
|
|
||||||
val data = body.data ?: throw ServiceException("规则配额数据为空")
|
|
||||||
|
|
||||||
return data.toRoomRuleQuotaEntity()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun addUserToRoom(
|
|
||||||
roomId: Int?,
|
|
||||||
trtcId: String?,
|
|
||||||
openIds: List<String>
|
|
||||||
): AddUserToRoomResultEntity {
|
|
||||||
val resp = ApiClient.api.addUserToRoom(
|
|
||||||
com.aiosman.ravenow.data.api.AddUserToRoomRequestBody(
|
|
||||||
roomId = roomId,
|
|
||||||
trtcId = trtcId,
|
|
||||||
openIds = openIds
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val body = resp.body() ?: throw ServiceException("添加用户到房间失败")
|
|
||||||
val data = body.data ?: throw ServiceException("添加用户响应数据为空")
|
|
||||||
|
|
||||||
return data.result.toAddUserToRoomResultEntity()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun addAgentToRoom(
|
|
||||||
roomId: Int?,
|
|
||||||
trtcId: String?,
|
|
||||||
agentOpenIds: List<String>
|
|
||||||
): AddAgentToRoomResultEntity {
|
|
||||||
val resp = ApiClient.api.addAgentToRoom(
|
|
||||||
com.aiosman.ravenow.data.api.AddAgentToRoomRequestBody(
|
|
||||||
roomId = roomId,
|
|
||||||
trtcId = trtcId,
|
|
||||||
agentOpenIds = agentOpenIds
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val body = resp.body() ?: throw ServiceException("添加智能体到房间失败")
|
|
||||||
val data = body.data ?: throw ServiceException("添加智能体响应数据为空")
|
|
||||||
|
|
||||||
return data.result.toAddAgentToRoomResultEntity()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun removeAgentFromRoom(
|
|
||||||
roomId: Int?,
|
|
||||||
trtcId: String?,
|
|
||||||
agentOpenIds: List<String>
|
|
||||||
): RemoveAgentFromRoomResultEntity {
|
|
||||||
val resp = ApiClient.api.removeAgentFromRoom(
|
|
||||||
com.aiosman.ravenow.data.api.RemoveAgentFromRoomRequestBody(
|
|
||||||
roomId = roomId,
|
|
||||||
trtcId = trtcId,
|
|
||||||
agentOpenIds = agentOpenIds
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val body = resp.body() ?: throw ServiceException("从房间移除智能体失败")
|
|
||||||
val data = body.data ?: throw ServiceException("移除智能体响应数据为空")
|
|
||||||
|
|
||||||
return data.toRemoveAgentFromRoomResultEntity()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun removeUserFromRoom(
|
|
||||||
roomId: Int?,
|
|
||||||
trtcId: String?,
|
|
||||||
userIds: List<String>
|
|
||||||
): RemoveUserFromRoomResultEntity {
|
|
||||||
val resp = ApiClient.api.removeUserFromRoom(
|
|
||||||
com.aiosman.ravenow.data.api.RemoveUserFromRoomRequestBody(
|
|
||||||
roomId = roomId,
|
|
||||||
trtcId = trtcId,
|
|
||||||
userIds = userIds
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val body = resp.body() ?: throw ServiceException("从房间移除用户失败")
|
|
||||||
val data = body.data ?: throw ServiceException("移除用户响应数据为空")
|
|
||||||
|
|
||||||
return data.toRemoveUserFromRoomResultEntity()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RoomRule 扩展函数,转换为 RoomRuleEntity
|
|
||||||
*/
|
|
||||||
fun RoomRule.toRoomRuleEntity(): RoomRuleEntity {
|
|
||||||
return RoomRuleEntity(
|
|
||||||
id = id,
|
|
||||||
rule = rule,
|
|
||||||
creator = creator?.toRoomRuleCreatorEntity(),
|
|
||||||
creatorType = creatorType,
|
|
||||||
roomId = roomId,
|
|
||||||
createdAt = createdAt,
|
|
||||||
updatedAt = updatedAt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RoomRuleCreator 扩展函数,转换为 RoomRuleCreatorEntity
|
|
||||||
*/
|
|
||||||
fun RoomRuleCreator.toRoomRuleCreatorEntity(): RoomRuleCreatorEntity {
|
|
||||||
return RoomRuleCreatorEntity(
|
|
||||||
id = id,
|
|
||||||
nickname = nickname,
|
|
||||||
avatar = avatar,
|
|
||||||
avatarMedium = avatarMedium,
|
|
||||||
avatarLarge = avatarLarge,
|
|
||||||
avatarDirectUrl = avatarDirectUrl,
|
|
||||||
avatarMediumDirectUrl = avatarMediumDirectUrl,
|
|
||||||
avatarLargeDirectUrl = avatarLargeDirectUrl
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RoomRuleQuota 扩展函数,转换为 RoomRuleQuotaEntity
|
|
||||||
*/
|
|
||||||
fun RoomRuleQuota.toRoomRuleQuotaEntity(): RoomRuleQuotaEntity {
|
|
||||||
return RoomRuleQuotaEntity(
|
|
||||||
baseMaxCount = baseMaxCount,
|
|
||||||
purchasedCount = purchasedCount,
|
|
||||||
totalMaxCount = totalMaxCount,
|
|
||||||
currentCount = currentCount,
|
|
||||||
remainingCount = remainingCount,
|
|
||||||
usagePercent = usagePercent
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Room Member Management 扩展函数 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AddUserToRoomResult 扩展函数,转换为 AddUserToRoomResultEntity
|
|
||||||
*/
|
|
||||||
fun com.aiosman.ravenow.data.api.AddUserToRoomResult.toAddUserToRoomResultEntity(): AddUserToRoomResultEntity {
|
|
||||||
return AddUserToRoomResultEntity(
|
|
||||||
totalCount = totalCount,
|
|
||||||
successCount = successCount,
|
|
||||||
failedCount = failedCount,
|
|
||||||
skippedCount = skippedCount,
|
|
||||||
successItems = successItems.map { it.toAddUserToRoomItemEntity() },
|
|
||||||
failedItems = failedItems.map { it.toAddUserToRoomFailedItemEntity() },
|
|
||||||
skippedItems = skippedItems.map { it.toAddUserToRoomItemEntity() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun com.aiosman.ravenow.data.api.AddUserToRoomItem.toAddUserToRoomItemEntity(): AddUserToRoomItemEntity {
|
|
||||||
return AddUserToRoomItemEntity(
|
|
||||||
userId = userId,
|
|
||||||
type = type
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun com.aiosman.ravenow.data.api.AddUserToRoomFailedItem.toAddUserToRoomFailedItemEntity(): AddUserToRoomFailedItemEntity {
|
|
||||||
return AddUserToRoomFailedItemEntity(
|
|
||||||
userId = userId,
|
|
||||||
type = type,
|
|
||||||
error = error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AddAgentToRoomResult 扩展函数,转换为 AddAgentToRoomResultEntity
|
|
||||||
*/
|
|
||||||
fun com.aiosman.ravenow.data.api.AddAgentToRoomResult.toAddAgentToRoomResultEntity(): AddAgentToRoomResultEntity {
|
|
||||||
return AddAgentToRoomResultEntity(
|
|
||||||
totalCount = totalCount,
|
|
||||||
successCount = successCount,
|
|
||||||
failedCount = failedCount,
|
|
||||||
skippedCount = skippedCount,
|
|
||||||
successItems = successItems.map { it.toAddAgentToRoomItemEntity() },
|
|
||||||
failedItems = failedItems.map { it.toAddAgentToRoomFailedItemEntity() },
|
|
||||||
skippedItems = skippedItems.map { it.toAddAgentToRoomItemEntity() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun com.aiosman.ravenow.data.api.AddAgentToRoomItem.toAddAgentToRoomItemEntity(): AddAgentToRoomItemEntity {
|
|
||||||
return AddAgentToRoomItemEntity(
|
|
||||||
agentOpenId = agentOpenId,
|
|
||||||
type = type
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun com.aiosman.ravenow.data.api.AddAgentToRoomFailedItem.toAddAgentToRoomFailedItemEntity(): AddAgentToRoomFailedItemEntity {
|
|
||||||
return AddAgentToRoomFailedItemEntity(
|
|
||||||
agentOpenId = agentOpenId,
|
|
||||||
type = type,
|
|
||||||
error = error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RemoveAgentFromRoomResult 扩展函数,转换为 RemoveAgentFromRoomResultEntity
|
|
||||||
*/
|
|
||||||
fun com.aiosman.ravenow.data.api.RemoveAgentFromRoomResult.toRemoveAgentFromRoomResultEntity(): RemoveAgentFromRoomResultEntity {
|
|
||||||
return RemoveAgentFromRoomResultEntity(
|
|
||||||
totalCount = totalCount,
|
|
||||||
successCount = successCount,
|
|
||||||
failedCount = failedCount,
|
|
||||||
skippedCount = skippedCount,
|
|
||||||
successItems = successItems.map { it.toRemoveAgentFromRoomItemEntity() },
|
|
||||||
failedItems = failedItems.map { it.toRemoveAgentFromRoomFailedItemEntity() },
|
|
||||||
skippedItems = skippedItems.map { it.toRemoveAgentFromRoomItemEntity() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun com.aiosman.ravenow.data.api.RemoveAgentFromRoomItem.toRemoveAgentFromRoomItemEntity(): RemoveAgentFromRoomItemEntity {
|
|
||||||
return RemoveAgentFromRoomItemEntity(
|
|
||||||
agentOpenId = agentOpenId,
|
|
||||||
type = type
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun com.aiosman.ravenow.data.api.RemoveAgentFromRoomFailedItem.toRemoveAgentFromRoomFailedItemEntity(): RemoveAgentFromRoomFailedItemEntity {
|
|
||||||
return RemoveAgentFromRoomFailedItemEntity(
|
|
||||||
agentOpenId = agentOpenId,
|
|
||||||
type = type,
|
|
||||||
error = error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RemoveUserFromRoomResult 扩展函数,转换为 RemoveUserFromRoomResultEntity
|
|
||||||
*/
|
|
||||||
fun com.aiosman.ravenow.data.api.RemoveUserFromRoomResult.toRemoveUserFromRoomResultEntity(): RemoveUserFromRoomResultEntity {
|
|
||||||
return RemoveUserFromRoomResultEntity(
|
|
||||||
totalCount = totalCount,
|
|
||||||
successCount = successCount,
|
|
||||||
failedCount = failedCount,
|
|
||||||
skippedCount = skippedCount,
|
|
||||||
successItems = successItems.map { it.toRemoveUserFromRoomItemEntity() },
|
|
||||||
failedItems = failedItems.map { it.toRemoveUserFromRoomFailedItemEntity() },
|
|
||||||
skippedItems = skippedItems.map { it.toRemoveUserFromRoomItemEntity() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun com.aiosman.ravenow.data.api.RemoveUserFromRoomItem.toRemoveUserFromRoomItemEntity(): RemoveUserFromRoomItemEntity {
|
|
||||||
return RemoveUserFromRoomItemEntity(
|
|
||||||
userId = userId,
|
|
||||||
type = type
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun com.aiosman.ravenow.data.api.RemoveUserFromRoomFailedItem.toRemoveUserFromRoomFailedItemEntity(): RemoveUserFromRoomFailedItemEntity {
|
|
||||||
return RemoveUserFromRoomFailedItemEntity(
|
|
||||||
userId = userId,
|
|
||||||
type = type,
|
|
||||||
error = error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package com.aiosman.ravenow.data.api
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.widget.Toast
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
|
|
||||||
//
|
|
||||||
enum class ErrorCode(val code: Int) {
|
|
||||||
USER_EXIST(40001),
|
|
||||||
USER_NOT_EXIST(40002),
|
|
||||||
InvalidateCaptcha(40004),
|
|
||||||
IncorrectOldPassword(40005),
|
|
||||||
// 未知错误
|
|
||||||
UNKNOWN(99999)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ErrorCode.toErrorMessage(context: Context): String {
|
|
||||||
return context.getErrorMessageCode(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ErrorCode.showToast(context: Context) {
|
|
||||||
Toast.makeText(context, toErrorMessage(context), Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
// code to ErrorCode
|
|
||||||
fun Int.toErrorCode(): ErrorCode {
|
|
||||||
return when (this) {
|
|
||||||
40001 -> ErrorCode.USER_EXIST
|
|
||||||
40002 -> ErrorCode.USER_NOT_EXIST
|
|
||||||
40004 -> ErrorCode.InvalidateCaptcha
|
|
||||||
40005 -> ErrorCode.IncorrectOldPassword
|
|
||||||
else -> ErrorCode.UNKNOWN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Context.getErrorMessageCode(code: Int?): String {
|
|
||||||
return when (code) {
|
|
||||||
40001 -> getString(R.string.error_10001_user_exist)
|
|
||||||
ErrorCode.IncorrectOldPassword.code -> getString(R.string.error_incorrect_old_password)
|
|
||||||
else -> getString(R.string.error_unknown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,58 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
package com.aiosman.ravenow.data.membership
|
|
||||||
|
|
||||||
import com.google.gson.JsonElement
|
|
||||||
import com.google.gson.annotations.SerializedName
|
|
||||||
|
|
||||||
data class MembershipConfigData(
|
|
||||||
@SerializedName("id") val id: Int,
|
|
||||||
@SerializedName("name") val name: String,
|
|
||||||
@SerializedName("description") val description: String,
|
|
||||||
@SerializedName("version") val version: String,
|
|
||||||
@SerializedName("config_data") val configData: ConfigData,
|
|
||||||
@SerializedName("createdAt") val createdAt: String,
|
|
||||||
@SerializedName("updatedAt") val updatedAt: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ConfigData(
|
|
||||||
@SerializedName("members") val members: List<Member>
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Member(
|
|
||||||
@SerializedName("name") val name: String,
|
|
||||||
@SerializedName("benefits") val benefits: List<Benefit>,
|
|
||||||
@SerializedName("goods") val goods: List<Good>,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Benefit(
|
|
||||||
@SerializedName("level") val level: Int,
|
|
||||||
@SerializedName("name") val name: String,
|
|
||||||
@SerializedName("value") val value: JsonElement,
|
|
||||||
@SerializedName("order") val order: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Good(
|
|
||||||
@SerializedName("description") val description: String,
|
|
||||||
@SerializedName("discount") val discount: Double,
|
|
||||||
@SerializedName("goods_id") val goodsId: String,
|
|
||||||
@SerializedName("originalPrice") val originalPrice: Double,
|
|
||||||
@SerializedName("period") val period: String,
|
|
||||||
@SerializedName("price") val price: Double,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ValidateProductRequestBody(
|
|
||||||
@SerializedName("plan_id") val planId: String,
|
|
||||||
@SerializedName("product_id") val productId: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ValidateData(
|
|
||||||
@SerializedName("isValid") val isValid: Boolean,
|
|
||||||
@SerializedName("mapped") val mapped: Boolean,
|
|
||||||
@SerializedName("hasUnfinishedOrder") val hasUnfinishedOrder: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class VipPriceModel(
|
|
||||||
val title: String,
|
|
||||||
val proPrice: String,
|
|
||||||
val standardPrice: String,
|
|
||||||
val proDesc: String,
|
|
||||||
val standardDesc: String,
|
|
||||||
val id: String,
|
|
||||||
val proGoodsId: String?,
|
|
||||||
val standardGoodsId: String?,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
const val MONTH_ID = "monthly"
|
|
||||||
const val YEAR_ID = "yearly"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class VipPageDataModel(
|
|
||||||
val title: String,
|
|
||||||
val proHave: Boolean?,
|
|
||||||
val proDesc: String,
|
|
||||||
val standardHave: Boolean?,
|
|
||||||
val standardDesc: String,
|
|
||||||
val freeHave: Boolean?,
|
|
||||||
val freeDesc: String,
|
|
||||||
val order: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
object VipModelMapper {
|
|
||||||
fun generatePageDataList(members: List<Member>): List<VipPageDataModel> {
|
|
||||||
if (members.size < 3) return emptyList()
|
|
||||||
val free = members[0]
|
|
||||||
val standard = members[1]
|
|
||||||
val pro = members[2]
|
|
||||||
|
|
||||||
val names = (members.flatMap { it.benefits.map { b -> b.name } }).toSet().sorted()
|
|
||||||
val list = names.map { name ->
|
|
||||||
val freeB = free.benefits.firstOrNull { it.name == name }
|
|
||||||
val stdB = standard.benefits.firstOrNull { it.name == name }
|
|
||||||
val proB = pro.benefits.firstOrNull { it.name == name }
|
|
||||||
|
|
||||||
val order = proB?.order ?: stdB?.order ?: freeB?.order ?: 0
|
|
||||||
VipPageDataModel(
|
|
||||||
title = name,
|
|
||||||
proHave = proB?.value?.asBooleanOrNull(),
|
|
||||||
proDesc = proB?.value?.asStringOrEmpty() ?: "",
|
|
||||||
standardHave = stdB?.value?.asBooleanOrNull(),
|
|
||||||
standardDesc = stdB?.value?.asStringOrEmpty() ?: "",
|
|
||||||
freeHave = freeB?.value?.asBooleanOrNull(),
|
|
||||||
freeDesc = freeB?.value?.asStringOrEmpty() ?: "",
|
|
||||||
order = order,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return list.sortedBy { it.order }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun generatePriceDataList(members: List<Member>): List<VipPriceModel> {
|
|
||||||
if (members.size < 3) return emptyList()
|
|
||||||
val standard = members[1]
|
|
||||||
val pro = members[2]
|
|
||||||
|
|
||||||
val list = mutableListOf<VipPriceModel>()
|
|
||||||
// 首月(示例:如果后端 period = "first_month")
|
|
||||||
val stdFirst = standard.goods.firstOrNull { it.period == "first_month" }
|
|
||||||
val proFirst = pro.goods.firstOrNull { it.period == "first_month" }
|
|
||||||
if (stdFirst != null && proFirst != null) {
|
|
||||||
list.add(
|
|
||||||
VipPriceModel(
|
|
||||||
title = "首月",
|
|
||||||
proPrice = proFirst.price.toInt().toString(),
|
|
||||||
standardPrice = stdFirst.price.toInt().toString(),
|
|
||||||
proDesc = proFirst.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
|
|
||||||
standardDesc = stdFirst.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
|
|
||||||
id = "first_month",
|
|
||||||
proGoodsId = proFirst.goodsId,
|
|
||||||
standardGoodsId = stdFirst.goodsId,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val stdMonth = standard.goods.firstOrNull { it.period == "month" }
|
|
||||||
val proMonth = pro.goods.firstOrNull { it.period == "month" }
|
|
||||||
if (stdMonth != null && proMonth != null) {
|
|
||||||
list.add(
|
|
||||||
VipPriceModel(
|
|
||||||
title = "月付",
|
|
||||||
proPrice = proMonth.price.toInt().toString(),
|
|
||||||
standardPrice = stdMonth.price.toInt().toString(),
|
|
||||||
proDesc = proMonth.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
|
|
||||||
standardDesc = stdMonth.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
|
|
||||||
id = VipPriceModel.MONTH_ID,
|
|
||||||
proGoodsId = proMonth.goodsId,
|
|
||||||
standardGoodsId = stdMonth.goodsId,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// 半年
|
|
||||||
val stdHalf = standard.goods.firstOrNull { it.period == "half_year" }
|
|
||||||
val proHalf = pro.goods.firstOrNull { it.period == "half_year" }
|
|
||||||
if (stdHalf != null && proHalf != null) {
|
|
||||||
list.add(
|
|
||||||
VipPriceModel(
|
|
||||||
title = "半年",
|
|
||||||
proPrice = proHalf.price.toInt().toString(),
|
|
||||||
standardPrice = stdHalf.price.toInt().toString(),
|
|
||||||
proDesc = proHalf.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
|
|
||||||
standardDesc = stdHalf.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
|
|
||||||
id = "half_year",
|
|
||||||
proGoodsId = proHalf.goodsId,
|
|
||||||
standardGoodsId = stdHalf.goodsId,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val stdYear = standard.goods.firstOrNull { it.period == "year" }
|
|
||||||
val proYear = pro.goods.firstOrNull { it.period == "year" }
|
|
||||||
if (stdYear != null && proYear != null) {
|
|
||||||
list.add(
|
|
||||||
VipPriceModel(
|
|
||||||
title = "每年",
|
|
||||||
proPrice = proYear.price.toInt().toString(),
|
|
||||||
standardPrice = stdYear.price.toInt().toString(),
|
|
||||||
proDesc = proYear.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
|
|
||||||
standardDesc = stdYear.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
|
|
||||||
id = VipPriceModel.YEAR_ID,
|
|
||||||
proGoodsId = proYear.goodsId,
|
|
||||||
standardGoodsId = stdYear.goodsId,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement.asStringOrEmpty(): String {
|
|
||||||
return try { if (isJsonPrimitive && asJsonPrimitive.isString) asString else "" } catch (_: Exception) { "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement.asBooleanOrNull(): Boolean? {
|
|
||||||
return try { if (isJsonPrimitive && asJsonPrimitive.isBoolean) asBoolean else null } catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
package com.aiosman.ravenow.entity
|
|
||||||
|
|
||||||
import androidx.paging.PagingSource
|
|
||||||
import androidx.paging.PagingState
|
|
||||||
import com.aiosman.ravenow.AppStore
|
|
||||||
import com.aiosman.ravenow.data.Agent
|
|
||||||
import com.aiosman.ravenow.data.ListContainer
|
|
||||||
import com.aiosman.ravenow.data.AgentService
|
|
||||||
import com.aiosman.ravenow.data.DataContainer
|
|
||||||
import com.aiosman.ravenow.data.ServiceException
|
|
||||||
import com.aiosman.ravenow.data.UploadImage
|
|
||||||
import com.aiosman.ravenow.data.api.ApiClient
|
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
||||||
import okhttp3.MultipartBody
|
|
||||||
import okhttp3.RequestBody.Companion.asRequestBody
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import java.io.File
|
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 智能体
|
|
||||||
*/
|
|
||||||
suspend fun createAgent(
|
|
||||||
title: String,
|
|
||||||
desc: String,
|
|
||||||
avatar: UploadImage? = null,
|
|
||||||
workflowId: Int = 1,
|
|
||||||
isPublic: Boolean = true,
|
|
||||||
breakMode: Boolean = false,
|
|
||||||
useWorkflow: Boolean = true,
|
|
||||||
): AgentEntity {
|
|
||||||
val textTitle = title.toRequestBody("text/plain".toMediaTypeOrNull())
|
|
||||||
val textDesc = desc.toRequestBody("text/plain".toMediaTypeOrNull())
|
|
||||||
val workflowIdRequestBody =
|
|
||||||
workflowId.toString().toRequestBody("text/plain".toMediaTypeOrNull())
|
|
||||||
val isPublicRequestBody = isPublic.toString().toRequestBody("text/plain".toMediaTypeOrNull())
|
|
||||||
val breakModeRequestBody = breakMode.toString().toRequestBody("text/plain".toMediaTypeOrNull())
|
|
||||||
val useWorkflowRequestBody =
|
|
||||||
useWorkflow.toString().toRequestBody("text/plain".toMediaTypeOrNull())
|
|
||||||
val workflowInputsValue = "{\"si\":\"$desc\"}"
|
|
||||||
val workflowInputsRequestBody =
|
|
||||||
workflowInputsValue.toRequestBody("text/plain".toMediaTypeOrNull())
|
|
||||||
|
|
||||||
val avatarField: MultipartBody.Part? = avatar?.let {
|
|
||||||
createMultipartBody(it.file, it.filename, "avatar")
|
|
||||||
}
|
|
||||||
val response = ApiClient.api.createAgent(
|
|
||||||
avatarField,
|
|
||||||
textTitle,
|
|
||||||
textDesc,
|
|
||||||
textDesc,
|
|
||||||
workflowIdRequestBody,
|
|
||||||
isPublicRequestBody,
|
|
||||||
breakModeRequestBody,
|
|
||||||
useWorkflowRequestBody,
|
|
||||||
workflowInputsRequestBody
|
|
||||||
)
|
|
||||||
val body = response.body()?.data ?: throw ServiceException("Failed to create agent")
|
|
||||||
return body.toAgentEntity()
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 智能体信息分页加载器
|
|
||||||
*/
|
|
||||||
class AgentPagingSource(
|
|
||||||
private val agentRemoteDataSource: AgentRemoteDataSource,
|
|
||||||
private val authorId: Int? = null
|
|
||||||
) : PagingSource<Int, AgentEntity>() {
|
|
||||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, AgentEntity> {
|
|
||||||
return try {
|
|
||||||
val currentPage = params.key ?: 1
|
|
||||||
val users = agentRemoteDataSource.getAgent(
|
|
||||||
pageNumber = currentPage,
|
|
||||||
authorId = authorId
|
|
||||||
)
|
|
||||||
LoadResult.Page(
|
|
||||||
data = users?.list ?: listOf(),
|
|
||||||
prevKey = if (currentPage == 1) null else currentPage - 1,
|
|
||||||
nextKey = if (users?.list?.isNotEmpty() == true) users.page + 1 else null
|
|
||||||
)
|
|
||||||
} catch (exception: IOException) {
|
|
||||||
return LoadResult.Error(exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getRefreshKey(state: PagingState<Int, AgentEntity>): Int? {
|
|
||||||
return state.anchorPosition
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 智能体搜索分页加载器(按标题关键字)
|
|
||||||
*/
|
|
||||||
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,
|
|
||||||
) {
|
|
||||||
suspend fun getAgent(
|
|
||||||
pageNumber: Int,
|
|
||||||
authorId: Int? = null
|
|
||||||
): ListContainer<AgentEntity>? {
|
|
||||||
return agentService.getAgent(
|
|
||||||
pageNumber = pageNumber,
|
|
||||||
authorId = authorId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchAgentByTitle(
|
|
||||||
pageNumber: Int,
|
|
||||||
title: String
|
|
||||||
): ListContainer<AgentEntity>? {
|
|
||||||
return agentService.searchAgentByTitle(
|
|
||||||
pageNumber = pageNumber,
|
|
||||||
title = title
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AgentServiceImpl() : AgentService {
|
|
||||||
val agentBackend = AgentBackend()
|
|
||||||
|
|
||||||
override suspend fun getAgent(
|
|
||||||
pageNumber: Int,
|
|
||||||
pageSize: Int,
|
|
||||||
authorId: Int?
|
|
||||||
): ListContainer<AgentEntity>? {
|
|
||||||
return agentBackend.getAgent(
|
|
||||||
pageNumber = pageNumber,
|
|
||||||
authorId = authorId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun searchAgentByTitle(
|
|
||||||
pageNumber: Int,
|
|
||||||
pageSize: Int,
|
|
||||||
title: String
|
|
||||||
): ListContainer<AgentEntity>? {
|
|
||||||
return agentBackend.searchAgentByTitle(
|
|
||||||
pageNumber = pageNumber,
|
|
||||||
title = title
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AgentBackend {
|
|
||||||
val DataBatchSize = 20
|
|
||||||
suspend fun getAgent(
|
|
||||||
pageNumber: Int,
|
|
||||||
authorId: Int? = null
|
|
||||||
): ListContainer<AgentEntity>? {
|
|
||||||
// 如果是游客模式且获取我的Agent(authorId为null),返回空列表
|
|
||||||
if (authorId == null && AppStore.isGuest) {
|
|
||||||
return ListContainer(
|
|
||||||
total = 0,
|
|
||||||
page = pageNumber,
|
|
||||||
pageSize = DataBatchSize,
|
|
||||||
list = emptyList()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val resp = if (authorId != null) {
|
|
||||||
ApiClient.api.getAgent(
|
|
||||||
page = pageNumber,
|
|
||||||
pageSize = DataBatchSize,
|
|
||||||
authorId = authorId
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ApiClient.api.getMyAgent(
|
|
||||||
page = pageNumber,
|
|
||||||
pageSize = DataBatchSize
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val body = resp.body() ?: return null
|
|
||||||
|
|
||||||
// 处理不同的返回类型
|
|
||||||
return if (authorId != null) {
|
|
||||||
// getAgent 返回 DataContainer<ListContainer<Agent>>
|
|
||||||
val dataContainer =
|
|
||||||
body as DataContainer<ListContainer<Agent>>
|
|
||||||
val listContainer = dataContainer.data
|
|
||||||
ListContainer(
|
|
||||||
total = listContainer.total,
|
|
||||||
page = pageNumber,
|
|
||||||
pageSize = DataBatchSize,
|
|
||||||
list = listContainer.list.map { it.toAgentEntity() }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// getMyAgent 返回 ListContainer<Agent>
|
|
||||||
val listContainer =
|
|
||||||
body as ListContainer<Agent>
|
|
||||||
ListContainer(
|
|
||||||
total = listContainer.total,
|
|
||||||
page = pageNumber,
|
|
||||||
pageSize = DataBatchSize,
|
|
||||||
list = listContainer.list.map { it.toAgentEntity() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
//val author: String,
|
|
||||||
val avatar: String,
|
|
||||||
val breakMode: Boolean,
|
|
||||||
val createdAt: String,
|
|
||||||
val desc: String,
|
|
||||||
val id: Int,
|
|
||||||
val isPublic: Boolean,
|
|
||||||
val openId: String,
|
|
||||||
//val profile: ProfileEntity,
|
|
||||||
val title: String,
|
|
||||||
val updatedAt: String,
|
|
||||||
val useCount: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
fun createMultipartBody(file: File, filename: String, name: String): MultipartBody.Part {
|
|
||||||
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
|
|
||||||
return MultipartBody.Part.createFormData(name, filename, requestFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
class AgentLoaderExtraArgs(
|
|
||||||
val authorId: Int? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
class AgentLoader : DataLoader<AgentEntity, AgentLoaderExtraArgs>() {
|
|
||||||
override suspend fun fetchData(
|
|
||||||
page: Int,
|
|
||||||
pageSize: Int,
|
|
||||||
extra: AgentLoaderExtraArgs
|
|
||||||
): ListContainer<AgentEntity> {
|
|
||||||
// 如果是游客模式且获取我的Agent(authorId为null),返回空列表
|
|
||||||
if (extra.authorId == null && AppStore.isGuest) {
|
|
||||||
return ListContainer(
|
|
||||||
total = 0,
|
|
||||||
page = page,
|
|
||||||
pageSize = pageSize,
|
|
||||||
list = emptyList()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = if (extra.authorId != null) {
|
|
||||||
ApiClient.api.getAgent(
|
|
||||||
page = page,
|
|
||||||
pageSize = pageSize,
|
|
||||||
authorId = extra.authorId
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ApiClient.api.getMyAgent(
|
|
||||||
page = page,
|
|
||||||
pageSize = pageSize
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val body = result.body() ?: throw ServiceException("Failed to get agent")
|
|
||||||
|
|
||||||
return if (extra.authorId != null) {
|
|
||||||
// getAgent 返回 DataContainer<ListContainer<Agent>>
|
|
||||||
val dataContainer = body as DataContainer<ListContainer<Agent>>
|
|
||||||
val listContainer = dataContainer.data
|
|
||||||
ListContainer(
|
|
||||||
list = listContainer.list.map { it.toAgentEntity() },
|
|
||||||
total = listContainer.total,
|
|
||||||
page = page,
|
|
||||||
pageSize = pageSize
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// getMyAgent 返回 ListContainer<Agent>
|
|
||||||
val listContainer =
|
|
||||||
body as ListContainer<Agent>
|
|
||||||
ListContainer(
|
|
||||||
list = listContainer.list.map { it.toAgentEntity() },
|
|
||||||
total = listContainer.total,
|
|
||||||
page = page,
|
|
||||||
pageSize = pageSize
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package com.aiosman.ravenow.entity
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.icu.util.Calendar
|
|
||||||
import com.aiosman.ravenow.ConstVars
|
|
||||||
import com.aiosman.ravenow.exp.formatChatTime
|
|
||||||
import com.google.gson.annotations.SerializedName
|
|
||||||
import io.openim.android.sdk.models.Message
|
|
||||||
import io.openim.android.sdk.models.PictureElem
|
|
||||||
|
|
||||||
data class ChatItem(
|
|
||||||
val message: String,
|
|
||||||
val avatar: String,
|
|
||||||
val time: String,
|
|
||||||
val userId: String,
|
|
||||||
val nickname: String,
|
|
||||||
val timeCategory: String = "",
|
|
||||||
val timestamp: Long = 0,
|
|
||||||
val imageList: MutableList<PictureInfo> = emptyList<PictureInfo>().toMutableList(),
|
|
||||||
val messageType: Int = 0,
|
|
||||||
val textDisplay: String = "",
|
|
||||||
val msgId: String, // Add this property
|
|
||||||
var showTimestamp: Boolean = false,
|
|
||||||
var showTimeDivider: Boolean = false
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
// OpenIM 消息类型常量
|
|
||||||
const val MESSAGE_TYPE_TEXT = 101
|
|
||||||
const val MESSAGE_TYPE_IMAGE = 102
|
|
||||||
const val MESSAGE_TYPE_AUDIO = 103
|
|
||||||
const val MESSAGE_TYPE_VIDEO = 104
|
|
||||||
const val MESSAGE_TYPE_FILE = 105
|
|
||||||
|
|
||||||
fun convertToChatItem(message: Message, context: Context, avatar: String? = null): ChatItem? {
|
|
||||||
val timestamp = message.createTime
|
|
||||||
val calendar = Calendar.getInstance()
|
|
||||||
calendar.timeInMillis = timestamp
|
|
||||||
|
|
||||||
var faceAvatar = avatar
|
|
||||||
if (faceAvatar == null) {
|
|
||||||
faceAvatar = "${ConstVars.BASE_SERVER}${message.senderFaceUrl}"
|
|
||||||
}
|
|
||||||
|
|
||||||
when (message.contentType) {
|
|
||||||
MESSAGE_TYPE_IMAGE -> {
|
|
||||||
val pictureElem = message.pictureElem
|
|
||||||
if (pictureElem != null && !pictureElem.sourcePicture.url.isNullOrEmpty()) {
|
|
||||||
return ChatItem(
|
|
||||||
message = "Image",
|
|
||||||
avatar = faceAvatar,
|
|
||||||
time = calendar.time.formatChatTime(context),
|
|
||||||
userId = message.sendID,
|
|
||||||
nickname = message.senderNickname ?: "",
|
|
||||||
timestamp = timestamp,
|
|
||||||
imageList = listOfNotNull(
|
|
||||||
PictureInfo(
|
|
||||||
url = pictureElem.sourcePicture.url ?: "",
|
|
||||||
width = pictureElem.sourcePicture.width,
|
|
||||||
height = pictureElem.sourcePicture.height,
|
|
||||||
size = pictureElem.sourcePicture.size
|
|
||||||
)
|
|
||||||
).toMutableList(),
|
|
||||||
messageType = MESSAGE_TYPE_IMAGE,
|
|
||||||
textDisplay = "Image",
|
|
||||||
msgId = message.clientMsgID
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
MESSAGE_TYPE_TEXT -> {
|
|
||||||
return ChatItem(
|
|
||||||
message = message.textElem?.content ?: "Unsupported message type",
|
|
||||||
avatar = faceAvatar,
|
|
||||||
time = calendar.time.formatChatTime(context),
|
|
||||||
userId = message.sendID,
|
|
||||||
nickname = message.senderNickname ?: "",
|
|
||||||
timestamp = timestamp,
|
|
||||||
imageList = emptyList<PictureInfo>().toMutableList(),
|
|
||||||
messageType = MESSAGE_TYPE_TEXT,
|
|
||||||
textDisplay = message.textElem?.content ?: "Unsupported message type",
|
|
||||||
msgId = message.clientMsgID
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenIM 图片信息数据类
|
|
||||||
data class PictureInfo(
|
|
||||||
val url: String,
|
|
||||||
val width: Int,
|
|
||||||
val height: Int,
|
|
||||||
val size: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
data class ChatNotification(
|
|
||||||
@SerializedName("userId")
|
|
||||||
val userId: Int,
|
|
||||||
@SerializedName("userTrtcId")
|
|
||||||
val userTrtcId: String,
|
|
||||||
@SerializedName("targetUserId")
|
|
||||||
val targetUserId: Int,
|
|
||||||
@SerializedName("targetTrtcId")
|
|
||||||
val targetTrtcId: String,
|
|
||||||
@SerializedName("strategy")
|
|
||||||
val strategy: String
|
|
||||||
)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package com.aiosman.ravenow.entity
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.google.gson.annotations.SerializedName
|
|
||||||
|
|
||||||
data class ReportReasons(
|
|
||||||
@SerializedName("id") var id: Int,
|
|
||||||
@SerializedName("text") var text: Map<String, String>
|
|
||||||
) {
|
|
||||||
fun getReasonText(context:Context): String? {
|
|
||||||
val language = context.resources.configuration.locale.language
|
|
||||||
val langMapping = mapOf(
|
|
||||||
"zh" to "zh",
|
|
||||||
"en" to "en"
|
|
||||||
)
|
|
||||||
val useLang = langMapping[language] ?: "en"
|
|
||||||
return text[useLang] ?: text["en"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package com.aiosman.ravenow.entity
|
|
||||||
|
|
||||||
data class GroupMember(
|
|
||||||
val userId: String,
|
|
||||||
val nickname: String,
|
|
||||||
val avatar: String,
|
|
||||||
val isOwner: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GroupInfo(
|
|
||||||
val groupId: String,
|
|
||||||
val groupName: String,
|
|
||||||
val groupAvatar: String,
|
|
||||||
val memberCount: Int,
|
|
||||||
val isCreator: Boolean = false,
|
|
||||||
val trtcType: String = "Public",
|
|
||||||
val privateFeePaid: Boolean = false,
|
|
||||||
)
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
package com.aiosman.ravenow.entity
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import com.aiosman.ravenow.data.ListContainer
|
|
||||||
|
|
||||||
abstract class DataLoader<T,ET> {
|
|
||||||
var list: MutableList<T> = mutableListOf()
|
|
||||||
var page by mutableStateOf(1)
|
|
||||||
var total by mutableStateOf(0L)
|
|
||||||
var pageSize by mutableStateOf(10)
|
|
||||||
var hasNext by mutableStateOf(true)
|
|
||||||
var isLoading by mutableStateOf(false)
|
|
||||||
var error by mutableStateOf<String?>(null)
|
|
||||||
var onListChanged: ((List<T>) -> Unit)? = null
|
|
||||||
var onError: ((String) -> Unit)? = null
|
|
||||||
private var firstLoad = true
|
|
||||||
|
|
||||||
|
|
||||||
abstract suspend fun fetchData(
|
|
||||||
page: Int, pageSize: Int, extra: ET
|
|
||||||
): ListContainer<T>
|
|
||||||
|
|
||||||
suspend fun loadData(
|
|
||||||
extra: ET
|
|
||||||
) {
|
|
||||||
Log.d("DataLoader", "loadData开始 - firstLoad: $firstLoad")
|
|
||||||
if (!firstLoad) {
|
|
||||||
Log.d("DataLoader", "loadData跳过 - 非首次加载")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
Log.d("DataLoader", "loadData跳过 - 正在加载中")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
firstLoad = false
|
|
||||||
isLoading = true
|
|
||||||
error = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
Log.d("DataLoader", "调用fetchData - page: $page, pageSize: $pageSize")
|
|
||||||
val result = fetchData(page, pageSize, extra)
|
|
||||||
list = result.list.toMutableList()
|
|
||||||
this.page = page
|
|
||||||
this.total = result.total
|
|
||||||
this.pageSize = pageSize
|
|
||||||
this.hasNext = result.list.size == pageSize
|
|
||||||
Log.d("DataLoader", "loadData完成 - 数据量: ${list.size}, total: $total, hasNext: $hasNext")
|
|
||||||
onListChanged?.invoke(list)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("DataLoader", "loadData失败", e)
|
|
||||||
error = e.message ?: "加载数据时发生未知错误"
|
|
||||||
firstLoad = true // 重置firstLoad状态,允许重试
|
|
||||||
onError?.invoke(error!!)
|
|
||||||
} finally {
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun loadMore(extra: ET) {
|
|
||||||
if (firstLoad) {
|
|
||||||
Log.d("DataLoader", "loadMore跳过 - firstLoad为true")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!hasNext) {
|
|
||||||
Log.d("DataLoader", "loadMore跳过 - hasNext为false")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
Log.d("DataLoader", "loadMore跳过 - 正在加载中")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = true
|
|
||||||
error = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
Log.d("DataLoader", "开始loadMore - 当前页: $page, 当前数据量: ${list.size}")
|
|
||||||
val result = fetchData(page + 1, pageSize, extra)
|
|
||||||
list.addAll(result.list)
|
|
||||||
page += 1
|
|
||||||
hasNext = result.list.size == pageSize
|
|
||||||
Log.d("DataLoader", "loadMore完成 - 新页: $page, 新数据量: ${list.size}, 本次获取: ${result.list.size}, hasNext: $hasNext")
|
|
||||||
onListChanged?.invoke(list)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("DataLoader", "loadMore失败", e)
|
|
||||||
error = e.message ?: "加载更多数据时发生未知错误"
|
|
||||||
onError?.invoke(error!!)
|
|
||||||
} finally {
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear() {
|
|
||||||
list.clear()
|
|
||||||
page = 1
|
|
||||||
total = 0
|
|
||||||
pageSize = 10
|
|
||||||
hasNext = true
|
|
||||||
firstLoad = true
|
|
||||||
isLoading = false
|
|
||||||
error = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
package com.aiosman.ravenow.entity
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.paging.PagingSource
|
|
||||||
import androidx.paging.PagingState
|
|
||||||
import com.aiosman.ravenow.data.ListContainer
|
|
||||||
import com.aiosman.ravenow.data.Room
|
|
||||||
import com.aiosman.ravenow.data.ServiceException
|
|
||||||
import com.aiosman.ravenow.data.api.ApiClient
|
|
||||||
import 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,
|
|
||||||
val description: String,
|
|
||||||
val trtcRoomId: String,
|
|
||||||
val trtcType: String,
|
|
||||||
val cover: String,
|
|
||||||
val avatar: String,
|
|
||||||
val recommendBanner: String,
|
|
||||||
val isRecommended: Boolean,
|
|
||||||
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>,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CreatorEntity(
|
|
||||||
val id: Int,
|
|
||||||
val userId: String,
|
|
||||||
val trtcUserId: String,
|
|
||||||
val profile: ProfileEntity
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UsersEntity(
|
|
||||||
val id: Int,
|
|
||||||
val userId: String,
|
|
||||||
val profile: ProfileEntity
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ProfileEntity(
|
|
||||||
val id: Int,
|
|
||||||
val username: String,
|
|
||||||
val nickname: String,
|
|
||||||
val avatar: String,
|
|
||||||
val banner: String,
|
|
||||||
val bio: String,
|
|
||||||
val trtcUserId: String,
|
|
||||||
val chatAIId: String,
|
|
||||||
val aiAccount: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 房间规则创建者信息
|
|
||||||
*/
|
|
||||||
data class RoomRuleCreatorEntity(
|
|
||||||
val id: Int,
|
|
||||||
val nickname: String,
|
|
||||||
val avatar: String,
|
|
||||||
val avatarMedium: String? = null,
|
|
||||||
val avatarLarge: String? = null,
|
|
||||||
val avatarDirectUrl: String? = null,
|
|
||||||
val avatarMediumDirectUrl: String? = null,
|
|
||||||
val avatarLargeDirectUrl: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 房间规则详情
|
|
||||||
*/
|
|
||||||
data class RoomRuleEntity(
|
|
||||||
val id: Int,
|
|
||||||
val rule: String,
|
|
||||||
val creator: RoomRuleCreatorEntity?,
|
|
||||||
val creatorType: String,
|
|
||||||
val roomId: Int,
|
|
||||||
val createdAt: String,
|
|
||||||
val updatedAt: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 房间规则配额信息
|
|
||||||
*/
|
|
||||||
data class RoomRuleQuotaEntity(
|
|
||||||
val baseMaxCount: Int,
|
|
||||||
val purchasedCount: Int,
|
|
||||||
val totalMaxCount: Int,
|
|
||||||
val currentCount: Int,
|
|
||||||
val remainingCount: Int,
|
|
||||||
val usagePercent: Double
|
|
||||||
)
|
|
||||||
|
|
||||||
// ========== Room Member Management 实体类 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加用户成功项目
|
|
||||||
*/
|
|
||||||
data class AddUserToRoomItemEntity(
|
|
||||||
val userId: String,
|
|
||||||
val type: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加用户失败项目
|
|
||||||
*/
|
|
||||||
data class AddUserToRoomFailedItemEntity(
|
|
||||||
val userId: String,
|
|
||||||
val type: String,
|
|
||||||
val error: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加用户到房间的结果
|
|
||||||
*/
|
|
||||||
data class AddUserToRoomResultEntity(
|
|
||||||
val totalCount: Int,
|
|
||||||
val successCount: Int,
|
|
||||||
val failedCount: Int,
|
|
||||||
val skippedCount: Int,
|
|
||||||
val successItems: List<AddUserToRoomItemEntity>,
|
|
||||||
val failedItems: List<AddUserToRoomFailedItemEntity>,
|
|
||||||
val skippedItems: List<AddUserToRoomItemEntity>
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加智能体成功项目
|
|
||||||
*/
|
|
||||||
data class AddAgentToRoomItemEntity(
|
|
||||||
val agentOpenId: String,
|
|
||||||
val type: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加智能体失败项目
|
|
||||||
*/
|
|
||||||
data class AddAgentToRoomFailedItemEntity(
|
|
||||||
val agentOpenId: String,
|
|
||||||
val type: String,
|
|
||||||
val error: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加智能体到房间的结果
|
|
||||||
*/
|
|
||||||
data class AddAgentToRoomResultEntity(
|
|
||||||
val totalCount: Int,
|
|
||||||
val successCount: Int,
|
|
||||||
val failedCount: Int,
|
|
||||||
val skippedCount: Int,
|
|
||||||
val successItems: List<AddAgentToRoomItemEntity>,
|
|
||||||
val failedItems: List<AddAgentToRoomFailedItemEntity>,
|
|
||||||
val skippedItems: List<AddAgentToRoomItemEntity>
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移除智能体成功项目
|
|
||||||
*/
|
|
||||||
data class RemoveAgentFromRoomItemEntity(
|
|
||||||
val agentOpenId: String,
|
|
||||||
val type: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移除智能体失败项目
|
|
||||||
*/
|
|
||||||
data class RemoveAgentFromRoomFailedItemEntity(
|
|
||||||
val agentOpenId: String,
|
|
||||||
val type: String,
|
|
||||||
val error: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从房间移除智能体的结果
|
|
||||||
*/
|
|
||||||
data class RemoveAgentFromRoomResultEntity(
|
|
||||||
val totalCount: Int,
|
|
||||||
val successCount: Int,
|
|
||||||
val failedCount: Int,
|
|
||||||
val skippedCount: Int,
|
|
||||||
val successItems: List<RemoveAgentFromRoomItemEntity>,
|
|
||||||
val failedItems: List<RemoveAgentFromRoomFailedItemEntity>,
|
|
||||||
val skippedItems: List<RemoveAgentFromRoomItemEntity>
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移除用户成功项目
|
|
||||||
*/
|
|
||||||
data class RemoveUserFromRoomItemEntity(
|
|
||||||
val userId: String,
|
|
||||||
val type: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移除用户失败项目
|
|
||||||
*/
|
|
||||||
data class RemoveUserFromRoomFailedItemEntity(
|
|
||||||
val userId: String,
|
|
||||||
val type: String,
|
|
||||||
val error: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从房间移除用户的结果
|
|
||||||
*/
|
|
||||||
data class RemoveUserFromRoomResultEntity(
|
|
||||||
val totalCount: Int,
|
|
||||||
val successCount: Int,
|
|
||||||
val failedCount: Int,
|
|
||||||
val skippedCount: Int,
|
|
||||||
val successItems: List<RemoveUserFromRoomItemEntity>,
|
|
||||||
val failedItems: List<RemoveUserFromRoomFailedItemEntity>,
|
|
||||||
val skippedItems: List<RemoveUserFromRoomItemEntity>
|
|
||||||
)
|
|
||||||
|
|
||||||
class RoomLoader : DataLoader<AgentEntity,AgentLoaderExtraArgs>() {
|
|
||||||
override suspend fun fetchData(
|
|
||||||
page: Int,
|
|
||||||
pageSize: Int,
|
|
||||||
extra: AgentLoaderExtraArgs
|
|
||||||
): ListContainer<AgentEntity> {
|
|
||||||
val result = ApiClient.api.getAgent(
|
|
||||||
page = page,
|
|
||||||
pageSize = pageSize,
|
|
||||||
|
|
||||||
)
|
|
||||||
val data = result.body()?.let {
|
|
||||||
ListContainer(
|
|
||||||
list = it.data.list.map { it.toAgentEntity()},
|
|
||||||
total = it.data.total,
|
|
||||||
page = page,
|
|
||||||
pageSize = pageSize
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (data == null) {
|
|
||||||
throw ServiceException("Failed to get agent")
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 房间远程数据源
|
|
||||||
*/
|
|
||||||
class RoomRemoteDataSource {
|
|
||||||
suspend fun searchRooms(
|
|
||||||
pageNumber: Int,
|
|
||||||
pageSize: Int = 20,
|
|
||||||
search: String
|
|
||||||
): ListContainer<RoomEntity>? {
|
|
||||||
return try {
|
|
||||||
val resp = ApiClient.api.getRooms(
|
|
||||||
page = pageNumber,
|
|
||||||
pageSize = pageSize,
|
|
||||||
search = search,
|
|
||||||
roomType = "public" // 搜索时只显示公有房间
|
|
||||||
)
|
|
||||||
if (!resp.isSuccessful) {
|
|
||||||
// API 调用失败,返回 null
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val body = resp.body() ?: return null
|
|
||||||
|
|
||||||
// 安全地转换数据,过滤掉转换失败的项目
|
|
||||||
val roomList = body.list.mapNotNull { room ->
|
|
||||||
try {
|
|
||||||
room.toRoomtEntity()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// 如果某个房间数据转换失败,记录错误但继续处理其他房间
|
|
||||||
Log.e("RoomRemoteDataSource", "Failed to convert room: ${room.id}", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ListContainer(
|
|
||||||
total = body.total,
|
|
||||||
page = pageNumber,
|
|
||||||
pageSize = pageSize,
|
|
||||||
list = roomList
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// 捕获所有异常,返回 null 让 PagingSource 处理
|
|
||||||
Log.e("RoomRemoteDataSource", "searchRooms error", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 房间搜索分页加载器
|
|
||||||
*/
|
|
||||||
class RoomSearchPagingSource(
|
|
||||||
private val roomRemoteDataSource: RoomRemoteDataSource,
|
|
||||||
private val keyword: String,
|
|
||||||
) : PagingSource<Int, RoomEntity>() {
|
|
||||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, RoomEntity> {
|
|
||||||
return try {
|
|
||||||
val currentPage = params.key ?: 1
|
|
||||||
val rooms = roomRemoteDataSource.searchRooms(
|
|
||||||
pageNumber = currentPage,
|
|
||||||
pageSize = params.loadSize,
|
|
||||||
search = keyword
|
|
||||||
)
|
|
||||||
if (rooms == null) {
|
|
||||||
// API 调用失败,返回空列表
|
|
||||||
LoadResult.Page(
|
|
||||||
data = emptyList(),
|
|
||||||
prevKey = if (currentPage == 1) null else currentPage - 1,
|
|
||||||
nextKey = null
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
LoadResult.Page(
|
|
||||||
data = rooms.list,
|
|
||||||
prevKey = if (currentPage == 1) null else currentPage - 1,
|
|
||||||
nextKey = if (rooms.list.isNotEmpty() && rooms.list.size >= params.loadSize) currentPage + 1 else null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (exception: Exception) {
|
|
||||||
// 捕获所有异常,包括 IOException、ServiceException 等
|
|
||||||
LoadResult.Error(exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getRefreshKey(state: PagingState<Int, RoomEntity>): Int? {
|
|
||||||
// 更健壮的实现:根据 anchorPosition 计算刷新键
|
|
||||||
return state.anchorPosition?.let { anchorPosition ->
|
|
||||||
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
|
|
||||||
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package com.aiosman.ravenow.event
|
|
||||||
|
|
||||||
data class FollowChangeEvent(
|
|
||||||
val userId: Int,
|
|
||||||
val isFollow: Boolean
|
|
||||||
)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.aiosman.ravenow.event
|
|
||||||
|
|
||||||
import com.aiosman.ravenow.entity.MomentEntity
|
|
||||||
|
|
||||||
data class MomentAddEvent(
|
|
||||||
val moment:MomentEntity
|
|
||||||
)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package com.aiosman.ravenow.event
|
|
||||||
|
|
||||||
data class MomentFavouriteChangeEvent(
|
|
||||||
val postId: Int,
|
|
||||||
val isFavourite: Boolean
|
|
||||||
)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.aiosman.ravenow.event
|
|
||||||
|
|
||||||
data class MomentLikeChangeEvent(
|
|
||||||
val postId: Int,
|
|
||||||
val likeCount: Int?,
|
|
||||||
val isLike: Boolean
|
|
||||||
)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package com.aiosman.ravenow.event
|
|
||||||
|
|
||||||
data class MomentRemoveEvent(
|
|
||||||
val postId: Int
|
|
||||||
)
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package com.aiosman.ravenow.exp
|
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
|
|
||||||
fun Bitmap.rotate(degree: Int): Bitmap {
|
|
||||||
val matrix = android.graphics.Matrix()
|
|
||||||
matrix.postRotate(degree.toFloat())
|
|
||||||
return Bitmap.createBitmap(this, 0, 0, this.width, this.height, matrix, true)
|
|
||||||
}
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
package com.aiosman.ravenow.im
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import io.openim.android.sdk.OpenIMClient
|
|
||||||
import io.openim.android.sdk.listener.*
|
|
||||||
import io.openim.android.sdk.models.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OpenIM SDK 管理器
|
|
||||||
* 负责 OpenIM SDK 的初始化和各种监听器的设置
|
|
||||||
*/
|
|
||||||
object OpenIMManager {
|
|
||||||
|
|
||||||
private const val TAG = "OpenIMManager"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化 OpenIM SDK
|
|
||||||
* @param context Android上下文
|
|
||||||
* @param initConfig SDK初始化配置
|
|
||||||
*/
|
|
||||||
fun initSDK(context: Context, initConfig: InitConfig) {
|
|
||||||
try {
|
|
||||||
OpenIMClient.getInstance().initSDK(
|
|
||||||
context.applicationContext as Application,
|
|
||||||
initConfig,
|
|
||||||
object : OnConnListener {
|
|
||||||
override fun onConnectFailed(code: Int, error: String?) {
|
|
||||||
Log.e(TAG, "连接服务器失败: code=$code, error=$error")
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onConnectSuccess() {
|
|
||||||
//连接服务器成功
|
|
||||||
Log.d(TAG, "连接服务器成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onConnecting() {
|
|
||||||
//连接服务器中...
|
|
||||||
Log.d(TAG, "连接服务器中...")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onKickedOffline() {
|
|
||||||
//当前用户被踢下线
|
|
||||||
Log.w(TAG, "当前用户被踢下线")
|
|
||||||
// 可以在这里处理用户被踢下线的逻辑,比如跳转到登录页面
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onUserTokenExpired() {
|
|
||||||
//登录票据已经过期
|
|
||||||
Log.w(TAG, "登录票据已经过期")
|
|
||||||
// 可以在这里处理token过期的逻辑,比如重新登录
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 初始化完成后设置各种监听器
|
|
||||||
initListeners()
|
|
||||||
|
|
||||||
Log.d(TAG, "OpenIM SDK 初始化成功")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "OpenIM SDK 初始化失败", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化所有监听器
|
|
||||||
*/
|
|
||||||
private fun initListeners() {
|
|
||||||
setUserListener()
|
|
||||||
setMessageListener()
|
|
||||||
setFriendshipListener()
|
|
||||||
setConversationListener()
|
|
||||||
setGroupListener()
|
|
||||||
setSignalingListener()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置用户信息监听器
|
|
||||||
*/
|
|
||||||
private fun setUserListener() {
|
|
||||||
OpenIMClient.getInstance().userInfoManager.setOnUserListener(object : OnUserListener {
|
|
||||||
override fun onSelfInfoUpdated(userInfo: UserInfo?) {
|
|
||||||
Log.d(TAG, "用户信息更新: ${userInfo?.nickname}")
|
|
||||||
// 处理用户信息更新
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置消息监听器
|
|
||||||
*/
|
|
||||||
private fun setMessageListener() {
|
|
||||||
OpenIMClient.getInstance().messageManager.setAdvancedMsgListener(object : OnAdvanceMsgListener {
|
|
||||||
override fun onRecvNewMessage(msg: Message?) {
|
|
||||||
Log.d(TAG, "收到新消息: ${msg?.toString()}")
|
|
||||||
// 处理新消息
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvC2CReadReceipt(list: List<C2CReadReceiptInfo>?) {
|
|
||||||
Log.d(TAG, "收到C2C已读回执,数量: ${list?.size}")
|
|
||||||
// 处理C2C已读回执
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvGroupMessageReadReceipt(groupMessageReceipt: GroupMessageReceipt?) {
|
|
||||||
Log.d(TAG, "收到群组消息已读回执")
|
|
||||||
// 处理群组消息已读回执
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvMessageRevokedV2(info: RevokedInfo?) {
|
|
||||||
Log.d(TAG, "消息被撤回: ${info?.clientMsgID}")
|
|
||||||
// 处理消息撤回
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvMessageExtensionsChanged(msgID: String?, list: List<KeyValue>?) {
|
|
||||||
Log.d(TAG, "消息扩展信息变更: $msgID")
|
|
||||||
// 处理消息扩展信息变更
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvMessageExtensionsDeleted(msgID: String?, list: List<String>?) {
|
|
||||||
Log.d(TAG, "消息扩展信息删除: $msgID")
|
|
||||||
// 处理消息扩展信息删除
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvMessageExtensionsAdded(msgID: String?, list: List<KeyValue>?) {
|
|
||||||
Log.d(TAG, "消息扩展信息添加: $msgID")
|
|
||||||
// 处理消息扩展信息添加
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMsgDeleted(message: Message?) {
|
|
||||||
Log.d(TAG, "消息被删除: ${message?.clientMsgID}")
|
|
||||||
// 处理消息删除
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvOfflineNewMessage(msg: List<Message>?) {
|
|
||||||
Log.d(TAG, "收到离线新消息,数量: ${msg?.size}")
|
|
||||||
// 处理离线新消息
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRecvOnlineOnlyMessage(s: String?) {
|
|
||||||
Log.d(TAG, "收到仅在线消息: $s")
|
|
||||||
// 处理仅在线消息
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置好友关系监听器
|
|
||||||
*/
|
|
||||||
private fun setFriendshipListener() {
|
|
||||||
OpenIMClient.getInstance().friendshipManager.setOnFriendshipListener(object : OnFriendshipListener {
|
|
||||||
override fun onFriendApplicationAdded(friendApplication: FriendApplicationInfo?) {
|
|
||||||
Log.d(TAG, "收到好友申请")
|
|
||||||
// 处理好友申请
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFriendApplicationDeleted(friendApplication: FriendApplicationInfo?) {
|
|
||||||
Log.d(TAG, "好友申请被删除")
|
|
||||||
// 处理好友申请删除
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFriendApplicationAccepted(friendApplication: FriendApplicationInfo?) {
|
|
||||||
Log.d(TAG, "好友申请被接受")
|
|
||||||
// 处理好友申请接受
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFriendApplicationRejected(friendApplication: FriendApplicationInfo?) {
|
|
||||||
Log.d(TAG, "好友申请被拒绝")
|
|
||||||
// 处理好友申请拒绝
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFriendAdded(friendInfo: FriendInfo?) {
|
|
||||||
Log.d(TAG, "添加好友: ${friendInfo?.nickname}")
|
|
||||||
// 处理添加好友
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFriendDeleted(friendInfo: FriendInfo?) {
|
|
||||||
Log.d(TAG, "删除好友: ${friendInfo?.nickname}")
|
|
||||||
// 处理删除好友
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFriendInfoChanged(friendInfo: FriendInfo?) {
|
|
||||||
Log.d(TAG, "好友信息变更: ${friendInfo?.nickname}")
|
|
||||||
// 处理好友信息变更
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBlacklistAdded(blackInfo: BlacklistInfo) {
|
|
||||||
Log.d(TAG, "添加黑名单")
|
|
||||||
// 处理添加黑名单
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBlacklistDeleted(blackInfo: BlacklistInfo) {
|
|
||||||
Log.d(TAG, "移除黑名单")
|
|
||||||
// 处理移除黑名单
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置会话监听器
|
|
||||||
*/
|
|
||||||
private fun setConversationListener() {
|
|
||||||
OpenIMClient.getInstance().conversationManager.setOnConversationListener(object : OnConversationListener {
|
|
||||||
override fun onConversationChanged(conversationList: MutableList<ConversationInfo>?) {
|
|
||||||
Log.d(TAG, "会话发生变化,数量: ${conversationList?.size}")
|
|
||||||
// 处理会话变化
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNewConversation(conversationList: MutableList<ConversationInfo>?) {
|
|
||||||
Log.d(TAG, "新增会话,数量: ${conversationList?.size}")
|
|
||||||
// 处理新增会话
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSyncServerFailed(reinstalled:Boolean) {
|
|
||||||
Log.e(TAG, "同步服务器失败")
|
|
||||||
// 处理同步失败
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSyncServerFinish(reinstalled:Boolean) {
|
|
||||||
Log.d(TAG, "同步服务器完成")
|
|
||||||
// 处理同步完成
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSyncServerStart(reinstalled:Boolean) {
|
|
||||||
Log.d(TAG, "开始同步服务器")
|
|
||||||
// 处理开始同步
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTotalUnreadMessageCountChanged(totalUnreadCount: Int) {
|
|
||||||
Log.d(TAG, "总未读消息数变化: $totalUnreadCount")
|
|
||||||
// 处理总未读数变化
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置群组监听器
|
|
||||||
*/
|
|
||||||
private fun setGroupListener() {
|
|
||||||
OpenIMClient.getInstance().groupManager.setOnGroupListener(object : OnGroupListener {
|
|
||||||
override fun onGroupApplicationAdded(groupApplication: GroupApplicationInfo?) {
|
|
||||||
Log.d(TAG, "收到入群申请")
|
|
||||||
// 处理入群申请
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGroupApplicationDeleted(groupApplication: GroupApplicationInfo?) {
|
|
||||||
Log.d(TAG, "入群申请被删除")
|
|
||||||
// 处理入群申请删除
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGroupApplicationAccepted(groupApplication: GroupApplicationInfo?) {
|
|
||||||
Log.d(TAG, "入群申请被接受")
|
|
||||||
// 处理入群申请接受
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGroupApplicationRejected(groupApplication: GroupApplicationInfo?) {
|
|
||||||
Log.d(TAG, "入群申请被拒绝")
|
|
||||||
// 处理入群申请拒绝
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGroupInfoChanged(groupInfo: GroupInfo?) {
|
|
||||||
Log.d(TAG, "群信息变更: ${groupInfo?.groupName}")
|
|
||||||
// 处理群信息变更
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGroupMemberAdded(groupMemberInfo: GroupMembersInfo?) {
|
|
||||||
Log.d(TAG, "群成员加入")
|
|
||||||
// 处理群成员加入
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGroupMemberDeleted(groupMemberInfo: GroupMembersInfo?) {
|
|
||||||
Log.d(TAG, "群成员退出")
|
|
||||||
// 处理群成员退出
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGroupMemberInfoChanged(groupMemberInfo: GroupMembersInfo?) {
|
|
||||||
Log.d(TAG, "群成员信息变更")
|
|
||||||
// 处理群成员信息变更
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onJoinedGroupAdded(groupInfo: GroupInfo?) {
|
|
||||||
Log.d(TAG, "加入新群: ${groupInfo?.groupName}")
|
|
||||||
// 处理加入新群
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onJoinedGroupDeleted(groupInfo: GroupInfo?) {
|
|
||||||
Log.d(TAG, "退出群聊: ${groupInfo?.groupName}")
|
|
||||||
// 处理退出群聊
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置信令监听器
|
|
||||||
*/
|
|
||||||
private fun setSignalingListener() {
|
|
||||||
// OpenIMClient.getInstance(). .setSignalingListener(object : OnSignalingListener {
|
|
||||||
// override fun onInvitationReceived(signalInvitationInfo: SignalInvitationInfo?) {
|
|
||||||
// Log.d(TAG, "收到信令邀请")
|
|
||||||
// // 处理信令邀请
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onInvitationCancelled(signalInvitationInfo: SignalInvitationInfo?) {
|
|
||||||
// Log.d(TAG, "信令邀请被取消")
|
|
||||||
// // 处理信令邀请取消
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onInvitationTimeout(signalInvitationInfo: SignalInvitationInfo?) {
|
|
||||||
// Log.d(TAG, "信令邀请超时")
|
|
||||||
// // 处理信令邀请超时
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onInviteeAccepted(signalInvitationInfo: SignalInvitationInfo?) {
|
|
||||||
// Log.d(TAG, "信令邀请被接受")
|
|
||||||
// // 处理信令邀请接受
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun onInviteeRejected(signalInvitationInfo: SignalInvitationInfo?) {
|
|
||||||
// Log.d(TAG, "信令邀请被拒绝")
|
|
||||||
// // 处理信令邀请拒绝
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取SDK数据库存储目录
|
|
||||||
* @param context Android上下文
|
|
||||||
* @return 存储目录路径
|
|
||||||
*/
|
|
||||||
fun getStorageDir(context: Context): String {
|
|
||||||
// 使用应用的内部存储目录下的im_sdk文件夹
|
|
||||||
val storageDir = context.filesDir.resolve("im_sdk")
|
|
||||||
|
|
||||||
// 如果目录不存在,创建它
|
|
||||||
if (!storageDir.exists()) {
|
|
||||||
storageDir.mkdirs()
|
|
||||||
}
|
|
||||||
|
|
||||||
return storageDir.absolutePath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package com.aiosman.ravenow.model
|
|
||||||
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
|
||||||
|
|
||||||
data class UpdateInfo(
|
|
||||||
val versionCode: Int,
|
|
||||||
val versionName: String,
|
|
||||||
val updateContent: String,
|
|
||||||
val downloadUrl: String,
|
|
||||||
val forceUpdate: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
class ApkInstallReceiver : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
Log.d("ApkInstallReceiver", "onReceive() called") // 添加日志输出
|
|
||||||
if (DownloadManager.ACTION_DOWNLOAD_COMPLETE == intent.action) {
|
|
||||||
Log.d("ApkInstallReceiver", "Download complete") // 添加日志输出
|
|
||||||
val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
|
|
||||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
|
||||||
|
|
||||||
// 方案二:通过 DownloadManager 的 API 获取 Uri
|
|
||||||
val uri = downloadManager.getUriForDownloadedFile(downloadId)
|
|
||||||
if (uri != null) {
|
|
||||||
installApk(context, uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun installApk(context: Context, uri: Uri) {
|
|
||||||
Log.d("ApkInstallReceiver", "installApk() called with: context = $context, uri = $uri") // 添加日志输出
|
|
||||||
val installIntent = Intent(Intent.ACTION_VIEW)
|
|
||||||
installIntent.setDataAndType(uri, "application/vnd.android.package-archive")
|
|
||||||
installIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
|
||||||
context.startActivity(installIntent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package com.aiosman.ravenow
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 持久化本地数据
|
|
||||||
*/
|
|
||||||
object AppStore {
|
|
||||||
private const val STORE_VERSION = 1
|
|
||||||
private const val PREFS_NAME = "app_prefs_$STORE_VERSION"
|
|
||||||
var token: String? = null
|
|
||||||
var rememberMe: Boolean = false
|
|
||||||
var isGuest: Boolean = false
|
|
||||||
private lateinit var sharedPreferences: SharedPreferences
|
|
||||||
lateinit var googleSignInOptions: GoogleSignInOptions
|
|
||||||
fun init(context: Context) {
|
|
||||||
sharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
this.loadData()
|
|
||||||
|
|
||||||
val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
|
|
||||||
.requestIdToken("754277015802-uarf8br8k8gkpbj0t9g65bvkvit630q5.apps.googleusercontent.com") // Replace with your server's client ID
|
|
||||||
.requestEmail()
|
|
||||||
.build()
|
|
||||||
googleSignInOptions = gso
|
|
||||||
// apply dark mode - 如果用户未手动设置,优先跟随系统
|
|
||||||
val hasUserPreference = sharedPreferences.contains("darkMode")
|
|
||||||
val resolvedDarkMode = if (hasUserPreference) {
|
|
||||||
sharedPreferences.getBoolean("darkMode", false)
|
|
||||||
} else {
|
|
||||||
val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
|
||||||
currentNightMode == Configuration.UI_MODE_NIGHT_YES
|
|
||||||
}
|
|
||||||
AppState.darkMode = resolvedDarkMode
|
|
||||||
AppState.appTheme = if (resolvedDarkMode) DarkThemeColors() else LightThemeColors()
|
|
||||||
|
|
||||||
// load chat background
|
|
||||||
val savedBgUrl = sharedPreferences.getString("chatBackgroundUrl", null)
|
|
||||||
if (savedBgUrl != null) {
|
|
||||||
AppState.chatBackgroundUrl = savedBgUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun saveData() {
|
|
||||||
// shared preferences
|
|
||||||
sharedPreferences.edit().apply {
|
|
||||||
putString("token", token)
|
|
||||||
putBoolean("rememberMe", rememberMe)
|
|
||||||
putBoolean("isGuest", isGuest)
|
|
||||||
}.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadData() {
|
|
||||||
// shared preferences
|
|
||||||
token = sharedPreferences.getString("token", null)
|
|
||||||
rememberMe = sharedPreferences.getBoolean("rememberMe", false)
|
|
||||||
isGuest = sharedPreferences.getBoolean("isGuest", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveDarkMode(darkMode: Boolean) {
|
|
||||||
sharedPreferences.edit().apply {
|
|
||||||
putBoolean("darkMode", darkMode)
|
|
||||||
}.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveChatBackgroundUrl(url: String?) {
|
|
||||||
sharedPreferences.edit().apply {
|
|
||||||
if (url != null) {
|
|
||||||
putString("chatBackgroundUrl", url)
|
|
||||||
} else {
|
|
||||||
remove("chatBackgroundUrl")
|
|
||||||
}
|
|
||||||
}.apply()
|
|
||||||
AppState.chatBackgroundUrl = url
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================== 用户本地扩展信息 =====================
|
|
||||||
// 后端暂未提供 MBTI 与星座字段,使用本地持久化按用户维度进行存储
|
|
||||||
private fun mbtiKey(userId: Int) = "mbti_user_$userId"
|
|
||||||
private fun zodiacKey(userId: Int) = "zodiac_user_$userId"
|
|
||||||
|
|
||||||
fun getUserMbti(userId: Int): String? {
|
|
||||||
return sharedPreferences.getString(mbtiKey(userId), null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setUserMbti(userId: Int, mbti: String?) {
|
|
||||||
sharedPreferences.edit().apply {
|
|
||||||
if (mbti.isNullOrEmpty()) remove(mbtiKey(userId)) else putString(mbtiKey(userId), mbti)
|
|
||||||
}.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getUserZodiac(userId: Int): String? {
|
|
||||||
return sharedPreferences.getString(zodiacKey(userId), null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setUserZodiac(userId: Int, zodiac: String?) {
|
|
||||||
sharedPreferences.edit().apply {
|
|
||||||
if (zodiac.isNullOrEmpty()) remove(zodiacKey(userId)) else putString(zodiacKey(userId), zodiac)
|
|
||||||
}.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,821 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui
|
|
||||||
|
|
||||||
import ChangePasswordScreen
|
|
||||||
import ImageViewer
|
|
||||||
import ModificationListScreen
|
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
|
||||||
import androidx.compose.animation.SharedTransitionLayout
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.navigation.NavHostController
|
|
||||||
import androidx.navigation.NavType
|
|
||||||
import androidx.navigation.compose.NavHost
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import androidx.navigation.navArgument
|
|
||||||
import com.aiosman.ravenow.LocalAnimatedContentScope
|
|
||||||
import com.aiosman.ravenow.LocalNavController
|
|
||||||
import com.aiosman.ravenow.LocalSharedTransitionScope
|
|
||||||
import com.aiosman.ravenow.ui.about.AboutScreen
|
|
||||||
import com.aiosman.ravenow.ui.account.AccountEditScreen2
|
|
||||||
import com.aiosman.ravenow.ui.account.AccountSetting
|
|
||||||
import com.aiosman.ravenow.ui.account.BlockedUsersScreen
|
|
||||||
import com.aiosman.ravenow.ui.account.MbtiSelectScreen
|
|
||||||
import com.aiosman.ravenow.ui.account.RemoveAccountScreen
|
|
||||||
import com.aiosman.ravenow.ui.account.ResetPasswordScreen
|
|
||||||
import com.aiosman.ravenow.ui.account.ZodiacSelectScreen
|
|
||||||
import com.aiosman.ravenow.ui.agent.AddAgentScreen
|
|
||||||
import com.aiosman.ravenow.ui.agent.AgentImageCropScreen
|
|
||||||
import com.aiosman.ravenow.ui.agent.AiPromptEditScreen
|
|
||||||
import com.aiosman.ravenow.ui.group.CreateGroupChatScreen
|
|
||||||
import com.aiosman.ravenow.ui.chat.ChatAiScreen
|
|
||||||
import com.aiosman.ravenow.ui.chat.ChatSettingScreen
|
|
||||||
import com.aiosman.ravenow.ui.chat.ChatScreen
|
|
||||||
import com.aiosman.ravenow.ui.chat.GroupChatScreen
|
|
||||||
import com.aiosman.ravenow.ui.comment.CommentsScreen
|
|
||||||
import com.aiosman.ravenow.ui.comment.notice.CommentNoticeScreen
|
|
||||||
import com.aiosman.ravenow.ui.composables.AgentCreatedSuccessIndicator
|
|
||||||
import com.aiosman.ravenow.ui.crop.ImageCropScreen
|
|
||||||
import com.aiosman.ravenow.ui.favourite.FavouriteListPage
|
|
||||||
import com.aiosman.ravenow.ui.favourite.FavouriteNoticeScreen
|
|
||||||
import com.aiosman.ravenow.ui.follower.FollowerListScreen
|
|
||||||
import com.aiosman.ravenow.ui.follower.FollowerNoticeScreen
|
|
||||||
import com.aiosman.ravenow.ui.follower.FollowingListScreen
|
|
||||||
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
|
|
||||||
import com.aiosman.ravenow.ui.like.LikeNoticeScreen
|
|
||||||
import com.aiosman.ravenow.ui.location.LocationDetailScreen
|
|
||||||
import com.aiosman.ravenow.ui.login.EmailSignupScreen
|
|
||||||
import com.aiosman.ravenow.ui.login.LoginPage
|
|
||||||
import com.aiosman.ravenow.ui.login.SignupScreen
|
|
||||||
import com.aiosman.ravenow.ui.login.UserAuthScreen
|
|
||||||
import com.aiosman.ravenow.ui.modification.EditModificationScreen
|
|
||||||
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,
|
|
||||||
) {
|
|
||||||
data object Index : NavigationRoute("Index")
|
|
||||||
data object ProfileTimeline : NavigationRoute("ProfileTimeline")
|
|
||||||
data object LocationDetail : NavigationRoute("LocationDetail/{x}/{y}")
|
|
||||||
data object OfficialPhoto : NavigationRoute("OfficialPhoto")
|
|
||||||
data object OfficialPhotographer : NavigationRoute("OfficialPhotographer")
|
|
||||||
data object Post : NavigationRoute("Post/{id}/{highlightCommentId}/{initImagePagerIndex}")
|
|
||||||
data object ModificationList : NavigationRoute("ModificationList")
|
|
||||||
data object MyMessage : NavigationRoute("MyMessage")
|
|
||||||
data object Comments : NavigationRoute("Comments")
|
|
||||||
data object Likes : NavigationRoute("Likes")
|
|
||||||
data object Followers : NavigationRoute("Followers")
|
|
||||||
data object NewPost : NavigationRoute("NewPost")
|
|
||||||
data object EditModification : NavigationRoute("EditModification")
|
|
||||||
data object Login : NavigationRoute("Login")
|
|
||||||
data object AccountProfile : NavigationRoute("AccountProfile/{id}?isAiAccount={isAiAccount}")
|
|
||||||
data object SignUp : NavigationRoute("SignUp")
|
|
||||||
data object UserAuth : NavigationRoute("UserAuth")
|
|
||||||
data object EmailSignUp : NavigationRoute("EmailSignUp")
|
|
||||||
data object AccountEdit : NavigationRoute("AccountEditScreen")
|
|
||||||
data object ImageViewer : NavigationRoute("ImageViewer")
|
|
||||||
data object ChangePasswordScreen : NavigationRoute("ChangePasswordScreen")
|
|
||||||
data object FavouritesScreen : NavigationRoute("FavouritesScreen")
|
|
||||||
data object NewPostImageGrid : NavigationRoute("NewPostImageGrid")
|
|
||||||
data object Search : NavigationRoute("Search")
|
|
||||||
data object FollowerList : NavigationRoute("FollowerList/{id}")
|
|
||||||
data object FollowingList : NavigationRoute("FollowingList/{id}")
|
|
||||||
data object ResetPassword : NavigationRoute("ResetPassword")
|
|
||||||
data object FavouriteList : NavigationRoute("FavouriteList")
|
|
||||||
data object Chat : NavigationRoute("Chat/{id}")
|
|
||||||
data object ChatAi : NavigationRoute("ChatAi/{id}")
|
|
||||||
data object ChatSetting : NavigationRoute("ChatSetting")
|
|
||||||
data object ChatGroup : NavigationRoute("ChatGroup/{id}/{name}/{avatar}")
|
|
||||||
data object CommentNoticeScreen : NavigationRoute("CommentNoticeScreen")
|
|
||||||
data object ImageCrop : NavigationRoute("ImageCrop")
|
|
||||||
data object AgentImageCrop : NavigationRoute("AgentImageCrop")
|
|
||||||
data object AccountSetting : NavigationRoute("AccountSetting")
|
|
||||||
data object AboutScreen : NavigationRoute("AboutScreen")
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun NavigationController(
|
|
||||||
navController: NavHostController,
|
|
||||||
startDestination: String = NavigationRoute.Login.route
|
|
||||||
) {
|
|
||||||
val navigationBarHeight = with(LocalDensity.current) {
|
|
||||||
WindowInsets.navigationBars.getBottom(this).toDp()
|
|
||||||
}
|
|
||||||
|
|
||||||
NavHost(
|
|
||||||
navController = navController,
|
|
||||||
startDestination = startDestination,
|
|
||||||
) {
|
|
||||||
composable(route = NavigationRoute.Index.route) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
IndexScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.ProfileTimeline.route) {
|
|
||||||
ProfileTimelineScreen()
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.LocationDetail.route,
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("x") { type = NavType.FloatType },
|
|
||||||
navArgument("y") { type = NavType.FloatType }
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.padding(bottom = navigationBarHeight)
|
|
||||||
) {
|
|
||||||
val x = it.arguments?.getFloat("x") ?: 0f
|
|
||||||
val y = it.arguments?.getFloat("y") ?: 0f
|
|
||||||
LocationDetailScreen(
|
|
||||||
x, y
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.OfficialPhoto.route) {
|
|
||||||
OfficialGalleryScreen()
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.OfficialPhotographer.route) {
|
|
||||||
OfficialPhotographerScreen()
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.Post.route,
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("id") { type = NavType.StringType },
|
|
||||||
navArgument("highlightCommentId") { type = NavType.IntType },
|
|
||||||
navArgument("initImagePagerIndex") { type = NavType.IntType }
|
|
||||||
),
|
|
||||||
enterTransition = {
|
|
||||||
// iOS push: new screen slides in from the right
|
|
||||||
slideInHorizontally(
|
|
||||||
initialOffsetX = { fullWidth -> fullWidth },
|
|
||||||
animationSpec = tween(durationMillis = 280)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
// iOS push: previous screen shifts slightly left (parallax)
|
|
||||||
slideOutHorizontally(
|
|
||||||
targetOffsetX = { fullWidth -> -fullWidth / 3 },
|
|
||||||
animationSpec = tween(durationMillis = 280)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
// iOS pop: previous screen slides back from slight left offset
|
|
||||||
slideInHorizontally(
|
|
||||||
initialOffsetX = { fullWidth -> -fullWidth / 3 },
|
|
||||||
animationSpec = tween(durationMillis = 280)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
// iOS pop: current screen slides out to the right
|
|
||||||
slideOutHorizontally(
|
|
||||||
targetOffsetX = { fullWidth -> fullWidth },
|
|
||||||
animationSpec = tween(durationMillis = 280)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { backStackEntry ->
|
|
||||||
val id = backStackEntry.arguments?.getString("id")
|
|
||||||
val highlightCommentId =
|
|
||||||
backStackEntry.arguments?.getInt("highlightCommentId")?.let {
|
|
||||||
if (it == 0) null else it
|
|
||||||
}
|
|
||||||
val initIndex = backStackEntry.arguments?.getInt("initImagePagerIndex")
|
|
||||||
PostScreen(
|
|
||||||
id!!,
|
|
||||||
highlightCommentId,
|
|
||||||
initImagePagerIndex = initIndex
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(route = NavigationRoute.ModificationList.route,
|
|
||||||
enterTransition = {
|
|
||||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
ModificationListScreen()
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.MyMessage.route,
|
|
||||||
enterTransition = {
|
|
||||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
NotificationsScreen()
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.Comments.route,
|
|
||||||
enterTransition = {
|
|
||||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
CommentsScreen()
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.Likes.route,
|
|
||||||
enterTransition = {
|
|
||||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
LikeNoticeScreen()
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.Followers.route,
|
|
||||||
enterTransition = {
|
|
||||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
FollowerNoticeScreen()
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.NewPost.route,
|
|
||||||
enterTransition = {
|
|
||||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
NewPostScreen()
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.EditModification.route) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.padding(top = 64.dp)
|
|
||||||
) {
|
|
||||||
EditModificationScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.Login.route) {
|
|
||||||
LoginPage()
|
|
||||||
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.AccountProfile.route,
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("id") { type = NavType.StringType },
|
|
||||||
navArgument("isAiAccount") {
|
|
||||||
type = NavType.BoolType
|
|
||||||
defaultValue = false
|
|
||||||
}
|
|
||||||
),
|
|
||||||
enterTransition = {
|
|
||||||
// iOS push: new screen slides in from the right
|
|
||||||
slideInHorizontally(
|
|
||||||
initialOffsetX = { fullWidth -> fullWidth },
|
|
||||||
animationSpec = tween(durationMillis = 280)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
// iOS push: previous screen shifts slightly left (parallax)
|
|
||||||
slideOutHorizontally(
|
|
||||||
targetOffsetX = { fullWidth -> -fullWidth / 3 },
|
|
||||||
animationSpec = tween(durationMillis = 280)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
// iOS pop: previous screen slides back from slight left offset
|
|
||||||
slideInHorizontally(
|
|
||||||
initialOffsetX = { fullWidth -> -fullWidth / 3 },
|
|
||||||
animationSpec = tween(durationMillis = 280)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
// iOS pop: current screen slides out to the right
|
|
||||||
slideOutHorizontally(
|
|
||||||
targetOffsetX = { fullWidth -> fullWidth },
|
|
||||||
animationSpec = tween(durationMillis = 280)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
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 = {
|
|
||||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
SignupScreen()
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.UserAuth.route,
|
|
||||||
enterTransition = {
|
|
||||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
UserAuthScreen()
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.EmailSignUp.route,
|
|
||||||
enterTransition = {
|
|
||||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
EmailSignupScreen()
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.AccountEdit.route,
|
|
||||||
enterTransition = {
|
|
||||||
// iOS风格:从底部向上滑入
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = { fullHeight -> fullHeight },
|
|
||||||
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
|
|
||||||
) + fadeIn(
|
|
||||||
animationSpec = tween(durationMillis = 300)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
// iOS风格:向底部滑出
|
|
||||||
slideOutVertically(
|
|
||||||
targetOffsetY = { fullHeight -> fullHeight },
|
|
||||||
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
|
|
||||||
) + fadeOut(
|
|
||||||
animationSpec = tween(durationMillis = 300)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
// 返回时从底部滑入
|
|
||||||
slideInVertically(
|
|
||||||
initialOffsetY = { fullHeight -> fullHeight },
|
|
||||||
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
|
|
||||||
) + fadeIn(
|
|
||||||
animationSpec = tween(durationMillis = 300)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
// 返回时向底部滑出
|
|
||||||
slideOutVertically(
|
|
||||||
targetOffsetY = { fullHeight -> fullHeight },
|
|
||||||
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
|
|
||||||
) + fadeOut(
|
|
||||||
animationSpec = tween(durationMillis = 300)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
AccountEditScreen2()
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.ImageViewer.route) {
|
|
||||||
|
|
||||||
ImageViewer()
|
|
||||||
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.ChangePasswordScreen.route) {
|
|
||||||
ChangePasswordScreen()
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.BlockedUsersScreen.route) {
|
|
||||||
BlockedUsersScreen()
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.RemoveAccountScreen.route) {
|
|
||||||
RemoveAccountScreen()
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.MbtiSelect.route) {
|
|
||||||
MbtiSelectScreen()
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.ZodiacSelect.route) {
|
|
||||||
ZodiacSelectScreen()
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.VipSelPage.route) {
|
|
||||||
VipSelPage()
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.FavouritesScreen.route) {
|
|
||||||
FavouriteNoticeScreen()
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.NewPostImageGrid.route) {
|
|
||||||
NewPostImageGridScreen()
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.Search.route) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
SearchScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.ScanQr.route) {
|
|
||||||
ScanQrScreen()
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.FollowerList.route,
|
|
||||||
arguments = listOf(navArgument("id") { type = NavType.IntType })
|
|
||||||
) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
FollowerListScreen(it.arguments?.getInt("id")!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.FollowingList.route,
|
|
||||||
arguments = listOf(navArgument("id") { type = NavType.IntType })
|
|
||||||
) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
FollowingListScreen(it.arguments?.getInt("id")!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.ResetPassword.route) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
ResetPasswordScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.FavouriteList.route) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
FavouriteListPage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.Chat.route,
|
|
||||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
|
||||||
) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
ChatScreen(it.arguments?.getString("id")!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.ChatAi.route,
|
|
||||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
|
||||||
) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
ChatAiScreen(it.arguments?.getString("id")!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(route = NavigationRoute.ChatSetting.route) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
ChatSettingScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.ChatGroup.route,
|
|
||||||
arguments = listOf(navArgument("id") { type = NavType.StringType },
|
|
||||||
navArgument("name") { type = NavType.StringType },
|
|
||||||
navArgument("avatar") { type = NavType.StringType })
|
|
||||||
) {
|
|
||||||
val encodedId = it.arguments?.getString("id")
|
|
||||||
val decodedId = encodedId?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
|
||||||
val name = it.arguments?.getString("name")
|
|
||||||
val avatar = it.arguments?.getString("avatar")
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
GroupChatScreen(decodedId?:"",name?:"",avatar?:"")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
composable(route = NavigationRoute.CommentNoticeScreen.route) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
CommentNoticeScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.ImageCrop.route) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
ImageCropScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.AgentImageCrop.route) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
AgentImageCropScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.AccountSetting.route) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
AccountSetting()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable(route = NavigationRoute.AboutScreen.route) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
AboutScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.AddAgent.route,
|
|
||||||
) {
|
|
||||||
AddAgentScreen()
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.CreateGroupChat.route,
|
|
||||||
enterTransition = {
|
|
||||||
slideInHorizontally(
|
|
||||||
initialOffsetX = { fullWidth -> fullWidth },
|
|
||||||
animationSpec = tween(durationMillis = 280)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
slideOutHorizontally(
|
|
||||||
targetOffsetX = { fullWidth -> -fullWidth / 3 },
|
|
||||||
animationSpec = tween(durationMillis = 280)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
slideInHorizontally(
|
|
||||||
initialOffsetX = { fullWidth -> -fullWidth / 3 },
|
|
||||||
animationSpec = tween(durationMillis = 280)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
slideOutHorizontally(
|
|
||||||
targetOffsetX = { fullWidth -> fullWidth },
|
|
||||||
animationSpec = tween(durationMillis = 280)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
CreateGroupChatScreen()
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.GroupInfo.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,
|
|
||||||
) {
|
|
||||||
GroupChatInfoScreen(decodedId?:"")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.GroupMembers.route,
|
|
||||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
|
||||||
) {
|
|
||||||
val encodedId = it.arguments?.getString("id")
|
|
||||||
val decodedId = encodedId?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
GroupMembersScreen(decodedId ?: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.AddGroupMember.route,
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("groupId") { type = NavType.StringType },
|
|
||||||
navArgument("groupName") { type = NavType.StringType }
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
val encodedGroupId = it.arguments?.getString("groupId")
|
|
||||||
val decodedGroupId = encodedGroupId?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
|
||||||
val encodedGroupName = it.arguments?.getString("groupName")
|
|
||||||
val decodedGroupName = encodedGroupName?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
AddGroupMemberScreen(decodedGroupId ?: "", decodedGroupName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.GroupProfileSettings.route,
|
|
||||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
|
||||||
) {
|
|
||||||
val encodedId = it.arguments?.getString("id")
|
|
||||||
val decodedId = encodedId?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
GroupProfileSettingsScreen(decodedId ?: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
composable(route = NavigationRoute.NotificationScreen.route) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
NotificationScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(
|
|
||||||
route = NavigationRoute.AiPromptEdit.route,
|
|
||||||
arguments = listOf(navArgument("chatAIId") { type = NavType.StringType })
|
|
||||||
) {
|
|
||||||
val chatAIId = it.arguments?.getString("chatAIId") ?: ""
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalAnimatedContentScope provides this,
|
|
||||||
) {
|
|
||||||
AiPromptEditScreen(chatAIId = chatAIId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
|
||||||
@Composable
|
|
||||||
fun Navigation(
|
|
||||||
startDestination: String = NavigationRoute.Login.route,
|
|
||||||
onLaunch: (navController: NavHostController) -> Unit
|
|
||||||
) {
|
|
||||||
val navController = rememberNavController()
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
onLaunch(navController)
|
|
||||||
}
|
|
||||||
SharedTransitionLayout {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalNavController provides navController,
|
|
||||||
LocalSharedTransitionScope provides this@SharedTransitionLayout,
|
|
||||||
) {
|
|
||||||
Box {
|
|
||||||
NavigationController(
|
|
||||||
navController = navController,
|
|
||||||
startDestination = startDestination
|
|
||||||
)
|
|
||||||
AgentCreatedSuccessIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun NavHostController.navigateToPost(
|
|
||||||
id: Int,
|
|
||||||
highlightCommentId: Int? = 0,
|
|
||||||
initImagePagerIndex: Int? = 0
|
|
||||||
) {
|
|
||||||
navigate(
|
|
||||||
route = NavigationRoute.Post.route
|
|
||||||
.replace("{id}", id.toString())
|
|
||||||
.replace("{highlightCommentId}", highlightCommentId.toString())
|
|
||||||
.replace("{initImagePagerIndex}", initImagePagerIndex.toString())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun NavHostController.navigateToChat(id: String) {
|
|
||||||
navigate(
|
|
||||||
route = NavigationRoute.Chat.route
|
|
||||||
.replace("{id}", id)
|
|
||||||
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun NavHostController.navigateToChatAi(id: String) {
|
|
||||||
navigate(
|
|
||||||
route = NavigationRoute.ChatAi.route
|
|
||||||
.replace("{id}", id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun NavHostController.navigateToGroupChat(id: String,name:String,avatar:String) {
|
|
||||||
val encodedId = java.net.URLEncoder.encode(id, "UTF-8")
|
|
||||||
val encodedName = java.net.URLEncoder.encode(name, "UTF-8")
|
|
||||||
val encodedAvator = java.net.URLEncoder.encode(avatar, "UTF-8")
|
|
||||||
navigate(
|
|
||||||
route = NavigationRoute.ChatGroup.route
|
|
||||||
.replace("{id}", encodedId)
|
|
||||||
.replace("{name}", encodedName)
|
|
||||||
.replace("{avatar}", encodedAvator)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun NavHostController.navigateToGroupInfo(id: String) {
|
|
||||||
val encodedId = java.net.URLEncoder.encode(id, "UTF-8")
|
|
||||||
|
|
||||||
navigate(
|
|
||||||
route = NavigationRoute.GroupInfo.route
|
|
||||||
.replace("{id}", encodedId)
|
|
||||||
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun NavHostController.goTo(
|
|
||||||
route: NavigationRoute
|
|
||||||
) {
|
|
||||||
navigate(route.route)
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.about
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
|
||||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AboutScreen() {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
val context = LocalContext.current
|
|
||||||
val versionText = context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(appColors.background),
|
|
||||||
) {
|
|
||||||
StatusBarSpacer()
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
|
||||||
) {
|
|
||||||
NoticeScreenHeader(
|
|
||||||
title = stringResource(R.string.about_paipai),
|
|
||||||
moreIcon = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
// app icondww
|
|
||||||
Box {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.mipmap.invalid_name),
|
|
||||||
contentDescription = "app icon",
|
|
||||||
modifier = Modifier.size(80.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
// app name
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.paipai),
|
|
||||||
fontSize = 24.sp,
|
|
||||||
color = appColors.text,
|
|
||||||
fontWeight = FontWeight.ExtraBold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
// app version
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.version_text, versionText ?: ""),
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = appColors.secondaryText,
|
|
||||||
fontWeight = FontWeight.Normal
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.account
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import com.aiosman.ravenow.data.AccountService
|
|
||||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
|
||||||
import com.aiosman.ravenow.data.UploadImage
|
|
||||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
|
||||||
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
|
|
||||||
import com.aiosman.ravenow.utils.TrtcHelper
|
|
||||||
import com.aiosman.ravenow.AppStore
|
|
||||||
import android.util.Log
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
object AccountEditViewModel : ViewModel() {
|
|
||||||
var name by mutableStateOf("")
|
|
||||||
var bio by mutableStateOf("")
|
|
||||||
var imageUrl by mutableStateOf<Uri?>(null)
|
|
||||||
var bannerImageUrl by mutableStateOf<Uri?>(null)
|
|
||||||
var bannerFile by mutableStateOf<File?>(null)
|
|
||||||
val accountService: AccountService = AccountServiceImpl()
|
|
||||||
var profile by mutableStateOf<AccountProfileEntity?>(null)
|
|
||||||
var croppedBitmap by mutableStateOf<Bitmap?>(null)
|
|
||||||
var isUpdating by mutableStateOf(false)
|
|
||||||
var isLoading by mutableStateOf(false)
|
|
||||||
// 本地扩展字段
|
|
||||||
var mbti by mutableStateOf<String?>(null)
|
|
||||||
var zodiac by mutableStateOf<String?>(null)
|
|
||||||
// 保存原始值,用于取消时恢复
|
|
||||||
private var originalName: String = ""
|
|
||||||
private var originalBio: String = ""
|
|
||||||
private var originalMbti: String? = null
|
|
||||||
private var originalZodiac: String? = null
|
|
||||||
suspend fun reloadProfile(updateTrtcProfile:Boolean = false, clearCroppedBitmap: Boolean = false) {
|
|
||||||
Log.d("AccountEditViewModel", "reloadProfile: 开始加载用户资料")
|
|
||||||
isLoading = true
|
|
||||||
try {
|
|
||||||
Log.d("AccountEditViewModel", "reloadProfile: 调用API获取用户资料")
|
|
||||||
accountService.getMyAccountProfile().let {
|
|
||||||
Log.d("AccountEditViewModel", "reloadProfile: 成功获取用户资料 - nickName: ${it.nickName}")
|
|
||||||
profile = it
|
|
||||||
name = it.nickName
|
|
||||||
bio = it.bio
|
|
||||||
// 保存原始值,用于取消时恢复
|
|
||||||
originalName = it.nickName
|
|
||||||
originalBio = it.bio
|
|
||||||
// 只在明确要求时清除之前裁剪的图片(例如保存成功后)
|
|
||||||
if (clearCroppedBitmap) {
|
|
||||||
croppedBitmap = null
|
|
||||||
}
|
|
||||||
// 读取本地扩展字段
|
|
||||||
try {
|
|
||||||
val uid = it.id // 使用 profile 的 id,确保非空
|
|
||||||
val loadedMbti = com.aiosman.ravenow.AppStore.getUserMbti(uid)
|
|
||||||
val loadedZodiac = com.aiosman.ravenow.AppStore.getUserZodiac(uid)
|
|
||||||
mbti = loadedMbti
|
|
||||||
zodiac = loadedZodiac
|
|
||||||
// 保存原始值
|
|
||||||
originalMbti = loadedMbti
|
|
||||||
originalZodiac = loadedZodiac
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
if (updateTrtcProfile) {
|
|
||||||
TrtcHelper.updateTrtcProfile(
|
|
||||||
it.nickName,
|
|
||||||
it.rawAvatar
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// 处理异常,避免UI消失
|
|
||||||
Log.e("AccountEditViewModel", "reloadProfile: 加载用户资料失败", e)
|
|
||||||
e.printStackTrace()
|
|
||||||
// 如果是首次加载失败,至少保持之前的profile不变
|
|
||||||
// 这样UI不会突然消失
|
|
||||||
} finally {
|
|
||||||
Log.d("AccountEditViewModel", "reloadProfile: 加载完成,isLoading设为false")
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetToOriginalData() {
|
|
||||||
// 恢复所有字段到原始值
|
|
||||||
name = originalName
|
|
||||||
bio = originalBio
|
|
||||||
mbti = originalMbti
|
|
||||||
zodiac = originalZodiac
|
|
||||||
// 清除之前裁剪的图片和壁纸
|
|
||||||
croppedBitmap = null
|
|
||||||
bannerImageUrl = null
|
|
||||||
bannerFile = null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
suspend fun updateUserProfile(context: Context) {
|
|
||||||
val newAvatar = croppedBitmap?.let {
|
|
||||||
val file = File(context.cacheDir, "avatar.jpg")
|
|
||||||
it.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream())
|
|
||||||
UploadImage(file, "avatar.jpg", "", "jpg")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理背景图更新
|
|
||||||
val newBanner = bannerImageUrl?.let { uri ->
|
|
||||||
bannerFile?.let { file ->
|
|
||||||
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
|
||||||
var uploadBanner: UploadImage? = null
|
|
||||||
cursor?.use { cur ->
|
|
||||||
val columnIndex = cur.getColumnIndex("_display_name")
|
|
||||||
if (cur.moveToFirst() && columnIndex != -1) {
|
|
||||||
val displayName = cur.getString(columnIndex)
|
|
||||||
val extension = displayName.substringAfterLast(".")
|
|
||||||
Log.d("AccountEditViewModel", "Banner file name: $displayName, extension: $extension")
|
|
||||||
uploadBanner = UploadImage(file, displayName, uri.toString(), extension)
|
|
||||||
} else {
|
|
||||||
// 如果无法获取文件名,使用默认值
|
|
||||||
val displayName = "banner.jpg"
|
|
||||||
val extension = "jpg"
|
|
||||||
uploadBanner = UploadImage(file, displayName, uri.toString(), extension)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
uploadBanner
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 去除换行符,确保昵称和个人简介不包含换行
|
|
||||||
val cleanName = name.trim().replace("\n", "").replace("\r", "")
|
|
||||||
val cleanBio = bio.trim().replace("\n", "").replace("\r", "")
|
|
||||||
|
|
||||||
val newName = if (cleanName == profile?.nickName) null else cleanName
|
|
||||||
accountService.updateProfile(
|
|
||||||
avatar = newAvatar,
|
|
||||||
banner = newBanner,
|
|
||||||
nickName = newName,
|
|
||||||
bio = cleanBio
|
|
||||||
)
|
|
||||||
// 保存本地扩展字段
|
|
||||||
try {
|
|
||||||
profile?.id?.let { uid ->
|
|
||||||
com.aiosman.ravenow.AppStore.setUserMbti(uid, mbti)
|
|
||||||
com.aiosman.ravenow.AppStore.setUserZodiac(uid, zodiac)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
// 清除背景图状态
|
|
||||||
bannerImageUrl = null
|
|
||||||
bannerFile = null
|
|
||||||
// 刷新用户资料,保存成功后清除裁剪的图片
|
|
||||||
reloadProfile(clearCroppedBitmap = true)
|
|
||||||
// 刷新个人资料页面的用户资料
|
|
||||||
MyProfileViewModel.loadUserProfile()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置ViewModel状态
|
|
||||||
* 用于用户登出或切换账号时清理数据
|
|
||||||
*/
|
|
||||||
fun ResetModel() {
|
|
||||||
Log.d("AccountEditViewModel", "ResetModel: 重置ViewModel状态")
|
|
||||||
profile = null
|
|
||||||
name = ""
|
|
||||||
bio = ""
|
|
||||||
imageUrl = null
|
|
||||||
bannerImageUrl = null
|
|
||||||
bannerFile = null
|
|
||||||
croppedBitmap = null
|
|
||||||
isUpdating = false
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
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.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.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.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.NavigationRoute
|
|
||||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
|
||||||
|
|
||||||
private object AccountSettingConstants {
|
|
||||||
const val BACK_BUTTON_SIZE = 36
|
|
||||||
const val BACK_BUTTON_ICON_SIZE = 24
|
|
||||||
const val BACK_BUTTON_START_PADDING = 19
|
|
||||||
const val OPTION_ITEM_HEIGHT = 56
|
|
||||||
const val OPTION_ITEM_ICON_SIZE = 24
|
|
||||||
const val OPTION_ITEM_HORIZONTAL_PADDING = 16
|
|
||||||
const val OPTION_ITEM_ICON_TEXT_SPACING = 12
|
|
||||||
const val OPTION_ITEM_TEXT_SIZE = 17
|
|
||||||
const val HEADER_VERTICAL_PADDING = 16
|
|
||||||
const val TITLE_OFFSET_X = 19
|
|
||||||
const val CARD_CORNER_RADIUS = 16
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CircularBackButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.size(AccountSettingConstants.BACK_BUTTON_SIZE.dp)
|
|
||||||
.background(
|
|
||||||
color = appColors.secondaryBackground,
|
|
||||||
shape = CircleShape
|
|
||||||
)
|
|
||||||
.noRippleClickable { onClick() },
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.drawable.rider_pro_back_icon),
|
|
||||||
contentDescription = "返回",
|
|
||||||
modifier = Modifier.size(AccountSettingConstants.BACK_BUTTON_ICON_SIZE.dp),
|
|
||||||
colorFilter = ColorFilter.tint(appColors.text)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SecurityOptionItem(
|
|
||||||
iconRes: Int,
|
|
||||||
label: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
applyColorFilter: Boolean = true
|
|
||||||
) {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(AccountSettingConstants.OPTION_ITEM_HEIGHT.dp)
|
|
||||||
.padding(horizontal = AccountSettingConstants.OPTION_ITEM_HORIZONTAL_PADDING.dp)
|
|
||||||
.noRippleClickable { onClick() },
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = iconRes),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(AccountSettingConstants.OPTION_ITEM_ICON_SIZE.dp),
|
|
||||||
colorFilter = if (applyColorFilter) ColorFilter.tint(appColors.text) else null
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(start = AccountSettingConstants.OPTION_ITEM_ICON_TEXT_SPACING.dp)
|
|
||||||
.weight(1f),
|
|
||||||
color = appColors.text,
|
|
||||||
fontSize = AccountSettingConstants.OPTION_ITEM_TEXT_SIZE.sp,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.drawable.rave_now_nav_right),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(AccountSettingConstants.OPTION_ITEM_ICON_SIZE.dp),
|
|
||||||
colorFilter = ColorFilter.tint(appColors.secondaryText)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun AccountSetting() {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(appColors.background),
|
|
||||||
) {
|
|
||||||
StatusBarSpacer()
|
|
||||||
|
|
||||||
// 顶部标题栏
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = AccountSettingConstants.HEADER_VERTICAL_PADDING.dp)
|
|
||||||
) {
|
|
||||||
CircularBackButton(
|
|
||||||
onClick = { navController.navigateUp() },
|
|
||||||
modifier = Modifier.padding(start = AccountSettingConstants.BACK_BUTTON_START_PADDING.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.account_and_security),
|
|
||||||
fontWeight = FontWeight.W800,
|
|
||||||
fontSize = AccountSettingConstants.OPTION_ITEM_TEXT_SIZE.sp,
|
|
||||||
color = appColors.text,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.Center)
|
|
||||||
.offset(x = AccountSettingConstants.TITLE_OFFSET_X.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 安全选项卡片
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
color = appColors.background,
|
|
||||||
shape = RoundedCornerShape(AccountSettingConstants.CARD_CORNER_RADIUS.dp)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
SecurityOptionItem(
|
|
||||||
iconRes = R.mipmap.icons_padlock,
|
|
||||||
label = stringResource(R.string.change_password),
|
|
||||||
onClick = { navController.navigate(NavigationRoute.ChangePasswordScreen.route) }
|
|
||||||
)
|
|
||||||
|
|
||||||
SecurityOptionItem(
|
|
||||||
iconRes = R.mipmap.icons_block,
|
|
||||||
label = stringResource(R.string.blocked_users),
|
|
||||||
onClick = { navController.navigate(NavigationRoute.BlockedUsersScreen.route) }
|
|
||||||
)
|
|
||||||
|
|
||||||
SecurityOptionItem(
|
|
||||||
iconRes = R.mipmap.icons_remove,
|
|
||||||
label = stringResource(R.string.remove_account),
|
|
||||||
onClick = { navController.navigate(NavigationRoute.RemoveAccountScreen.route) },
|
|
||||||
applyColorFilter = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.account
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MbtiBottomSheetHost() {
|
|
||||||
val show = MbtiSheetManager.visible.collectAsState(false).value
|
|
||||||
if (show) {
|
|
||||||
MbtiSelectBottomSheet(
|
|
||||||
onClose = { MbtiSheetManager.close() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.account
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.systemBars
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.unit.Velocity
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.aiosman.ravenow.AppState
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.LocalNavController
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
|
||||||
|
|
||||||
// MBTI类型列表
|
|
||||||
val MBTI_TYPES = listOf(
|
|
||||||
"INTJ", "INTP", "ENTJ", "ENTP",
|
|
||||||
"INFJ", "INFP", "ENFJ", "ENFP",
|
|
||||||
"ISTJ", "ISFJ", "ESTJ", "ESFJ",
|
|
||||||
"ISTP", "ISFP", "ESTP", "ESFP"
|
|
||||||
)
|
|
||||||
|
|
||||||
fun getMbtiImageResId(mbti: String, isDarkMode: Boolean): Int {
|
|
||||||
return when {
|
|
||||||
isDarkMode && mbti == "ENTP" -> R.mipmap.anmbti_entp
|
|
||||||
isDarkMode && mbti == "ESTP" -> R.mipmap.anmbti_estp
|
|
||||||
isDarkMode && mbti == "ENTJ" -> R.mipmap.anmbti_entj
|
|
||||||
else -> when (mbti) {
|
|
||||||
"INTJ" -> R.mipmap.mbti_intj
|
|
||||||
"INTP" -> R.mipmap.mbti_intp
|
|
||||||
"ENTJ" -> R.mipmap.mbti_entj
|
|
||||||
"ENTP" -> R.mipmap.mbti_entp
|
|
||||||
"INFJ" -> R.mipmap.mbti_infj
|
|
||||||
"INFP" -> R.mipmap.mbti_infp
|
|
||||||
"ENFJ" -> R.mipmap.mbti_enfj
|
|
||||||
"ENFP" -> R.mipmap.mbti_enfp
|
|
||||||
"ISTJ" -> R.mipmap.mbti_istj
|
|
||||||
"ISFJ" -> R.mipmap.mbti_isfj
|
|
||||||
"ESTJ" -> R.mipmap.mbti_estj
|
|
||||||
"ESFJ" -> R.mipmap.mbti_esfj
|
|
||||||
"ISTP" -> R.mipmap.mbti_istp
|
|
||||||
"ISFP" -> R.mipmap.mbti_isfp
|
|
||||||
"ESTP" -> R.mipmap.mbti_estp
|
|
||||||
"ESFP" -> R.mipmap.mbti_esfp
|
|
||||||
else -> R.mipmap.xingzuo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun MbtiSelectBottomSheet(
|
|
||||||
onClose: () -> Unit
|
|
||||||
) {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
val isDarkMode = AppState.darkMode
|
|
||||||
val model = AccountEditViewModel
|
|
||||||
val currentMbti = model.mbti
|
|
||||||
val sheetBackgroundColor = if (isDarkMode) {
|
|
||||||
appColors.secondaryBackground
|
|
||||||
} else {
|
|
||||||
Color(0xFFFFFFFF)
|
|
||||||
}
|
|
||||||
|
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = onClose,
|
|
||||||
sheetState = sheetState,
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
|
||||||
dragHandle = {}
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight()
|
|
||||||
.padding(top = 8.dp)
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight()
|
|
||||||
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)),
|
|
||||||
color = sheetBackgroundColor,
|
|
||||||
tonalElevation = 0.dp,
|
|
||||||
shadowElevation = 0.dp,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
// 头部
|
|
||||||
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)
|
|
||||||
|
|
||||||
// 左上角返回按钮:整体「箭头 + 取消」在按钮内居中
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.CenterStart)
|
|
||||||
.width(91.dp)
|
|
||||||
.height(44.dp)
|
|
||||||
.clip(RoundedCornerShape(1000.dp))
|
|
||||||
.background(
|
|
||||||
brush = Brush.linearGradient(
|
|
||||||
colors = cancelButtonGradientColors
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.noRippleClickable { onClose() },
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.drawable.rider_pro_back_icon),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(17.dp),
|
|
||||||
colorFilter = ColorFilter.tint(cancelButtonContentColor)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "取消",
|
|
||||||
fontSize = 17.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = cancelButtonContentColor,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Clip
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 中间标题
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.choose_mbti),
|
|
||||||
color = appColors.text,
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
|
|
||||||
// NestedScroll,阻止滚动事件向上传到 BottomSheet
|
|
||||||
val nestedScrollConnection = remember {
|
|
||||||
object : NestedScrollConnection {
|
|
||||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
|
||||||
return Offset.Zero
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPostScroll(
|
|
||||||
consumed: Offset,
|
|
||||||
available: Offset,
|
|
||||||
source: NestedScrollSource
|
|
||||||
): Offset {
|
|
||||||
return available
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
|
||||||
return Velocity.Zero
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
|
||||||
return available
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val descriptionBackgroundColor = if (isDarkMode) {
|
|
||||||
Color(0xFF2A2A2A)
|
|
||||||
} else {
|
|
||||||
Color(0xFFFAF9FB)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 列表:上面是说明文字,下面是 MBTI 网格
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f)
|
|
||||||
.nestedScroll(nestedScrollConnection),
|
|
||||||
contentPadding = PaddingValues(
|
|
||||||
start = 8.dp,
|
|
||||||
top = 0.dp,
|
|
||||||
end = 8.dp,
|
|
||||||
bottom = 8.dp
|
|
||||||
),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
// 说明文字
|
|
||||||
item {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(descriptionBackgroundColor)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.mbti_description),
|
|
||||||
color = appColors.text,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
lineHeight = 20.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MBTI 类型网格(2 列)
|
|
||||||
itemsIndexed(MBTI_TYPES.chunked(2)) { rowIndex, rowItems ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(
|
|
||||||
bottom = if (rowIndex < MBTI_TYPES.chunked(2).size - 1) 10.dp else 0.dp
|
|
||||||
),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
|
||||||
) {
|
|
||||||
rowItems.forEach { mbti ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
MbtiItem(
|
|
||||||
mbti = mbti,
|
|
||||||
isSelected = mbti == currentMbti,
|
|
||||||
onClick = {
|
|
||||||
model.mbti = mbti
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rowItems.size == 1) {
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保留原有的 MbtiSelectScreen 用于导航路由(如果需要)
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun MbtiSelectScreen() {
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
MbtiSelectBottomSheet(
|
|
||||||
onClose = {
|
|
||||||
navController.navigateUp()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MbtiItem(
|
|
||||||
mbti: String,
|
|
||||||
isSelected: Boolean,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
val isDarkMode = AppState.darkMode
|
|
||||||
|
|
||||||
// 卡片背景色
|
|
||||||
val cardBackgroundColor = if (isDarkMode) {
|
|
||||||
Color(0xFF2A2A2A) // 比 secondaryBackground (0xFF1C1C1C) 更亮的灰色
|
|
||||||
} else {
|
|
||||||
Color(0xFFFAF9FB)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.aspectRatio(1.1f)
|
|
||||||
.shadow(
|
|
||||||
elevation = if (isDarkMode) 8.dp else 2.dp,
|
|
||||||
shape = RoundedCornerShape(21.dp),
|
|
||||||
spotColor = if (isDarkMode) Color.Black.copy(alpha = 0.5f) else Color.Black.copy(alpha = 0.1f)
|
|
||||||
)
|
|
||||||
.clip(RoundedCornerShape(21.dp))
|
|
||||||
.background(cardBackgroundColor)
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
onClick()
|
|
||||||
}
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
// 直接把 MBTI 图标和文字放在灰色卡片内部,布局与星座保持一致
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = getMbtiImageResId(mbti, isDarkMode)),
|
|
||||||
contentDescription = mbti,
|
|
||||||
modifier = Modifier.size(96.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = mbti,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = appColors.text,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.offset(y = (-10).dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.account
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
|
|
||||||
object MbtiSheetManager {
|
|
||||||
private val _visible = MutableStateFlow(false)
|
|
||||||
val visible: StateFlow<Boolean> = _visible.asStateFlow()
|
|
||||||
|
|
||||||
fun open() {
|
|
||||||
_visible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun close() {
|
|
||||||
_visible.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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,376 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.account
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.systemBars
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
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.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.unit.Velocity
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.aiosman.ravenow.AppState
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
|
||||||
|
|
||||||
// 星座资源ID列表
|
|
||||||
val ZODIAC_SIGN_RES_IDS = listOf(
|
|
||||||
R.string.zodiac_aries,
|
|
||||||
R.string.zodiac_taurus,
|
|
||||||
R.string.zodiac_gemini,
|
|
||||||
R.string.zodiac_cancer,
|
|
||||||
R.string.zodiac_leo,
|
|
||||||
R.string.zodiac_virgo,
|
|
||||||
R.string.zodiac_libra,
|
|
||||||
R.string.zodiac_scorpio,
|
|
||||||
R.string.zodiac_sagittarius,
|
|
||||||
R.string.zodiac_capricorn,
|
|
||||||
R.string.zodiac_aquarius,
|
|
||||||
R.string.zodiac_pisces
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据星座资源ID获取对应的图片资源ID
|
|
||||||
*/
|
|
||||||
fun getZodiacImageResId(zodiacResId: Int): Int {
|
|
||||||
return when (zodiacResId) {
|
|
||||||
R.string.zodiac_aries -> R.mipmap.baiyang
|
|
||||||
R.string.zodiac_taurus -> R.mipmap.jingniu
|
|
||||||
R.string.zodiac_gemini -> R.mipmap.shuangzi
|
|
||||||
R.string.zodiac_cancer -> R.mipmap.juxie
|
|
||||||
R.string.zodiac_leo -> R.mipmap.shizi
|
|
||||||
R.string.zodiac_virgo -> R.mipmap.chunv
|
|
||||||
R.string.zodiac_libra -> R.mipmap.tiancheng
|
|
||||||
R.string.zodiac_scorpio -> R.mipmap.tianxie
|
|
||||||
R.string.zodiac_sagittarius -> R.mipmap.sheshou
|
|
||||||
R.string.zodiac_capricorn -> R.mipmap.moxie
|
|
||||||
R.string.zodiac_aquarius -> R.mipmap.shuiping
|
|
||||||
R.string.zodiac_pisces -> R.mipmap.shuangyu
|
|
||||||
else -> R.mipmap.xingzuo // 默认使用占位图片
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据存储的星座字符串(可能是任何语言)找到对应的资源ID
|
|
||||||
* 如果找不到,返回null
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun findZodiacResId(storedZodiac: String?): Int? {
|
|
||||||
if (storedZodiac.isNullOrEmpty()) return null
|
|
||||||
|
|
||||||
// 尝试在所有语言的资源中查找匹配
|
|
||||||
ZODIAC_SIGN_RES_IDS.forEachIndexed { index, resId ->
|
|
||||||
val zodiacText = stringResource(resId)
|
|
||||||
if (zodiacText == storedZodiac) {
|
|
||||||
return resId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果找不到精确匹配,尝试通过资源ID索引查找(兼容旧数据)
|
|
||||||
// 这里可以根据需要添加更多兼容逻辑
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun ZodiacSelectBottomSheet(
|
|
||||||
onClose: () -> Unit
|
|
||||||
) {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
val isDarkMode = AppState.darkMode
|
|
||||||
val model = AccountEditViewModel
|
|
||||||
val currentZodiacResId = findZodiacResId(model.zodiac)
|
|
||||||
val sheetBackgroundColor = if (isDarkMode) {
|
|
||||||
appColors.secondaryBackground
|
|
||||||
} else {
|
|
||||||
Color(0xFFFFFFFF)
|
|
||||||
}
|
|
||||||
|
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = onClose,
|
|
||||||
sheetState = sheetState,
|
|
||||||
// 对齐发布动态草稿箱样式:底层透明,内容区域自己绘制圆角和背景
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
|
||||||
dragHandle = {}
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight()
|
|
||||||
.padding(top = 8.dp)
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight()
|
|
||||||
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)),
|
|
||||||
color = sheetBackgroundColor,
|
|
||||||
tonalElevation = 0.dp,
|
|
||||||
shadowElevation = 0.dp,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
// 头部 - 使用 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)
|
|
||||||
|
|
||||||
// 左上角返回按钮:参考 iOS 设计,整体「箭头 + 取消」在 91x44 的按钮内居中
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.CenterStart)
|
|
||||||
.width(91.dp)
|
|
||||||
.height(44.dp)
|
|
||||||
.clip(RoundedCornerShape(1000.dp))
|
|
||||||
.background(
|
|
||||||
brush = Brush.linearGradient(
|
|
||||||
colors = cancelButtonGradientColors
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.noRippleClickable { onClose() },
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
// 不再固定宽度,让内容自然占位,避免裁剪掉“消”字
|
|
||||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.drawable.rider_pro_back_icon),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(17.dp),
|
|
||||||
colorFilter = ColorFilter.tint(cancelButtonContentColor)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "取消",
|
|
||||||
fontSize = 17.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = cancelButtonContentColor,
|
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Clip
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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:
|
|
||||||
// 1. 不抢在列表前面消费事件,让 LazyVerticalGrid 正常滚动
|
|
||||||
// 2. 在列表滚动之后把剩余滚动吃掉,避免继续传递到 BottomSheet 去触发下拉关闭
|
|
||||||
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 {
|
|
||||||
// 列表滚动完之后,把剩余滚动(尤其是向下拖拽)全部吃掉,防止再传给 BottomSheet
|
|
||||||
return available
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
|
||||||
// 不抢在列表前面处理 fling,让 LazyVerticalGrid 先做惯性滚动
|
|
||||||
return Velocity.Zero
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
|
||||||
// 列表惯性滚动之后,把剩余的 fling 速度吃掉,避免带动 BottomSheet 下滑关闭
|
|
||||||
return available
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 网格列表 - 2列(与草稿箱一样放在内容区域内部滚动)
|
|
||||||
LazyVerticalGrid(
|
|
||||||
columns = GridCells.Fixed(2),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f)
|
|
||||||
.nestedScroll(nestedScrollConnection),
|
|
||||||
contentPadding = PaddingValues(
|
|
||||||
start = 8.dp,
|
|
||||||
top = 8.dp,
|
|
||||||
end = 8.dp,
|
|
||||||
bottom = 8.dp
|
|
||||||
),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
|
||||||
) {
|
|
||||||
itemsIndexed(ZODIAC_SIGN_RES_IDS) { index, zodiacResId ->
|
|
||||||
val zodiacText = stringResource(zodiacResId)
|
|
||||||
ZodiacItem(
|
|
||||||
zodiac = zodiacText,
|
|
||||||
zodiacResId = zodiacResId,
|
|
||||||
isSelected = zodiacResId == currentZodiacResId,
|
|
||||||
onClick = {
|
|
||||||
model.zodiac = zodiacText
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保留原有的 ZodiacSelectScreen 用于导航路由(如果需要)
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun ZodiacSelectScreen() {
|
|
||||||
val navController = com.aiosman.ravenow.LocalNavController.current
|
|
||||||
ZodiacSelectBottomSheet(
|
|
||||||
onClose = {
|
|
||||||
navController.navigateUp()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ZodiacItem(
|
|
||||||
zodiac: String,
|
|
||||||
zodiacResId: Int,
|
|
||||||
isSelected: Boolean,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
val isDarkMode = AppState.darkMode
|
|
||||||
|
|
||||||
// 卡片背景色:浅灰色 (250, 249, 251)
|
|
||||||
// 暗色模式下使用比背景色更亮的颜色,以形成对比
|
|
||||||
val cardBackgroundColor = if (isDarkMode) {
|
|
||||||
Color(0xFF2A2A2A) // 比 secondaryBackground (0xFF1C1C1C) 更亮的灰色
|
|
||||||
} else {
|
|
||||||
Color(0xFFFAF9FB)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.aspectRatio(1.1f)
|
|
||||||
.shadow(
|
|
||||||
elevation = if (isDarkMode) 8.dp else 2.dp,
|
|
||||||
shape = RoundedCornerShape(21.dp),
|
|
||||||
spotColor = if (isDarkMode) Color.Black.copy(alpha = 0.5f) else Color.Black.copy(alpha = 0.1f)
|
|
||||||
)
|
|
||||||
.clip(RoundedCornerShape(21.dp))
|
|
||||||
.background(cardBackgroundColor)
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
onClick()
|
|
||||||
}
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
// 直接把图标和文字放在灰色卡片内部,不再额外嵌套一层 Box
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = getZodiacImageResId(zodiacResId)),
|
|
||||||
contentDescription = zodiac,
|
|
||||||
// 图标稍微放大一些,让视觉更聚焦在星座图标上
|
|
||||||
modifier = Modifier.size(96.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(0.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = zodiac,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = appColors.text,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.offset(y = (-10).dp) // 再整体向上偏移 5dp(共 10dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,736 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.account
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material.icons.filled.ArrowForward
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.filled.Edit as EditIcon
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
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.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.aiosman.ravenow.AppState
|
|
||||||
import com.aiosman.ravenow.AppStore
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.LocalNavController
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.ui.NavigationRoute
|
|
||||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
|
||||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
|
||||||
import com.aiosman.ravenow.ui.composables.debouncedClickable
|
|
||||||
import com.aiosman.ravenow.ui.composables.rememberDebouncedNavigation
|
|
||||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.systemBars
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
|
||||||
import com.aiosman.ravenow.ConstVars
|
|
||||||
import com.aiosman.ravenow.ui.composables.pickupAndCompressLauncher
|
|
||||||
import android.widget.Toast
|
|
||||||
import java.io.File
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import com.aiosman.ravenow.ui.account.ZodiacBottomSheetHost
|
|
||||||
import com.aiosman.ravenow.ui.account.ZodiacSheetManager
|
|
||||||
import com.aiosman.ravenow.ui.account.MbtiBottomSheetHost
|
|
||||||
import com.aiosman.ravenow.ui.account.MbtiSheetManager
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 编辑用户资料界面
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,) {
|
|
||||||
val model = AccountEditViewModel
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val context = LocalContext.current
|
|
||||||
var usernameError by remember { mutableStateOf<String?>(null) }
|
|
||||||
var bioError by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
// 防抖导航器
|
|
||||||
val debouncedNavigation = rememberDebouncedNavigation()
|
|
||||||
|
|
||||||
// 添加图片选择启动器
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val pickBannerImageLauncher = pickupAndCompressLauncher(
|
|
||||||
context,
|
|
||||||
scope,
|
|
||||||
maxSize = ConstVars.BANNER_IMAGE_MAX_SIZE,
|
|
||||||
quality = 100
|
|
||||||
) { uri, file ->
|
|
||||||
// 处理选中的图片
|
|
||||||
// 保存到 ViewModel 中,等待保存时一起上传
|
|
||||||
model.bannerImageUrl = uri
|
|
||||||
model.bannerFile = file
|
|
||||||
// 如果提供了回调,也调用它(用于个人主页直接更新)
|
|
||||||
onUpdateBanner?.invoke(uri, file, context)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onNicknameChange(value: String) {
|
|
||||||
// 去除换行符,确保昵称不包含换行
|
|
||||||
val cleanValue = value.replace("\n", "").replace("\r", "")
|
|
||||||
model.name = cleanValue
|
|
||||||
// 实时验证,但不显示错误(只在保存时显示)
|
|
||||||
usernameError = when {
|
|
||||||
cleanValue.trim().isEmpty() -> context.getString(R.string.error_nickname_empty)
|
|
||||||
cleanValue.length < 3 -> context.getString(R.string.error_nickname_too_short)
|
|
||||||
cleanValue.length > 20 -> context.getString(R.string.error_nickname_too_long)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun validateNickname(): String? {
|
|
||||||
val cleanValue = model.name.replace("\n", "").replace("\r", "")
|
|
||||||
return when {
|
|
||||||
cleanValue.trim().isEmpty() -> context.getString(R.string.error_nickname_empty)
|
|
||||||
cleanValue.length < 3 -> context.getString(R.string.error_nickname_too_short)
|
|
||||||
cleanValue.length > 20 -> context.getString(R.string.error_nickname_too_long)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
|
|
||||||
fun onBioChange(value: String) {
|
|
||||||
// 去除换行符,确保个人简介不包含换行
|
|
||||||
val cleanValue = value.replace("\n", "").replace("\r", "")
|
|
||||||
model.bio = cleanValue
|
|
||||||
// 实时验证,但不显示错误(只在保存时显示)
|
|
||||||
bioError = when {
|
|
||||||
cleanValue.length > 100 -> context.getString(R.string.error_bio_too_long)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun validateBio(): String? {
|
|
||||||
val cleanValue = model.bio.replace("\n", "").replace("\r", "")
|
|
||||||
return when {
|
|
||||||
cleanValue.length > 100 -> context.getString(R.string.error_bio_too_long)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun validate(): Boolean {
|
|
||||||
return usernameError == null && bioError == null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否为游客模式
|
|
||||||
if (AppStore.isGuest) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
// 游客模式不允许编辑资料,返回上一页(防抖)
|
|
||||||
debouncedNavigation {
|
|
||||||
navController.navigateUp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 游客模式时不渲染任何内容
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
// 每次进入编辑页面时都重新加载当前用户的资料
|
|
||||||
// 确保显示的是当前登录用户的信息,而不是之前用户的缓存数据
|
|
||||||
model.reloadProfile()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理系统返回键
|
|
||||||
BackHandler {
|
|
||||||
// 用户未保存直接返回,恢复所有字段到原始值
|
|
||||||
model.resetToOriginalData()
|
|
||||||
navController.navigateUp()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置状态栏为透明,根据暗色模式决定图标颜色
|
|
||||||
val systemUiController = rememberSystemUiController()
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusBarMaskLayout(
|
|
||||||
modifier = Modifier.background(appColors.background),
|
|
||||||
darkIcons = !AppState.darkMode, // 根据暗色模式决定图标颜色
|
|
||||||
maskBoxBackgroundColor = Color.Transparent
|
|
||||||
) {
|
|
||||||
// 挂载星座选择弹窗
|
|
||||||
ZodiacBottomSheetHost()
|
|
||||||
// 挂载MBTI选择弹窗
|
|
||||||
MbtiBottomSheetHost()
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(appColors.background)
|
|
||||||
) {
|
|
||||||
when {
|
|
||||||
model.isLoading -> {
|
|
||||||
// 加载中状态
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
androidx.compose.material3.CircularProgressIndicator(
|
|
||||||
color = appColors.main
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
model.profile != null -> {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
// 顶部背景区域(圆角在底部,覆盖状态栏)
|
|
||||||
// 优先显示新选择的背景图,如果没有则显示原有的背景图
|
|
||||||
val banner = model.bannerImageUrl?.toString() ?: model.profile?.banner
|
|
||||||
val statusBarPadding = WindowInsets.systemBars.asPaddingValues().calculateTopPadding()
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.offset(y = -statusBarPadding)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(402.dp)
|
|
||||||
.height(206.dp)
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.clip(RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp))
|
|
||||||
) {
|
|
||||||
if (banner != null) {
|
|
||||||
CustomAsyncImage(
|
|
||||||
context = context,
|
|
||||||
imageUrl = banner,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentDescription = "Banner",
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Gray.copy(alpha = 0.2f))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更换封面按钮(位于壁纸右下方)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.padding(end = 16.dp, bottom = 20.dp)
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(Color(0x5C7D7C80)) // RGB(125, 120, 128, 0.36)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
Intent(Intent.ACTION_PICK).apply {
|
|
||||||
type = "image/*"
|
|
||||||
pickBannerImageLauncher.launch(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
// 更换封面图标
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.mipmap.fengm),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = Color.White
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.change_cover),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 状态栏区域(时间、信号、电池)
|
|
||||||
// 这里使用系统状态栏,不单独实现
|
|
||||||
|
|
||||||
// 导航栏区域
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(statusBarPadding + 12.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = 12.dp)
|
|
||||||
) {
|
|
||||||
// 返回按钮
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color.White.copy(alpha = 0.3f))
|
|
||||||
.noRippleClickable {
|
|
||||||
// 用户未保存直接返回,恢复所有字段到原始值
|
|
||||||
model.resetToOriginalData()
|
|
||||||
navController.navigateUp()
|
|
||||||
}
|
|
||||||
.align(Alignment.CenterStart),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.drawable.rider_pro_back_icon),
|
|
||||||
contentDescription = "Back",
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
colorFilter = ColorFilter.tint(Color.White)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标题
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.edit_profile_info),
|
|
||||||
fontSize = 17.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = Color.White,
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户头像区域(距离顶部-50dp,包含状态栏高度,左右居中)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.offset(y = (-50).dp - statusBarPadding),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
val it = model.profile!!
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.size(96.dp),
|
|
||||||
contentAlignment = Alignment.BottomEnd
|
|
||||||
) {
|
|
||||||
// 头像
|
|
||||||
CustomAsyncImage(
|
|
||||||
context,
|
|
||||||
model.croppedBitmap ?: it.avatar,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(96.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.border(2.4.dp, appColors.background, CircleShape),
|
|
||||||
contentDescription = "",
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
|
|
||||||
// 编辑头像按钮
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(28.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color(0xFF110C13))
|
|
||||||
.border(2.dp, Color.White, CircleShape)
|
|
||||||
.debouncedClickable(debounceTime = 800L) {
|
|
||||||
debouncedNavigation {
|
|
||||||
navController.navigate(NavigationRoute.ImageCrop.route)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.mipmap.bi),
|
|
||||||
contentDescription = "Edit Avatar",
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f)
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.offset(y = (-74).dp)//
|
|
||||||
) {
|
|
||||||
// 昵称输入框
|
|
||||||
ProfileInfoCard(
|
|
||||||
label = stringResource(R.string.nickname),
|
|
||||||
value = model.name,
|
|
||||||
placeholder = stringResource(R.string.nickname_placeholder),
|
|
||||||
onValueChange = { onNicknameChange(it) },
|
|
||||||
isMultiline = false
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// 个人简介输入框
|
|
||||||
ProfileInfoCard(
|
|
||||||
label = stringResource(R.string.personal_intro),
|
|
||||||
value = model.bio,
|
|
||||||
placeholder = "",
|
|
||||||
onValueChange = { onBioChange(it) },
|
|
||||||
isMultiline = true
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// MBTI 类型和星座
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(appColors.secondaryBackground)
|
|
||||||
) {
|
|
||||||
// MBTI 类型
|
|
||||||
ProfileSelectItem(
|
|
||||||
label = stringResource(R.string.mbti_type),
|
|
||||||
value = model.mbti ?: "ENFP",
|
|
||||||
iconColor = Color(0xFF7C45ED),
|
|
||||||
iconResDark = null, // TODO: 添加MBTI暗色模式图标
|
|
||||||
iconResLight = null, // TODO: 添加MBTI亮色模式图标
|
|
||||||
onClick = {
|
|
||||||
MbtiSheetManager.open()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 分隔线
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(0.3.dp)
|
|
||||||
.background(appColors.divider)
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 星座(使用当前图标)
|
|
||||||
ProfileSelectItem(
|
|
||||||
label = stringResource(R.string.zodiac),
|
|
||||||
value = model.zodiac?.let { storedZodiac ->
|
|
||||||
// 尝试找到对应的资源ID并显示当前语言的文本
|
|
||||||
findZodiacResId(storedZodiac)?.let { resId ->
|
|
||||||
stringResource(resId)
|
|
||||||
} ?: storedZodiac // 如果找不到,显示原始存储的值
|
|
||||||
} ?: stringResource(R.string.zodiac_aries),
|
|
||||||
iconColor = Color(0xFFFFCC00),
|
|
||||||
iconResDark = R.mipmap.frame_4, // 星座暗色模式图标
|
|
||||||
iconResLight = R.mipmap.xingzuo, // 星座亮色模式图标
|
|
||||||
onClick = {
|
|
||||||
ZodiacSheetManager.open()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
// 保存按钮
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp)
|
|
||||||
.clip(RoundedCornerShape(1000.dp))
|
|
||||||
.background(
|
|
||||||
brush = Brush.horizontalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color(0xFF7C45ED), // RGB(124, 69, 237) - 左侧
|
|
||||||
Color(0xFF7C57EE), // RGB(124, 87, 238) - 中间
|
|
||||||
Color(0xFF7BD8F8) // RGB(123, 216, 248) - 右侧
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.debouncedClickable(
|
|
||||||
enabled = !model.isUpdating,
|
|
||||||
debounceTime = 1000L
|
|
||||||
) {
|
|
||||||
if (model.isUpdating) return@debouncedClickable
|
|
||||||
|
|
||||||
// 点击保存时重新验证
|
|
||||||
val nicknameErrorMsg = validateNickname()
|
|
||||||
val bioErrorMsg = validateBio()
|
|
||||||
|
|
||||||
// 如果有错误,显示对应的错误提示
|
|
||||||
when {
|
|
||||||
nicknameErrorMsg != null -> {
|
|
||||||
Toast.makeText(context, nicknameErrorMsg, Toast.LENGTH_SHORT).show()
|
|
||||||
return@debouncedClickable
|
|
||||||
}
|
|
||||||
bioErrorMsg != null -> {
|
|
||||||
Toast.makeText(context, bioErrorMsg, Toast.LENGTH_SHORT).show()
|
|
||||||
return@debouncedClickable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证通过,执行保存
|
|
||||||
model.viewModelScope.launch {
|
|
||||||
model.isUpdating = true
|
|
||||||
try {
|
|
||||||
model.updateUserProfile(context)
|
|
||||||
model.viewModelScope.launch(Dispatchers.Main) {
|
|
||||||
debouncedNavigation {
|
|
||||||
navController.navigateUp()
|
|
||||||
}
|
|
||||||
model.isUpdating = false
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// 捕获所有异常,包括网络异常
|
|
||||||
model.viewModelScope.launch(Dispatchers.Main) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.network_error_check_network),
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
model.isUpdating = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.save),
|
|
||||||
fontSize = 17.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
// 没有数据且不在加载中,显示错误信息
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.error_load_profile_failed),
|
|
||||||
color = appColors.text
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 信息输入卡片组件
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ProfileInfoCard(
|
|
||||||
label: String,
|
|
||||||
value: String,
|
|
||||||
placeholder: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
isMultiline: Boolean = false
|
|
||||||
) {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
|
||||||
var lineCount by remember { mutableStateOf(1) }
|
|
||||||
|
|
||||||
// 根据行数决定对齐方式:单行时居中,多行时顶部对齐
|
|
||||||
val verticalAlignment = if (isMultiline) {
|
|
||||||
if (lineCount <= 1) Alignment.CenterVertically else Alignment.Top
|
|
||||||
} else {
|
|
||||||
Alignment.CenterVertically
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(if (isMultiline) 66.dp else 56.dp) // 昵称框高度56dp,个人简介66dp
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(appColors.secondaryBackground),
|
|
||||||
contentAlignment = if (isMultiline && lineCount > 1) Alignment.TopStart else Alignment.CenterStart
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.padding(vertical = if (isMultiline && lineCount > 1) 11.dp else 0.dp),
|
|
||||||
verticalAlignment = verticalAlignment
|
|
||||||
) {
|
|
||||||
// 标签
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(100.dp)
|
|
||||||
.height(if (isMultiline) 44.dp else 56.dp),
|
|
||||||
contentAlignment = Alignment.CenterStart
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
fontSize = 17.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = appColors.text
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
|
|
||||||
// 输入框
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
// 对于个人简介(isMultiline = true),当值为空且没有焦点时显示图标
|
|
||||||
if (value.isEmpty() && isMultiline && !isFocused) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(44.dp),
|
|
||||||
contentAlignment = Alignment.CenterStart
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.mipmap.icons_infor_edit),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = appColors.secondaryText
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (value.isEmpty() && !isMultiline && placeholder.isNotEmpty()) {
|
|
||||||
// 对于非多行输入框,仍然显示 placeholder 文字
|
|
||||||
Text(
|
|
||||||
text = placeholder,
|
|
||||||
fontSize = 17.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = appColors.secondaryText,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
BasicTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.onFocusChanged { focusState ->
|
|
||||||
isFocused = focusState.isFocused
|
|
||||||
},
|
|
||||||
textStyle = androidx.compose.ui.text.TextStyle(
|
|
||||||
fontSize = if (isMultiline) 15.sp else 17.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = appColors.text
|
|
||||||
),
|
|
||||||
cursorBrush = SolidColor(appColors.text),
|
|
||||||
maxLines = if (isMultiline) Int.MAX_VALUE else 1,
|
|
||||||
singleLine = !isMultiline,
|
|
||||||
onTextLayout = { textLayoutResult: TextLayoutResult ->
|
|
||||||
if (isMultiline) {
|
|
||||||
lineCount = textLayoutResult.lineCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 选择项组件(MBTI、星座)
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ProfileSelectItem(
|
|
||||||
label: String,
|
|
||||||
value: String,
|
|
||||||
iconColor: Color,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
iconResDark: Int? = null,
|
|
||||||
iconResLight: Int? = null
|
|
||||||
) {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(56.dp)
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
// 自定义图标
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(
|
|
||||||
id = if (AppState.darkMode) {
|
|
||||||
// 暗色模式下使用和亮色模式一样的图标
|
|
||||||
iconResLight ?: iconResDark ?: R.mipmap.naoz
|
|
||||||
} else {
|
|
||||||
iconResLight ?: R.mipmap.naoz // 使用传入的亮色模式图标,或默认占位
|
|
||||||
}
|
|
||||||
),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
tint = iconColor
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
fontSize = 17.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = appColors.text
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
fontSize = 17.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = appColors.secondaryText
|
|
||||||
)
|
|
||||||
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ArrowForward,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(8.dp),
|
|
||||||
tint = appColors.secondaryText
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.account
|
|
||||||
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
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.LocalNavController
|
|
||||||
import com.aiosman.ravenow.AppState
|
|
||||||
import com.aiosman.ravenow.AppStore
|
|
||||||
import com.aiosman.ravenow.Messaging
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
|
||||||
import com.aiosman.ravenow.data.ServiceException
|
|
||||||
import com.aiosman.ravenow.data.api.ErrorCode
|
|
||||||
import com.aiosman.ravenow.data.api.showToast
|
|
||||||
import com.aiosman.ravenow.data.api.toErrorMessage
|
|
||||||
import com.aiosman.ravenow.ui.NavigationRoute
|
|
||||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
|
||||||
import com.aiosman.ravenow.ui.composables.ActionButton
|
|
||||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
|
||||||
import com.aiosman.ravenow.ui.composables.TextInputField
|
|
||||||
import com.aiosman.ravenow.utils.PasswordValidator
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RemoveAccountScreen() {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
var inputPassword by remember { mutableStateOf("") }
|
|
||||||
var passwordError by remember { mutableStateOf<String?>(null) }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
// 暗色模式下的 hint 文本颜色
|
|
||||||
val isDarkMode = AppState.darkMode
|
|
||||||
val hintColor = if (isDarkMode) {
|
|
||||||
Color(0xFFFFFFFF).copy(alpha = 0.7f)
|
|
||||||
} else {
|
|
||||||
null // 使用默认颜色
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeAccount(password: String) {
|
|
||||||
// 使用通用密码校验器
|
|
||||||
val passwordValidation = PasswordValidator.validateCurrentPassword(password, context)
|
|
||||||
if (!passwordValidation.isValid) {
|
|
||||||
passwordError = passwordValidation.errorMessage
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val accountService = AccountServiceImpl()
|
|
||||||
accountService.removeAccount(password)
|
|
||||||
Messaging.unregisterDevice(context)
|
|
||||||
AppStore.apply {
|
|
||||||
token = null
|
|
||||||
rememberMe = false
|
|
||||||
saveData()
|
|
||||||
}
|
|
||||||
//返回到登录页面
|
|
||||||
navController.navigate(NavigationRoute.Login.route) {
|
|
||||||
popUpTo(NavigationRoute.Login.route) {
|
|
||||||
inclusive = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//重置AppState
|
|
||||||
AppState.ReloadAppState(context)
|
|
||||||
Toast.makeText(context, "Account has been deleted", Toast.LENGTH_SHORT).show()
|
|
||||||
} catch (e: ServiceException) {
|
|
||||||
passwordError = "Incorrect password"
|
|
||||||
// e.errorType.showToast(context)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
Toast.makeText(context, "An error occurred. Please try again.", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(appColors.background),
|
|
||||||
) {
|
|
||||||
StatusBarSpacer()
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
|
||||||
) {
|
|
||||||
NoticeScreenHeader(
|
|
||||||
title = stringResource(R.string.remove_account),
|
|
||||||
moreIcon = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
|
||||||
.background(appColors.background),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(bottom = 32.dp, top = 16.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.remove_account_desc),
|
|
||||||
fontSize = 16.sp,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = appColors.text
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TextInputField(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
text = inputPassword,
|
|
||||||
onValueChange = {
|
|
||||||
inputPassword = it
|
|
||||||
if (passwordError != null) {
|
|
||||||
passwordError = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
password = true,
|
|
||||||
hint = stringResource(R.string.remove_account_password_hint),
|
|
||||||
error = passwordError,
|
|
||||||
customHintColor = hintColor
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
ActionButton(
|
|
||||||
text = stringResource(R.string.remove_account),
|
|
||||||
fullWidth = true,
|
|
||||||
enabled = true,
|
|
||||||
click = {
|
|
||||||
removeAccount(inputPassword)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,815 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.agent
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
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.activity.compose.BackHandler
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.LocalNavController
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.ui.NavigationRoute
|
|
||||||
import com.aiosman.ravenow.ui.account.AccountEditViewModel
|
|
||||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
|
||||||
import com.aiosman.ravenow.ui.comment.ScreenHeader
|
|
||||||
import com.aiosman.ravenow.ui.comment.ScreenHeader2
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import com.aiosman.ravenow.ui.composables.ActionButton
|
|
||||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
|
||||||
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 kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import com.aiosman.ravenow.AppState
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
||||||
import androidx.compose.animation.core.RepeatMode
|
|
||||||
import androidx.compose.animation.core.StartOffset
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.material3.TextField
|
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import com.aiosman.ravenow.ui.agent.AddAgentViewModel.showManualCreation
|
|
||||||
import com.airbnb.lottie.compose.LottieAnimation
|
|
||||||
import com.airbnb.lottie.compose.LottieCompositionSpec
|
|
||||||
import com.airbnb.lottie.compose.LottieConstants
|
|
||||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
|
||||||
/**
|
|
||||||
* 添加智能体界面
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun AddAgentScreen() {
|
|
||||||
val model = AddAgentViewModel
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val context = LocalContext.current
|
|
||||||
var agnetNameError by remember { mutableStateOf<String?>(null) }
|
|
||||||
var agnetDescError by remember { mutableStateOf<String?>(null) }
|
|
||||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
|
||||||
var isProcessing by remember { mutableStateOf(false) }
|
|
||||||
var showWaveAnimation by remember { mutableStateOf(false) }
|
|
||||||
var isCreatingAgent by remember { mutableStateOf(false) } // 控制是否处于创建状态
|
|
||||||
var showManualCreationForm by remember { mutableStateOf(false) } // 控制是否显示手动创建表单
|
|
||||||
var tempDesc by remember { mutableStateOf("") } // 独立的临时描述变量
|
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
|
|
||||||
fun onNameChange(value: String) {
|
|
||||||
model.name = value.trim()
|
|
||||||
agnetNameError = when {
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
|
|
||||||
fun onDescChange(value: String) {
|
|
||||||
model.desc = value.trim()
|
|
||||||
agnetDescError = when {
|
|
||||||
value.length > 512 -> "简介长度不能大于512"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fun onTempDescChange(value: String) {
|
|
||||||
tempDesc = value.trim()
|
|
||||||
agnetDescError = when {
|
|
||||||
value.length > 512 -> "简介长度不能大于512"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fun validate(): Boolean {
|
|
||||||
return agnetNameError == null && agnetDescError == null
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI文案优化
|
|
||||||
suspend fun optimizeTextWithAI(content: String): String? {
|
|
||||||
return try {
|
|
||||||
val sessionId = ""
|
|
||||||
val response = com.aiosman.ravenow.data.api.ApiClient.api.agentMoment(
|
|
||||||
com.aiosman.ravenow.data.api.AgentMomentRequestBody(
|
|
||||||
generateText = content,
|
|
||||||
sessionId = sessionId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
response.body()?.data
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理系统返回键
|
|
||||||
BackHandler {
|
|
||||||
// 如果不是在选择头像过程中,则清空数据
|
|
||||||
if (!model.isSelectingAvatar) {
|
|
||||||
model.clearData()
|
|
||||||
}
|
|
||||||
navController.popBackStack()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面进入时重置头像选择状态
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
model.isSelectingAvatar = false
|
|
||||||
// 根据标记恢复相应的状态
|
|
||||||
if (model.isAutoModeManualForm) {
|
|
||||||
// 恢复自动模式下的手动表单状态
|
|
||||||
showManualCreationForm = model.showManualCreationForm
|
|
||||||
isCreatingAgent = model.isCreatingAgent
|
|
||||||
showWaveAnimation = model.showWaveAnimation
|
|
||||||
showManualCreation = model.showManualCreation
|
|
||||||
} else {
|
|
||||||
// 恢复手动模式下的状态
|
|
||||||
showManualCreation = model.showManualCreation
|
|
||||||
showManualCreationForm = model.showManualCreationForm
|
|
||||||
isCreatingAgent = model.isCreatingAgent
|
|
||||||
showWaveAnimation = model.showWaveAnimation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(color = appColors.decentBackground),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
var showManualCreation by remember {
|
|
||||||
mutableStateOf(model.showManualCreation)
|
|
||||||
}
|
|
||||||
StatusBarSpacer()
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 16.dp)
|
|
||||||
.background(color = appColors.decentBackground)
|
|
||||||
) {
|
|
||||||
// 自定义header,控制返回按钮行为
|
|
||||||
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() }
|
|
||||||
) {
|
|
||||||
// 与BackHandler保持一致的逻辑
|
|
||||||
if (!model.isSelectingAvatar) {
|
|
||||||
model.clearData()
|
|
||||||
}
|
|
||||||
navController.navigateUp()
|
|
||||||
},
|
|
||||||
colorFilter = ColorFilter.tint(appColors.text)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(12.dp))
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.agent_add),
|
|
||||||
fontWeight = FontWeight.W600,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
fontSize = 17.sp,
|
|
||||||
color = appColors.text
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(1.dp))
|
|
||||||
|
|
||||||
if (!isCreatingAgent) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp)
|
|
||||||
.padding(horizontal = 20.dp),
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.mipmap.group_copy),
|
|
||||||
contentDescription = "",
|
|
||||||
modifier = Modifier
|
|
||||||
.size(48.dp)
|
|
||||||
.clip(
|
|
||||||
RoundedCornerShape(48.dp)
|
|
||||||
),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
.padding(start = 20.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "${AppState.profile?.nickName ?: "User"} ${stringResource(R.string.welcome_1)}",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = appColors.text,
|
|
||||||
fontWeight = FontWeight.W600
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!isCreatingAgent) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
.padding(start = 20.dp)
|
|
||||||
) {
|
|
||||||
if (!showManualCreation) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.welcome_2),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = appColors.text.copy(alpha = 0.6f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
if (!showManualCreation) {
|
|
||||||
//自动创造AI界面
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 20.dp)
|
|
||||||
) {
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(95.dp)
|
|
||||||
.shadow(
|
|
||||||
elevation = 10.dp,
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
spotColor = Color(0x33F563FF),
|
|
||||||
ambientColor = Color(0x99F563FF),
|
|
||||||
clip = false
|
|
||||||
)
|
|
||||||
.background(
|
|
||||||
brush = Brush.linearGradient(
|
|
||||||
listOf(
|
|
||||||
Color(0xFF6246FF),
|
|
||||||
Color(0xFF7C45ED)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(10.dp)
|
|
||||||
)
|
|
||||||
.padding(0.5.dp)
|
|
||||||
.background(
|
|
||||||
color = appColors.inputBackground2,
|
|
||||||
shape = RoundedCornerShape(10.dp)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.noRippleClickable {
|
|
||||||
model.viewModelScope.launch {
|
|
||||||
focusRequester.requestFocus()
|
|
||||||
keyboardController?.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
TextField(
|
|
||||||
value = tempDesc,
|
|
||||||
onValueChange = { value -> onTempDescChange(value) },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(95.dp)
|
|
||||||
.focusRequester(focusRequester),
|
|
||||||
placeholder = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.agent_desc_hint_auto),
|
|
||||||
color = Color.Gray
|
|
||||||
)
|
|
||||||
},
|
|
||||||
textStyle = TextStyle(
|
|
||||||
color = LocalAppTheme.current.text,
|
|
||||||
fontSize = 16.sp
|
|
||||||
),
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
|
||||||
disabledIndicatorColor = Color.Transparent,
|
|
||||||
focusedContainerColor = appColors.inputBackground2,
|
|
||||||
unfocusedContainerColor = appColors.inputBackground2,
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
supportingText = null,
|
|
||||||
trailingIcon = null,
|
|
||||||
leadingIcon = null
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.padding(end = 12.dp, bottom = 12.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
// 只有在有内容且未处理中且未创建中时才可点击
|
|
||||||
if (tempDesc.isNotEmpty() && !isProcessing && !isCreatingAgent) {
|
|
||||||
isProcessing = true
|
|
||||||
showWaveAnimation = true // 显示构思动画
|
|
||||||
keyboardController?.hide()
|
|
||||||
model.viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val optimizedText = optimizeTextWithAI(tempDesc)
|
|
||||||
if (optimizedText != null) {
|
|
||||||
onTempDescChange(optimizedText)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
} finally {
|
|
||||||
isProcessing = false
|
|
||||||
showWaveAnimation = false
|
|
||||||
isCreatingAgent = true
|
|
||||||
showManualCreationForm = true
|
|
||||||
onDescChange(tempDesc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.then(
|
|
||||||
if (tempDesc.isEmpty() || isProcessing || isCreatingAgent) {
|
|
||||||
Modifier.alpha(0.5f)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.mipmap.icons_info_magic),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = Color.Unspecified
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(5.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.agent_text_beautify),
|
|
||||||
color = Color(0xFF6246FF),
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
if ((isCreatingAgent && showWaveAnimation) || (isProcessing && showWaveAnimation)) {
|
|
||||||
// 显示构思动画
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.Start)
|
|
||||||
.padding(start = 20.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.size(32.dp)
|
|
||||||
) {
|
|
||||||
LottieAnimation(
|
|
||||||
composition = rememberLottieComposition(LottieCompositionSpec.Asset("loading.lottie")).value,
|
|
||||||
iterations = LottieConstants.IterateForever,
|
|
||||||
modifier = Modifier.matchParentSize()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.ideaing),
|
|
||||||
color = appColors.text.copy(alpha = 0.6f),
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (isCreatingAgent && !showWaveAnimation && showManualCreationForm) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.align(Alignment.Start)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.avatar),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = appColors.text,
|
|
||||||
fontWeight = FontWeight.W600
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(72.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(
|
|
||||||
brush = Brush.linearGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color(0x777c45ed),
|
|
||||||
Color(0x777c68ef),
|
|
||||||
Color(0x557bd8f8)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.align(Alignment.Start)
|
|
||||||
.noRippleClickable {
|
|
||||||
// 保存当前状态
|
|
||||||
model.showManualCreationForm = showManualCreationForm
|
|
||||||
model.isCreatingAgent = isCreatingAgent
|
|
||||||
model.showWaveAnimation = showWaveAnimation
|
|
||||||
model.showManualCreation = showManualCreation
|
|
||||||
model.isAutoModeManualForm = true // 标记为自动模式下的手动表单
|
|
||||||
// 设置正在选择头像的标志
|
|
||||||
model.isSelectingAvatar = true
|
|
||||||
navController.navigate(NavigationRoute.AgentImageCrop.route)
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
){
|
|
||||||
// 如果已有裁剪后的头像,则显示头像,否则显示编辑图标
|
|
||||||
if (model.croppedBitmap != null) {
|
|
||||||
Image(
|
|
||||||
bitmap = model.croppedBitmap!!.asImageBitmap(),
|
|
||||||
contentDescription = "Avatar",
|
|
||||||
modifier = Modifier
|
|
||||||
.size(72.dp)
|
|
||||||
.clip(CircleShape),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.mipmap.icons_infor_edit),
|
|
||||||
contentDescription = "Edit",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(18.dp))
|
|
||||||
// 原版两个输入框
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.agent_name),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = appColors.text,
|
|
||||||
fontWeight = FontWeight.W600
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
FormTextInput(
|
|
||||||
value = model.name,
|
|
||||||
hint = stringResource(R.string.agent_name_hint_1),
|
|
||||||
background = appColors.inputBackground2,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { value ->
|
|
||||||
onNameChange(value)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.agent_desc),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = appColors.text,
|
|
||||||
fontWeight = FontWeight.W600
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
FormTextInput2(
|
|
||||||
value = model.desc,
|
|
||||||
hint = stringResource(R.string.agent_desc_hint),
|
|
||||||
background = appColors.inputBackground2,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { value ->
|
|
||||||
onDescChange(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (!isCreatingAgent && !showWaveAnimation) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.Start)
|
|
||||||
.padding(start = 20.dp)
|
|
||||||
.width(136.dp)
|
|
||||||
.height(40.dp)
|
|
||||||
.border(
|
|
||||||
width = 1.dp,
|
|
||||||
color = Color(0x33858B98),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
)
|
|
||||||
.background(
|
|
||||||
color = appColors.background,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
)
|
|
||||||
.noRippleClickable {
|
|
||||||
showManualCreation = true
|
|
||||||
tempDesc = ""
|
|
||||||
agnetDescError = null
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(horizontal = 18.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.mipmap.icons_infor_edit),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = appColors.text,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.create_agent_hand),
|
|
||||||
color = appColors.text,
|
|
||||||
fontWeight = FontWeight.W600,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}else {
|
|
||||||
//手动创造AI界面
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.align(Alignment.Start)
|
|
||||||
) {
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val textMeasurer = rememberTextMeasurer()
|
|
||||||
val autoLabel = stringResource(R.string.create_agent_auto)
|
|
||||||
val measuredTextWidth = remember(autoLabel, textMeasurer) {
|
|
||||||
textMeasurer.measure(
|
|
||||||
text = AnnotatedString(autoLabel),
|
|
||||||
style = TextStyle(
|
|
||||||
color = appColors.text,
|
|
||||||
fontWeight = FontWeight.W600,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
).size.width
|
|
||||||
}
|
|
||||||
val textWidthDp = with(density) { measuredTextWidth.toDp() }
|
|
||||||
val contentWidth = 24.dp + 18.dp + 8.dp + textWidthDp
|
|
||||||
val boxWidth = if (contentWidth > 140.dp) 250.dp else 140.dp
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.Start)
|
|
||||||
.width(boxWidth)
|
|
||||||
.height(40.dp)
|
|
||||||
.shadow(
|
|
||||||
elevation = 10.dp,
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
spotColor = Color(0x33F563FF),
|
|
||||||
ambientColor = Color(0x99F563FF),
|
|
||||||
clip = false
|
|
||||||
)
|
|
||||||
.background(
|
|
||||||
brush = Brush.linearGradient(
|
|
||||||
listOf(
|
|
||||||
Color(0xFF6246FF),
|
|
||||||
Color(0xFF7C45ED)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(10.dp)
|
|
||||||
)
|
|
||||||
.padding(0.5.dp)
|
|
||||||
.background(
|
|
||||||
color = appColors.background,
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
)
|
|
||||||
.noRippleClickable {
|
|
||||||
showManualCreation = false
|
|
||||||
model.name = ""
|
|
||||||
model.desc = ""
|
|
||||||
model.croppedBitmap = null
|
|
||||||
isCreatingAgent = false
|
|
||||||
showManualCreationForm = false
|
|
||||||
showWaveAnimation = false
|
|
||||||
isProcessing = false
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.mipmap.icons_info_magic),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = appColors.text,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = autoLabel,
|
|
||||||
color = appColors.text,
|
|
||||||
fontWeight = FontWeight.W600,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.avatar),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = appColors.text,
|
|
||||||
fontWeight = FontWeight.W600
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(72.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(
|
|
||||||
brush = Brush.linearGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color(0x777c45ed),
|
|
||||||
Color(0x777c68ef),
|
|
||||||
Color(0x557bd8f8)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.align(Alignment.Start)
|
|
||||||
.noRippleClickable {
|
|
||||||
// 保存当前状态
|
|
||||||
model.showManualCreation = showManualCreation
|
|
||||||
model.showManualCreationForm = showManualCreationForm
|
|
||||||
model.isCreatingAgent = isCreatingAgent
|
|
||||||
model.showWaveAnimation = showWaveAnimation
|
|
||||||
model.isAutoModeManualForm = false // 标记为手动模式下的手动表单
|
|
||||||
// 设置正在选择头像的标志
|
|
||||||
model.isSelectingAvatar = true
|
|
||||||
navController.navigate(NavigationRoute.AgentImageCrop.route)
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
// 如果已有裁剪后的头像,则显示头像,否则显示编辑图标
|
|
||||||
if (model.croppedBitmap != null) {
|
|
||||||
Image(
|
|
||||||
bitmap = model.croppedBitmap!!.asImageBitmap(),
|
|
||||||
contentDescription = "Avatar",
|
|
||||||
modifier = Modifier
|
|
||||||
.size(72.dp)
|
|
||||||
.clip(CircleShape),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.mipmap.icons_infor_edit),
|
|
||||||
contentDescription = "Edit",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(18.dp))
|
|
||||||
// 原版两个输入框
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.agent_name),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = appColors.text,
|
|
||||||
fontWeight = FontWeight.W600
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
FormTextInput(
|
|
||||||
value = model.name,
|
|
||||||
hint = stringResource(R.string.agent_name_hint_1),
|
|
||||||
background = appColors.inputBackground2,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { value ->
|
|
||||||
onNameChange(value)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.agent_desc),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = appColors.text,
|
|
||||||
fontWeight = FontWeight.W600
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
FormTextInput2(
|
|
||||||
value = model.desc,
|
|
||||||
hint = stringResource(R.string.agent_desc_hint),
|
|
||||||
background = appColors.inputBackground2,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { value ->
|
|
||||||
onDescChange(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//手动创造AI界面
|
|
||||||
}
|
|
||||||
|
|
||||||
// 错误信息显示
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
errorMessage?.let { error ->
|
|
||||||
Text(
|
|
||||||
text = error,
|
|
||||||
color = Color.Red,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(bottom = 20.dp)
|
|
||||||
.align(Alignment.Center),
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ActionButton(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(345.dp)
|
|
||||||
.padding(bottom = 40.dp)
|
|
||||||
.background(
|
|
||||||
brush = Brush.linearGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color(0x777c45ed),
|
|
||||||
Color(0x777c68ef),
|
|
||||||
Color(0x557bd8f8)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(24.dp)
|
|
||||||
),
|
|
||||||
color = Color.White,
|
|
||||||
backgroundColor = Color.Transparent,
|
|
||||||
text = stringResource(R.string.create_confirm),
|
|
||||||
isLoading = model.isUpdating,
|
|
||||||
enabled = !model.isUpdating && validate()
|
|
||||||
) {
|
|
||||||
// 验证输入
|
|
||||||
val validationError = model.validate()
|
|
||||||
if (validationError != null) {
|
|
||||||
// 显示验证错误
|
|
||||||
errorMessage = validationError
|
|
||||||
model.viewModelScope.launch {
|
|
||||||
kotlinx.coroutines.delay(3000)
|
|
||||||
errorMessage = null
|
|
||||||
}
|
|
||||||
return@ActionButton
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除之前的错误信息
|
|
||||||
errorMessage = null
|
|
||||||
|
|
||||||
// 调用创建智能体API
|
|
||||||
model.viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val result = model.createAgent(context)
|
|
||||||
if (result != null) {
|
|
||||||
// 创建成功,清空数据并关闭页面
|
|
||||||
model.clearData()
|
|
||||||
navController.popBackStack()
|
|
||||||
AppState.agentCreatedSuccess = true
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// 显示错误信息
|
|
||||||
errorMessage = "创建智能体失败: ${e.message}"
|
|
||||||
e.printStackTrace()
|
|
||||||
// 3秒后清除错误信息
|
|
||||||
kotlinx.coroutines.delay(3000)
|
|
||||||
errorMessage = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.agent
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import com.aiosman.ravenow.data.AccountService
|
|
||||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
|
||||||
import com.aiosman.ravenow.data.UploadImage
|
|
||||||
import com.aiosman.ravenow.data.ServiceException
|
|
||||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
|
||||||
import com.aiosman.ravenow.entity.AgentEntity
|
|
||||||
import com.aiosman.ravenow.entity.createAgent
|
|
||||||
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
|
|
||||||
import com.aiosman.ravenow.utils.TrtcHelper
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
object AddAgentViewModel : ViewModel() {
|
|
||||||
var name by mutableStateOf("")
|
|
||||||
var desc by mutableStateOf("")
|
|
||||||
var croppedBitmap by mutableStateOf<Bitmap?>(null)
|
|
||||||
var isUpdating by mutableStateOf(false)
|
|
||||||
var isSelectingAvatar by mutableStateOf(false) // 标记是否正在选择头像
|
|
||||||
var showManualCreationForm by mutableStateOf(false)
|
|
||||||
var isCreatingAgent by mutableStateOf(false)
|
|
||||||
var showWaveAnimation by mutableStateOf(false)
|
|
||||||
var showManualCreation by mutableStateOf(false)
|
|
||||||
// 添加一个标志来区分两种手动表单状态
|
|
||||||
var isAutoModeManualForm by mutableStateOf(false)
|
|
||||||
suspend fun updateAgentAvatar(context: Context) {
|
|
||||||
croppedBitmap?.let {
|
|
||||||
val file = File(context.cacheDir, "agent_avatar.jpg")
|
|
||||||
it.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream())
|
|
||||||
// 这里可以上传图片到服务器,暂时先保存到本地
|
|
||||||
// UploadImage(file, "agent_avatar.jpg", "", "jpg")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createAgent(context: Context): AgentEntity? {
|
|
||||||
try {
|
|
||||||
isUpdating = true
|
|
||||||
|
|
||||||
// 准备头像文件
|
|
||||||
val avatarFile = if (croppedBitmap != null) {
|
|
||||||
val file = File(context.cacheDir, "agent_avatar.jpg")
|
|
||||||
croppedBitmap!!.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream())
|
|
||||||
UploadImage(file, "agent_avatar.jpg", "", "jpg")
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用API创建智能体
|
|
||||||
val result = createAgent(
|
|
||||||
title = name,
|
|
||||||
desc = desc,
|
|
||||||
avatar = avatarFile
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
|
||||||
} finally {
|
|
||||||
isUpdating = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun validate(): String? {
|
|
||||||
return when {
|
|
||||||
name.isEmpty() -> "智能体名称不能为空"
|
|
||||||
name.length < 2 -> "智能体名称长度不能少于2个字符"
|
|
||||||
name.length > 20 -> "智能体名称长度不能超过20个字符"
|
|
||||||
desc.isEmpty() -> "智能体描述不能为空"
|
|
||||||
desc.length > 512 -> "智能体描述长度不能超过512个字符"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清空所有页面数据
|
|
||||||
*/
|
|
||||||
fun clearData() {
|
|
||||||
name = ""
|
|
||||||
desc = ""
|
|
||||||
croppedBitmap = null
|
|
||||||
isUpdating = false
|
|
||||||
isSelectingAvatar = false
|
|
||||||
showManualCreationForm = false
|
|
||||||
isCreatingAgent = false
|
|
||||||
showWaveAnimation = false
|
|
||||||
showManualCreation = false
|
|
||||||
isAutoModeManualForm = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.agent
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
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.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
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.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
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.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.aiosman.ravenow.AppState
|
|
||||||
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.google.accompanist.systemuicontroller.rememberSystemUiController
|
|
||||||
import com.image.cropview.CropType
|
|
||||||
import com.image.cropview.EdgeType
|
|
||||||
import com.image.cropview.ImageCrop
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.InputStream
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 专门用于智能体头像裁剪的页面
|
|
||||||
* 支持创建和编辑两种模式
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun AgentImageCropScreen() {
|
|
||||||
var imageCrop by remember { mutableStateOf<ImageCrop?>(null) }
|
|
||||||
var croppedBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
|
||||||
val context = LocalContext.current
|
|
||||||
val configuration = LocalConfiguration.current
|
|
||||||
var imageWidthInDp by remember { mutableStateOf(0) }
|
|
||||||
var imageHeightInDp by remember { mutableStateOf(0) }
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
|
|
||||||
// 检查是否在编辑模式(通过检查是否有编辑ViewModel的实例)
|
|
||||||
val isEditMode = remember {
|
|
||||||
// 通过检查导航栈或使用其他方式判断
|
|
||||||
// 暂时使用一个简单的方法:检查AddAgentViewModel是否正在选择头像
|
|
||||||
// 如果不是,则可能是编辑模式
|
|
||||||
!AddAgentViewModel.isSelectingAvatar
|
|
||||||
}
|
|
||||||
|
|
||||||
val imagePickLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.GetContent()
|
|
||||||
) { uri: Uri? ->
|
|
||||||
uri?.let {
|
|
||||||
val bitmap = uriToBitmap(context = context, uri = it)
|
|
||||||
if (bitmap != null) {
|
|
||||||
val aspectRatio = bitmap.height.toFloat() / bitmap.width.toFloat()
|
|
||||||
imageHeightInDp = (imageWidthInDp.toFloat() * aspectRatio).toInt()
|
|
||||||
imageCrop = ImageCrop(bitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (uri == null) {
|
|
||||||
// 用户取消选择图片,重置标志
|
|
||||||
if (!isEditMode) {
|
|
||||||
AddAgentViewModel.isSelectingAvatar = false
|
|
||||||
}
|
|
||||||
navController.popBackStack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val systemUiController = rememberSystemUiController()
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
systemUiController.setStatusBarColor(darkIcons = false, color = Color.Black)
|
|
||||||
imagePickLauncher.launch("image/*")
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
imageCrop = null
|
|
||||||
val isDarkMode = AppState.darkMode
|
|
||||||
systemUiController.setStatusBarColor(
|
|
||||||
darkIcons = !isDarkMode,
|
|
||||||
color = if(isDarkMode)Color.Black else Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.background(Color.Black).fillMaxSize()
|
|
||||||
) {
|
|
||||||
StatusBarSpacer()
|
|
||||||
|
|
||||||
// 头部工具栏
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.rider_pro_back_icon),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.clickable {
|
|
||||||
// 用户取消头像选择,重置标志
|
|
||||||
if (!isEditMode) {
|
|
||||||
AddAgentViewModel.isSelectingAvatar = false
|
|
||||||
}
|
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
colorFilter = ColorFilter.tint(Color.White)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
// 确认按钮
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (croppedBitmap != null) Color.Green else Color.White,
|
|
||||||
modifier = Modifier.clickable {
|
|
||||||
if (croppedBitmap != null) {
|
|
||||||
// 如果已经有裁剪结果,直接返回
|
|
||||||
if (isEditMode) {
|
|
||||||
// 编辑模式:需要找到当前的编辑ViewModel实例
|
|
||||||
// 由于无法直接访问,我们使用一个全局状态或者通过其他方式传递
|
|
||||||
// 暂时先保存到AddAgentViewModel,编辑页面会检查
|
|
||||||
AddAgentViewModel.croppedBitmap = croppedBitmap
|
|
||||||
} else {
|
|
||||||
AddAgentViewModel.croppedBitmap = croppedBitmap
|
|
||||||
// 重置头像选择标志
|
|
||||||
AddAgentViewModel.isSelectingAvatar = false
|
|
||||||
AddAgentViewModel.viewModelScope.launch {
|
|
||||||
AddAgentViewModel.updateAgentAvatar(context)
|
|
||||||
navController.popBackStack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
navController.popBackStack()
|
|
||||||
} else {
|
|
||||||
// 进行裁剪
|
|
||||||
imageCrop?.let {
|
|
||||||
val bitmap = it.onCrop()
|
|
||||||
croppedBitmap = bitmap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 裁剪预览区域
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(24.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(imageHeightInDp.dp)
|
|
||||||
.onGloballyPositioned { coordinates ->
|
|
||||||
with(density) {
|
|
||||||
imageWidthInDp = coordinates.size.width.toDp().value.toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
imageCrop?.ImageCropView(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
guideLineColor = Color.White,
|
|
||||||
guideLineWidth = 2.dp,
|
|
||||||
edgeCircleSize = 5.dp,
|
|
||||||
cropType = CropType.SQUARE,
|
|
||||||
edgeType = EdgeType.CIRCULAR
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 裁剪结果预览
|
|
||||||
croppedBitmap?.let { bitmap ->
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
Color.Black.copy(alpha = 0.8f),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
)
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "✅ 裁剪完成",
|
|
||||||
color = Color.Green,
|
|
||||||
fontSize = 16.sp
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
CustomAsyncImage(
|
|
||||||
context,
|
|
||||||
bitmap,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(100.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color.Gray.copy(alpha = 0.3f), CircleShape),
|
|
||||||
contentDescription = "智能体头像预览",
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "点击 ✓ 确认使用此头像",
|
|
||||||
color = Color.White.copy(alpha = 0.8f),
|
|
||||||
fontSize = 12.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun uriToBitmap(context: Context, uri: Uri): Bitmap? {
|
|
||||||
return try {
|
|
||||||
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
|
|
||||||
BitmapFactory.decodeStream(inputStream)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,483 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.chat
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import android.util.Log
|
|
||||||
import android.webkit.MimeTypeMap
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.aiosman.ravenow.data.AccountService
|
|
||||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
|
||||||
import com.aiosman.ravenow.data.UserService
|
|
||||||
import com.aiosman.ravenow.data.UserServiceImpl
|
|
||||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
|
||||||
import com.aiosman.ravenow.entity.ChatItem
|
|
||||||
import io.openim.android.sdk.OpenIMClient
|
|
||||||
import io.openim.android.sdk.enums.ConversationType
|
|
||||||
import io.openim.android.sdk.enums.ViewType
|
|
||||||
import io.openim.android.sdk.listener.OnAdvanceMsgListener
|
|
||||||
import io.openim.android.sdk.listener.OnBase
|
|
||||||
import io.openim.android.sdk.listener.OnMsgSendCallback
|
|
||||||
import io.openim.android.sdk.models.*
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.InputStream
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 聊天ViewModel基类,包含所有聊天功能的通用实现
|
|
||||||
* 子类需要实现抽象方法来处理特定的聊天类型(单聊/群聊)
|
|
||||||
*/
|
|
||||||
abstract class BaseChatViewModel : ViewModel() {
|
|
||||||
|
|
||||||
// 通用状态属性
|
|
||||||
var chatData by mutableStateOf<List<ChatItem>>(emptyList())
|
|
||||||
var myProfile by mutableStateOf<AccountProfileEntity?>(null)
|
|
||||||
var hasMore by mutableStateOf(true)
|
|
||||||
var isLoading by mutableStateOf(false)
|
|
||||||
var lastMessage: Message? = null
|
|
||||||
val showTimestampMap = mutableMapOf<String, Boolean>()
|
|
||||||
var goToNew by mutableStateOf(false)
|
|
||||||
var conversationID: String = "" // 会话ID,通过getOneConversation初始化
|
|
||||||
|
|
||||||
// 通用服务
|
|
||||||
val userService: UserService = UserServiceImpl()
|
|
||||||
val accountService: AccountService = AccountServiceImpl()
|
|
||||||
var textMessageListener: OnAdvanceMsgListener? = null
|
|
||||||
|
|
||||||
val fetchHistorySize = 20
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化方法,子类需要实现具体的初始化逻辑
|
|
||||||
*/
|
|
||||||
abstract fun init(context: Context)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取日志标签,子类需要实现
|
|
||||||
*/
|
|
||||||
abstract fun getLogTag(): String
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取会话参数,子类需要实现
|
|
||||||
* @return Triple(targetId, conversationType, isSingleChat)
|
|
||||||
*/
|
|
||||||
abstract fun getConversationParams(): Triple<String, Int, Boolean>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理接收到的新消息,子类可以重写以添加特定逻辑
|
|
||||||
*/
|
|
||||||
open fun handleNewMessage(message: Message, context: Context): Boolean {
|
|
||||||
return false // 默认不处理,子类重写
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取发送消息时的接收者ID,子类需要实现
|
|
||||||
*/
|
|
||||||
abstract fun getReceiverInfo(): Pair<String?, String?> // (recvID, groupID)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送消息成功后的额外处理,子类可以重写
|
|
||||||
*/
|
|
||||||
open fun onMessageSentSuccess(message: String, sentMessage: Message?) {
|
|
||||||
// 默认无额外处理,子类可以重写
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取会话信息并初始化conversationID
|
|
||||||
*/
|
|
||||||
fun getOneConversation(onSuccess: (() -> Unit)? = null) {
|
|
||||||
val (targetId, conversationType, isSingleChat) = getConversationParams()
|
|
||||||
|
|
||||||
OpenIMClient.getInstance().conversationManager.getOneConversation(
|
|
||||||
object : OnBase<ConversationInfo> {
|
|
||||||
override fun onError(code: Int, error: String) {
|
|
||||||
Log.e(getLogTag(), "getOneConversation error: $error")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSuccess(data: ConversationInfo) {
|
|
||||||
conversationID = data.conversationID
|
|
||||||
Log.d(getLogTag(), "获取会话信息成功,conversationID: $conversationID")
|
|
||||||
onSuccess?.invoke()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
targetId,
|
|
||||||
conversationType
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册消息监听器
|
|
||||||
*/
|
|
||||||
fun RegistListener(context: Context) {
|
|
||||||
// 检查 OpenIM 是否已登录
|
|
||||||
if (!com.aiosman.ravenow.AppState.enableChat) {
|
|
||||||
Log.w(getLogTag(), "OpenIM 未登录,跳过注册消息监听器")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
textMessageListener = object : OnAdvanceMsgListener {
|
|
||||||
override fun onRecvNewMessage(msg: Message?) {
|
|
||||||
msg?.let { message ->
|
|
||||||
if (handleNewMessage(message, context)) {
|
|
||||||
val chatItem = ChatItem.convertToChatItem(message, context, avatar = getMessageAvatar(message))
|
|
||||||
chatItem?.let {
|
|
||||||
chatData = listOf(it) + chatData
|
|
||||||
goToNew = true
|
|
||||||
Log.i(getLogTag(), "收到来自 ${message.sendID} 的消息,更新聊天列表")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OpenIMClient.getInstance().messageManager.setAdvancedMsgListener(textMessageListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取消息头像,子类可以重写
|
|
||||||
*/
|
|
||||||
open fun getMessageAvatar(message: Message): String? {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取消注册消息监听器
|
|
||||||
*/
|
|
||||||
fun UnRegistListener() {
|
|
||||||
textMessageListener = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清除未读消息
|
|
||||||
*/
|
|
||||||
fun clearUnRead() {
|
|
||||||
if (conversationID.isEmpty()) {
|
|
||||||
Log.w(getLogTag(), "conversationID为空,无法清除未读消息")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
OpenIMClient.getInstance().messageManager.markConversationMessageAsRead(
|
|
||||||
conversationID,
|
|
||||||
object : OnBase<String> {
|
|
||||||
override fun onSuccess(data: String?) {
|
|
||||||
Log.i("openim", "清除未读消息成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(code: Int, error: String?) {
|
|
||||||
Log.i("openim", "清除未读消息失败, code:$code, error:$error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载更多历史消息
|
|
||||||
*/
|
|
||||||
fun onLoadMore(context: Context) {
|
|
||||||
if (!hasMore || isLoading) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loadHistoryMessages(context, isLoadMore = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送文本消息
|
|
||||||
*/
|
|
||||||
fun sendMessage(message: String, context: Context) {
|
|
||||||
// 检查 OpenIM 是否已登录
|
|
||||||
if (!com.aiosman.ravenow.AppState.enableChat) {
|
|
||||||
Log.w(getLogTag(), "OpenIM 未登录,无法发送消息")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val textMessage = OpenIMClient.getInstance().messageManager.createTextMessage(message)
|
|
||||||
val (recvID, groupID) = getReceiverInfo()
|
|
||||||
|
|
||||||
OpenIMClient.getInstance().messageManager.sendMessage(
|
|
||||||
object : OnMsgSendCallback {
|
|
||||||
override fun onProgress(progress: Long) {
|
|
||||||
// 发送进度
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(code: Int, error: String?) {
|
|
||||||
Log.e(getLogTag(), "发送消息失败: $error")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSuccess(data: Message?) {
|
|
||||||
Log.d(getLogTag(), "发送消息成功")
|
|
||||||
onMessageSentSuccess(message, data)
|
|
||||||
data?.let { sentMessage ->
|
|
||||||
val chatItem = ChatItem.convertToChatItem(sentMessage, context, avatar = myProfile?.avatar)
|
|
||||||
chatItem?.let {
|
|
||||||
chatData = listOf(it) + chatData
|
|
||||||
goToNew = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
textMessage,
|
|
||||||
recvID,
|
|
||||||
groupID,
|
|
||||||
OfflinePushInfo()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送图片消息
|
|
||||||
*/
|
|
||||||
fun sendImageMessage(imageUri: Uri, context: Context) {
|
|
||||||
val tempFile = createTempFile(context, imageUri)
|
|
||||||
val imagePath = tempFile?.path
|
|
||||||
if (imagePath != null) {
|
|
||||||
val imageMessage = OpenIMClient.getInstance().messageManager.createImageMessageFromFullPath(imagePath)
|
|
||||||
val (recvID, groupID) = getReceiverInfo()
|
|
||||||
|
|
||||||
OpenIMClient.getInstance().messageManager.sendMessage(
|
|
||||||
object : OnMsgSendCallback {
|
|
||||||
override fun onProgress(progress: Long) {
|
|
||||||
Log.d(getLogTag(), "发送图片消息进度: $progress")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(code: Int, error: String?) {
|
|
||||||
Log.e(getLogTag(), "发送图片消息失败: $error")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSuccess(data: Message?) {
|
|
||||||
Log.d(getLogTag(), "发送图片消息成功")
|
|
||||||
data?.let { sentMessage ->
|
|
||||||
val chatItem = ChatItem.convertToChatItem(sentMessage, context, avatar = myProfile?.avatar)
|
|
||||||
chatItem?.let {
|
|
||||||
chatData = listOf(it) + chatData
|
|
||||||
goToNew = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
imageMessage,
|
|
||||||
recvID,
|
|
||||||
groupID,
|
|
||||||
OfflinePushInfo()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建临时文件
|
|
||||||
*/
|
|
||||||
fun createTempFile(context: Context, uri: Uri): File? {
|
|
||||||
return try {
|
|
||||||
val projection = arrayOf(MediaStore.Images.Media.DATA)
|
|
||||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
|
||||||
cursor?.use {
|
|
||||||
if (it.moveToFirst()) {
|
|
||||||
val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
|
||||||
val filePath = it.getString(columnIndex)
|
|
||||||
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
|
|
||||||
val mimeType = context.contentResolver.getType(uri)
|
|
||||||
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
|
|
||||||
val tempFile =
|
|
||||||
File.createTempFile("temp_image", ".$extension", context.cacheDir)
|
|
||||||
val outputStream = FileOutputStream(tempFile)
|
|
||||||
|
|
||||||
inputStream?.use { input ->
|
|
||||||
outputStream.use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tempFile
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取历史消息
|
|
||||||
*/
|
|
||||||
fun fetchHistoryMessage(context: Context) {
|
|
||||||
loadHistoryMessages(context, isLoadMore = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载历史消息的通用方法
|
|
||||||
* @param context 上下文
|
|
||||||
* @param isLoadMore 是否是加载更多(true:追加到现有数据,false:替换现有数据)
|
|
||||||
*/
|
|
||||||
private fun loadHistoryMessages(context: Context, isLoadMore: Boolean) {
|
|
||||||
if (conversationID.isEmpty()) {
|
|
||||||
Log.w(getLogTag(), "conversationID为空,无法${if (isLoadMore) "加载更多" else "获取"}历史消息")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoadMore) {
|
|
||||||
isLoading = true
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
OpenIMClient.getInstance().messageManager.getAdvancedHistoryMessageList(
|
|
||||||
object : OnBase<AdvancedMessage> {
|
|
||||||
override fun onSuccess(data: AdvancedMessage?) {
|
|
||||||
val messages = data?.messageList ?: emptyList()
|
|
||||||
val newChatItems = messages.mapNotNull {
|
|
||||||
ChatItem.convertToChatItem(it, context, avatar = getMessageAvatar(it))
|
|
||||||
}.reversed() // 反转顺序,使最新消息在前面
|
|
||||||
|
|
||||||
// 根据是否是加载更多来决定数据处理方式
|
|
||||||
chatData = if (isLoadMore) {
|
|
||||||
chatData + newChatItems // 追加到现有数据
|
|
||||||
} else {
|
|
||||||
newChatItems // 替换现有数据
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messages.size < fetchHistorySize) {
|
|
||||||
hasMore = false
|
|
||||||
}
|
|
||||||
lastMessage = messages.firstOrNull()
|
|
||||||
|
|
||||||
if (isLoadMore) {
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(getLogTag(), "${if (isLoadMore) "加载更多" else "获取"}历史消息成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(code: Int, error: String?) {
|
|
||||||
Log.e(getLogTag(), "${if (isLoadMore) "加载更多" else "获取"}历史消息失败: $error")
|
|
||||||
if (isLoadMore) {
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
conversationID,
|
|
||||||
if (isLoadMore) lastMessage else null, // 首次加载不传lastMessage
|
|
||||||
fetchHistorySize,
|
|
||||||
ViewType.History
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取显示的聊天列表
|
|
||||||
*/
|
|
||||||
fun getDisplayChatList(): List<ChatItem> {
|
|
||||||
val list = chatData
|
|
||||||
// 更新每条消息的时间戳显示状态
|
|
||||||
for (item in list) {
|
|
||||||
item.showTimestamp = showTimestampMap.getOrDefault(item.msgId, false)
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,657 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.chat
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.animation.Crossfade
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.ime
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
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.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.Icon
|
|
||||||
import androidx.compose.material.Scaffold
|
|
||||||
import androidx.compose.material.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.platform.SoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
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.viewModelScope
|
|
||||||
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.ChatItem
|
|
||||||
import com.aiosman.ravenow.exp.formatChatTime
|
|
||||||
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.utils.NetworkUtils
|
|
||||||
import com.aiosman.ravenow.ui.NavigationRoute
|
|
||||||
import com.aiosman.ravenow.AppState
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import io.openim.android.sdk.enums.MessageType
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatAiScreen(userId: String) {
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val context = LocalNavController.current.context
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
val chatBackgroundUrl = AppState.chatBackgroundUrl
|
|
||||||
var goToNewCount by remember { mutableStateOf(0) }
|
|
||||||
val viewModel = viewModel<ChatAiViewModel>(
|
|
||||||
key = "ChatAiViewModel_$userId",
|
|
||||||
factory = object : ViewModelProvider.Factory {
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
|
||||||
return ChatAiViewModel(userId) as T
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
var isLoadingMore by remember { mutableStateOf(false) } // Add a state for loading
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
viewModel.init(context = context)
|
|
||||||
}
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
viewModel.UnRegistListener()
|
|
||||||
viewModel.clearUnRead()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
val navigationBarHeight = with(LocalDensity.current) {
|
|
||||||
WindowInsets.navigationBars.getBottom(this).toDp()
|
|
||||||
}
|
|
||||||
var inBottom by remember { mutableStateOf(true) }
|
|
||||||
// 监听滚动状态,触发加载更多
|
|
||||||
LaunchedEffect(listState) {
|
|
||||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
|
|
||||||
.collect { index ->
|
|
||||||
Log.d("ChatScreen", "lastVisibleItemIndex: ${index}")
|
|
||||||
if (index == listState.layoutInfo.totalItemsCount - 1) {
|
|
||||||
coroutineScope.launch {
|
|
||||||
viewModel.onLoadMore(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 监听滚动状态,触发滚动到底部
|
|
||||||
LaunchedEffect(listState) {
|
|
||||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index }
|
|
||||||
.collect { index ->
|
|
||||||
inBottom = index == 0
|
|
||||||
if (index == 0) {
|
|
||||||
goToNewCount = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 监听是否需要滚动到最新消息
|
|
||||||
LaunchedEffect(viewModel.goToNew) {
|
|
||||||
if (viewModel.goToNew) {
|
|
||||||
if (inBottom) {
|
|
||||||
listState.scrollToItem(0)
|
|
||||||
} else {
|
|
||||||
goToNewCount++
|
|
||||||
}
|
|
||||||
viewModel.goToNew = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
|
||||||
if (chatBackgroundUrl != null && chatBackgroundUrl.isNotEmpty()) {
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = chatBackgroundUrl,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentDescription = "chat_background",
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize(),
|
|
||||||
backgroundColor = Color.Transparent,
|
|
||||||
topBar = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(Color.Transparent)
|
|
||||||
) {
|
|
||||||
StatusBarSpacer()
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 16.dp, horizontal = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.Start,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.rider_pro_back_icon),
|
|
||||||
modifier = Modifier
|
|
||||||
.size(28.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
navController.navigateUp()
|
|
||||||
},
|
|
||||||
contentDescription = null,
|
|
||||||
colorFilter = ColorFilter.tint(
|
|
||||||
AppColors.text)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = viewModel.userProfile?.avatar ?: "",
|
|
||||||
modifier = Modifier
|
|
||||||
.size(32.dp)
|
|
||||||
.clip(RoundedCornerShape(40.dp)),
|
|
||||||
contentDescription = "avatar"
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = viewModel.userProfile?.nickName ?: "",
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
style = TextStyle(
|
|
||||||
color = AppColors.text,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = androidx.compose.ui.text.font.FontWeight.W700
|
|
||||||
),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Box {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.rider_pro_more_horizon),
|
|
||||||
modifier = Modifier
|
|
||||||
.size(28.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
navController.navigate(NavigationRoute.ChatSetting.route)
|
|
||||||
},
|
|
||||||
contentDescription = null,
|
|
||||||
colorFilter = ColorFilter.tint(
|
|
||||||
AppColors.text)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
bottomBar = {
|
|
||||||
val hasChatBackground = AppState.chatBackgroundUrl != null && AppState.chatBackgroundUrl!!.isNotEmpty()
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.imePadding()
|
|
||||||
) {
|
|
||||||
if (!hasChatBackground) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(1.dp)
|
|
||||||
.background(
|
|
||||||
AppColors.decentBackground)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
ChatAiInput(
|
|
||||||
onSendImage = {
|
|
||||||
it?.let {
|
|
||||||
if (NetworkUtils.isNetworkAvailable(context)) {
|
|
||||||
viewModel.sendImageMessage(it, context)
|
|
||||||
} else {
|
|
||||||
android.widget.Toast.makeText(context, "网络连接异常,请检查网络设置", android.widget.Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
viewModel.sendMessage(it, context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) { paddingValues ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Transparent)
|
|
||||||
.padding(paddingValues)
|
|
||||||
) {
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize(),
|
|
||||||
reverseLayout = true,
|
|
||||||
verticalArrangement = Arrangement.Top
|
|
||||||
) {
|
|
||||||
val chatList = groupMessagesByTime(viewModel.getDisplayChatList(), viewModel)
|
|
||||||
items(chatList.size, key = { index -> chatList[index].msgId + UUID.randomUUID().toString()}) { index ->
|
|
||||||
val item = chatList[index]
|
|
||||||
if (item.showTimeDivider) {
|
|
||||||
val calendar = java.util.Calendar.getInstance()
|
|
||||||
calendar.timeInMillis = item.timestamp
|
|
||||||
Text(
|
|
||||||
text = calendar.time.formatChatTime(context), // Format the timestamp
|
|
||||||
style = TextStyle(
|
|
||||||
color = AppColors.secondaryText,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ChatAiItem(item = item, viewModel.myProfile?.trtcUserId!!)
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
// item {
|
|
||||||
// Spacer(modifier = Modifier.height(72.dp))
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
if (goToNewCount > 0) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.padding(bottom = 16.dp, end = 16.dp)
|
|
||||||
.shadow(4.dp, shape = RoundedCornerShape(16.dp))
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(AppColors.background)
|
|
||||||
.padding(8.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
coroutineScope.launch {
|
|
||||||
listState.scrollToItem(0)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "${goToNewCount} New Message",
|
|
||||||
style = TextStyle(
|
|
||||||
color = AppColors.text,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatAiSelfItem(item: ChatItem) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = androidx.compose.ui.Alignment.End,
|
|
||||||
) {
|
|
||||||
/* Text(
|
|
||||||
text = item.nickname,
|
|
||||||
style = TextStyle(
|
|
||||||
color = Color.Gray,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(bottom = 2.dp)
|
|
||||||
)
|
|
||||||
*/
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(
|
|
||||||
min = 20.dp,
|
|
||||||
max = (if (item.messageType == MessageType.TEXT) 250.dp else 150.dp)
|
|
||||||
)
|
|
||||||
.clip(RoundedCornerShape(20.dp))
|
|
||||||
.background(Color(0xFF6246FF))
|
|
||||||
.padding(
|
|
||||||
vertical = (if (item.messageType == MessageType.TEXT) 8.dp else 0.dp),
|
|
||||||
horizontal = (if (item.messageType == MessageType.TEXT) 16.dp else 0.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
) {
|
|
||||||
when (item.messageType) {
|
|
||||||
MessageType.TEXT -> {
|
|
||||||
Text(
|
|
||||||
text = item.message,
|
|
||||||
style = TextStyle(
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Start
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
MessageType.PICTURE -> {
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = item.imageList[1].url,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentDescription = "image"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
Text(
|
|
||||||
text = "不支持的消息类型",
|
|
||||||
style = TextStyle(
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/*Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.clip(RoundedCornerShape(24.dp))
|
|
||||||
) {
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = item.avatar,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentDescription = "avatar"
|
|
||||||
)
|
|
||||||
}*/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatAiOtherItem(item: ChatItem) {
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.Start,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.clip(RoundedCornerShape(24.dp))
|
|
||||||
) {
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = item.avatar,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentDescription = "avatar"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Column {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(
|
|
||||||
min = 20.dp,
|
|
||||||
max = (if (item.messageType == MessageType.TEXT) 250.dp else 150.dp)
|
|
||||||
)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(AppColors.bubbleBackground)
|
|
||||||
.padding(
|
|
||||||
vertical = (if (item.messageType == MessageType.TEXT) 8.dp else 0.dp),
|
|
||||||
horizontal = (if (item.messageType == MessageType.TEXT) 16.dp else 0.dp)
|
|
||||||
)
|
|
||||||
.padding(bottom = (if (item.messageType == MessageType.TEXT) 3.dp else 0.dp))
|
|
||||||
) {
|
|
||||||
when (item.messageType) {
|
|
||||||
MessageType.TEXT -> {
|
|
||||||
Text(
|
|
||||||
text = item.message,
|
|
||||||
style = TextStyle(
|
|
||||||
color = AppColors.text,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Start
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
MessageType.PICTURE -> {
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = item.imageList[1].url,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentDescription = "image"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
Text(
|
|
||||||
text = "Unsupported message type",
|
|
||||||
style = TextStyle(
|
|
||||||
color = AppColors.text,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatAiItem(item: ChatItem, currentUserId: String) {
|
|
||||||
val isCurrentUser = item.userId == currentUserId
|
|
||||||
if (isCurrentUser) {
|
|
||||||
ChatAiSelfItem(item)
|
|
||||||
} else {
|
|
||||||
ChatAiOtherItem(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatAiInput(
|
|
||||||
onSendImage: (Uri?) -> Unit = {},
|
|
||||||
onSend: (String) -> Unit = {},
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val navigationBarHeight = with(LocalDensity.current) {
|
|
||||||
WindowInsets.navigationBars.getBottom(this).toDp()
|
|
||||||
}
|
|
||||||
var keyboardController by remember { mutableStateOf<SoftwareKeyboardController?>(null) }
|
|
||||||
var isKeyboardOpen by remember { mutableStateOf(false) }
|
|
||||||
var text by remember { mutableStateOf("") }
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
val inputBarHeight by animateDpAsState(
|
|
||||||
targetValue = if (isKeyboardOpen) 8.dp else (navigationBarHeight + 8.dp),
|
|
||||||
animationSpec = tween(
|
|
||||||
durationMillis = 300,
|
|
||||||
easing = androidx.compose.animation.core.LinearEasing
|
|
||||||
), label = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
LaunchedEffect(isKeyboardOpen) {
|
|
||||||
inputBarHeight
|
|
||||||
}
|
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
val windowInsets = WindowInsets.ime
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val softwareKeyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
val currentDensity by rememberUpdatedState(density)
|
|
||||||
|
|
||||||
LaunchedEffect(windowInsets.getBottom(currentDensity)) {
|
|
||||||
if (windowInsets.getBottom(currentDensity) <= 0) {
|
|
||||||
focusManager.clearFocus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val imagePickUpLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
|
||||||
) {
|
|
||||||
if (it.resultCode == Activity.RESULT_OK) {
|
|
||||||
val uri = it.data?.data
|
|
||||||
onSendImage(uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box( modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(Color.Transparent)
|
|
||||||
.padding(start = 16.dp, end = 16.dp, bottom = 45.dp),){
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(20.dp))
|
|
||||||
.background(appColors.decentBackground)
|
|
||||||
.padding(start = 16.dp, end = 8.dp, top = 2.dp, bottom = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
) {
|
|
||||||
BasicTextField(
|
|
||||||
value = text,
|
|
||||||
onValueChange = {
|
|
||||||
text = it
|
|
||||||
},
|
|
||||||
textStyle = TextStyle(
|
|
||||||
color = appColors.text,
|
|
||||||
fontSize = 16.sp
|
|
||||||
),
|
|
||||||
cursorBrush = SolidColor(appColors.text),
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 8.dp)
|
|
||||||
.onFocusChanged { focusState ->
|
|
||||||
isKeyboardOpen = focusState.isFocused
|
|
||||||
}
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
awaitPointerEventScope {
|
|
||||||
keyboardController = softwareKeyboardController
|
|
||||||
awaitFirstDown().also {
|
|
||||||
keyboardController?.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onDone = {
|
|
||||||
keyboardController?.hide()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Crossfade(
|
|
||||||
targetState = text.isNotEmpty(), animationSpec = tween(500),
|
|
||||||
label = ""
|
|
||||||
) { isNotEmpty ->
|
|
||||||
val alpha by animateFloatAsState(
|
|
||||||
targetValue = if (isNotEmpty) 1f else 0.5f,
|
|
||||||
animationSpec = tween(300)
|
|
||||||
)
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.mipmap.btn),
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.alpha(alpha)
|
|
||||||
.noRippleClickable {
|
|
||||||
if (text.isNotEmpty()) {
|
|
||||||
if (NetworkUtils.isNetworkAvailable(context)) {
|
|
||||||
onSend(text)
|
|
||||||
text = ""
|
|
||||||
} else {
|
|
||||||
android.widget.Toast.makeText(context, "网络连接异常,请检查网络设置", android.widget.Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun groupMessagesByTime(chatList: List<ChatItem>, viewModel: ChatAiViewModel): List<ChatItem> {
|
|
||||||
for (i in chatList.indices) { // Iterate in normal order
|
|
||||||
if (i == 0) {
|
|
||||||
viewModel.showTimestampMap[chatList[i].msgId] = false
|
|
||||||
chatList[i].showTimeDivider = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val currentMessage = chatList[i]
|
|
||||||
val timeDiff = currentMessage.timestamp - chatList[i - 1].timestamp
|
|
||||||
// 时间间隔大于 3 分钟,显示时间戳
|
|
||||||
if (-timeDiff > 30 * 60 * 1000) {
|
|
||||||
viewModel.showTimestampMap[currentMessage.msgId] = true
|
|
||||||
currentMessage.showTimeDivider = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chatList
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.chat
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.aiosman.ravenow.ChatState
|
|
||||||
import com.aiosman.ravenow.data.api.ApiClient
|
|
||||||
import com.aiosman.ravenow.data.api.SendChatAiRequestBody
|
|
||||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
|
||||||
import com.aiosman.ravenow.entity.ChatNotification
|
|
||||||
import io.openim.android.sdk.enums.ConversationType
|
|
||||||
import io.openim.android.sdk.models.*
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
|
|
||||||
class ChatAiViewModel(
|
|
||||||
val userId: String,
|
|
||||||
) : BaseChatViewModel() {
|
|
||||||
var userProfile by mutableStateOf<AccountProfileEntity?>(null)
|
|
||||||
var chatNotification by mutableStateOf<ChatNotification?>(null)
|
|
||||||
override fun init(context: Context) {
|
|
||||||
// 获取用户信息
|
|
||||||
viewModelScope.launch {
|
|
||||||
val resp = userService.getUserProfile(userId)
|
|
||||||
userProfile = resp
|
|
||||||
myProfile = accountService.getMyAccountProfile()
|
|
||||||
|
|
||||||
RegistListener(context)
|
|
||||||
|
|
||||||
// 获取会话信息,然后加载历史消息
|
|
||||||
getOneConversation {
|
|
||||||
fetchHistoryMessage(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取通知信息
|
|
||||||
val notiStrategy = ChatState.getStrategyByTargetTrtcId(resp.trtcUserId)
|
|
||||||
chatNotification = notiStrategy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getConversationParams(): Triple<String, Int, Boolean> {
|
|
||||||
return Triple(userProfile?.trtcUserId ?: userId, ConversationType.SINGLE_CHAT, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getLogTag(): String {
|
|
||||||
return "ChatAiViewModel"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun handleNewMessage(message: Message, context: Context): Boolean {
|
|
||||||
// 只处理当前聊天对象的消息
|
|
||||||
val currentChatUserId = userProfile?.trtcUserId
|
|
||||||
val currentUserId = com.aiosman.ravenow.AppState.profile?.trtcUserId
|
|
||||||
|
|
||||||
if (currentChatUserId != null && currentUserId != null) {
|
|
||||||
// 检查消息是否来自当前聊天对象,且不是自己发送的消息
|
|
||||||
return (message.sendID == currentChatUserId || message.sendID == currentUserId) &&
|
|
||||||
message.sendID != currentUserId
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getReceiverInfo(): Pair<String?, String?> {
|
|
||||||
return Pair(userProfile?.trtcUserId, null) // (recvID, groupID)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getMessageAvatar(message: Message): String? {
|
|
||||||
return if (message.sendID == com.aiosman.ravenow.AppState.profile?.trtcUserId) {
|
|
||||||
myProfile?.avatar
|
|
||||||
} else {
|
|
||||||
userProfile?.avatar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessageSentSuccess(message: String, sentMessage: Message?) {
|
|
||||||
// AI聊天特有的处理逻辑
|
|
||||||
sendChatAiMessage(myProfile?.trtcUserId!!, userProfile?.trtcUserId!!, message)
|
|
||||||
createGroup2ChatAi(userProfile?.trtcUserId!!, "ai_group")
|
|
||||||
}
|
|
||||||
fun createGroup2ChatAi(
|
|
||||||
trtcUserId: String,
|
|
||||||
groupName: String,
|
|
||||||
) {
|
|
||||||
// OpenIM 不支持会话分组功能,这里可以留空或者使用其他方式实现
|
|
||||||
Log.d("ChatAiViewModel", "OpenIM 不支持会话分组功能")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendChatAiMessage(
|
|
||||||
fromTrtcUserId: String,
|
|
||||||
toTrtcUserId: String,
|
|
||||||
message: String,
|
|
||||||
) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val response = ApiClient.api.sendChatAiMessage(SendChatAiRequestBody(fromTrtcUserId = fromTrtcUserId,toTrtcUserId = toTrtcUserId,message = message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateNotificationStrategy(strategy: String) {
|
|
||||||
userProfile?.let {
|
|
||||||
val result = ChatState.updateChatNotification(it.id, strategy)
|
|
||||||
chatNotification = result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val notificationStrategy get() = chatNotification?.strategy ?: "default"
|
|
||||||
}
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.chat
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.SheetState
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.LocalNavController
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
|
||||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatSettingScreen() {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
var showThemeSheet by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(appColors.secondaryBackground)
|
|
||||||
) {
|
|
||||||
StatusBarSpacer()
|
|
||||||
Box(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) {
|
|
||||||
NoticeScreenHeader(title = stringResource(R.string.chat_settings), moreIcon = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
|
||||||
SettingCard(
|
|
||||||
title = stringResource(R.string.chat_theme_settings),
|
|
||||||
onClick = { showThemeSheet = true }
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
SettingCard(
|
|
||||||
title = stringResource(R.string.report),
|
|
||||||
onClick = { /* TODO: 跳转举报 */ }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showThemeSheet) {
|
|
||||||
ThemePickerSheet(onClose = { showThemeSheet = false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SettingCard(title: String, onClick: () -> Unit) {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(appColors.background)
|
|
||||||
.clickable { onClick() }
|
|
||||||
.padding(horizontal = 12.dp, vertical = 14.dp)
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
color = appColors.text,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.W500,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.rave_now_nav_right),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = appColors.text,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun ThemePickerSheet(onClose: () -> Unit) {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
val configuration = LocalConfiguration.current
|
|
||||||
val screenHeight = configuration.screenHeightDp.dp
|
|
||||||
val sheetHeight = screenHeight * 0.9f
|
|
||||||
var previewUrl by remember { mutableStateOf<String?>(null) }
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = onClose,
|
|
||||||
sheetState = sheetState,
|
|
||||||
containerColor = appColors.secondaryBackground,
|
|
||||||
dragHandle = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(sheetHeight),
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 4.dp, bottom = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.size(24.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.custom_background),
|
|
||||||
color = appColors.text,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.W600,
|
|
||||||
modifier = Modifier.weight(1f).padding(start = 90.dp),
|
|
||||||
)
|
|
||||||
IconButton(onClick = onClose) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.rider_pro_close),
|
|
||||||
contentDescription = "close",
|
|
||||||
tint = appColors.text
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从相册选择
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(appColors.background)
|
|
||||||
.clickable { /* TODO: 打开相册选择 */ }
|
|
||||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(R.string.select_from_gallery), color = appColors.text, fontSize = 15.sp, modifier = Modifier.weight(1f))
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.group_info_edit),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = appColors.text,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Text(text = stringResource(R.string.featured_backgrounds), color = appColors.text, fontSize = 12.sp)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
val presets = remember {
|
|
||||||
listOf(
|
|
||||||
"https://picsum.photos/seed/ai1/400/600",
|
|
||||||
"https://picsum.photos/seed/ai2/400/600",
|
|
||||||
"https://picsum.photos/seed/ai3/400/600",
|
|
||||||
"https://picsum.photos/seed/ai4/400/600",
|
|
||||||
"https://picsum.photos/seed/ai5/400/600",
|
|
||||||
"https://picsum.photos/seed/ai6/400/600",
|
|
||||||
"https://picsum.photos/seed/ai7/400/600",
|
|
||||||
"https://picsum.photos/seed/ai8/400/600",
|
|
||||||
"https://picsum.photos/seed/ai9/400/600",
|
|
||||||
"https://picsum.photos/seed/ai10/400/600",
|
|
||||||
"https://picsum.photos/seed/ai11/400/600",
|
|
||||||
"https://picsum.photos/seed/ai12/400/600",
|
|
||||||
"https://picsum.photos/seed/ai13/400/600",
|
|
||||||
"https://picsum.photos/seed/ai14/400/600",
|
|
||||||
"https://picsum.photos/seed/ai15/400/600",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyVerticalGrid(
|
|
||||||
columns = GridCells.Fixed(3),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
items(presets) { url ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { previewUrl = url }
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(appColors.decentBackground)
|
|
||||||
) {
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = url,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.aspectRatio(3f / 4f),
|
|
||||||
contentDescription = "preset",
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
Text(
|
|
||||||
text = "Heart Drive",
|
|
||||||
color = appColors.text,
|
|
||||||
fontSize = 12.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 预览自定义背景弹窗
|
|
||||||
if (previewUrl != null) {
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = { previewUrl = null },
|
|
||||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
|
||||||
containerColor = appColors.secondaryBackground,
|
|
||||||
dragHandle = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(sheetHeight)
|
|
||||||
) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = previewUrl!!,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentDescription = "preview_bg",
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.CenterHorizontally)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 16.dp)
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(R.string.previewing_custom_background), color = Color.White, fontSize = 15.sp)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Column(modifier = Modifier.padding(8.dp)) {
|
|
||||||
Row {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(24.dp))
|
|
||||||
.background(Color.White)
|
|
||||||
.padding(horizontal = 14.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(R.string.each_theme_unique_experience), color = Color.Black, fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Row {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(24.dp))
|
|
||||||
.background(Color.White)
|
|
||||||
.padding(horizontal = 14.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(R.string.select_apply_to_use_theme), color = Color.Black, fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Row {
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(20.dp))
|
|
||||||
.background(Color(0xFF7C4DFF))
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(R.string.tap_cancel_to_preview_other_themes), color = Color.White, fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
// 底部按钮
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
.padding(bottom = 60.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(Color.White)
|
|
||||||
.clickable { previewUrl = null }
|
|
||||||
.padding(vertical = 12.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(R.string.cancel), color = Color.Black, fontSize = 14.sp)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.size(12.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(
|
|
||||||
brush = Brush.horizontalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color(0xFFEE2A33),
|
|
||||||
Color(0xFFD80264),
|
|
||||||
Color(0xFF664C92)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.clickable {
|
|
||||||
previewUrl?.let { url ->
|
|
||||||
com.aiosman.ravenow.AppStore.saveChatBackgroundUrl(url)
|
|
||||||
previewUrl = null
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(vertical = 12.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(R.string.moment_ai_apply), color = Color.White, fontSize = 14.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.chat
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.aiosman.ravenow.ChatState
|
|
||||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
|
||||||
import com.aiosman.ravenow.entity.ChatNotification
|
|
||||||
import io.openim.android.sdk.enums.ConversationType
|
|
||||||
import io.openim.android.sdk.models.Message
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
|
|
||||||
class ChatViewModel(
|
|
||||||
val userId: String,
|
|
||||||
) : BaseChatViewModel() {
|
|
||||||
var userProfile by mutableStateOf<AccountProfileEntity?>(null)
|
|
||||||
var chatNotification by mutableStateOf<ChatNotification?>(null)
|
|
||||||
override fun init(context: Context) {
|
|
||||||
// 获取用户信息
|
|
||||||
viewModelScope.launch {
|
|
||||||
val resp = userService.getUserProfile(userId)
|
|
||||||
userProfile = resp
|
|
||||||
myProfile = accountService.getMyAccountProfile()
|
|
||||||
|
|
||||||
RegistListener(context)
|
|
||||||
|
|
||||||
// 获取会话信息,然后加载历史消息
|
|
||||||
getOneConversation {
|
|
||||||
fetchHistoryMessage(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取通知信息
|
|
||||||
val notiStrategy = ChatState.getStrategyByTargetTrtcId(resp.trtcUserId)
|
|
||||||
chatNotification = notiStrategy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getConversationParams(): Triple<String, Int, Boolean> {
|
|
||||||
return Triple(userProfile?.trtcUserId ?: userId, ConversationType.SINGLE_CHAT, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getLogTag(): String {
|
|
||||||
return "ChatViewModel"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun handleNewMessage(message: Message, context: Context): Boolean {
|
|
||||||
// 只处理当前聊天对象的消息
|
|
||||||
val currentChatUserId = userProfile?.trtcUserId
|
|
||||||
val currentUserId = com.aiosman.ravenow.AppState.profile?.trtcUserId
|
|
||||||
|
|
||||||
if (currentChatUserId != null && currentUserId != null) {
|
|
||||||
// 检查消息是否来自当前聊天对象,且不是自己发送的消息
|
|
||||||
return (message.sendID == currentChatUserId || message.sendID == currentUserId) &&
|
|
||||||
message.sendID != currentUserId
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getReceiverInfo(): Pair<String?, String?> {
|
|
||||||
return Pair(userProfile?.trtcUserId, null) // (recvID, groupID)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getMessageAvatar(message: Message): String? {
|
|
||||||
return if (message.sendID == com.aiosman.ravenow.AppState.profile?.trtcUserId) {
|
|
||||||
myProfile?.avatar
|
|
||||||
} else {
|
|
||||||
userProfile?.avatar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateNotificationStrategy(strategy: String) {
|
|
||||||
userProfile?.let {
|
|
||||||
val result = ChatState.updateChatNotification(it.id, strategy)
|
|
||||||
chatNotification = result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val notificationStrategy get() = chatNotification?.strategy ?: "default"
|
|
||||||
}
|
|
||||||
@@ -1,689 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.chat
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.animation.Crossfade
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.ime
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
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.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.Icon
|
|
||||||
import androidx.compose.material.Scaffold
|
|
||||||
import androidx.compose.material.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.platform.SoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
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
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
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.ChatItem
|
|
||||||
import com.aiosman.ravenow.exp.formatChatTime
|
|
||||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
|
||||||
import com.aiosman.ravenow.ui.composables.DropdownMenu
|
|
||||||
import com.aiosman.ravenow.ui.composables.MenuItem
|
|
||||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
|
||||||
import com.aiosman.ravenow.ui.navigateToGroupInfo
|
|
||||||
import com.aiosman.ravenow.utils.NetworkUtils
|
|
||||||
// 临时兼容层 - TODO: 完成 OpenIM 迁移后删除
|
|
||||||
import io.openim.android.sdk.enums.MessageType
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GroupChatScreen(groupId: String,name: String,avatar: String,) {
|
|
||||||
var isMenuExpanded by remember { mutableStateOf(false) }
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val context = LocalNavController.current.context
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
var goToNewCount by remember { mutableStateOf(0) }
|
|
||||||
|
|
||||||
val viewModel = viewModel<GroupChatViewModel>(
|
|
||||||
key = "GroupChatViewModel_$groupId",
|
|
||||||
factory = object : ViewModelProvider.Factory {
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
|
||||||
return GroupChatViewModel(groupId,name,avatar) as T
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
viewModel.init(context = context)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
viewModel.UnRegistListener()
|
|
||||||
viewModel.clearUnRead()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
var inBottom by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
LaunchedEffect(listState) {
|
|
||||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
|
|
||||||
.collect { index ->
|
|
||||||
if (index == listState.layoutInfo.totalItemsCount - 1) {
|
|
||||||
coroutineScope.launch {
|
|
||||||
viewModel.onLoadMore(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(listState) {
|
|
||||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index }
|
|
||||||
.collect { index ->
|
|
||||||
inBottom = index == 0
|
|
||||||
if (index == 0) {
|
|
||||||
goToNewCount = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(viewModel.goToNew) {
|
|
||||||
if (viewModel.goToNew) {
|
|
||||||
if (inBottom) {
|
|
||||||
listState.scrollToItem(0)
|
|
||||||
} else {
|
|
||||||
goToNewCount++
|
|
||||||
}
|
|
||||||
viewModel.goToNew = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
topBar = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(AppColors.background)
|
|
||||||
) {
|
|
||||||
StatusBarSpacer()
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 16.dp, horizontal = 16.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(AppColors.text)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
|
|
||||||
if (viewModel.groupInfo?.groupAvatar?.isNotEmpty() == true) {
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = viewModel.groupInfo!!.groupAvatar,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(35.dp)
|
|
||||||
.clip(RoundedCornerShape(15.dp)),
|
|
||||||
contentDescription = "群聊头像"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = viewModel.groupName,
|
|
||||||
style = TextStyle(
|
|
||||||
color = AppColors.text,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
|
|
||||||
),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.rider_pro_more_horizon),
|
|
||||||
modifier = Modifier
|
|
||||||
.size(28.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
navController.navigateToGroupInfo(groupId)
|
|
||||||
},
|
|
||||||
contentDescription = "更多",
|
|
||||||
colorFilter = ColorFilter.tint(AppColors.text)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
bottomBar = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.imePadding()
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(1.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
GroupChatInput(
|
|
||||||
onSendImage = { uri ->
|
|
||||||
uri?.let {
|
|
||||||
if (NetworkUtils.isNetworkAvailable(context)) {
|
|
||||||
viewModel.sendImageMessage(it, context)
|
|
||||||
} else {
|
|
||||||
android.widget.Toast.makeText(context, "网络连接异常,请检查网络设置", android.widget.Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { message ->
|
|
||||||
viewModel.sendMessage(message, context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) { paddingValues ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(AppColors.background)
|
|
||||||
.padding(paddingValues)
|
|
||||||
) {
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
reverseLayout = true,
|
|
||||||
verticalArrangement = Arrangement.Top
|
|
||||||
) {
|
|
||||||
val chatList = groupMessagesByTime(viewModel.getDisplayChatList(), viewModel)
|
|
||||||
items(chatList.size, key = { index -> chatList[index].msgId + UUID.randomUUID().toString()}) { index ->
|
|
||||||
val item = chatList[index]
|
|
||||||
Column {
|
|
||||||
if (item.showTimeDivider) {
|
|
||||||
val calendar = java.util.Calendar.getInstance()
|
|
||||||
calendar.timeInMillis = item.timestamp
|
|
||||||
Text(
|
|
||||||
text = calendar.time.formatChatTime(context),
|
|
||||||
style = TextStyle(
|
|
||||||
color = AppColors.secondaryText,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// 获取上一个item的userId,用于判断是否显示头像和昵称
|
|
||||||
val previousItem = if (index < chatList.size - 1) chatList[index + 1] else null
|
|
||||||
val showAvatarAndNickname = previousItem?.userId != item.userId
|
|
||||||
GroupChatItem(
|
|
||||||
item = item,
|
|
||||||
currentUserId = viewModel.myProfile?.trtcUserId!!,
|
|
||||||
showAvatarAndNickname = showAvatarAndNickname
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (goToNewCount > 0) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.padding(bottom = 16.dp, end = 16.dp)
|
|
||||||
.shadow(4.dp, shape = RoundedCornerShape(16.dp))
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(AppColors.background)
|
|
||||||
.padding(8.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
coroutineScope.launch {
|
|
||||||
listState.scrollToItem(0)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "${goToNewCount} 条新消息",
|
|
||||||
style = TextStyle(
|
|
||||||
color = AppColors.text,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GroupChatSelfItem(item: ChatItem) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = androidx.compose.ui.Alignment.End,
|
|
||||||
) {
|
|
||||||
/* Text(
|
|
||||||
text = item.nickname,
|
|
||||||
style = TextStyle(
|
|
||||||
color = Color.Gray,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(bottom = 2.dp)
|
|
||||||
)
|
|
||||||
*/
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(
|
|
||||||
min = 20.dp,
|
|
||||||
max = (if (item.messageType == MessageType.TEXT) 250.dp else 150.dp)
|
|
||||||
)
|
|
||||||
.clip(RoundedCornerShape(20.dp))
|
|
||||||
.background(Color(0xFF6246FF))
|
|
||||||
.padding(
|
|
||||||
vertical = (if (item.messageType == MessageType.TEXT) 8.dp else 0.dp),
|
|
||||||
horizontal = (if (item.messageType == MessageType.TEXT) 16.dp else 0.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
) {
|
|
||||||
when (item.messageType) {
|
|
||||||
MessageType.TEXT -> {
|
|
||||||
Text(
|
|
||||||
text = item.message,
|
|
||||||
style = TextStyle(
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Start
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
MessageType.PICTURE -> {
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = item.imageList[1].url,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentDescription = "image"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
Text(
|
|
||||||
text = "不支持的消息类型",
|
|
||||||
style = TextStyle(
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/*Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.clip(RoundedCornerShape(24.dp))
|
|
||||||
) {
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = item.avatar,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentDescription = "avatar"
|
|
||||||
)
|
|
||||||
}*/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GroupChatOtherItem(item: ChatItem, showAvatarAndNickname: Boolean = true) {
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.Start,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
if (showAvatarAndNickname) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.clip(RoundedCornerShape(24.dp))
|
|
||||||
) {
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = item.avatar.replace("storage/avatars/", "/avatar/"),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentDescription = "avatar"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
} else {
|
|
||||||
// 当不显示头像时,添加左边距以保持消息对齐
|
|
||||||
Spacer(modifier = Modifier.width(36.dp))
|
|
||||||
}
|
|
||||||
Column {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(
|
|
||||||
min = 20.dp,
|
|
||||||
max = (if (item.messageType == MessageType.TEXT) 250.dp else 150.dp)
|
|
||||||
)
|
|
||||||
.clip(RoundedCornerShape(20.dp))
|
|
||||||
.background(AppColors.bubbleBackground)
|
|
||||||
.padding(
|
|
||||||
vertical = (if (item.messageType == MessageType.TEXT) 8.dp else 0.dp),
|
|
||||||
horizontal = (if (item.messageType == MessageType.TEXT) 16.dp else 0.dp)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
when (item.messageType) {
|
|
||||||
MessageType.TEXT -> {
|
|
||||||
Text(
|
|
||||||
text = item.message,
|
|
||||||
style = TextStyle(
|
|
||||||
color = AppColors.text,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Start
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
MessageType.PICTURE -> {
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = item.imageList[1].url,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentDescription = "image"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
Text(
|
|
||||||
text = "不支持的消息类型",
|
|
||||||
style = TextStyle(
|
|
||||||
color = AppColors.text,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showAvatarAndNickname) {
|
|
||||||
Text(
|
|
||||||
text = item.nickname,
|
|
||||||
style = TextStyle(
|
|
||||||
color = AppColors.secondaryText,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(bottom = 2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GroupChatItem(item: ChatItem, currentUserId: String, showAvatarAndNickname: Boolean = true) {
|
|
||||||
val isCurrentUser = item.userId == currentUserId
|
|
||||||
|
|
||||||
// 管理员消息显示特殊布局
|
|
||||||
if (item.userId == "administrator") {
|
|
||||||
GroupChatAdminItem(item)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据是否是当前用户显示不同样式
|
|
||||||
when (item.userId) {
|
|
||||||
currentUserId -> GroupChatSelfItem(item)
|
|
||||||
else -> GroupChatOtherItem(item, showAvatarAndNickname)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GroupChatAdminItem(item: ChatItem) {
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 50.dp, vertical = 8.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.padding(vertical = 8.dp, horizontal = 16.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = item.message,
|
|
||||||
style = TextStyle(
|
|
||||||
color = AppColors.secondaryText,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
),
|
|
||||||
maxLines = Int.MAX_VALUE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GroupChatInput(
|
|
||||||
onSendImage: (Uri?) -> Unit = {},
|
|
||||||
onSend: (String) -> Unit = {},
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val navigationBarHeight = with(LocalDensity.current) {
|
|
||||||
WindowInsets.navigationBars.getBottom(this).toDp()
|
|
||||||
}
|
|
||||||
var keyboardController by remember { mutableStateOf<SoftwareKeyboardController?>(null) }
|
|
||||||
var isKeyboardOpen by remember { mutableStateOf(false) }
|
|
||||||
var text by remember { mutableStateOf("") }
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
val inputBarHeight by animateDpAsState(
|
|
||||||
targetValue = if (isKeyboardOpen) 8.dp else (navigationBarHeight + 8.dp),
|
|
||||||
animationSpec = tween(
|
|
||||||
durationMillis = 300,
|
|
||||||
easing = androidx.compose.animation.core.LinearEasing
|
|
||||||
), label = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
LaunchedEffect(isKeyboardOpen) {
|
|
||||||
inputBarHeight
|
|
||||||
}
|
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
val windowInsets = WindowInsets.ime
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val softwareKeyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
val currentDensity by rememberUpdatedState(density)
|
|
||||||
|
|
||||||
LaunchedEffect(windowInsets.getBottom(currentDensity)) {
|
|
||||||
if (windowInsets.getBottom(currentDensity) <= 0) {
|
|
||||||
focusManager.clearFocus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val imagePickUpLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.StartActivityForResult()
|
|
||||||
) {
|
|
||||||
if (it.resultCode == Activity.RESULT_OK) {
|
|
||||||
val uri = it.data?.data
|
|
||||||
onSendImage(uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box( modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),){
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(20.dp))
|
|
||||||
.background(appColors.decentBackground)
|
|
||||||
.padding(start = 16.dp, end = 8.dp, top = 2.dp, bottom = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
) {
|
|
||||||
BasicTextField(
|
|
||||||
value = text,
|
|
||||||
onValueChange = {
|
|
||||||
text = it
|
|
||||||
},
|
|
||||||
textStyle = TextStyle(
|
|
||||||
color = appColors.text,
|
|
||||||
fontSize = 16.sp
|
|
||||||
),
|
|
||||||
cursorBrush = SolidColor(appColors.text),
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 8.dp)
|
|
||||||
.onFocusChanged { focusState ->
|
|
||||||
isKeyboardOpen = focusState.isFocused
|
|
||||||
}
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
awaitPointerEventScope {
|
|
||||||
keyboardController = softwareKeyboardController
|
|
||||||
awaitFirstDown().also {
|
|
||||||
keyboardController?.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onDone = {
|
|
||||||
keyboardController?.hide()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Crossfade(
|
|
||||||
targetState = text.isNotEmpty(), animationSpec = tween(500),
|
|
||||||
label = ""
|
|
||||||
) { isNotEmpty ->
|
|
||||||
val alpha by animateFloatAsState(
|
|
||||||
targetValue = if (isNotEmpty) 1f else 0.5f,
|
|
||||||
animationSpec = tween(300)
|
|
||||||
)
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.mipmap.btn),
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.alpha(alpha)
|
|
||||||
.noRippleClickable {
|
|
||||||
if (text.isNotEmpty()) {
|
|
||||||
if (NetworkUtils.isNetworkAvailable(context)) {
|
|
||||||
onSend(text)
|
|
||||||
text = ""
|
|
||||||
} else {
|
|
||||||
android.widget.Toast.makeText(context, "网络连接异常,请检查网络设置", android.widget.Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun groupMessagesByTime(chatList: List<ChatItem>, viewModel: GroupChatViewModel): List<ChatItem> {
|
|
||||||
for (i in chatList.indices) {
|
|
||||||
if (i == 0) {
|
|
||||||
viewModel.showTimestampMap[chatList[i].msgId] = false
|
|
||||||
chatList[i].showTimeDivider = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val currentMessage = chatList[i]
|
|
||||||
val timeDiff = currentMessage.timestamp - chatList[i - 1].timestamp
|
|
||||||
if (-timeDiff > 10 * 60 * 1000) {
|
|
||||||
viewModel.showTimestampMap[currentMessage.msgId] = true
|
|
||||||
currentMessage.showTimeDivider = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chatList
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.chat
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.aiosman.ravenow.AppStore
|
|
||||||
import com.aiosman.ravenow.data.api.ApiClient
|
|
||||||
import com.aiosman.ravenow.data.api.SendChatAiRequestBody
|
|
||||||
import io.openim.android.sdk.enums.ConversationType
|
|
||||||
import io.openim.android.sdk.models.*
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class GroupChatViewModel(
|
|
||||||
val groupId: String,
|
|
||||||
val name: String,
|
|
||||||
val avatar: String,
|
|
||||||
) : BaseChatViewModel() {
|
|
||||||
var groupInfo by mutableStateOf<GroupInfo?>(null)
|
|
||||||
|
|
||||||
// 群聊特有属性
|
|
||||||
var memberCount by mutableStateOf(0)
|
|
||||||
var groupName by mutableStateOf("")
|
|
||||||
var groupAvatar by mutableStateOf("")
|
|
||||||
|
|
||||||
data class GroupInfo(
|
|
||||||
val groupId: String,
|
|
||||||
val groupName: String,
|
|
||||||
val groupAvatar: String,
|
|
||||||
val memberCount: Int,
|
|
||||||
val ownerId: String
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun init(context: Context) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
getGroupInfo()
|
|
||||||
myProfile = accountService.getMyAccountProfile()
|
|
||||||
RegistListener(context)
|
|
||||||
|
|
||||||
// 获取会话信息,然后加载历史消息
|
|
||||||
getOneConversation {
|
|
||||||
fetchHistoryMessage(context)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("GroupChatViewModel", "初始化失败: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getGroupInfo() {
|
|
||||||
try {
|
|
||||||
val response = ApiClient.api.getRoomDetail(trtcId = groupId)
|
|
||||||
val room = response.body()?.data
|
|
||||||
groupInfo = room?.let {
|
|
||||||
GroupInfo(
|
|
||||||
groupId = groupId,
|
|
||||||
groupName = it.name,
|
|
||||||
groupAvatar = if (it.avatar.isNullOrEmpty()) {
|
|
||||||
val groupIdBase64 = Base64.encodeToString(
|
|
||||||
groupId.toByteArray(),
|
|
||||||
Base64.NO_WRAP
|
|
||||||
)
|
|
||||||
"${ApiClient.RETROFIT_URL}group/avatar?groupIdBase64=$groupIdBase64&token=${AppStore.token}"
|
|
||||||
} else {
|
|
||||||
"${ApiClient.BASE_API_URL}/outside${it.avatar}?token=${AppStore.token}"
|
|
||||||
},
|
|
||||||
memberCount = it.userCount,
|
|
||||||
ownerId = it.creator.userId
|
|
||||||
)
|
|
||||||
} ?: GroupInfo(
|
|
||||||
groupId = groupId,
|
|
||||||
groupName = name,
|
|
||||||
groupAvatar = avatar,
|
|
||||||
memberCount = 0,
|
|
||||||
ownerId = ""
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("GroupChatViewModel", "加载群信息失败: ${e.message}", e)
|
|
||||||
groupInfo = GroupInfo(
|
|
||||||
groupId = groupId,
|
|
||||||
groupName = name,
|
|
||||||
groupAvatar = avatar,
|
|
||||||
memberCount = 0,
|
|
||||||
ownerId = ""
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
groupName = groupInfo?.groupName ?: ""
|
|
||||||
groupAvatar = groupInfo?.groupAvatar ?: ""
|
|
||||||
memberCount = groupInfo?.memberCount ?: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getConversationParams(): Triple<String, Int, Boolean> {
|
|
||||||
// 根据群组类型决定ConversationType,这里假设是普通群聊
|
|
||||||
return Triple(groupId, ConversationType.GROUP_CHAT, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getLogTag(): String {
|
|
||||||
return "GroupChatViewModel"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun handleNewMessage(message: Message, context: Context): Boolean {
|
|
||||||
// 检查是否是当前群聊的消息
|
|
||||||
return message.groupID == groupId
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getReceiverInfo(): Pair<String?, String?> {
|
|
||||||
return Pair(null, groupId) // (recvID, groupID)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getMessageAvatar(message: Message): String? {
|
|
||||||
// 群聊中,如果是自己发送的消息显示自己的头像,否则为null(由ChatItem处理)
|
|
||||||
return if (message.sendID == com.aiosman.ravenow.AppState.profile?.trtcUserId) {
|
|
||||||
myProfile?.avatar
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessageSentSuccess(message: String, sentMessage: Message?) {
|
|
||||||
// 群聊特有的处理逻辑
|
|
||||||
sendChatAiMessage(message = message, trtcGroupId = groupId)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun sendChatAiMessage(
|
|
||||||
trtcGroupId: String,
|
|
||||||
message: String,
|
|
||||||
) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val response = ApiClient.api.sendChatAiMessage(SendChatAiRequestBody(trtcGroupId = trtcGroupId,message = message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.comment.notice
|
|
||||||
|
|
||||||
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.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.paging.LoadState
|
|
||||||
import androidx.paging.compose.collectAsLazyPagingItems
|
|
||||||
import com.aiosman.ravenow.AppState
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.LocalNavController
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.entity.CommentEntity
|
|
||||||
import com.aiosman.ravenow.exp.timeAgo
|
|
||||||
import com.aiosman.ravenow.ui.NavigationRoute
|
|
||||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
|
||||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
|
||||||
import com.aiosman.ravenow.ui.navigateToPost
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import com.aiosman.ravenow.utils.NetworkUtils
|
|
||||||
import com.aiosman.ravenow.ui.network.NetworkErrorContent
|
|
||||||
@Composable
|
|
||||||
fun CommentNoticeScreen(includeStatusBarPadding: Boolean = true){
|
|
||||||
val viewModel = viewModel<CommentNoticeListViewModel>(
|
|
||||||
key = "CommentNotice",
|
|
||||||
factory = object : ViewModelProvider.Factory {
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
|
||||||
return CommentNoticeListViewModel() as T
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
val context = LocalContext.current
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
viewModel.initData(context)
|
|
||||||
}
|
|
||||||
var dataFlow = viewModel.commentItemsFlow
|
|
||||||
var comments = dataFlow.collectAsLazyPagingItems()
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
|
|
||||||
StatusBarMaskLayout(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(color = AppColors.background)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
darkIcons = !AppState.darkMode,
|
|
||||||
maskBoxBackgroundColor = AppColors.background,
|
|
||||||
includeStatusBarPadding = includeStatusBarPadding
|
|
||||||
) {
|
|
||||||
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current)
|
|
||||||
|
|
||||||
if (!isNetworkAvailable) {
|
|
||||||
NetworkErrorContent(
|
|
||||||
onReload = {
|
|
||||||
viewModel.initData(context, force = true)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if (comments.itemCount == 0 && comments.loadState.refresh is LoadState.NotLoading) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(top = 149.dp),
|
|
||||||
contentAlignment = Alignment.TopCenter
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
androidx.compose.foundation.Image(
|
|
||||||
painter = painterResource(id = R.mipmap.invalid_name_5),
|
|
||||||
contentDescription = "No Comment",
|
|
||||||
modifier = Modifier
|
|
||||||
.size(width = 181.dp, height = 153.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(9.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.no_one_pinged_yet),
|
|
||||||
color = AppColors.text,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.W600
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.background(color = AppColors.background)
|
|
||||||
) {
|
|
||||||
items(comments.itemCount) { index ->
|
|
||||||
comments[index]?.let { comment ->
|
|
||||||
CommentNoticeItem(comment) {
|
|
||||||
viewModel.updateReadStatus(comment.id)
|
|
||||||
viewModel.viewModelScope.launch {
|
|
||||||
var highlightCommentId = comment.id
|
|
||||||
comment.parentCommentId?.let {
|
|
||||||
highlightCommentId = it
|
|
||||||
}
|
|
||||||
navController.navigateToPost(
|
|
||||||
id = comment.post!!.id,
|
|
||||||
highlightCommentId = highlightCommentId,
|
|
||||||
initImagePagerIndex = 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// handle load error
|
|
||||||
when {
|
|
||||||
comments.loadState.append is LoadState.Loading -> {
|
|
||||||
item {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(64.dp)
|
|
||||||
.padding(16.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
modifier = Modifier.width(160.dp),
|
|
||||||
color = AppColors.main
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
comments.loadState.append is LoadState.Error -> {
|
|
||||||
item {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(64.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
comments.retry()
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Load comment error, click to retry",
|
|
||||||
color = AppColors.text
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Spacer(modifier = Modifier.height(72.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CommentNoticeItem(
|
|
||||||
commentItem: CommentEntity,
|
|
||||||
onPostClick: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val context = LocalContext.current
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
val commentPrefix = stringResource(R.string.comment_notice)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(vertical = 12.dp)
|
|
||||||
) {
|
|
||||||
// 左侧头像区域
|
|
||||||
CustomAsyncImage(
|
|
||||||
context = context,
|
|
||||||
imageUrl = commentItem.avatar,
|
|
||||||
contentDescription = commentItem.name,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.noRippleClickable {
|
|
||||||
navController.navigate(
|
|
||||||
NavigationRoute.AccountProfile.route.replace(
|
|
||||||
"{id}",
|
|
||||||
commentItem.author.toString()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 右侧内容区域
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(start = 8.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
onPostClick()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
// 主要信息列
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = commentItem.name,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = AppColors.text,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
// 评论内容行
|
|
||||||
Row {
|
|
||||||
var text = commentItem.comment
|
|
||||||
if (commentItem.parentCommentId != null) {
|
|
||||||
text = "Reply you: $text"
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "$commentPrefix $text",
|
|
||||||
fontSize = 14.sp,
|
|
||||||
maxLines = 1,
|
|
||||||
color = AppColors.secondaryText,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = commentItem.date.timeAgo(context),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = AppColors.secondaryText,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
// 右侧帖子图片
|
|
||||||
commentItem.post?.let {
|
|
||||||
Box {
|
|
||||||
CustomAsyncImage(
|
|
||||||
context = context,
|
|
||||||
imageUrl = it.images[0].thumbnail,
|
|
||||||
contentDescription = "Post Image",
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
)
|
|
||||||
// 未读指示器
|
|
||||||
if (commentItem.unread) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(AppColors.main, CircleShape)
|
|
||||||
.size(12.dp)
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.composables
|
|
||||||
|
|
||||||
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.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
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.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
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.entity.AgentEntity
|
|
||||||
import com.aiosman.ravenow.entity.MomentEntity
|
|
||||||
import com.aiosman.ravenow.exp.timeAgo
|
|
||||||
import com.aiosman.ravenow.ui.NavigationRoute
|
|
||||||
import com.aiosman.ravenow.ui.index.tabs.message.MessageListViewModel
|
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AgentCard(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
agentEntity: AgentEntity,
|
|
||||||
onClick: () -> Unit = {},
|
|
||||||
onAvatarClick: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
val context = LocalContext.current
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(AppColors.background)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.padding(start = 0.dp, end = 0.dp, top = 16.dp, bottom = 8.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
onClick ()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(RoundedCornerShape(40.dp))
|
|
||||||
.noRippleClickable {
|
|
||||||
onAvatarClick()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
CustomAsyncImage(
|
|
||||||
context,
|
|
||||||
agentEntity.avatar,
|
|
||||||
contentDescription = agentEntity.openId,
|
|
||||||
modifier = Modifier.size(40.dp),
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
defaultRes = com.aiosman.ravenow.R.mipmap.group_copy
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(start = 12.dp, end = 12.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(22.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
textAlign = TextAlign.Start,
|
|
||||||
text = agentEntity.title,
|
|
||||||
color = AppColors.text,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
style = TextStyle(fontWeight = FontWeight.W700)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(21.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
modifier = Modifier,
|
|
||||||
text = agentEntity.desc,
|
|
||||||
color = AppColors.text,
|
|
||||||
maxLines = 1,
|
|
||||||
fontSize = 12.sp
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
//MomentPostLocation(momentEntity.location)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.composables
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.aiosman.ravenow.AppState
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.ui.composables.toolbar.CollapsingToolbarScaffoldScopeInstance.align
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AgentCreatedSuccessIndicator() {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
if (AppState.agentCreatedSuccess) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(bottom = 70.dp),
|
|
||||||
contentAlignment = Alignment.BottomCenter
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(150.dp)
|
|
||||||
.height(40.dp)
|
|
||||||
.background(appColors.text.copy(alpha = 0.5f), shape = RoundedCornerShape(15.dp)),
|
|
||||||
contentAlignment = Alignment.CenterStart
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.mipmap.bars_x_buttons_home_n_copy_2),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
tint = Color.Unspecified
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(7.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.create_success),
|
|
||||||
color = appColors.background,
|
|
||||||
fontSize = 13.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
delay(3000)
|
|
||||||
AppState.agentCreatedSuccess = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
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
|
|
||||||
import androidx.compose.ui.platform.debugInspectorInfo
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 防抖点击修饰符
|
|
||||||
* @param enabled 是否启用点击
|
|
||||||
* @param debounceTime 防抖时间(毫秒),默认500ms
|
|
||||||
* @param onClick 点击回调
|
|
||||||
*/
|
|
||||||
fun Modifier.debouncedClickable(
|
|
||||||
enabled: Boolean = true,
|
|
||||||
debounceTime: Long = 500L,
|
|
||||||
onClick: () -> Unit
|
|
||||||
): Modifier = composed(
|
|
||||||
inspectorInfo = debugInspectorInfo {
|
|
||||||
name = "debouncedClickable"
|
|
||||||
properties["enabled"] = enabled
|
|
||||||
properties["debounceTime"] = debounceTime
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
var isClickable by remember { mutableStateOf(true) }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
clickable(
|
|
||||||
enabled = enabled && isClickable,
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
indication = null
|
|
||||||
) {
|
|
||||||
if (isClickable) {
|
|
||||||
isClickable = false
|
|
||||||
onClick()
|
|
||||||
scope.launch {
|
|
||||||
delay(debounceTime)
|
|
||||||
isClickable = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 防抖点击修饰符(带涟漪效果)
|
|
||||||
* @param enabled 是否启用点击
|
|
||||||
* @param debounceTime 防抖时间(毫秒),默认500ms
|
|
||||||
* @param onClick 点击回调
|
|
||||||
*/
|
|
||||||
fun Modifier.debouncedClickableWithRipple(
|
|
||||||
enabled: Boolean = true,
|
|
||||||
debounceTime: Long = 500L,
|
|
||||||
onClick: () -> Unit
|
|
||||||
): Modifier = composed(
|
|
||||||
inspectorInfo = debugInspectorInfo {
|
|
||||||
name = "debouncedClickableWithRipple"
|
|
||||||
properties["enabled"] = enabled
|
|
||||||
properties["debounceTime"] = debounceTime
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
var isClickable by remember { mutableStateOf(true) }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
clickable(
|
|
||||||
enabled = enabled && isClickable,
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
) {
|
|
||||||
if (isClickable) {
|
|
||||||
isClickable = false
|
|
||||||
onClick()
|
|
||||||
scope.launch {
|
|
||||||
delay(debounceTime)
|
|
||||||
isClickable = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用防抖处理器
|
|
||||||
* 可以用于任何需要防抖的场景
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberDebouncer(debounceTime: Long = 500L): ((() -> Unit) -> Unit) {
|
|
||||||
var isExecuting by remember { mutableStateOf(false) }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
return remember {
|
|
||||||
{ action ->
|
|
||||||
if (!isExecuting) {
|
|
||||||
isExecuting = true
|
|
||||||
action()
|
|
||||||
scope.launch {
|
|
||||||
delay(debounceTime)
|
|
||||||
isExecuting = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 防抖状态管理器
|
|
||||||
* 可以手动控制防抖状态
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberDebouncedState(
|
|
||||||
debounceTime: Long = 500L
|
|
||||||
): Triple<Boolean, () -> Unit, () -> Unit> {
|
|
||||||
var isDebouncing by remember { mutableStateOf(false) }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val startDebounce: () -> Unit = remember {
|
|
||||||
{
|
|
||||||
isDebouncing = true
|
|
||||||
scope.launch {
|
|
||||||
delay(debounceTime)
|
|
||||||
isDebouncing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val reset = remember {
|
|
||||||
{
|
|
||||||
isDebouncing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Triple(isDebouncing, startDebounce, reset)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 防抖的导航处理器
|
|
||||||
* 专门用于导航操作的防抖
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberDebouncedNavigation(
|
|
||||||
debounceTime: Long = 1000L
|
|
||||||
): ((() -> Unit) -> Unit) {
|
|
||||||
var isNavigating by remember { mutableStateOf(false) }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
return remember {
|
|
||||||
{ navigation ->
|
|
||||||
if (!isNavigating) {
|
|
||||||
isNavigating = true
|
|
||||||
try {
|
|
||||||
navigation()
|
|
||||||
} finally {
|
|
||||||
scope.launch {
|
|
||||||
delay(debounceTime)
|
|
||||||
isNavigating = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.composables
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
|
||||||
|
|
||||||
data class MenuItem(
|
|
||||||
val title: String,
|
|
||||||
val icon: Int,
|
|
||||||
val action: () -> Unit
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DropdownMenu(
|
|
||||||
expanded: Boolean = false,
|
|
||||||
menuItems: List<MenuItem> = emptyList(),
|
|
||||||
width: Int? = null,
|
|
||||||
onDismissRequest: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
|
|
||||||
MaterialTheme(
|
|
||||||
colorScheme = MaterialTheme.colorScheme.copy(
|
|
||||||
surface = AppColors.secondaryBackground,
|
|
||||||
onSurface = AppColors.text,
|
|
||||||
outline = AppColors.divider,
|
|
||||||
),
|
|
||||||
shapes = MaterialTheme.shapes.copy(
|
|
||||||
extraSmall = RoundedCornerShape(12.dp),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
androidx.compose.material3.DropdownMenu(
|
|
||||||
expanded = expanded,
|
|
||||||
onDismissRequest = onDismissRequest,
|
|
||||||
modifier = Modifier
|
|
||||||
.let {
|
|
||||||
if (width != null) it.width(width.dp) else it
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
for (item in menuItems) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(vertical = 14.dp, horizontal = 24.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
item.action()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
item.title,
|
|
||||||
fontWeight = FontWeight.W500,
|
|
||||||
color = AppColors.text,
|
|
||||||
)
|
|
||||||
if (width != null) {
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
} else {
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
}
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = item.icon),
|
|
||||||
contentDescription = "",
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
tint = AppColors.text
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.composables
|
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
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.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
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.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.material.Icon
|
|
||||||
import androidx.compose.material.Text
|
|
||||||
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.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.platform.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
|
|
||||||
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.R
|
|
||||||
import com.aiosman.ravenow.entity.CommentEntity
|
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun EditCommentBottomModal(
|
|
||||||
replyComment: CommentEntity? = null,
|
|
||||||
autoFocus: Boolean = false,
|
|
||||||
onSend: (String) -> Unit = {},
|
|
||||||
) {
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
var text by remember { mutableStateOf("") }
|
|
||||||
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
val context = LocalContext.current
|
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
|
|
||||||
LaunchedEffect(autoFocus) {
|
|
||||||
if (autoFocus) {
|
|
||||||
// 延迟一下,确保输入框已经渲染完成
|
|
||||||
kotlinx.coroutines.delay(150)
|
|
||||||
focusRequester.requestFocus()
|
|
||||||
// 显示键盘
|
|
||||||
keyboardController?.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(AppColors.background)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.Top
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f)
|
|
||||||
.clip(RoundedCornerShape(20.dp))
|
|
||||||
.background(AppColors.inputBackground)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
BasicTextField(
|
|
||||||
value = text,
|
|
||||||
onValueChange = {
|
|
||||||
text = it
|
|
||||||
},
|
|
||||||
cursorBrush = SolidColor(AppColors.text),
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.focusRequester(focusRequester),
|
|
||||||
textStyle = TextStyle(
|
|
||||||
color = AppColors.text,
|
|
||||||
fontWeight = FontWeight.Normal
|
|
||||||
),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.CenterStart
|
|
||||||
) {
|
|
||||||
innerTextField()
|
|
||||||
if (text.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = if (replyComment == null) {
|
|
||||||
stringResource(R.string.post_comment_hint)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.reply_to_user, replyComment.name ?: "")
|
|
||||||
},
|
|
||||||
color = AppColors.text.copy(alpha = 0.3f), // 30%透明度
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.mipmap.btn),
|
|
||||||
contentDescription = "Send",
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.padding(top = 13.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
if (text.isNotEmpty()) {
|
|
||||||
onSend(text)
|
|
||||||
text = ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tint = Color.Unspecified
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(navBarHeight))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.composables
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
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.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 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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CustomAsyncImage 组件使用说明:
|
|
||||||
*
|
|
||||||
* @param context 上下文,可选
|
|
||||||
* @param imageUrl 图片URL或Bitmap对象
|
|
||||||
* @param contentDescription 图片描述
|
|
||||||
* @param modifier 修饰符
|
|
||||||
* @param blurHash 模糊哈希值(暂未使用)
|
|
||||||
* @param placeholderRes 加载时显示的占位符图片资源ID
|
|
||||||
* @param errorRes 加载失败时显示的错误图片资源ID
|
|
||||||
* @param defaultRes 当imageUrl为空或null时显示的默认图片资源ID(优先级最高)
|
|
||||||
* @param contentScale 图片缩放模式
|
|
||||||
*
|
|
||||||
* 使用示例:
|
|
||||||
* CustomAsyncImage(
|
|
||||||
* imageUrl = "https://example.com/image.jpg",
|
|
||||||
* contentDescription = "用户头像",
|
|
||||||
* defaultRes = R.mipmap.default_avatar,
|
|
||||||
* placeholderRes = R.mipmap.loading_placeholder,
|
|
||||||
* errorRes = R.mipmap.error_image
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberImageBitmap(imageUrl: String, imageLoader: ImageLoader): Bitmap? {
|
|
||||||
val context = LocalContext.current
|
|
||||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(imageUrl) {
|
|
||||||
val request = ImageRequest.Builder(context)
|
|
||||||
.data(imageUrl)
|
|
||||||
.crossfade(true)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val result = withContext(Dispatchers.IO) {
|
|
||||||
val successResult = imageLoader.execute(request) as? SuccessResult
|
|
||||||
successResult?.let {
|
|
||||||
val drawable = it.image.asDrawable(context.resources)
|
|
||||||
drawable.toBitmap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bitmap = result
|
|
||||||
}
|
|
||||||
|
|
||||||
return bitmap
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CustomAsyncImage(
|
|
||||||
context: Context? = null,
|
|
||||||
imageUrl: Any?,
|
|
||||||
contentDescription: String?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
blurHash: String? = null,
|
|
||||||
@DrawableRes
|
|
||||||
placeholderRes: Int? = null,
|
|
||||||
@DrawableRes
|
|
||||||
errorRes: Int? = null,
|
|
||||||
@DrawableRes
|
|
||||||
defaultRes: Int? = null,
|
|
||||||
contentScale: ContentScale = ContentScale.Crop,
|
|
||||||
showShimmer: Boolean = true
|
|
||||||
) {
|
|
||||||
val localContext = LocalContext.current
|
|
||||||
|
|
||||||
// 使用remember来缓存ImageLoader,避免重复创建
|
|
||||||
val imageLoader = remember { getImageLoader(context ?: localContext) }
|
|
||||||
|
|
||||||
// 处理 imageUrl 为 null 或空字符串的情况
|
|
||||||
if (imageUrl == null || imageUrl == "") {
|
|
||||||
// 优先使用 defaultRes,然后是 placeholderRes
|
|
||||||
val fallbackRes = defaultRes ?: placeholderRes
|
|
||||||
if (fallbackRes != null) {
|
|
||||||
if (showShimmer) {
|
|
||||||
SimpleShimmer(modifier = modifier) {
|
|
||||||
Image(
|
|
||||||
painter = androidx.compose.ui.res.painterResource(fallbackRes),
|
|
||||||
contentDescription = contentDescription,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = contentScale
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Image(
|
|
||||||
painter = androidx.compose.ui.res.painterResource(fallbackRes),
|
|
||||||
contentDescription = contentDescription,
|
|
||||||
modifier = modifier,
|
|
||||||
contentScale = contentScale
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理 Bitmap 类型
|
|
||||||
if (imageUrl is Bitmap) {
|
|
||||||
if (showShimmer) {
|
|
||||||
SimpleShimmer(modifier = modifier) {
|
|
||||||
Image(
|
|
||||||
bitmap = imageUrl.asImageBitmap(),
|
|
||||||
contentDescription = contentDescription,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = contentScale
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Image(
|
|
||||||
bitmap = imageUrl.asImageBitmap(),
|
|
||||||
contentDescription = contentDescription,
|
|
||||||
modifier = modifier,
|
|
||||||
contentScale = contentScale
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理字符串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(ctx)
|
|
||||||
.data(imageUrl)
|
|
||||||
.crossfade(200)
|
|
||||||
.memoryCachePolicy(coil3.request.CachePolicy.ENABLED)
|
|
||||||
.diskCachePolicy(coil3.request.CachePolicy.ENABLED)
|
|
||||||
.apply {
|
|
||||||
// 设置占位符图片
|
|
||||||
placeholderImage?.let { placeholder(it) }
|
|
||||||
// 设置错误时显示的图片
|
|
||||||
errorImage?.let { error(it) }
|
|
||||||
}
|
|
||||||
.build(),
|
|
||||||
contentDescription = contentDescription,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = contentScale,
|
|
||||||
imageLoader = imageLoader,
|
|
||||||
onLoading = { isLoading = true },
|
|
||||||
onSuccess = { isLoading = false },
|
|
||||||
onError = { isLoading = false }
|
|
||||||
)
|
|
||||||
|
|
||||||
// 只在加载时显示shimmer
|
|
||||||
if (isLoading) {
|
|
||||||
SimpleShimmer(modifier = Modifier.fillMaxSize()) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
AsyncImage(
|
|
||||||
model = ImageRequest.Builder(ctx)
|
|
||||||
.data(imageUrl)
|
|
||||||
.crossfade(200)
|
|
||||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
|
||||||
.diskCachePolicy(CachePolicy.ENABLED)
|
|
||||||
.apply {
|
|
||||||
// 设置占位符图片
|
|
||||||
placeholderImage?.let { placeholder(it) }
|
|
||||||
// 设置错误时显示的图片
|
|
||||||
errorImage?.let { error(it) }
|
|
||||||
}
|
|
||||||
.build(),
|
|
||||||
contentDescription = contentDescription,
|
|
||||||
modifier = modifier,
|
|
||||||
contentScale = contentScale,
|
|
||||||
imageLoader = imageLoader
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
使用示例:
|
|
||||||
|
|
||||||
1. 基本使用(带默认图片):
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = user.avatar,
|
|
||||||
contentDescription = "用户头像",
|
|
||||||
defaultRes = R.mipmap.default_avatar
|
|
||||||
)
|
|
||||||
|
|
||||||
2. 完整配置:
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = "https://example.com/image.jpg",
|
|
||||||
contentDescription = "产品图片",
|
|
||||||
defaultRes = R.mipmap.default_product,
|
|
||||||
placeholderRes = R.mipmap.loading_placeholder,
|
|
||||||
errorRes = R.mipmap.error_image,
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
|
|
||||||
3. 处理空URL:
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = "", // 空字符串会显示默认图片
|
|
||||||
contentDescription = "头像",
|
|
||||||
defaultRes = R.mipmap.default_avatar
|
|
||||||
)
|
|
||||||
|
|
||||||
4. 处理Bitmap:
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = bitmapObject, // Bitmap对象会直接显示
|
|
||||||
contentDescription = "裁剪后的图片"
|
|
||||||
)
|
|
||||||
*/
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.composables
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.*
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从左到右扫描的Shimmer加载效果
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ShimmerEffect(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
val infiniteTransition = rememberInfiniteTransition()
|
|
||||||
val translateAnim by infiniteTransition.animateFloat(
|
|
||||||
initialValue = 0f,
|
|
||||||
targetValue = 1000f,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(
|
|
||||||
durationMillis = 1200,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
),
|
|
||||||
repeatMode = RepeatMode.Restart
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val shimmerColors = listOf(
|
|
||||||
Color.LightGray.copy(alpha = 0.6f),
|
|
||||||
Color.LightGray.copy(alpha = 0.2f),
|
|
||||||
Color.LightGray.copy(alpha = 0.6f),
|
|
||||||
)
|
|
||||||
|
|
||||||
val brush = Brush.linearGradient(
|
|
||||||
colors = shimmerColors,
|
|
||||||
start = Offset.Zero,
|
|
||||||
end = Offset(x = translateAnim, y = translateAnim)
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
|
||||||
content()
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(brush)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 带圆角的Shimmer占位符
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ShimmerPlaceholder(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
cornerRadius: Float = 8f
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.background(
|
|
||||||
color = Color.LightGray.copy(alpha = 0.3f),
|
|
||||||
shape = RoundedCornerShape(cornerRadius.dp)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.composables
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.*
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 简单的从左到右扫描的Shimmer加载效果
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SimpleShimmer(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
val infiniteTransition = rememberInfiniteTransition()
|
|
||||||
val translateAnim by infiniteTransition.animateFloat(
|
|
||||||
initialValue = 0f,
|
|
||||||
targetValue = 1000f,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = tween(
|
|
||||||
durationMillis = 1200,
|
|
||||||
easing = FastOutSlowInEasing
|
|
||||||
),
|
|
||||||
repeatMode = RepeatMode.Restart
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val shimmerColors = listOf(
|
|
||||||
Color.LightGray.copy(alpha = 0.6f),
|
|
||||||
Color.LightGray.copy(alpha = 0.2f),
|
|
||||||
Color.LightGray.copy(alpha = 0.6f),
|
|
||||||
)
|
|
||||||
|
|
||||||
val brush = Brush.linearGradient(
|
|
||||||
colors = shimmerColors,
|
|
||||||
start = Offset.Zero,
|
|
||||||
end = Offset(x = translateAnim, y = translateAnim)
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
|
||||||
content()
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(brush)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 带圆角的Shimmer占位符
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SimpleShimmerPlaceholder(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
cornerRadius: Float = 8f
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.background(
|
|
||||||
color = Color.LightGray.copy(alpha = 0.3f),
|
|
||||||
shape = RoundedCornerShape(cornerRadius.dp)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.composables
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.wrapContentWidth
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
/**
|
|
||||||
* 可复用的标签页组件
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun TabItem(
|
|
||||||
text: String,
|
|
||||||
isSelected: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.wrapContentWidth()
|
|
||||||
.noRippleClickable { onClick() },
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
fontSize = 15.sp,
|
|
||||||
color = if (isSelected) AppColors.tabSelectedText else AppColors.tabUnselectedText,
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(if (isSelected) AppColors.tabSelectedBackground else AppColors.tabUnselectedBackground)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 标签页之间的间隔组件
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun TabSpacer() {
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun UnderlineTabItem(
|
|
||||||
text: String,
|
|
||||||
isSelected: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
|
|
||||||
// 动画化字体大小和padding
|
|
||||||
val animatedFontSize by animateFloatAsState(
|
|
||||||
targetValue = if (isSelected) 17f else 15f,
|
|
||||||
animationSpec = tween(durationMillis = 200),
|
|
||||||
label = "fontSize"
|
|
||||||
)
|
|
||||||
val animatedPadding by animateDpAsState(
|
|
||||||
targetValue = if (isSelected) 20.dp else 16.dp,
|
|
||||||
animationSpec = tween(durationMillis = 200),
|
|
||||||
label = "padding"
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.noRippleClickable { onClick() },
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
// 文本层 - 始终居中,不受下划线影响
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
fontSize = animatedFontSize.sp,
|
|
||||||
fontWeight = FontWeight.ExtraBold,
|
|
||||||
color = if (isSelected) AppColors.text else AppColors.text.copy(alpha = 0.6f),
|
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = animatedPadding)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 下划线层 - 固定在底部,不影响文本位置
|
|
||||||
if (isSelected) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.size(24.dp)
|
|
||||||
.offset(y = (15).dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.mipmap.underline),
|
|
||||||
contentDescription = "selected indicator",
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.composables
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
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.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
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.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.aiosman.ravenow.AppState
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
|
||||||
|
|
||||||
private val LabelTextColor = Color(red = 60f / 255f, green = 60f / 255f, blue = 67f / 255f, alpha = 0.6f)
|
|
||||||
private val HintTextColor = Color(red = 60f / 255f, green = 60f / 255f, blue = 67f / 255f, alpha = 0.3f)
|
|
||||||
private val PasswordIconColor = Color(red = 17f / 255f, green = 12f / 255f, blue = 19f / 255f)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TextInputField(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
text: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
password: Boolean = false,
|
|
||||||
label: String? = null,
|
|
||||||
hint: String? = null,
|
|
||||||
error: String? = null,
|
|
||||||
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 = labelColor,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
modifier = Modifier.padding(start = 8.dp, top = 8.dp, bottom = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(customCornerRadius.dp))
|
|
||||||
.background(backgroundColor)
|
|
||||||
.border(
|
|
||||||
width = 2.dp,
|
|
||||||
color = if (error == null) Color.Transparent else AppColors.error,
|
|
||||||
shape = RoundedCornerShape(customCornerRadius.dp)
|
|
||||||
)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
){
|
|
||||||
leadingIcon?.let {
|
|
||||||
Box(modifier = Modifier.size(24.dp)) {
|
|
||||||
it()
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.size(12.dp))
|
|
||||||
}
|
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
|
||||||
BasicTextField(
|
|
||||||
value = text,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.onFocusChanged { focusState ->
|
|
||||||
isFocused = focusState.isFocused
|
|
||||||
},
|
|
||||||
textStyle = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.W400,
|
|
||||||
color = AppColors.text
|
|
||||||
),
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = if (password) KeyboardType.Password else KeyboardType.Email
|
|
||||||
),
|
|
||||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
|
||||||
singleLine = true,
|
|
||||||
enabled = enabled,
|
|
||||||
cursorBrush = SolidColor(AppColors.text),
|
|
||||||
)
|
|
||||||
if (text.isEmpty() && hint != null) {
|
|
||||||
Text(
|
|
||||||
text = hint,
|
|
||||||
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) {
|
|
||||||
R.drawable.rider_pro_eye
|
|
||||||
} else {
|
|
||||||
R.mipmap.icon_eyes_closed_light
|
|
||||||
}
|
|
||||||
),
|
|
||||||
contentDescription = "Password",
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
showPassword = !showPassword
|
|
||||||
},
|
|
||||||
colorFilter = ColorFilter.tint(iconColor)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = error != null,
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut()
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.mipmap.rider_pro_input_error),
|
|
||||||
contentDescription = "Error",
|
|
||||||
modifier = Modifier.size(8.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(4.dp))
|
|
||||||
AnimatedContent(targetState = error) { targetError ->
|
|
||||||
Text(targetError ?: "", color = AppColors.error, fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.composables.form
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
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.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 水平布局的输入框
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun FormTextInput(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
value: String,
|
|
||||||
label: String? = null,
|
|
||||||
error: String? = null,
|
|
||||||
hint: String? = null,
|
|
||||||
background: Color? = null,
|
|
||||||
onValueChange: (String) -> Unit
|
|
||||||
) {
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(25.dp))
|
|
||||||
.background(background ?: AppColors.inputBackground)
|
|
||||||
.let {
|
|
||||||
if (error != null) {
|
|
||||||
it.border(1.dp, AppColors.error, RoundedCornerShape(24.dp))
|
|
||||||
} else if (background != null && background == Color.White) {
|
|
||||||
// 如果传入白色背景,添加灰色边框
|
|
||||||
it.border(
|
|
||||||
1.dp,
|
|
||||||
Color(red = 124f / 255f, green = 116f / 255f, blue = 128f / 255f, alpha = 0.08f),
|
|
||||||
RoundedCornerShape(25.dp)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(17.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
focusRequester.requestFocus()
|
|
||||||
keyboardController?.show()
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
label?.let {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(100.dp),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.W600,
|
|
||||||
color = AppColors.text
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.mipmap.icons_infor_edit),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(16.dp)
|
|
||||||
.align(Alignment.TopStart),
|
|
||||||
tint = AppColors.text.copy(alpha = 0.4f)
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.width(24.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
if (value.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = hint ?: "",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = AppColors.inputHint
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
BasicTextField(
|
|
||||||
maxLines = 1,
|
|
||||||
value = value,
|
|
||||||
onValueChange = {
|
|
||||||
onValueChange(it)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.focusRequester(focusRequester),
|
|
||||||
singleLine = true,
|
|
||||||
textStyle = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = AppColors.text
|
|
||||||
),
|
|
||||||
cursorBrush = SolidColor(AppColors.text),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = error != null,
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut()
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.mipmap.rider_pro_input_error),
|
|
||||||
contentDescription = "Error",
|
|
||||||
modifier = Modifier.size(8.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(4.dp))
|
|
||||||
AnimatedContent(targetState = error) { targetError ->
|
|
||||||
Text(targetError ?: "", color = AppColors.error, fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.composables.form
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
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.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 垂直布局的输入框
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun FormTextInput2(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
value: String,
|
|
||||||
label: String? = null,
|
|
||||||
error: String? = null,
|
|
||||||
hint: String? = null,
|
|
||||||
background: Color? = null,
|
|
||||||
focusRequester: FocusRequester? = null,
|
|
||||||
onValueChange: (String) -> Unit
|
|
||||||
) {
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
val localFocusRequester = focusRequester ?: remember { FocusRequester() }
|
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier.height(150.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(25.dp))
|
|
||||||
.background(background ?: AppColors.inputBackground)
|
|
||||||
.let {
|
|
||||||
if (error != null) {
|
|
||||||
it.border(1.dp, AppColors.error, RoundedCornerShape(24.dp))
|
|
||||||
} else if (background != null && background == Color.White) {
|
|
||||||
// 如果传入白色背景,添加灰色边框
|
|
||||||
it.border(
|
|
||||||
1.dp,
|
|
||||||
Color(red = 124f / 255f, green = 116f / 255f, blue = 128f / 255f, alpha = 0.08f),
|
|
||||||
RoundedCornerShape(25.dp)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(17.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
localFocusRequester.requestFocus()
|
|
||||||
keyboardController?.show()
|
|
||||||
},
|
|
||||||
|
|
||||||
) {
|
|
||||||
label?.let {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(100.dp),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.W600,
|
|
||||||
color = AppColors.text
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.mipmap.icons_infor_edit),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(16.dp)
|
|
||||||
.align(Alignment.TopStart),
|
|
||||||
tint = AppColors.text.copy(alpha = 0.4f)
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.width(24.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
if (value.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = hint ?: "",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = AppColors.inputHint
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
BasicTextField(
|
|
||||||
maxLines = 6,
|
|
||||||
value = value,
|
|
||||||
onValueChange = {
|
|
||||||
onValueChange(it)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.focusRequester(localFocusRequester),
|
|
||||||
textStyle = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = AppColors.text,
|
|
||||||
lineHeight = 20.sp
|
|
||||||
),
|
|
||||||
cursorBrush = SolidColor(AppColors.text),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = error != null,
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut()
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.mipmap.rider_pro_input_error),
|
|
||||||
contentDescription = "Error",
|
|
||||||
modifier = Modifier.size(8.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(4.dp))
|
|
||||||
AnimatedContent(targetState = error) { targetError ->
|
|
||||||
Text(targetError ?: "", color = AppColors.error, fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.composables.toolbar
|
|
||||||
|
|
||||||
enum class FabPosition {
|
|
||||||
Center,
|
|
||||||
End
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.dialogs
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
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.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.BasicAlertDialog
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
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.rememberCoroutineScope
|
|
||||||
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.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import com.aiosman.ravenow.ConstVars
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.model.UpdateInfo
|
|
||||||
import com.aiosman.ravenow.ui.composables.ActionButton
|
|
||||||
import com.google.firebase.perf.config.RemoteConfigManager.getVersionCode
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun CheckUpdateDialog() {
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
val context = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
|
||||||
var newApkUrl by remember { mutableStateOf("") }
|
|
||||||
var progress by remember { mutableStateOf(0f) }
|
|
||||||
var isDownloading by remember { mutableStateOf(false) } // Add downloading state
|
|
||||||
var message by remember { mutableStateOf("") }
|
|
||||||
var versionName by remember { mutableStateOf("") }
|
|
||||||
fun checkUpdate() {
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val client = OkHttpClient()
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("${ConstVars.BASE_SERVER}/static/update/beta/version.json")
|
|
||||||
.build()
|
|
||||||
val response = client.newCall(request).execute()
|
|
||||||
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
val responseBody = response.body?.string()
|
|
||||||
val updateInfo = Gson().fromJson(responseBody, UpdateInfo::class.java)
|
|
||||||
|
|
||||||
val versionCode = getVersionCode(context)
|
|
||||||
if (updateInfo.versionCode > versionCode) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
message = updateInfo.updateContent
|
|
||||||
versionName = updateInfo.versionName
|
|
||||||
showDialog = true
|
|
||||||
newApkUrl = updateInfo.downloadUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun downloadApk() {
|
|
||||||
isDownloading = true
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(newApkUrl)
|
|
||||||
.build()
|
|
||||||
val client = OkHttpClient()
|
|
||||||
client.newCall(request).execute().use { response ->
|
|
||||||
if (!response.isSuccessful) throw Exception("Unexpected code $response")
|
|
||||||
val body = response.body
|
|
||||||
if (body != null) {
|
|
||||||
val apkFile = File(context.cacheDir, "rider_pro.apk")
|
|
||||||
val totalBytes = body.contentLength()
|
|
||||||
var downloadedBytes = 0L
|
|
||||||
|
|
||||||
apkFile.outputStream().use { output ->
|
|
||||||
body.byteStream().use { input ->
|
|
||||||
val buffer = ByteArray(8 * 1024)
|
|
||||||
var bytesRead: Int
|
|
||||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
|
||||||
output.write(buffer, 0, bytesRead)
|
|
||||||
downloadedBytes += bytesRead
|
|
||||||
progress = downloadedBytes / totalBytes.toFloat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val apkUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile)
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW)
|
|
||||||
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
|
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
|
||||||
context.startActivity(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isDownloading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
checkUpdate()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDialog) {
|
|
||||||
BasicAlertDialog(
|
|
||||||
onDismissRequest = {
|
|
||||||
if (!isDownloading) {
|
|
||||||
showDialog = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 24.dp, end = 24.dp, bottom = 120.dp),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.TopCenter
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(96.dp))
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(32.dp))
|
|
||||||
.background(AppColors.background)
|
|
||||||
.padding(vertical = 32.dp, horizontal = 24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(48.dp))
|
|
||||||
Text(
|
|
||||||
stringResource(id = R.string.update_find_new_version),
|
|
||||||
fontWeight = FontWeight.W900,
|
|
||||||
fontSize = 22.sp,
|
|
||||||
color = AppColors.text
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
Text(
|
|
||||||
versionName,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
color = AppColors.text
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
color = AppColors.text
|
|
||||||
)
|
|
||||||
if (progress > 0) {
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { progress },
|
|
||||||
color = AppColors.main,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(16.dp)),
|
|
||||||
trackColor = AppColors.basicMain
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
ActionButton(
|
|
||||||
text = stringResource(id = R.string.update_later),
|
|
||||||
color = AppColors.text,
|
|
||||||
backgroundColor = AppColors.basicMain,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
fullWidth = false,
|
|
||||||
roundCorner = 16f,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
) {
|
|
||||||
showDialog = false
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
ActionButton(
|
|
||||||
text = stringResource(id = R.string.update_update_now),
|
|
||||||
backgroundColor = AppColors.main,
|
|
||||||
color = AppColors.mainText,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
fullWidth = false,
|
|
||||||
roundCorner = 16f,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
) {
|
|
||||||
downloadApk()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.mipmap.rider_pro_update_header),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.favourite
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
|
||||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
|
||||||
import androidx.compose.material.pullrefresh.pullRefresh
|
|
||||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.paging.compose.collectAsLazyPagingItems
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.LocalNavController
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
|
||||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
|
||||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
|
||||||
import com.aiosman.ravenow.ui.favourite.FavouriteListViewModel.refreshPager
|
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
|
||||||
import com.aiosman.ravenow.ui.navigateToPost
|
|
||||||
import com.aiosman.ravenow.ui.network.NetworkErrorContent
|
|
||||||
import com.aiosman.ravenow.utils.NetworkUtils
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
|
||||||
@Composable
|
|
||||||
fun FavouriteListPage() {
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
val model = FavouriteListViewModel
|
|
||||||
var dataFlow = model.favouriteMomentsFlow
|
|
||||||
var moments = dataFlow.collectAsLazyPagingItems()
|
|
||||||
val context = LocalContext.current
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val state = rememberPullRefreshState(FavouriteListViewModel.isLoading, onRefresh = {
|
|
||||||
model.refreshPager(force = true)
|
|
||||||
})
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
refreshPager()
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize().background(AppColors.background)
|
|
||||||
) {
|
|
||||||
StatusBarSpacer()
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f)
|
|
||||||
.pullRefresh(state)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
|
||||||
) {
|
|
||||||
NoticeScreenHeader(stringResource(R.string.favourites_upper), moreIcon = false)
|
|
||||||
}
|
|
||||||
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current)
|
|
||||||
var moments = dataFlow.collectAsLazyPagingItems()
|
|
||||||
|
|
||||||
if (!isNetworkAvailable) {
|
|
||||||
NetworkErrorContent(
|
|
||||||
onReload = {
|
|
||||||
model.refreshPager(force = true)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if(moments.itemCount == 0) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(top=189.dp),
|
|
||||||
contentAlignment = Alignment.TopCenter
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(
|
|
||||||
id = if (com.aiosman.ravenow.AppState.darkMode) R.mipmap.empty_img
|
|
||||||
else R.mipmap.empty_img),
|
|
||||||
contentDescription = "No favourites",
|
|
||||||
modifier = Modifier.size(181.dp, 153.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(9.dp)) // 调整间距为9dp
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.favourites_null),
|
|
||||||
color = AppColors.text,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.W600
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}else{
|
|
||||||
LazyVerticalGrid(
|
|
||||||
columns = GridCells.Fixed(3),
|
|
||||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
items(moments.itemCount) { idx ->
|
|
||||||
val momentItem = moments[idx] ?: return@items
|
|
||||||
// 获取缩略图URL:优先使用图片,如果没有图片则使用视频缩略图
|
|
||||||
val thumbnailUrl = when {
|
|
||||||
momentItem.images.isNotEmpty() -> momentItem.images[0].thumbnail
|
|
||||||
momentItem.videos != null && momentItem.videos.isNotEmpty() -> {
|
|
||||||
momentItem.videos.first().thumbnailUrl ?: momentItem.videos.first().thumbnailDirectUrl
|
|
||||||
}
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thumbnailUrl == null) return@items
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.aspectRatio(1f)
|
|
||||||
.padding(2.dp)
|
|
||||||
.noRippleClickable {
|
|
||||||
navController.navigateToPost(
|
|
||||||
id = momentItem.id,
|
|
||||||
highlightCommentId = 0,
|
|
||||||
initImagePagerIndex = 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
CustomAsyncImage(
|
|
||||||
imageUrl = thumbnailUrl,
|
|
||||||
contentDescription = "",
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.clip(RoundedCornerShape(8.dp)),
|
|
||||||
context = context
|
|
||||||
)
|
|
||||||
|
|
||||||
val isVideoMoment = momentItem.images.isEmpty() && !momentItem.videos.isNullOrEmpty()
|
|
||||||
|
|
||||||
if (momentItem.images.size > 1 || (momentItem.videos?.size ?: 0) > 1) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(top = 8.dp, end = 8.dp)
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
painter = painterResource(R.drawable.rider_pro_picture_more),
|
|
||||||
contentDescription = "",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isVideoMoment) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(top = 8.dp, end = 8.dp)
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.background(
|
|
||||||
color = Color.Black.copy(alpha = 0.4f),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.PlayArrow,
|
|
||||||
contentDescription = "",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PullRefreshIndicator(
|
|
||||||
FavouriteListViewModel.isLoading,
|
|
||||||
state,
|
|
||||||
Modifier.align(Alignment.TopCenter)
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.follower
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
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.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.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.paging.compose.collectAsLazyPagingItems
|
|
||||||
import com.aiosman.ravenow.AppState
|
|
||||||
import com.aiosman.ravenow.GuestLoginCheckOut
|
|
||||||
import com.aiosman.ravenow.GuestLoginCheckOutScene
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.LocalNavController
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.exp.viewModelFactory
|
|
||||||
import com.aiosman.ravenow.ui.NavigationRoute
|
|
||||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
|
||||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import com.aiosman.ravenow.utils.NetworkUtils
|
|
||||||
import com.aiosman.ravenow.ui.network.NetworkErrorContent
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
|
||||||
@Composable
|
|
||||||
fun FollowerListScreen(userId: Int) {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val model: FollowerListViewModel = viewModel(factory = viewModelFactory {
|
|
||||||
FollowerListViewModel(userId)
|
|
||||||
}, key = "viewModel_${userId}")
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val refreshState = rememberPullRefreshState(model.isLoading, onRefresh = {
|
|
||||||
model.loadData(userId, true)
|
|
||||||
})
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
model.loadData(userId)
|
|
||||||
}
|
|
||||||
StatusBarMaskLayout(
|
|
||||||
modifier = Modifier.background(color = appColors.background).padding(horizontal = 16.dp),
|
|
||||||
darkIcons = !AppState.darkMode,
|
|
||||||
maskBoxBackgroundColor = appColors.background
|
|
||||||
) {
|
|
||||||
var dataFlow = model.usersFlow
|
|
||||||
var users = dataFlow.collectAsLazyPagingItems()
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 16.dp)
|
|
||||||
) {
|
|
||||||
NoticeScreenHeader(stringResource(R.string.followers_upper), moreIcon = false)
|
|
||||||
}
|
|
||||||
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current)
|
|
||||||
|
|
||||||
if (!isNetworkAvailable) {
|
|
||||||
NetworkErrorContent(
|
|
||||||
onReload = {
|
|
||||||
model.loadData(userId, true)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if (users.itemCount == 0) {
|
|
||||||
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_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.awaiting_traveler),
|
|
||||||
color = appColors.text,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.W600,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.padding(horizontal = 24.dp),
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f)
|
|
||||||
.pullRefresh(refreshState)
|
|
||||||
) {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
items(users.itemCount) { index ->
|
|
||||||
users[index]?.let { user ->
|
|
||||||
FollowItem(
|
|
||||||
avatar = user.avatar,
|
|
||||||
nickname = user.nickName,
|
|
||||||
userId = user.id,
|
|
||||||
isFollowing = user.isFollowing
|
|
||||||
) {
|
|
||||||
// 检查游客模式,如果是游客则跳转登录
|
|
||||||
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.FOLLOW_USER)) {
|
|
||||||
navController.navigate(NavigationRoute.Login.route)
|
|
||||||
} else {
|
|
||||||
scope.launch {
|
|
||||||
if (user.isFollowing) {
|
|
||||||
model.unFollowUser(user.id)
|
|
||||||
} else {
|
|
||||||
model.followUser(user.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PullRefreshIndicator(
|
|
||||||
refreshing = model.isLoading,
|
|
||||||
state = refreshState,
|
|
||||||
modifier = Modifier.align(Alignment.TopCenter)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.follower
|
|
||||||
|
|
||||||
class FollowerListViewModel(
|
|
||||||
val userId: Int
|
|
||||||
) : BaseFollowModel() {
|
|
||||||
override var followerId: Int? = userId
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.follower
|
|
||||||
|
|
||||||
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.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
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.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.paging.compose.collectAsLazyPagingItems
|
|
||||||
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.NavigationRoute
|
|
||||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
|
||||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
|
||||||
import com.aiosman.ravenow.ui.composables.FollowButton
|
|
||||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
|
||||||
import com.aiosman.ravenow.ui.network.NetworkErrorContent
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import com.aiosman.ravenow.utils.NetworkUtils
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关注消息列表
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun FollowerNoticeScreen(includeStatusBarPadding: Boolean = true) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
StatusBarMaskLayout(
|
|
||||||
modifier = Modifier.background(color = AppColors.background).padding(horizontal = 16.dp),
|
|
||||||
darkIcons = !AppState.darkMode,
|
|
||||||
maskBoxBackgroundColor = AppColors.background,
|
|
||||||
includeStatusBarPadding = includeStatusBarPadding
|
|
||||||
) {
|
|
||||||
val model = FollowerNoticeViewModel
|
|
||||||
var dataFlow = model.followerItemsFlow
|
|
||||||
var followers = dataFlow.collectAsLazyPagingItems()
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
model.reload()
|
|
||||||
model.updateNotice()
|
|
||||||
}
|
|
||||||
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current)
|
|
||||||
|
|
||||||
if (!isNetworkAvailable) {
|
|
||||||
NetworkErrorContent(
|
|
||||||
onReload = {
|
|
||||||
model.reload(force = true)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if (followers.itemCount == 0) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(top=149.dp),
|
|
||||||
contentAlignment = Alignment.TopCenter
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.mipmap.invalid_name_5),
|
|
||||||
contentDescription = "No Followers",
|
|
||||||
modifier = Modifier
|
|
||||||
.size(width = 181.dp, height = 153.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(9.dp))
|
|
||||||
androidx.compose.material.Text(
|
|
||||||
text = stringResource(R.string.no_one_pinged_yet),
|
|
||||||
color = AppColors.text,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.W600,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.padding(horizontal = 24.dp),
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}else{
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
.background(color = AppColors.background)
|
|
||||||
) {
|
|
||||||
items(followers.itemCount) { index ->
|
|
||||||
followers[index]?.let { follower ->
|
|
||||||
FollowItem(
|
|
||||||
avatar = follower.avatar,
|
|
||||||
nickname = follower.nickname,
|
|
||||||
userId = follower.userId,
|
|
||||||
isFollowing = follower.isFollowing
|
|
||||||
) {
|
|
||||||
scope.launch {
|
|
||||||
model.followUser(follower.userId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FollowItem(
|
|
||||||
avatar: String,
|
|
||||||
nickname: String,
|
|
||||||
userId: Int,
|
|
||||||
isFollowing: Boolean,
|
|
||||||
onFollow: () -> Unit = {}
|
|
||||||
) {
|
|
||||||
val AppColors = LocalAppTheme.current
|
|
||||||
val context = LocalContext.current
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val followText = stringResource(R.string.followed_you)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
// 左侧头像区域
|
|
||||||
CustomAsyncImage(
|
|
||||||
context = context,
|
|
||||||
imageUrl = avatar,
|
|
||||||
contentDescription = nickname,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.noRippleClickable {
|
|
||||||
navController.navigate(
|
|
||||||
NavigationRoute.AccountProfile.route.replace(
|
|
||||||
"{id}",
|
|
||||||
userId.toString()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
// 右侧内容区域
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(start = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = nickname,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = AppColors.text,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = followText,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = AppColors.secondaryText,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!isFollowing && userId != AppState.UserId) {
|
|
||||||
FollowButton(
|
|
||||||
isFollowing = false,
|
|
||||||
) {
|
|
||||||
onFollow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.follower
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
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.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.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.paging.compose.collectAsLazyPagingItems
|
|
||||||
import com.aiosman.ravenow.AppState
|
|
||||||
import com.aiosman.ravenow.GuestLoginCheckOut
|
|
||||||
import com.aiosman.ravenow.GuestLoginCheckOutScene
|
|
||||||
import com.aiosman.ravenow.LocalAppTheme
|
|
||||||
import com.aiosman.ravenow.LocalNavController
|
|
||||||
import com.aiosman.ravenow.R
|
|
||||||
import com.aiosman.ravenow.exp.viewModelFactory
|
|
||||||
import com.aiosman.ravenow.ui.NavigationRoute
|
|
||||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
|
||||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
|
||||||
import com.aiosman.ravenow.ui.network.NetworkErrorContent
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import com.aiosman.ravenow.utils.NetworkUtils
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
|
||||||
@Composable
|
|
||||||
fun FollowingListScreen(userId: Int) {
|
|
||||||
val appColors = LocalAppTheme.current
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val model : FollowingListViewModel = viewModel(factory = viewModelFactory {
|
|
||||||
FollowingListViewModel(userId)
|
|
||||||
}, key = "viewModel_${userId}")
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val refreshState = rememberPullRefreshState(model.isLoading, onRefresh = {
|
|
||||||
model.loadData(userId, true)
|
|
||||||
})
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
model.loadData(userId)
|
|
||||||
}
|
|
||||||
StatusBarMaskLayout(
|
|
||||||
modifier = Modifier.background(color = appColors.background).padding(horizontal = 16.dp),
|
|
||||||
darkIcons = !AppState.darkMode,
|
|
||||||
maskBoxBackgroundColor = appColors.background
|
|
||||||
) {
|
|
||||||
var dataFlow = model.usersFlow
|
|
||||||
var users = dataFlow.collectAsLazyPagingItems()
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 16.dp)
|
|
||||||
) {
|
|
||||||
NoticeScreenHeader(stringResource(R.string.following_upper), moreIcon = false)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current)
|
|
||||||
|
|
||||||
if (!isNetworkAvailable) {
|
|
||||||
NetworkErrorContent(
|
|
||||||
onReload = {
|
|
||||||
model.loadData(userId, true)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if(users.itemCount == 0) {
|
|
||||||
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_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.awaiting_traveler),
|
|
||||||
color = appColors.text,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.W600,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.padding(horizontal = 24.dp),
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}else{
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f)
|
|
||||||
.pullRefresh(refreshState)
|
|
||||||
) {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
items(users.itemCount) { index ->
|
|
||||||
users[index]?.let { user ->
|
|
||||||
FollowItem(
|
|
||||||
avatar = user.avatar,
|
|
||||||
nickname = user.nickName,
|
|
||||||
userId = user.id,
|
|
||||||
isFollowing = user.isFollowing
|
|
||||||
) {
|
|
||||||
// 检查游客模式,如果是游客则跳转登录
|
|
||||||
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.FOLLOW_USER)) {
|
|
||||||
navController.navigate(NavigationRoute.Login.route)
|
|
||||||
} else {
|
|
||||||
scope.launch {
|
|
||||||
if (user.isFollowing) {
|
|
||||||
model.unFollowUser(user.id)
|
|
||||||
} else {
|
|
||||||
model.followUser(user.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PullRefreshIndicator(
|
|
||||||
refreshing = model.isLoading,
|
|
||||||
state = refreshState,
|
|
||||||
modifier = Modifier.align(Alignment.TopCenter)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package com.aiosman.ravenow.ui.follower
|
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.paging.Pager
|
|
||||||
import androidx.paging.PagingConfig
|
|
||||||
import androidx.paging.PagingData
|
|
||||||
import androidx.paging.cachedIn
|
|
||||||
import androidx.paging.map
|
|
||||||
import com.aiosman.ravenow.data.UserServiceImpl
|
|
||||||
import com.aiosman.ravenow.entity.AccountPagingSource
|
|
||||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class FollowingListViewModel(
|
|
||||||
val userId: Int
|
|
||||||
) : BaseFollowModel() {
|
|
||||||
override var followingId: Int? = userId
|
|
||||||
}
|
|
||||||
@@ -1,592 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user