2 Commits

Author SHA1 Message Date
efa96c8e74 修正trtc sdk初始化问题 2024-10-24 17:40:46 +08:00
dbcb4025a6 优化启动速度 2024-10-24 17:17:01 +08:00
1243 changed files with 11377 additions and 56952 deletions

2
.idea/.name generated
View File

@@ -1 +1 @@
RaveNow RiderPro

2
.idea/compiler.xml generated
View File

@@ -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>

View File

@@ -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
View File

@@ -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>

View File

@@ -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
View File

@@ -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
View File

@@ -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">

View File

@@ -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>

View File

@@ -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. 清理所有相关的临时注释

View File

@@ -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)
} }

View File

@@ -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": [],

View File

@@ -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 *;
}

View File

@@ -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)
} }
} }

View File

@@ -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.

View File

@@ -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))
}
}

View File

@@ -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),
)

View File

@@ -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)
}
}

View File

@@ -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")
}

View File

@@ -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)
"[消息]"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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,
)
}

View File

@@ -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()
)
)
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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-DDnull 表示不限制
* @param endTime 结束时间格式YYYY-MM-DDnull 表示不限制
* @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
}
}
}

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -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

View File

@@ -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 }
}
}
}
}

View File

@@ -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 }
}

View File

@@ -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}")
}
}
}
}

View File

@@ -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>? {
// 如果是游客模式且获取我的AgentauthorId为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> {
// 如果是游客模式且获取我的AgentauthorId为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
)
}
}
}

View File

@@ -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
)

View File

@@ -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"]
}
}

View File

@@ -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,
)

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -1,6 +0,0 @@
package com.aiosman.ravenow.event
data class FollowChangeEvent(
val userId: Int,
val isFollow: Boolean
)

View File

@@ -1,7 +0,0 @@
package com.aiosman.ravenow.event
import com.aiosman.ravenow.entity.MomentEntity
data class MomentAddEvent(
val moment:MomentEntity
)

View File

@@ -1,6 +0,0 @@
package com.aiosman.ravenow.event
data class MomentFavouriteChangeEvent(
val postId: Int,
val isFavourite: Boolean
)

View File

@@ -1,7 +0,0 @@
package com.aiosman.ravenow.event
data class MomentLikeChangeEvent(
val postId: Int,
val likeCount: Int?,
val isLike: Boolean
)

View File

@@ -1,5 +0,0 @@
package com.aiosman.ravenow.event
data class MomentRemoveEvent(
val postId: Int
)

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}

View File

@@ -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
)
}
}
}

View File

@@ -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
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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() }
)
}
}

View File

@@ -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)
)
}
}

View File

@@ -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
}
}

View File

@@ -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() }
)
}
}

View File

@@ -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
)
}
}

View File

@@ -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
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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)
}
)
}
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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)
}
}
}
}
}
}
}
}
}

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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))
}
}
}

View File

@@ -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)
)
}
}
}
}
}
}

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}
}
}
}

View File

@@ -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
)
}
}
}
}
}
}

View File

@@ -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))
}
}

View File

@@ -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 = "裁剪后的图片"
)
*/

View File

@@ -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()
}

View File

@@ -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)
)
)
}

View File

@@ -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)
)
)
}

View File

@@ -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()
)
}
}
}
}

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -1,6 +0,0 @@
package com.aiosman.ravenow.ui.composables.toolbar
enum class FabPosition {
Center,
End
}

View File

@@ -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)
)
}
}
}
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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)
)
}
}
}
}

View File

@@ -1,7 +0,0 @@
package com.aiosman.ravenow.ui.follower
class FollowerListViewModel(
val userId: Int
) : BaseFollowModel() {
override var followerId: Int? = userId
}

View File

@@ -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()
}
}
}
}
}

View File

@@ -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)
)
}
}
}
}

View File

@@ -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
}

View File

@@ -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
)
}
}

View File

@@ -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