commit f18ee9e36015c93cf64d40c6fe466a78e5699690
Author: 高帆 <3031465419@qq.com>
Date: Tue Nov 4 14:40:01 2025 +0800
test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..13e3093
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+/.idea/*
+app/release/*
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..39a8bad
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,15 @@
+{
+ // 使用 IntelliSense 了解相关属性。
+ // 悬停以查看现有属性的描述。
+ // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "type": "chrome",
+ "request": "launch",
+ "name": "针对 localhost 启动 Chrome",
+ "url": "http://localhost:8080",
+ "webRoot": "${workspaceFolder}"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md
new file mode 100644
index 0000000..219ab9d
--- /dev/null
+++ b/MIGRATION_GUIDE.md
@@ -0,0 +1,176 @@
+# 腾讯云 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. 清理所有相关的临时注释
diff --git a/README b/README
new file mode 100644
index 0000000..3283e0d
--- /dev/null
+++ b/README
@@ -0,0 +1,4 @@
+签名
+
+使用rider_pro_app文件进行签名
+密码是GFX*&KBg2yuq8mPP
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..3645cee
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,131 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.jetbrains.kotlin.android)
+ id("com.google.gms.google-services")
+ id("com.google.firebase.crashlytics")
+ id("com.google.firebase.firebase-perf")
+
+}
+android {
+ namespace = "com.aiosman.ravenow"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.aiosman.ravenow"
+ minSdk = 24
+ targetSdk = 34
+ versionCode = 1000019
+ versionName = "1.0.000.19"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ addManifestPlaceholders(
+ mapOf(
+ "JPUSH_PKGNAME " to applicationId!!,
+ "JPUSH_APPKEY" to "cbd968cae60346065e03f9d7",
+ "JPUSH_CHANNEL" to "developer-default",
+ )
+
+ )
+ }
+
+ buildTypes {
+ debug {
+ isDebuggable = true
+ }
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.3"
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+
+}
+
+dependencies {
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3.android)
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.paging.compose)
+ implementation(libs.androidx.paging.runtime)
+ implementation(libs.maps.compose)
+ implementation(libs.accompanist.systemuicontroller)
+ implementation(libs.androidx.media3.exoplayer) // 核心播放器
+ implementation(libs.androidx.media3.ui) // UI组件(可选)
+ implementation(libs.androidx.media3.session)
+ implementation(libs.androidx.activity.ktx)
+ implementation(libs.androidx.lifecycle.common.jvm)
+ implementation(libs.googleid)
+ implementation(libs.identity.credential)
+ implementation(libs.androidx.lifecycle.process)
+ implementation(libs.rendering)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+ implementation(libs.androidx.animation)
+ implementation(libs.coil.compose)
+ implementation(libs.coil)
+ implementation(libs.play.services.auth)
+ implementation(libs.kotlin.faker)
+ implementation(libs.androidx.material)
+ implementation(libs.zoomable)
+ implementation(libs.retrofit)
+ implementation(libs.converter.gson)
+ implementation(libs.androidx.credentials)
+ implementation(libs.androidx.credentials.play.services.auth)
+ implementation(libs.jwtdecode)
+
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.crashlytics)
+ implementation(libs.firebase.analytics)
+ implementation(libs.firebase.perf)
+
+ implementation(libs.firebase.messaging.ktx)
+
+ implementation (libs.jpush.google)
+ implementation (libs.im.sdk)
+ 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)
+
+}
+
diff --git a/app/google-services.json b/app/google-services.json
new file mode 100644
index 0000000..35e9cea
--- /dev/null
+++ b/app/google-services.json
@@ -0,0 +1,29 @@
+{
+ "project_info": {
+ "project_number": "987156664714",
+ "project_id": "riderpro-a9b22",
+ "storage_bucket": "riderpro-a9b22.appspot.com"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:987156664714:android:2c29c11b9cd8be78b9f873",
+ "android_client_info": {
+ "package_name": "com.aiosman.ravenow"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": "AIzaSyB9zLZ80Bz6OrAWCCZxM8BK75dWS7RROBM"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..db7313a
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,42 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
+
+# OpenIM SDK ProGuard rules
+-keep class io.openim.android.sdk.** { *; }
+-keep class io.openim.core.** { *; }
+-keepclassmembers class io.openim.android.sdk.** { *; }
+-keepclassmembers class io.openim.core.** { *; }
+
+# Keep OpenIM models and listeners
+-keep class io.openim.android.sdk.models.** { *; }
+-keep class io.openim.android.sdk.listener.** { *; }
+-keep class io.openim.android.sdk.enums.** { *; }
+
+# Keep OpenIM Client and managers
+-keep class io.openim.android.sdk.OpenIMClient { *; }
+-keep class io.openim.android.sdk.manager.** { *; }
+
+# Prevent obfuscation of callback methods
+-keepclassmembers class * implements io.openim.android.sdk.listener.** {
+ public *;
+}
+
diff --git a/app/src/androidTest/java/com/aiosman/ravenow/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/aiosman/ravenow/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..25911e7
--- /dev/null
+++ b/app/src/androidTest/java/com/aiosman/ravenow/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.aiosman.ravenow
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.aiosman.ravenow", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8725683
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/assets/loading.lottie b/app/src/main/assets/loading.lottie
new file mode 100644
index 0000000..fad2046
Binary files /dev/null and b/app/src/main/assets/loading.lottie differ
diff --git a/app/src/main/assets/login.lottie b/app/src/main/assets/login.lottie
new file mode 100644
index 0000000..edd794b
Binary files /dev/null and b/app/src/main/assets/login.lottie differ
diff --git a/app/src/main/assets/login_light.lottie b/app/src/main/assets/login_light.lottie
new file mode 100644
index 0000000..bc69308
Binary files /dev/null and b/app/src/main/assets/login_light.lottie differ
diff --git a/app/src/main/java/com/aiosman/ravenow/AppState.kt b/app/src/main/java/com/aiosman/ravenow/AppState.kt
new file mode 100644
index 0000000..e5aa238
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/AppState.kt
@@ -0,0 +1,238 @@
+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.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 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(LightThemeColors())
+ var googleClientId: String? = null
+ var enableGoogleLogin: Boolean = false
+ var enableChat = false
+ var agentCreatedSuccess by mutableStateOf(false)
+ var chatBackgroundUrl by mutableStateOf(null)
+ suspend fun initWithAccount(scope: CoroutineScope, context: Context) {
+ // 如果是游客模式,使用简化的初始化流程
+ if (AppStore.isGuest) {
+ initWithGuestAccount()
+ 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)
+ }
+
+ /**
+ * 游客模式的简化初始化
+ */
+ private fun initWithGuestAccount() {
+ // 游客模式下,不初始化推送和TRTC
+ // 设置默认的用户信息
+ UserId = 0
+ profile = null
+ enableChat = false
+ Log.d("AppState", "Guest mode initialized without push notifications and TRTC")
+ }
+
+ 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 {
+ 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
+
+ // 清除游客状态
+ AppStore.isGuest = false
+
+ context.stopService(Intent(context, OpenIMService::class.java))
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/ChatState.kt b/app/src/main/java/com/aiosman/ravenow/ChatState.kt
new file mode 100644
index 0000000..2f32d67
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ChatState.kt
@@ -0,0 +1,40 @@
+package com.aiosman.ravenow
+
+import com.aiosman.ravenow.data.ChatService
+import com.aiosman.ravenow.data.ChatServiceImpl
+import com.aiosman.ravenow.entity.ChatNotification
+
+/**
+ * 保存一些关于聊天的状态
+ */
+object ChatState {
+ val chatService: ChatService = ChatServiceImpl()
+ var chatNotificationList = mutableListOf()
+ suspend fun getStrategyByTargetTrtcId(targetTrtcId: String): ChatNotification? {
+ // 先从缓存中查找
+ if (chatNotificationList.isNotEmpty()) {
+ chatNotificationList.forEach {
+ if (it.targetTrtcId == targetTrtcId) {
+ return it
+ }
+ }
+ }
+ // 缓存中没有再从网络获取
+ chatService.getChatNotifications(targetTrtcId)?.let {
+ chatNotificationList.add(it)
+ return it
+ }
+ // 存在未设置策略的情况
+ return null
+ }
+
+ suspend fun updateChatNotification(targetUserId: Int, strategy: String): ChatNotification {
+ val updatedData = chatService.updateChatNotification(targetUserId, strategy)
+ chatNotificationList = chatNotificationList.filter {
+ it.targetUserId != targetUserId
+ }.toMutableList().apply {
+ add(updatedData)
+ }
+ return updatedData
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/Colors.kt b/app/src/main/java/com/aiosman/ravenow/Colors.kt
new file mode 100644
index 0000000..f43c2d3
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/Colors.kt
@@ -0,0 +1,130 @@
+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(0x2E7C7480),
+ 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),
+
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/Const.kt b/app/src/main/java/com/aiosman/ravenow/Const.kt
new file mode 100644
index 0000000..ecf5dc6
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/Const.kt
@@ -0,0 +1,77 @@
+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.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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/ImageListScreen.kt b/app/src/main/java/com/aiosman/ravenow/ImageListScreen.kt
new file mode 100644
index 0000000..0bf658d
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ImageListScreen.kt
@@ -0,0 +1,67 @@
+import android.content.Context
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import coil.compose.rememberAsyncImagePainter
+import coil.request.ImageRequest
+import coil.ImageLoader
+import coil.disk.DiskCache
+import coil.memory.MemoryCache
+
+data class ImageItem(val url: String)
+
+@Composable
+fun ImageListScreen(context: Context, imageList: List) {
+ val imageLoader = getImageLoader(context)
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp)
+ ) {
+ items(imageList) { item ->
+ ImageItem(item, imageLoader, context) // 传递 context 参数
+ }
+ }
+}
+
+@Composable
+fun ImageItem(item: ImageItem, imageLoader: ImageLoader, context: Context) { // 接收 context 参数
+ val painter = rememberAsyncImagePainter(
+ model = ImageRequest.Builder(context) // 使用 context 参数
+ .data(item.url)
+ .crossfade(true)
+ .build(),
+ imageLoader = imageLoader
+ )
+
+ Image(
+ painter = painter,
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp),
+ contentScale = ContentScale.Crop
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+}
+
+fun getImageLoader(context: Context): ImageLoader {
+ return ImageLoader.Builder(context)
+ .memoryCache {
+ MemoryCache.Builder(context)
+ .maxSizePercent(0.25) // 设置内存缓存大小为可用内存的 25%
+ .build()
+ }
+ .diskCache {
+ DiskCache.Builder()
+ .directory(context.cacheDir.resolve("image_cache"))
+ .maxSizePercent(0.02) // 设置磁盘缓存大小为可用存储空间的 2%
+ .build()
+ }
+ .build()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/JpushReciver.kt b/app/src/main/java/com/aiosman/ravenow/JpushReciver.kt
new file mode 100644
index 0000000..043a95b
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/JpushReciver.kt
@@ -0,0 +1,49 @@
+package com.aiosman.ravenow
+
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import cn.jpush.android.api.NotificationMessage
+import cn.jpush.android.service.JPushMessageReceiver
+import com.google.gson.Gson
+import com.google.gson.annotations.SerializedName
+
+data class ActionExtra(
+ @SerializedName("action")
+ val action: String,
+ @SerializedName("postId")
+ val postId: String?,
+ @SerializedName("commentId")
+ val commentId: String?
+)
+
+class JpushReciver : JPushMessageReceiver() {
+ val gson = Gson()
+ override fun onInAppMessageClick(p0: Context?, p1: NotificationMessage?) {
+ super.onInAppMessageClick(p0, p1)
+ // 打开自定义的页面
+ Log.d("JpushReciver", "onInAppMessageClick")
+ }
+
+ override fun onNotifyMessageOpened(context: Context?, message: NotificationMessage) {
+ super.onNotifyMessageOpened(context, message)
+ // 打开自定义的页面
+ Log.d("JpushReciver", "onNotifyMessageOpened")
+ val actionExtra = message.notificationExtras?.let {
+ gson.fromJson(it, ActionExtra::class.java)
+ }
+ val intent = Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ actionExtra?.postId?.let {
+ intent.putExtra("POST_ID", it)
+ }
+ actionExtra?.commentId?.let {
+ intent.putExtra("COMMENT_ID", it)
+ }
+ actionExtra?.action?.let {
+ intent.putExtra("ACTION", it)
+ }
+ context?.startActivity(intent)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/JpushService.kt b/app/src/main/java/com/aiosman/ravenow/JpushService.kt
new file mode 100644
index 0000000..c8e5559
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/JpushService.kt
@@ -0,0 +1,8 @@
+package com.aiosman.ravenow
+
+import cn.jpush.android.service.JCommonService
+
+class JpushService : JCommonService() {
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/MainActivity.kt b/app/src/main/java/com/aiosman/ravenow/MainActivity.kt
new file mode 100644
index 0000000..faf7459
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/MainActivity.kt
@@ -0,0 +1,259 @@
+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.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.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.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
+
+ // 请求通知权限
+ 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)
+
+ if (AppState.darkMode) {
+ window.decorView.setBackgroundColor(android.graphics.Color.BLACK)
+ }
+ 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 {
+ var showSplash by remember { mutableStateOf(true) }
+
+ LaunchedEffect(Unit) {
+ kotlinx.coroutines.delay(2000)
+ showSplash = false
+ }
+
+ if (showSplash) {
+ SplashScreen()
+ } else {
+ CompositionLocalProvider(
+ LocalAppTheme provides AppState.appTheme
+ ) {
+ CheckUpdateDialog()
+ 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? = 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)
+ }
+ }
+}
+
+val LocalNavController = compositionLocalOf {
+ error("NavController not provided")
+}
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+val LocalSharedTransitionScope = compositionLocalOf {
+ error("SharedTransitionScope not provided")
+}
+
+val LocalAnimatedContentScope = compositionLocalOf {
+ error("AnimatedContentScope not provided")
+}
+
+
+val LocalAppTheme = compositionLocalOf {
+ error("AppThemeData not provided")
+}
diff --git a/app/src/main/java/com/aiosman/ravenow/MainActivityLifecycle.kt b/app/src/main/java/com/aiosman/ravenow/MainActivityLifecycle.kt
new file mode 100644
index 0000000..6be79c4
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/MainActivityLifecycle.kt
@@ -0,0 +1,18 @@
+package com.aiosman.ravenow
+
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+
+object MainActivityLifecycle {
+ var isForeground = false
+}
+
+class MainActivityLifecycleObserver : DefaultLifecycleObserver {
+ override fun onStart(owner: LifecycleOwner) {
+ MainActivityLifecycle.isForeground = true
+ }
+
+ override fun onPause(owner: LifecycleOwner) {
+ MainActivityLifecycle.isForeground = false
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/Messaging.kt b/app/src/main/java/com/aiosman/ravenow/Messaging.kt
new file mode 100644
index 0000000..d1a8b21
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/Messaging.kt
@@ -0,0 +1,58 @@
+package com.aiosman.ravenow
+
+import android.content.Context
+import android.util.Log
+import cn.jpush.android.api.JPushInterface
+import com.aiosman.ravenow.data.AccountService
+import com.aiosman.ravenow.data.AccountServiceImpl
+import com.google.android.gms.tasks.OnCompleteListener
+import com.google.firebase.messaging.FirebaseMessaging
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+object Messaging {
+ fun registerDevice(scope: CoroutineScope, context: Context) {
+
+ registerJpush(scope, context)
+// registerFCM(scope)
+ }
+
+ suspend fun unregisterDevice(context: Context) {
+ unregisterJpush(context)
+ }
+
+
+ fun registerJpush(scope: CoroutineScope, context: Context) {
+ val accountService: AccountService = AccountServiceImpl()
+ val regId = JPushInterface.getRegistrationID(context)
+ scope.launch {
+ accountService.registerMessageChannel(client = "jpush", identifier = regId)
+ }
+ }
+
+ private suspend fun unregisterJpush(context: Context) {
+ val accountService: AccountService = AccountServiceImpl()
+ val regId = JPushInterface.getRegistrationID(context)
+ accountService.unregisterMessageChannel(client = "jpush", identifier = regId)
+ }
+
+ fun registerFCM(scope: CoroutineScope) {
+ val accountService: AccountService = AccountServiceImpl()
+ FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task ->
+ if (!task.isSuccessful) {
+ Log.w("Pushing", "Fetching FCM registration token failed", task.exception)
+ return@OnCompleteListener
+ }
+
+ // Get new FCM registration token
+ val token = task.result
+
+ // Log and toast
+ Log.d("Pushing", token)
+ scope.launch {
+ accountService.registerMessageChannel(client = "fcm", token)
+ }
+ })
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/MyFirebaseMessagingService.kt b/app/src/main/java/com/aiosman/ravenow/MyFirebaseMessagingService.kt
new file mode 100644
index 0000000..e3194eb
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/MyFirebaseMessagingService.kt
@@ -0,0 +1,94 @@
+package com.aiosman.ravenow
+
+import android.Manifest
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.util.Log
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+
+val MessageTypeLike = "like"
+fun showLikeNotification(context: Context, title: String, message: String, postId: Int) {
+ val channelId = ConstVars.MOMENT_LIKE_CHANNEL_ID
+// Create an Intent to open the specific activity
+ val intent = Intent(context, MainActivity::class.java).apply {
+ putExtra("POST_ID", postId.toString())
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+
+ // Create a PendingIntent to wrap the Intent
+ val pendingIntent = PendingIntent.getActivity(
+ context,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notificationBuilder = NotificationCompat.Builder(context, channelId)
+ .setSmallIcon(R.drawable.rider_pro_favourite)
+ .setContentTitle(title)
+ .setContentText(message)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setContentIntent(pendingIntent)
+ .setAutoCancel(true)
+
+
+ with(NotificationManagerCompat.from(context)) {
+ if (ActivityCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ // 用户没有授权,不显示通知
+ return
+ }
+ notify(System.currentTimeMillis().toInt(), notificationBuilder.build())
+ }
+}
+
+class MyFirebaseMessagingService : FirebaseMessagingService() {
+ override fun onNewToken(token: String) {
+ Log.d("Pushing", "Refreshed token: $token")
+ super.onNewToken(token)
+ }
+
+ override fun onMessageReceived(remoteMessage: RemoteMessage) {
+
+ if (remoteMessage.data.containsKey("action")) {
+ when (remoteMessage.data["action"]) {
+ MessageTypeLike -> {
+ val postId = remoteMessage.data["postId"]?.toInt() ?: return
+ showLikeNotification(
+ applicationContext,
+ remoteMessage.data["title"] ?: "FCM Message",
+ remoteMessage.data["body"] ?: "No message body",
+ postId
+ )
+ }
+
+ else -> {
+ Log.w("Pushing", "Unknown message type: ${remoteMessage.data["messageType"]}")
+ }
+ }
+ }
+ super.onMessageReceived(remoteMessage)
+ }
+
+ fun prepareIntent(clickAction: String?): Intent {
+ val intent: Intent
+ val isAppInBackground: Boolean = !MainActivityLifecycle.isForeground
+ intent = if (isAppInBackground) {
+ Intent(this, MainActivity::class.java)
+ } else {
+ Intent(clickAction)
+ }
+ return intent
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/OpenIMService.kt b/app/src/main/java/com/aiosman/ravenow/OpenIMService.kt
new file mode 100644
index 0000000..c1edf3b
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/OpenIMService.kt
@@ -0,0 +1,302 @@
+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?) {
+ 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?) {
+ Log.d(TAG, "消息扩展信息变更: $msgID")
+ // 处理消息扩展信息变更
+ }
+
+ override fun onRecvMessageExtensionsDeleted(msgID: String?, list: List?) {
+ Log.d(TAG, "消息扩展信息删除: $msgID")
+ // 处理消息扩展信息删除
+ }
+
+ override fun onRecvMessageExtensionsAdded(msgID: String?, list: List?) {
+ Log.d(TAG, "消息扩展信息添加: $msgID")
+ // 处理消息扩展信息添加
+ }
+
+ override fun onMsgDeleted(message: Message?) {
+ Log.d(TAG, "消息被删除: ${message?.clientMsgID}")
+ // 处理消息删除
+ }
+
+ override fun onRecvOfflineNewMessage(msg: List?) {
+ 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)
+ "[消息]"
+ }
+ }
+}
diff --git a/app/src/main/java/com/aiosman/ravenow/RaveNowApplication.kt b/app/src/main/java/com/aiosman/ravenow/RaveNowApplication.kt
new file mode 100644
index 0000000..d9b948e
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/RaveNowApplication.kt
@@ -0,0 +1,57 @@
+package com.aiosman.ravenow
+
+import android.app.Application
+import android.content.Context
+import android.util.Log
+import com.google.firebase.FirebaseApp
+import com.google.firebase.perf.FirebasePerformance
+
+/**
+ * 自定义Application类,用于处理多进程中的Firebase初始化
+ */
+class RaveNowApplication : Application() {
+
+ 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"
+ }
+ }
+}
diff --git a/app/src/main/java/com/aiosman/ravenow/data/AccountService.kt b/app/src/main/java/com/aiosman/ravenow/data/AccountService.kt
new file mode 100644
index 0000000..4b25c18
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/AccountService.kt
@@ -0,0 +1,645 @@
+package com.aiosman.ravenow.data
+
+import com.aiosman.ravenow.AppState
+import com.aiosman.ravenow.AppStore
+import com.aiosman.ravenow.data.api.ApiClient
+import com.aiosman.ravenow.data.api.AppConfig
+import com.aiosman.ravenow.data.api.CaptchaInfo
+import com.aiosman.ravenow.data.api.ChangePasswordRequestBody
+import com.aiosman.ravenow.data.api.GoogleRegisterRequestBody
+import com.aiosman.ravenow.data.api.GuestLoginRequestBody
+import com.aiosman.ravenow.data.api.LoginUserRequestBody
+import com.aiosman.ravenow.data.api.RegisterMessageChannelRequestBody
+import com.aiosman.ravenow.data.api.RegisterRequestBody
+import com.aiosman.ravenow.data.api.RemoveAccountRequestBody
+import com.aiosman.ravenow.data.api.ResetPasswordRequestBody
+import com.aiosman.ravenow.data.api.TrtcSignResponseBody
+import com.aiosman.ravenow.data.api.UnRegisterMessageChannelRequestBody
+import com.aiosman.ravenow.data.api.UpdateNoticeRequestBody
+import com.aiosman.ravenow.data.DataContainer
+import com.aiosman.ravenow.data.ListContainer
+import com.aiosman.ravenow.data.api.UpdateUserLangRequestBody
+import com.aiosman.ravenow.entity.AccountFavouriteEntity
+import com.aiosman.ravenow.entity.AccountLikeEntity
+import com.aiosman.ravenow.entity.AccountProfileEntity
+import com.aiosman.ravenow.entity.NoticeCommentEntity
+import com.aiosman.ravenow.entity.NoticePostEntity
+import com.aiosman.ravenow.entity.NoticeUserEntity
+import com.google.gson.annotations.SerializedName
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.io.File
+
+/**
+ * 用户资料
+ */
+data class AccountProfile(
+ // 用户ID
+ val id: Int,
+ // 用户名
+ val username: String,
+ // 昵称
+ val nickname: String,
+ // 头像
+ val avatar: String,
+ // 关注数
+ val followingCount: Int,
+ // 粉丝数
+ val followerCount: Int,
+ // 是否关注
+ val isFollowing: Boolean,
+ // 个人简介
+ val bio: String,
+ // 主页背景图
+ val banner: String?,
+ // trtcUserId
+ val trtcUserId: String,
+
+ val openImToken: String?,
+
+ // aiAccount true:ai false:普通用户
+ val aiAccount: Boolean,
+
+ val chatAIId: String,
+) {
+ /**
+ * 转换为Entity
+ */
+ fun toAccountProfileEntity(): AccountProfileEntity {
+ return AccountProfileEntity(
+ id = id,
+ followerCount = followerCount,
+ followingCount = followingCount,
+ nickName = nickname,
+ avatar =
+ "${ApiClient.BASE_SERVER}$avatar",
+ bio = bio,
+ country = "Worldwide",
+ isFollowing = isFollowing,
+ banner = banner.let {
+ if (!it.isNullOrEmpty()) {
+ return@let "${ApiClient.BASE_SERVER}$it"
+ }
+ null
+ },
+ trtcUserId = trtcUserId,
+ chatToken = openImToken,
+ aiAccount = aiAccount,
+ rawAvatar = avatar,
+ chatAIId = chatAIId
+ )
+ }
+}
+
+/**
+ * 消息关联资料
+ */
+data class NoticePost(
+ // 动态ID
+ @SerializedName("id")
+ val id: Int,
+ // 动态内容
+ @SerializedName("textContent")
+ // 动态图片
+ val textContent: String,
+ // 动态图片
+ @SerializedName("images")
+ val images: List,
+ // 动态时间
+ @SerializedName("time")
+ val time: String,
+) {
+ /**
+ * 转换为Entity
+ */
+ fun toNoticePostEntity(): NoticePostEntity {
+ return NoticePostEntity(
+ id = id,
+ textContent = textContent,
+ images = images.map {
+ it.copy(
+ url = "${ApiClient.BASE_SERVER}${it.url}",
+ thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}",
+ blurHash = it.blurHash,
+ width = it.width,
+ height = it.height
+ )
+ },
+ time = ApiClient.dateFromApiString(time)
+ )
+ }
+}
+
+//"comment": {
+// "id": 103,
+// "content": "ppp",
+// "time": "2024-09-08 15:31:37"
+//}
+data class NoticeComment(
+ @SerializedName("id")
+ val id: Int,
+ @SerializedName("content")
+ val content: String,
+ @SerializedName("time")
+ val time: String,
+ @SerializedName("replyComment")
+ val replyComment: NoticeComment?,
+ @SerializedName("postId")
+ val postId: Int,
+ @SerializedName("post")
+ val post: NoticePost?,
+) {
+ fun toNoticeCommentEntity(): NoticeCommentEntity {
+ return NoticeCommentEntity(
+ id = id,
+ content = content,
+ postId = postId,
+ time = ApiClient.dateFromApiString(time),
+ replyComment = replyComment?.toNoticeCommentEntity(),
+ post = post?.toNoticePostEntity()
+ )
+ }
+}
+
+/**
+ * 消息关联用户
+ */
+data class NoticeUser(
+ // 用户ID
+ @SerializedName("id")
+ val id: Int,
+ // 昵称
+ @SerializedName("nickName")
+ val nickName: String,
+ // 头像
+ @SerializedName("avatar")
+ val avatar: String,
+) {
+ /**
+ * 转换为Entity
+ */
+ fun toNoticeUserEntity(): NoticeUserEntity {
+ return NoticeUserEntity(
+ id = id,
+ nickName = nickName,
+ avatar = "${ApiClient.BASE_SERVER}$avatar",
+ )
+ }
+}
+
+/**
+ * 点赞消息通知
+ */
+data class AccountLike(
+ // 是否未读
+ @SerializedName("isUnread")
+ val isUnread: Boolean,
+ // 动态
+ @SerializedName("post")
+ val post: NoticePost?,
+ @SerializedName("comment")
+ val comment: NoticeComment?,
+ // 点赞用户
+ @SerializedName("user")
+ val user: NoticeUser,
+ // 点赞时间
+ @SerializedName("likeTime")
+ val likeTime: String,
+ // 动态ID
+ @SerializedName("postId")
+ val postId: Int,
+) {
+ fun toAccountLikeEntity(): AccountLikeEntity {
+ return AccountLikeEntity(
+ post = post?.toNoticePostEntity(),
+ comment = comment?.toNoticeCommentEntity(),
+ user = user.toNoticeUserEntity(),
+ likeTime = ApiClient.dateFromApiString(likeTime),
+ postId = postId
+ )
+ }
+}
+
+data class AccountFavourite(
+ @SerializedName("isUnread")
+ val isUnread: Boolean,
+ @SerializedName("post")
+ val post: NoticePost,
+ @SerializedName("user")
+ val user: NoticeUser,
+ @SerializedName("favoriteTime")
+ val favouriteTime: String,
+) {
+ fun toAccountFavouriteEntity(): AccountFavouriteEntity {
+ return AccountFavouriteEntity(
+ post = post.toNoticePostEntity(),
+ user = user.toNoticeUserEntity(),
+ favoriteTime = ApiClient.dateFromApiString(favouriteTime)
+ )
+ }
+}
+
+data class AccountFollow(
+ @SerializedName("id")
+ val id: Int,
+ @SerializedName("username")
+ val username: String,
+ @SerializedName("nickname")
+ val nickname: String,
+ @SerializedName("avatar")
+ val avatar: String,
+ @SerializedName("isUnread")
+ val isUnread: Boolean,
+ @SerializedName("userId")
+ val userId: Int,
+ @SerializedName("isFollowing")
+ val isFollowing: Boolean,
+)
+
+//{
+// "likeCount": 0,
+// "followCount": 0,
+// "favoriteCount": 0
+//}
+data class AccountNotice(
+ @SerializedName("likeCount")
+ val likeCount: Int,
+ @SerializedName("followCount")
+ val followCount: Int,
+ @SerializedName("favoriteCount")
+ val favoriteCount: Int,
+ @SerializedName("commentCount")
+ val commentCount: Int,
+)
+
+
+interface AccountService {
+ /**
+ * 获取登录当前用户的资料
+ */
+ suspend fun getMyAccountProfile(): AccountProfileEntity
+
+ /**
+ * 获取登录的用户认证信息
+ */
+ suspend fun getMyAccount(): UserAuth
+
+ /**
+ * 使用用户名密码登录
+ * @param loginName 用户名
+ * @param password 密码
+ * @param captchaInfo 验证码信息
+ */
+ suspend fun loginUserWithPassword(
+ loginName: String,
+ password: String,
+ captchaInfo: CaptchaInfo? = null
+ ): UserAuth
+
+ /**
+ * 使用google登录
+ * @param googleId googleId
+ */
+ suspend fun loginUserWithGoogle(googleId: String): UserAuth
+
+ /**
+ * 游客登录
+ * @param deviceId 设备ID
+ * @param deviceInfo 设备信息
+ */
+ suspend fun guestLogin(deviceId: String, deviceInfo: String? = null): UserAuth
+
+ /**
+ * 退出登录
+ */
+ suspend fun logout()
+
+ /**
+ * 更新用户资料
+ * @param avatar 头像
+ * @param nickName 昵称
+ * @param bio 简介
+ * @param banner 主页背景图
+ */
+ suspend fun updateProfile(
+ avatar: UploadImage?,
+ banner: UploadImage?,
+ nickName: String?,
+ bio: String?
+ )
+
+ /**
+ * 注册用户
+ * @param loginName 用户名
+ * @param password 密码
+ */
+ suspend fun registerUserWithPassword(loginName: String, password: String)
+
+ /**
+ * 使用google账号注册
+ * @param idToken googleIdToken
+ */
+ suspend fun regiterUserWithGoogleAccount(idToken: String)
+
+ /**
+ * 修改密码
+ * @param oldPassword 旧密码
+ * @param newPassword 新密码
+ */
+ suspend fun changeAccountPassword(oldPassword: String, newPassword: String)
+
+ /**
+ * 获取我的点赞通知
+ * @param page 页码
+ * @param pageSize 每页数量
+ */
+ suspend fun getMyLikeNotice(page: Int, pageSize: Int): ListContainer
+
+ /**
+ * 获取我的关注通知
+ * @param page 页码
+ * @param pageSize 每页数量
+ */
+ suspend fun getMyFollowNotice(page: Int, pageSize: Int): ListContainer
+
+ /**
+ * 获取我的收藏通知
+ * @param page 页码
+ * @param pageSize 每页数量
+ */
+ suspend fun getMyFavouriteNotice(page: Int, pageSize: Int): ListContainer
+
+ /**
+ * 获取我的通知信息
+ */
+ suspend fun getMyNoticeInfo(): AccountNotice
+
+ /**
+ * 更新通知信息,更新最后一次查看时间
+ * @param payload 通知信息
+ */
+ suspend fun updateNotice(payload: UpdateNoticeRequestBody)
+
+ /**
+ * 注册消息通道
+ */
+ suspend fun registerMessageChannel(client: String, identifier: String)
+
+ /**
+ * 取消注册消息通道
+ */
+ suspend fun unregisterMessageChannel(client: String, identifier: String)
+
+ /**
+ * 重置密码
+ */
+ suspend fun resetPassword(email: String)
+
+ /**
+ * 更新用户额外信息
+ */
+ suspend fun updateUserExtra(language: String, timeOffset: Int, timezone: String)
+
+ /**
+ * 获取腾讯云TRTC签名
+ */
+ suspend fun getMyTrtcSign(): TrtcSignResponseBody
+
+ suspend fun getAppConfig(): AppConfig
+
+ suspend fun removeAccount(password: String)
+
+ /**
+ * 获取AI智能体列表
+ * @param page 页码
+ * @param pageSize 每页数量
+ */
+ suspend fun getAgent(page: Int, pageSize: Int): retrofit2.Response>>
+
+ /**
+ * 创建群聊
+ * @param name 群聊名称
+ * @param userIds 用户ID列表
+ * @param promptIds AI智能体ID列表
+ */
+ suspend fun createGroupChat(name: String, userIds: List, promptIds: List): retrofit2.Response>
+}
+
+class AccountServiceImpl : AccountService {
+ override suspend fun getMyAccountProfile(): AccountProfileEntity {
+ // 如果已有缓存,直接返回缓存结果
+ AppState.profile?.let { return it }
+
+ // 第一次调用,获取数据并缓存
+ val resp = ApiClient.api.getMyAccount()
+ val body = resp.body() ?: throw ServiceException("Failed to get account")
+ val profile = body.data.toAccountProfileEntity()
+
+ // 缓存结果到共享状态
+ AppState.profile = profile
+ return profile
+ }
+
+ override suspend fun getMyAccount(): UserAuth {
+ val resp = ApiClient.api.checkToken()
+ val body = resp.body() ?: throw ServiceException("Failed to get account")
+ AppState.UserId = body.id
+ return UserAuth(body.id)
+ }
+
+ override suspend fun loginUserWithPassword(
+ loginName: String,
+ password: String,
+ captchaInfo: CaptchaInfo?
+ ): UserAuth {
+ val resp = ApiClient.api.login(LoginUserRequestBody(
+ username = loginName,
+ password = password,
+ captcha = captchaInfo,
+ ))
+ if (!resp.isSuccessful) {
+ parseErrorResponse(resp.errorBody())?.let {
+ throw it.toServiceException()
+ }
+ throw ServiceException("Failed to register")
+ }
+ return UserAuth(0, resp.body()?.token)
+ }
+
+ override suspend fun loginUserWithGoogle(googleId: String): UserAuth {
+ val resp = ApiClient.api.login(LoginUserRequestBody(googleId = googleId))
+ val body = resp.body() ?: throw ServiceException("Failed to login")
+
+ return UserAuth(0, body.token)
+ }
+
+ override suspend fun guestLogin(deviceId: String, deviceInfo: String?): UserAuth {
+ val resp = ApiClient.api.guestLogin(GuestLoginRequestBody(
+ deviceId = deviceId,
+ deviceInfo = deviceInfo
+ ))
+ if (!resp.isSuccessful) {
+ parseErrorResponse(resp.errorBody())?.let {
+ throw it.toServiceException()
+ }
+ throw ServiceException("Failed to guest login")
+ }
+ val body = resp.body() ?: throw ServiceException("Failed to guest login")
+ return UserAuth(0, body.token, isGuest = true)
+ }
+
+ override suspend fun regiterUserWithGoogleAccount(idToken: String) {
+ val resp = ApiClient.api.registerWithGoogle(GoogleRegisterRequestBody(idToken))
+ if (!resp.isSuccessful) {
+ parseErrorResponse(resp.errorBody())?.let {
+ throw it.toServiceException()
+ }
+ throw ServiceException("Failed to register")
+ }
+ }
+
+ override suspend fun logout() {
+ // do nothing
+ }
+
+
+ fun createMultipartBody(file: File, filename: String, name: String): MultipartBody.Part {
+ val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
+ return MultipartBody.Part.createFormData(name, filename, requestFile)
+ }
+
+ override suspend fun updateProfile(
+ avatar: UploadImage?,
+ banner: UploadImage?,
+ nickName: String?,
+ bio: String?
+ ) {
+ val nicknameField: RequestBody? = nickName?.toRequestBody("text/plain".toMediaTypeOrNull())
+ val bioField: RequestBody? = bio?.toRequestBody("text/plain".toMediaTypeOrNull())
+ val avatarField: MultipartBody.Part? = avatar?.let {
+ createMultipartBody(it.file, it.filename, "avatar")
+ }
+ val bannerField: MultipartBody.Part? = banner?.let {
+ createMultipartBody(it.file, it.filename, "banner")
+ }
+ ApiClient.api.updateProfile(avatarField, bannerField, nicknameField, bioField)
+ }
+
+ override suspend fun registerUserWithPassword(loginName: String, password: String) {
+ val resp = ApiClient.api.register(RegisterRequestBody(loginName, password))
+
+ if (!resp.isSuccessful) {
+ parseErrorResponse(resp.errorBody())?.let {
+ throw it.toServiceException()
+ }
+ throw ServiceException("Failed to register")
+ }
+ }
+
+ override suspend fun changeAccountPassword(oldPassword: String, newPassword: String) {
+ val resp = ApiClient.api.changePassword(ChangePasswordRequestBody(oldPassword, newPassword))
+ if (!resp.isSuccessful) {
+ parseErrorResponse(resp.errorBody())?.let {
+ throw it.toServiceException()
+ }
+ throw ServiceException("Failed to change password")
+ }
+ }
+
+ override suspend fun getMyLikeNotice(page: Int, pageSize: Int): ListContainer {
+ val resp = ApiClient.api.getMyLikeNotices(page, pageSize)
+ val body = resp.body() ?: throw ServiceException("Failed to get account")
+ return body
+ }
+
+ override suspend fun getMyFollowNotice(page: Int, pageSize: Int): ListContainer {
+ val resp = ApiClient.api.getMyFollowNotices(page, pageSize)
+ val body = resp.body() ?: throw ServiceException("Failed to get account")
+ return body
+ }
+
+ override suspend fun getMyFavouriteNotice(
+ page: Int,
+ pageSize: Int
+ ): ListContainer {
+ val resp = ApiClient.api.getMyFavouriteNotices(page, pageSize)
+ val body = resp.body() ?: throw ServiceException("Failed to get account")
+ return body
+ }
+
+ override suspend fun getMyNoticeInfo(): AccountNotice {
+ val resp = ApiClient.api.getMyNoticeInfo()
+ val body = resp.body() ?: throw ServiceException("Failed to get account")
+ return body.data
+ }
+
+ override suspend fun updateNotice(payload: UpdateNoticeRequestBody) {
+ ApiClient.api.updateNoticeInfo(payload)
+ }
+
+ override suspend fun registerMessageChannel(client: String, identifier: String) {
+ ApiClient.api.registerMessageChannel(RegisterMessageChannelRequestBody(client, identifier))
+ }
+
+ override suspend fun unregisterMessageChannel(client: String, identifier: String) {
+ ApiClient.api.unRegisterMessageChannel(UnRegisterMessageChannelRequestBody(client, identifier))
+ }
+
+ override suspend fun resetPassword(email: String) {
+ val resp = ApiClient.api.resetPassword(
+ ResetPasswordRequestBody(
+ username = email
+ )
+ )
+ if (!resp.isSuccessful) {
+ parseErrorResponse(resp.errorBody())?.let {
+ throw it.toServiceException()
+ }
+ throw ServiceException("Failed to reset password")
+ }
+ }
+
+ override suspend fun updateUserExtra(language: String, timeOffset: Int, timezone: String) {
+ ApiClient.api.updateUserExtra(UpdateUserLangRequestBody(language, timeOffset, timezone))
+ }
+
+ override suspend fun getMyTrtcSign(): TrtcSignResponseBody {
+ val resp = ApiClient.api.getChatSign()
+ val body = resp.body() ?: throw ServiceException("Failed to get trtc sign")
+ return body.data
+ }
+
+ override suspend fun getAppConfig(): AppConfig {
+ val resp = ApiClient.api.getAppConfig()
+ val body = resp.body() ?: throw ServiceException("Failed to get app config")
+ return body.data
+ }
+
+ override suspend fun removeAccount(password: String) {
+ val resp = ApiClient.api.deleteAccount(
+ RemoveAccountRequestBody(password)
+ )
+ if (!resp.isSuccessful) {
+ parseErrorResponse(resp.errorBody())?.let {
+ throw it.toServiceException()
+ }
+ throw ServiceException("Failed to remove account")
+ }
+ }
+
+ override suspend fun getAgent(page: Int, pageSize: Int): retrofit2.Response>> {
+ return ApiClient.api.getAgent(page, pageSize)
+ }
+
+ override suspend fun createGroupChat(name: String, userIds: List, promptIds: List): retrofit2.Response> {
+ val requestBody = com.aiosman.ravenow.data.api.CreateGroupChatRequestBody(
+ name = name,
+ userIds = userIds,
+ promptIds = promptIds
+ )
+ return ApiClient.api.createGroupChat(requestBody)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/data/AgentService.kt b/app/src/main/java/com/aiosman/ravenow/data/AgentService.kt
new file mode 100644
index 0000000..ca5441c
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/AgentService.kt
@@ -0,0 +1,98 @@
+package com.aiosman.ravenow.data
+
+import com.aiosman.ravenow.AppStore
+import com.aiosman.ravenow.data.api.ApiClient
+import com.aiosman.ravenow.entity.AgentEntity
+import com.aiosman.ravenow.entity.ProfileEntity
+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,
+ @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 {
+ /**
+ * 获取智能体列表
+ */
+ suspend fun getAgent(
+ pageNumber: Int,
+ pageSize: Int = 20,
+ authorId: Int? = null
+ ): ListContainer?
+
+}
+
+
diff --git a/app/src/main/java/com/aiosman/ravenow/data/CaptchaService.kt b/app/src/main/java/com/aiosman/ravenow/data/CaptchaService.kt
new file mode 100644
index 0000000..e1de48c
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/CaptchaService.kt
@@ -0,0 +1,45 @@
+package com.aiosman.ravenow.data
+
+import com.aiosman.ravenow.data.api.ApiClient
+import com.aiosman.ravenow.data.api.CaptchaRequestBody
+import com.aiosman.ravenow.data.api.CaptchaResponseBody
+import com.aiosman.ravenow.data.api.CheckLoginCaptchaRequestBody
+import com.aiosman.ravenow.data.api.GenerateLoginCaptchaRequestBody
+
+
+interface CaptchaService {
+ suspend fun generateCaptcha(source: String): CaptchaResponseBody
+ suspend fun checkLoginCaptcha(username: String): Boolean
+ suspend fun generateLoginCaptcha(username: String): CaptchaResponseBody
+}
+
+class CaptchaServiceImpl : CaptchaService {
+ override suspend fun generateCaptcha(source: String): CaptchaResponseBody {
+ val resp = ApiClient.api.generateCaptcha(
+ CaptchaRequestBody(source)
+ )
+ val data = resp.body() ?: throw Exception("Failed to generate captcha")
+ return data.data.copy(
+ masterBase64 = data.data.masterBase64.replace("data:image/jpeg;base64,", ""),
+ thumbBase64 = data.data.thumbBase64.replace("data:image/png;base64,", "")
+ )
+ }
+
+ override suspend fun checkLoginCaptcha(username: String): Boolean {
+ val resp = ApiClient.api.checkLoginCaptcha(
+ CheckLoginCaptchaRequestBody(username)
+ )
+ return resp.body()?.data ?: true
+ }
+
+ override suspend fun generateLoginCaptcha(username: String): CaptchaResponseBody {
+ val resp = ApiClient.api.generateLoginCaptcha(
+ GenerateLoginCaptchaRequestBody(username)
+ )
+ val data = resp.body() ?: throw Exception("Failed to generate captcha")
+ return data.data.copy(
+ masterBase64 = data.data.masterBase64.replace("data:image/jpeg;base64,", ""),
+ thumbBase64 = data.data.thumbBase64.replace("data:image/png;base64,", "")
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/data/ChatService.kt b/app/src/main/java/com/aiosman/ravenow/data/ChatService.kt
new file mode 100644
index 0000000..1be7a4a
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/ChatService.kt
@@ -0,0 +1,42 @@
+package com.aiosman.ravenow.data
+
+import com.aiosman.ravenow.data.api.ApiClient
+import com.aiosman.ravenow.data.api.UpdateChatNotificationRequestBody
+import com.aiosman.ravenow.entity.ChatNotification
+
+interface ChatService {
+ suspend fun getChatNotifications(
+ targetTrtcId: String
+ ): ChatNotification?
+
+ suspend fun updateChatNotification(
+ targetUserId: Int,
+ strategy: String
+ ): ChatNotification
+}
+
+class ChatServiceImpl : ChatService {
+ override suspend fun getChatNotifications(
+ targetTrtcId: String
+ ): ChatNotification? {
+ val resp = ApiClient.api.getChatNotification(targetTrtcId)
+ if (resp.isSuccessful) {
+ return resp.body()?.data
+ }
+ return null
+ }
+
+ override suspend fun updateChatNotification(
+ targetUserId: Int,
+ strategy: String
+ ): ChatNotification {
+ val resp = ApiClient.api.updateChatNotification(UpdateChatNotificationRequestBody(
+ targetUserId = targetUserId,
+ strategy = strategy
+ ))
+ if (resp.isSuccessful) {
+ return resp.body()?.data!!
+ }
+ throw Exception("update chat notification failed")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/data/CommentService.kt b/app/src/main/java/com/aiosman/ravenow/data/CommentService.kt
new file mode 100644
index 0000000..dd07a21
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/CommentService.kt
@@ -0,0 +1,255 @@
+package com.aiosman.ravenow.data
+
+import com.aiosman.ravenow.data.api.ApiClient
+import com.aiosman.ravenow.data.api.CommentRequestBody
+import com.aiosman.ravenow.entity.CommentEntity
+import com.google.gson.annotations.SerializedName
+
+/**
+ * 评论相关 Service
+ */
+interface CommentService {
+ /**
+ * 获取动态
+ * @param pageNumber 页码
+ * @param postId 动态ID,过滤条件
+ * @param postUser 动态作者ID,获取某个用户所有动态下的评论
+ * @param selfNotice 是否是自己的通知
+ * @param order 排序
+ * @param parentCommentId 父评论ID
+ * @param pageSize 每页数量
+ * @return 评论列表
+ */
+ suspend fun getComments(
+ pageNumber: Int,
+ postId: Int? = null,
+ postUser: Int? = null,
+ selfNotice: Boolean? = null,
+ order: String? = null,
+ parentCommentId: Int? = null,
+ pageSize: Int? = null
+ ): ListContainer
+
+ /**
+ * 创建评论
+ * @param postId 动态ID
+ * @param content 评论内容
+ * @param parentCommentId 父评论ID
+ * @param replyUserId 回复用户ID
+ */
+ suspend fun createComment(
+ postId: Int,
+ content: String,
+ parentCommentId: Int? = null,
+ replyUserId: Int? = null,
+ replyCommentId: Int? = null
+ ): CommentEntity
+
+ /**
+ * 点赞评论
+ * @param commentId 评论ID
+ */
+ suspend fun likeComment(commentId: Int)
+
+ /**
+ * 取消点赞评论
+ * @param commentId 评论ID
+ */
+ suspend fun dislikeComment(commentId: Int)
+
+ /**
+ * 更新评论已读状态
+ * @param commentId 评论ID
+ */
+ suspend fun updateReadStatus(commentId: Int)
+
+ /**
+ * 删除评论
+ * @param commentId 评论ID
+ */
+ suspend fun DeleteComment(commentId: Int)
+
+ /**
+ * 获取评论
+ * @param commentId 评论ID
+ */
+ suspend fun getCommentById(commentId: Int): CommentEntity
+
+}
+
+/**
+ * 评论
+ */
+data class Comment(
+ // 评论ID
+ @SerializedName("id")
+ val id: Int,
+ // 评论内容
+ @SerializedName("content")
+ val content: String,
+ // 评论用户
+ @SerializedName("user")
+ val user: User,
+ // 点赞数
+ @SerializedName("likeCount")
+ val likeCount: Int,
+ // 是否点赞
+ @SerializedName("isLiked")
+ val isLiked: Boolean,
+ // 创建时间
+ @SerializedName("createdAt")
+ val createdAt: String,
+ // 动态ID
+ @SerializedName("postId")
+ val postId: Int,
+ // 动态
+ @SerializedName("post")
+ val post: NoticePost?,
+ // 是否未读
+ @SerializedName("isUnread")
+ val isUnread: Boolean,
+ @SerializedName("reply")
+ val reply: List,
+ @SerializedName("replyUser")
+ val replyUser: User?,
+ @SerializedName("parentCommentId")
+ val parentCommentId: Int?,
+ @SerializedName("replyCount")
+ val replyCount: Int
+) {
+ /**
+ * 转换为Entity
+ */
+ fun toCommentEntity(): CommentEntity {
+ return CommentEntity(
+ id = id,
+ name = user.nickName,
+ comment = content,
+ date = ApiClient.dateFromApiString(createdAt),
+ likes = likeCount,
+ postId = postId,
+ avatar = "${ApiClient.BASE_SERVER}${user.avatar}",
+ author = user.id,
+ liked = isLiked,
+ unread = isUnread,
+ post = post?.let {
+ it.copy(
+ images = it.images.map {
+ it.copy(
+ url = "${ApiClient.BASE_SERVER}${it.url}",
+ thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}"
+ )
+ }
+ )
+ },
+ reply = reply.map { it.toCommentEntity() },
+ replyUserNickname = replyUser?.nickName,
+ replyUserId = replyUser?.id,
+ replyUserAvatar = replyUser?.avatar?.let { "${ApiClient.BASE_SERVER}$it" },
+ parentCommentId = parentCommentId,
+ replyCount = replyCount
+ )
+ }
+}
+
+class CommentRemoteDataSource(
+ private val commentService: CommentService,
+) {
+ suspend fun getComments(
+ pageNumber: Int,
+ postId: Int?,
+ postUser: Int?,
+ selfNotice: Boolean?,
+ order: String?,
+ parentCommentId: Int?,
+ pageSize: Int? = 20
+ ): ListContainer {
+ return commentService.getComments(
+ pageNumber,
+ postId,
+ postUser = postUser,
+ selfNotice = selfNotice,
+ order = order,
+ parentCommentId = parentCommentId,
+ pageSize = pageSize
+ )
+ }
+}
+
+
+class CommentServiceImpl : CommentService {
+ override suspend fun getComments(
+ pageNumber: Int,
+ postId: Int?,
+ postUser: Int?,
+ selfNotice: Boolean?,
+ order: String?,
+ parentCommentId: Int?,
+ pageSize: Int?
+ ): ListContainer {
+ val resp = ApiClient.api.getComments(
+ page = pageNumber,
+ postId = postId,
+ postUser = postUser,
+ order = order,
+ selfNotice = selfNotice?.let {
+ if (it) 1 else 0
+ },
+ parentCommentId = parentCommentId,
+ pageSize = pageSize ?: 20
+ )
+ val body = resp.body() ?: throw ServiceException("Failed to get comments")
+ return ListContainer(
+ list = body.list.map { it.toCommentEntity() },
+ page = body.page,
+ total = body.total,
+ pageSize = body.pageSize
+ )
+ }
+
+ override suspend fun createComment(
+ postId: Int,
+ content: String,
+ parentCommentId: Int?,
+ replyUserId: Int?,
+ replyCommentId: Int?
+ ): CommentEntity {
+ val resp = ApiClient.api.createComment(
+ postId,
+ CommentRequestBody(
+ content = content,
+ parentCommentId = parentCommentId,
+ replyUserId = replyUserId,
+ replyCommentId = replyCommentId
+ ),
+ )
+ val body = resp.body() ?: throw ServiceException("Failed to create comment")
+ return body.data.toCommentEntity()
+ }
+
+ override suspend fun likeComment(commentId: Int) {
+ val resp = ApiClient.api.likeComment(commentId)
+ return
+ }
+
+ override suspend fun dislikeComment(commentId: Int) {
+ val resp = ApiClient.api.dislikeComment(commentId)
+ return
+ }
+
+ override suspend fun updateReadStatus(commentId: Int) {
+ val resp = ApiClient.api.updateReadStatus(commentId)
+ return
+ }
+
+ override suspend fun DeleteComment(commentId: Int) {
+ val resp = ApiClient.api.deleteComment(commentId)
+ return
+ }
+
+ override suspend fun getCommentById(commentId: Int): CommentEntity {
+ val resp = ApiClient.api.getComment(commentId)
+ val body = resp.body() ?: throw ServiceException("Failed to get comment")
+ return body.data.toCommentEntity()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/data/CommonService.kt b/app/src/main/java/com/aiosman/ravenow/data/CommonService.kt
new file mode 100644
index 0000000..f7ac1eb
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/CommonService.kt
@@ -0,0 +1,50 @@
+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
+)
+
+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()
+ )
+ )
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/data/DataContainer.kt b/app/src/main/java/com/aiosman/ravenow/data/DataContainer.kt
new file mode 100644
index 0000000..4d791c0
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/DataContainer.kt
@@ -0,0 +1,8 @@
+package com.aiosman.ravenow.data
+
+/**
+ * 通用接口返回数据
+ */
+data class DataContainer(
+ val data: T
+)
diff --git a/app/src/main/java/com/aiosman/ravenow/data/DictService.kt b/app/src/main/java/com/aiosman/ravenow/data/DictService.kt
new file mode 100644
index 0000000..6a63f79
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/DictService.kt
@@ -0,0 +1,29 @@
+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): List
+}
+
+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): List {
+ val resp = ApiClient.api.getDicts(keys.joinToString(","))
+ return resp.body()?.list ?: throw Exception("failed to get dict list")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/data/Exception.kt b/app/src/main/java/com/aiosman/ravenow/data/Exception.kt
new file mode 100644
index 0000000..de14fad
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/Exception.kt
@@ -0,0 +1,47 @@
+package com.aiosman.ravenow.data
+
+import com.aiosman.ravenow.data.api.ErrorCode
+import com.aiosman.ravenow.data.api.toErrorCode
+import com.google.gson.Gson
+import com.google.gson.annotations.SerializedName
+import okhttp3.ResponseBody
+
+/**
+ * 错误返回
+ */
+class ServiceException(
+ override val message: String,
+ val code: Int? = 0,
+ val data: Any? = null,
+ val error: String? = null,
+ val name: String? = null,
+ val errorType: ErrorCode = ErrorCode.UNKNOWN
+) : Exception(
+ message
+)
+
+data class ApiErrorResponse(
+ @SerializedName("code")
+ val code: Int?,
+ @SerializedName("error")
+ val error: String?,
+ @SerializedName("message")
+ val name: String?,
+) {
+ fun toServiceException(): ServiceException {
+ return ServiceException(
+ message = error ?: name ?: "",
+ code = code,
+ error = error,
+ name = name,
+ errorType = (code ?: 0).toErrorCode()
+ )
+ }
+}
+
+fun parseErrorResponse(errorBody: ResponseBody?): ApiErrorResponse? {
+ return errorBody?.let {
+ val gson = Gson()
+ gson.fromJson(it.charStream(), ApiErrorResponse::class.java)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/data/ListContainer.kt b/app/src/main/java/com/aiosman/ravenow/data/ListContainer.kt
new file mode 100644
index 0000000..0a3cf0f
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/ListContainer.kt
@@ -0,0 +1,22 @@
+package com.aiosman.ravenow.data
+
+import com.google.gson.annotations.SerializedName
+
+
+/**
+ * 通用列表接口返回
+ */
+data class ListContainer(
+ // 总数
+ @SerializedName("total")
+ val total: Int,
+ // 当前页
+ @SerializedName("page")
+ val page: Int,
+ // 每页数量
+ @SerializedName("pageSize")
+ val pageSize: Int,
+ // 列表
+ @SerializedName("list")
+ val list: List
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/data/MomentService.kt b/app/src/main/java/com/aiosman/ravenow/data/MomentService.kt
new file mode 100644
index 0000000..ab7f3ff
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/MomentService.kt
@@ -0,0 +1,196 @@
+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.google.gson.annotations.SerializedName
+import java.io.File
+
+data class Moment(
+ @SerializedName("id")
+ val id: Long,
+ @SerializedName("textContent")
+ val textContent: String,
+ @SerializedName("images")
+ val images: List,
+ @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("shareCount")
+ 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,
+) {
+ fun toMomentItem(): MomentEntity {
+ return MomentEntity(
+ id = id.toInt(),
+ avatar = "${ApiClient.BASE_SERVER}${user.avatar}",
+ nickname = user.nickName,
+ location = "Worldwide",
+ time = ApiClient.dateFromApiString(time),
+ 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(
+ url = "${ApiClient.BASE_SERVER}${it.url}",
+ thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}",
+ id = it.id,
+ blurHash = it.blurHash,
+ width = it.width,
+ height = it.height
+ )
+ },
+ authorId = user.id.toInt(),
+ liked = isLiked,
+ isFavorite = isFavorite,
+ // 新闻相关字段
+ isNews = isNews,
+ newsTitle = newsTitle ?: "",
+ newsUrl = newsUrl ?: "",
+ newsSource = newsSource ?: "",
+ newsCategory = newsCategory ?: "",
+ newsLanguage = newsLanguage ?: "",
+ newsContent = newsContent ?: ""
+ )
+ }
+}
+
+data class Image(
+ @SerializedName("id")
+ val id: Long,
+ @SerializedName("url")
+ val url: String,
+ @SerializedName("thumbnail")
+ val thumbnail: String,
+ @SerializedName("blurHash")
+ val blurHash: String?,
+ @SerializedName("width")
+ val width: Int?,
+ @SerializedName("height")
+ val height: Int?
+)
+
+data class User(
+ @SerializedName("id")
+ val id: Long,
+ @SerializedName("nickName")
+ val nickName: String,
+ @SerializedName("avatar")
+ val avatar: String
+)
+
+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
+
+ /**
+ * 创建动态
+ * @param content 动态内容
+ * @param authorId 作者ID
+ * @param images 图片列表
+ * @param relPostId 关联动态ID
+ */
+ suspend fun createMoment(
+ content: String,
+ authorId: Int,
+ images: List,
+ 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)
+}
+
+
diff --git a/app/src/main/java/com/aiosman/ravenow/data/RoomService.kt b/app/src/main/java/com/aiosman/ravenow/data/RoomService.kt
new file mode 100644
index 0000000..5928ea0
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/RoomService.kt
@@ -0,0 +1,111 @@
+package com.aiosman.ravenow.data
+
+import com.aiosman.ravenow.AppStore
+import com.aiosman.ravenow.data.api.ApiClient
+import com.aiosman.ravenow.entity.AgentEntity
+import com.aiosman.ravenow.entity.CreatorEntity
+import com.aiosman.ravenow.entity.ProfileEntity
+import com.aiosman.ravenow.entity.RoomEntity
+import com.aiosman.ravenow.entity.UsersEntity
+import com.google.gson.annotations.SerializedName
+
+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("maxMemberLimit")
+ val maxMemberLimit: Int,
+ @SerializedName("canJoin")
+ val canJoin: Boolean,
+ @SerializedName("canJoinCode")
+ val canJoinCode: Int,
+ @SerializedName("users")
+ val users: List
+
+ ) {
+ 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,
+ maxMemberLimit = maxMemberLimit,
+ canJoin = canJoin,
+ canJoinCode = canJoinCode,
+ users = users.map { it.toUsersEntity() }
+ )
+ }
+}
+
+
+data class Creator(
+ @SerializedName("id")
+ val id: Int,
+ @SerializedName("userId")
+ val userId: String,
+ @SerializedName("trtcUserId")
+ val trtcUserId: String,
+ @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,
+ @SerializedName("profile")
+ val profile: Profile
+){
+ fun toUsersEntity(): UsersEntity {
+ return UsersEntity(
+ id = id,
+ userId = userId,
+ profile = profile.toProfileEntity()
+ )
+ }
+}
+
+
+
+
diff --git a/app/src/main/java/com/aiosman/ravenow/data/UserService.kt b/app/src/main/java/com/aiosman/ravenow/data/UserService.kt
new file mode 100644
index 0000000..d3ba590
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/UserService.kt
@@ -0,0 +1,122 @@
+package com.aiosman.ravenow.data
+
+import com.aiosman.ravenow.data.api.ApiClient
+import com.aiosman.ravenow.entity.AccountProfileEntity
+
+data class UserAuth(
+ val id: Int,
+ val token: String? = null,
+ val isGuest: Boolean = false
+)
+
+/**
+ * 用户相关 Service
+ */
+interface UserService {
+ /**
+ * 获取用户信息
+ * @param id 用户ID
+ * @return 用户信息
+ */
+ suspend fun getUserProfile(id: String): AccountProfileEntity
+
+ /**
+ * 关注用户
+ * @param id 用户ID
+ */
+ suspend fun followUser(id: String)
+
+ /**
+ * 取消关注用户
+ * @param id 用户ID
+ */
+ suspend fun unFollowUser(id: String)
+
+ /**
+ * 获取用户列表
+ * @param pageSize 分页大小
+ * @param page 页码
+ * @param nickname 昵称搜索
+ * @param followerId 粉丝ID,账号粉丝
+ * @param followingId 关注ID,账号关注
+ * @return 用户列表
+ */
+ suspend fun getUsers(
+ pageSize: Int = 20,
+ page: Int = 1,
+ nickname: String? = null,
+ followerId: Int? = null,
+ followingId: Int? = null
+ ): ListContainer
+
+
+ /**
+ * 获取用户信息
+ * @param id 用户ID
+ * @return 用户信息
+ */
+
+ suspend fun getUserProfileByTrtcUserId(id: String,includeAI: Int):AccountProfileEntity
+
+ /**
+ * 获取用户信息
+ * @param id 用户ID
+ * @return 用户信息
+ */
+
+ suspend fun getUserProfileByOpenId(id: String):AccountProfileEntity
+
+}
+
+class UserServiceImpl : UserService {
+ override suspend fun getUserProfile(id: String): AccountProfileEntity {
+ val resp = ApiClient.api.getAccountProfileById(id.toInt())
+ val body = resp.body() ?: throw ServiceException("Failed to get account")
+ return body.data.toAccountProfileEntity()
+ }
+
+ override suspend fun followUser(id: String) {
+ val resp = ApiClient.api.followUser(id.toInt())
+ return
+ }
+
+ override suspend fun unFollowUser(id: String) {
+ val resp = ApiClient.api.unfollowUser(id.toInt())
+ return
+ }
+
+ override suspend fun getUsers(
+ pageSize: Int,
+ page: Int,
+ nickname: String?,
+ followerId: Int?,
+ followingId: Int?
+ ): ListContainer {
+ val resp = ApiClient.api.getUsers(
+ page = page,
+ pageSize = pageSize,
+ search = nickname,
+ followerId = followerId,
+ followingId = followingId
+ )
+ val body = resp.body() ?: throw ServiceException("Failed to get account")
+ return ListContainer(
+ list = body.list.map { it.toAccountProfileEntity() },
+ page = body.page,
+ total = body.total,
+ pageSize = body.pageSize,
+ )
+ }
+
+ override suspend fun getUserProfileByTrtcUserId(id: String,includeAI: Int): AccountProfileEntity {
+ val resp = ApiClient.api.getAccountProfileByTrtcUserId(id,includeAI)
+ val body = resp.body() ?: throw ServiceException("Failed to get account")
+ return body.data.toAccountProfileEntity()
+ }
+
+ override suspend fun getUserProfileByOpenId(id: String): AccountProfileEntity {
+ val resp = ApiClient.api.getAccountProfileByOpenId(id)
+ val body = resp.body() ?: throw ServiceException("Failed to get account")
+ return body.data.toAccountProfileEntity()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/data/api/ApiClient.kt b/app/src/main/java/com/aiosman/ravenow/data/api/ApiClient.kt
new file mode 100644
index 0000000..15ea499
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/api/ApiClient.kt
@@ -0,0 +1,106 @@
+package com.aiosman.ravenow.data.api
+
+import android.icu.text.SimpleDateFormat
+import android.icu.util.TimeZone
+import com.aiosman.ravenow.AppStore
+import com.aiosman.ravenow.ConstVars
+import com.auth0.android.jwt.JWT
+import kotlinx.coroutines.runBlocking
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.Response
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.util.Date
+import java.util.Locale
+
+fun getSafeOkHttpClient(
+ authInterceptor: AuthInterceptor? = null
+): OkHttpClient {
+ return OkHttpClient.Builder()
+ .apply {
+ authInterceptor?.let {
+ addInterceptor(it)
+ }
+ }
+ .build()
+}
+
+class AuthInterceptor() : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val requestBuilder = chain.request().newBuilder()
+ val token = AppStore.token
+ token?.let {
+ val jwt = JWT(token)
+ val expiresAt = jwt.expiresAt?.time?.minus(3000)
+ val currentTime = System.currentTimeMillis()
+ val isExpired = expiresAt != null && currentTime > expiresAt
+ if (isExpired) {
+ runBlocking {
+ val newToken = refreshToken()
+ if (newToken != null) {
+ AppStore.token = newToken
+ }
+ }
+ }
+ }
+
+ requestBuilder.addHeader("Authorization", "Bearer ${AppStore.token}")
+ requestBuilder.addHeader("DEVICE-OS", "Android")
+
+ val response = chain.proceed(requestBuilder.build())
+ return response
+ }
+
+ private suspend fun refreshToken(): String? {
+ val client = Retrofit.Builder()
+ .baseUrl(ApiClient.RETROFIT_URL)
+ .addConverterFactory(GsonConverterFactory.create())
+ .client(getSafeOkHttpClient())
+ .build()
+ .create(RaveNowAPI::class.java)
+
+ val resp = client.refreshToken(AppStore.token ?: "")
+ val newToken = resp.body()?.token
+ if (newToken != null) {
+ AppStore.token = newToken
+ }
+ return newToken
+ }
+}
+
+object ApiClient {
+ val BASE_SERVER = ConstVars.BASE_SERVER
+ val BASE_API_URL = "${BASE_SERVER}/api/v1"
+ val RETROFIT_URL = "${BASE_API_URL}/"
+ const val TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"
+ private val okHttpClient: OkHttpClient by lazy {
+ getSafeOkHttpClient(authInterceptor = AuthInterceptor())
+ }
+ private val retrofit: Retrofit by lazy {
+ Retrofit.Builder()
+ .baseUrl(RETROFIT_URL)
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ }
+ val api: RaveNowAPI by lazy {
+ retrofit.create(RaveNowAPI::class.java)
+ }
+
+ fun formatTime(date: Date): String {
+ val dateFormat = SimpleDateFormat(TIME_FORMAT, Locale.getDefault())
+ return dateFormat.format(date)
+ }
+
+ fun dateFromApiString(apiString: String): Date {
+ val timeFormat = TIME_FORMAT
+ val simpleDateFormat = SimpleDateFormat(timeFormat, Locale.getDefault())
+ simpleDateFormat.timeZone = TimeZone.getTimeZone("UTC")
+ val date = simpleDateFormat.parse(apiString)
+
+ simpleDateFormat.timeZone = TimeZone.getDefault()
+ val localDateString = simpleDateFormat.format(date)
+ return simpleDateFormat.parse(localDateString)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/data/api/Error.kt b/app/src/main/java/com/aiosman/ravenow/data/api/Error.kt
new file mode 100644
index 0000000..aef2bae
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/api/Error.kt
@@ -0,0 +1,42 @@
+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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/data/api/RiderProAPI.kt b/app/src/main/java/com/aiosman/ravenow/data/api/RiderProAPI.kt
new file mode 100644
index 0000000..e78a82a
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/api/RiderProAPI.kt
@@ -0,0 +1,868 @@
+package com.aiosman.ravenow.data.api
+
+import com.aiosman.ravenow.data.AccountFavourite
+import com.aiosman.ravenow.data.AccountFollow
+import com.aiosman.ravenow.data.AccountLike
+import com.aiosman.ravenow.data.AccountNotice
+import com.aiosman.ravenow.data.AccountProfile
+import com.aiosman.ravenow.data.Agent
+import com.aiosman.ravenow.data.Comment
+import com.aiosman.ravenow.data.DataContainer
+import com.aiosman.ravenow.data.ListContainer
+import com.aiosman.ravenow.data.Moment
+import com.aiosman.ravenow.data.Room
+import com.aiosman.ravenow.entity.ChatNotification
+import com.aiosman.ravenow.data.membership.MembershipConfigData
+import com.aiosman.ravenow.data.membership.ValidateData
+import com.aiosman.ravenow.data.membership.ValidateProductRequestBody
+import com.google.gson.annotations.SerializedName
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import retrofit2.Response
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.Multipart
+import retrofit2.http.PATCH
+import retrofit2.http.POST
+import retrofit2.http.Part
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+data class RegisterRequestBody(
+ @SerializedName("username")
+ val username: String,
+ @SerializedName("password")
+ val password: String
+)
+data class AgentMomentRequestBody(
+ @SerializedName("generateText")
+ val generateText: String,
+ @SerializedName("sessionId")
+ val sessionId: String
+)
+
+data class SingleChatRequestBody(
+ @SerializedName("agentOpenId")
+ val agentOpenId: String? = null,
+ @SerializedName("agentTrtcId")
+ val agentTrtcId: String? = null,
+)
+
+data class GroupChatRequestBody(
+ @SerializedName("trtcGroupId")
+ val trtcGroupId: String,
+)
+
+
+data class SendChatAiRequestBody(
+ @SerializedName("trtcGroupId")
+ val trtcGroupId: String? = null,
+ @SerializedName("fromTrtcUserId")
+ val fromTrtcUserId: String? = null,
+ @SerializedName("toTrtcUserId")
+ val toTrtcUserId: String? = null,
+ @SerializedName("message")
+ val message: String,
+ @SerializedName("skipTrtc")
+ val skipTrtc: Boolean? = true,
+
+)
+
+
+data class CreateGroupChatRequestBody(
+ @SerializedName("name")
+ val name: String,
+ @SerializedName("userIds")
+ val userIds: List,
+ @SerializedName("promptIds")
+ val promptIds: List,
+ )
+
+data class JoinGroupChatRequestBody(
+ @SerializedName("trtcId")
+ val trtcId: String? = null,
+ @SerializedName("roomId")
+ val roomId: Int? = null,
+)
+
+data class LoginUserRequestBody(
+ @SerializedName("username")
+ val username: String? = null,
+ @SerializedName("password")
+ val password: String? = null,
+ @SerializedName("googleId")
+ val googleId: String? = null,
+ @SerializedName("captcha")
+ val captcha: CaptchaInfo? = null,
+)
+
+data class GuestLoginRequestBody(
+ @SerializedName("deviceID")
+ val deviceId: String,
+ @SerializedName("platform")
+ val platform: String = "android",
+ @SerializedName("deviceInfo")
+ val deviceInfo: String? = null,
+ @SerializedName("userAgent")
+ val userAgent: String? = null,
+ @SerializedName("ipAddress")
+ val ipAddress: String? = null
+)
+
+data class GoogleRegisterRequestBody(
+ @SerializedName("idToken")
+ val idToken: String
+)
+
+data class AuthResult(
+ @SerializedName("code")
+ val code: Int,
+ @SerializedName("expire")
+ val expire: String,
+ @SerializedName("token")
+ val token: String
+)
+
+data class ValidateTokenResult(
+ @SerializedName("id")
+ val id: Int,
+)
+
+data class CommentRequestBody(
+ @SerializedName("content")
+ val content: String,
+ @SerializedName("parentCommentId")
+ val parentCommentId: Int? = null,
+ @SerializedName("replyUserId")
+ val replyUserId: Int? = null,
+ @SerializedName("replyCommentId")
+ val replyCommentId: Int? = null,
+)
+
+data class ChangePasswordRequestBody(
+ @SerializedName("currentPassword")
+ val oldPassword: String = "",
+ @SerializedName("newPassword")
+ val newPassword: String = ""
+)
+
+data class UpdateNoticeRequestBody(
+ @SerializedName("lastLookLikeTime")
+ val lastLookLikeTime: String? = null,
+ @SerializedName("lastLookFollowTime")
+ val lastLookFollowTime: String? = null,
+ @SerializedName("lastLookFavoriteTime")
+ val lastLookFavouriteTime: String? = null
+)
+
+data class RegisterMessageChannelRequestBody(
+ @SerializedName("client")
+ val client: String,
+ @SerializedName("identifier")
+ val identifier: String,
+)
+
+data class UnRegisterMessageChannelRequestBody(
+ @SerializedName("client")
+ val client: String,
+ @SerializedName("identifier")
+ val identifier: String,
+)
+data class ResetPasswordRequestBody(
+ @SerializedName("username")
+ val username: String,
+)
+
+data class UpdateUserLangRequestBody(
+ @SerializedName("language")
+ val lang: String,
+ @SerializedName("timeOffset")
+ val timeOffset: Int,
+ @SerializedName("timezone")
+ val timezone: String,
+)
+
+data class TrtcSignResponseBody(
+ @SerializedName("sig")
+ val sig: String,
+ @SerializedName("userId")
+ val userId: String,
+)
+
+data class AppConfig(
+ @SerializedName("trtcAppId")
+ val trtcAppId: Int,
+)
+
+data class DictItem(
+ @SerializedName("key")
+ val key: String,
+ @SerializedName("value")
+ val value: Any,
+ @SerializedName("desc")
+ val desc: String,
+)
+
+data class CaptchaRequestBody(
+ @SerializedName("source")
+ val source: String,
+)
+
+data class CaptchaResponseBody(
+ @SerializedName("id")
+ val id: Int,
+ @SerializedName("thumb_base64")
+ val thumbBase64: String,
+ @SerializedName("master_base64")
+ val masterBase64: String,
+ @SerializedName("count")
+ val count: Int,
+)
+
+data class CheckLoginCaptchaRequestBody(
+ @SerializedName("username")
+ val username: String,
+)
+
+data class GenerateLoginCaptchaRequestBody(
+ @SerializedName("username")
+ val username: String,
+)
+
+data class DotPosition(
+ @SerializedName("index")
+ val index: Int,
+ @SerializedName("x")
+ val x: Int,
+ @SerializedName("y")
+ val y: Int,
+)
+
+data class CaptchaInfo(
+ @SerializedName("id")
+ val id: Int,
+ @SerializedName("dot")
+ val dot: List
+)
+
+
+data class UpdateChatNotificationRequestBody(
+ @SerializedName("targetUserId")
+ val targetUserId: Int,
+ @SerializedName("strategy")
+ val strategy: String,
+)
+
+
+data class CreateReportRequestBody(
+ @SerializedName("reportType")
+ val reportType: String,
+ @SerializedName("reportId")
+ val reportId: Int,
+ @SerializedName("reason")
+ val reason: Int,
+ @SerializedName("extra")
+ val extra: String,
+ @SerializedName("base64Images")
+ val base64Images: List,
+)
+
+data class RemoveAccountRequestBody(
+ @SerializedName("password")
+ val password: String,
+)
+
+// API 错误响应(用于加入房间等接口的错误处理)
+data class ApiErrorResponse(
+ @SerializedName("err")
+ val error: String,
+ @SerializedName("success")
+ val success: Boolean
+)
+
+// 群聊中的用户信息
+data class GroupChatUser(
+ @SerializedName("ID")
+ val id: Int,
+ @SerializedName("CreatedAt")
+ val createdAt: String,
+ @SerializedName("UpdatedAt")
+ val updatedAt: String,
+ @SerializedName("DeletedAt")
+ val deletedAt: String?,
+ @SerializedName("userSessionId")
+ val userSessionId: String,
+ @SerializedName("sessions")
+ val sessions: Any?, // 根据实际需要可以定义具体类型
+ @SerializedName("prompts")
+ val prompts: Any?, // 根据实际需要可以定义具体类型
+ @SerializedName("isAgent")
+ val isAgent: Boolean
+)
+
+// 智能体角色信息
+data class GroupChatPrompt(
+ @SerializedName("ID")
+ val id: Int,
+ @SerializedName("CreatedAt")
+ val createdAt: String,
+ @SerializedName("UpdatedAt")
+ val updatedAt: String,
+ @SerializedName("DeletedAt")
+ val deletedAt: String?,
+ @SerializedName("Title")
+ val title: String,
+ @SerializedName("Desc")
+ val desc: String,
+ @SerializedName("Value")
+ val value: String,
+ @SerializedName("Enable")
+ val enable: Boolean,
+ @SerializedName("UserSessions")
+ val userSessions: Any?, // 根据实际需要可以定义具体类型
+ @SerializedName("Avatar")
+ val avatar: String,
+ @SerializedName("AuthorId")
+ val authorId: Int?,
+ @SerializedName("Author")
+ val author: Any?, // 根据实际需要可以定义具体类型
+ @SerializedName("TokenCount")
+ val tokenCount: Int,
+ @SerializedName("OpenId")
+ val openId: String,
+ @SerializedName("Public")
+ val public: Boolean,
+ @SerializedName("BreakMode")
+ val breakMode: Boolean,
+ @SerializedName("DocNamespace")
+ val docNamespace: String,
+ @SerializedName("UseRag")
+ val useRag: Boolean,
+ @SerializedName("RagThreshold")
+ val ragThreshold: Double,
+ @SerializedName("WorkflowId")
+ val workflowId: Int?,
+ @SerializedName("Workflow")
+ val workflow: Any?, // 根据实际需要可以定义具体类型
+ @SerializedName("WorkflowInputs")
+ val workflowInputs: Any?, // 根据实际需要可以定义具体类型
+ @SerializedName("Source")
+ val source: String,
+ @SerializedName("categories")
+ val categories: Any? // 根据实际需要可以定义具体类型
+)
+
+// 群聊详细信息响应
+data class GroupChatResponse(
+ @SerializedName("ID")
+ val id: Int,
+ @SerializedName("CreatedAt")
+ val createdAt: String,
+ @SerializedName("UpdatedAt")
+ val updatedAt: String,
+ @SerializedName("DeletedAt")
+ val deletedAt: String?,
+ @SerializedName("name")
+ val name: String,
+ @SerializedName("description")
+ val description: String,
+ @SerializedName("creatorId")
+ val creatorId: Int,
+ @SerializedName("creator")
+ val creator: Any?, // 根据实际需要可以定义具体类型
+ @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("users")
+ val users: List,
+ @SerializedName("prompts")
+ val prompts: List,
+ @SerializedName("source")
+ val source: String
+)
+
+
+data class CategoryTranslation(
+ @SerializedName("name")
+ val name: String?,
+ @SerializedName("description")
+ val description: String?
+)
+
+data class CategoryTemplate(
+ @SerializedName("id")
+ val id: Int,
+ @SerializedName("name")
+ val name: String,
+ @SerializedName("description")
+ val description: String,
+ @SerializedName("avatar")
+ val avatar: String,
+ @SerializedName("parentId")
+ val parentId: Int?,
+ @SerializedName("parent")
+ val parent: CategoryTemplate?,
+ @SerializedName("children")
+ val children: List?,
+ @SerializedName("sort")
+ val sort: Int,
+ @SerializedName("isActive")
+ val isActive: Boolean,
+ @SerializedName("promptCount")
+ val promptCount: Int?,
+ @SerializedName("createdAt")
+ val createdAt: String,
+ @SerializedName("updatedAt")
+ val updatedAt: String,
+ @SerializedName("translations")
+ val translations: Map?
+) {
+ /**
+ * 根据语言代码获取翻译后的名称,如果没有翻译则返回默认名称
+ */
+ fun getLocalizedName(lang: String): String {
+ // 尝试获取完整的语言标记(如 "zh-CN")
+ val translation = translations?.get(lang)
+ if (translation?.name != null && translation.name.isNotEmpty()) {
+ return translation.name
+ }
+
+ // 如果没有找到,尝试语言代码的前缀(如 "zh")
+ val langPrefix = lang.split("-", "_").firstOrNull()
+ if (langPrefix != null) {
+ translations?.entries?.forEach { (key, value) ->
+ if (key.startsWith(langPrefix) && value.name != null && value.name.isNotEmpty()) {
+ return value.name
+ }
+ }
+ }
+
+ // 如果没有翻译,返回默认名称
+ return name
+ }
+
+ /**
+ * 根据语言代码获取翻译后的描述,如果没有翻译则返回默认描述
+ */
+ fun getLocalizedDescription(lang: String): String {
+ // 尝试获取完整的语言标记(如 "zh-CN")
+ val translation = translations?.get(lang)
+ if (translation?.description != null && translation.description.isNotEmpty()) {
+ return translation.description
+ }
+
+ // 如果没有找到,尝试语言代码的前缀(如 "zh")
+ val langPrefix = lang.split("-", "_").firstOrNull()
+ if (langPrefix != null) {
+ translations?.entries?.forEach { (key, value) ->
+ if (key.startsWith(langPrefix) && value.description != null && value.description.isNotEmpty()) {
+ return value.description
+ }
+ }
+ }
+
+ // 如果没有翻译,返回默认描述
+ return description
+ }
+}
+
+data class CategoryListResponse(
+ @SerializedName("page")
+ val page: Int,
+ @SerializedName("pageSize")
+ val pageSize: Int,
+ @SerializedName("total")
+ val total: Int,
+ @SerializedName("list")
+ val list: List
+)
+
+interface RaveNowAPI {
+ @GET("membership/config")
+ @retrofit2.http.Headers("X-Requires-Auth: true")
+ suspend fun getMembershipConfig(): Response>
+
+ @POST("membership/android/product/validate")
+ @retrofit2.http.Headers("X-Requires-Auth: true")
+ suspend fun validateAndroidProduct(
+ @Body body: ValidateProductRequestBody
+ ): Response>
+ @POST("register")
+ suspend fun register(@Body body: RegisterRequestBody): Response
+
+ @POST("login")
+ suspend fun login(@Body body: LoginUserRequestBody): Response
+
+ @POST("guest/login")
+ suspend fun guestLogin(@Body body: GuestLoginRequestBody): Response
+
+ @GET("auth/token")
+ suspend fun checkToken(): Response
+
+ @GET("auth/refresh_token")
+ suspend fun refreshToken(
+ @Query("token") token: String
+ ): Response
+
+ @GET("posts")
+ suspend fun getPosts(
+ @Query("page") page: Int = 1,
+ @Query("pageSize") pageSize: Int = 20,
+ @Query("timelineId") timelineId: Int? = null,
+ @Query("authorId") authorId: Int? = null,
+ @Query("contentSearch") contentSearch: String? = null,
+ @Query("postUser") postUser: Int? = null,
+ @Query("trend") trend: String? = null,
+ @Query("favouriteUserId") favouriteUserId: Int? = null,
+ @Query("explore") explore: String? = null,
+ @Query("newsFilter") newsFilter: String? = null,
+ ): Response>
+
+ @Multipart
+ @POST("posts")
+ suspend fun createPost(
+ @Part image: List,
+ @Part("textContent") textContent: RequestBody,
+ ): Response>
+
+ @GET("post/{id}")
+ suspend fun getPost(
+ @Path("id") id: Int
+ ): Response>
+
+ @POST("post/{id}/like")
+ suspend fun likePost(
+ @Path("id") id: Int
+ ): Response
+
+ @POST("post/{id}/dislike")
+ suspend fun dislikePost(
+ @Path("id") id: Int
+ ): Response
+
+ @POST("post/{id}/favorite")
+ suspend fun favoritePost(
+ @Path("id") id: Int
+ ): Response
+
+ @POST("post/{id}/unfavorite")
+ suspend fun unfavoritePost(
+ @Path("id") id: Int
+ ): Response
+
+ @POST("post/{id}/comment")
+ suspend fun createComment(
+ @Path("id") id: Int,
+ @Body body: CommentRequestBody
+ ): Response>
+
+ @POST("comment/{id}/like")
+ suspend fun likeComment(
+ @Path("id") id: Int
+ ): Response
+
+ @POST("comment/{id}/dislike")
+ suspend fun dislikeComment(
+ @Path("id") id: Int
+ ): Response
+
+ @POST("comment/{id}/read")
+ suspend fun updateReadStatus(
+ @Path("id") id: Int
+ ): Response
+
+
+ @GET("comments")
+ suspend fun getComments(
+ @Query("page") page: Int = 1,
+ @Query("postId") postId: Int? = null,
+ @Query("pageSize") pageSize: Int = 20,
+ @Query("postUser") postUser: Int? = null,
+ @Query("selfNotice") selfNotice: Int? = 0,
+ @Query("order") order: String? = null,
+ @Query("parentCommentId") parentCommentId: Int? = null,
+ ): Response>
+
+ @GET("account/my")
+ suspend fun getMyAccount(): Response>
+
+ @Multipart
+ @PATCH("account/my/profile")
+ suspend fun updateProfile(
+ @Part avatar: MultipartBody.Part?,
+ @Part banner: MultipartBody.Part?,
+ @Part("nickname") nickname: RequestBody?,
+ @Part("bio") bio: RequestBody?,
+ ): Response
+
+ @POST("account/my/password")
+ suspend fun changePassword(
+ @Body body: ChangePasswordRequestBody
+ ): Response
+
+ @GET("account/my/notice/like")
+ suspend fun getMyLikeNotices(
+ @Query("page") page: Int = 1,
+ @Query("pageSize") pageSize: Int = 20,
+ ): Response>
+
+ @GET("account/my/notice/follow")
+ suspend fun getMyFollowNotices(
+ @Query("page") page: Int = 1,
+ @Query("pageSize") pageSize: Int = 20,
+ ): Response>
+
+ @GET("account/my/notice/favourite")
+ suspend fun getMyFavouriteNotices(
+ @Query("page") page: Int = 1,
+ @Query("pageSize") pageSize: Int = 20,
+ ): Response>
+
+ @GET("account/my/notice")
+ suspend fun getMyNoticeInfo(): Response>
+
+ @POST("account/my/notice")
+ suspend fun updateNoticeInfo(
+ @Body body: UpdateNoticeRequestBody
+ ): Response
+
+ @POST("account/my/messaging")
+ suspend fun registerMessageChannel(
+ @Body body: RegisterMessageChannelRequestBody
+ ): Response
+
+ @POST("account/my/messaging/unregister")
+ suspend fun unRegisterMessageChannel(
+ @Body body: UnRegisterMessageChannelRequestBody
+ ): Response
+
+ @GET("profile/{id}")
+ suspend fun getAccountProfileById(
+ @Path("id") id: Int
+ ): Response>
+
+ @GET("profile/trtc/{id}")
+ suspend fun getAccountProfileByTrtcUserId(
+ @Path("id") id: String,
+ @Query("includeAI") includeAI: Int
+ ): Response>
+
+
+ @GET("profile/aichat/profile/{id}")
+ suspend fun getAccountProfileByOpenId(
+ @Path("id") id: String
+ ): Response>
+
+ @POST("user/{id}/follow")
+ suspend fun followUser(
+ @Path("id") id: Int
+ ): Response
+
+ @POST("user/{id}/unfollow")
+ suspend fun unfollowUser(
+ @Path("id") id: Int
+ ): Response
+
+ @GET("users")
+ suspend fun getUsers(
+ @Query("page") page: Int = 1,
+ @Query("pageSize") pageSize: Int = 20,
+ @Query("nickname") search: String? = null,
+ @Query("followerId") followerId: Int? = null,
+ @Query("followingId") followingId: Int? = null,
+ @Query("includeAI") includeAI: Boolean? = false,
+ @Query("chatSessionIdNotNull") chatSessionIdNotNull: Boolean? = true,
+ ): Response>
+
+ @POST("register/google")
+ suspend fun registerWithGoogle(@Body body: GoogleRegisterRequestBody): Response
+
+ @DELETE("post/{id}")
+ suspend fun deletePost(
+ @Path("id") id: Int
+ ): Response
+
+ @DELETE("comment/{id}")
+ suspend fun deleteComment(
+ @Path("id") id: Int
+ ): Response
+
+ @POST("account/my/password/reset")
+ suspend fun resetPassword(
+ @Body body: ResetPasswordRequestBody
+ ): Response
+
+ @GET("comment/{id}")
+ suspend fun getComment(
+ @Path("id") id: Int
+ ): Response>
+
+ @PATCH("account/my/extra")
+ suspend fun updateUserExtra(
+ @Body body: UpdateUserLangRequestBody
+ ): Response
+
+ @GET("account/my/chat/sign")
+ suspend fun getChatSign(): Response>
+
+ @GET("app/info")
+ suspend fun getAppConfig(): Response>
+
+ @GET("dict")
+ suspend fun getDict(
+ @Query("key") key: String
+ ): Response>
+
+ @GET("dicts")
+ suspend fun getDicts(
+ @Query("keys") keys: String
+ ): Response>
+
+ @POST("captcha/generate")
+ suspend fun generateCaptcha(
+ @Body body: CaptchaRequestBody
+ ): Response>
+
+ @POST("login/needCaptcha")
+ suspend fun checkLoginCaptcha(
+ @Body body: CheckLoginCaptchaRequestBody
+ ): Response>
+
+ @POST("captcha/login/generate")
+ suspend fun generateLoginCaptcha(
+ @Body body: GenerateLoginCaptchaRequestBody
+ ): Response>
+
+ @GET("chat/notification")
+ suspend fun getChatNotification(
+ @Query("targetTrtcId") targetTrtcId: String
+ ): Response>
+
+ @POST("chat/notification")
+ suspend fun updateChatNotification(
+ @Body body: UpdateChatNotificationRequestBody
+ ): Response>
+
+ @POST("reports")
+ suspend fun createReport(
+ @Body body: CreateReportRequestBody
+ ): Response
+
+ @POST("account/my/delete")
+ suspend fun deleteAccount(
+ @Body body: RemoveAccountRequestBody
+ ): Response
+
+ @GET("outside/prompts")
+ suspend fun getAgent(
+ @Query("page") page: Int = 1,
+ @Query("pageSize") pageSize: Int = 20,
+ @Query("withWorkflow") withWorkflow: Int = 1,
+ @Query("authorId") authorId: Int? = null,
+ @Query("categoryIds") categoryIds: List? = null,
+ @Query("random") random: Int? = null,
+ ): Response>>
+
+ @GET("outside/my/prompts")
+ suspend fun getMyAgent(
+ @Query("page") page: Int = 1,
+ @Query("pageSize") pageSize: Int = 20,
+ @Query("withWorkflow") withWorkflow: Int = 1,
+ ): Response>
+
+ @Multipart
+ @POST("outside/prompts")
+ suspend fun createAgent(
+ @Part avatar: MultipartBody.Part?,
+ @Part("title") title: RequestBody?,
+ @Part("value") value: RequestBody?,
+ @Part("desc") desc: RequestBody?,
+ @Part("workflowId") workflowId: RequestBody?,
+ @Part("public") isPublic: RequestBody?,
+ @Part("breakMode") breakMode: RequestBody?,
+ @Part("useWorkflow") useWorkflow: RequestBody?,
+ @Part("workflowInputs") workflowInputs: RequestBody?,
+ ): Response>
+
+
+ @POST("generate/postText")
+ suspend fun agentMoment(@Body body: AgentMomentRequestBody): Response>
+
+ @GET("outside/rooms/open")
+ suspend fun createGroupChatAi(
+ @Query("trtcGroupId") trtcGroupId: String? = null,
+ @Query("roomId") roomId: Int? = null
+ ): Response>
+
+ @POST("outside/rooms/create-single-chat")
+ suspend fun createSingleChat(@Body body: SingleChatRequestBody): Response>
+
+ @POST("outside/rooms/message")
+ suspend fun sendChatAiMessage(@Body body: SendChatAiRequestBody): Response>
+
+ @POST("outside/rooms")
+ suspend fun createGroupChat(@Body body: CreateGroupChatRequestBody): Response>
+
+ @GET("outside/rooms")
+ suspend fun getRooms(@Query("page") page: Int = 1,
+ @Query("pageSize") pageSize: Int = 20,
+ @Query("isRecommended") isRecommended: Int = 1,
+ @Query("random") random: Int? = null,
+ ): Response>
+
+ @GET("outside/rooms/detail")
+ suspend fun getRoomDetail(@Query("trtcId") trtcId: String,
+ ): Response>
+
+ @POST("outside/rooms/join")
+ suspend fun joinRoom(@Body body: JoinGroupChatRequestBody,
+ ): Response>
+
+ @GET("outside/categories")
+ suspend fun getCategories(
+ @Query("page") page: Int? = null,
+ @Query("pageSize") pageSize: Int? = null,
+ @Query("parentId") parentId: Int? = null,
+ @Query("isActive") isActive: Boolean? = null,
+ @Query("name") name: String? = null,
+ @Query("withChildren") withChildren: Boolean? = null,
+ @Query("withParent") withParent: Boolean? = null,
+ @Query("withCount") withCount: Boolean? = null,
+ @Query("hideEmpty") hideEmpty: Boolean? = null,
+ @Query("lang") lang: String? = null
+ ): Response
+
+ @GET("outside/categories/tree")
+ suspend fun getCategoryTree(
+ @Query("withCount") withCount: Boolean? = null,
+ @Query("hideEmpty") hideEmpty: Boolean? = null
+ ): Response>>
+
+ @GET("outside/categories/{id}")
+ suspend fun getCategoryById(
+ @Path("id") id: Int
+ ): Response>
+
+ @GET("outside/prompts")
+ suspend fun getPromptsByCategory(
+ @Query("categoryIds") categoryIds: List? = null,
+ @Query("categoryName") categoryName: String? = null,
+ @Query("uncategorized") uncategorized: String? = null,
+ @Query("page") page: Int? = null,
+ @Query("pageSize") pageSize: Int? = null
+ ): Response>
+
+}
+
diff --git a/app/src/main/java/com/aiosman/ravenow/data/membership/MembershipModels.kt b/app/src/main/java/com/aiosman/ravenow/data/membership/MembershipModels.kt
new file mode 100644
index 0000000..8836fe4
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/data/membership/MembershipModels.kt
@@ -0,0 +1,192 @@
+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
+)
+
+data class Member(
+ @SerializedName("name") val name: String,
+ @SerializedName("benefits") val benefits: List,
+ @SerializedName("goods") val goods: List,
+)
+
+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): List {
+ 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): List {
+ if (members.size < 3) return emptyList()
+ val standard = members[1]
+ val pro = members[2]
+
+ val list = mutableListOf()
+ // 首月(示例:如果后端 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 }
+}
+
+
diff --git a/app/src/main/java/com/aiosman/ravenow/entity/Account.kt b/app/src/main/java/com/aiosman/ravenow/entity/Account.kt
new file mode 100644
index 0000000..db1c616
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/entity/Account.kt
@@ -0,0 +1,205 @@
+package com.aiosman.ravenow.entity
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.aiosman.ravenow.data.AccountFollow
+import com.aiosman.ravenow.data.AccountService
+import com.aiosman.ravenow.data.Image
+import com.aiosman.ravenow.data.api.ApiClient
+import java.io.IOException
+import java.util.Date
+
+/**
+ * 用户点赞
+ */
+data class AccountLikeEntity(
+ // 动态
+ val post: NoticePostEntity?,
+ // 回复评论
+ val comment: NoticeCommentEntity?,
+ // 点赞用户
+ val user: NoticeUserEntity,
+ // 点赞时间
+ val likeTime: Date,
+ // 动态ID
+ val postId: Int
+)
+
+/**
+ * 用户收藏
+ */
+data class AccountFavouriteEntity(
+ // 动态
+ val post: NoticePostEntity,
+ // 收藏用户
+ val user: NoticeUserEntity,
+ // 收藏时间
+ val favoriteTime: Date,
+)
+
+/**
+ * 用户信息
+ */
+data class AccountProfileEntity(
+ // 用户ID
+ val id: Int,
+ // 粉丝数
+ val followerCount: Int,
+ // 关注数
+ val followingCount: Int,
+ // 昵称
+ val nickName: String,
+ // 头像
+ val avatar: String,
+ // 个人简介
+ val bio: String,
+ // 国家
+ val country: String,
+ // 是否关注,针对当前登录用户
+ val isFollowing: Boolean,
+ // 主页背景图
+ val banner: String?,
+ // trtcUserId
+ val trtcUserId: String,
+ val chatToken: String?,
+
+ val aiAccount: Boolean,
+ val rawAvatar: String,
+
+ val chatAIId: String,
+)
+
+/**
+ * 消息关联的动态
+ */
+data class NoticePostEntity(
+ // 动态ID
+ val id: Int,
+ // 动态内容
+ val textContent: String,
+ // 动态图片
+ val images: List,
+ // 时间
+ val time: Date,
+)
+
+data class NoticeCommentEntity(
+ // 评论ID
+ val id: Int,
+ // 评论内容
+ val content: String,
+ // 评论时间
+ val time: Date,
+ // 引用评论
+ val replyComment: NoticeCommentEntity?,
+ // 动态
+ val postId: Int,
+ // 动态
+ val post : NoticePostEntity?,
+)
+
+/**
+ * 消息关联的用户
+ */
+data class NoticeUserEntity(
+ // 用户ID
+ val id: Int,
+ // 昵称
+ val nickName: String,
+ // 头像
+ val avatar: String,
+)
+
+/**
+ * 用户点赞消息分页数据加载器
+ */
+class LikeItemPagingSource(
+ private val accountService: AccountService,
+) : PagingSource() {
+ override suspend fun load(params: LoadParams): LoadResult {
+ return try {
+ val currentPage = params.key ?: 1
+ val likes = accountService.getMyLikeNotice(
+ page = currentPage,
+ pageSize = 20,
+ )
+
+ LoadResult.Page(
+ data = likes.list.map {
+ it.toAccountLikeEntity()
+ },
+ prevKey = if (currentPage == 1) null else currentPage - 1,
+ nextKey = if (likes.list.isEmpty()) null else likes.page + 1
+ )
+ } catch (exception: IOException) {
+ return LoadResult.Error(exception)
+ }
+ }
+
+ override fun getRefreshKey(state: PagingState): Int? {
+ return state.anchorPosition
+ }
+}
+
+/**
+ * 用户收藏消息分页数据加载器
+ */
+class FavoriteItemPagingSource(
+ private val accountService: AccountService,
+) : PagingSource() {
+ override suspend fun load(params: LoadParams): LoadResult {
+ return try {
+ val currentPage = params.key ?: 1
+ val favouriteListContainer = accountService.getMyFavouriteNotice(
+ page = currentPage,
+ pageSize = 20,
+ )
+ LoadResult.Page(
+ data = favouriteListContainer.list.map {
+ it.toAccountFavouriteEntity()
+ },
+ prevKey = if (currentPage == 1) null else currentPage - 1,
+ nextKey = if (favouriteListContainer.list.isEmpty()) null else favouriteListContainer.page + 1
+ )
+ } catch (exception: IOException) {
+ return LoadResult.Error(exception)
+ }
+ }
+
+ override fun getRefreshKey(state: PagingState): Int? {
+ return state.anchorPosition
+ }
+}
+
+/**
+ * 用户关注消息分页数据加载器
+ */
+class FollowItemPagingSource(
+ private val accountService: AccountService,
+) : PagingSource() {
+ override suspend fun load(params: LoadParams): LoadResult {
+ return try {
+ val currentPage = params.key ?: 1
+ val followListContainer = accountService.getMyFollowNotice(
+ page = currentPage,
+ pageSize = 20,
+ )
+
+ LoadResult.Page(
+ data = followListContainer.list.map {
+ it.copy(
+ avatar = "${ApiClient.BASE_SERVER}${it.avatar}",
+ )
+ },
+ prevKey = if (currentPage == 1) null else currentPage - 1,
+ nextKey = if (followListContainer.list.isEmpty()) null else followListContainer.page + 1
+ )
+ } catch (exception: IOException) {
+ return LoadResult.Error(exception)
+ }
+ }
+
+ override fun getRefreshKey(state: PagingState): Int? {
+ return state.anchorPosition
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/entity/Agent.kt b/app/src/main/java/com/aiosman/ravenow/entity/Agent.kt
new file mode 100644
index 0000000..e979b24
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/entity/Agent.kt
@@ -0,0 +1,263 @@
+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.MomentService
+import com.aiosman.ravenow.data.ServiceException
+import com.aiosman.ravenow.data.UploadImage
+import com.aiosman.ravenow.data.UserService
+import com.aiosman.ravenow.data.api.ApiClient
+import okhttp3.MediaType
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import retrofit2.http.Part
+import java.io.File
+import java.io.IOException
+
+/**
+ * 智能体
+ */
+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() {
+ override suspend fun load(params: LoadParams): LoadResult {
+ 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? {
+ return state.anchorPosition
+ }
+
+}
+
+
+class AgentRemoteDataSource(
+ private val agentService: AgentService,
+) {
+ suspend fun getAgent(
+ pageNumber: Int,
+ authorId: Int? = null
+ ): ListContainer? {
+ return agentService.getAgent(
+ pageNumber = pageNumber,
+ authorId = authorId
+ )
+ }
+}
+
+class AgentServiceImpl() : AgentService {
+ val agentBackend = AgentBackend()
+
+ override suspend fun getAgent(
+ pageNumber: Int,
+ pageSize: Int,
+ authorId: Int?
+ ): ListContainer? {
+ return agentBackend.getAgent(
+ pageNumber = pageNumber,
+ authorId = authorId
+ )
+ }
+}
+
+class AgentBackend {
+ val DataBatchSize = 20
+ suspend fun getAgent(
+ pageNumber: Int,
+ authorId: Int? = null
+ ): ListContainer? {
+ // 如果是游客模式且获取我的Agent(authorId为null),返回空列表
+ if (authorId == null && AppStore.isGuest) {
+ return ListContainer(
+ total = 0,
+ page = pageNumber,
+ pageSize = DataBatchSize,
+ list = emptyList()
+ )
+ }
+
+ val resp = if (authorId != null) {
+ ApiClient.api.getAgent(
+ page = pageNumber,
+ pageSize = DataBatchSize,
+ authorId = authorId
+ )
+ } else {
+ ApiClient.api.getMyAgent(
+ page = pageNumber,
+ pageSize = DataBatchSize
+ )
+ }
+
+ val body = resp.body() ?: return null
+
+ // 处理不同的返回类型
+ return if (authorId != null) {
+ // getAgent 返回 DataContainer>
+ val dataContainer =
+ body as com.aiosman.ravenow.data.DataContainer>
+ val listContainer = dataContainer.data
+ ListContainer(
+ total = listContainer.total,
+ page = pageNumber,
+ pageSize = DataBatchSize,
+ list = listContainer.list.map { it.toAgentEntity() }
+ )
+ } else {
+ // getMyAgent 返回 ListContainer
+ val listContainer =
+ body as com.aiosman.ravenow.data.ListContainer
+ 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() {
+ override suspend fun fetchData(
+ page: Int,
+ pageSize: Int,
+ extra: AgentLoaderExtraArgs
+ ): ListContainer {
+ // 如果是游客模式且获取我的Agent(authorId为null),返回空列表
+ if (extra.authorId == null && AppStore.isGuest) {
+ return ListContainer(
+ total = 0,
+ page = page,
+ pageSize = pageSize,
+ list = emptyList()
+ )
+ }
+
+ val result = if (extra.authorId != null) {
+ ApiClient.api.getAgent(
+ page = page,
+ pageSize = pageSize,
+ authorId = extra.authorId
+ )
+ } else {
+ ApiClient.api.getMyAgent(
+ page = page,
+ pageSize = pageSize
+ )
+ }
+
+ val body = result.body() ?: throw ServiceException("Failed to get agent")
+
+ return if (extra.authorId != null) {
+ // getAgent 返回 DataContainer>
+ val dataContainer = body as DataContainer>
+ val listContainer = dataContainer.data
+ ListContainer(
+ list = listContainer.list.map { it.toAgentEntity() },
+ total = listContainer.total,
+ page = page,
+ pageSize = pageSize
+ )
+ } else {
+ // getMyAgent 返回 ListContainer
+ val listContainer =
+ body as com.aiosman.ravenow.data.ListContainer
+ ListContainer(
+ list = listContainer.list.map { it.toAgentEntity() },
+ total = listContainer.total,
+ page = page,
+ pageSize = pageSize
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/entity/Chat.kt b/app/src/main/java/com/aiosman/ravenow/entity/Chat.kt
new file mode 100644
index 0000000..5b568f1
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/entity/Chat.kt
@@ -0,0 +1,114 @@
+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 = emptyList().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().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
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/entity/Comment.kt b/app/src/main/java/com/aiosman/ravenow/entity/Comment.kt
new file mode 100644
index 0000000..02fac34
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/entity/Comment.kt
@@ -0,0 +1,64 @@
+package com.aiosman.ravenow.entity
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.aiosman.ravenow.data.CommentRemoteDataSource
+import com.aiosman.ravenow.data.NoticePost
+import java.util.Date
+
+data class CommentEntity(
+ val id: Int,
+ val name: String,
+ val comment: String,
+ val date: Date,
+ val likes: Int,
+ val postId: Int = 0,
+ val avatar: String,
+ val author: Long,
+ var liked: Boolean,
+ var unread: Boolean = false,
+ var post: NoticePost?,
+ var reply: List,
+ var replyUserId: Long?,
+ var replyUserNickname: String?,
+ var replyUserAvatar: String?,
+ var parentCommentId: Int?,
+ var replyCount: Int,
+ var replyPage: Int = 1
+)
+
+class CommentPagingSource(
+ private val remoteDataSource: CommentRemoteDataSource,
+ private val postId: Int? = null,
+ private val postUser: Int? = null,
+ private val selfNotice: Boolean? = null,
+ private val order: String? = null,
+ private val parentCommentId: Int? = null
+) : PagingSource() {
+ override suspend fun load(params: LoadParams): LoadResult {
+ return try {
+ val currentPage = params.key ?: 1
+ val comments = remoteDataSource.getComments(
+ pageNumber = currentPage,
+ postId = postId,
+ postUser = postUser,
+ selfNotice = selfNotice,
+ order = order,
+ parentCommentId = parentCommentId,
+ pageSize = params.loadSize
+ )
+ LoadResult.Page(
+ data = comments.list,
+ prevKey = if (currentPage == 1) null else currentPage - 1,
+ nextKey = if (comments.list.isEmpty()) null else comments.page + 1
+ )
+ } catch (exception: Exception) {
+ return LoadResult.Error(exception)
+ }
+ }
+
+ override fun getRefreshKey(state: PagingState): Int? {
+ return state.anchorPosition
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/entity/Common.kt b/app/src/main/java/com/aiosman/ravenow/entity/Common.kt
new file mode 100644
index 0000000..677a21b
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/entity/Common.kt
@@ -0,0 +1,19 @@
+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
+) {
+ 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"]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/entity/Group.kt b/app/src/main/java/com/aiosman/ravenow/entity/Group.kt
new file mode 100644
index 0000000..0f11a02
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/entity/Group.kt
@@ -0,0 +1,16 @@
+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,
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/entity/Loader.kt b/app/src/main/java/com/aiosman/ravenow/entity/Loader.kt
new file mode 100644
index 0000000..187f972
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/entity/Loader.kt
@@ -0,0 +1,109 @@
+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 {
+ var list: MutableList = mutableListOf()
+ var page by mutableStateOf(1)
+ var total by mutableStateOf(0)
+ var pageSize by mutableStateOf(10)
+ var hasNext by mutableStateOf(true)
+ var isLoading by mutableStateOf(false)
+ var error by mutableStateOf(null)
+ var onListChanged: ((List) -> Unit)? = null
+ var onError: ((String) -> Unit)? = null
+ private var firstLoad = true
+
+
+ abstract suspend fun fetchData(
+ page: Int, pageSize: Int, extra: ET
+ ): ListContainer
+
+ 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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/entity/Moment.kt b/app/src/main/java/com/aiosman/ravenow/entity/Moment.kt
new file mode 100644
index 0000000..8bb0878
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/entity/Moment.kt
@@ -0,0 +1,401 @@
+package com.aiosman.ravenow.entity
+
+import androidx.annotation.DrawableRes
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.aiosman.ravenow.data.ListContainer
+import com.aiosman.ravenow.data.MomentService
+import com.aiosman.ravenow.data.ServiceException
+import com.aiosman.ravenow.data.UploadImage
+import com.aiosman.ravenow.data.api.AgentMomentRequestBody
+import com.aiosman.ravenow.data.api.ApiClient
+import com.aiosman.ravenow.data.parseErrorResponse
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.io.File
+import java.io.IOException
+import java.util.Date
+
+/**
+ * 动态分页加载器
+ */
+class MomentPagingSource(
+ private val remoteDataSource: MomentRemoteDataSource,
+ private val author: Int? = null,
+ private val timelineId: Int? = null,
+ private val contentSearch: String? = null,
+ private val trend: Boolean? = false,
+ private val explore: Boolean? = false,
+ private val favoriteUserId: Int? = null
+) : PagingSource() {
+ override suspend fun load(params: LoadParams): LoadResult {
+ return try {
+ val currentPage = params.key ?: 1
+ val moments = remoteDataSource.getMoments(
+ pageNumber = currentPage,
+ author = author,
+ timelineId = timelineId,
+ contentSearch = contentSearch,
+ trend = trend,
+ explore = explore,
+ favoriteUserId = favoriteUserId
+ )
+
+ LoadResult.Page(
+ data = moments.list,
+ prevKey = if (currentPage == 1) null else currentPage - 1,
+ nextKey = if (moments.list.isEmpty()) null else moments.page + 1
+ )
+ } catch (exception: IOException) {
+ return LoadResult.Error(exception)
+ }
+ }
+
+ override fun getRefreshKey(state: PagingState): Int? {
+ return state.anchorPosition
+ }
+
+}
+
+class MomentRemoteDataSource(
+ private val momentService: MomentService,
+) {
+ suspend fun getMoments(
+ pageNumber: Int,
+ author: Int?,
+ timelineId: Int?,
+ contentSearch: String?,
+ trend: Boolean?,
+ explore: Boolean?,
+ favoriteUserId: Int?
+ ): ListContainer {
+ return momentService.getMoments(
+ pageNumber = pageNumber,
+ author = author,
+ timelineId = timelineId,
+ contentSearch = contentSearch,
+ trend = trend,
+ explore = explore,
+ favoriteUserId = favoriteUserId
+ )
+ }
+}
+
+class MomentServiceImpl() : MomentService {
+ val momentBackend = MomentBackend()
+
+ override suspend fun getMoments(
+ pageNumber: Int,
+ author: Int?,
+ timelineId: Int?,
+ contentSearch: String?,
+ trend: Boolean?,
+ explore: Boolean?,
+ favoriteUserId: Int?,
+ ): ListContainer {
+ return momentBackend.fetchMomentItems(
+ pageNumber = pageNumber,
+ author = author,
+ timelineId = timelineId,
+ contentSearch = contentSearch,
+ trend = trend,
+ favoriteUserId = favoriteUserId,
+ explore = explore
+ )
+ }
+
+ override suspend fun getMomentById(id: Int): MomentEntity {
+ return momentBackend.getMomentById(id)
+ }
+
+
+ override suspend fun likeMoment(id: Int) {
+ momentBackend.likeMoment(id)
+ }
+
+ override suspend fun dislikeMoment(id: Int) {
+ momentBackend.dislikeMoment(id)
+ }
+
+ override suspend fun createMoment(
+ content: String,
+ authorId: Int,
+ images: List,
+ relPostId: Int?
+ ): MomentEntity {
+ return momentBackend.createMoment(content, authorId, images, relPostId)
+ }
+
+ override suspend fun agentMoment(content: String): String {
+ return momentBackend.agentMoment(content)
+ }
+
+ override suspend fun favoriteMoment(id: Int) {
+ momentBackend.favoriteMoment(id)
+ }
+
+ override suspend fun unfavoriteMoment(id: Int) {
+ momentBackend.unfavoriteMoment(id)
+ }
+
+ override suspend fun deleteMoment(id: Int) {
+ momentBackend.deleteMoment(id)
+ }
+
+}
+
+class MomentBackend {
+ val DataBatchSize = 20
+ suspend fun fetchMomentItems(
+ pageNumber: Int,
+ author: Int? = null,
+ timelineId: Int?,
+ contentSearch: String?,
+ trend: Boolean?,
+ explore: Boolean?,
+ favoriteUserId: Int? = null
+ ): ListContainer {
+ val resp = ApiClient.api.getPosts(
+ pageSize = DataBatchSize,
+ page = pageNumber,
+ timelineId = timelineId,
+ authorId = author,
+ contentSearch = contentSearch,
+ trend = if (trend == true) "true" else "",
+ favouriteUserId = favoriteUserId,
+ explore = if (explore == true) "true" else ""
+ )
+ val body = resp.body() ?: throw ServiceException("Failed to get moments")
+ return ListContainer(
+ total = body.total,
+ page = pageNumber,
+ pageSize = DataBatchSize,
+ list = body.list.map { it.toMomentItem() }
+ )
+ }
+
+ suspend fun getMomentById(id: Int): MomentEntity {
+ var resp = ApiClient.api.getPost(id)
+ if (!resp.isSuccessful) {
+ parseErrorResponse(resp.errorBody())?.let {
+ throw it.toServiceException()
+ }
+ throw ServiceException("Failed to get moment")
+ }
+ return resp.body()?.data?.toMomentItem() ?: throw ServiceException("Failed to get moment")
+ }
+
+ suspend fun likeMoment(id: Int) {
+ ApiClient.api.likePost(id)
+ }
+
+ suspend fun dislikeMoment(id: Int) {
+ ApiClient.api.dislikePost(id)
+ }
+
+ fun createMultipartBody(file: File, name: String): MultipartBody.Part {
+ val requestFile = RequestBody.create("image/*".toMediaTypeOrNull(), file)
+ return MultipartBody.Part.createFormData(name, file.name, requestFile)
+ }
+
+ suspend fun createMoment(
+ content: String,
+ authorId: Int,
+ imageUriList: List,
+ relPostId: Int?
+ ): MomentEntity {
+ val textContent = content.toRequestBody("text/plain".toMediaTypeOrNull())
+ val imageList = imageUriList.map { item ->
+ val file = item.file
+ createMultipartBody(file, "image")
+ }
+ val response = ApiClient.api.createPost(imageList, textContent = textContent)
+ val body = response.body()?.data ?: throw ServiceException("Failed to create moment")
+ return body.toMomentItem()
+
+ }
+
+ suspend fun agentMoment(
+ content: String,
+ ): String {
+ val textContent = content.toRequestBody("text/plain".toMediaTypeOrNull())
+ val sessionId = ""
+ val response = ApiClient.api.agentMoment(AgentMomentRequestBody(generateText = content, sessionId =sessionId ))
+ val body = response.body()?.data ?: throw ServiceException("Failed to agent moment")
+ return body.toString()
+
+ }
+
+ suspend fun favoriteMoment(id: Int) {
+ ApiClient.api.favoritePost(id)
+ }
+
+ suspend fun unfavoriteMoment(id: Int) {
+ ApiClient.api.unfavoritePost(id)
+ }
+
+ suspend fun deleteMoment(id: Int) {
+ ApiClient.api.deletePost(id)
+ }
+
+}
+
+/**
+ * 动态图片
+ */
+data class MomentImageEntity(
+ // 图片ID
+ val id: Long,
+ // 图片URL
+ val url: String,
+ // 缩略图URL
+ val thumbnail: String,
+ // 图片BlurHash
+ val blurHash: String? = null,
+ // 宽度
+ var width: Int? = null,
+ // 高度
+ var height: Int? = null
+)
+
+/**
+ * 动态
+ */
+data class MomentEntity(
+ // 动态ID
+ val id: Int,
+ // 作者头像
+ val avatar: String,
+ // 作者昵称
+ val nickname: String,
+ // 区域
+ val location: String,
+ // 动态时间
+ val time: Date,
+ // 是否关注
+ val followStatus: Boolean,
+ // 动态内容
+ val momentTextContent: String,
+ // 动态图片
+ @DrawableRes val momentPicture: Int,
+ // 点赞数
+ val likeCount: Int,
+ // 评论数
+ val commentCount: Int,
+ // 分享数
+ val shareCount: Int,
+ // 收藏数
+ val favoriteCount: Int,
+ // 动态图片列表
+ val images: List = emptyList(),
+ // 作者ID
+ val authorId: Int = 0,
+ // 是否点赞
+ var liked: Boolean = false,
+ // 关联动态ID
+ var relPostId: Int? = null,
+ // 关联动态
+ var relMoment: MomentEntity? = null,
+ // 是否收藏
+ var isFavorite: Boolean = false,
+ // 新闻相关字段
+ val isNews: Boolean = false,
+ val newsTitle: String = "",
+ val newsUrl: String = "",
+ val newsSource: String = "",
+ val newsCategory: String = "",
+ val newsLanguage: String = "",
+ val newsContent: String = ""
+)
+class MomentLoaderExtraArgs(
+ val explore: Boolean? = false,
+ val timelineId: Int? = null,
+ val authorId : Int? = null,
+ val newsOnly: Boolean? = null
+)
+class MomentLoader : DataLoader() {
+ override suspend fun fetchData(
+ page: Int,
+ pageSize: Int,
+ extra: MomentLoaderExtraArgs
+ ): ListContainer {
+ val result = ApiClient.api.getPosts(
+ page = page,
+ pageSize = pageSize,
+ explore = if (extra.explore == true) "true" else "",
+ timelineId = extra.timelineId,
+ authorId = extra.authorId,
+ newsFilter = if (extra.newsOnly == true) "news_only" else ""
+ )
+ val data = result.body()?.let {
+ ListContainer(
+ list = it.list.map { it.toMomentItem() },
+ total = it.total,
+ page = page,
+ pageSize = pageSize
+ )
+ }
+ if (data == null) {
+ throw ServiceException("Failed to get moments")
+ }
+ return data
+ }
+
+ fun updateMomentLike(id: Int,isLike:Boolean) {
+ this.list = this.list.map { momentItem ->
+ if (momentItem.id == id) {
+ momentItem.copy(likeCount = momentItem.likeCount + if (isLike) 1 else -1, liked = isLike)
+ } else {
+ momentItem
+ }
+ }.toMutableList()
+ onListChanged?.invoke(this.list)
+ }
+
+ fun updateFavoriteCount(id: Int,isFavorite:Boolean) {
+ this.list = this.list.map { momentItem ->
+ if (momentItem.id == id) {
+ momentItem.copy(favoriteCount = momentItem.favoriteCount + if (isFavorite) 1 else -1, isFavorite = isFavorite)
+ } else {
+ momentItem
+ }
+ }.toMutableList()
+ onListChanged?.invoke(this.list)
+ }
+
+ fun updateCommentCount(id: Int, delta: Int) {
+ this.list = this.list.map { momentItem ->
+ if (momentItem.id == id) {
+ val newCount = (momentItem.commentCount + delta).coerceAtLeast(0)
+ momentItem.copy(commentCount = newCount)
+ } else {
+ momentItem
+ }
+ }.toMutableList()
+ onListChanged?.invoke(this.list)
+ }
+
+ fun removeMoment(id: Int) {
+ this.list = this.list.filter { it.id != id }.toMutableList()
+ onListChanged?.invoke(this.list)
+ }
+
+ fun addMoment(moment: MomentEntity) {
+ this.list.add(0, moment)
+ onListChanged?.invoke(this.list)
+ }
+
+ fun updateFollowStatus(authorId:Int,isFollow:Boolean) {
+ this.list = this.list.map { momentItem ->
+ if (momentItem.authorId == authorId) {
+ momentItem.copy(followStatus = isFollow)
+ } else {
+ momentItem
+ }
+ }.toMutableList()
+ onListChanged?.invoke(this.list)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/entity/Room.kt b/app/src/main/java/com/aiosman/ravenow/entity/Room.kt
new file mode 100644
index 0000000..26c4b0a
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/entity/Room.kt
@@ -0,0 +1,85 @@
+package com.aiosman.ravenow.entity
+
+import com.aiosman.ravenow.data.ListContainer
+import com.aiosman.ravenow.data.ServiceException
+import com.aiosman.ravenow.data.api.ApiClient
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import java.io.File
+
+/**
+ * 群聊房间
+ */
+
+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 maxMemberLimit: Int,
+ val canJoin: Boolean,
+ val canJoinCode: Int,
+ val users: List,
+)
+
+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,
+)
+
+class RoomLoader : DataLoader() {
+ override suspend fun fetchData(
+ page: Int,
+ pageSize: Int,
+ extra: AgentLoaderExtraArgs
+ ): ListContainer {
+ 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
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/entity/User.kt b/app/src/main/java/com/aiosman/ravenow/entity/User.kt
new file mode 100644
index 0000000..a741a2a
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/entity/User.kt
@@ -0,0 +1,40 @@
+package com.aiosman.ravenow.entity
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.aiosman.ravenow.data.UserService
+import java.io.IOException
+
+/**
+ * 用户信息分页加载器
+ */
+class AccountPagingSource(
+ private val userService: UserService,
+ private val nickname: String? = null,
+ private val followerId: Int? = null,
+ private val followingId: Int? = null
+) : PagingSource() {
+ override suspend fun load(params: LoadParams): LoadResult {
+ return try {
+ val currentPage = params.key ?: 1
+ val users = userService.getUsers(
+ page = currentPage,
+ nickname = nickname,
+ followerId = followerId,
+ followingId = followingId
+ )
+ LoadResult.Page(
+ data = users.list,
+ prevKey = if (currentPage == 1) null else currentPage - 1,
+ nextKey = if (users.list.isEmpty()) null else users.page + 1
+ )
+ } catch (exception: IOException) {
+ return LoadResult.Error(exception)
+ }
+ }
+
+ override fun getRefreshKey(state: PagingState): Int? {
+ return state.anchorPosition
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/event/FollowChangeEvent.kt b/app/src/main/java/com/aiosman/ravenow/event/FollowChangeEvent.kt
new file mode 100644
index 0000000..bc5bafc
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/event/FollowChangeEvent.kt
@@ -0,0 +1,6 @@
+package com.aiosman.ravenow.event
+
+data class FollowChangeEvent(
+ val userId: Int,
+ val isFollow: Boolean
+)
diff --git a/app/src/main/java/com/aiosman/ravenow/event/MomentAddEvent.kt b/app/src/main/java/com/aiosman/ravenow/event/MomentAddEvent.kt
new file mode 100644
index 0000000..eb0d24f
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/event/MomentAddEvent.kt
@@ -0,0 +1,7 @@
+package com.aiosman.ravenow.event
+
+import com.aiosman.ravenow.entity.MomentEntity
+
+data class MomentAddEvent(
+ val moment:MomentEntity
+)
diff --git a/app/src/main/java/com/aiosman/ravenow/event/MomentFavouriteChangeEvent.kt b/app/src/main/java/com/aiosman/ravenow/event/MomentFavouriteChangeEvent.kt
new file mode 100644
index 0000000..04cc43e
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/event/MomentFavouriteChangeEvent.kt
@@ -0,0 +1,6 @@
+package com.aiosman.ravenow.event
+
+data class MomentFavouriteChangeEvent(
+ val postId: Int,
+ val isFavourite: Boolean
+)
diff --git a/app/src/main/java/com/aiosman/ravenow/event/MomentLikeChangeEvent.kt b/app/src/main/java/com/aiosman/ravenow/event/MomentLikeChangeEvent.kt
new file mode 100644
index 0000000..4f5eb0a
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/event/MomentLikeChangeEvent.kt
@@ -0,0 +1,7 @@
+package com.aiosman.ravenow.event
+
+data class MomentLikeChangeEvent(
+ val postId: Int,
+ val likeCount: Int?,
+ val isLike: Boolean
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/event/MomentRemoveEvent.kt b/app/src/main/java/com/aiosman/ravenow/event/MomentRemoveEvent.kt
new file mode 100644
index 0000000..e9734a4
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/event/MomentRemoveEvent.kt
@@ -0,0 +1,5 @@
+package com.aiosman.ravenow.event
+
+data class MomentRemoveEvent(
+ val postId: Int
+)
diff --git a/app/src/main/java/com/aiosman/ravenow/exp/Bitmap.kt b/app/src/main/java/com/aiosman/ravenow/exp/Bitmap.kt
new file mode 100644
index 0000000..6acb9b6
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/exp/Bitmap.kt
@@ -0,0 +1,9 @@
+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)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/exp/Date.kt b/app/src/main/java/com/aiosman/ravenow/exp/Date.kt
new file mode 100644
index 0000000..8375f99
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/exp/Date.kt
@@ -0,0 +1,81 @@
+package com.aiosman.ravenow.exp
+
+import android.content.Context
+import android.icu.text.SimpleDateFormat
+import android.icu.util.Calendar
+import com.aiosman.ravenow.R
+import java.util.Date
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+
+/**
+ * 格式化时间为 xx 前
+ */
+fun Date.timeAgo(context: Context): String {
+ val now = Date()
+ val diffInMillis = now.time - this.time
+
+ val seconds = diffInMillis / 1000
+ val minutes = seconds / 60
+ val hours = minutes / 60
+ val days = hours / 24
+ val years = days / 365
+
+ return when {
+ seconds < 60 -> context.getString(R.string.second_ago, seconds)
+ minutes < 60 -> context.getString(R.string.minute_ago, minutes)
+ hours < 24 -> context.getString(R.string.hour_ago, hours)
+ days < 365 -> context.getString(R.string.days_ago, days)
+ else -> SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(this)
+ }
+}
+
+/**
+ * 格式化时间为 xx-xx
+ */
+fun Date.formatPostTime(): String {
+ val now = Calendar.getInstance()
+ val calendar = Calendar.getInstance()
+ calendar.time = this
+ val year = calendar.get(Calendar.YEAR)
+ var nowYear = now.get(Calendar.YEAR)
+ val dateFormat = if (year == nowYear) {
+ SimpleDateFormat("MM-dd", Locale.getDefault())
+ } else {
+ SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
+ }
+ return dateFormat.format(this)
+}
+
+/**
+ * YYYY.DD.MM HH:MM
+ */
+fun Date.formatPostTime2(): String {
+ val calendar = Calendar.getInstance()
+ calendar.time = this
+ val year = calendar.get(Calendar.YEAR)
+ val month = calendar.get(Calendar.MONTH) + 1
+ val day = calendar.get(Calendar.DAY_OF_MONTH)
+ val hour = calendar.get(Calendar.HOUR_OF_DAY)
+ val minute = calendar.get(Calendar.MINUTE)
+ return "$year.$month.$day $hour:$minute"
+}
+
+fun Date.formatChatTime(context: Context): String {
+ val now = Date()
+ val diffInMillis = now.time - this.time
+
+ val seconds = TimeUnit.MILLISECONDS.toSeconds(diffInMillis)
+ val minutes = TimeUnit.MILLISECONDS.toMinutes(diffInMillis)
+ val hours = TimeUnit.MILLISECONDS.toHours(diffInMillis)
+ val days = TimeUnit.MILLISECONDS.toDays(diffInMillis)
+ val years = days / 365
+
+ return when {
+ seconds < 60 -> context.getString(R.string.seconds_ago, seconds)
+ minutes < 60 -> context.getString(R.string.minutes_ago, minutes)
+ hours < 24 -> SimpleDateFormat("HH:mm", Locale.getDefault()).format(this)
+ days < 365 -> SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()).format(this)
+ else -> SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(this)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/exp/StatusBarExp.kt b/app/src/main/java/com/aiosman/ravenow/exp/StatusBarExp.kt
new file mode 100644
index 0000000..08e26fd
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/exp/StatusBarExp.kt
@@ -0,0 +1,95 @@
+package com.aiosman.ravenow.exp
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.Context
+import android.content.res.Resources
+import android.os.Build
+import android.util.TypedValue
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import androidx.annotation.ColorInt
+//import androidx.appcompat.app.AppCompatActivity
+
+private const val COLOR_TRANSPARENT = 0
+
+@SuppressLint("ObsoleteSdkInt")
+@JvmOverloads
+fun Activity.immersive(@ColorInt color: Int = COLOR_TRANSPARENT, darkMode: Boolean? = null) {
+ when {
+ Build.VERSION.SDK_INT >= 21 -> {
+ when (color) {
+ COLOR_TRANSPARENT -> {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
+ var systemUiVisibility = window.decorView.systemUiVisibility
+ systemUiVisibility = systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ systemUiVisibility = systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ window.decorView.systemUiVisibility = systemUiVisibility
+ window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
+ window.statusBarColor = color
+ }
+ else -> {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
+ var systemUiVisibility = window.decorView.systemUiVisibility
+ systemUiVisibility = systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ systemUiVisibility = systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ window.decorView.systemUiVisibility = systemUiVisibility
+ window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
+ window.statusBarColor = color
+ }
+ }
+ }
+ Build.VERSION.SDK_INT >= 19 -> {
+ window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
+ if (color != COLOR_TRANSPARENT) {
+ setTranslucentView(window.decorView as ViewGroup, color)
+ }
+ }
+ }
+ if (darkMode != null) {
+ darkMode(darkMode)
+ }
+}
+
+@JvmOverloads
+fun Activity.darkMode(darkMode: Boolean = true) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ var systemUiVisibility = window.decorView.systemUiVisibility
+ systemUiVisibility = if (darkMode) {
+ systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
+ } else {
+ systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()
+ }
+ window.decorView.systemUiVisibility = systemUiVisibility
+ }
+}
+
+private fun Context.setTranslucentView(container: ViewGroup, color: Int) {
+ if (Build.VERSION.SDK_INT >= 19) {
+ var simulateStatusBar: View? = container.findViewById(android.R.id.custom)
+ if (simulateStatusBar == null && color != 0) {
+ simulateStatusBar = View(container.context)
+ simulateStatusBar.id = android.R.id.custom
+ val lp = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, statusBarHeight)
+ container.addView(simulateStatusBar, lp)
+ }
+ simulateStatusBar?.setBackgroundColor(color)
+ }
+}
+
+val Context?.statusBarHeight: Int
+ get() {
+ this ?: return 0
+ var result = 24
+ val resId = resources.getIdentifier("status_bar_height", "dimen", "android")
+ result = if (resId > 0) {
+ resources.getDimensionPixelSize(resId)
+ } else {
+ TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ result.toFloat(), Resources.getSystem().displayMetrics
+ ).toInt()
+ }
+ return result
+ }
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/exp/ViewModel.kt b/app/src/main/java/com/aiosman/ravenow/exp/ViewModel.kt
new file mode 100644
index 0000000..fdf410d
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/exp/ViewModel.kt
@@ -0,0 +1,9 @@
+package com.aiosman.ravenow.exp
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+
+inline fun viewModelFactory(crossinline f: () -> VM) =
+ object : ViewModelProvider.Factory {
+ override fun create(aClass: Class):T = f() as T
+ }
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/im/OpenIMManager.kt b/app/src/main/java/com/aiosman/ravenow/im/OpenIMManager.kt
new file mode 100644
index 0000000..835e149
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/im/OpenIMManager.kt
@@ -0,0 +1,342 @@
+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?) {
+ 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?) {
+ Log.d(TAG, "消息扩展信息变更: $msgID")
+ // 处理消息扩展信息变更
+ }
+
+ override fun onRecvMessageExtensionsDeleted(msgID: String?, list: List?) {
+ Log.d(TAG, "消息扩展信息删除: $msgID")
+ // 处理消息扩展信息删除
+ }
+
+ override fun onRecvMessageExtensionsAdded(msgID: String?, list: List?) {
+ Log.d(TAG, "消息扩展信息添加: $msgID")
+ // 处理消息扩展信息添加
+ }
+
+ override fun onMsgDeleted(message: Message?) {
+ Log.d(TAG, "消息被删除: ${message?.clientMsgID}")
+ // 处理消息删除
+ }
+
+ override fun onRecvOfflineNewMessage(msg: List?) {
+ 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?) {
+ Log.d(TAG, "会话发生变化,数量: ${conversationList?.size}")
+ // 处理会话变化
+ }
+
+ override fun onNewConversation(conversationList: MutableList?) {
+ 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
+ }
+}
diff --git a/app/src/main/java/com/aiosman/ravenow/llama.py b/app/src/main/java/com/aiosman/ravenow/llama.py
new file mode 100644
index 0000000..44c7129
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/llama.py
@@ -0,0 +1,23 @@
+import os
+
+def read_files_recursively(directory, output_filename="llama.txt"):
+ """
+ 递归读取指定目录下的所有文件,并将它们的内容按顺序写入一个新文件中。
+ 每个文件的内容以文件名和相对路径作为注释开头。
+ """
+
+ script_filename = os.path.basename(__file__) # 获取当前脚本的文件名
+
+ with open(output_filename, "w", encoding="utf-8") as outfile:
+ for root, dirs, files in os.walk(directory):
+ for filename in sorted(files):
+ if filename != script_filename:
+ filepath = os.path.join(root, filename)
+ relative_path = os.path.relpath(filepath, directory)
+ outfile.write(f"### {relative_path} ###\n")
+ with open(filepath, "r", encoding="utf-8") as infile:
+ outfile.write(infile.read())
+ outfile.write("\n\n")
+
+if __name__ == "__main__":
+ read_files_recursively(".") # 从当前目录开始递归读取
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/model/ChatNotificationData.kt b/app/src/main/java/com/aiosman/ravenow/model/ChatNotificationData.kt
new file mode 100644
index 0000000..6cb1ef7
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/model/ChatNotificationData.kt
@@ -0,0 +1,11 @@
+package com.aiosman.ravenow.model
+
+import androidx.annotation.DrawableRes
+
+data class ChatNotificationData(
+ @DrawableRes val avatar: Int,
+ val name: String,
+ val message: String,
+ val time: String,
+ val unread: Int
+)
diff --git a/app/src/main/java/com/aiosman/ravenow/model/ChatNotificationUtils.kt b/app/src/main/java/com/aiosman/ravenow/model/ChatNotificationUtils.kt
new file mode 100644
index 0000000..4ab2493
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/model/ChatNotificationUtils.kt
@@ -0,0 +1,50 @@
+package com.aiosman.ravenow.model
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import kotlin.math.ceil
+import kotlinx.coroutines.delay
+
+internal class TestChatBackend(
+ private val backendDataList: List,
+ private val loadDelay: Long = 500,
+) {
+ val DataBatchSize = 1
+ class DesiredLoadResultPageResponse(val data: List)
+ /** Returns [DataBatchSize] items for a key */
+ fun searchItemsByKey(key: Int): DesiredLoadResultPageResponse {
+ val maxKey = ceil(backendDataList.size.toFloat() / DataBatchSize).toInt()
+ if (key >= maxKey) {
+ return DesiredLoadResultPageResponse(emptyList())
+ }
+ val from = key * DataBatchSize
+ val to = minOf((key + 1) * DataBatchSize, backendDataList.size)
+ val currentSublist = backendDataList.subList(from, to)
+ return DesiredLoadResultPageResponse(currentSublist)
+ }
+ fun getAllData() = TestChatPagingSource(this, loadDelay)
+}
+internal class TestChatPagingSource(
+ private val backend: TestChatBackend,
+ private val loadDelay: Long,
+) : PagingSource() {
+ override suspend fun load(params: LoadParams): LoadResult {
+ // Simulate latency
+ delay(loadDelay)
+ val pageNumber = params.key ?: 0
+ val response = backend.searchItemsByKey(pageNumber)
+ // Since 0 is the lowest page number, return null to signify no more pages should
+ // be loaded before it.
+ val prevKey = if (pageNumber > 0) pageNumber - 1 else null
+ // This API defines that it's out of data when a page returns empty. When out of
+ // data, we return `null` to signify no more pages should be loaded
+ val nextKey = if (response.data.isNotEmpty()) pageNumber + 1 else null
+ return LoadResult.Page(data = response.data, prevKey = prevKey, nextKey = nextKey)
+ }
+ override fun getRefreshKey(state: PagingState): Int? {
+ return state.anchorPosition?.let {
+ state.closestPageToPosition(it)?.prevKey?.plus(1)
+ ?: state.closestPageToPosition(it)?.nextKey?.minus(1)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/model/UpdateInfo.kt b/app/src/main/java/com/aiosman/ravenow/model/UpdateInfo.kt
new file mode 100644
index 0000000..5c05bd9
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/model/UpdateInfo.kt
@@ -0,0 +1,41 @@
+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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/store.kt b/app/src/main/java/com/aiosman/ravenow/store.kt
new file mode 100644
index 0000000..470bee5
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/store.kt
@@ -0,0 +1,74 @@
+package com.aiosman.ravenow
+
+import android.content.Context
+import android.content.SharedPreferences
+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
+ if (sharedPreferences.getBoolean("darkMode", false)) {
+ AppState.darkMode = true
+ AppState.appTheme = DarkThemeColors()
+ }
+
+ // 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
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/test/TestStreetMap.kt b/app/src/main/java/com/aiosman/ravenow/test/TestStreetMap.kt
new file mode 100644
index 0000000..e976265
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/test/TestStreetMap.kt
@@ -0,0 +1,28 @@
+package com.aiosman.ravenow.test
+
+data class StreetPosition(
+ val name:String,
+ val lat:Double,
+ val lng:Double
+)
+val countries = listOf(
+ StreetPosition("哈龙湾, 越南",16.5000, 107.1000),
+ StreetPosition("芽庄, 越南",12.2500, 109.0833),
+ StreetPosition("岘港, 越南",16.0667, 108.2167),
+ StreetPosition("美奈, 越南",11.9333, 108.9833),
+ StreetPosition("富国岛, 越南",10.0000, 104.0000),
+ StreetPosition("金三角, 泰国, 缅甸, 老挝",20.2500, 99.7500),
+ StreetPosition("普吉岛, 泰国",7.9444, 98.3000),
+ StreetPosition("苏梅岛, 泰国",9.5333, 99.9333),
+ StreetPosition("曼谷, 泰国",13.7500, 100.5000),
+ StreetPosition("马六甲, 马来西亚",2.2000, 102.2500),
+ StreetPosition("兰卡威群岛, 马来西亚",6.3000, 99.9000),
+ StreetPosition("沙巴, 马来西亚",6.0833, 116.0833),
+ StreetPosition("巴厘岛, 印度尼西亚",8.3333, 115.1000),
+ StreetPosition("龙目岛, 印度尼西亚",8.3333, 116.4000),
+ StreetPosition("婆罗洲, 印度尼西亚",3.0000, 114.0000),
+ StreetPosition("宿务, 菲律宾",10.3167, 123.8833),
+ StreetPosition("长滩岛, 菲律宾",11.5833, 121.9167),
+ StreetPosition("保和岛, 菲律宾",10.3000, 123.3333),
+ StreetPosition("科隆岛, 菲律宾",5.1167, 119.3333)
+)
diff --git a/app/src/main/java/com/aiosman/ravenow/ui/Navi.kt b/app/src/main/java/com/aiosman/ravenow/ui/Navi.kt
new file mode 100644
index 0000000..63af769
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ui/Navi.kt
@@ -0,0 +1,701 @@
+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.RemoveAccountScreen
+import com.aiosman.ravenow.ui.account.ResetPasswordScreen
+import com.aiosman.ravenow.ui.agent.AddAgentScreen
+import com.aiosman.ravenow.ui.agent.AgentImageCropScreen
+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.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.index.tabs.profile.vip.VipSelPage
+import com.aiosman.ravenow.ui.notification.NotificationScreen
+
+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 VipSelPage : NavigationRoute("VipSelPage")
+ data object RemoveAccountScreen: NavigationRoute("RemoveAccount")
+ data object NotificationScreen : NavigationRoute("NotificationScreen")
+}
+
+
+@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
+ 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.RemoveAccountScreen.route) {
+ RemoveAccountScreen()
+ }
+ 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.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.NotificationScreen.route) {
+ CompositionLocalProvider(
+ LocalAnimatedContentScope provides this,
+ ) {
+ NotificationScreen()
+ }
+ }
+
+ }
+
+}
+
+@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.goTo(
+ route: NavigationRoute
+) {
+ navigate(route.route)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/ui/about/AboutScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/about/AboutScreen.kt
new file mode 100644
index 0000000..e091155
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ui/about/AboutScreen.kt
@@ -0,0 +1,82 @@
+package com.aiosman.ravenow.ui.about
+
+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.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_rave_now),
+ moreIcon = false
+ )
+ }
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .padding(start = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(48.dp))
+ // app icon
+ Box {
+ Image(
+ painter = painterResource(id = R.mipmap.rider_pro_color_logo_next),
+ contentDescription = "app icon",
+ modifier = Modifier.size(80.dp)
+ )
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ // app name
+ Text(
+ text = "Rave Now".uppercase(),
+ 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
+ )
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/ui/account/AccountEditViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/account/AccountEditViewModel.kt
new file mode 100644
index 0000000..cf56c7e
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ui/account/AccountEditViewModel.kt
@@ -0,0 +1,107 @@
+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(null)
+ val accountService: AccountService = AccountServiceImpl()
+ var profile by mutableStateOf(null)
+ var croppedBitmap by mutableStateOf(null)
+ var isUpdating by mutableStateOf(false)
+ var isLoading by mutableStateOf(false)
+ suspend fun reloadProfile(updateTrtcProfile: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
+ // 清除之前裁剪的图片
+ croppedBitmap = null
+ 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() {
+ profile?.let {
+ name = it.nickName
+ bio = it.bio
+ // 清除之前裁剪的图片
+ croppedBitmap = 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 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 = null,
+ nickName = newName,
+ bio = cleanBio
+ )
+ // 刷新用户资料
+ reloadProfile()
+ // 刷新个人资料页面的用户资料
+ MyProfileViewModel.loadUserProfile()
+ }
+
+ /**
+ * 重置ViewModel状态
+ * 用于用户登出或切换账号时清理数据
+ */
+ fun ResetModel() {
+ Log.d("AccountEditViewModel", "ResetModel: 重置ViewModel状态")
+ profile = null
+ name = ""
+ bio = ""
+ imageUrl = null
+ croppedBitmap = null
+ isUpdating = false
+ isLoading = false
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/ui/account/AccountSetting.kt b/app/src/main/java/com/aiosman/ravenow/ui/account/AccountSetting.kt
new file mode 100644
index 0000000..f6baafc
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ui/account/AccountSetting.kt
@@ -0,0 +1,117 @@
+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.WindowInsets
+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.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.aiosman.ravenow.AppState
+import com.aiosman.ravenow.AppStore
+import com.aiosman.ravenow.LocalAppTheme
+import com.aiosman.ravenow.LocalNavController
+import com.aiosman.ravenow.Messaging
+import com.aiosman.ravenow.R
+import com.aiosman.ravenow.data.AccountServiceImpl
+import com.aiosman.ravenow.ui.NavigationRoute
+import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
+import com.aiosman.ravenow.ui.composables.ActionButton
+import com.aiosman.ravenow.ui.composables.StatusBarSpacer
+import com.aiosman.ravenow.ui.index.NavItem
+import com.aiosman.ravenow.ui.modifiers.noRippleClickable
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AccountSetting() {
+ val appColors = LocalAppTheme.current
+ val navController = LocalNavController.current
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(appColors.background),
+ ) {
+ StatusBarSpacer()
+ Box(
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
+ ) {
+ NoticeScreenHeader(
+ title = stringResource(R.string.account_and_security),
+ moreIcon = false
+ )
+ }
+ Column(
+ modifier = Modifier.padding(start = 24.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp)
+ ) {
+ NavItem(
+ iconRes = R.mipmap.rider_pro_change_password,
+ label = stringResource(R.string.change_password),
+ modifier = Modifier.noRippleClickable {
+ navController.navigate(NavigationRoute.ChangePasswordScreen.route)
+ }
+ )
+ }
+
+ // 分割线
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(appColors.divider)
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp)
+ ) {
+ NavItem(
+ iconRes = R.drawable.rider_pro_moment_delete,
+ label = stringResource(R.string.remove_account),
+ modifier = Modifier.noRippleClickable {
+ navController.navigate(NavigationRoute.RemoveAccountScreen.route)
+ }
+ )
+ }
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(appColors.divider)
+ )
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/ui/account/ResetPassword.kt b/app/src/main/java/com/aiosman/ravenow/ui/account/ResetPassword.kt
new file mode 100644
index 0000000..c43b7ce
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ui/account/ResetPassword.kt
@@ -0,0 +1,205 @@
+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.PaddingValues
+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.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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.aiosman.ravenow.ConstVars
+import com.aiosman.ravenow.data.api.ErrorCode
+import com.aiosman.ravenow.LocalAppTheme
+import com.aiosman.ravenow.LocalNavController
+import com.aiosman.ravenow.R
+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.ServiceException
+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 kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@Composable
+fun ResetPasswordScreen() {
+ var username by remember { mutableStateOf("") }
+ val accountService: AccountService = AccountServiceImpl()
+ val dictService: DictService = DictServiceImpl()
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ var isSendSuccess by remember { mutableStateOf(null) }
+ var isLoading by remember { mutableStateOf(false) }
+ val navController = LocalNavController.current
+ var usernameError by remember { mutableStateOf(null) }
+ var countDown by remember { mutableStateOf(null) }
+ var countDownMax by remember { mutableStateOf(60) }
+ val appColors = LocalAppTheme.current
+ fun validate(): Boolean {
+ if (username.isEmpty()) {
+ usernameError = context.getString(R.string.text_error_email_required)
+ return false
+ }
+ usernameError = null
+ return true
+ }
+
+ LaunchedEffect(Unit) {
+ try {
+ dictService.getDictByKey(ConstVars.DIC_KEY_RESET_EMAIL_INTERVAL).let {
+ countDownMax = it.value as? Int ?: 60
+ }
+ } catch (e: Exception) {
+ countDownMax = 60
+ }
+ }
+
+ fun startCountDown() {
+ scope.launch {
+ countDown = countDownMax
+ while (countDown!! > 0) {
+ delay(1000)
+ countDown = countDown!! - 1
+ }
+ countDown = null
+ }
+ }
+
+ fun resetPassword() {
+ if (!validate()) return
+ scope.launch {
+ isLoading = true
+ try {
+ accountService.resetPassword(username)
+ isSendSuccess = true
+ startCountDown()
+ } catch (e: ServiceException) {
+ if (e.code == ErrorCode.USER_NOT_EXIST.code){
+ usernameError = context.getString(R.string.error_40002_user_not_exist)
+ } else {
+ Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: Exception) {
+ Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
+ isSendSuccess = false
+ } finally {
+ isLoading = false
+ }
+ }
+ }
+
+
+ Column(
+ modifier = Modifier.fillMaxSize().background(color = appColors.background)
+ ) {
+ StatusBarSpacer()
+ Box(
+ modifier = Modifier.padding(
+ start = 16.dp,
+ end = 16.dp,
+ top = 16.dp,
+ bottom = 0.dp
+ )
+ ) {
+ NoticeScreenHeader(
+ stringResource(R.string.recover_account_upper),
+ moreIcon = false
+ )
+ }
+ Spacer(modifier = Modifier.height(72.dp))
+ Column(
+ modifier = Modifier.padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ TextInputField(
+ text = username,
+ onValueChange = { username = it },
+ hint = stringResource(R.string.text_hint_email),
+ enabled = !isLoading && countDown == null,
+ error = usernameError,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Box(
+ modifier = Modifier.height(72.dp)
+ ) {
+ isSendSuccess?.let {
+ if (it) {
+ Text(
+ text = stringResource(R.string.reset_mail_send_success),
+ style = TextStyle(
+ color = appColors.text,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold
+ ),
+ modifier = Modifier.fillMaxSize()
+ )
+ } else {
+ Text(
+ text = stringResource(R.string.reset_mail_send_failed),
+ style = TextStyle(
+ color = appColors.text,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold
+ ),
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+ }
+
+ ActionButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(48.dp),
+ text = if (countDown != null) {
+ stringResource(R.string.resend, "(${countDown})")
+ } else {
+ stringResource(R.string.recover)
+ },
+ backgroundColor = appColors.main,
+ color = appColors.mainText,
+ isLoading = isLoading,
+ contentPadding = PaddingValues(0.dp),
+ enabled = countDown == null,
+ ) {
+ resetPassword()
+ }
+ isSendSuccess?.let {
+ Spacer(modifier = Modifier.height(16.dp))
+ ActionButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(48.dp),
+ text = stringResource(R.string.back_upper),
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ navController.navigateUp()
+ }
+
+ }
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/ui/account/changepassword.kt b/app/src/main/java/com/aiosman/ravenow/ui/account/changepassword.kt
new file mode 100644
index 0000000..2f01757
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ui/account/changepassword.kt
@@ -0,0 +1,164 @@
+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.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.aiosman.ravenow.LocalAppTheme
+import com.aiosman.ravenow.LocalNavController
+import com.aiosman.ravenow.R
+import com.aiosman.ravenow.data.AccountService
+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.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
+
+/**
+ * 修改密码页面的 ViewModel
+ */
+class ChangePasswordViewModel {
+ val accountService: AccountService = AccountServiceImpl()
+
+ /**
+ * 修改密码
+ * @param currentPassword 当前密码
+ * @param newPassword 新密码
+ */
+ suspend fun changePassword(currentPassword: String, newPassword: String) {
+ accountService.changeAccountPassword(currentPassword, newPassword)
+ }
+}
+
+/**
+ * 修改密码页面
+ */
+@Composable
+fun ChangePasswordScreen() {
+ val context = LocalContext.current
+ val viewModel = remember { ChangePasswordViewModel() }
+ var currentPassword by remember { mutableStateOf("") }
+ var newPassword by remember { mutableStateOf("") }
+ var confirmPassword by remember { mutableStateOf("") }
+
+ val scope = rememberCoroutineScope()
+ val navController = LocalNavController.current
+ var oldPasswordError by remember { mutableStateOf(null) }
+ var confirmPasswordError by remember { mutableStateOf(null) }
+ var passwordError by remember { mutableStateOf(null) }
+ val AppColors = LocalAppTheme.current
+ fun validate(): Boolean {
+ // 使用通用密码校验器校验当前密码
+ val currentPasswordValidation = PasswordValidator.validateCurrentPassword(currentPassword, context)
+ oldPasswordError = if (!currentPasswordValidation.isValid) currentPasswordValidation.errorMessage else null
+
+ // 使用通用密码校验器校验新密码
+ val newPasswordValidation = PasswordValidator.validatePassword(newPassword, context)
+ passwordError = if (!newPasswordValidation.isValid) newPasswordValidation.errorMessage else null
+
+ // 使用通用密码确认校验器
+ val confirmPasswordValidation = PasswordValidator.validatePasswordConfirmation(newPassword, confirmPassword, context)
+ confirmPasswordError = if (!confirmPasswordValidation.isValid) confirmPasswordValidation.errorMessage else null
+
+ return passwordError == null && confirmPasswordError == null && oldPasswordError == null
+ }
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(AppColors.background),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ StatusBarSpacer()
+ Box(
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
+ ) {
+ NoticeScreenHeader(
+ title = stringResource(R.string.change_password),
+ moreIcon = false
+ )
+
+ }
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ ) {
+ Spacer(modifier = Modifier.height(80.dp))
+ TextInputField(
+ text = currentPassword,
+ onValueChange = { currentPassword = it },
+ password = true,
+ label = stringResource(R.string.current_password),
+ hint = stringResource(R.string.current_password_tip5),
+ error = oldPasswordError
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ TextInputField(
+ text = newPassword,
+ onValueChange = { newPassword = it },
+ password = true,
+ label = stringResource(R.string.new_password),
+ hint = stringResource(R.string.new_password),
+ error = passwordError
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ TextInputField(
+ text = confirmPassword,
+ onValueChange = { confirmPassword = it },
+ password = true,
+ label = stringResource(R.string.confirm_new_password_tip1),
+ hint = stringResource(R.string.new_password_tip1),
+ error = confirmPasswordError
+ )
+ Spacer(modifier = Modifier.height(50.dp))
+ ActionButton(
+ modifier = Modifier
+ .width(345.dp),
+ text = stringResource(R.string.lets_ride_upper),
+ ) {
+ if (validate()) {
+ scope.launch {
+ try {
+ viewModel.changePassword(currentPassword, newPassword)
+
+ navController.navigateUp()
+ } catch (e: ServiceException) {
+ when (e.errorType) {
+ ErrorCode.IncorrectOldPassword ->
+ oldPasswordError = e.errorType.toErrorMessage(context)
+ else ->
+ e.errorType.showToast(context)
+ }
+ } catch (e: Exception) {
+ Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/ui/account/edit.kt b/app/src/main/java/com/aiosman/ravenow/ui/account/edit.kt
new file mode 100644
index 0000000..bb5fa90
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ui/account/edit.kt
@@ -0,0 +1,242 @@
+package com.aiosman.ravenow.ui.account
+
+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.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.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TopAppBar
+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.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import com.aiosman.ravenow.LocalNavController
+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.composables.CustomAsyncImage
+import com.aiosman.ravenow.ui.modifiers.noRippleClickable
+import com.aiosman.ravenow.ui.post.NewPostViewModel.uriToFile
+import kotlinx.coroutines.launch
+
+/**
+ * 编辑用户资料界面
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AccountEditScreen() {
+ val accountService: AccountService = AccountServiceImpl()
+ var name by remember { mutableStateOf("") }
+ var bio by remember { mutableStateOf("") }
+ var imageUrl by remember { mutableStateOf(null) }
+ var bannerImageUrl by remember { mutableStateOf(null) }
+ var profile by remember {
+ mutableStateOf(
+ null
+ )
+ }
+ val navController = LocalNavController.current
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+
+ /**
+ * 加载用户资料
+ */
+ suspend fun reloadProfile() {
+ accountService.getMyAccountProfile().let {
+ profile = it
+ name = it.nickName
+ bio = it.bio
+ }
+ }
+
+ fun updateUserProfile() {
+ scope.launch {
+ val newAvatar = imageUrl?.let {
+ val cursor = context.contentResolver.query(it, null, null, null, null)
+ var newAvatar: UploadImage? = null
+ cursor?.use { cur ->
+ if (cur.moveToFirst()) {
+ val columnIndex = cur.getColumnIndex("_display_name")
+ val displayName = if (columnIndex >= 0) cur.getString(columnIndex) else "unknown"
+ val extension = displayName.substringAfterLast(".")
+ Log.d("NewPost", "File name: $displayName, extension: $extension")
+ // read as file
+ val file = uriToFile(context, it)
+ Log.d("NewPost", "File size: ${file.length()}")
+ newAvatar = UploadImage(file, displayName, it.toString(), extension)
+ }
+ }
+ newAvatar
+ }
+ var newBanner = bannerImageUrl?.let {
+ val cursor = context.contentResolver.query(it, null, null, null, null)
+ var newBanner: UploadImage? = null
+ cursor?.use { cur ->
+ if (cur.moveToFirst()) {
+ val columnIndex = cur.getColumnIndex("_display_name")
+ val displayName = if (columnIndex >= 0) cur.getString(columnIndex) else "unknown"
+ val extension = displayName.substringAfterLast(".")
+ Log.d("NewPost", "File name: $displayName, extension: $extension")
+ // read as file
+ val file = uriToFile(context, it)
+ Log.d("NewPost", "File size: ${file.length()}")
+ newBanner = UploadImage(file, displayName, it.toString(), extension)
+ }
+ }
+ newBanner
+ }
+ val newName = if (name == profile?.nickName) null else name
+ accountService.updateProfile(
+ avatar = newAvatar,
+ banner = newBanner,
+ nickName = newName,
+ bio = bio
+ )
+ reloadProfile()
+ navController.popBackStack()
+ }
+ }
+
+ val pickImageLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ val uri = result.data?.data
+ uri?.let {
+ imageUrl = it
+ }
+ }
+ }
+ val pickBannerImageLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ val uri = result.data?.data
+ uri?.let {
+ bannerImageUrl = uri
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ reloadProfile()
+ }
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Edit") },
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(
+ onClick = {
+ updateUserProfile()
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = "Save"
+ )
+ }
+ }
+ ) { padding ->
+ profile?.let {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CustomAsyncImage(
+ context,
+ if (imageUrl != null) {
+ imageUrl.toString()
+ } else {
+ it.avatar
+ },
+ contentDescription = null,
+ modifier = Modifier
+ .size(100.dp)
+ .noRippleClickable {
+ Intent(Intent.ACTION_PICK).apply {
+ type = "image/*"
+ pickImageLauncher.launch(this)
+ }
+ },
+ contentScale = ContentScale.Crop
+ )
+ Spacer(modifier = Modifier.size(16.dp))
+ CustomAsyncImage(
+ context,
+ if (bannerImageUrl != null) {
+ bannerImageUrl.toString()
+ } else {
+ it.banner!!
+ },
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .noRippleClickable {
+ Intent(Intent.ACTION_PICK).apply {
+ type = "image/*"
+ pickBannerImageLauncher.launch(this)
+ }
+ },
+ contentScale = ContentScale.Crop
+ )
+ Spacer(modifier = Modifier.size(16.dp))
+ TextField(
+ value = name,
+ onValueChange = {
+ name = it
+ },
+ label = {
+ Text("Name")
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.size(16.dp))
+ TextField(
+ value = bio,
+ onValueChange = {
+ bio = it
+ },
+ label = {
+ Text("Bio")
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/ui/account/edit2.kt b/app/src/main/java/com/aiosman/ravenow/ui/account/edit2.kt
new file mode 100644
index 0000000..1e28a55
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ui/account/edit2.kt
@@ -0,0 +1,350 @@
+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.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.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.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.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+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.comment.NoticeScreenHeader
+import com.aiosman.ravenow.ui.composables.CustomAsyncImage
+import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
+import com.aiosman.ravenow.ui.composables.StatusBarSpacer
+import com.aiosman.ravenow.ui.composables.form.FormTextInput
+import com.aiosman.ravenow.ui.composables.debouncedClickable
+import com.aiosman.ravenow.ui.composables.rememberDebouncedNavigation
+import com.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.width
+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.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import com.aiosman.ravenow.ConstVars
+import com.aiosman.ravenow.ui.composables.pickupAndCompressLauncher
+import java.io.File
+
+/**
+ * 编辑用户资料界面
+ */
+@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(null) }
+ var bioError by remember { mutableStateOf(null) }
+
+ // 防抖导航器
+ val debouncedNavigation = rememberDebouncedNavigation()
+
+ // 添加图片选择启动器
+ val scope = rememberCoroutineScope()
+ val pickBannerImageLauncher = pickupAndCompressLauncher(
+ context,
+ scope,
+ maxSize = ConstVars.BANNER_IMAGE_MAX_SIZE,
+ quality = 100
+ ) { uri, file ->
+ // 处理选中的图片
+ onUpdateBanner?.invoke(uri, file, context)
+ }
+
+ fun onNicknameChange(value: String) {
+ // 去除换行符,确保昵称不包含换行
+ val cleanValue = value.replace("\n", "").replace("\r", "")
+ model.name = cleanValue
+ usernameError = when {
+ cleanValue.trim().isEmpty() -> "昵称不能为空"
+ cleanValue.length < 3 -> "昵称长度不能小于3"
+ cleanValue.length > 20 -> "昵称长度不能大于20"
+ 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 -> "个人简介长度不能大于100"
+ else -> null
+ }
+ }
+
+ fun validate(): Boolean {
+ return usernameError == null && bioError == null
+ }
+
+ // 检查是否为游客模式
+ if (AppStore.isGuest) {
+ LaunchedEffect(Unit) {
+ // 游客模式不允许编辑资料,返回上一页(防抖)
+ debouncedNavigation {
+ navController.navigateUp()
+ }
+ }
+ // 游客模式时不渲染任何内容
+ return
+ }
+
+ LaunchedEffect(Unit) {
+ // 每次进入编辑页面时都重新加载当前用户的资料
+ // 确保显示的是当前登录用户的信息,而不是之前用户的缓存数据
+ model.reloadProfile()
+ }
+ StatusBarMaskLayout(
+ modifier = Modifier.background(color = appColors.background).padding(horizontal = 16.dp),
+ darkIcons = !AppState.darkMode,
+ maskBoxBackgroundColor = appColors.background
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(color = appColors.background),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ //StatusBarSpacer()
+ Box(
+ modifier = Modifier.padding(horizontal = 0.dp, vertical = 16.dp)
+ ) {
+ NoticeScreenHeader(
+ title = stringResource(R.string.edit_profile),
+ moreIcon = false
+ ) {
+
+ Icon(
+ modifier = Modifier
+ .size(24.dp)
+ .debouncedClickable(
+ enabled = validate() && !model.isUpdating,
+ debounceTime = 1000L
+ ) {
+ if (validate() && !model.isUpdating) {
+ model.viewModelScope.launch {
+ model.isUpdating = true
+ model.updateUserProfile(context)
+ model.viewModelScope.launch(Dispatchers.Main) {
+ debouncedNavigation {
+ navController.navigateUp()
+ }
+ model.isUpdating = false
+ }
+ }
+ }
+ },
+ imageVector = Icons.Default.Check,
+ contentDescription = "保存",
+ tint = if (validate() && !model.isUpdating) appColors.text else appColors.nonActiveText
+ )
+ }
+ }
+
+
+ // 添加横幅图片区域
+ val banner = model.profile?.banner
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(300.dp)
+ .clip(RoundedCornerShape(12.dp))
+ ) {
+ if (banner != null) {
+ CustomAsyncImage(
+ context = LocalContext.current,
+ imageUrl = banner,
+ modifier = Modifier.fillMaxSize(),
+ contentDescription = "Banner",
+ contentScale = ContentScale.Crop
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Gray.copy(alpha = 0.1f))
+ )
+ }
+ Box(
+ modifier = Modifier
+ .width(120.dp)
+ .height(42.dp)
+ .align(Alignment.BottomEnd)
+ .padding(end = 12.dp, bottom = 12.dp)
+ .background(
+ color = Color.Black.copy(alpha = 0.4f),
+ shape = RoundedCornerShape(9.dp)
+ )
+ .noRippleClickable {
+ Intent(Intent.ACTION_PICK).apply {
+ type = "image/*"
+ pickBannerImageLauncher.launch(this)
+ }
+ }
+ ){
+ Text(
+ text = "change",
+ fontSize = 14.sp,
+ fontWeight = FontWeight.W600,
+ color = Color.White,
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ // 显示内容或加载状态
+ Log.d("AccountEditScreen2", "UI状态 - profile: ${model.profile?.nickName}, isLoading: ${model.isLoading}")
+ when {
+ model.profile != null -> {
+ Log.d("AccountEditScreen2", "显示用户资料内容")
+ // 有数据时显示内容
+ val it = model.profile!!
+ Box(
+ modifier = Modifier.size(88.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CustomAsyncImage(
+ context,
+ model.croppedBitmap ?: it.avatar,
+ modifier = Modifier
+ .size(88.dp)
+ .clip(
+ RoundedCornerShape(88.dp)
+ ),
+ contentDescription = "",
+ contentScale = ContentScale.Crop
+ )
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .clip(CircleShape)
+ .background(
+ brush = Brush.linearGradient(
+ colors = listOf(
+ Color(0xFF7c45ed),
+ Color(0x997c68ef),
+ Color(0xFF7bd8f8)
+ )
+ ),
+ )
+ .align(Alignment.BottomEnd)
+ .debouncedClickable(
+ debounceTime = 800L
+ ) {
+ debouncedNavigation {
+ navController.navigate(NavigationRoute.ImageCrop.route)
+ }
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Default.Add,
+ contentDescription = "Add",
+ tint = Color.White,
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(18.dp))
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(horizontal = 16.dp)
+ ) {
+ FormTextInput(
+ value = model.name,
+ label = stringResource(R.string.nickname),
+ hint = "Input nickname",
+ modifier = Modifier.fillMaxWidth(),
+ error = usernameError
+ ) { value ->
+ onNicknameChange(value)
+ }
+ FormTextInput(
+ value = model.bio,
+ label = stringResource(R.string.bio),
+ hint = "Input bio",
+ modifier = Modifier.fillMaxWidth(),
+ error = bioError
+ ) { value ->
+ onBioChange(value)
+ }
+ }
+ }
+ model.isLoading -> {
+ Log.d("AccountEditScreen2", "显示加载指示器")
+ // 加载中状态
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ androidx.compose.material3.CircularProgressIndicator(
+ color = appColors.main
+ )
+ }
+ }
+ else -> {
+ Log.d("AccountEditScreen2", "显示错误信息 - 没有数据且不在加载中")
+ // 没有数据且不在加载中,显示错误信息
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ androidx.compose.material3.Text(
+ text = "加载用户资料失败,请重试",
+ color = appColors.text
+ )
+ }
+ }
+ }
+
+ }}
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/ui/account/removeaccount.kt b/app/src/main/java/com/aiosman/ravenow/ui/account/removeaccount.kt
new file mode 100644
index 0000000..44bcab0
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ui/account/removeaccount.kt
@@ -0,0 +1,149 @@
+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.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(null) }
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+
+ 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
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+ ActionButton(
+ text = stringResource(R.string.remove_account),
+ fullWidth = true,
+ enabled = true,
+ click = {
+ removeAccount(inputPassword)
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/aiosman/ravenow/ui/agent/AddAgent.kt b/app/src/main/java/com/aiosman/ravenow/ui/agent/AddAgent.kt
new file mode 100644
index 0000000..440599f
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ui/agent/AddAgent.kt
@@ -0,0 +1,794 @@
+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.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.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+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(null) }
+ var agnetDescError by remember { mutableStateOf(null) }
+ var errorMessage by remember { mutableStateOf(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)
+ ) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.Start)
+ .width(140.dp)
+ .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 = stringResource(R.string.create_agent_auto),
+ 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
+ }
+ }
+ }
+ }
+
+
+}
diff --git a/app/src/main/java/com/aiosman/ravenow/ui/agent/AddAgentViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/agent/AddAgentViewModel.kt
new file mode 100644
index 0000000..ec53eac
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ui/agent/AddAgentViewModel.kt
@@ -0,0 +1,98 @@
+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(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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/ui/agent/AgentImageCropScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/agent/AgentImageCropScreen.kt
new file mode 100644
index 0000000..33fd7b7
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ui/agent/AgentImageCropScreen.kt
@@ -0,0 +1,231 @@
+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(null) }
+ var croppedBitmap by remember { mutableStateOf(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
+
+ 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) {
+ // 用户取消选择图片,重置标志
+ 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 {
+ // 用户取消头像选择,重置标志
+ 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) {
+ // 如果已经有裁剪结果,直接返回
+ AddAgentViewModel.croppedBitmap = croppedBitmap
+ // 重置头像选择标志
+ AddAgentViewModel.isSelectingAvatar = false
+ AddAgentViewModel.viewModelScope.launch {
+ AddAgentViewModel.updateAgentAvatar(context)
+ 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
+ }
+}
diff --git a/app/src/main/java/com/aiosman/ravenow/ui/chat/BaseChatViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/chat/BaseChatViewModel.kt
new file mode 100644
index 0000000..86f98af
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ui/chat/BaseChatViewModel.kt
@@ -0,0 +1,377 @@
+package com.aiosman.ravenow.ui.chat
+
+import android.content.Context
+import android.net.Uri
+import android.provider.MediaStore
+import android.util.Log
+import android.webkit.MimeTypeMap
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.aiosman.ravenow.data.AccountService
+import com.aiosman.ravenow.data.AccountServiceImpl
+import com.aiosman.ravenow.data.UserService
+import com.aiosman.ravenow.data.UserServiceImpl
+import com.aiosman.ravenow.entity.AccountProfileEntity
+import com.aiosman.ravenow.entity.ChatItem
+import io.openim.android.sdk.OpenIMClient
+import io.openim.android.sdk.enums.ConversationType
+import io.openim.android.sdk.enums.ViewType
+import io.openim.android.sdk.listener.OnAdvanceMsgListener
+import io.openim.android.sdk.listener.OnBase
+import io.openim.android.sdk.listener.OnMsgSendCallback
+import io.openim.android.sdk.models.*
+import kotlinx.coroutines.launch
+import java.io.File
+import java.io.FileOutputStream
+import java.io.InputStream
+
+/**
+ * 聊天ViewModel基类,包含所有聊天功能的通用实现
+ * 子类需要实现抽象方法来处理特定的聊天类型(单聊/群聊)
+ */
+abstract class BaseChatViewModel : ViewModel() {
+
+ // 通用状态属性
+ var chatData by mutableStateOf>(emptyList())
+ var myProfile by mutableStateOf(null)
+ var hasMore by mutableStateOf(true)
+ var isLoading by mutableStateOf(false)
+ var lastMessage: Message? = null
+ val showTimestampMap = mutableMapOf()
+ 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
+
+ /**
+ * 处理接收到的新消息,子类可以重写以添加特定逻辑
+ */
+ open fun handleNewMessage(message: Message, context: Context): Boolean {
+ return false // 默认不处理,子类重写
+ }
+
+ /**
+ * 获取发送消息时的接收者ID,子类需要实现
+ */
+ abstract fun getReceiverInfo(): Pair // (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 {
+ 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 {
+ 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 {
+ 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 {
+ val list = chatData
+ // 更新每条消息的时间戳显示状态
+ for (item in list) {
+ item.showTimestamp = showTimestampMap.getOrDefault(item.msgId, false)
+ }
+ return list
+ }
+}
diff --git a/app/src/main/java/com/aiosman/ravenow/ui/chat/ChatAiScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/chat/ChatAiScreen.kt
new file mode 100644
index 0000000..1dbd4f6
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ui/chat/ChatAiScreen.kt
@@ -0,0 +1,657 @@
+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(
+ key = "ChatAiViewModel_$userId",
+ factory = object : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): 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(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.rider_pro_im_send),
+ 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, viewModel: ChatAiViewModel): List {
+ 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
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aiosman/ravenow/ui/chat/ChatAiViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/chat/ChatAiViewModel.kt
new file mode 100644
index 0000000..85816e9
--- /dev/null
+++ b/app/src/main/java/com/aiosman/ravenow/ui/chat/ChatAiViewModel.kt
@@ -0,0 +1,108 @@
+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(null)
+ var chatNotification by mutableStateOf