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(null) + override fun init(context: Context) { + // 获取用户信息 + viewModelScope.launch { + val resp = userService.getUserProfile(userId) + userProfile = resp + myProfile = accountService.getMyAccountProfile() + + RegistListener(context) + + // 获取会话信息,然后加载历史消息 + getOneConversation { + fetchHistoryMessage(context) + } + + // 获取通知信息 + val notiStrategy = ChatState.getStrategyByTargetTrtcId(resp.trtcUserId) + chatNotification = notiStrategy + } + } + + override fun getConversationParams(): Triple { + return Triple(userProfile?.trtcUserId ?: userId, ConversationType.SINGLE_CHAT, true) + } + + override fun getLogTag(): String { + return "ChatAiViewModel" + } + + override fun handleNewMessage(message: Message, context: Context): Boolean { + // 只处理当前聊天对象的消息 + val currentChatUserId = userProfile?.trtcUserId + val currentUserId = com.aiosman.ravenow.AppState.profile?.trtcUserId + + if (currentChatUserId != null && currentUserId != null) { + // 检查消息是否来自当前聊天对象,且不是自己发送的消息 + return (message.sendID == currentChatUserId || message.sendID == currentUserId) && + message.sendID != currentUserId + } + return false + } + + override fun getReceiverInfo(): Pair { + return Pair(userProfile?.trtcUserId, null) // (recvID, groupID) + } + + override fun getMessageAvatar(message: Message): String? { + return if (message.sendID == com.aiosman.ravenow.AppState.profile?.trtcUserId) { + myProfile?.avatar + } else { + userProfile?.avatar + } + } + + override fun onMessageSentSuccess(message: String, sentMessage: Message?) { + // AI聊天特有的处理逻辑 + sendChatAiMessage(myProfile?.trtcUserId!!, userProfile?.trtcUserId!!, message) + createGroup2ChatAi(userProfile?.trtcUserId!!, "ai_group") + } + fun createGroup2ChatAi( + trtcUserId: String, + groupName: String, + ) { + // OpenIM 不支持会话分组功能,这里可以留空或者使用其他方式实现 + Log.d("ChatAiViewModel", "OpenIM 不支持会话分组功能") + } + + fun sendChatAiMessage( + fromTrtcUserId: String, + toTrtcUserId: String, + message: String, + ) { + viewModelScope.launch { + val response = ApiClient.api.sendChatAiMessage(SendChatAiRequestBody(fromTrtcUserId = fromTrtcUserId,toTrtcUserId = toTrtcUserId,message = message)) + } + } + + suspend fun updateNotificationStrategy(strategy: String) { + userProfile?.let { + val result = ChatState.updateChatNotification(it.id, strategy) + chatNotification = result + } + } + + val notificationStrategy get() = chatNotification?.strategy ?: "default" +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/chat/ChatScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/chat/ChatScreen.kt new file mode 100644 index 0000000..c8cf846 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/chat/ChatScreen.kt @@ -0,0 +1,690 @@ +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.layout.ContentScale +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.AppState +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.entity.ChatItem +import com.aiosman.ravenow.exp.formatChatTime +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.DropdownMenu +import com.aiosman.ravenow.ui.composables.MenuItem +import com.aiosman.ravenow.ui.composables.StatusBarSpacer +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.utils.NetworkUtils +import io.openim.android.sdk.enums.MessageType +import kotlinx.coroutines.launch +import java.util.UUID + + +@Composable +fun ChatScreen(userId: String) { + val navController = LocalNavController.current + val context = LocalNavController.current.context + val AppColors = LocalAppTheme.current + var goToNewCount by remember { mutableStateOf(0) } + val viewModel = viewModel( + key = "ChatViewModel_$userId", + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return ChatViewModel(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() + ) { + // 背景图层 + val bgUrl = AppState.chatBackgroundUrl + if (bgUrl != null) { + CustomAsyncImage( + imageUrl = bgUrl, + modifier = Modifier.fillMaxSize(), + contentDescription = "chat_background", + contentScale = ContentScale.Crop + ) + } else { + // 无背景时使用主题背景色 + Box( + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) + ) + } + + Scaffold( + modifier = Modifier + .fillMaxSize(), + backgroundColor = Color.Transparent, + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(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(24.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(32.dp)), + contentDescription = "avatar" + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = viewModel.userProfile?.nickName ?: "", + modifier = Modifier.weight(1f) + , + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + style = TextStyle( + color = AppColors.text, + fontSize = 18.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold + ) + ) + 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 = { + Column( + modifier = Modifier + .fillMaxWidth() + .imePadding() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + + ) + Spacer(modifier = Modifier.height(8.dp)) + ChatInput( + 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) + ) + } + + ChatItem(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 ChatSelfItem(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 ChatOtherItem(item: ChatItem) { + val AppColors = LocalAppTheme.current + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.Start, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(40.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.background) + .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 = 16.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 ChatItem(item: ChatItem, currentUserId: String) { + val isCurrentUser = item.userId == currentUserId + if (isCurrentUser) { + ChatSelfItem(item) + } else { + ChatOtherItem(item) + } +} + +@Composable +fun ChatInput( + 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 = "" + ) + + // 在 isKeyboardOpen 变化时立即更新 inputBarHeight 的动画目标值 + LaunchedEffect(isKeyboardOpen) { + inputBarHeight // 触发 inputBarHeight 的重组 + } + val focusManager = LocalFocusManager.current + val windowInsets = WindowInsets.ime + val density = LocalDensity.current + val softwareKeyboardController = LocalSoftwareKeyboardController.current + val currentDensity by rememberUpdatedState(density) + + LaunchedEffect(windowInsets.getBottom(currentDensity)) { + if (windowInsets.getBottom(currentDensity) <= 0) { + focusManager.clearFocus() + } + } + + val imagePickUpLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { + if (it.resultCode == Activity.RESULT_OK) { + val uri = it.data?.data + onSendImage(uri) + } + } + Box( modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 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)) + Image( + painter = painterResource(R.mipmap.rider_pro_im_image), + contentDescription = "Image", + modifier = Modifier + .size(30.dp) + .noRippleClickable { + if (NetworkUtils.isNetworkAvailable(context)) { + imagePickUpLauncher.launch( + Intent.createChooser( + Intent(Intent.ACTION_GET_CONTENT).apply { + type = "image/*" + }, + "Select Image" + ) + ) + } else { + android.widget.Toast.makeText(context, "网络连接异常,请检查网络设置", android.widget.Toast.LENGTH_SHORT).show() + } + }, + ) + + 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: ChatViewModel): 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/ChatSettingScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/chat/ChatSettingScreen.kt new file mode 100644 index 0000000..4aac6cf --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/chat/ChatSettingScreen.kt @@ -0,0 +1,362 @@ +package com.aiosman.ravenow.ui.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.composables.StatusBarSpacer +import com.aiosman.ravenow.ui.comment.NoticeScreenHeader +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.style.TextAlign +import com.aiosman.ravenow.ui.composables.CustomAsyncImage + +@Composable +fun ChatSettingScreen() { + val appColors = LocalAppTheme.current + val navController = LocalNavController.current + var showThemeSheet by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(appColors.secondaryBackground) + ) { + StatusBarSpacer() + Box(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) { + NoticeScreenHeader(title = stringResource(R.string.chat_settings), moreIcon = false) + } + + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + SettingCard( + title = stringResource(R.string.chat_theme_settings), + onClick = { showThemeSheet = true } + ) + Spacer(modifier = Modifier.height(12.dp)) + SettingCard( + title = stringResource(R.string.report), + onClick = { /* TODO: 跳转举报 */ } + ) + } + } + + if (showThemeSheet) { + ThemePickerSheet(onClose = { showThemeSheet = false }) + } +} + +@Composable +private fun SettingCard(title: String, onClick: () -> Unit) { + val appColors = LocalAppTheme.current + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(appColors.background) + .clickable { onClick() } + .padding(horizontal = 12.dp, vertical = 14.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = title, + color = appColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.weight(1f) + ) + Icon( + painter = painterResource(id = R.drawable.rave_now_nav_right), + contentDescription = null, + tint = appColors.text, + modifier = Modifier.size(20.dp) + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ThemePickerSheet(onClose: () -> Unit) { + val appColors = LocalAppTheme.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + val sheetHeight = screenHeight * 0.9f + var previewUrl by remember { mutableStateOf(null) } + ModalBottomSheet( + onDismissRequest = onClose, + sheetState = sheetState, + containerColor = appColors.secondaryBackground, + dragHandle = null, + modifier = Modifier + .fillMaxWidth() + .height(sheetHeight), + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.size(24.dp)) + Text( + text = stringResource(R.string.custom_background), + color = appColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600, + modifier = Modifier.weight(1f).padding(start = 90.dp), + ) + IconButton(onClick = onClose) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_close), + contentDescription = "close", + tint = appColors.text + ) + } + } + + // 从相册选择 + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(appColors.background) + .clickable { /* TODO: 打开相册选择 */ } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = stringResource(R.string.select_from_gallery), color = appColors.text, fontSize = 15.sp, modifier = Modifier.weight(1f)) + Icon( + painter = painterResource(id = R.drawable.group_info_edit), + contentDescription = null, + tint = appColors.text, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + Text(text = stringResource(R.string.featured_backgrounds), color = appColors.text, fontSize = 12.sp) + Spacer(modifier = Modifier.height(8.dp)) + + val presets = remember { + listOf( + "https://picsum.photos/seed/ai1/400/600", + "https://picsum.photos/seed/ai2/400/600", + "https://picsum.photos/seed/ai3/400/600", + "https://picsum.photos/seed/ai4/400/600", + "https://picsum.photos/seed/ai5/400/600", + "https://picsum.photos/seed/ai6/400/600", + "https://picsum.photos/seed/ai7/400/600", + "https://picsum.photos/seed/ai8/400/600", + "https://picsum.photos/seed/ai9/400/600", + "https://picsum.photos/seed/ai10/400/600", + "https://picsum.photos/seed/ai11/400/600", + "https://picsum.photos/seed/ai12/400/600", + "https://picsum.photos/seed/ai13/400/600", + "https://picsum.photos/seed/ai14/400/600", + "https://picsum.photos/seed/ai15/400/600", + ) + } + + LazyVerticalGrid( + columns = GridCells.Fixed(3), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + ) { + items(presets) { url -> + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { previewUrl = url } + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(appColors.decentBackground) + ) { + CustomAsyncImage( + imageUrl = url, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(3f / 4f), + contentDescription = "preset", + contentScale = ContentScale.Crop + ) + } + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Heart Drive", + color = appColors.text, + fontSize = 12.sp + ) + } + } + } + + // 预览自定义背景弹窗 + if (previewUrl != null) { + ModalBottomSheet( + onDismissRequest = { previewUrl = null }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = appColors.secondaryBackground, + dragHandle = null, + modifier = Modifier + .fillMaxWidth() + .height(sheetHeight) + ) { + Box(modifier = Modifier.fillMaxSize()) { + CustomAsyncImage( + imageUrl = previewUrl!!, + modifier = Modifier.fillMaxSize(), + contentDescription = "preview_bg", + contentScale = ContentScale.Crop + ) + + Column(modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 8.dp)) { + Box( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(horizontal = 12.dp, vertical = 16.dp) + ) { + Text(text = stringResource(R.string.previewing_custom_background), color = Color.White, fontSize = 15.sp) + } + + + Column(modifier = Modifier.padding(8.dp)) { + Row { + Box( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .background(Color.White) + .padding(horizontal = 14.dp, vertical = 8.dp) + ) { + Text(text = stringResource(R.string.each_theme_unique_experience), color = Color.Black, fontSize = 12.sp) + } + } + Spacer(modifier = Modifier.height(12.dp)) + Row { + Box( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .background(Color.White) + .padding(horizontal = 14.dp, vertical = 8.dp) + ) { + Text(text = stringResource(R.string.select_apply_to_use_theme), color = Color.Black, fontSize = 12.sp) + } + } + Spacer(modifier = Modifier.height(12.dp)) + Row { + Spacer(modifier = Modifier.weight(1f)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(Color(0xFF7C4DFF)) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text(text = stringResource(R.string.tap_cancel_to_preview_other_themes), color = Color.White, fontSize = 12.sp) + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + // 底部按钮 + Row( + modifier = Modifier.fillMaxWidth() + .padding(bottom = 60.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White) + .clickable { previewUrl = null } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text(text = stringResource(R.string.cancel), color = Color.Black, fontSize = 14.sp) + } + Spacer(modifier = Modifier.size(12.dp)) + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(12.dp)) + .background( + brush = Brush.horizontalGradient( + colors = listOf( + Color(0xFFEE2A33), + Color(0xFFD80264), + Color(0xFF664C92) + ) + ) + ) + .clickable { + previewUrl?.let { url -> + com.aiosman.ravenow.AppStore.saveChatBackgroundUrl(url) + previewUrl = null + onClose() + } + } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text(text = stringResource(R.string.moment_ai_apply), color = Color.White, fontSize = 14.sp) + } + } + } + } + } + } + } + } +} + + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/chat/ChatViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/chat/ChatViewModel.kt new file mode 100644 index 0000000..4c2590f --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/chat/ChatViewModel.kt @@ -0,0 +1,82 @@ +package com.aiosman.ravenow.ui.chat + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.viewModelScope +import com.aiosman.ravenow.ChatState +import com.aiosman.ravenow.entity.AccountProfileEntity +import com.aiosman.ravenow.entity.ChatNotification +import io.openim.android.sdk.enums.ConversationType +import io.openim.android.sdk.models.Message +import kotlinx.coroutines.launch + + +class ChatViewModel( + val userId: String, +) : BaseChatViewModel() { + var userProfile by mutableStateOf(null) + var chatNotification by mutableStateOf(null) + override fun init(context: Context) { + // 获取用户信息 + viewModelScope.launch { + val resp = userService.getUserProfile(userId) + userProfile = resp + myProfile = accountService.getMyAccountProfile() + + RegistListener(context) + + // 获取会话信息,然后加载历史消息 + getOneConversation { + fetchHistoryMessage(context) + } + + // 获取通知信息 + val notiStrategy = ChatState.getStrategyByTargetTrtcId(resp.trtcUserId) + chatNotification = notiStrategy + } + } + + override fun getConversationParams(): Triple { + return Triple(userProfile?.trtcUserId ?: userId, ConversationType.SINGLE_CHAT, true) + } + + override fun getLogTag(): String { + return "ChatViewModel" + } + + override fun handleNewMessage(message: Message, context: Context): Boolean { + // 只处理当前聊天对象的消息 + val currentChatUserId = userProfile?.trtcUserId + val currentUserId = com.aiosman.ravenow.AppState.profile?.trtcUserId + + if (currentChatUserId != null && currentUserId != null) { + // 检查消息是否来自当前聊天对象,且不是自己发送的消息 + return (message.sendID == currentChatUserId || message.sendID == currentUserId) && + message.sendID != currentUserId + } + return false + } + + override fun getReceiverInfo(): Pair { + return Pair(userProfile?.trtcUserId, null) // (recvID, groupID) + } + + override fun getMessageAvatar(message: Message): String? { + return if (message.sendID == com.aiosman.ravenow.AppState.profile?.trtcUserId) { + myProfile?.avatar + } else { + userProfile?.avatar + } + } + + suspend fun updateNotificationStrategy(strategy: String) { + userProfile?.let { + val result = ChatState.updateChatNotification(it.id, strategy) + chatNotification = result + } + } + + val notificationStrategy get() = chatNotification?.strategy ?: "default" +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/chat/GroupChatScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/chat/GroupChatScreen.kt new file mode 100644 index 0000000..6845ff6 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/chat/GroupChatScreen.kt @@ -0,0 +1,716 @@ +package com.aiosman.ravenow.ui.chat + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Icon +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.entity.ChatItem +import com.aiosman.ravenow.exp.formatChatTime +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.DropdownMenu +import com.aiosman.ravenow.ui.composables.MenuItem +import com.aiosman.ravenow.ui.composables.StatusBarSpacer +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.navigateToGroupInfo +import com.aiosman.ravenow.utils.NetworkUtils +// 临时兼容层 - TODO: 完成 OpenIM 迁移后删除 +import io.openim.android.sdk.enums.MessageType +import kotlinx.coroutines.launch +import java.util.UUID + +@Composable +fun GroupChatScreen(groupId: String,name: String,avatar: String,) { + var isMenuExpanded by remember { mutableStateOf(false) } + val navController = LocalNavController.current + val context = LocalNavController.current.context + val AppColors = LocalAppTheme.current + var goToNewCount by remember { mutableStateOf(0) } + + val viewModel = viewModel( + key = "GroupChatViewModel_$groupId", + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return GroupChatViewModel(groupId,name,avatar) as T + } + } + ) + + LaunchedEffect(Unit) { + viewModel.init(context = context) + } + + DisposableEffect(Unit) { + onDispose { + viewModel.UnRegistListener() + viewModel.clearUnRead() + } + } + + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + var inBottom by remember { mutableStateOf(true) } + + LaunchedEffect(listState) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } + .collect { index -> + if (index == listState.layoutInfo.totalItemsCount - 1) { + coroutineScope.launch { + viewModel.onLoadMore(context) + } + } + } + } + + LaunchedEffect(listState) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index } + .collect { index -> + inBottom = index == 0 + if (index == 0) { + goToNewCount = 0 + } + } + } + + LaunchedEffect(viewModel.goToNew) { + if (viewModel.goToNew) { + if (inBottom) { + listState.scrollToItem(0) + } else { + goToNewCount++ + } + viewModel.goToNew = false + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(AppColors.background) + ) { + StatusBarSpacer() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.rider_pro_back_icon), + modifier = Modifier + .size(24.dp) + .noRippleClickable { + navController.navigateUp() + }, + contentDescription = null, + colorFilter = ColorFilter.tint(AppColors.text) + ) + Spacer(modifier = Modifier.width(16.dp)) + + if (viewModel.groupAvatar.isNotEmpty()) { + CustomAsyncImage( + imageUrl = viewModel.groupAvatar, + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(8.dp)) + .noRippleClickable { + navController.navigateToGroupInfo(groupId) + }, + contentDescription = "群聊头像" + ) + } else { + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(8.dp)) + .background(AppColors.decentBackground) + .noRippleClickable { + navController.navigateToGroupInfo(groupId) + }, + contentAlignment = Alignment.Center + ) { + Text( + text = viewModel.groupName, + style = TextStyle( + color = AppColors.text, + fontSize = 18.sp, + + fontWeight = androidx.compose.ui.text.font.FontWeight.W700 + ), + maxLines = 1, + overflow =TextOverflow.Ellipsis, + + ) + } + } + + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = viewModel.groupName, + style = TextStyle( + color = AppColors.text, + fontSize = 18.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold + ), + maxLines = 1, + overflow =TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + Box { + Image( + painter = painterResource(R.drawable.rider_pro_more_horizon), + modifier = Modifier + .size(28.dp) + .noRippleClickable { + navController.navigateToGroupInfo(groupId) + }, + contentDescription = null, + colorFilter = ColorFilter.tint(AppColors.text) + ) + } + } + } + }, + bottomBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .imePadding() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + GroupChatInput( + onSendImage = { uri -> + uri?.let { + if (NetworkUtils.isNetworkAvailable(context)) { + viewModel.sendImageMessage(it, context) + } else { + android.widget.Toast.makeText(context, "网络连接异常,请检查网络设置", android.widget.Toast.LENGTH_SHORT).show() + } + } + }, + ) { message -> + viewModel.sendMessage(message, context) + } + } + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) + .padding(paddingValues) + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + reverseLayout = true, + verticalArrangement = Arrangement.Top + ) { + val chatList = groupMessagesByTime(viewModel.getDisplayChatList(), viewModel) + items(chatList.size, key = { index -> chatList[index].msgId + UUID.randomUUID().toString()}) { index -> + val item = chatList[index] + Column { + if (item.showTimeDivider) { + val calendar = java.util.Calendar.getInstance() + calendar.timeInMillis = item.timestamp + Text( + text = calendar.time.formatChatTime(context), + style = TextStyle( + color = AppColors.secondaryText, + fontSize = 11.sp, + textAlign = TextAlign.Center + ), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + // 获取上一个item的userId,用于判断是否显示头像和昵称 + val previousItem = if (index < chatList.size - 1) chatList[index + 1] else null + val showAvatarAndNickname = previousItem?.userId != item.userId + GroupChatItem( + item = item, + currentUserId = viewModel.myProfile?.trtcUserId!!, + showAvatarAndNickname = showAvatarAndNickname + ) + } + } + } + + if (goToNewCount > 0) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 16.dp, end = 16.dp) + .shadow(4.dp, shape = RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(16.dp)) + .background(AppColors.background) + .padding(8.dp) + .noRippleClickable { + coroutineScope.launch { + listState.scrollToItem(0) + } + }, + ) { + Text( + text = "${goToNewCount} 条新消息", + style = TextStyle( + color = AppColors.text, + fontSize = 12.sp, + ), + ) + } + } + } + } +} + +@Composable +fun GroupChatSelfItem(item: ChatItem) { + val context = LocalContext.current + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth() + ) { + Column( + horizontalAlignment = androidx.compose.ui.Alignment.End, + ) { + /* Text( + text = item.nickname, + style = TextStyle( + color = Color.Gray, + fontSize = 12.sp, + ), + modifier = Modifier.padding(bottom = 2.dp) + ) + */ + Box( + modifier = Modifier + .widthIn( + min = 20.dp, + max = (if (item.messageType == MessageType.TEXT) 250.dp else 150.dp) + ) + .clip(RoundedCornerShape(20.dp)) + .background(Color(0xFF6246FF)) + .padding( + vertical = (if (item.messageType == MessageType.TEXT) 8.dp else 0.dp), + horizontal = (if (item.messageType == MessageType.TEXT) 16.dp else 0.dp) + ) + + ) { + when (item.messageType) { + MessageType.TEXT -> { + Text( + text = item.message, + style = TextStyle( + color = Color.White, + fontSize = 14.sp, + ), + textAlign = TextAlign.Start + ) + } + + MessageType.PICTURE -> { + CustomAsyncImage( + imageUrl = item.imageList[1].url, + modifier = Modifier.fillMaxSize(), + contentDescription = "image" + ) + } + + else -> { + Text( + text = "不支持的消息类型", + style = TextStyle( + color = Color.White, + fontSize = 14.sp, + ) + ) + } + } + } + } + /*Spacer(modifier = Modifier.width(12.dp)) + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(24.dp)) + ) { + CustomAsyncImage( + imageUrl = item.avatar, + modifier = Modifier.fillMaxSize(), + contentDescription = "avatar" + ) + }*/ + } + } +} + +@Composable +fun GroupChatOtherItem(item: ChatItem, showAvatarAndNickname: Boolean = true) { + val AppColors = LocalAppTheme.current + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.Start, + modifier = Modifier.fillMaxWidth() + ) { + if (showAvatarAndNickname) { + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(24.dp)) + ) { + CustomAsyncImage( + imageUrl = item.avatar.replace("storage/avatars/", "/avatar/"), + modifier = Modifier.fillMaxSize(), + contentDescription = "avatar" + ) + } + Spacer(modifier = Modifier.width(12.dp)) + } else { + // 当不显示头像时,添加左边距以保持消息对齐 + Spacer(modifier = Modifier.width(36.dp)) + } + Column { + Box( + modifier = Modifier + .widthIn( + min = 20.dp, + max = (if (item.messageType == MessageType.TEXT) 250.dp else 150.dp) + ) + .clip(RoundedCornerShape(20.dp)) + .background(AppColors.bubbleBackground) + .padding( + vertical = (if (item.messageType == MessageType.TEXT) 8.dp else 0.dp), + horizontal = (if (item.messageType == MessageType.TEXT) 16.dp else 0.dp) + ) + ) { + when (item.messageType) { + MessageType.TEXT -> { + Text( + text = item.message, + style = TextStyle( + color = AppColors.text, + fontSize = 14.sp, + ), + textAlign = TextAlign.Start + ) + } + + MessageType.PICTURE -> { + CustomAsyncImage( + imageUrl = item.imageList[1].url, + modifier = Modifier.fillMaxSize(), + contentDescription = "image" + ) + } + + else -> { + Text( + text = "不支持的消息类型", + style = TextStyle( + color = AppColors.text, + fontSize = 16.sp, + ) + ) + } + } + } + + if (showAvatarAndNickname) { + Text( + text = item.nickname, + style = TextStyle( + color = AppColors.secondaryText, + fontSize = 12.sp, + ), + modifier = Modifier.padding(bottom = 2.dp) + ) + } + } + } + } +} + +@Composable +fun GroupChatItem(item: ChatItem, currentUserId: String, showAvatarAndNickname: Boolean = true) { + val isCurrentUser = item.userId == currentUserId + + // 管理员消息显示特殊布局 + if (item.userId == "administrator") { + GroupChatAdminItem(item) + return + } + + // 根据是否是当前用户显示不同样式 + when (item.userId) { + currentUserId -> GroupChatSelfItem(item) + else -> GroupChatOtherItem(item, showAvatarAndNickname) + } +} + +@Composable +fun GroupChatAdminItem(item: ChatItem) { + val AppColors = LocalAppTheme.current + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 50.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .padding(vertical = 8.dp, horizontal = 16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = item.message, + style = TextStyle( + color = AppColors.secondaryText, + fontSize = 12.sp, + textAlign = TextAlign.Center + ), + maxLines = Int.MAX_VALUE + ) + } + } +} + +@Composable +fun GroupChatInput( + onSendImage: (Uri?) -> Unit = {}, + onSend: (String) -> Unit = {}, +) { + val context = LocalContext.current + val navigationBarHeight = with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + var keyboardController by remember { mutableStateOf(null) } + var isKeyboardOpen by remember { mutableStateOf(false) } + var text by remember { mutableStateOf("") } + val appColors = LocalAppTheme.current + val inputBarHeight by animateDpAsState( + targetValue = if (isKeyboardOpen) 8.dp else (navigationBarHeight + 8.dp), + animationSpec = tween( + durationMillis = 300, + easing = androidx.compose.animation.core.LinearEasing + ), label = "" + ) + + LaunchedEffect(isKeyboardOpen) { + inputBarHeight + } + val focusManager = LocalFocusManager.current + val windowInsets = WindowInsets.ime + val density = LocalDensity.current + val softwareKeyboardController = LocalSoftwareKeyboardController.current + val currentDensity by rememberUpdatedState(density) + + LaunchedEffect(windowInsets.getBottom(currentDensity)) { + if (windowInsets.getBottom(currentDensity) <= 0) { + focusManager.clearFocus() + } + } + + val imagePickUpLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { + if (it.resultCode == Activity.RESULT_OK) { + val uri = it.data?.data + onSendImage(uri) + } + } + Box( modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 12.dp),){ + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(appColors.decentBackground) + .padding(start = 16.dp, end = 8.dp, top = 2.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .weight(1f) + ) { + BasicTextField( + value = text, + onValueChange = { + text = it + }, + textStyle = TextStyle( + color = appColors.text, + fontSize = 16.sp + ), + cursorBrush = SolidColor(appColors.text), + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .onFocusChanged { focusState -> + isKeyboardOpen = focusState.isFocused + } + .pointerInput(Unit) { + awaitPointerEventScope { + keyboardController = softwareKeyboardController + awaitFirstDown().also { + keyboardController?.show() + } + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + keyboardController?.hide() + } + ) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + Crossfade( + targetState = text.isNotEmpty(), animationSpec = tween(500), + label = "" + ) { isNotEmpty -> + val alpha by animateFloatAsState( + targetValue = if (isNotEmpty) 1f else 0.5f, + animationSpec = tween(300) + ) + Image( + painter = painterResource(R.mipmap.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: GroupChatViewModel): List { + for (i in chatList.indices) { + if (i == 0) { + viewModel.showTimestampMap[chatList[i].msgId] = false + chatList[i].showTimeDivider = false + continue + } + val currentMessage = chatList[i] + val timeDiff = currentMessage.timestamp - chatList[i - 1].timestamp + if (-timeDiff > 10 * 60 * 1000) { + viewModel.showTimestampMap[currentMessage.msgId] = true + currentMessage.showTimeDivider = true + } + } + return chatList +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/chat/GroupChatViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/chat/GroupChatViewModel.kt new file mode 100644 index 0000000..c64a773 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/chat/GroupChatViewModel.kt @@ -0,0 +1,109 @@ +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.data.api.ApiClient +import com.aiosman.ravenow.data.api.SendChatAiRequestBody +import io.openim.android.sdk.enums.ConversationType +import io.openim.android.sdk.models.* +import kotlinx.coroutines.launch + +class GroupChatViewModel( + val groupId: String, + val name: String, + val avatar: String, +) : BaseChatViewModel() { + var groupInfo by mutableStateOf(null) + + // 群聊特有属性 + var memberCount by mutableStateOf(0) + var groupName by mutableStateOf("") + var groupAvatar by mutableStateOf("") + + data class GroupInfo( + val groupId: String, + val groupName: String, + val groupAvatar: String, + val memberCount: Int, + val ownerId: String + ) + + override fun init(context: Context) { + viewModelScope.launch { + try { + getGroupInfo() + myProfile = accountService.getMyAccountProfile() + RegistListener(context) + + // 获取会话信息,然后加载历史消息 + getOneConversation { + fetchHistoryMessage(context) + } + } catch (e: Exception) { + Log.e("GroupChatViewModel", "初始化失败: ${e.message}") + } + } + } + + private suspend fun getGroupInfo() { + // 简化群组信息获取,使用默认信息 + groupInfo = GroupInfo( + groupId = groupId, + groupName = name, + groupAvatar = avatar, + memberCount = 0, + ownerId = "" + ) + groupName = groupInfo?.groupName ?: "" + groupAvatar = groupInfo?.groupAvatar ?: "" + memberCount = groupInfo?.memberCount ?: 0 + } + + override fun getConversationParams(): Triple { + // 根据群组类型决定ConversationType,这里假设是普通群聊 + return Triple(groupId, ConversationType.GROUP_CHAT, false) + } + + override fun getLogTag(): String { + return "GroupChatViewModel" + } + + override fun handleNewMessage(message: Message, context: Context): Boolean { + // 检查是否是当前群聊的消息 + return message.groupID == groupId + } + + override fun getReceiverInfo(): Pair { + return Pair(null, groupId) // (recvID, groupID) + } + + override fun getMessageAvatar(message: Message): String? { + // 群聊中,如果是自己发送的消息显示自己的头像,否则为null(由ChatItem处理) + return if (message.sendID == com.aiosman.ravenow.AppState.profile?.trtcUserId) { + myProfile?.avatar + } else { + null + } + } + + override fun onMessageSentSuccess(message: String, sentMessage: Message?) { + // 群聊特有的处理逻辑 + sendChatAiMessage(message = message, trtcGroupId = groupId) + } + + + + fun sendChatAiMessage( + trtcGroupId: String, + message: String, + ) { + viewModelScope.launch { + val response = ApiClient.api.sendChatAiMessage(SendChatAiRequestBody(trtcGroupId = trtcGroupId,message = message)) + } + } + +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/comment/CommentModal.kt b/app/src/main/java/com/aiosman/ravenow/ui/comment/CommentModal.kt new file mode 100644 index 0000000..c3483b7 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/comment/CommentModal.kt @@ -0,0 +1,240 @@ +package com.aiosman.ravenow.ui.comment + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.aiosman.ravenow.R +import com.aiosman.ravenow.entity.CommentEntity +import com.aiosman.ravenow.ui.composables.EditCommentBottomModal +import com.aiosman.ravenow.ui.post.CommentContent +import com.aiosman.ravenow.ui.post.CommentMenuModal +import com.aiosman.ravenow.ui.post.CommentsViewModel +import com.aiosman.ravenow.ui.post.OrderSelectionComponent +import kotlinx.coroutines.launch + +/** + * 评论弹窗的 ViewModel + */ +class CommentModalViewModel( + val postId: Int? +) : ViewModel() { + var commentText by mutableStateOf("") + var commentsViewModel: CommentsViewModel = CommentsViewModel(postId.toString()) + init { + commentsViewModel.preTransit() + } + + +} + + +/** + * 评论弹窗 + * @param postId 帖子ID + * @param onCommentAdded 评论添加回调 + * @param onDismiss 关闭回调 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CommentModalContent( + postId: Int? = null, + commentCount: Int = 0, + onCommentAdded: () -> Unit = {}, + onDismiss: () -> Unit = {} +) { + val model = viewModel( + key = "CommentModalViewModel_$postId", + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return CommentModalViewModel(postId) as T + } + } + ) + val commentViewModel = model.commentsViewModel + var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + LaunchedEffect(Unit) { + + } + var showCommentMenu by remember { mutableStateOf(false) } + var contextComment by remember { mutableStateOf(null) } + val insets = WindowInsets + val imePadding = insets.ime.getBottom(density = LocalDensity.current) + var bottomPadding by remember { mutableStateOf(0.dp) } + var softwareKeyboardController = LocalSoftwareKeyboardController.current + var replyComment by remember { mutableStateOf(null) } + + LaunchedEffect(imePadding) { + bottomPadding = imePadding.dp + } + DisposableEffect(Unit) { + onDispose { + onDismiss() + } + } + if (showCommentMenu) { + ModalBottomSheet( + onDismissRequest = { + showCommentMenu = false + }, + containerColor = Color.White, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), + dragHandle = {}, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + windowInsets = WindowInsets(0) + ) { + CommentMenuModal( + onDeleteClick = { + showCommentMenu = false + contextComment?.let { + commentViewModel.deleteComment(it.id) + } + } + ) + } + } + suspend fun sendComment() { + if (model.commentText.isNotEmpty()) { + softwareKeyboardController?.hide() + commentViewModel.createComment( + model.commentText, + ) + } + onCommentAdded() + } + Column( + modifier = Modifier + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, bottom = 16.dp, end = 16.dp) + + ) { + Text( + stringResource(R.string.comment), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.Center) + ) + } + + HorizontalDivider( + color = Color(0xFFF7F7F7) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(id = R.string.comment_count, commentCount), + fontSize = 14.sp, + color = Color(0xff666666) + ) + OrderSelectionComponent { + commentViewModel.order = it + commentViewModel.reloadComment() + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .weight(1f) + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + ) { + item { + CommentContent( + viewModel = commentViewModel, + onLongClick = { commentEntity: CommentEntity -> + + }, + onReply = { parentComment, _, _, _ -> + + }, + ) + Spacer(modifier = Modifier.height(72.dp)) + } + + } + } + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xfff7f7f7)) + ) { + EditCommentBottomModal(replyComment) { + commentViewModel.viewModelScope.launch { + if (replyComment != null) { + if (replyComment?.parentCommentId != null) { + // 第三级评论 + commentViewModel.createComment( + it, + parentCommentId = replyComment?.parentCommentId, + replyUserId = replyComment?.author?.toInt() + ) + } else { + // 子级评论 + commentViewModel.createComment(it, replyComment?.id) + } + } else { + // 顶级评论 + commentViewModel.createComment(it) + } + } + + } + Spacer(modifier = Modifier.height(navBarHeight)) + + } + + + } + +} + + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/comment/CommentsScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/comment/CommentsScreen.kt new file mode 100644 index 0000000..dcafc0d --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/comment/CommentsScreen.kt @@ -0,0 +1,267 @@ +package com.aiosman.ravenow.ui.comment + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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 androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout +import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder + +@Preview +@Composable +fun CommentsScreen() { + StatusBarMaskLayout( + darkIcons = true, + maskBoxBackgroundColor = Color(0xFFFFFFFF) + ) { + Column( + modifier = Modifier + .weight(1f) + .background(color = Color(0xFFFFFFFF)) + .padding(horizontal = 16.dp) + ) { + NoticeScreenHeader("COMMENTS") + Spacer(modifier = Modifier.height(28.dp)) + LazyColumn( + modifier = Modifier.weight(1f) + ) { + item { + repeat(20) { + CommentsItem() + } + BottomNavigationPlaceholder() + } + } + } + } + +} + +@Composable +fun NoticeScreenHeader( + title:String, + moreIcon: Boolean = true, + rightIcon: @Composable (() -> Unit)? = null +) { + val nav = LocalNavController.current + val AppColors = LocalAppTheme.current + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + //返回tab + Image( + painter = painterResource(id = R.drawable.rider_pro_back_icon,), + contentDescription = title, + modifier = Modifier.size(24.dp).clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + nav.navigateUp() + }, + colorFilter = ColorFilter.tint(AppColors.text) + ) + Spacer(modifier = Modifier.weight(1f)) + if (moreIcon) { + Image( + painter = painterResource(id = R.drawable.rider_pro_more_horizon), + contentDescription = "More", + modifier = Modifier.size(24.dp) + ) + } + if (rightIcon != null) { + rightIcon() + } + } + Text( + title, fontWeight = FontWeight.W800, + fontSize = 17.sp, + color = AppColors.text, + modifier = Modifier + .align(Alignment.Center) + ) + } +} + +@Composable +fun ScreenHeader( + title:String, + moreIcon: Boolean = true, + rightIcon: @Composable (() -> Unit)? = null +) { + val nav = LocalNavController.current + val AppColors = LocalAppTheme.current + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_close,), + contentDescription = title, + modifier = Modifier.size(24.dp).clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + nav.navigateUp() + }, + colorFilter = ColorFilter.tint(AppColors.text) + ) + Spacer(modifier = Modifier.size(12.dp)) + Text(title, + fontWeight = FontWeight.W600, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontSize = 17.sp, + color = AppColors.text) + Spacer(modifier = Modifier.size(12.dp)) + if (moreIcon) { + Spacer(modifier = Modifier.weight(1f)) + Image( + painter = painterResource(id = R.drawable.rider_pro_more_horizon), + contentDescription = "More", + modifier = Modifier + .size(24.dp), + ) + } + if (rightIcon != null) { + //rightIcon() + } + } +} + +@Composable +fun ScreenHeader2( + title:String, + moreIcon: Boolean = true, + rightIcon: @Composable (() -> Unit)? = null +) { + val nav = LocalNavController.current + val AppColors = LocalAppTheme.current + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_back_icon,), + contentDescription = title, + modifier = Modifier.size(24.dp).clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + nav.navigateUp() + }, + colorFilter = ColorFilter.tint(AppColors.text) + ) + Spacer(modifier = Modifier.size(12.dp)) + Text(title, + fontWeight = FontWeight.W600, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontSize = 17.sp, + color = AppColors.text) + Spacer(modifier = Modifier.size(12.dp)) + if (moreIcon) { + Spacer(modifier = Modifier.weight(1f)) + Image( + painter = painterResource(id = R.drawable.rider_pro_more_horizon), + contentDescription = "More", + modifier = Modifier + .size(24.dp), + ) + } + if (rightIcon != null) { + //rightIcon() + } + } +} + +@Composable +fun CommentsItem() { + Box( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.drawable.default_avatar), + contentDescription = "Avatar", + modifier = Modifier + .size(40.dp) + ) + Spacer(modifier = Modifier.size(12.dp)) + Column( + modifier = Modifier.weight(1f) + ) { + Text("Username", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(modifier = Modifier.size(4.dp)) + Text("Content", color = Color(0x99000000), fontSize = 12.sp) + Spacer(modifier = Modifier.size(4.dp)) + Text("Date", color = Color(0x99000000), fontSize = 12.sp) + Spacer(modifier = Modifier.height(16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_like), + contentDescription = "Like", + modifier = Modifier.size(16.dp), + ) + Text( + "270", + color = Color(0x99000000), + fontSize = 12.sp, + modifier = Modifier.padding(start = 4.dp) + ) + Spacer(modifier = Modifier.width(45.dp)) + Image( + painter = painterResource(id = R.drawable.rider_pro_comments), + contentDescription = "Comments", + modifier = Modifier.size(16.dp) + ) + Text( + "270", + color = Color(0x99000000), + fontSize = 12.sp, + modifier = Modifier.padding(start = 4.dp) + ) + } + } + Box { + Image( + painter = painterResource(id = R.drawable.default_moment_img), + contentDescription = "More", + modifier = Modifier.size(64.dp) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/comment/notice/CommentNotice.kt b/app/src/main/java/com/aiosman/ravenow/ui/comment/notice/CommentNotice.kt new file mode 100644 index 0000000..6babb80 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/comment/notice/CommentNotice.kt @@ -0,0 +1,321 @@ +package com.aiosman.ravenow.ui.comment.notice + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.entity.CommentEntity +import com.aiosman.ravenow.exp.timeAgo +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.comment.NoticeScreenHeader +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.StatusBarSpacer +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.navigateToPost +import kotlinx.coroutines.launch +import com.aiosman.ravenow.utils.NetworkUtils +import com.aiosman.ravenow.ui.network.ReloadButton +@Composable +fun CommentNoticeScreen() { + val viewModel = viewModel( + key = "CommentNotice", + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return CommentNoticeListViewModel() as T + } + } + ) + val context = LocalContext.current + LaunchedEffect(Unit) { + viewModel.initData(context) + } + var dataFlow = viewModel.commentItemsFlow + var comments = dataFlow.collectAsLazyPagingItems() + val navController = LocalNavController.current + val AppColors = LocalAppTheme.current + + Column( + modifier = Modifier.fillMaxSize().background(color = AppColors.background) + ) { + StatusBarSpacer() + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current) + + if (!isNetworkAvailable) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 149.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + androidx.compose.foundation.Image( + painter = painterResource(id = R.mipmap.invalid_name_10), + contentDescription = "network error", + modifier = Modifier.size(181.dp) + ) + Spacer(modifier = Modifier.size(24.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_title), + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_subtitle), + color = AppColors.text, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + Spacer(modifier = Modifier.height(16.dp)) + ReloadButton( + onClick = { + viewModel.initData(context, force = true) + } + ) + } + } + } else if (comments.itemCount == 0 && comments.loadState.refresh is LoadState.NotLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 149.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + androidx.compose.foundation.Image( + painter = painterResource( + id =if(AppState.darkMode) R.mipmap.qst_pl_qs_as_img + else R.mipmap.invalid_name_11), + contentDescription = "No Comment", + modifier = Modifier.size(181.dp) + ) + Spacer(modifier = Modifier.size(24.dp)) + Text( + text = "等一位旅人~", + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = "去发布动态,让更多人参与对话", + color = AppColors.text, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + } + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize().padding(horizontal = 16.dp) + ) { + items(comments.itemCount) { index -> + comments[index]?.let { comment -> + CommentNoticeItem(comment) { + viewModel.updateReadStatus(comment.id) + viewModel.viewModelScope.launch { + var highlightCommentId = comment.id + comment.parentCommentId?.let { + highlightCommentId = it + } + navController.navigateToPost( + id = comment.post!!.id, + highlightCommentId = highlightCommentId, + initImagePagerIndex = 0 + ) + } + } + } + } + // handle load error + when { + comments.loadState.append is LoadState.Loading -> { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + LinearProgressIndicator( + modifier = Modifier.width(160.dp), + color = AppColors.main + ) + } + } + } + + comments.loadState.append is LoadState.Error -> { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .noRippleClickable { + comments.retry() + }, + contentAlignment = Alignment.Center + ) { + Text( + text = "Load comment error, click to retry", + color = AppColors.text + ) + } + } + } + } + item { + Spacer(modifier = Modifier.height(72.dp)) + } + } + } + } +} + +@Composable +fun CommentNoticeItem( + commentItem: CommentEntity, + onPostClick: () -> Unit = {}, +) { + val navController = LocalNavController.current + val context = LocalContext.current + val AppColors = LocalAppTheme.current + + Row( + modifier = Modifier.padding(vertical = 20.dp, horizontal = 16.dp) + ) { + Box { + CustomAsyncImage( + context = context, + imageUrl = commentItem.avatar, + contentDescription = commentItem.name, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .noRippleClickable { + navController.navigate( + NavigationRoute.AccountProfile.route.replace( + "{id}", + commentItem.author.toString() + ) + ) + } + ) + } + Row( + modifier = Modifier + .weight(1f) + .padding(start = 12.dp) + .noRippleClickable { + onPostClick() + } + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = commentItem.name, + fontSize = 18.sp, + modifier = Modifier, + color = AppColors.text + ) + Spacer(modifier = Modifier.height(4.dp)) + Row { + var text = commentItem.comment + if (commentItem.parentCommentId != null) { + text = "Reply you: $text" + } + Text( + text = text, + fontSize = 14.sp, + maxLines = 1, + color = AppColors.secondaryText, + modifier = Modifier.weight(1f), + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = commentItem.date.timeAgo(context), + fontSize = 14.sp, + color = AppColors.secondaryText, + ) + } + + } + Spacer(modifier = Modifier.width(24.dp)) + commentItem.post?.let { + Box { + Box( + modifier = Modifier.padding(4.dp) + ) { + CustomAsyncImage( + context = context, + imageUrl = it.images[0].thumbnail, + contentDescription = "Post Image", + modifier = Modifier + .size(48.dp).clip(RoundedCornerShape(8.dp)) + ) + // unread indicator + + } + + if (commentItem.unread) { + Box( + modifier = Modifier + .background(AppColors.main, CircleShape) + .size(12.dp) + .align(Alignment.TopEnd) + ) + } + } + + + } + } + + + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/comment/notice/CommentNoticeListViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/comment/notice/CommentNoticeListViewModel.kt new file mode 100644 index 0000000..348a22b --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/comment/notice/CommentNoticeListViewModel.kt @@ -0,0 +1,82 @@ +package com.aiosman.ravenow.ui.comment.notice + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.aiosman.ravenow.data.AccountService +import com.aiosman.ravenow.data.AccountServiceImpl +import com.aiosman.ravenow.data.CommentRemoteDataSource +import com.aiosman.ravenow.data.CommentService +import com.aiosman.ravenow.data.CommentServiceImpl +import com.aiosman.ravenow.data.UserService +import com.aiosman.ravenow.data.UserServiceImpl +import com.aiosman.ravenow.entity.CommentEntity +import com.aiosman.ravenow.entity.CommentPagingSource + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class CommentNoticeListViewModel : ViewModel() { + val accountService: AccountService = AccountServiceImpl() + val userService: UserService = UserServiceImpl() + private val commentService: CommentService = CommentServiceImpl() + private val _commentItemsFlow = MutableStateFlow>(PagingData.empty()) + val commentItemsFlow = _commentItemsFlow.asStateFlow() + var isLoading by mutableStateOf(false) + var isFirstLoad = true + fun initData(context: Context, force: Boolean = false) { + if (!isFirstLoad && !force) { + return + } + if (force) { + isLoading = true + } + isFirstLoad = false + viewModelScope.launch { + Pager( + config = PagingConfig(pageSize = 5, enablePlaceholders = false), + pagingSourceFactory = { + CommentPagingSource( + CommentRemoteDataSource(commentService), + selfNotice = true, + order = "latest" + ) + } + ).flow.cachedIn(viewModelScope).collectLatest { + _commentItemsFlow.value = it + } + } + + isLoading = false + + } + + private fun updateIsRead(id: Int) { + val currentPagingData = _commentItemsFlow.value + val updatedPagingData = currentPagingData.map { commentEntity -> + if (commentEntity.id == id) { + commentEntity.copy(unread = false) + } else { + commentEntity + } + } + _commentItemsFlow.value = updatedPagingData + } + + fun updateReadStatus(id: Int) { + viewModelScope.launch { + commentService.updateReadStatus(id) + updateIsRead(id) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/ActionButton.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/ActionButton.kt new file mode 100644 index 0000000..be66223 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/ActionButton.kt @@ -0,0 +1,147 @@ +package com.aiosman.ravenow.ui.composables + +//import androidx.compose.foundation.layout.ColumnScopeInstance.weight +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + +@Composable +fun ActionButton( + modifier: Modifier = Modifier, + text: String, + color: Color? = null, + backgroundColor: Color? = null, + backgroundBrush: Brush? = null, + leading: @Composable (() -> Unit)? = null, + expandText: Boolean = false, + contentPadding: PaddingValues = PaddingValues(vertical = 16.dp), + isLoading: Boolean = false, + loadingTextColor: Color? = null, + loadingText: String = "Loading", + loadingBackgroundColor: Color? = null, + disabledBackgroundColor: Color? = null, + enabled: Boolean = true, + fullWidth: Boolean = false, + roundCorner: Float = 24f, + fontSize: TextUnit = 17.sp, + fontWeight: FontWeight = FontWeight.W900, + click: () -> Unit = {} +) { + val AppColors = LocalAppTheme.current + val animatedBackgroundColor by animateColorAsState( + targetValue = run { + if (enabled) { + if (isLoading) { + loadingBackgroundColor ?: AppColors.loadingMain + } else { + backgroundColor ?: AppColors.basicMain + } + } else { + disabledBackgroundColor ?: AppColors.disabledBackground + } + }, + animationSpec = tween(300), label = "" + ) + Box( + modifier = modifier + .clip(RoundedCornerShape(roundCorner.dp)) + .background( + brush = backgroundBrush ?: Brush.linearGradient( + colors = listOf(animatedBackgroundColor, animatedBackgroundColor) + ) + ) + .noRippleClickable { + if (enabled && !isLoading) { + click() + } + } + .padding(contentPadding), + + contentAlignment = Alignment.CenterStart + ) { + if (!isLoading) { + Box( + modifier = Modifier + .align(Alignment.Center) + .let { + if (fullWidth) { + it.fillMaxWidth() + } else { + it + } + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterStart + ) { + Box(modifier = Modifier.align(Alignment.CenterStart)) { + leading?.invoke() + } + } + + Text( + text, + fontSize = fontSize, + color = color ?: AppColors.text, + fontWeight = fontWeight, + textAlign = if (expandText) TextAlign.Center else TextAlign.Start + ) + } + } else { + Box( + modifier = Modifier + .let { + if (fullWidth) { + it.fillMaxWidth() + } else { + it + } + } + .padding(horizontal = 16.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterStart + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = AppColors.text + ) + Text( + loadingText, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = loadingTextColor ?: AppColors.loadingText, + ) + } + } + } + + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/Agent.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/Agent.kt new file mode 100644 index 0000000..a988478 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/Agent.kt @@ -0,0 +1,119 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.entity.AgentEntity +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.exp.timeAgo +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.index.tabs.message.MessageListViewModel +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + +@Composable +fun AgentCard( + modifier: Modifier = Modifier, + agentEntity: AgentEntity, + onClick: () -> Unit = {}, + onAvatarClick: () -> Unit = {}, +) { + val AppColors = LocalAppTheme.current + val context = LocalContext.current + Column( + modifier = modifier + .fillMaxWidth() + .background(AppColors.background) + ) { + Box( + modifier = Modifier.padding(start = 0.dp, end = 0.dp, top = 16.dp, bottom = 8.dp) + .noRippleClickable { + onClick () + } + ) { + Row( + modifier = Modifier + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(40.dp)) + .noRippleClickable { + onAvatarClick() + } + ) { + CustomAsyncImage( + context, + agentEntity.avatar, + contentDescription = agentEntity.openId, + modifier = Modifier.size(40.dp), + contentScale = ContentScale.Crop + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(start = 12.dp, end = 12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(22.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Start, + text = agentEntity.title, + color = AppColors.text, + fontSize = 16.sp, + style = TextStyle(fontWeight = FontWeight.W700) + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .height(21.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier, + text = agentEntity.desc, + color = AppColors.text, + maxLines = 1, + fontSize = 12.sp + ) + Spacer(modifier = Modifier.width(8.dp)) + //MomentPostLocation(momentEntity.location) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/AgentCreatedSuccessIndicator.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/AgentCreatedSuccessIndicator.kt new file mode 100644 index 0000000..4677c6c --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/AgentCreatedSuccessIndicator.kt @@ -0,0 +1,75 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.composables.toolbar.CollapsingToolbarScaffoldScopeInstance.align +import kotlinx.coroutines.delay + +@Composable +fun AgentCreatedSuccessIndicator() { + val appColors = LocalAppTheme.current + if (AppState.agentCreatedSuccess) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 70.dp), + contentAlignment = Alignment.BottomCenter + ) { + Box( + modifier = Modifier + .width(150.dp) + .height(40.dp) + .background(appColors.text.copy(alpha = 0.5f), shape = RoundedCornerShape(15.dp)), + contentAlignment = Alignment.CenterStart + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp) + ) { + Icon( + painter = painterResource(id = R.mipmap.bars_x_buttons_home_n_copy_2), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified + ) + Spacer(modifier = Modifier.width(7.dp)) + Text( + text = stringResource(R.string.create_success), + color = appColors.background, + fontSize = 13.sp + ) + } + } + } + + + LaunchedEffect(Unit) { + delay(3000) + AppState.agentCreatedSuccess = false + } + } +} + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/AnimatedCounter.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/AnimatedCounter.kt new file mode 100644 index 0000000..8f32742 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/AnimatedCounter.kt @@ -0,0 +1,41 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme + +@Composable +fun AnimatedCounter(count: Int, modifier: Modifier = Modifier, fontSize: Int = 24) { + val AppColors = LocalAppTheme.current + + AnimatedContent( + targetState = count, + transitionSpec = { + // Compare the incoming number with the previous number. + if (targetState > initialState) { + // If the target number is larger, it slides up and fades in + // while the initial (smaller) number slides up and fades out. + (slideInVertically { height -> height } + fadeIn()).togetherWith(slideOutVertically { height -> -height } + fadeOut()) + } else { + // If the target number is smaller, it slides down and fades in + // while the initial number slides down and fades out. + (slideInVertically { height -> -height } + fadeIn()).togetherWith(slideOutVertically { height -> height } + fadeOut()) + }.using( + // Disable clipping since the faded slide-in/out should + // be displayed out of bounds. + SizeTransform(clip = false) + ) + } + ) { targetCount -> + Text(text = "$targetCount", modifier = modifier, fontSize = fontSize.sp, color = AppColors.text) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/AnimatedFavouriteButton.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/AnimatedFavouriteButton.kt new file mode 100644 index 0000000..6d56ac2 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/AnimatedFavouriteButton.kt @@ -0,0 +1,69 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import kotlinx.coroutines.launch + +@Composable +fun AnimatedFavouriteIcon( + modifier: Modifier = Modifier, + isFavourite: Boolean = false, + onClick: (() -> Unit)? = null +) { + val AppColors = LocalAppTheme.current + val animatableRotation = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + suspend fun shake() { + repeat(2) { + animatableRotation.animateTo( + targetValue = 10f, + animationSpec = tween(100) + ) { + + } + animatableRotation.animateTo( + targetValue = -10f, + animationSpec = tween(100) + ) { + + } + + } + animatableRotation.animateTo( + targetValue = 0f, + animationSpec = tween(100) + ) + } + Box(contentAlignment = Alignment.Center, modifier = Modifier.noRippleClickable { + onClick?.invoke() + // Trigger shake animation + scope.launch { + shake() + } + }) { + Image( + painter = if (isFavourite) { + painterResource(id = R.mipmap.icon_variant_2) + } else { + painterResource(id = R.mipmap.icon_collect) + }, + contentDescription = "Favourite", + modifier = modifier.graphicsLayer { + rotationZ = animatableRotation.value + }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/AnimatedLikeButton.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/AnimatedLikeButton.kt new file mode 100644 index 0000000..6b0a9c6 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/AnimatedLikeButton.kt @@ -0,0 +1,69 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import kotlinx.coroutines.launch + +@Composable +fun AnimatedLikeIcon( + modifier: Modifier = Modifier, + liked: Boolean = false, + onClick: (() -> Unit)? = null +) { + val AppColors = LocalAppTheme.current + + val animatableRotation = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + suspend fun shake() { + repeat(2) { + animatableRotation.animateTo( + targetValue = 10f, + animationSpec = tween(100) + ) { + + } + animatableRotation.animateTo( + targetValue = -10f, + animationSpec = tween(100) + ) { + + } + + } + animatableRotation.animateTo( + targetValue = 0f, + animationSpec = tween(100) + ) + } + Box(contentAlignment = Alignment.Center, modifier = Modifier.noRippleClickable { + onClick?.invoke() + // Trigger shake animation + scope.launch { + shake() + } + }) { + Image( + painter = if (!liked) painterResource(id = R.drawable.rider_pro_moment_like) else painterResource( + id = R.drawable.rider_pro_moment_liked + ), + contentDescription = "Like", + modifier = modifier.graphicsLayer { + rotationZ = animatableRotation.value + }, + colorFilter = if (!liked) ColorFilter.tint(AppColors.text) else null + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/BlurHash.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/BlurHash.kt new file mode 100644 index 0000000..c071ab2 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/BlurHash.kt @@ -0,0 +1,76 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.core.graphics.drawable.toDrawable +import coil.annotation.ExperimentalCoilApi +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.aiosman.ravenow.utils.BlurHashDecoder +import com.aiosman.ravenow.utils.Utils.getImageLoader + +const val DEFAULT_HASHED_BITMAP_WIDTH = 4 +const val DEFAULT_HASHED_BITMAP_HEIGHT = 3 + +/** + * This function is used to load an image asynchronously and blur it using BlurHash. + * @param imageUrl The URL of the image to be loaded. + * @param modifier The modifier to be applied to the image. + * @param imageModifier The modifier to be applied to the image. + * @param contentDescription The content description to be applied to the image. + * @param contentScale The content scale to be applied to the image. + * @param isCrossFadeRequired Whether cross-fade is required or not. + * @param onImageLoadSuccess The callback to be called when the image is loaded successfully. + * @param onImageLoadFailure The callback to be called when the image is failed to load. + * @see AsyncImage + */ +@Suppress("LongParameterList") +@ExperimentalCoilApi +@Composable +fun AsyncBlurImage( + imageUrl: String, + blurHash: String, + modifier: Modifier = Modifier, + imageModifier: Modifier? = null, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Fit, + isCrossFadeRequired: Boolean = false, + onImageLoadSuccess: () -> Unit = {}, + onImageLoadFailure: () -> Unit = {} +) { + val context = LocalContext.current + val resources = context.resources + val imageLoader = getImageLoader(context) + + val blurBitmap by remember(blurHash) { + mutableStateOf( + BlurHashDecoder.decode( + blurHash = blurHash, + width = DEFAULT_HASHED_BITMAP_WIDTH, + height = DEFAULT_HASHED_BITMAP_HEIGHT + ) + ) + } + + AsyncImage( + modifier = imageModifier ?: modifier, + model = ImageRequest.Builder(context) + .data(imageUrl) + .crossfade(isCrossFadeRequired) + .placeholder( + blurBitmap?.toDrawable(resources) + ) + .fallback(blurBitmap?.toDrawable(resources)) + .build(), + contentDescription = contentDescription, + contentScale = contentScale, + onSuccess = { onImageLoadSuccess() }, + onError = { onImageLoadFailure() }, + imageLoader = imageLoader + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/BottomNavigationPlaceholder.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/BottomNavigationPlaceholder.kt new file mode 100644 index 0000000..958447c --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/BottomNavigationPlaceholder.kt @@ -0,0 +1,24 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity + +@Composable +fun BottomNavigationPlaceholder( + color: Color? = null +) { + val navigationBarHeight = with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + Box( + modifier = Modifier.height(navigationBarHeight).fillMaxWidth().background(color ?: Color.Transparent) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/Checkbox.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/Checkbox.kt new file mode 100644 index 0000000..d5ea6bd --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/Checkbox.kt @@ -0,0 +1,54 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + + +@Composable +fun Checkbox( + size: Int = 24, + checked: Boolean = false, + onCheckedChange: (Boolean) -> Unit = {} +) { + val AppColors = LocalAppTheme.current + val backgroundColor by animateColorAsState(if (checked) AppColors.checkedBackground else Color.Transparent) + val borderColor by animateColorAsState(if (checked) Color.Transparent else AppColors.secondaryText) + val borderWidth by animateDpAsState(if (checked) 0.dp else 2.dp) + + Box( + modifier = Modifier + .size(size.dp) + .noRippleClickable { + onCheckedChange(!checked) + } + .clip(CircleShape) + .background(color = backgroundColor) + .border(width = borderWidth, color = borderColor, shape = CircleShape) + .padding(2.dp) + ) { + if (checked) { + Icon( + Icons.Default.Check, + contentDescription = "Checked", + tint = AppColors.checkedText + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/CheckboxWithLabel.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/CheckboxWithLabel.kt new file mode 100644 index 0000000..6d5cccf --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/CheckboxWithLabel.kt @@ -0,0 +1,41 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme + +@Composable +fun CheckboxWithLabel( + checked: Boolean = false, + checkSize: Int = 16, + label: String = "", + fontSize: Int = 12, + error: Boolean = false, + onCheckedChange: (Boolean) -> Unit, +) { + val AppColors = LocalAppTheme.current + Row( + ) { + Checkbox( + checked = checked, + onCheckedChange = { + onCheckedChange(it) + }, + size = checkSize + ) + Text( + text = label, + modifier = Modifier.padding(start = 8.dp), + fontSize = fontSize.sp, + style = TextStyle( + color = if (error) AppColors.error else AppColors.text + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/ClickCaptchaView.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/ClickCaptchaView.kt new file mode 100644 index 0000000..708ec73 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/ClickCaptchaView.kt @@ -0,0 +1,186 @@ +package com.aiosman.ravenow.ui.composables + +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.compose.foundation.Canvas +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.material.AlertDialog +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.aiosman.ravenow.R +import com.aiosman.ravenow.data.api.CaptchaResponseBody +import java.io.ByteArrayInputStream + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ClickCaptchaView( + captchaData: CaptchaResponseBody, + onPositionClicked: (Offset) -> Unit +) { + var clickPositions by remember { mutableStateOf(listOf()) } + + val context = LocalContext.current + val imageBitmap = remember(captchaData.masterBase64) { + val decodedString = Base64.decode(captchaData.masterBase64, Base64.DEFAULT) + val inputStream = ByteArrayInputStream(decodedString) + BitmapFactory.decodeStream(inputStream).asImageBitmap() + } + val thumbnailBitmap = remember(captchaData.thumbBase64) { + val decodedString = Base64.decode(captchaData.thumbBase64, Base64.DEFAULT) + val inputStream = ByteArrayInputStream(decodedString) + BitmapFactory.decodeStream(inputStream).asImageBitmap() + } + var boxWidth by remember { mutableStateOf(0) } + var boxHeightInDp by remember { mutableStateOf(0.dp) } + var scale by remember { mutableStateOf(1f) } + val density = LocalDensity.current + Column( + modifier = Modifier + .fillMaxWidth() + ) { + + Text(stringResource(R.string.captcha_hint)) + Spacer(modifier = Modifier.height(16.dp)) + Image( + bitmap = thumbnailBitmap, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { + boxWidth = it.size.width + scale = imageBitmap.width.toFloat() / boxWidth + boxHeightInDp = with(density) { (imageBitmap.height.toFloat() / scale).toDp() } + } + .background(Color.Gray) + ) { + if (boxWidth != 0 && boxHeightInDp != 0.dp) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(boxHeightInDp) + ) { + Image( + bitmap = imageBitmap, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxSize() + .pointerInteropFilter { event -> + if (event.action == android.view.MotionEvent.ACTION_DOWN) { + val newPosition = Offset(event.x, event.y) + clickPositions = clickPositions + newPosition + // 计算出点击的位置在图片上的坐标 + val imagePosition = Offset( + newPosition.x * scale, + newPosition.y * scale + ) + onPositionClicked(imagePosition) + true + } else { + false + } + } + ) + + // Draw markers at click positions + clickPositions.forEachIndexed { index, position -> + Canvas(modifier = Modifier.fillMaxSize()) { + drawCircle( + color = Color(0xaada3832).copy(), + radius = 40f, + center = position + ) + drawContext.canvas.nativeCanvas.apply { + drawText( + (index + 1).toString(), + position.x, + position.y + 15f, // Adjusting the y position to center the text + android.graphics.Paint().apply { + color = android.graphics.Color.WHITE + textSize = 50f + textAlign = android.graphics.Paint.Align.CENTER + } + ) + } + } + } + } + } + + } + } + +} + +@Composable +fun ClickCaptchaDialog( + captchaData: CaptchaResponseBody, + onLoadCaptcha: () -> Unit, + onDismissRequest: () -> Unit, + onPositionClicked: (Offset) -> Unit +) { + AlertDialog( + onDismissRequest = { + onDismissRequest() + }, + title = { + Text(stringResource(R.string.captcha)) + }, + text = { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + ) { + ClickCaptchaView( + captchaData = captchaData, + onPositionClicked = onPositionClicked + ) + } + Spacer(modifier = Modifier.height(16.dp)) + + ActionButton( + text = stringResource(R.string.refresh), + modifier = Modifier + .fillMaxWidth(), + ) { + onLoadCaptcha() + } + } + }, + confirmButton = { + + }, + ) +} + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/CustomClickableText.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/CustomClickableText.kt new file mode 100644 index 0000000..75c4b92 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/CustomClickableText.kt @@ -0,0 +1,50 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow + +@Composable +fun CustomClickableText( + text: AnnotatedString, + modifier: Modifier = Modifier, + style: TextStyle = TextStyle.Default, + softWrap: Boolean = true, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, + onTextLayout: (TextLayoutResult) -> Unit = {}, + onLongPress: () -> Unit = {}, + onClick: (Int) -> Unit +) { + val layoutResult = remember { mutableStateOf(null) } + val pressIndicator = Modifier.pointerInput(onClick) { + detectTapGestures( + onLongPress = { onLongPress() } + ) { pos -> + layoutResult.value?.let { layoutResult -> + onClick(layoutResult.getOffsetForPosition(pos)) + } + } + } + + BasicText( + text = text, + modifier = modifier.then(pressIndicator), + style = style, + softWrap = softWrap, + overflow = overflow, + maxLines = maxLines, + onTextLayout = { + layoutResult.value = it + onTextLayout(it) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/DebounceUtils.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/DebounceUtils.kt new file mode 100644 index 0000000..4199840 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/DebounceUtils.kt @@ -0,0 +1,163 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.platform.debugInspectorInfo +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * 防抖点击修饰符 + * @param enabled 是否启用点击 + * @param debounceTime 防抖时间(毫秒),默认500ms + * @param onClick 点击回调 + */ +fun Modifier.debouncedClickable( + enabled: Boolean = true, + debounceTime: Long = 500L, + onClick: () -> Unit +): Modifier = composed( + inspectorInfo = debugInspectorInfo { + name = "debouncedClickable" + properties["enabled"] = enabled + properties["debounceTime"] = debounceTime + } +) { + var isClickable by remember { mutableStateOf(true) } + val scope = rememberCoroutineScope() + + clickable( + enabled = enabled && isClickable, + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + if (isClickable) { + isClickable = false + onClick() + scope.launch { + delay(debounceTime) + isClickable = true + } + } + } +} + +/** + * 防抖点击修饰符(带涟漪效果) + * @param enabled 是否启用点击 + * @param debounceTime 防抖时间(毫秒),默认500ms + * @param onClick 点击回调 + */ +fun Modifier.debouncedClickableWithRipple( + enabled: Boolean = true, + debounceTime: Long = 500L, + onClick: () -> Unit +): Modifier = composed( + inspectorInfo = debugInspectorInfo { + name = "debouncedClickableWithRipple" + properties["enabled"] = enabled + properties["debounceTime"] = debounceTime + } +) { + var isClickable by remember { mutableStateOf(true) } + val scope = rememberCoroutineScope() + + clickable( + enabled = enabled && isClickable, + interactionSource = remember { MutableInteractionSource() }, + indication = androidx.compose.material.ripple.rememberRipple() + ) { + if (isClickable) { + isClickable = false + onClick() + scope.launch { + delay(debounceTime) + isClickable = true + } + } + } +} + +/** + * 通用防抖处理器 + * 可以用于任何需要防抖的场景 + */ +@Composable +fun rememberDebouncer(debounceTime: Long = 500L): ((() -> Unit) -> Unit) { + var isExecuting by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + return remember { + { action -> + if (!isExecuting) { + isExecuting = true + action() + scope.launch { + delay(debounceTime) + isExecuting = false + } + } + } + } +} + +/** + * 防抖状态管理器 + * 可以手动控制防抖状态 + */ +@Composable +fun rememberDebouncedState( + debounceTime: Long = 500L +): Triple Unit, () -> Unit> { + var isDebouncing by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + val startDebounce: () -> Unit = remember { + { + isDebouncing = true + scope.launch { + delay(debounceTime) + isDebouncing = false + } + } + } + + val reset = remember { + { + isDebouncing = false + } + } + + return Triple(isDebouncing, startDebounce, reset) +} + +/** + * 防抖的导航处理器 + * 专门用于导航操作的防抖 + */ +@Composable +fun rememberDebouncedNavigation( + debounceTime: Long = 1000L +): ((() -> Unit) -> Unit) { + var isNavigating by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + return remember { + { navigation -> + if (!isNavigating) { + isNavigating = true + try { + navigation() + } finally { + scope.launch { + delay(debounceTime) + isNavigating = false + } + } + } + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/DragAndDrop.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/DragAndDrop.kt new file mode 100644 index 0000000..be02b6c --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/DragAndDrop.kt @@ -0,0 +1,285 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridItemScope +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.shape.RoundedCornerShape +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.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toOffset +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun DraggableGrid( + items: List, + getItemId: (T) -> String, + onMove: (Int, Int) -> Unit, + onDragModeStart: () -> Unit, // New parameter for drag start + onDragModeEnd: () -> Unit, // New parameter for drag end, + additionalItems: List<@Composable () -> Unit> = emptyList(), // New parameter for additional items + lockedIndices: List = emptyList(), // New parameter for locked indices + content: @Composable (T, Boolean) -> Unit, +) { + + val gridState = rememberLazyGridState() + val dragDropState = + rememberGridDragDropState(gridState, onMove, onDragModeStart, onDragModeEnd, lockedIndices) + LazyVerticalGrid( + columns = GridCells.Fixed(5), + modifier = Modifier.dragContainer(dragDropState).padding(horizontal = 8.dp), + state = gridState, + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + + ) { + itemsIndexed(items, key = { _, item -> + getItemId(item) + }) { index, item -> + DraggableItem(dragDropState, index) { isDragging -> + content(item, isDragging) + } + } + additionalItems.forEach { additionalItem -> + item { + additionalItem() + } + } + + } +} + +fun Modifier.dragContainer(dragDropState: GridDragDropState): Modifier { + return pointerInput(dragDropState) { + detectDragGesturesAfterLongPress( + onDrag = { change, offset -> + change.consume() + dragDropState.onDrag(offset = offset) + }, + onDragStart = { offset -> dragDropState.onDragStart(offset) }, + onDragEnd = { dragDropState.onDragInterrupted() }, + onDragCancel = { dragDropState.onDragInterrupted() } + ) + } +} + +@ExperimentalFoundationApi +@Composable +fun LazyGridItemScope.DraggableItem( + dragDropState: GridDragDropState, + index: Int, + modifier: Modifier = Modifier, + content: @Composable (isDragging: Boolean) -> Unit, +) { + val dragging = index == dragDropState.draggingItemIndex + val draggingModifier = if (dragging) { + Modifier + .zIndex(1f) + .graphicsLayer { + translationX = dragDropState.draggingItemOffset.x + translationY = dragDropState.draggingItemOffset.y + } + } else if (index == dragDropState.previousIndexOfDraggedItem) { + Modifier + .zIndex(1f) + .graphicsLayer { + translationX = dragDropState.previousItemOffset.value.x + translationY = dragDropState.previousItemOffset.value.y + } + } else { + Modifier.animateItemPlacement() + } + Box(modifier = modifier.then(draggingModifier).clip(RoundedCornerShape(8.dp)), propagateMinConstraints = true) { + content(dragging) + } +} + + +@Composable +fun rememberGridDragDropState( + gridState: LazyGridState, + onMove: (Int, Int) -> Unit, + onDragModeStart: () -> Unit, + onDragModeEnd: () -> Unit, + lockedIndices: List // New parameter for locked indices +): GridDragDropState { + val scope = rememberCoroutineScope() + val state = remember(gridState) { + GridDragDropState( + state = gridState, + onMove = onMove, + scope = scope, + onDragModeStart = onDragModeStart, + onDragModeEnd = onDragModeEnd, + lockedIndices = lockedIndices // Pass the locked indices + ) + } + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + gridState.scrollBy(diff) + } + } + return state +} + +class GridDragDropState internal constructor( + private val state: LazyGridState, + private val scope: CoroutineScope, + private val onMove: (Int, Int) -> Unit, + private val onDragModeStart: () -> Unit, + private val onDragModeEnd: () -> Unit, + private val lockedIndices: List // New parameter for locked indices +) { + var draggingItemIndex by mutableStateOf(null) + private set + + internal val scrollChannel = Channel() + + private var draggingItemDraggedDelta by mutableStateOf(Offset.Zero) + private var draggingItemInitialOffset by mutableStateOf(Offset.Zero) + internal val draggingItemOffset: Offset + get() = draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemDraggedDelta - item.offset.toOffset() + } ?: Offset.Zero + + private val draggingItemLayoutInfo: LazyGridItemInfo? + get() = state.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == draggingItemIndex } + + internal var previousIndexOfDraggedItem by mutableStateOf(null) + private set + internal var previousItemOffset = Animatable(Offset.Zero, Offset.VectorConverter) + private set + + internal fun onDragStart(offset: Offset) { + state.layoutInfo.visibleItemsInfo + .firstOrNull { item -> + offset.x.toInt() in item.offset.x..item.offsetEnd.x && + offset.y.toInt() in item.offset.y..item.offsetEnd.y + }?.also { + if (it.index !in lockedIndices) { // Check if the item is not locked + draggingItemIndex = it.index + draggingItemInitialOffset = it.offset.toOffset() + onDragModeStart() // Notify drag start + } + } + } + + internal fun onDragInterrupted() { + if (draggingItemIndex != null) { + previousIndexOfDraggedItem = draggingItemIndex + val startOffset = draggingItemOffset + scope.launch { + previousItemOffset.snapTo(startOffset) + previousItemOffset.animateTo( + Offset.Zero, + spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Offset.VisibilityThreshold + ) + ) + previousIndexOfDraggedItem = null + } + } + draggingItemDraggedDelta = Offset.Zero + draggingItemIndex = null + draggingItemInitialOffset = Offset.Zero + onDragModeEnd() // Notify drag end + } + + internal fun onDrag(offset: Offset) { + draggingItemDraggedDelta += offset + + val draggingItem = draggingItemLayoutInfo ?: return + val startOffset = draggingItem.offset.toOffset() + draggingItemOffset + val endOffset = startOffset + draggingItem.size.toSize() + val middleOffset = startOffset + (endOffset - startOffset) / 2f + + val targetItem = state.layoutInfo.visibleItemsInfo.find { item -> + middleOffset.x.toInt() in item.offset.x..item.offsetEnd.x && + middleOffset.y.toInt() in item.offset.y..item.offsetEnd.y && + draggingItem.index != item.index && + item.index !in lockedIndices // Check if the target item is not locked + } + if (targetItem != null) { + val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) { + draggingItem.index + } else if (draggingItem.index == state.firstVisibleItemIndex) { + targetItem.index + } else { + null + } + if (scrollToIndex != null) { + scope.launch { + state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) + onMove.invoke(draggingItem.index, targetItem.index) + } + } else { + onMove.invoke(draggingItem.index, targetItem.index) + } + draggingItemIndex = targetItem.index + } else { + val overscroll = when { + draggingItemDraggedDelta.y > 0 -> + (endOffset.y - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + + draggingItemDraggedDelta.y < 0 -> + (startOffset.y - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) + + else -> 0f + } + if (overscroll != 0f) { + scrollChannel.trySend(overscroll) + } + } + } + + private val LazyGridItemInfo.offsetEnd: IntOffset + get() = this.offset + this.size +} + + +operator fun IntOffset.plus(size: IntSize): IntOffset { + return IntOffset(x + size.width, y + size.height) +} + +operator fun Offset.plus(size: Size): Offset { + return Offset(x + size.width, y + size.height) +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/DropdownMenu.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/DropdownMenu.kt new file mode 100644 index 0000000..da84e39 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/DropdownMenu.kt @@ -0,0 +1,92 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + +data class MenuItem( + val title: String, + val icon: Int, + val action: () -> Unit +) + +@Composable +fun DropdownMenu( + expanded: Boolean = false, + menuItems: List = emptyList(), + width: Int? = null, + onDismissRequest: () -> Unit = {}, +) { + val AppColors = LocalAppTheme.current + + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = AppColors.secondaryBackground, + onSurface = AppColors.text, + outline = AppColors.divider, + ), + shapes = MaterialTheme.shapes.copy( + extraSmall = RoundedCornerShape(12.dp), + ) + ) { + androidx.compose.material3.DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = Modifier + .let { + if (width != null) it.width(width.dp) else it + } + ) { + for (item in menuItems) { + Box( + modifier = Modifier + .padding(vertical = 14.dp, horizontal = 24.dp) + .noRippleClickable { + item.action() + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + item.title, + fontWeight = FontWeight.W500, + color = AppColors.text, + ) + if (width != null) { + Spacer(modifier = Modifier.weight(1f)) + } else { + Spacer(modifier = Modifier.width(16.dp)) + } + Icon( + painter = painterResource(id = item.icon), + contentDescription = "", + modifier = Modifier.size(24.dp), + tint = AppColors.text + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/EditCommentBottomModal.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/EditCommentBottomModal.kt new file mode 100644 index 0000000..a00dc72 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/EditCommentBottomModal.kt @@ -0,0 +1,142 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.entity.CommentEntity +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + +@Composable +fun EditCommentBottomModal( + replyComment: CommentEntity? = null, + autoFocus: Boolean = false, + onSend: (String) -> Unit = {}, +) { + val AppColors = LocalAppTheme.current + var text by remember { mutableStateOf("") } + var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val focusRequester = remember { FocusRequester() } + val context = LocalContext.current + + LaunchedEffect(autoFocus) { + if (autoFocus) { + focusRequester.requestFocus() + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(AppColors.background) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(20.dp)) + .background(Color.Gray.copy(alpha = 0.1f)) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + BasicTextField( + value = text, + onValueChange = { + text = it + }, + cursorBrush = SolidColor(AppColors.text), + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), + textStyle = TextStyle( + color = Color.Black, + fontWeight = FontWeight.Normal + ), + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterStart + ) { + innerTextField() + if (text.isEmpty()) { + Text( + text = if (replyComment == null) "快来互动吧..." else "回复@${replyComment.name}", + color = AppColors.text.copy(alpha = 0.3f), // 30%透明度 + ) + } + } + } + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) + Icon( + painter = painterResource(id = R.mipmap.btn), + contentDescription = "Send", + modifier = Modifier + .size(40.dp) + .padding(top = 13.dp) + .noRippleClickable { + if (text.isNotEmpty()) { + onSend(text) + text = "" + } + }, + tint = Color.Unspecified + ) + + } + Spacer(modifier = Modifier.height(navBarHeight)) + } +} + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/FollowButton.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/FollowButton.kt new file mode 100644 index 0000000..4ab16b6 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/FollowButton.kt @@ -0,0 +1,52 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + +@Composable +fun FollowButton( + isFollowing: Boolean, + fontSize: TextUnit = 14.sp, + onFollowClick: () -> Unit, +){ + val AppColors = LocalAppTheme.current + Box( + modifier = Modifier + .wrapContentWidth() + .clip(RoundedCornerShape(8.dp)) + .background( + color = if (isFollowing) AppColors.main else AppColors.nonActive + ) + .padding(horizontal = 16.dp, vertical = 8.dp) + .noRippleClickable { + onFollowClick() + }, + contentAlignment = Alignment.Center + ) { + Text( + text = if (isFollowing) stringResource(R.string.following_upper) else stringResource( + R.string.follow_upper + ), + fontSize = fontSize, + color = if (isFollowing) AppColors.mainText else AppColors.text, + style = TextStyle(fontWeight = FontWeight.Bold) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/Image.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/Image.kt new file mode 100644 index 0000000..b73d17d --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/Image.kt @@ -0,0 +1,236 @@ +package com.aiosman.ravenow.ui.composables + +import android.content.Context +import android.graphics.Bitmap +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.core.graphics.drawable.toBitmap +import coil.ImageLoader +import coil.compose.AsyncImage +import coil.request.ImageRequest +import coil.request.SuccessResult +import com.aiosman.ravenow.utils.Utils.getImageLoader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * CustomAsyncImage 组件使用说明: + * + * @param context 上下文,可选 + * @param imageUrl 图片URL或Bitmap对象 + * @param contentDescription 图片描述 + * @param modifier 修饰符 + * @param blurHash 模糊哈希值(暂未使用) + * @param placeholderRes 加载时显示的占位符图片资源ID + * @param errorRes 加载失败时显示的错误图片资源ID + * @param defaultRes 当imageUrl为空或null时显示的默认图片资源ID(优先级最高) + * @param contentScale 图片缩放模式 + * + * 使用示例: + * CustomAsyncImage( + * imageUrl = "https://example.com/image.jpg", + * contentDescription = "用户头像", + * defaultRes = R.mipmap.default_avatar, + * placeholderRes = R.mipmap.loading_placeholder, + * errorRes = R.mipmap.error_image + * ) + */ +@Composable +fun rememberImageBitmap(imageUrl: String, imageLoader: ImageLoader): Bitmap? { + val context = LocalContext.current + var bitmap by remember { mutableStateOf(null) } + + LaunchedEffect(imageUrl) { + val request = ImageRequest.Builder(context) + .data(imageUrl) + .crossfade(true) + .build() + + val result = withContext(Dispatchers.IO) { + (imageLoader.execute(request) as? SuccessResult)?.drawable?.toBitmap() + } + + bitmap = result + } + + return bitmap +} + +@Composable +fun CustomAsyncImage( + context: Context? = null, + imageUrl: Any?, + contentDescription: String?, + modifier: Modifier = Modifier, + blurHash: String? = null, + @DrawableRes + placeholderRes: Int? = null, + @DrawableRes + errorRes: Int? = null, + @DrawableRes + defaultRes: Int? = null, + contentScale: ContentScale = ContentScale.Crop, + showShimmer: Boolean = true +) { + val localContext = LocalContext.current + + // 使用remember来缓存ImageLoader,避免重复创建 + val imageLoader = remember { getImageLoader(context ?: localContext) } + + // 处理 imageUrl 为 null 或空字符串的情况 + if (imageUrl == null || imageUrl == "") { + // 优先使用 defaultRes,然后是 placeholderRes + val fallbackRes = defaultRes ?: placeholderRes + if (fallbackRes != null) { + if (showShimmer) { + SimpleShimmer(modifier = modifier) { + Image( + painter = androidx.compose.ui.res.painterResource(fallbackRes), + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale + ) + } + } else { + Image( + painter = androidx.compose.ui.res.painterResource(fallbackRes), + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale + ) + } + return + } + } + + // 处理 Bitmap 类型 + if (imageUrl is Bitmap) { + if (showShimmer) { + SimpleShimmer(modifier = modifier) { + Image( + bitmap = imageUrl.asImageBitmap(), + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale + ) + } + } else { + Image( + bitmap = imageUrl.asImageBitmap(), + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale + ) + } + return + } + + // 处理字符串URL + if (showShimmer) { + var isLoading by remember { mutableStateOf(true) } + + Box(modifier = modifier) { + AsyncImage( + model = ImageRequest.Builder(context ?: localContext) + .data(imageUrl) + .crossfade(200) + .memoryCachePolicy(coil.request.CachePolicy.ENABLED) + .diskCachePolicy(coil.request.CachePolicy.ENABLED) + .apply { + // 设置占位符图片 + if (placeholderRes != null) { + placeholder(placeholderRes) + } + // 设置错误时显示的图片 + if (errorRes != null) { + error(errorRes) + } + } + .build(), + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale, + imageLoader = imageLoader, + onLoading = { isLoading = true }, + onSuccess = { isLoading = false }, + onError = { isLoading = false } + ) + + // 只在加载时显示shimmer + if (isLoading) { + SimpleShimmer(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) + } + } + } + } else { + AsyncImage( + model = ImageRequest.Builder(context ?: localContext) + .data(imageUrl) + .crossfade(200) + .memoryCachePolicy(coil.request.CachePolicy.ENABLED) + .diskCachePolicy(coil.request.CachePolicy.ENABLED) + .apply { + // 设置占位符图片 + if (placeholderRes != null) { + placeholder(placeholderRes) + } + // 设置错误时显示的图片 + if (errorRes != null) { + error(errorRes) + } + } + .build(), + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + imageLoader = imageLoader + ) + } +} + +/* +使用示例: + +1. 基本使用(带默认图片): +CustomAsyncImage( + imageUrl = user.avatar, + contentDescription = "用户头像", + defaultRes = R.mipmap.default_avatar +) + +2. 完整配置: +CustomAsyncImage( + imageUrl = "https://example.com/image.jpg", + contentDescription = "产品图片", + defaultRes = R.mipmap.default_product, + placeholderRes = R.mipmap.loading_placeholder, + errorRes = R.mipmap.error_image, + contentScale = ContentScale.Crop +) + +3. 处理空URL: +CustomAsyncImage( + imageUrl = "", // 空字符串会显示默认图片 + contentDescription = "头像", + defaultRes = R.mipmap.default_avatar +) + +4. 处理Bitmap: +CustomAsyncImage( + imageUrl = bitmapObject, // Bitmap对象会直接显示 + contentDescription = "裁剪后的图片" +) +*/ \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/Moment.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/Moment.kt new file mode 100644 index 0000000..e80046b --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/Moment.kt @@ -0,0 +1,583 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.entity.MomentImageEntity +import com.aiosman.ravenow.exp.timeAgo +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.comment.CommentModalContent +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.navigateToPost + +@Composable +fun MomentCard( + modifier: Modifier = Modifier, + momentEntity: MomentEntity, + onLikeClick: () -> Unit = {}, + onFavoriteClick: () -> Unit = {}, + onAddComment: () -> Unit = {}, + onFollowClick: () -> Unit = {}, + hideAction: Boolean = false, + showFollowButton: Boolean = true +) { + val AppColors = LocalAppTheme.current + var imageIndex by remember { mutableStateOf(0) } + val navController = LocalNavController.current + Column( + modifier = modifier + .fillMaxWidth() + .background(AppColors.background) + ) { + Box( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp) + ) { + MomentTopRowGroup( + momentEntity = momentEntity, + onFollowClick = onFollowClick, + showFollowButton = showFollowButton + ) + } + val lastClickTime = remember { mutableStateOf(0L) } + val clickDelay = 500L + Column( + modifier = Modifier + .fillMaxWidth() + .noRippleClickable { + val currentTime = System.currentTimeMillis() + if (currentTime - lastClickTime.value > clickDelay) { + lastClickTime.value = currentTime + navController.navigateToPost( + momentEntity.id, + highlightCommentId = 0, + initImagePagerIndex = imageIndex + ) + } + } + ) { + MomentContentGroup( + momentEntity = momentEntity, + onPageChange = { index -> imageIndex = index } + ) + } + if (!hideAction) { + MomentBottomOperateRowGroup( + momentEntity = momentEntity, + onLikeClick = onLikeClick, + onAddComment = onAddComment, + onFavoriteClick = onFavoriteClick, + imageIndex = imageIndex, + onCommentClick = { + navController.navigateToPost( + momentEntity.id, + highlightCommentId = 0, + initImagePagerIndex = imageIndex + ) + } + ) + } + } +} + +@Composable +fun ModificationListHeader() { + val navController = LocalNavController.current + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF8F8F8)) + .padding(4.dp) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + navController.navigate("ModificationList") + } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .background(Color(0xFFEB4869)) + .padding(8.dp) + ) { + Icon( + Icons.Filled.Build, + contentDescription = "Modification Icon", + tint = Color.White, // Assuming the icon should be white + modifier = Modifier.size(12.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Modification List", + color = Color(0xFF333333), + fontSize = 14.sp, + textAlign = TextAlign.Left + ) + } + } + } +} + +@Composable +fun MomentName(name: String, modifier: Modifier = Modifier) { + val AppColors = LocalAppTheme.current + Text( + modifier = modifier, + textAlign = TextAlign.Start, + text = name, + color = AppColors.text, + fontSize = 16.sp, style = TextStyle(fontWeight = FontWeight.Bold) + ) +} + +@Composable +fun MomentFollowBtn() { + Box( + modifier = Modifier + .size(width = 53.dp, height = 18.dp) + .padding(start = 8.dp), + contentAlignment = Alignment.Center + ) { + Image( + modifier = Modifier + .fillMaxSize(), + painter = painterResource(id = R.drawable.follow_bg), + contentDescription = "" + ) + Text( + text = "Follow", + color = Color.White, + fontSize = 12.sp + ) + } +} + +@Composable +fun MomentPostLocation(location: String) { + val AppColors = LocalAppTheme.current + Text( + text = location, + color = AppColors.secondaryText, + fontSize = 12.sp, + ) +} + +@Composable +fun MomentPostTime(time: String) { + val AppColors = LocalAppTheme.current + Text( + modifier = Modifier, + text = time, color = AppColors.text, + fontSize = 12.sp + ) +} + +@Composable +fun MomentTopRowGroup( + momentEntity: MomentEntity, + showFollowButton: Boolean = true, + onFollowClick: () -> Unit = {} +) { + val navController = LocalNavController.current + val context = LocalContext.current + Row( + modifier = Modifier + ) { + val lastClickTime = remember { mutableStateOf(0L) } + val clickDelay = 500L + CustomAsyncImage( + context, + momentEntity.avatar, + contentDescription = "", + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(40.dp)) + .noRippleClickable { + val currentTime = System.currentTimeMillis() + if (currentTime - lastClickTime.value > clickDelay) { + lastClickTime.value = currentTime + navController.navigate( + NavigationRoute.AccountProfile.route.replace( + "{id}", + momentEntity.authorId.toString() + ) + ) + } + }, + contentScale = ContentScale.Crop + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 12.dp, end = 12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(22.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MomentName( + modifier = Modifier.weight(1f) + .noRippleClickable { + val currentTime = System.currentTimeMillis() + if (currentTime - lastClickTime.value > clickDelay) { + lastClickTime.value = currentTime + navController.navigate( + NavigationRoute.AccountProfile.route.replace( + "{id}", + momentEntity.authorId.toString() + ) + ) + } + }, + name = momentEntity.nickname + ) + Spacer(modifier = Modifier.width(16.dp)) + + } + Row( + modifier = Modifier + .fillMaxWidth() + .height(21.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MomentPostTime(momentEntity.time.timeAgo(context)) + Spacer(modifier = Modifier.width(8.dp)) + //MomentPostLocation(momentEntity.location) + } + } + val isFollowing = momentEntity.followStatus + if (showFollowButton && !isFollowing) { + Spacer(modifier = Modifier.width(16.dp)) + if (AppState.UserId != momentEntity.authorId) { + FollowButton( + isFollowing = false + ) { + onFollowClick() + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PostImageView( + images: List, + onPageChange: (Int) -> Unit = {} +) { + val pagerState = rememberPagerState(pageCount = { images.size }) + LaunchedEffect(pagerState.currentPage) { + onPageChange(pagerState.currentPage) + } + val context = LocalContext.current + + Column( + modifier = Modifier.fillMaxWidth() + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + ) { page -> + val image = images[page] + CustomAsyncImage( + context, + image.thumbnail, + contentDescription = "Image", + blurHash = image.blurHash, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + ) + + } + } + +} + +@Composable +fun MomentContentGroup( + momentEntity: MomentEntity, + onPageChange: (Int) -> Unit = {} +) { + val AppColors = LocalAppTheme.current + if (momentEntity.relMoment != null) { + RelPostCard( + momentEntity = momentEntity.relMoment!!, + modifier = Modifier.background(Color(0xFFF8F8F8)) + ) + } else { + Box( + modifier = Modifier.fillMaxWidth() + ) { + PostImageView( + images = momentEntity.images, + onPageChange = onPageChange + ) + } + } + if (momentEntity.momentTextContent.isNotEmpty()) { + Text( + text = momentEntity.momentTextContent, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 8.dp), + fontSize = 16.sp, + color = AppColors.text + ) + } + + +} + +@Composable +fun MomentOperateBtn(@DrawableRes icon: Int, count: String) { + val AppColors = LocalAppTheme.current + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + modifier = Modifier + .size(width = 24.dp, height = 24.dp), + painter = painterResource(id = icon), + contentDescription = "", + ) + if (count.isNotEmpty()) { + Text( + text = count, + modifier = Modifier.padding(start = 7.dp), + fontSize = 14.sp, + color = AppColors.text + ) + } + } +} + +@Composable +fun MomentOperateBtn(count: String, content: @Composable () -> Unit) { + Row( + modifier = Modifier, + verticalAlignment = Alignment.CenterVertically + ) { + content() + AnimatedCounter( + count = count.toInt(), + fontSize = 14, + modifier = Modifier + .padding(start = 7.dp) + .width(24.dp) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MomentBottomOperateRowGroup( + onLikeClick: () -> Unit = {}, + onAddComment: () -> Unit = {}, + onCommentClick: () -> Unit = {}, + onFavoriteClick: () -> Unit = {}, + momentEntity: MomentEntity, + imageIndex: Int = 0 +) { + val lastClickTime = remember { mutableStateOf(0L) } + val clickDelay = 500L + var showCommentModal by remember { mutableStateOf(false) } + if (showCommentModal) { + ModalBottomSheet( + onDismissRequest = { showCommentModal = false }, + containerColor = Color.White, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), + windowInsets = WindowInsets(0), + dragHandle = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .clip(CircleShape) + ) { + + } + } + ) { + CommentModalContent( + postId = momentEntity.id, + commentCount = momentEntity.commentCount, + onCommentAdded = { + onAddComment() + } + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(start = 16.dp, end = 0.dp) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + if (momentEntity.images.size > 1) { + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + momentEntity.images.forEachIndexed { index, _ -> + Box( + modifier = Modifier + .size(4.dp) + .clip(CircleShape) + .background( + if (imageIndex == index) Color.Red else Color.Gray.copy( + alpha = 0.5f + ) + ) + .padding(1.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f).fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically + ) { + // 点赞按钮 + MomentOperateBtn(count = momentEntity.likeCount.toString()) { + AnimatedLikeIcon( + modifier = Modifier.size(24.dp), + liked = momentEntity.liked + ) { + onLikeClick() + } + } + Spacer(modifier = Modifier.width(10.dp)) + // 评论按钮 + Box( + modifier = Modifier.noRippleClickable { + val currentTime = System.currentTimeMillis() + if (currentTime - lastClickTime.value > clickDelay) { + lastClickTime.value = currentTime + onCommentClick() + } + } + ) { + MomentOperateBtn( + icon = R.mipmap.icon_comment, + count = momentEntity.commentCount.toString() + ) + } + Spacer(modifier = Modifier.width(28.dp)) + // 转发按钮 + Box( + modifier = Modifier.noRippleClickable { + // TODO: 实现转发功能 + } + ) { + MomentOperateBtn( + icon = R.mipmap.icon_share, + count = "" + ) + } + } + + // 收藏按钮 + MomentOperateBtn(count = momentEntity.favoriteCount.toString()) { + AnimatedFavouriteIcon( + modifier = Modifier.size(24.dp), + isFavourite = momentEntity.isFavorite + ) { + onFavoriteClick() + } + } + } + } + } + + +} + +@Composable +fun MomentListLoading() { + CircularProgressIndicator( + modifier = + Modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + color = Color.Red + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/PickupAndCompressLauhcner.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/PickupAndCompressLauhcner.kt new file mode 100644 index 0000000..9fd8d77 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/PickupAndCompressLauhcner.kt @@ -0,0 +1,38 @@ +package com.aiosman.ravenow.ui.composables + +import android.app.Activity +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import com.aiosman.ravenow.utils.Utils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.io.File + +/** + * 选择图片并压缩 + */ +@Composable +fun pickupAndCompressLauncher( + context: Context, + scope: CoroutineScope, + maxSize: Int = 512, + quality: Int = 85, + onImagePicked: (Uri, File) -> Unit +) = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() +) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val uri = result.data?.data + uri?.let { + scope.launch { + // Compress the image + val file = Utils.compressImage(context, it, maxSize = maxSize, quality = quality) + // Check the compressed image size + onImagePicked(it, file) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/PolicyCheckbox.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/PolicyCheckbox.kt new file mode 100644 index 0000000..9f09199 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/PolicyCheckbox.kt @@ -0,0 +1,140 @@ +package com.aiosman.ravenow.ui.composables + +import android.net.http.SslError +import android.webkit.SslErrorHandler +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import com.aiosman.ravenow.ConstVars +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.data.DictService +import com.aiosman.ravenow.data.DictServiceImpl +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PolicyCheckbox( + checked: Boolean = false, + error: Boolean = false, + onCheckedChange: (Boolean) -> Unit, +) { + var showModal by remember { mutableStateOf(false) } + var modalSheetState = androidx.compose.material3.rememberModalBottomSheetState( + skipPartiallyExpanded = true, + ) + var scope = rememberCoroutineScope() + val dictService: DictService = DictServiceImpl() + var policyUrl by remember { mutableStateOf("") } + val appColor = LocalAppTheme.current + fun openPolicyModel() { + scope.launch { + try { + val resp = dictService.getDictByKey(ConstVars.DICT_KEY_PRIVATE_POLICY_URL) + policyUrl = resp.value as String + showModal = true + + } catch (e: Exception) { + e.printStackTrace() + } + } + + } + if (showModal) { + ModalBottomSheet( + onDismissRequest = { + showModal = false + }, + sheetState = modalSheetState, + windowInsets = WindowInsets(0), + containerColor = Color.White, + ) { + WebViewDisplay( + url = policyUrl + ) + } + } + Row { + Checkbox( + checked = checked, + onCheckedChange = { + onCheckedChange(it) + }, + size = 16 + ) + val text = buildAnnotatedString { + val keyword = stringResource(R.string.private_policy_keyword) + val template = stringResource(R.string.private_policy_template) + append(template) + append(" ") + withStyle(style = SpanStyle(color = if (error) appColor.error else appColor.text)) { + append(keyword) + } + + addStyle( + style = SpanStyle( + color = appColor.main, + textDecoration = TextDecoration.Underline + ), + start = template.length + 1, + end = template.length + keyword.length + 1 + ) + append(".") + } + ClickableText( + text = text, + modifier = Modifier.padding(start = 8.dp), + onClick = { + openPolicyModel() + }, + style = TextStyle( + fontSize = 12.sp, + color = if (error) appColor.error else appColor.text + ) + ) + } +} + +@Composable +fun WebViewDisplay(modifier: Modifier = Modifier, url: String) { + LazyColumn( + modifier = modifier.fillMaxSize() + ) { + item { + AndroidView( + factory = { context -> + WebView(context).apply { + loadUrl(url) + } + }, + modifier = modifier.fillMaxSize() + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/RelPostCard.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/RelPostCard.kt new file mode 100644 index 0000000..62565a0 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/RelPostCard.kt @@ -0,0 +1,39 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +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.entity.MomentEntity + +@Composable +fun RelPostCard( + momentEntity: MomentEntity, + modifier: Modifier = Modifier, +) { + val image = momentEntity.images.firstOrNull() + val context = LocalContext.current + Column( + modifier = modifier + ) { + MomentTopRowGroup(momentEntity = momentEntity) + Box( + modifier=Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) { + image?.let { + CustomAsyncImage( + context, + image.thumbnail, + contentDescription = null, + modifier = Modifier.size(100.dp), + contentScale = ContentScale.Crop + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/ShimmerEffect.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/ShimmerEffect.kt new file mode 100644 index 0000000..c2a9a2a --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/ShimmerEffect.kt @@ -0,0 +1,74 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * 从左到右扫描的Shimmer加载效果 + */ +@Composable +fun ShimmerEffect( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val infiniteTransition = rememberInfiniteTransition() + val translateAnim by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1200, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Restart + ) + ) + + val shimmerColors = listOf( + Color.LightGray.copy(alpha = 0.6f), + Color.LightGray.copy(alpha = 0.2f), + Color.LightGray.copy(alpha = 0.6f), + ) + + val brush = Brush.linearGradient( + colors = shimmerColors, + start = Offset.Zero, + end = Offset(x = translateAnim, y = translateAnim) + ) + + Box(modifier = modifier) { + content() + Box( + modifier = Modifier + .fillMaxSize() + .background(brush) + ) + } +} + +/** + * 带圆角的Shimmer占位符 + */ +@Composable +fun ShimmerPlaceholder( + modifier: Modifier = Modifier, + cornerRadius: Float = 8f +) { + Box( + modifier = modifier + .background( + color = Color.LightGray.copy(alpha = 0.3f), + shape = RoundedCornerShape(cornerRadius.dp) + ) + ) +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/SimpleShimmer.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/SimpleShimmer.kt new file mode 100644 index 0000000..29adade --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/SimpleShimmer.kt @@ -0,0 +1,74 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * 简单的从左到右扫描的Shimmer加载效果 + */ +@Composable +fun SimpleShimmer( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val infiniteTransition = rememberInfiniteTransition() + val translateAnim by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1200, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Restart + ) + ) + + val shimmerColors = listOf( + Color.LightGray.copy(alpha = 0.6f), + Color.LightGray.copy(alpha = 0.2f), + Color.LightGray.copy(alpha = 0.6f), + ) + + val brush = Brush.linearGradient( + colors = shimmerColors, + start = Offset.Zero, + end = Offset(x = translateAnim, y = translateAnim) + ) + + Box(modifier = modifier) { + content() + Box( + modifier = Modifier + .fillMaxSize() + .background(brush) + ) + } +} + +/** + * 带圆角的Shimmer占位符 + */ +@Composable +fun SimpleShimmerPlaceholder( + modifier: Modifier = Modifier, + cornerRadius: Float = 8f +) { + Box( + modifier = modifier + .background( + color = Color.LightGray.copy(alpha = 0.3f), + shape = RoundedCornerShape(cornerRadius.dp) + ) + ) +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/StatusBarMask.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/StatusBarMask.kt new file mode 100644 index 0000000..a8f007b --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/StatusBarMask.kt @@ -0,0 +1,69 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.systemBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.aiosman.ravenow.LocalAppTheme +import com.google.accompanist.systemuicontroller.rememberSystemUiController + +@Composable +fun StatusBarMask(darkIcons: Boolean = true) { + val paddingValues = WindowInsets.systemBars.asPaddingValues() + val systemUiController = rememberSystemUiController() + LaunchedEffect(Unit) { + systemUiController.setStatusBarColor(Color.Transparent, darkIcons = darkIcons) + } + Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding())) + +} + +@Composable +fun StatusBarMaskLayout( + modifier: Modifier = Modifier, + darkIcons: Boolean = true, + useNavigationBarMask: Boolean = true, + maskBoxBackgroundColor: Color = Color.Transparent, + content: @Composable ColumnScope.() -> Unit +) { + val AppColors = LocalAppTheme.current + val paddingValues = WindowInsets.systemBars.asPaddingValues() + val systemUiController = rememberSystemUiController() + val navigationBarPaddings = + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + LaunchedEffect(Unit) { + systemUiController.setStatusBarColor(Color.Transparent, darkIcons = darkIcons) + } + Column( + modifier = modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .height(paddingValues.calculateTopPadding()) + .fillMaxWidth() + .background(maskBoxBackgroundColor) + ) { + + } + content() + if (navigationBarPaddings > 24.dp && useNavigationBarMask) { + Box( + modifier = Modifier + .height(navigationBarPaddings).fillMaxWidth().background(AppColors.background) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/StatusBarSpacer.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/StatusBarSpacer.kt new file mode 100644 index 0000000..b198d34 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/StatusBarSpacer.kt @@ -0,0 +1,15 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.systemBars +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun StatusBarSpacer() { + val paddingValues = WindowInsets.systemBars.asPaddingValues() + Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding())) +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/TabItem.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/TabItem.kt new file mode 100644 index 0000000..abac32b --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/TabItem.kt @@ -0,0 +1,104 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +/** + * 可复用的标签页组件 + */ +@Composable +fun TabItem( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val AppColors = LocalAppTheme.current + + Column( + modifier = modifier + .noRippleClickable { onClick() }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = text, + fontSize = 15.sp, + color = if (isSelected) AppColors.tabSelectedText else AppColors.tabUnselectedText, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(if (isSelected) AppColors.tabSelectedBackground else AppColors.tabUnselectedBackground) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + } +} + +/** + * 标签页之间的间隔组件 + */ +@Composable +fun TabSpacer() { + Spacer(modifier = Modifier.width(8.dp)) +} + + +@Composable +fun UnderlineTabItem( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val AppColors = LocalAppTheme.current + + Column( + modifier = modifier + .noRippleClickable { onClick() }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = text, + fontSize = 15.sp, + fontWeight = FontWeight.ExtraBold, + color = if (isSelected) AppColors.text else AppColors.text.copy(alpha = 0.6f), + modifier = Modifier.padding(horizontal = 16.dp).padding(top = 13.dp) + ) + + // 选中状态下显示图标 + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center + ) { + if (isSelected) { + Image( + painter = painterResource(id = R.mipmap.underline), + contentDescription = "selected indicator", + + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/TextInputField.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/TextInputField.kt new file mode 100644 index 0000000..3bcb007 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/TextInputField.kt @@ -0,0 +1,145 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + +@Composable +fun TextInputField( + modifier: Modifier = Modifier, + text: String, + onValueChange: (String) -> Unit, + password: Boolean = false, + label: String? = null, + hint: String? = null, + error: String? = null, + enabled: Boolean = true +) { + val AppColors = LocalAppTheme.current + var showPassword by remember { mutableStateOf(!password) } + var isFocused by remember { mutableStateOf(false) } + Column(modifier = modifier) { + label?.let { + Text(it, color = AppColors.secondaryText) + Spacer(modifier = Modifier.height(16.dp)) + } + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .background(AppColors.inputBackground) + .border( + width = 2.dp, + color = if (error == null) Color.Transparent else AppColors.error, + shape = RoundedCornerShape(24.dp) + ) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically){ + BasicTextField( + value = text, + onValueChange = onValueChange, + modifier = Modifier + .weight(1f) + .onFocusChanged { focusState -> + isFocused = focusState.isFocused + }, + textStyle = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.W500, + color = AppColors.text + ), + keyboardOptions = KeyboardOptions( + keyboardType = if (password) KeyboardType.Password else KeyboardType.Text + ), + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + singleLine = true, + enabled = enabled, + cursorBrush = SolidColor(AppColors.text), + ) + if (password) { + Image( + painter = painterResource(id = R.drawable.rider_pro_eye), + contentDescription = "Password", + modifier = Modifier + .size(18.dp) + .noRippleClickable { + showPassword = !showPassword + }, + colorFilter = ColorFilter.tint(AppColors.text) + ) + } + } + + if (text.isEmpty()) { + hint?.let { + Text(it, modifier = Modifier.padding(start = 5.dp), color = AppColors.inputHint, fontWeight = FontWeight.W600) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .height(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AnimatedVisibility( + visible = error != null, + enter = fadeIn(), + exit = fadeOut() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(id = R.mipmap.rider_pro_input_error), + contentDescription = "Error", + modifier = Modifier.size(8.dp) + ) + Spacer(modifier = Modifier.size(4.dp)) + AnimatedContent(targetState = error) { targetError -> + Text(targetError ?: "", color = AppColors.error, fontSize = 12.sp) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/form/FormTextInput.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/form/FormTextInput.kt new file mode 100644 index 0000000..3f4c97b --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/form/FormTextInput.kt @@ -0,0 +1,173 @@ +package com.aiosman.ravenow.ui.composables.form + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + +/** + * 水平布局的输入框 + */ +@Composable +fun FormTextInput( + modifier: Modifier = Modifier, + value: String, + label: String? = null, + error: String? = null, + hint: String? = null, + background: Color? = null, + onValueChange: (String) -> Unit +) { + val AppColors = LocalAppTheme.current + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + Column( + modifier = modifier + ) { + Row( + modifier = Modifier.fillMaxWidth() + .clip(RoundedCornerShape(25.dp)) + .background(background ?: AppColors.inputBackground) + .let { + if (error != null) { + it.border(1.dp, AppColors.error, RoundedCornerShape(24.dp)) + } else { + it + } + } + .padding(17.dp) + .noRippleClickable { + focusRequester.requestFocus() + keyboardController?.show() + }, + verticalAlignment = Alignment.CenterVertically + ) { + label?.let { + Text( + text = it, + modifier = Modifier + .widthIn(100.dp), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.W600, + color = AppColors.text + ) + ) + } + Box( + modifier = Modifier + .weight(1f) + ) { + Icon( + painter = painterResource(id = R.mipmap.icons_infor_edit), + contentDescription = null, + modifier = Modifier + .size(16.dp) + .align(Alignment.TopStart), + tint = AppColors.text.copy(alpha = 0.4f) + ) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.width(24.dp)) + Box( + modifier = Modifier.weight(1f) + ) { + if (value.isEmpty()) { + Text( + text = hint ?: "", + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + color = AppColors.inputHint + ) + ) + } + + BasicTextField( + maxLines = 1, + value = value, + onValueChange = { + onValueChange(it) + }, + modifier = Modifier + .focusRequester(focusRequester), + singleLine = true, + textStyle = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + color = AppColors.text + ), + cursorBrush = SolidColor(AppColors.text), + ) + } + } + } + + + + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .height(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AnimatedVisibility( + visible = error != null, + enter = fadeIn(), + exit = fadeOut() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(id = R.mipmap.rider_pro_input_error), + contentDescription = "Error", + modifier = Modifier.size(8.dp) + ) + Spacer(modifier = Modifier.size(4.dp)) + AnimatedContent(targetState = error) { targetError -> + Text(targetError ?: "", color = AppColors.error, fontSize = 12.sp) + } + } + } + } + } + +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/form/FormTextInput2.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/form/FormTextInput2.kt new file mode 100644 index 0000000..cf7bc18 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/form/FormTextInput2.kt @@ -0,0 +1,174 @@ +package com.aiosman.ravenow.ui.composables.form + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + +/** + * 垂直布局的输入框 + */ +@Composable +fun FormTextInput2( + modifier: Modifier = Modifier, + value: String, + label: String? = null, + error: String? = null, + hint: String? = null, + background: Color? = null, + focusRequester: FocusRequester? = null, + onValueChange: (String) -> Unit +) { + val AppColors = LocalAppTheme.current + val localFocusRequester = focusRequester ?: remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + Column( + modifier = modifier.height(150.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth() + .clip(RoundedCornerShape(25.dp)) + .background(background ?: AppColors.inputBackground) + .let { + if (error != null) { + it.border(1.dp, AppColors.error, RoundedCornerShape(24.dp)) + } else { + it + } + } + .padding(17.dp) + .noRippleClickable { + localFocusRequester.requestFocus() + keyboardController?.show() + }, + + ) { + label?.let { + Text( + text = it, + modifier = Modifier + .widthIn(100.dp), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.W600, + color = AppColors.text + ) + ) + } + Box( + modifier = Modifier + .weight(1f) + ) { + Icon( + painter = painterResource(id = R.mipmap.icons_infor_edit), + contentDescription = null, + modifier = Modifier + .size(16.dp) + .align(Alignment.TopStart), + tint = AppColors.text.copy(alpha = 0.4f) + ) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.width(24.dp)) + Box( + modifier = Modifier.weight(1f) + ) { + if (value.isEmpty()) { + Text( + text = hint ?: "", + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + color = AppColors.inputHint + ) + ) + } + + BasicTextField( + maxLines = 6, + value = value, + onValueChange = { + onValueChange(it) + }, + modifier = Modifier + .focusRequester(localFocusRequester), + textStyle = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + color = AppColors.text, + lineHeight = 20.sp + ), + cursorBrush = SolidColor(AppColors.text), + ) + } + } + } + + + + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .height(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AnimatedVisibility( + visible = error != null, + enter = fadeIn(), + exit = fadeOut() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(id = R.mipmap.rider_pro_input_error), + contentDescription = "Error", + modifier = Modifier.size(8.dp) + ) + Spacer(modifier = Modifier.size(4.dp)) + AnimatedContent(targetState = error) { targetError -> + Text(targetError ?: "", color = AppColors.error, fontSize = 12.sp) + } + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/Annotations.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/Annotations.kt new file mode 100644 index 0000000..0f0d0e2 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/Annotations.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.aiosman.ravenow.ui.composables.toolbar + +@RequiresOptIn( + message = "This is an experimental API of compose-collapsing-toolbar. Any declarations with " + + "the annotation might be removed or changed in some way without any notice.", + level = RequiresOptIn.Level.WARNING +) +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CLASS +) +@Retention(AnnotationRetention.BINARY) +annotation class ExperimentalToolbarApi diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/AppBarContainer.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/AppBarContainer.kt new file mode 100644 index 0000000..3f35355 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/AppBarContainer.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.aiosman.ravenow.ui.composables.toolbar + +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import kotlin.math.max + +@Deprecated( + "Use AppBarContainer for naming consistency", + replaceWith = ReplaceWith( + "AppBarContainer(modifier, scrollStrategy, collapsingToolbarState, content)", + "me.onebone.toolbar" + ) +) +@Composable +fun AppbarContainer( + modifier: Modifier = Modifier, + scrollStrategy: ScrollStrategy, + collapsingToolbarState: CollapsingToolbarState, + content: @Composable AppbarContainerScope.() -> Unit +) { + AppBarContainer( + modifier = modifier, + scrollStrategy = scrollStrategy, + collapsingToolbarState = collapsingToolbarState, + content = content + ) +} + +@Deprecated( + "AppBarContainer is replaced with CollapsingToolbarScaffold", + replaceWith = ReplaceWith( + "CollapsingToolbarScaffold", + "me.onebone.toolbar" + ) +) +@Composable +fun AppBarContainer( + modifier: Modifier = Modifier, + scrollStrategy: ScrollStrategy, + /** The state of a connected collapsing toolbar */ + collapsingToolbarState: CollapsingToolbarState, + content: @Composable AppbarContainerScope.() -> Unit +) { + val offsetY = remember { mutableStateOf(0) } + val flingBehavior = ScrollableDefaults.flingBehavior() + + val (scope, measurePolicy) = remember(scrollStrategy, collapsingToolbarState) { + AppbarContainerScopeImpl(scrollStrategy.create(offsetY, collapsingToolbarState, flingBehavior)) to + AppbarMeasurePolicy(scrollStrategy, collapsingToolbarState, offsetY) + } + + Layout( + content = { scope.content() }, + measurePolicy = measurePolicy, + modifier = modifier + ) +} + +interface AppbarContainerScope { + fun Modifier.appBarBody(): Modifier +} + +internal class AppbarContainerScopeImpl( + private val nestedScrollConnection: NestedScrollConnection +): AppbarContainerScope { + override fun Modifier.appBarBody(): Modifier { + return this + .then(AppBarBodyMarkerModifier) + .nestedScroll(nestedScrollConnection) + } +} + +private object AppBarBodyMarkerModifier: ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return AppBarBodyMarker + } +} + +private object AppBarBodyMarker + +private class AppbarMeasurePolicy( + private val scrollStrategy: ScrollStrategy, + private val toolbarState: CollapsingToolbarState, + private val offsetY: State +): MeasurePolicy { + override fun MeasureScope.measure( + measurables: List, + constraints: Constraints + ): MeasureResult { + var width = 0 + var height = 0 + + var toolbarPlaceable: Placeable? = null + + val nonToolbars = measurables.filter { + val data = it.parentData + if(data != AppBarBodyMarker) { + if(toolbarPlaceable != null) + throw IllegalStateException("There cannot exist multiple toolbars under single parent") + + val placeable = it.measure(constraints.copy( + minWidth = 0, + minHeight = 0 + )) + width = max(width, placeable.width) + height = max(height, placeable.height) + + toolbarPlaceable = placeable + + false + }else{ + true + } + } + + val placeables = nonToolbars.map { measurable -> + val childConstraints = if(scrollStrategy == ScrollStrategy.ExitUntilCollapsed) { + constraints.copy( + minWidth = 0, + minHeight = 0, + maxHeight = max(0, constraints.maxHeight - toolbarState.minHeight) + ) + }else{ + constraints.copy( + minWidth = 0, + minHeight = 0 + ) + } + + val placeable = measurable.measure(childConstraints) + + width = max(width, placeable.width) + height = max(height, placeable.height) + + placeable + } + + height += (toolbarPlaceable?.height ?: 0) + + return layout( + width.coerceIn(constraints.minWidth, constraints.maxWidth), + height.coerceIn(constraints.minHeight, constraints.maxHeight) + ) { + toolbarPlaceable?.place(x = 0, y = offsetY.value) + + placeables.forEach { placeable -> + placeable.place( + x = 0, + y = offsetY.value + (toolbarPlaceable?.height ?: 0) + ) + } + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/CollapsingToolbar.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/CollapsingToolbar.kt new file mode 100644 index 0000000..48833e0 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/CollapsingToolbar.kt @@ -0,0 +1,386 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.aiosman.ravenow.ui.composables.toolbar + +import androidx.annotation.FloatRange +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.tween +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +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.clipToBounds +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import kotlin.math.absoluteValue +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +@Stable +class CollapsingToolbarState( + initial: Int = Int.MAX_VALUE +): ScrollableState { + /** + * [height] indicates current height of the toolbar. + */ + var height: Int by mutableStateOf(initial) + private set + + /** + * [minHeight] indicates the minimum height of the collapsing toolbar. The toolbar + * may collapse its height to [minHeight] but not smaller. This size is determined by + * the smallest child. + */ + var minHeight: Int + get() = minHeightState + internal set(value) { + minHeightState = value + + if(height < value) { + height = value + } + } + + /** + * [maxHeight] indicates the maximum height of the collapsing toolbar. The toolbar + * may expand its height to [maxHeight] but not larger. This size is determined by + * the largest child. + */ + var maxHeight: Int + get() = maxHeightState + internal set(value) { + maxHeightState = value + + if(value < height) { + height = value + } + } + + private var maxHeightState by mutableStateOf(Int.MAX_VALUE) + private var minHeightState by mutableStateOf(0) + + val progress: Float + @FloatRange(from = 0.0, to = 1.0) + get() = + if(minHeight == maxHeight) { + 0f + }else{ + ((height - minHeight).toFloat() / (maxHeight - minHeight)).coerceIn(0f, 1f) + } + + private val scrollableState = ScrollableState { value -> + val consume = if(value < 0) { + max(minHeight.toFloat() - height, value) + }else{ + min(maxHeight.toFloat() - height, value) + } + + val current = consume + deferredConsumption + val currentInt = current.toInt() + + if(current.absoluteValue > 0) { + height += currentInt + deferredConsumption = current - currentInt + } + + consume + } + + private var deferredConsumption: Float = 0f + + /** + * @return consumed scroll value is returned + */ + @Deprecated( + message = "feedScroll() is deprecated, use dispatchRawDelta() instead.", + replaceWith = ReplaceWith("dispatchRawDelta(value)") + ) + fun feedScroll(value: Float): Float = dispatchRawDelta(value) + + @ExperimentalToolbarApi + suspend fun expand(duration: Int = 200) { + val anim = AnimationState(height.toFloat()) + + scroll { + var prev = anim.value + anim.animateTo(maxHeight.toFloat(), tween(duration)) { + scrollBy(value - prev) + prev = value + } + } + } + + @ExperimentalToolbarApi + suspend fun collapse(duration: Int = 200) { + val anim = AnimationState(height.toFloat()) + + scroll { + var prev = anim.value + anim.animateTo(minHeight.toFloat(), tween(duration)) { + scrollBy(value - prev) + prev = value + } + } + } + + /** + * @return Remaining velocity after fling + */ + suspend fun fling(flingBehavior: FlingBehavior, velocity: Float): Float { + var left = velocity + scroll { + with(flingBehavior) { + left = performFling(left) + } + } + + return left + } + + override val isScrollInProgress: Boolean + get() = scrollableState.isScrollInProgress + + override fun dispatchRawDelta(delta: Float): Float = scrollableState.dispatchRawDelta(delta) + + override suspend fun scroll( + scrollPriority: MutatePriority, + block: suspend ScrollScope.() -> Unit + ) = scrollableState.scroll(scrollPriority, block) +} + +@Composable +fun rememberCollapsingToolbarState( + initial: Int = Int.MAX_VALUE +): CollapsingToolbarState { + return remember { + CollapsingToolbarState( + initial = initial + ) + } +} + +@Composable +fun CollapsingToolbar( + modifier: Modifier = Modifier, + clipToBounds: Boolean = true, + collapsingToolbarState: CollapsingToolbarState, + content: @Composable CollapsingToolbarScope.() -> Unit +) { + val measurePolicy = remember(collapsingToolbarState) { + CollapsingToolbarMeasurePolicy(collapsingToolbarState) + } + + Layout( + content = { CollapsingToolbarScopeInstance.content() }, + measurePolicy = measurePolicy, + modifier = modifier.then( + if (clipToBounds) { + Modifier.clipToBounds() + } else { + Modifier + } + ) + ) +} + +private class CollapsingToolbarMeasurePolicy( + private val collapsingToolbarState: CollapsingToolbarState +): MeasurePolicy { + override fun MeasureScope.measure( + measurables: List, + constraints: Constraints + ): MeasureResult { + val placeables = measurables.map { + it.measure( + constraints.copy( + minWidth = 0, + minHeight = 0, + maxHeight = Constraints.Infinity + ) + ) + } + + val placeStrategy = measurables.map { it.parentData } + + val minHeight = placeables.minOfOrNull { it.height } + ?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0 + + val maxHeight = placeables.maxOfOrNull { it.height } + ?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0 + + val maxWidth = placeables.maxOfOrNull{ it.width } + ?.coerceIn(constraints.minWidth, constraints.maxWidth) ?: 0 + + collapsingToolbarState.also { + it.minHeight = minHeight + it.maxHeight = maxHeight + } + + val height = collapsingToolbarState.height + return layout(maxWidth, height) { + val progress = collapsingToolbarState.progress + + placeables.forEachIndexed { i, placeable -> + val strategy = placeStrategy[i] + if(strategy is CollapsingToolbarData) { + strategy.progressListener?.onProgressUpdate(progress) + } + + when(strategy) { + is CollapsingToolbarRoadData -> { + val collapsed = strategy.whenCollapsed + val expanded = strategy.whenExpanded + + val collapsedOffset = collapsed.align( + size = IntSize(placeable.width, placeable.height), + space = IntSize(maxWidth, height), + layoutDirection = layoutDirection + ) + + val expandedOffset = expanded.align( + size = IntSize(placeable.width, placeable.height), + space = IntSize(maxWidth, height), + layoutDirection = layoutDirection + ) + + val offset = collapsedOffset + (expandedOffset - collapsedOffset) * progress + + placeable.place(offset.x, offset.y) + } + is CollapsingToolbarParallaxData -> + placeable.placeRelative( + x = 0, + y = -((maxHeight - minHeight) * (1 - progress) * strategy.ratio).roundToInt() + ) + else -> placeable.placeRelative(0, 0) + } + } + } + } +} + +interface CollapsingToolbarScope { + fun Modifier.progress(listener: ProgressListener): Modifier + + fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier + + fun Modifier.parallax(ratio: Float = 0.2f): Modifier + + fun Modifier.pin(): Modifier +} + +internal object CollapsingToolbarScopeInstance: CollapsingToolbarScope { + override fun Modifier.progress(listener: ProgressListener): Modifier { + return this.then(ProgressUpdateListenerModifier(listener)) + } + + override fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier { + return this.then(RoadModifier(whenCollapsed, whenExpanded)) + } + + override fun Modifier.parallax(ratio: Float): Modifier { + return this.then(ParallaxModifier(ratio)) + } + + override fun Modifier.pin(): Modifier { + return this.then(PinModifier()) + } +} + +internal class RoadModifier( + private val whenCollapsed: Alignment, + private val whenExpanded: Alignment +): ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return CollapsingToolbarRoadData( + this@RoadModifier.whenCollapsed, this@RoadModifier.whenExpanded, + (parentData as? CollapsingToolbarData)?.progressListener + ) + } +} + +internal class ParallaxModifier( + private val ratio: Float +): ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return CollapsingToolbarParallaxData(ratio, (parentData as? CollapsingToolbarData)?.progressListener) + } +} + +internal class PinModifier: ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return CollapsingToolbarPinData((parentData as? CollapsingToolbarData)?.progressListener) + } +} + +internal class ProgressUpdateListenerModifier( + private val listener: ProgressListener +): ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return CollapsingToolbarProgressData(listener) + } +} + +fun interface ProgressListener { + fun onProgressUpdate(value: Float) +} + +internal sealed class CollapsingToolbarData( + var progressListener: ProgressListener? +) + +internal class CollapsingToolbarProgressData( + progressListener: ProgressListener? +): CollapsingToolbarData(progressListener) + +internal class CollapsingToolbarRoadData( + var whenCollapsed: Alignment, + var whenExpanded: Alignment, + progressListener: ProgressListener? = null +): CollapsingToolbarData(progressListener) + +internal class CollapsingToolbarPinData( + progressListener: ProgressListener? = null +): CollapsingToolbarData(progressListener) + +internal class CollapsingToolbarParallaxData( + var ratio: Float, + progressListener: ProgressListener? = null +): CollapsingToolbarData(progressListener) diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/CollapsingToolbarScaffold.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/CollapsingToolbarScaffold.kt new file mode 100644 index 0000000..e59ba3d --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/CollapsingToolbarScaffold.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.aiosman.ravenow.ui.composables.toolbar + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import kotlin.math.max + +@Stable +class CollapsingToolbarScaffoldState( + val toolbarState: CollapsingToolbarState, + initialOffsetY: Int = 0 +) { + val offsetY: Int + get() = offsetYState.value + + internal val offsetYState = mutableStateOf(initialOffsetY) +} + +private class CollapsingToolbarScaffoldStateSaver: Saver> { + override fun restore(value: List): CollapsingToolbarScaffoldState = + CollapsingToolbarScaffoldState( + CollapsingToolbarState(value[0] as Int), + value[1] as Int + ) + + override fun SaverScope.save(value: CollapsingToolbarScaffoldState): List = + listOf( + value.toolbarState.height, + value.offsetY + ) +} + +@Composable +fun rememberCollapsingToolbarScaffoldState( + toolbarState: CollapsingToolbarState = rememberCollapsingToolbarState() +): CollapsingToolbarScaffoldState { + return rememberSaveable(toolbarState, saver = CollapsingToolbarScaffoldStateSaver()) { + CollapsingToolbarScaffoldState(toolbarState) + } +} + +interface CollapsingToolbarScaffoldScope { + @ExperimentalToolbarApi + fun Modifier.align(alignment: Alignment): Modifier +} + +@Composable +fun CollapsingToolbarScaffold( + modifier: Modifier, + state: CollapsingToolbarScaffoldState, + scrollStrategy: ScrollStrategy, + enabled: Boolean = true, + toolbarModifier: Modifier = Modifier, + toolbarClipToBounds: Boolean = true, + toolbarScrollable: Boolean = false, + toolbar: @Composable CollapsingToolbarScope.(ScrollState) -> Unit, + body: @Composable CollapsingToolbarScaffoldScope.() -> Unit +) { + val flingBehavior = ScrollableDefaults.flingBehavior() + val layoutDirection = LocalLayoutDirection.current + + val nestedScrollConnection = remember(scrollStrategy, state) { + scrollStrategy.create(state.offsetYState, state.toolbarState, flingBehavior) + } + + val toolbarState = state.toolbarState + val toolbarScrollState = rememberScrollState() + + Layout( + content = { + CollapsingToolbar( + modifier = toolbarModifier, + clipToBounds = toolbarClipToBounds, + collapsingToolbarState = toolbarState, + ) { + ToolbarScrollableBox( + enabled, + toolbarScrollable, + toolbarState, + toolbarScrollState + ) + toolbar(toolbarScrollState) + } + + CollapsingToolbarScaffoldScopeInstance.body() + }, + modifier = modifier + .then( + if (enabled) { + Modifier.nestedScroll(nestedScrollConnection) + } else { + Modifier + } + ) + ) { measurables, constraints -> + check(measurables.size >= 2) { + "the number of children should be at least 2: toolbar, (at least one) body" + } + + val toolbarConstraints = constraints.copy( + minWidth = 0, + minHeight = 0 + ) + val bodyConstraints = constraints.copy( + minWidth = 0, + minHeight = 0, + maxHeight = when (scrollStrategy) { + ScrollStrategy.ExitUntilCollapsed -> + (constraints.maxHeight - toolbarState.minHeight).coerceAtLeast(0) + + ScrollStrategy.EnterAlways, ScrollStrategy.EnterAlwaysCollapsed -> + constraints.maxHeight + } + ) + + val toolbarPlaceable = measurables[0].measure(toolbarConstraints) + + val bodyMeasurables = measurables.subList(1, measurables.size) + val childrenAlignments = bodyMeasurables.map { + (it.parentData as? ScaffoldParentData)?.alignment + } + val bodyPlaceables = bodyMeasurables.map { + it.measure(bodyConstraints) + } + + val toolbarHeight = toolbarPlaceable.height + + val width = max( + toolbarPlaceable.width, + bodyPlaceables.maxOfOrNull { it.width } ?: 0 + ).coerceIn(constraints.minWidth, constraints.maxWidth) + val height = max( + toolbarHeight, + bodyPlaceables.maxOfOrNull { it.height } ?: 0 + ).coerceIn(constraints.minHeight, constraints.maxHeight) + + layout(width, height) { + bodyPlaceables.forEachIndexed { index, placeable -> + val alignment = childrenAlignments[index] + + if (alignment == null) { + placeable.placeRelative(0, toolbarHeight + state.offsetY) + } else { + val offset = alignment.align( + size = IntSize(placeable.width, placeable.height), + space = IntSize(width, height), + layoutDirection = layoutDirection + ) + placeable.place(offset) + } + } + toolbarPlaceable.placeRelative(0, state.offsetY) + } + } +} + +@Composable +private fun ToolbarScrollableBox( + enabled: Boolean, + toolbarScrollable: Boolean, + toolbarState: CollapsingToolbarState, + toolbarScrollState: ScrollState +) { + val toolbarScrollableEnabled = enabled && toolbarScrollable + if (toolbarScrollableEnabled && toolbarState.height != Constraints.Infinity) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(LocalDensity.current) { toolbarState.height.toDp() }) + .verticalScroll(state = toolbarScrollState) + ) + } +} + +internal object CollapsingToolbarScaffoldScopeInstance: CollapsingToolbarScaffoldScope { + @ExperimentalToolbarApi + override fun Modifier.align(alignment: Alignment): Modifier = + this.then(ScaffoldChildAlignmentModifier(alignment)) +} + +private class ScaffoldChildAlignmentModifier( + private val alignment: Alignment +) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return (parentData as? ScaffoldParentData) ?: ScaffoldParentData(alignment) + } +} + +private data class ScaffoldParentData( + var alignment: Alignment? = null +) diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/FabPlacement.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/FabPlacement.kt new file mode 100644 index 0000000..f7c250f --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/FabPlacement.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.aiosman.ravenow.ui.composables.toolbar + +import androidx.compose.runtime.Immutable + +@Immutable +class FabPlacement( + val left: Int, + val width: Int, + val height: Int +) \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/FabPosition.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/FabPosition.kt new file mode 100644 index 0000000..d35145a --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/FabPosition.kt @@ -0,0 +1,6 @@ +package com.aiosman.ravenow.ui.composables.toolbar + +enum class FabPosition { + Center, + End +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/ScrollStrategy.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/ScrollStrategy.kt new file mode 100644 index 0000000..557e970 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/ScrollStrategy.kt @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.aiosman.ravenow.ui.composables.toolbar + +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.runtime.MutableState +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.unit.Velocity + +enum class ScrollStrategy { + EnterAlways { + override fun create( + offsetY: MutableState, + toolbarState: CollapsingToolbarState, + flingBehavior: FlingBehavior + ): NestedScrollConnection = + EnterAlwaysNestedScrollConnection(offsetY, toolbarState, flingBehavior) + }, + EnterAlwaysCollapsed { + override fun create( + offsetY: MutableState, + toolbarState: CollapsingToolbarState, + flingBehavior: FlingBehavior + ): NestedScrollConnection = + EnterAlwaysCollapsedNestedScrollConnection(offsetY, toolbarState, flingBehavior) + }, + ExitUntilCollapsed { + override fun create( + offsetY: MutableState, + toolbarState: CollapsingToolbarState, + flingBehavior: FlingBehavior + ): NestedScrollConnection = + ExitUntilCollapsedNestedScrollConnection(toolbarState, flingBehavior) + }; + + internal abstract fun create( + offsetY: MutableState, + toolbarState: CollapsingToolbarState, + flingBehavior: FlingBehavior + ): NestedScrollConnection +} + +private class ScrollDelegate( + private val offsetY: MutableState +) { + private var scrollToBeConsumed: Float = 0f + + fun doScroll(delta: Float) { + val scroll = scrollToBeConsumed + delta + val scrollInt = scroll.toInt() + + scrollToBeConsumed = scroll - scrollInt + + offsetY.value += scrollInt + } +} + +internal class EnterAlwaysNestedScrollConnection( + private val offsetY: MutableState, + private val toolbarState: CollapsingToolbarState, + private val flingBehavior: FlingBehavior +): NestedScrollConnection { + private val scrollDelegate = ScrollDelegate(offsetY) + //private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl()) + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val dy = available.y + + val toolbar = toolbarState.height.toFloat() + val offset = offsetY.value.toFloat() + + // -toolbarHeight <= offsetY + dy <= 0 + val consume = if(dy < 0) { + val toolbarConsumption = toolbarState.dispatchRawDelta(dy) + val remaining = dy - toolbarConsumption + val offsetConsumption = remaining.coerceAtLeast(-toolbar - offset) + scrollDelegate.doScroll(offsetConsumption) + + toolbarConsumption + offsetConsumption + }else{ + val offsetConsumption = dy.coerceAtMost(-offset) + scrollDelegate.doScroll(offsetConsumption) + + val toolbarConsumption = toolbarState.dispatchRawDelta(dy - offsetConsumption) + + offsetConsumption + toolbarConsumption + } + + return Offset(0f, consume) + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val left = if(available.y > 0) { + toolbarState.fling(flingBehavior, available.y) + }else{ + // If velocity < 0, the main content should have a remaining scroll space + // so the scroll resumes to the onPreScroll(..., Fling) phase. Hence we do + // not need to process it at onPostFling() manually. + available.y + } + + return Velocity(x = 0f, y = available.y - left) + } +} + +internal class EnterAlwaysCollapsedNestedScrollConnection( + private val offsetY: MutableState, + private val toolbarState: CollapsingToolbarState, + private val flingBehavior: FlingBehavior +): NestedScrollConnection { + private val scrollDelegate = ScrollDelegate(offsetY) + //private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl()) + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val dy = available.y + + val consumed = if(dy > 0) { // expanding: offset -> body -> toolbar + val offsetConsumption = dy.coerceAtMost(-offsetY.value.toFloat()) + scrollDelegate.doScroll(offsetConsumption) + + offsetConsumption + }else{ // collapsing: toolbar -> offset -> body + val toolbarConsumption = toolbarState.dispatchRawDelta(dy) + val offsetConsumption = (dy - toolbarConsumption).coerceAtLeast(-toolbarState.height.toFloat() - offsetY.value) + + scrollDelegate.doScroll(offsetConsumption) + + toolbarConsumption + offsetConsumption + } + + return Offset(0f, consumed) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + val dy = available.y + + return if(dy > 0) { + Offset(0f, toolbarState.dispatchRawDelta(dy)) + }else{ + Offset(0f, 0f) + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val dy = available.y + + val left = if(dy > 0) { + // onPostFling() has positive available scroll value only called if the main scroll + // has leftover scroll, i.e. the scroll of the main content has done. So we just process + // fling if the available value is positive. + toolbarState.fling(flingBehavior, dy) + }else{ + dy + } + + return Velocity(x = 0f, y = available.y - left) + } +} + +internal class ExitUntilCollapsedNestedScrollConnection( + private val toolbarState: CollapsingToolbarState, + private val flingBehavior: FlingBehavior +): NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val dy = available.y + + val consume = if(dy < 0) { // collapsing: toolbar -> body + toolbarState.dispatchRawDelta(dy) + }else{ + 0f + } + + return Offset(0f, consume) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + val dy = available.y + + val consume = if(dy > 0) { // expanding: body -> toolbar + toolbarState.dispatchRawDelta(dy) + }else{ + 0f + } + + return Offset(0f, consume) + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val left = if(available.y < 0) { + toolbarState.fling(flingBehavior, available.y) + }else{ + available.y + } + + return Velocity(x = 0f, y = available.y - left) + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val velocity = available.y + + val left = if(velocity > 0) { + toolbarState.fling(flingBehavior, velocity) + }else{ + velocity + } + + return Velocity(x = 0f, y = available.y - left) + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/ToolbarWithFabScaffold.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/ToolbarWithFabScaffold.kt new file mode 100644 index 0000000..5b1f42b --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/toolbar/ToolbarWithFabScaffold.kt @@ -0,0 +1,107 @@ +package com.aiosman.ravenow.ui.composables.toolbar + +import androidx.compose.foundation.ScrollState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +@ExperimentalToolbarApi +@Composable +fun ToolbarWithFabScaffold( + modifier: Modifier, + state: CollapsingToolbarScaffoldState, + scrollStrategy: ScrollStrategy, + toolbarModifier: Modifier = Modifier, + toolbarClipToBounds: Boolean = true, + toolbar: @Composable CollapsingToolbarScope.(ScrollState) -> Unit, + toolbarScrollable: Boolean = false, + fab: @Composable () -> Unit, + fabPosition: FabPosition = FabPosition.End, + body: @Composable CollapsingToolbarScaffoldScope.() -> Unit +) { + SubcomposeLayout( + modifier = modifier + ) { constraints -> + + val toolbarScaffoldConstraints = constraints.copy( + minWidth = 0, + minHeight = 0, + maxHeight = constraints.maxHeight + ) + + val toolbarScaffoldPlaceables = subcompose(ToolbarWithFabScaffoldContent.ToolbarScaffold) { + CollapsingToolbarScaffold( + modifier = modifier, + state = state, + scrollStrategy = scrollStrategy, + toolbarModifier = toolbarModifier, + toolbarClipToBounds = toolbarClipToBounds, + toolbar = toolbar, + body = body, + toolbarScrollable = toolbarScrollable + ) + }.map { it.measure(toolbarScaffoldConstraints) } + + val fabConstraints = constraints.copy( + minWidth = 0, + minHeight = 0 + ) + + val fabPlaceables = subcompose( + ToolbarWithFabScaffoldContent.Fab, + fab + ).mapNotNull { measurable -> + measurable.measure(fabConstraints).takeIf { it.height != 0 && it.width != 0 } + } + + val fabPlacement = if (fabPlaceables.isNotEmpty()) { + val fabWidth = fabPlaceables.maxOfOrNull { it.width } ?: 0 + val fabHeight = fabPlaceables.maxOfOrNull { it.height } ?: 0 + // FAB distance from the left of the layout, taking into account LTR / RTL + val fabLeftOffset = if (fabPosition == FabPosition.End) { + if (layoutDirection == LayoutDirection.Ltr) { + constraints.maxWidth - 16.dp.roundToPx() - fabWidth + } else { + 16.dp.roundToPx() + } + } else { + (constraints.maxWidth - fabWidth) / 2 + } + + FabPlacement( + left = fabLeftOffset, + width = fabWidth, + height = fabHeight + ) + } else { + null + } + + val fabOffsetFromBottom = fabPlacement?.let { + it.height + 16.dp.roundToPx() + } + + val width = constraints.maxWidth + val height = constraints.maxHeight + + layout(width, height) { + toolbarScaffoldPlaceables.forEach { + it.place(0, 0) + } + + fabPlacement?.let { placement -> + fabPlaceables.forEach { + it.place(placement.left, height - fabOffsetFromBottom!!) + } + } + + } + + } +} + +private enum class ToolbarWithFabScaffoldContent { + ToolbarScaffold, Fab +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/crop/ImageCropScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/crop/ImageCropScreen.kt new file mode 100644 index 0000000..3677ad2 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/crop/ImageCropScreen.kt @@ -0,0 +1,169 @@ +package com.aiosman.ravenow.ui.crop + +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.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.onGloballyPositioned +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.lifecycle.viewModelScope +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.account.AccountEditViewModel +import com.aiosman.ravenow.ui.agent.AddAgentViewModel +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 +import com.aiosman.ravenow.AppState +@Composable +fun ImageCropScreen() { + var imageCrop by remember { mutableStateOf(null) } + val context = LocalContext.current + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp + var imageWidthInDp by remember { mutableStateOf(0) } + var imageHeightInDp by remember { mutableStateOf(0) } + var density = LocalDensity.current + var navController = LocalNavController.current + + var 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) { + 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 { + navController.popBackStack() + }, + colorFilter = ColorFilter.tint(Color.White) + ) + Spacer( + modifier = Modifier.weight(1f) + ) + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.clickable { + imageCrop?.let { + val bitmap = it.onCrop() + + // 专门处理个人资料头像 + AccountEditViewModel.croppedBitmap = bitmap + AccountEditViewModel.viewModelScope.launch { + AccountEditViewModel.updateUserProfile(context) + navController.popBackStack() + } + } + } + ) + } + Box( + modifier = Modifier.fillMaxWidth().padding(24.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(imageHeightInDp.dp) + .onGloballyPositioned { + with(density) { + imageWidthInDp = it.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 + ) + } + } + } +} + + +// Configure ImageCropView. + + +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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/dialogs/CheckUpdateDialog.kt b/app/src/main/java/com/aiosman/ravenow/ui/dialogs/CheckUpdateDialog.kt new file mode 100644 index 0000000..d3aa1d7 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/dialogs/CheckUpdateDialog.kt @@ -0,0 +1,237 @@ +package com.aiosman.ravenow.ui.dialogs + +import android.content.Intent +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import com.aiosman.ravenow.ConstVars +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.model.UpdateInfo +import com.aiosman.ravenow.ui.composables.ActionButton +import com.google.firebase.perf.config.RemoteConfigManager.getVersionCode +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CheckUpdateDialog() { + val AppColors = LocalAppTheme.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + var showDialog by remember { mutableStateOf(false) } + var newApkUrl by remember { mutableStateOf("") } + var progress by remember { mutableStateOf(0f) } + var isDownloading by remember { mutableStateOf(false) } // Add downloading state + var message by remember { mutableStateOf("") } + var versionName by remember { mutableStateOf("") } + fun checkUpdate() { + scope.launch(Dispatchers.IO) { + try { + val client = OkHttpClient() + val request = Request.Builder() + .url("${ConstVars.BASE_SERVER}/static/update/beta/version.json") + .build() + val response = client.newCall(request).execute() + + if (response.isSuccessful) { + val responseBody = response.body?.string() + val updateInfo = Gson().fromJson(responseBody, UpdateInfo::class.java) + + val versionCode = getVersionCode(context) + if (updateInfo.versionCode > versionCode) { + withContext(Dispatchers.Main) { + message = updateInfo.updateContent + versionName = updateInfo.versionName + showDialog = true + newApkUrl = updateInfo.downloadUrl + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun downloadApk() { + isDownloading = true + scope.launch(Dispatchers.IO) { + val request = Request.Builder() + .url(newApkUrl) + .build() + val client = OkHttpClient() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw Exception("Unexpected code $response") + val body = response.body + if (body != null) { + val apkFile = File(context.cacheDir, "rider_pro.apk") + val totalBytes = body.contentLength() + var downloadedBytes = 0L + + apkFile.outputStream().use { output -> + body.byteStream().use { input -> + val buffer = ByteArray(8 * 1024) + var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + downloadedBytes += bytesRead + progress = downloadedBytes / totalBytes.toFloat() + } + } + } + + val apkUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile) + val intent = Intent(Intent.ACTION_VIEW) + intent.setDataAndType(apkUri, "application/vnd.android.package-archive") + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + context.startActivity(intent) + } + } + isDownloading = false + } + } + + LaunchedEffect(Unit) { + checkUpdate() + } + + if (showDialog) { + BasicAlertDialog( + onDismissRequest = { + if (!isDownloading) { + showDialog = false + } + }, + modifier = Modifier, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, bottom = 120.dp), + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Spacer(modifier = Modifier.height(96.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(32.dp)) + .background(AppColors.background) + .padding(vertical = 32.dp, horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(48.dp)) + Text( + stringResource(id = R.string.update_find_new_version), + fontWeight = FontWeight.W900, + fontSize = 22.sp, + color = AppColors.text + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + versionName, + modifier = Modifier.fillMaxWidth(), + color = AppColors.text + ) + Text( + message, + modifier = Modifier.fillMaxWidth(), + color = AppColors.text + ) + if (progress > 0) { + Spacer(modifier = Modifier.height(16.dp)) + LinearProgressIndicator( + progress = { progress }, + color = AppColors.main, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)), + trackColor = AppColors.basicMain + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + Row( + modifier = Modifier.fillMaxWidth() + ) { + ActionButton( + text = stringResource(id = R.string.update_later), + color = AppColors.text, + backgroundColor = AppColors.basicMain, + modifier = Modifier.weight(1f), + fullWidth = false, + roundCorner = 16f, + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + ) { + showDialog = false + } + Spacer(modifier = Modifier.width(16.dp)) + ActionButton( + text = stringResource(id = R.string.update_update_now), + backgroundColor = AppColors.main, + color = AppColors.mainText, + modifier = Modifier.weight(1f), + fullWidth = false, + roundCorner = 16f, + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + ) { + downloadApk() + } + } + Spacer(modifier = Modifier.height(16.dp)) + + } + } + Image( + painter = painterResource(id = R.mipmap.rider_pro_update_header), + contentDescription = null, + modifier = Modifier.padding(16.dp) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/favourite/FavouriteListPage.kt b/app/src/main/java/com/aiosman/ravenow/ui/favourite/FavouriteListPage.kt new file mode 100644 index 0000000..cc24fd1 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/favourite/FavouriteListPage.kt @@ -0,0 +1,206 @@ +package com.aiosman.ravenow.ui.favourite + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.comment.NoticeScreenHeader +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.StatusBarSpacer +import com.aiosman.ravenow.ui.favourite.FavouriteListViewModel.refreshPager +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.navigateToPost +import com.aiosman.ravenow.ui.network.ReloadButton +import com.aiosman.ravenow.utils.NetworkUtils + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun FavouriteListPage() { + val AppColors = LocalAppTheme.current + val model = FavouriteListViewModel + var dataFlow = model.favouriteMomentsFlow + var moments = dataFlow.collectAsLazyPagingItems() + val context = LocalContext.current + val navController = LocalNavController.current + val state = rememberPullRefreshState(FavouriteListViewModel.isLoading, onRefresh = { + model.refreshPager(force = true) + }) + LaunchedEffect(Unit) { + refreshPager() + } + Column( + modifier = Modifier.fillMaxSize().background(AppColors.background) + ) { + StatusBarSpacer() + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .pullRefresh(state) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + ) { + NoticeScreenHeader(stringResource(R.string.favourites_upper), moreIcon = false) + } + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current) + var moments = dataFlow.collectAsLazyPagingItems() + + if (!isNetworkAvailable) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top=149.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.mipmap.invalid_name_10), + contentDescription = "network error", + modifier = Modifier.size(181.dp) + ) + Spacer(modifier = Modifier.size(24.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_title), + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_subtitle), + color = AppColors.secondaryText, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + Spacer(modifier = Modifier.height(16.dp)) + ReloadButton( + onClick = { + model.refreshPager(force = true) + } + ) + } + } + } else if(moments.itemCount == 0) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top=189.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource( + id = if (com.aiosman.ravenow.AppState.darkMode) R.mipmap.syss_yh_qs_as_img + else R.mipmap.invalid_name_1), + contentDescription = "No favourites", + modifier = Modifier.size(110.dp) + ) + Spacer(modifier = Modifier.size(24.dp)) + Text( + text = stringResource(R.string.favourites_null), + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + } + } + }else{ + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp) + ) { + items(moments.itemCount) { idx -> + val momentItem = moments[idx] ?: return@items + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .padding(2.dp) + .noRippleClickable { + navController.navigateToPost( + id = momentItem.id, + highlightCommentId = 0, + initImagePagerIndex = 0 + ) + } + ) { + CustomAsyncImage( + imageUrl = momentItem.images[0].thumbnail, + contentDescription = "", + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)), + context = context + ) + if (momentItem.images.size > 1) { + Box( + modifier = Modifier + .padding(top = 8.dp, end = 8.dp) + .align(Alignment.TopEnd) + ) { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.rider_pro_picture_more), + contentDescription = "", + ) + } + } + } + } + } + } + } + PullRefreshIndicator( + FavouriteListViewModel.isLoading, + state, + Modifier.align(Alignment.TopCenter) + ) + + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/favourite/FavouriteListViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/favourite/FavouriteListViewModel.kt new file mode 100644 index 0000000..1159488 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/favourite/FavouriteListViewModel.kt @@ -0,0 +1,70 @@ +package com.aiosman.ravenow.ui.favourite + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.data.MomentService +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.entity.MomentPagingSource +import com.aiosman.ravenow.entity.MomentRemoteDataSource +import com.aiosman.ravenow.entity.MomentServiceImpl +import com.aiosman.ravenow.event.MomentFavouriteChangeEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode + +object FavouriteListViewModel:ViewModel() { + private val momentService: MomentService = MomentServiceImpl() + private val _favouriteMomentsFlow = + MutableStateFlow>(PagingData.empty()) + val favouriteMomentsFlow = _favouriteMomentsFlow.asStateFlow() + var isLoading by mutableStateOf(false) + + init { + EventBus.getDefault().register(this) + } + fun refreshPager(force:Boolean = false) { + viewModelScope.launch { + if (force) { + isLoading = true + } + Pager( + config = PagingConfig(pageSize = 20, enablePlaceholders = false), + pagingSourceFactory = { + MomentPagingSource( + MomentRemoteDataSource(momentService), + favoriteUserId = AppState.UserId + ) + } + ).flow.cachedIn(viewModelScope).collectLatest { + _favouriteMomentsFlow.value = it + isLoading = false + } + + } + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onMomentFavouriteChangeEvent(event: MomentFavouriteChangeEvent) { + if (!event.isFavourite) { + // 当取消收藏时,刷新列表以移除该项目 + refreshPager(force = true) + } + } + + fun ResetModel() { + isLoading = false + EventBus.getDefault().unregister(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/favourite/FavouriteNoticeScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/favourite/FavouriteNoticeScreen.kt new file mode 100644 index 0000000..0af2c97 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/favourite/FavouriteNoticeScreen.kt @@ -0,0 +1,85 @@ +package com.aiosman.ravenow.ui.favourite + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.comment.NoticeScreenHeader +import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout +import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder +import com.aiosman.ravenow.ui.like.ActionPostNoticeItem + +/** + * 收藏消息界面 + */ +@Composable +fun FavouriteNoticeScreen() { + + val model = FavouriteNoticeViewModel + val listState = rememberLazyListState() + var dataFlow = model.favouriteItemsFlow + var favourites = dataFlow.collectAsLazyPagingItems() + LaunchedEffect(Unit) { + model.reload() + model.updateNotice() + } + StatusBarMaskLayout( + darkIcons = !AppState.darkMode, + maskBoxBackgroundColor = Color(0xFFFFFFFF) + ) { + Column( + modifier = Modifier + .weight(1f) + .background(color = Color(0xFFFFFFFF)) + .padding(horizontal = 16.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + NoticeScreenHeader( + stringResource(R.string.favourites_upper), + moreIcon = false + ) + } + LazyColumn( + modifier = Modifier.weight(1f), + state = listState, + ) { + items(favourites.itemCount) { + val favouriteItem = favourites[it] + if (favouriteItem != null) { + ActionPostNoticeItem( + avatar = favouriteItem.user.avatar, + nickName = favouriteItem.user.nickName, + likeTime = favouriteItem.favoriteTime, + thumbnail = favouriteItem.post.images[0].thumbnail, + action = "favourite", + userId = favouriteItem.user.id, + postId = favouriteItem.post.id, + ) + } + } + item { + BottomNavigationPlaceholder() + } + } + + + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/favourite/FavouriteNoticeViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/favourite/FavouriteNoticeViewModel.kt new file mode 100644 index 0000000..d4dea07 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/favourite/FavouriteNoticeViewModel.kt @@ -0,0 +1,62 @@ +package com.aiosman.ravenow.ui.favourite + +import android.icu.util.Calendar +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.aiosman.ravenow.entity.AccountFavouriteEntity +import com.aiosman.ravenow.data.AccountService +import com.aiosman.ravenow.entity.FavoriteItemPagingSource +import com.aiosman.ravenow.data.AccountServiceImpl +import com.aiosman.ravenow.data.api.ApiClient +import com.aiosman.ravenow.data.api.UpdateNoticeRequestBody +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * 收藏消息列表的 ViewModel + */ +object FavouriteNoticeViewModel : ViewModel() { + private val accountService: AccountService = AccountServiceImpl() + private val _favouriteItemsFlow = + MutableStateFlow>(PagingData.empty()) + val favouriteItemsFlow = _favouriteItemsFlow.asStateFlow() + var isFirstLoad = true + + fun reload(force: Boolean = false) { + if (!isFirstLoad && !force) { + return + } + isFirstLoad = false + viewModelScope.launch { + Pager( + config = PagingConfig(pageSize = 5, enablePlaceholders = false), + pagingSourceFactory = { + FavoriteItemPagingSource( + accountService + ) + } + ).flow.cachedIn(viewModelScope).collectLatest { + _favouriteItemsFlow.value = it + } + } + } + // 更新收藏消息的查看时间 + suspend fun updateNotice() { + var now = Calendar.getInstance().time + accountService.updateNotice( + UpdateNoticeRequestBody( + lastLookFavouriteTime = ApiClient.formatTime(now) + ) + ) + } + + fun ResetModel() { + isFirstLoad = true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/follower/BaseFollowModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/follower/BaseFollowModel.kt new file mode 100644 index 0000000..64c76db --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/follower/BaseFollowModel.kt @@ -0,0 +1,85 @@ +package com.aiosman.ravenow.ui.follower + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.aiosman.ravenow.data.UserServiceImpl +import com.aiosman.ravenow.entity.AccountPagingSource +import com.aiosman.ravenow.entity.AccountProfileEntity +import com.aiosman.ravenow.event.FollowChangeEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe + +open class BaseFollowModel:ViewModel() { + private val userService = UserServiceImpl() + private val _usersFlow = MutableStateFlow>(PagingData.empty()) + val usersFlow = _usersFlow.asStateFlow() + var isLoading by mutableStateOf(false) + open var followerId: Int? = null + open var followingId: Int? = null + init { + EventBus.getDefault().register(this) + } + fun loadData(id: Int,force : Boolean = false) { + if (isLoading) return + isLoading = true + viewModelScope.launch { + Pager( + config = PagingConfig(pageSize = 20, enablePlaceholders = false), + pagingSourceFactory = { + AccountPagingSource( + userService, + followerId = followerId, + followingId = followingId + ) + } + ).flow.cachedIn(viewModelScope).collectLatest { + _usersFlow.value = it + } + } + isLoading = false + } + + @Subscribe + fun onFollowChangeEvent(event: FollowChangeEvent) { + updateIsFollow(event.userId, event.isFollow) + } + + private fun updateIsFollow(id: Int, isFollow: Boolean = true) { + val currentPagingData = usersFlow.value + val updatedPagingData = currentPagingData.map { user -> + if (user.id == id) { + user.copy(isFollowing = isFollow) + } else { + user + } + } + _usersFlow.value = updatedPagingData + } + + suspend fun followUser(userId: Int) { + userService.followUser(userId.toString()) + EventBus.getDefault().post(FollowChangeEvent(userId, true)) + } + + suspend fun unFollowUser(userId: Int) { + userService.unFollowUser(userId.toString()) + EventBus.getDefault().post(FollowChangeEvent(userId, false)) + } + + override fun onCleared() { + super.onCleared() + EventBus.getDefault().unregister(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowerList.kt b/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowerList.kt new file mode 100644 index 0000000..5826d60 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowerList.kt @@ -0,0 +1,190 @@ +package com.aiosman.ravenow.ui.follower + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.GuestLoginCheckOut +import com.aiosman.ravenow.GuestLoginCheckOutScene +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.exp.viewModelFactory +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.comment.NoticeScreenHeader +import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout +import kotlinx.coroutines.launch +import com.aiosman.ravenow.utils.NetworkUtils +import com.aiosman.ravenow.ui.network.ReloadButton + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun FollowerListScreen(userId: Int) { + val appColors = LocalAppTheme.current + val navController = LocalNavController.current + val model: FollowerListViewModel = viewModel(factory = viewModelFactory { + FollowerListViewModel(userId) + }, key = "viewModel_${userId}") + val scope = rememberCoroutineScope() + val refreshState = rememberPullRefreshState(model.isLoading, onRefresh = { + model.loadData(userId, true) + }) + LaunchedEffect(Unit) { + model.loadData(userId) + } + StatusBarMaskLayout( + modifier = Modifier.background(color = appColors.background).padding(horizontal = 16.dp), + darkIcons = !AppState.darkMode, + maskBoxBackgroundColor = appColors.background + ) { + var dataFlow = model.usersFlow + var users = dataFlow.collectAsLazyPagingItems() + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + NoticeScreenHeader(stringResource(R.string.followers_upper), moreIcon = false) + } + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current) + + if (!isNetworkAvailable) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 149.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.mipmap.invalid_name_10), + contentDescription = "network error", + modifier = Modifier.size(181.dp) + ) + Spacer(modifier = Modifier.size(24.dp)) + androidx.compose.material.Text( + text = stringResource(R.string.friend_chat_no_network_title), + color = appColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.size(8.dp)) + androidx.compose.material.Text( + text = stringResource(R.string.friend_chat_no_network_subtitle), + color = appColors.text, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + Spacer(modifier = Modifier.height(16.dp)) + ReloadButton( + onClick = { + model.loadData(userId, true) + } + ) + } + } + } else if (users.itemCount == 0) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 149.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource( + id =if(AppState.darkMode) R.mipmap.qst_fs_qs_as_img + else R.mipmap.invalid_name_8), + contentDescription = null, + modifier = Modifier.size(181.dp) + ) + Spacer(modifier = Modifier.size(24.dp)) + androidx.compose.material.Text( + text = "还没有人关注哦", + color = appColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.size(8.dp)) + androidx.compose.material.Text( + text = "去发布动态,吸引更多粉丝~", + color = appColors.text, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + } + } + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .pullRefresh(refreshState) + ) { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items(users.itemCount) { index -> + users[index]?.let { user -> + FollowItem( + avatar = user.avatar, + nickname = user.nickName, + userId = user.id, + isFollowing = user.isFollowing + ) { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.FOLLOW_USER)) { + navController.navigate(NavigationRoute.Login.route) + } else { + scope.launch { + if (user.isFollowing) { + model.unFollowUser(user.id) + } else { + model.followUser(user.id) + } + } + } + } + } + } + } + PullRefreshIndicator( + refreshing = model.isLoading, + state = refreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowerListViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowerListViewModel.kt new file mode 100644 index 0000000..01580eb --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowerListViewModel.kt @@ -0,0 +1,7 @@ +package com.aiosman.ravenow.ui.follower + +class FollowerListViewModel( + val userId: Int +) : BaseFollowModel() { + override var followerId: Int? = userId +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowerNotice.kt b/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowerNotice.kt new file mode 100644 index 0000000..6fde198 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowerNotice.kt @@ -0,0 +1,215 @@ +package com.aiosman.ravenow.ui.follower + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.comment.NoticeScreenHeader +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.FollowButton +import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.network.ReloadButton +import kotlinx.coroutines.launch +import com.aiosman.ravenow.utils.NetworkUtils + +/** + * 关注消息列表 + */ +@Composable +fun FollowerNoticeScreen() { + val scope = rememberCoroutineScope() + val AppColors = LocalAppTheme.current + StatusBarMaskLayout( + modifier = Modifier.background(color = AppColors.background).padding(horizontal = 16.dp), + darkIcons = !AppState.darkMode, + maskBoxBackgroundColor = AppColors.background + ) { + val model = FollowerNoticeViewModel + var dataFlow = model.followerItemsFlow + var followers = dataFlow.collectAsLazyPagingItems() + LaunchedEffect(Unit) { + model.reload() + model.updateNotice() + } + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current) + + if (!isNetworkAvailable) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top=149.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.mipmap.invalid_name_10), + contentDescription = "network error", + modifier = Modifier.size(181.dp) + ) + Spacer(modifier = Modifier.size(24.dp)) + androidx.compose.material.Text( + text = stringResource(R.string.friend_chat_no_network_title), + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.size(8.dp)) + androidx.compose.material.Text( + text = stringResource(R.string.friend_chat_no_network_subtitle), + color = AppColors.text, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + Spacer(modifier = Modifier.height(16.dp)) + ReloadButton( + onClick = { + model.reload(force = true) + } + ) + } + } + } else if (followers.itemCount == 0) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top=149.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource( + id =if(AppState.darkMode) R.mipmap.qst_fs_qs_as_img + else R.mipmap.invalid_name_8), + contentDescription = "No Followers", + modifier = Modifier.size(181.dp) + ) + Spacer(modifier = Modifier.size(24.dp)) + androidx.compose.material.Text( + text = "还没有人关注哦", + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.size(8.dp)) + androidx.compose.material.Text( + text = "去发布动态,吸引更多粉丝~", + color = AppColors.text, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + } + } + }else{ + LazyColumn( + modifier = Modifier.weight(1f) + .background(color = AppColors.background) + ) { + items(followers.itemCount) { index -> + followers[index]?.let { follower -> + FollowItem( + avatar = follower.avatar, + nickname = follower.nickname, + userId = follower.userId, + isFollowing = follower.isFollowing + ) { + scope.launch { + model.followUser(follower.userId) + } + } + } + } + } + } + + + } +} + + +@Composable +fun FollowItem( + avatar: String, + nickname: String, + userId: Int, + isFollowing: Boolean, + onFollow: () -> Unit = {} +) { + val AppColors = LocalAppTheme.current + val context = LocalContext.current + val navController = LocalNavController.current + Box( + modifier = Modifier + .padding(vertical = 16.dp) + .noRippleClickable { + navController.navigate( + NavigationRoute.AccountProfile.route.replace( + "{id}", + userId.toString() + ) + ) + } + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + CustomAsyncImage( + context = context, + imageUrl = avatar, + contentDescription = nickname, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier.weight(1f) + ) { + Text(nickname, fontWeight = FontWeight.Bold, fontSize = 16.sp, color = AppColors.text) + } + if (!isFollowing && userId != AppState.UserId) { + FollowButton( + isFollowing = false, + ) { + onFollow() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowerNoticeViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowerNoticeViewModel.kt new file mode 100644 index 0000000..35bc7df --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowerNoticeViewModel.kt @@ -0,0 +1,96 @@ +package com.aiosman.ravenow.ui.follower + +import android.icu.util.Calendar +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.aiosman.ravenow.data.AccountFollow +import com.aiosman.ravenow.data.AccountService +import com.aiosman.ravenow.entity.FollowItemPagingSource +import com.aiosman.ravenow.data.AccountServiceImpl +import com.aiosman.ravenow.data.UserServiceImpl +import com.aiosman.ravenow.data.UserService +import com.aiosman.ravenow.data.api.ApiClient +import com.aiosman.ravenow.data.api.UpdateNoticeRequestBody +import com.aiosman.ravenow.event.FollowChangeEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe + +/** + * 关注消息列表的 ViewModel + */ +object FollowerNoticeViewModel : ViewModel() { + private val accountService: AccountService = AccountServiceImpl() + private val userService: UserService = UserServiceImpl() + private val _followerItemsFlow = + MutableStateFlow>(PagingData.empty()) + val followerItemsFlow = _followerItemsFlow.asStateFlow() + var isFirstLoad = true + + init { + EventBus.getDefault().register(this) + } + + fun reload(force: Boolean = false) { + if (!isFirstLoad && !force) { + return + } + isFirstLoad = false + viewModelScope.launch { + Pager( + config = PagingConfig(pageSize = 5, enablePlaceholders = false), + pagingSourceFactory = { + FollowItemPagingSource( + accountService + ) + } + ).flow.cachedIn(viewModelScope).collectLatest { + _followerItemsFlow.value = it + } + } + } + @Subscribe + fun onFollowChangeEvent(event: FollowChangeEvent) { + updateIsFollow(event.userId, event.isFollow) + } + private fun updateIsFollow(id: Int, isFollow: Boolean = true) { + val currentPagingData = _followerItemsFlow.value + val updatedPagingData = currentPagingData.map { follow -> + if (follow.userId == id) { + follow.copy(isFollowing = isFollow) + } else { + follow + } + } + _followerItemsFlow.value = updatedPagingData + } + suspend fun followUser(userId: Int) { + userService.followUser(userId.toString()) + EventBus.getDefault().post(FollowChangeEvent(userId, true)) + } + + suspend fun updateNotice() { + var now = Calendar.getInstance().time + accountService.updateNotice( + UpdateNoticeRequestBody( + lastLookFollowTime = ApiClient.formatTime(now) + ) + ) + } + fun ResetModel() { + isFirstLoad = true + } + + override fun onCleared() { + super.onCleared() + EventBus.getDefault().unregister(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowingList.kt b/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowingList.kt new file mode 100644 index 0000000..1575fb4 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowingList.kt @@ -0,0 +1,192 @@ +package com.aiosman.ravenow.ui.follower + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.GuestLoginCheckOut +import com.aiosman.ravenow.GuestLoginCheckOutScene +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.exp.viewModelFactory +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.comment.NoticeScreenHeader +import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout +import com.aiosman.ravenow.ui.network.ReloadButton +import kotlinx.coroutines.launch +import com.aiosman.ravenow.utils.NetworkUtils + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun FollowingListScreen(userId: Int) { + val appColors = LocalAppTheme.current + val navController = LocalNavController.current + val model : FollowingListViewModel = viewModel(factory = viewModelFactory { + FollowingListViewModel(userId) + }, key = "viewModel_${userId}") + val scope = rememberCoroutineScope() + val refreshState = rememberPullRefreshState(model.isLoading, onRefresh = { + model.loadData(userId, true) + }) + LaunchedEffect(Unit) { + model.loadData(userId) + } + StatusBarMaskLayout( + modifier = Modifier.background(color = appColors.background).padding(horizontal = 16.dp), + darkIcons = !AppState.darkMode, + maskBoxBackgroundColor = appColors.background + ) { + var dataFlow = model.usersFlow + var users = dataFlow.collectAsLazyPagingItems() + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + NoticeScreenHeader(stringResource(R.string.following_upper), moreIcon = false) + + } + + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current) + + if (!isNetworkAvailable) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top=149.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.mipmap.invalid_name_10), + contentDescription = "network error", + modifier = Modifier.size(181.dp) + ) + Spacer(modifier = Modifier.size(24.dp)) + androidx.compose.material.Text( + text = stringResource(R.string.friend_chat_no_network_title), + color = appColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.size(8.dp)) + androidx.compose.material.Text( + text = stringResource(R.string.friend_chat_no_network_subtitle), + color = appColors.secondaryText, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + Spacer(modifier = Modifier.height(16.dp)) + ReloadButton( + onClick = { + model.loadData(userId, true) + } + ) + } + } + } else if(users.itemCount == 0) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top=149.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource( + id =if(AppState.darkMode) R.mipmap.qst_gz_qs_as_img_my + else R.mipmap.invalid_name_9), + contentDescription = null, + modifier = Modifier.size(181.dp) + ) + Spacer(modifier = Modifier.size(24.dp)) + androidx.compose.material.Text( + text = "没有关注任何灵魂", + color = appColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.size(8.dp)) + androidx.compose.material.Text( + text = "探索一下,总有一个你想靠近的光点 ✨", + color = appColors.secondaryText, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + } + } + }else{ + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .pullRefresh(refreshState) + ) { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items(users.itemCount) { index -> + users[index]?.let { user -> + FollowItem( + avatar = user.avatar, + nickname = user.nickName, + userId = user.id, + isFollowing = user.isFollowing + ) { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.FOLLOW_USER)) { + navController.navigate(NavigationRoute.Login.route) + } else { + scope.launch { + if (user.isFollowing) { + model.unFollowUser(user.id) + } else { + model.followUser(user.id) + } + } + } + } + } + } + } + PullRefreshIndicator( + refreshing = model.isLoading, + state = refreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowingListViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowingListViewModel.kt new file mode 100644 index 0000000..054ef39 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/follower/FollowingListViewModel.kt @@ -0,0 +1,25 @@ +package com.aiosman.ravenow.ui.follower + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.aiosman.ravenow.data.UserServiceImpl +import com.aiosman.ravenow.entity.AccountPagingSource +import com.aiosman.ravenow.entity.AccountProfileEntity +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class FollowingListViewModel( + val userId: Int +) : BaseFollowModel() { + override var followingId: Int? = userId +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/gallery/Gallery.kt b/app/src/main/java/com/aiosman/ravenow/ui/gallery/Gallery.kt new file mode 100644 index 0000000..83fed17 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/gallery/Gallery.kt @@ -0,0 +1,278 @@ +package com.aiosman.ravenow.ui.gallery + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.R +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun ProfileTimelineScreen() { + val pagerState = rememberPagerState(pageCount = { 2 }) + val scope = rememberCoroutineScope() + val systemUiController = rememberSystemUiController() + fun switchToPage(page: Int) { + scope.launch { + pagerState.animateScrollToPage(page) + } + } + LaunchedEffect(Unit) { + systemUiController.setNavigationBarColor(Color.Transparent) + } + Scaffold( + topBar = { + TopAppBar( + title = { + Text("Gallery") + }, + navigationIcon = { }, + actions = { }) + }, + ) { paddingValues: PaddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + Column(modifier = Modifier) { + ScrollableTabRow( + edgePadding = 0.dp, + selectedTabIndex = pagerState.currentPage, + modifier = Modifier, + divider = { }, + indicator = { tabPositions -> + Box( + modifier = Modifier + .tabIndicatorOffset(tabPositions[pagerState.currentPage]) + + ) { + Box( + modifier = Modifier + .align(Alignment.Center) + .height(4.dp) + .width(16.dp) + .background(color = Color.Red) + + ) + } + } + ) { + Tab( + text = { Text("Timeline", color = Color.Black) }, + selected = pagerState.currentPage == 0, + onClick = { switchToPage(0) } + + ) + Tab( + text = { Text("Position", color = Color.Black) }, + selected = pagerState.currentPage == 1, + onClick = { switchToPage(1) } + ) + } + HorizontalPager( + state = pagerState, + modifier = Modifier + .weight(1f) + .fillMaxSize() + ) { page -> + when (page) { + 0 -> GalleryTimeline() + 1 -> GalleryPosition() + } + } + } + } + + } +} +@Composable +fun GalleryTimeline() { + val mockList = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "10") + Box( + modifier = Modifier + .fillMaxSize() + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + ) { + items(mockList) { item -> + TimelineItem() + } + + } + } + +} +@Composable +fun DashedVerticalLine(modifier: Modifier = Modifier) { + BoxWithConstraints(modifier = modifier) { + Canvas(modifier = Modifier.height(maxHeight)) { + val path = Path().apply { + moveTo(size.width / 2, 0f) + lineTo(size.width / 2, size.height) + } + drawPath( + path = path, + color = Color.Gray, + ) + } + } +} +@Composable +fun DashedLine() { + Canvas(modifier = Modifier + .width(1.dp) // 控制线条的宽度 + .fillMaxHeight()) { // 填满父容器的高度 + + val canvasWidth = size.width + val canvasHeight = size.height + + // 创建一个PathEffect来定义如何绘制线段 + val pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) + + drawLine( + color = Color.Gray, // 线条颜色 + start = Offset(x = canvasWidth / 2, y = 0f), // 起始点 + end = Offset(x = canvasWidth / 2, y = canvasHeight), // 终点 + pathEffect = pathEffect // 应用虚线效果 + ) + } +} +@Preview +@Composable +fun TimelineItem() { + val itemsList = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9") + Box( + modifier = Modifier + .padding(16.dp) + .fillMaxSize() + .wrapContentWidth() + + ) { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .width(64.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("12", fontSize = 22.sp, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) + Text("7月", fontSize = 20.sp,fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) + // add vertical dash line +// Box( +// modifier = Modifier +// .height(120.dp) +// .width(3.dp) +// .background(Color.Gray) +// ) + DashedLine() + } + Column { + Row( + modifier = Modifier + .padding(bottom = 16.dp) + ) { + Image( + painter = painterResource(id = R.drawable.default_avatar), + contentDescription = "", + modifier = Modifier + .padding(end = 16.dp) + .size(24.dp) + .clip(CircleShape) // Clip the image to a circle + ) + Text("Onyama Limba") + } + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + ) { + Column { + repeat(3) { // Create three rows + Row(modifier = Modifier.weight(1f)) { + repeat(3) { // Create three columns in each row + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) // Keep the aspect ratio 1:1 for square shape + .padding(4.dp) + ){ + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Gray) + ) { + Text("1") + } + } + } + } + } + } + } + } + } + } +} + +@Composable +fun GalleryPosition() { + val mockList = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "10") + Box( + modifier = Modifier + .fillMaxSize() + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + ) { + items(mockList) { item -> + TimelineItem() + } + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/gallery/OfficialGallery.kt b/app/src/main/java/com/aiosman/ravenow/ui/gallery/OfficialGallery.kt new file mode 100644 index 0000000..ce8b558 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/gallery/OfficialGallery.kt @@ -0,0 +1,201 @@ +package com.aiosman.ravenow.ui.gallery + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout +import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder + +@Preview +@Composable +fun OfficialGalleryScreen() { + StatusBarMaskLayout { + Column( + modifier = Modifier + .fillMaxSize() + .padding(start = 16.dp, end = 16.dp) + ) { + OfficialGalleryPageHeader() + Spacer(modifier = Modifier.height(16.dp)) + ImageGrid() + } + } + +} + +@Composable +fun OfficialGalleryPageHeader() { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_back_icon), // Replace with your logo resource + contentDescription = "Logo", + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "官方摄影师作品", fontWeight = FontWeight.Bold, fontSize = 16.sp) + } +} + +@Composable +fun CertificationSection() { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFFFF3CD), RoundedCornerShape(8.dp)) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_launcher_foreground), // Replace with your certification icon resource + contentDescription = "Certification Icon", + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "成为认证摄影师", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = { /*TODO*/ }, + + ) { + Text(text = "去认证", color = Color.White) + } + } +} + +@Composable +fun ImageGrid() { + val photographers = listOf( + Pair( + "Diego Morata", + R.drawable.rider_pro_moment_demo_1 + ), // Replace with your image resources + Pair("Usha Oliver", R.drawable.rider_pro_moment_demo_2), + Pair("Mohsen Salehi", R.drawable.rider_pro_moment_demo_3), + Pair("Thanawan Chadee", R.drawable.rider_pro_moment_demo_1), + Pair("Photographer 5", R.drawable.rider_pro_moment_demo_2), + Pair("Photographer 6", R.drawable.rider_pro_moment_demo_3), + Pair( + "Diego Morata", + R.drawable.rider_pro_moment_demo_1 + ), // Replace with your image resources + Pair("Usha Oliver", R.drawable.rider_pro_moment_demo_2), + Pair("Mohsen Salehi", R.drawable.rider_pro_moment_demo_3), + Pair("Thanawan Chadee", R.drawable.rider_pro_moment_demo_1), + Pair("Photographer 5", R.drawable.rider_pro_moment_demo_2), + Pair("Photographer 6", R.drawable.rider_pro_moment_demo_3), + Pair( + "Diego Morata", + R.drawable.rider_pro_moment_demo_1 + ), // Replace with your image resources + Pair("Usha Oliver", R.drawable.rider_pro_moment_demo_2), + Pair("Mohsen Salehi", R.drawable.rider_pro_moment_demo_3), + Pair("Thanawan Chadee", R.drawable.rider_pro_moment_demo_1), + Pair("Photographer 5", R.drawable.rider_pro_moment_demo_2), + Pair("Photographer 6", R.drawable.rider_pro_moment_demo_3), + Pair( + "Diego Morata", + R.drawable.rider_pro_moment_demo_1 + ), // Replace with your image resources + Pair("Usha Oliver", R.drawable.rider_pro_moment_demo_2), + Pair("Mohsen Salehi", R.drawable.rider_pro_moment_demo_3), + Pair("Thanawan Chadee", R.drawable.rider_pro_moment_demo_1), + Pair("Photographer 5", R.drawable.rider_pro_moment_demo_2), + Pair("Photographer 6", R.drawable.rider_pro_moment_demo_3) + ) + + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(photographers.size) { index -> + PhotographerCard(photographers[index].first, photographers[index].second) + } + item{ + BottomNavigationPlaceholder() + } + } +} + +@Composable +fun PhotographerCard(name: String, imageRes: Int) { + val navController = LocalNavController.current + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)) + .background(Color.LightGray) + ) { + Image( + painter = painterResource(id = imageRes), + contentDescription = name, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(270.dp) + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0x55000000)) + .align(Alignment.BottomStart) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .clickable { + navController.navigate("OfficialPhotographer") + }, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.default_avatar), // Replace with your profile picture resource + contentDescription = "Profile Picture", + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = name, color = Color.White) + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/gallery/OfficialPhotographer.kt b/app/src/main/java/com/aiosman/ravenow/ui/gallery/OfficialPhotographer.kt new file mode 100644 index 0000000..2c648b3 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/gallery/OfficialPhotographer.kt @@ -0,0 +1,294 @@ +package com.aiosman.ravenow.ui.gallery + +import android.util.Log +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout +import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder + +data class ArtWork( + val id: Int, + val resId: Int, +) + +fun GenerateMockArtWorks(): List { + val pickupImage = listOf( + R.drawable.default_avatar, + R.drawable.default_moment_img, + R.drawable.rider_pro_moment_demo_1, + R.drawable.rider_pro_moment_demo_2, + R.drawable.rider_pro_moment_demo_3, + ) + return List(30) { + ArtWork( + id = it, + resId = pickupImage.random() + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Preview +@Composable +fun OfficialPhotographerScreen() { + val lazyListState = rememberLazyListState() + var artWorks by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(Unit) { + artWorks = GenerateMockArtWorks() + } + // Observe the scroll state and calculate opacity + val alpha by remember { + derivedStateOf { + // Example calculation: Adjust the range and formula as needed + val alp = minOf(1f, lazyListState.firstVisibleItemScrollOffset / 900f) + Log.d("alpha", "alpha: $alp") + alp + } + } + StatusBarMaskLayout( + maskBoxBackgroundColor = Color.Black, + darkIcons = false + ) { + Column { + Box( + modifier = Modifier + .background(Color.Black) + + + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState + ) { + item { + Box( + modifier = Modifier + .height(400.dp) + .fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.drawable.default_moment_img), + contentDescription = "Logo", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + // dark alpha overlay + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = alpha)) + ) + + // on bottom of box + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 1f), + Color.Black.copy(alpha = 1f), + Color.Black.copy(alpha = 0f), + ), + startY = Float.POSITIVE_INFINITY, + endY = 0f + ) + ) + .padding(16.dp) + .align(alignment = Alignment.BottomCenter) + + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.default_avatar), + contentDescription = "", + modifier = Modifier + .size(32.dp) + .clip(CircleShape) // Clip the image to a circle + ) + Spacer(modifier = Modifier.width(8.dp)) + // name + Text("Onyama Limba", color = Color.White, fontSize = 14.sp) + Spacer(modifier = Modifier.width(8.dp)) + // round box + Box( + modifier = Modifier + .background(Color.Red, CircleShape) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + // certification + Text("摄影师", color = Color.White, fontSize = 12.sp) + } + Spacer(modifier = Modifier.weight(1f)) + IconButton( + onClick = { /*TODO*/ }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Filled.Favorite, + contentDescription = null, + tint = Color.White + ) + } + Spacer(modifier = Modifier.width(4.dp)) + Text("123", color = Color.White) + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + onClick = {}, + modifier = Modifier.size(32.dp) + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_eye), + contentDescription = "", + modifier = Modifier + .size(24.dp) + ) + } + Spacer(modifier = Modifier.width(4.dp)) + Text("123", color = Color.White) + + } + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + // description + Text( + "摄影师 Diego Morata 的作品", + color = Color.White, + modifier = Modifier.align(Alignment.Center) + ) + } + } + + // circle avatar + + } + + + } + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val imageSize = + (screenWidth - (4.dp * 4)) / 3 // Subtracting total padding and divi + val itemWidth = screenWidth / 3 - 4.dp * 2 + FlowRow( + modifier = Modifier.padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + maxItemsInEachRow = 3 + ) { + for (artWork in artWorks) { + Box( + modifier = Modifier + .width(itemWidth) + .aspectRatio(1f) + .background(Color.Gray) + ) { + Image( + painter = painterResource(id = artWork.resId), + contentDescription = "", + contentScale = ContentScale.Crop, + modifier = Modifier + .width(imageSize) + .aspectRatio(1f) + ) + } + } + BottomNavigationPlaceholder() + + } + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .background(Color.Black.copy(alpha = alpha)) + .padding(horizontal = 16.dp) + ) { + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_back_icon), + colorFilter = ColorFilter.tint(Color.White), + contentDescription = "", + modifier = Modifier + .size(32.dp) + .clip(CircleShape) // Clip the image to a circle + ) + if (alpha == 1f) { + Spacer(modifier = Modifier.width(8.dp)) + Image( + painter = painterResource(id = R.drawable.default_avatar), + contentDescription = "", + modifier = Modifier + .size(32.dp) + .clip(CircleShape) // Clip the image to a circle + + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Onyama Limba", color = Color.White) + } + } + + } + } + + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/group/AiAgentListScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/group/AiAgentListScreen.kt new file mode 100644 index 0000000..95ad19d --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/group/AiAgentListScreen.kt @@ -0,0 +1,134 @@ +package com.aiosman.ravenow.ui.group + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun AiAgentListScreen( + searchText: String, + selectedMemberIds: Set = emptySet(), + onMemberSelect: (GroupMember) -> Unit +) { + val AppColors = LocalAppTheme.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + + // 使用ViewModel + val viewModel = remember { AiAgentListViewModel() } + + // 获取过滤后的数据 + val filteredAgents = viewModel.getFilteredAgents(searchText) + + // 下拉刷新状态 + val pullRefreshState = rememberPullRefreshState( + refreshing = viewModel.isRefreshing, + onRefresh = { + viewModel.refresh() + } + ) + + // 上拉加载更多 + LaunchedEffect(listState) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo } + .collect { visibleItems -> + if (visibleItems.isNotEmpty()) { + val lastVisibleItem = visibleItems.last() + if (lastVisibleItem.index >= filteredAgents.size - 3 && viewModel.hasMoreData && !viewModel.isLoadingMore && !viewModel.isLoading) { + viewModel.loadMore() + } + } + } + } + + // 显示错误信息 + viewModel.errorMessage?.let { error -> + LaunchedEffect(error) { + // 可以在这里显示错误提示 + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + ) { + if (viewModel.isLoading && filteredAgents.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "加载中...", + color = AppColors.secondaryText, + fontSize = 14.sp + ) + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + items = filteredAgents, + key = { it.id } + ) { agent -> + MemberItem( + member = agent, + isSelected = selectedMemberIds.contains(agent.id), + onSelect = { onMemberSelect(agent) } + ) + } + + // 加载更多指示器 + if (viewModel.isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "加载更多...", + color = AppColors.secondaryText, + fontSize = 14.sp + ) + } + } + } + } + } + + // 下拉刷新指示器 + PullRefreshIndicator( + refreshing = viewModel.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + backgroundColor = AppColors.background, + contentColor = AppColors.main + ) + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/group/AiAgentListViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/group/AiAgentListViewModel.kt new file mode 100644 index 0000000..4df597a --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/group/AiAgentListViewModel.kt @@ -0,0 +1,118 @@ +package com.aiosman.ravenow.ui.group + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.aiosman.ravenow.AppStore +import com.aiosman.ravenow.data.AccountService +import com.aiosman.ravenow.data.AccountServiceImpl +import com.aiosman.ravenow.data.api.ApiClient +import kotlinx.coroutines.launch + +class AiAgentListViewModel : ViewModel() { + private val accountService: AccountService = AccountServiceImpl() + + // 状态管理 + var aiAgents by mutableStateOf>(emptyList()) + private set + + var isLoading by mutableStateOf(false) + private set + + var isRefreshing by mutableStateOf(false) + private set + + var isLoadingMore by mutableStateOf(false) + private set + + var currentPage by mutableStateOf(1) + private set + + var hasMoreData by mutableStateOf(true) + private set + + var errorMessage by mutableStateOf(null) + private set + + private val pageSize = 20 + + init { + loadAgents(1) + } + + // 加载AI智能体数据 + fun loadAgents(page: Int, isRefresh: Boolean = false) { + viewModelScope.launch { + try { + if (isRefresh) { + isRefreshing = true + } else if (page == 1) { + isLoading = true + } else { + isLoadingMore = true + } + + errorMessage = null + + val response = accountService.getAgent(page, pageSize) + if (response.isSuccessful && response.body() != null) { + val agentData = response.body()!! + val newAgents: List = agentData.data.list.map { agent -> + GroupMember( + id = agent.openId, + name = agent.title, + avatar = "${ApiClient.BASE_API_URL+"/outside"}${agent.avatar}"+"?token="+"${AppStore.token}", + isAi = true + ) + } + + if (isRefresh || page == 1) { + aiAgents = newAgents + currentPage = 1 + } else { + aiAgents = aiAgents + newAgents + currentPage = page + } + + hasMoreData = newAgents.size >= pageSize + } else { + errorMessage = "获取AI智能体列表失败: ${response.message()}" + } + } catch (e: Exception) { + errorMessage = "获取AI智能体列表失败: ${e.message}" + } finally { + isLoading = false + isRefreshing = false + isLoadingMore = false + } + } + } + + // 刷新数据 + fun refresh() { + loadAgents(1, true) + } + + // 加载更多数据 + fun loadMore() { + if (hasMoreData && !isLoadingMore && !isLoading) { + loadAgents(currentPage + 1) + } + } + + // 清除错误信息 + fun clearError() { + errorMessage = null + } + + // 搜索过滤 + fun getFilteredAgents(searchText: String): List { + return if (searchText.isEmpty()) { + aiAgents + } else { + aiAgents.filter { it.name.contains(searchText, ignoreCase = true) } + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/group/CreateGroupChatScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/group/CreateGroupChatScreen.kt new file mode 100644 index 0000000..60e99af --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/group/CreateGroupChatScreen.kt @@ -0,0 +1,518 @@ +package com.aiosman.ravenow.ui.group + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.StatusBarSpacer +import com.aiosman.ravenow.ui.composables.TabItem +import com.aiosman.ravenow.ui.composables.TabSpacer +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.launch + +// 成员数据类 +data class GroupMember( + val id: String, + val name: String, + val avatar: String, + val isAi:Boolean = false +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CreateGroupChatScreen() { + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + val systemUiController = rememberSystemUiController() + val context = LocalContext.current + + // 状态管理 + var groupName by remember { mutableStateOf(TextFieldValue("")) } + var searchText by remember { mutableStateOf(TextFieldValue("")) } + var selectedMembers by remember { mutableStateOf(listOf()) } + var selectedMemberIds by remember { mutableStateOf>(emptySet()) } + var pagerState = rememberPagerState(pageCount = { 2 }) + var scope = rememberCoroutineScope() + + // LazyRow状态管理 + val lazyRowState = rememberLazyListState() + + // 清除错误信息 + LaunchedEffect(groupName.text, searchText.text) { + if (CreateGroupChatViewModel.errorMessage != null) { + CreateGroupChatViewModel.clearError() + } + } + + // 监听页面切换,清除当前tab的选中状态(但保留已选成员列表) + LaunchedEffect(pagerState.currentPage) { + // 不清除selectedMemberIds,因为我们需要保持跨tab的选中状态 + // 这样用户可以在AI智能体和朋友之间切换,选中的状态会保持 + } + + // 监听selectedMembers变化,当有新成员添加时自动滚动到最后一个 + LaunchedEffect(selectedMembers.size) { + if (selectedMembers.isNotEmpty()) { + // 延迟一点时间确保LazyRow已经更新了布局 + kotlinx.coroutines.delay(100) + lazyRowState.animateScrollToItem(selectedMembers.size - 1) + } + } + + val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + LaunchedEffect(Unit) { + systemUiController.setNavigationBarColor(Color.Transparent) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) + ) { + Column( + modifier = Modifier + .fillMaxSize() + ) { + StatusBarSpacer() + + // 头部 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 返回按钮 + Image( + painter = painterResource(id = R.drawable.rider_pro_back_icon), + contentDescription = "back", + modifier = Modifier + .size(24.dp) + .noRippleClickable { + navController.popBackStack() + }, + colorFilter = ColorFilter.tint(AppColors.text) + ) + + // 标题 + Text( + text = stringResource(R.string.create_group_chat), + fontSize = 17.sp, + fontWeight = FontWeight.W700, + color = AppColors.text, + modifier = Modifier + .weight(1f) + .padding(start = 16.dp) + ) + + // 一键创建按钮 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.noRippleClickable { + // 一键创建逻辑 + } + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_new_post_add_pic), + contentDescription = "quick create", + modifier = Modifier.size(16.dp), + colorFilter = ColorFilter.tint(AppColors.main) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.quick_create), + fontSize = 14.sp, + color = AppColors.main + ) + } + } + + // 搜索栏 + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .background( + color = AppColors.inputBackground, + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 16.dp, vertical = 13.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_nav_search), + contentDescription = stringResource(R.string.search), + modifier = Modifier.size(16.dp), + colorFilter = ColorFilter.tint(AppColors.secondaryText) + ) + Spacer(modifier = Modifier.width(8.dp)) + BasicTextField( + value = searchText, + onValueChange = { searchText = it }, + textStyle = androidx.compose.ui.text.TextStyle( + color = AppColors.text, + fontSize = 14.sp + ), + modifier = Modifier.weight(1f), + singleLine = true, + cursorBrush = SolidColor(AppColors.text), + decorationBox = { innerTextField -> + Box { + if (searchText.text.isEmpty()) { + Text( + text = stringResource(R.string.search), + color = AppColors.secondaryText, + fontSize = 14.sp + ) + } + innerTextField() + } + } + ) + } + } + + // // 搜索栏 + // Box( + // modifier = Modifier + // .fillMaxWidth() + // .padding(horizontal = 16.dp, vertical = 8.dp) + // .background( + // color = AppColors.inputBackground, + // shape = RoundedCornerShape(8.dp) + // ) + // .padding(horizontal = 12.dp, vertical = 8.dp) + // ) { + + // Row( + // verticalAlignment = Alignment.CenterVertically + // ) { + // Image( + // painter = painterResource(id = R.drawable.rider_pro_nav_search), + // contentDescription = stringResource(R.string.search), + // modifier = Modifier.size(16.dp), + // colorFilter = ColorFilter.tint(AppColors.secondaryText) + // ) + // Spacer(modifier = Modifier.width(8.dp)) + // if (searchText.text.isEmpty()) { + // Text( + // text = stringResource(R.string.search), + // color = AppColors.secondaryText, + // fontSize = 14.sp + // ) + // } + // innerTextField() + // } + // } + // ) + // } + + // 群聊名称输入:同一圆角灰色矩形容器 + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .background( + color = AppColors.inputBackground, + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.group_name), + fontSize = 14.sp, + color = AppColors.text, + modifier = Modifier.width(80.dp) + ) + BasicTextField( + value = groupName, + onValueChange = { groupName = it }, + textStyle = androidx.compose.ui.text.TextStyle( + color = AppColors.text, + fontSize = 14.sp + ), + modifier = Modifier + .weight(1f), + singleLine = true, + cursorBrush = SolidColor(AppColors.text), + decorationBox = { innerTextField -> + Box(Modifier.fillMaxWidth()) { + if (groupName.text.isEmpty()) { + Text( + text = stringResource(R.string.group_name_hint), + color = AppColors.inputHint, + fontSize = 14.sp + ) + } + innerTextField() + } + } + ) + } + } + + // 已选成员列表 + if (selectedMembers.isNotEmpty()) { + // 显示选中成员数量 + /* Text( + text = "已选择 ${selectedMembers.size} 个成员", + fontSize = 14.sp, + color = AppColors.secondaryText, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + )*/ + LazyRow( + state = lazyRowState, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(selectedMembers) { member -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.width(48.dp) + ) { + Box { + CustomAsyncImage( + context = context, + imageUrl = member.avatar, + contentDescription = member.name, + defaultRes = R.drawable.default_avatar, + placeholderRes = R.drawable.default_avatar, + errorRes = R.drawable.default_avatar, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + ) + + // 删除按钮 + Box( + modifier = Modifier + .size(20.dp) + .background(AppColors.error, CircleShape) + .align(Alignment.TopEnd) + .noRippleClickable { + // 删除成员时同时更新选中状态 + val (newSelectedMemberIds, newSelectedMembers) = CreateGroupChatViewModel.removeSelectedMember( + member, selectedMemberIds, selectedMembers + ) + selectedMemberIds = newSelectedMemberIds + selectedMembers = newSelectedMembers + }, + contentAlignment = Alignment.Center + ) { + Text( + text = "×", + color = AppColors.mainText, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + } + } + + // 名称显示 + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (member.name.length > 5) { + member.name.substring(0, 5) + "..." + } else { + member.name + }, + fontSize = 12.sp, + + color = AppColors.text, + maxLines = 1, + modifier = Modifier.fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally) + ) + } + } + } + } + + // Tab切换 + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.Bottom + ) { + TabItem( + text = stringResource(R.string.chat_ai), + isSelected = pagerState.currentPage == 0, + onClick = { + scope.launch { + pagerState.animateScrollToPage(0) + } + } + ) + TabSpacer() + TabItem( + text = stringResource(R.string.chat_friend), + isSelected = pagerState.currentPage == 1, + onClick = { + scope.launch { + pagerState.animateScrollToPage(1) + } + } + ) + } + + // 内容区域 - 自适应填满剩余高度 + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .weight(1f) // 这里让列表占据剩余空间 + ) { + when (it) { + 0 -> { + // AI智能体列表 + AiAgentListScreen( + searchText = searchText.text, + selectedMemberIds = selectedMemberIds, + onMemberSelect = { member -> + val (newSelectedMemberIds, newSelectedMembers) = CreateGroupChatViewModel.toggleMemberSelection( + member, selectedMemberIds, selectedMembers + ) + selectedMemberIds = newSelectedMemberIds + selectedMembers = newSelectedMembers + } + ) + } + 1 -> { + // 朋友列表 + FriendListScreen( + searchText = searchText.text, + selectedMemberIds = selectedMemberIds, + onMemberSelect = { member -> + val (newSelectedMemberIds, newSelectedMembers) = CreateGroupChatViewModel.toggleMemberSelection( + member, selectedMemberIds, selectedMembers + ) + selectedMemberIds = newSelectedMemberIds + selectedMembers = newSelectedMembers + } + ) + } + } + } + + // 创建群聊按钮 - 固定在底部 + Button( + onClick = { + // 创建群聊逻辑 + if (selectedMembers.isNotEmpty()) { + scope.launch { + val success = CreateGroupChatViewModel.createGroupChat( + groupName = groupName.text, + selectedMembers = selectedMembers, + context = context + ) + if (success) { + navController.popBackStack() + } + } + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = navigationBarPadding + 16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = AppColors.main, + contentColor = AppColors.mainText, + disabledContainerColor = AppColors.disabledBackground, + disabledContentColor = AppColors.text + ), + shape = RoundedCornerShape(24.dp), + enabled = groupName.text.isNotEmpty() && selectedMembers.isNotEmpty() && !CreateGroupChatViewModel.isLoading + ) { + if (CreateGroupChatViewModel.isLoading) { + Text( + text = "创建中...", + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + } else { + Text( + text = stringResource(R.string.create_group_chat), + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + } + } + + } + + // 居中显示的错误提示弹窗 + CreateGroupChatViewModel.errorMessage?.let { error -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), // 在Box中居中对齐 + horizontalAlignment = Alignment.CenterHorizontally, // 水平居中 + verticalArrangement = Arrangement.Center // 垂直居中 + ) { + androidx.compose.material3.Card( + modifier = Modifier + .fillMaxWidth(0.8f), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = error, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + color = Color.Red, + fontSize = 14.sp, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/group/CreateGroupChatViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/group/CreateGroupChatViewModel.kt new file mode 100644 index 0000000..0fbfc28 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/group/CreateGroupChatViewModel.kt @@ -0,0 +1,109 @@ +package com.aiosman.ravenow.ui.group + +import android.content.Context +import android.icu.util.Calendar +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import androidx.navigation.NavHostController +import androidx.paging.PagingData +import androidx.paging.map +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.ConstVars +import com.aiosman.ravenow.data.AccountNotice +import com.aiosman.ravenow.data.AccountService +import com.aiosman.ravenow.data.AccountServiceImpl +import com.aiosman.ravenow.data.UserService +import com.aiosman.ravenow.data.UserServiceImpl +import com.aiosman.ravenow.entity.CommentEntity +import com.aiosman.ravenow.exp.formatChatTime +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.navigateToChat +import com.aiosman.ravenow.utils.TrtcHelper +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlin.coroutines.suspendCoroutine + +object CreateGroupChatViewModel : ViewModel() { + val accountService: AccountService = AccountServiceImpl() + val userService: UserService = UserServiceImpl() + + // 状态管理 + var isLoading by mutableStateOf(false) + var errorMessage by mutableStateOf(null) + + // 创建群聊 + suspend fun createGroupChat( + groupName: String, + selectedMembers: List, + context: Context + ): Boolean { + return try { + isLoading = true + + // 根据isAi属性分别获取userIds和promptIds + val userIds = selectedMembers.filter { !it.isAi }.map { it.id } + val promptIds = selectedMembers.filter { it.isAi }.map { it.id } + + val response = accountService.createGroupChat(groupName, userIds, promptIds) + if (response.isSuccessful && response.body() != null) { + isLoading = false + true + } else { + isLoading = false + val errorMsg = "创建群聊失败: ${response.message()}" + showToast(errorMsg) + false + } + } catch (e: Exception) { + isLoading = false + val errorMsg = "创建群聊失败: ${e.message}" + showToast(errorMsg) + false + } + } + + private fun showToast(message: String) { + errorMessage = message + viewModelScope.launch { + delay(3000) + errorMessage = null + } + } + + // 清除错误信息 + fun clearError() { + errorMessage = null + } + + // 添加成员到选中列表 + fun addSelectedMember(member: GroupMember, selectedMemberIds: Set, selectedMembers: List): Pair, List> { + val newSelectedMemberIds = selectedMemberIds + member.id + val newSelectedMembers = if (selectedMembers.none { it.id == member.id }) { + selectedMembers + member + } else { + selectedMembers + } + return Pair(newSelectedMemberIds, newSelectedMembers) + } + + // 从选中列表移除成员 + fun removeSelectedMember(member: GroupMember, selectedMemberIds: Set, selectedMembers: List): Pair, List> { + val newSelectedMemberIds = selectedMemberIds - member.id + val newSelectedMembers = selectedMembers.filter { it.id != member.id } + return Pair(newSelectedMemberIds, newSelectedMembers) + } + + // 切换成员选中状态 + fun toggleMemberSelection(member: GroupMember, selectedMemberIds: Set, selectedMembers: List): Pair, List> { + return if (selectedMemberIds.contains(member.id)) { + removeSelectedMember(member, selectedMemberIds, selectedMembers) + } else { + addSelectedMember(member, selectedMemberIds, selectedMembers) + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/group/FriendListScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/group/FriendListScreen.kt new file mode 100644 index 0000000..a0e28f9 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/group/FriendListScreen.kt @@ -0,0 +1,134 @@ +package com.aiosman.ravenow.ui.group + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun FriendListScreen( + searchText: String, + selectedMemberIds: Set = emptySet(), + onMemberSelect: (GroupMember) -> Unit +) { + val AppColors = LocalAppTheme.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + + // 使用ViewModel + val viewModel = remember { FriendListViewModel() } + + // 获取过滤后的数据 + val filteredFriends = viewModel.getFilteredFriends(searchText) + + // 下拉刷新状态 + val pullRefreshState = rememberPullRefreshState( + refreshing = viewModel.isRefreshing, + onRefresh = { + viewModel.refresh() + } + ) + + // 上拉加载更多 + LaunchedEffect(listState) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo } + .collect { visibleItems -> + if (visibleItems.isNotEmpty()) { + val lastVisibleItem = visibleItems.last() + if (lastVisibleItem.index >= filteredFriends.size - 3 && viewModel.hasMoreData && !viewModel.isLoadingMore && !viewModel.isLoading) { + viewModel.loadMore() + } + } + } + } + + // 显示错误信息 + viewModel.errorMessage?.let { error -> + LaunchedEffect(error) { + // 可以在这里显示错误提示 + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + ) { + if (viewModel.isLoading && filteredFriends.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "加载中...", + color = AppColors.secondaryText, + fontSize = 14.sp + ) + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + items = filteredFriends, + key = { it.id } + ) { friend -> + MemberItem( + member = friend, + isSelected = selectedMemberIds.contains(friend.id), + onSelect = { onMemberSelect(friend) } + ) + } + + // 加载更多指示器 + if (viewModel.isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "加载更多...", + color = AppColors.secondaryText, + fontSize = 14.sp + ) + } + } + } + } + } + + // 下拉刷新指示器 + PullRefreshIndicator( + refreshing = viewModel.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + backgroundColor = AppColors.background, + contentColor = AppColors.main + ) + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/group/FriendListViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/group/FriendListViewModel.kt new file mode 100644 index 0000000..5ad7f59 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/group/FriendListViewModel.kt @@ -0,0 +1,113 @@ +package com.aiosman.ravenow.ui.group + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.aiosman.ravenow.AppStore +import com.aiosman.ravenow.data.UserService +import com.aiosman.ravenow.data.UserServiceImpl +import com.aiosman.ravenow.data.api.ApiClient +import kotlinx.coroutines.launch + +class FriendListViewModel : ViewModel() { + private val userService: UserService = UserServiceImpl() + + // 状态管理 + var friends by mutableStateOf>(emptyList()) + private set + + var isLoading by mutableStateOf(false) + private set + + var isRefreshing by mutableStateOf(false) + private set + + var isLoadingMore by mutableStateOf(false) + private set + + var currentPage by mutableStateOf(1) + private set + + var hasMoreData by mutableStateOf(true) + private set + + var errorMessage by mutableStateOf(null) + private set + + private val pageSize = 20 + + init { + loadFriends(1) + } + + // 加载朋友数据 + fun loadFriends(page: Int, isRefresh: Boolean = false) { + viewModelScope.launch { + try { + if (isRefresh) { + isRefreshing = true + } else if (page == 1) { + isLoading = true + } else { + isLoadingMore = true + } + + errorMessage = null + + val userData = userService.getUsers(pageSize, page) + val newFriends: List = userData.list.map { user -> + GroupMember( + id = user.chatAIId, + name = user.nickName, + avatar = user.avatar, + isAi = false + ) + } + + if (isRefresh || page == 1) { + friends = newFriends + currentPage = 1 + } else { + friends = friends + newFriends + currentPage = page + } + + hasMoreData = newFriends.size >= pageSize + } catch (e: Exception) { + errorMessage = "获取朋友列表失败: ${e.message}" + } finally { + isLoading = false + isRefreshing = false + isLoadingMore = false + } + } + } + + // 刷新数据 + fun refresh() { + loadFriends(1, true) + } + + // 加载更多数据 + fun loadMore() { + if (hasMoreData && !isLoadingMore && !isLoading) { + loadFriends(currentPage + 1) + } + } + + // 清除错误信息 + fun clearError() { + errorMessage = null + } + + // 搜索过滤 + fun getFilteredFriends(searchText: String): List { + return if (searchText.isEmpty()) { + friends + } else { + friends.filter { it.name.contains(searchText, ignoreCase = true) } + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/group/GroupChatInfoScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/group/GroupChatInfoScreen.kt new file mode 100644 index 0000000..37654cf --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/group/GroupChatInfoScreen.kt @@ -0,0 +1,380 @@ +package com.aiosman.ravenow.ui.group + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.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.ui.composables.CustomAsyncImage +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 + +@Composable +fun GroupChatInfoScreen(groupId: String) { + val navController = LocalNavController.current + val context = LocalContext.current + val AppColors = LocalAppTheme.current + + val viewModel = viewModel( + key = "GroupChatInfoViewModel_$groupId", + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return GroupChatInfoViewModel(groupId) as T + } + } + ) + + Column( + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) + ) { + // 顶部导航栏 + Column( + modifier = Modifier + .fillMaxWidth() + .background(AppColors.background) + ) { + StatusBarSpacer() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.rider_pro_back_icon), + modifier = Modifier + .size(24.dp) + .noRippleClickable { + navController.navigateUp() + }, + contentDescription = null, + colorFilter = ColorFilter.tint(AppColors.text) + ) + Spacer(modifier = Modifier.width(138.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Start, + text = stringResource(R.string.group_info), + style = androidx.compose.ui.text.TextStyle( + color = AppColors.text, + fontSize = 17.sp, + fontWeight = FontWeight.Bold + ) + + ) + Spacer(modifier = Modifier.width(40.dp)) + } + } + + // 内容区域 + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp) + ) { + // 群聊头像和名称 + item { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (viewModel.groupInfo?.groupAvatar?.isNotEmpty() == true) { + CustomAsyncImage( + imageUrl = viewModel.groupInfo!!.groupAvatar, + modifier = Modifier + .size(80.dp) + .clip(RoundedCornerShape(12.dp)), + contentDescription = "群聊头像" + ) + } else { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(AppColors.decentBackground), + contentAlignment = Alignment.Center + ) { + Text( + text = viewModel.groupInfo?.groupName?.firstOrNull()?.toString() ?: "", + style = androidx.compose.ui.text.TextStyle( + color = AppColors.text, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = viewModel.groupInfo?.groupName ?: "群聊", + style = androidx.compose.ui.text.TextStyle( + color = AppColors.text, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + ) + } + } + + // 操作按钮 + item { + Spacer(modifier = Modifier.height(32.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + // 添加其他人 + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.noRippleClickable { + // TODO: 实现添加其他人功能 + } + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.rider_pro_add_other), + modifier = Modifier.size(24.dp), + contentDescription = null, + colorFilter = ColorFilter.tint( + AppColors.text) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.group_info_add_other), + style = androidx.compose.ui.text.TextStyle( + color = AppColors.text, + fontSize = 12.sp + ) + ) + } + + // 通知设置 + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.noRippleClickable { + viewModel.viewModelScope.launch { + /*if (viewModel.notificationStrategy == "mute") { + viewModel.updateNotificationStrategy("active") + } else { + viewModel.updateNotificationStrategy("mute") + }*/ + } + } + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(if (viewModel.notificationStrategy == "mute") R.drawable.rider_pro_notice_mute else R.drawable.rider_pro_notice_active,), + modifier = Modifier.size(24.dp), + contentDescription = null, + colorFilter = ColorFilter.tint( + AppColors.text) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.group_info_notice_setting), + style = androidx.compose.ui.text.TextStyle( + color = AppColors.text, + fontSize = 12.sp + ) + ) + } + + // 退出群聊 + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.noRippleClickable { + // TODO: 实现退出群聊功能 + } + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.group_info_exit + ), + modifier = Modifier.size(24.dp), + contentDescription = null, + colorFilter = ColorFilter.tint( + AppColors.text) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.group_info_exit), + style = androidx.compose.ui.text.TextStyle( + color = AppColors.text, + fontSize = 12.sp + ) + ) + } + } + } + + // 设置选项 + item { + Spacer(modifier = Modifier.height(32.dp)) + + // 设置聊天主题 + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .padding(16.dp) + .noRippleClickable { + // TODO: 实现设置聊天主题功能 + }, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.group_info_edit), + modifier = Modifier.size(24.dp), + contentDescription = null, + colorFilter = ColorFilter.tint( + AppColors.text) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.group_info_edit), + style = androidx.compose.ui.text.TextStyle( + color = AppColors.text, + fontSize = 16.sp + ), + modifier = Modifier.weight(1f) + ) + Image( + painter = painterResource(R.drawable.rave_now_nav_right), + modifier = Modifier.size(18.dp), + contentDescription = null, + ) + } + + Spacer(modifier = Modifier.height(1.dp)) + + // 群聊成员 + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .padding(16.dp) + .noRippleClickable { + // TODO: 实现查看群聊成员功能 + }, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.group_info_users), + modifier = Modifier.size(24.dp), + contentDescription = null, + colorFilter = ColorFilter.tint( + AppColors.text) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "群聊成员 (${viewModel.groupInfo?.memberCount ?: 0})", + style = androidx.compose.ui.text.TextStyle( + color = AppColors.text, + fontSize = 16.sp + ), + modifier = Modifier.weight(1f) + ) + Image( + painter = painterResource(R.drawable.rave_now_nav_right), + modifier = Modifier.size(18.dp), + contentDescription = null, + ) + } + + Spacer(modifier = Modifier.height(1.dp)) + //仅自己可见 + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .padding(16.dp) + .noRippleClickable { + // TODO: 实现仅自己可见功能 + }, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.mipmap.rider_pro_change_password), + modifier = Modifier.size(24.dp), + contentDescription = null, + colorFilter = ColorFilter.tint( + AppColors.text) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "仅自己可见", + style = androidx.compose.ui.text.TextStyle( + color = AppColors.text, + fontSize = 16.sp + ), + modifier = Modifier.weight(1f) + ) + Switch( + checked = true, + onCheckedChange = { + // TODO: 实现群聊仅自己可见功能 + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = Color.Green, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = AppColors.main.copy(alpha = 0.5f), + uncheckedBorderColor = Color.Transparent + ), + modifier = Modifier.scale(1f).height(18.dp) + ) + } + } + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/group/GroupChatInfoViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/group/GroupChatInfoViewModel.kt new file mode 100644 index 0000000..bdc7fcf --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/group/GroupChatInfoViewModel.kt @@ -0,0 +1,74 @@ +package com.aiosman.ravenow.ui.group + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.aiosman.ravenow.AppStore +import com.aiosman.ravenow.ChatState +import com.aiosman.ravenow.data.api.ApiClient +import com.aiosman.ravenow.entity.ChatNotification +import com.aiosman.ravenow.entity.GroupInfo +import com.aiosman.ravenow.entity.GroupMember +import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel +import kotlinx.coroutines.launch + +class GroupChatInfoViewModel( + private val groupId: String +) : ViewModel() { + + var groupInfo by mutableStateOf(null) + var isLoading by mutableStateOf(false) + var error by mutableStateOf(null) + var chatNotification by mutableStateOf(null) + val notificationStrategy get() = chatNotification?.strategy ?: "default" + init { + loadGroupInfo() + } + suspend fun updateNotificationStrategy(strategy: String) { + val result = ChatState.updateChatNotification(groupId.hashCode(), strategy) + chatNotification = result + } + + + private fun loadGroupInfo() { + viewModelScope.launch { + val notiStrategy = ChatState.getStrategyByTargetTrtcId(groupId) + chatNotification = notiStrategy + try { + isLoading = true + error = null + + // 调用接口获取群聊详情 + val response = ApiClient.api.getRoomDetail(trtcId = groupId) + + // 使用接口返回的数据 + val room = response.body()?.data + groupInfo = room?.let { + GroupInfo( + groupId = groupId, + groupName = it.name, + groupAvatar = if (it.avatar.isNullOrEmpty()) { + // 将 groupId 转换为 Base64 + val groupIdBase64 = android.util.Base64.encodeToString( + groupId.toByteArray(), + android.util.Base64.NO_WRAP + ) + "${ApiClient.RETROFIT_URL+"group/avatar?groupIdBase64="}${groupIdBase64}"+"&token="+"${AppStore.token}" + } else { + "${ApiClient.BASE_API_URL+"/outside"}${it.avatar}"+"?token="+"${AppStore.token}" + }, + memberCount = room.userCount, + isCreator = room.creator.userId == MyProfileViewModel.profile?.id.toString() + ) + } + + } catch (e: Exception) { + error = e.message ?: "加载失败" + } finally { + isLoading = false + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/group/MemberItem.kt b/app/src/main/java/com/aiosman/ravenow/ui/group/MemberItem.kt new file mode 100644 index 0000000..6f92684 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/group/MemberItem.kt @@ -0,0 +1,66 @@ +package com.aiosman.ravenow.ui.group + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +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.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.Checkbox +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + +@Composable +fun MemberItem( + member: GroupMember, + isSelected: Boolean = false, + onSelect: () -> Unit +) { + val AppColors = LocalAppTheme.current + val context = LocalContext.current + + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) // 固定高度防止跳动 + .noRippleClickable { onSelect() } + .padding(horizontal = 0.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CustomAsyncImage( + context = context, + imageUrl = member.avatar, + contentDescription = member.name, + defaultRes = R.drawable.default_avatar, + placeholderRes = R.drawable.default_avatar, + errorRes = R.drawable.default_avatar, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = member.name, + fontSize = 16.sp, + color = AppColors.text, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Checkbox( + size = 20, + checked = isSelected, + onCheckedChange = { onSelect() } + ) + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/imageviewer/ImageViewerViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/imageviewer/ImageViewerViewModel.kt new file mode 100644 index 0000000..1654e91 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/imageviewer/ImageViewerViewModel.kt @@ -0,0 +1,14 @@ +package com.aiosman.ravenow.ui.imageviewer + +import androidx.lifecycle.ViewModel +import com.aiosman.ravenow.entity.MomentImageEntity + +object ImageViewerViewModel:ViewModel() { + var imageList = mutableListOf() + var initialIndex = 0 + fun asNew(images: List, index: Int = 0) { + imageList.clear() + imageList.addAll(images) + initialIndex = index + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/imageviewer/imageviewer.kt b/app/src/main/java/com/aiosman/ravenow/ui/imageviewer/imageviewer.kt new file mode 100644 index 0000000..35a1553 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/imageviewer/imageviewer.kt @@ -0,0 +1,203 @@ +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout +import com.aiosman.ravenow.ui.imageviewer.ImageViewerViewModel +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.utils.FileUtil.saveImageToGallery +import kotlinx.coroutines.launch +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable + + +@OptIn( + ExperimentalFoundationApi::class, +) +@Composable +fun ImageViewer() { + val model = ImageViewerViewModel + val images = model.imageList + val pagerState = + rememberPagerState(pageCount = { images.size }, initialPage = model.initialIndex) + val navController = LocalNavController.current + val context = LocalContext.current + val navigationBarPaddings = + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp + val scope = rememberCoroutineScope() + val showRawImageStates = remember { mutableStateListOf(*Array(images.size) { false }) } + var isDownloading by remember { mutableStateOf(false) } + var currentPage by remember { mutableStateOf(model.initialIndex) } + LaunchedEffect(pagerState) { + currentPage = pagerState.currentPage + } + StatusBarMaskLayout( + modifier = Modifier.background(Color.Black), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + horizontalAlignment = Alignment.CenterHorizontally + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .weight(0.8f), + ) { page -> + val zoomState = rememberZoomState() + CustomAsyncImage( + context, + if (showRawImageStates[page]) images[page].url else images[page].thumbnail, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .zoomable( + zoomState = zoomState, + onTap = { + navController.navigateUp() + } + ) + , + contentScale = ContentScale.Fit, + ) + } + Box(modifier = Modifier.padding(top = 10.dp, bottom = 10.dp)){ + if (images.size > 1) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xff333333).copy(alpha = 0.6f)) + .padding(vertical = 4.dp, horizontal = 24.dp) + ) { + Text( + text = "${pagerState.currentPage + 1}/${images.size}", + color = Color.White, + ) + } + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .weight(0.2f) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black + ), + ) + ) + .padding(start = 16.dp, end = 16.dp, bottom = navigationBarPaddings), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 72.dp, end = 72.dp) + .padding(top = 16.dp), + horizontalArrangement = Arrangement.Center + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .noRippleClickable { + if (isDownloading) { + return@noRippleClickable + } + isDownloading = true + scope.launch { + saveImageToGallery(context, images[pagerState.currentPage].url) + isDownloading = false + } + } + ) { + if (isDownloading) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = Color.White + ) + } else { + Icon( + painter = painterResource(id = R.drawable.rider_pro_download_icon), + contentDescription = "", + modifier = Modifier.size(32.dp), + tint = Color.White + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + Text( + stringResource(R.string.download), + color = Color.White + ) + } + if (!showRawImageStates[pagerState.currentPage]) { + Spacer(modifier = Modifier.weight(1f)) + } + if (!showRawImageStates[pagerState.currentPage]) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.noRippleClickable { + showRawImageStates[pagerState.currentPage] = true + } + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_original_raw), + contentDescription = "", + modifier = Modifier.size(32.dp), + tint = Color.White + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + stringResource(R.string.original), + color = Color.White + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/CreateBottomSheet.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/CreateBottomSheet.kt new file mode 100644 index 0000000..f8ff1e5 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/CreateBottomSheet.kt @@ -0,0 +1,167 @@ +package com.aiosman.ravenow.ui.index + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.res.stringResource +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateBottomSheet( + sheetState: SheetState, + onDismiss: () -> Unit, + onAiClick: () -> Unit, + onGroupChatClick: () -> Unit, + onMomentClick: () -> Unit +) { + val appColors = LocalAppTheme.current +//水平效果呈现镜像排列 + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + windowInsets = BottomSheetDefaults.windowInsets, + containerColor = appColors.background, + dragHandle = null, + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp, bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.mipmap.h_cj_rw_icon), + contentDescription = null, + modifier = Modifier + .padding(start = 16.dp), + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(appColors.text) + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(R.string.create_title), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = appColors.text, + modifier = Modifier + .padding(end = 3.dp) + ) + Image( + painter = painterResource(R.mipmap.h_cj_x_img), + contentDescription = null, + modifier = Modifier + .padding(end = 18.dp), + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(appColors.text) + ) + } + + Spacer(modifier = Modifier.height(30.dp)) + // 三个创建选项 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + // 动态选项 + CreateOption( + icon = R.drawable.ic_create_monent, + label = stringResource(R.string.create_moment), + onClick = onMomentClick + ) + // 群聊选项 + CreateOption( + icon = R.mipmap.icons_circle_camera, + label = stringResource(R.string.create_group_chat_option), + onClick = onGroupChatClick + ) + // AI选项 + CreateOption( + icon = R.mipmap.icons_circle_ai, + label = stringResource(R.string.create_ai), + onClick = onAiClick + ) + } + + Spacer(modifier = Modifier.height(40.dp)) + + // 关闭按钮 + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .noRippleClickable { onDismiss() }, + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.ic_create_close), + contentDescription = stringResource(R.string.create_close), + modifier = Modifier.size(32.dp) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun CreateOption( + icon: Int, + label: String, + onClick: () -> Unit +) { + val appColors = LocalAppTheme.current + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.noRippleClickable { onClick() } + ) { + // 直接使用图标,不要背景 + Image( + painter = painterResource(icon), + contentDescription = label, + modifier = Modifier.size(72.dp) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 文字标签 + Text( + text = label, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = appColors.text + ) + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/Index.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/Index.kt new file mode 100644 index 0000000..a5df506 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/Index.kt @@ -0,0 +1,625 @@ +package com.aiosman.ravenow.ui.index + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemColors +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.LayoutDirection +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.GuestLoginCheckOut +import com.aiosman.ravenow.GuestLoginCheckOutScene +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.Messaging +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.index.tabs.add.AddPage +import com.aiosman.ravenow.ui.index.tabs.ai.Agent +import com.aiosman.ravenow.ui.index.tabs.message.NotificationsScreen +import com.aiosman.ravenow.ui.index.tabs.moment.MomentsList +import com.aiosman.ravenow.ui.index.tabs.profile.ProfileWrap +import com.aiosman.ravenow.ui.index.tabs.search.DiscoverScreen +import com.aiosman.ravenow.ui.index.tabs.shorts.ShortVideo +import com.aiosman.ravenow.ui.index.tabs.street.StreetPage +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.post.NewPostViewModel +import com.aiosman.ravenow.utils.ResourceCleanupManager +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun IndexScreen() { + val AppColors = LocalAppTheme.current + val model = IndexViewModel + val navigationBarHeight = with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + val navController = LocalNavController.current + val item = listOf( + NavigationItem.Home, + //NavigationItem.Dynamic, + NavigationItem.Ai, + NavigationItem.Add, + NavigationItem.Notification, + NavigationItem.Profile + ) + val systemUiController = rememberSystemUiController() + val pagerState = rememberPagerState(pageCount = { item.size }) + val coroutineScope = rememberCoroutineScope() + val drawerState = rememberDrawerState(DrawerValue.Closed) + val bottomSheetState = rememberModalBottomSheetState() + val context = LocalContext.current + + // 注意:不要在离开 Index 路由时全量清理资源,以免返回后列表被重置 + LaunchedEffect(Unit) { + systemUiController.setNavigationBarColor(Color.Transparent) + } + LaunchedEffect(model.openDrawer) { + if (model.openDrawer) { + drawerState.open() + model.openDrawer = false + } + } + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = drawerState.isOpen, + drawerContent = { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Column( + modifier = Modifier + .requiredWidth(250.dp) + .fillMaxHeight() + .background( + AppColors.background + ) + ) { + Spacer(modifier = Modifier.height(88.dp)) + NavItem( + iconRes = R.drawable.rave_now_nav_account, + label = stringResource(R.string.account_and_security), + modifier = Modifier.noRippleClickable { + coroutineScope.launch { + drawerState.close() + navController.navigate(NavigationRoute.AccountSetting.route) + } + + } + ) + Spacer(modifier = Modifier.height(16.dp)) + NavItem( + iconRes = R.drawable.rider_pro_favourited, + label = stringResource(R.string.favourites), + modifier = Modifier.noRippleClickable { + coroutineScope.launch { + drawerState.close() + navController.navigate(NavigationRoute.FavouriteList.route) + } + + } + ) + + NavItem( + iconRes = R.drawable.rave_now_nav_night, + label = stringResource(R.string.dark_mode), + rightContent = { + Switch( + checked = AppState.darkMode, + onCheckedChange = { + AppState.switchTheme() + }, + + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = AppColors.main, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = AppColors.main.copy(alpha = 0.5f), + uncheckedBorderColor = Color.Transparent + ), + modifier = Modifier.scale(0.8f) + ) + } + ) + // divider + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 16.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(AppColors.divider) + ) + } + NavItem( + iconRes = R.drawable.rave_now_nav_about, + label = stringResource(R.string.blocked), + modifier = Modifier.noRippleClickable { + coroutineScope.launch { + drawerState.close() + navController.navigate(NavigationRoute.AboutScreen.route) + } + } + ) + NavItem( + iconRes = R.drawable.rave_now_nav_about, + label = stringResource(R.string.feedback), + modifier = Modifier.noRippleClickable { + coroutineScope.launch { + drawerState.close() + navController.navigate(NavigationRoute.AboutScreen.route) + } + } + ) + NavItem( + iconRes = R.drawable.rave_now_nav_about, + label = stringResource(R.string.about_rave_now), + modifier = Modifier.noRippleClickable { + coroutineScope.launch { + drawerState.close() + navController.navigate(NavigationRoute.AboutScreen.route) + } + } + ) + // divider + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 16.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(AppColors.divider) + ) + } + +// NavItem( +// iconRes = R.drawable.rave_now_nav_switch, +// label = "Switch Account" +// ) +// Spacer(modifier = Modifier.height(16.dp)) + NavItem( + iconRes = R.drawable.rave_now_nav_logout, + label = stringResource(R.string.logout), + modifier = Modifier.noRippleClickable { + coroutineScope.launch { + drawerState.close() + // 只有非游客用户才需要取消注册推送设备 + if (!AppStore.isGuest) { + Messaging.unregisterDevice(context) + } + AppStore.apply { + token = null + rememberMe = false + isGuest = false // 清除游客状态 + saveData() + } + // 删除推送渠道 + + navController.navigate(NavigationRoute.Login.route) { + popUpTo(NavigationRoute.Login.route) { + inclusive = true + } + } + AppState.ReloadAppState(context) + } + } + ) + + } + } + } + ) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Scaffold( + bottomBar = { + NavigationBar( + modifier = Modifier.height(58.dp + navigationBarHeight), + containerColor = AppColors.tabUnselectedBackground + ) { + item.forEachIndexed { idx, it -> + val isSelected = model.tabIndex == idx + val selectedColor = Color(0xFF7C45ED) + + val iconTint by animateColorAsState( + targetValue = if (isSelected) AppColors.brandColorsColor else AppColors.text, + animationSpec = tween(durationMillis = 250), label = "" + ) + + Box( + modifier = Modifier + .weight(1f) + .padding(top = 2.dp) + .noRippleClickable { + if (it.route === NavigationItem.Add.route) { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CREATE_POST)) { + navController.navigate(NavigationRoute.Login.route) + return@noRippleClickable + } + // 显示创建底部弹窗 + model.showCreateBottomSheet = true + return@noRippleClickable + } + + // 检查消息tab的游客模式 + if (it.route === NavigationItem.Notification.route) { + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.VIEW_MESSAGES)) { + navController.navigate(NavigationRoute.Login.route) + return@noRippleClickable + } + } + + // 检查我的tab的游客模式 + if (it.route === NavigationItem.Profile.route) { + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.VIEW_PROFILE)) { + navController.navigate(NavigationRoute.Login.route) + return@noRippleClickable + } + } + + coroutineScope.launch { + pagerState.scrollToPage(idx) + } + model.tabIndex = idx + }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + if (it.route == NavigationItem.Add.route) { + // Add按钮:只显示大图标 + Image( + painter = painterResource(if (isSelected) it.selectedIcon() else it.icon()), + contentDescription = it.label(), + modifier = Modifier.size(32.dp), + colorFilter = if (!isSelected) ColorFilter.tint(AppColors.text) else null + ) + } else { + // 其他按钮:图标+文字 + Box( + modifier = Modifier + .width(48.dp) + .height(32.dp) + .background( + color = if (isSelected) selectedColor.copy(alpha = 0.15f) else Color.Transparent, + shape = RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(if (isSelected) it.selectedIcon() else it.icon()), + contentDescription = it.label(), + modifier = Modifier.size(24.dp), + colorFilter = if (!isSelected) ColorFilter.tint(AppColors.text) else null + ) + } + + // 文字标签,可控制间距 + Spacer(modifier = Modifier.height(1.dp)) + + Text( + text = it.label(), + fontSize = 10.sp, + color = if (isSelected) selectedColor else AppColors.text, + fontWeight = if (isSelected) FontWeight.W600 else FontWeight.Normal + ) + } + } + } + + } + } + } + ) { innerPadding -> + innerPadding + HorizontalPager( + state = pagerState, + modifier = Modifier + .background(AppColors.background) + .padding(0.dp), + beyondBoundsPageCount = 4, + userScrollEnabled = false + ) { page -> + when (page) { + 0 -> Agent() + 1 -> Home() + 2 -> Add() + 3 -> Notifications() + 4 -> Profile() + } + } + } + } + } + + // 创建底部弹窗 + if (model.showCreateBottomSheet) { + CreateBottomSheet( + sheetState = bottomSheetState, + onDismiss = { + // 使用协程来优雅地关闭弹窗 + coroutineScope.launch { + bottomSheetState.hide() + model.showCreateBottomSheet = false + } + }, + onAiClick = { + // 使用协程来优雅地关闭弹窗并导航 + coroutineScope.launch { + bottomSheetState.hide() // 触发关闭动画 + model.showCreateBottomSheet = false + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CREATE_AGENT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + navController.navigate(NavigationRoute.AddAgent.route) + } + } + }, + onGroupChatClick = { + // 使用协程来优雅地关闭弹窗并导航 + coroutineScope.launch { + bottomSheetState.hide() // 触发关闭动画 + model.showCreateBottomSheet = false + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.JOIN_GROUP_CHAT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + navController.navigate(NavigationRoute.CreateGroupChat.route) + } + } + }, + onMomentClick = { + // 使用协程来优雅地关闭弹窗并导航 + coroutineScope.launch { + bottomSheetState.hide() // 触发关闭动画 + model.showCreateBottomSheet = false + // 导航到动态创建页面 + NewPostViewModel.asNewPost() + navController.navigate(NavigationRoute.NewPost.route) + } + } + ) + } + } + +} + +@Composable +fun Home() { + val systemUiController = rememberSystemUiController() + val context = LocalContext.current + + LaunchedEffect(AppState.darkMode) { + systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode) + } + + // 注意:避免在离开 Home 时清理动态资源,防止返回详情后触发重新加载 + + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + MomentsList() + } +} + + +@Composable +fun Street() { + val systemUiController = rememberSystemUiController() + val context = LocalContext.current + + LaunchedEffect(AppState.darkMode) { + systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode) + } + + // 页面退出时清理搜索相关资源 + DisposableEffect(Unit) { + onDispose { + ResourceCleanupManager.cleanupPageResources("search") + } + } + + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + StreetPage() + } +} + +@Composable +fun Add() { + val systemUiController = rememberSystemUiController() + LaunchedEffect(AppState.darkMode) { + systemUiController.setStatusBarColor(Color.Black, darkIcons = !AppState.darkMode) + } + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + AddPage() + } +} + +@Composable +fun Video() { + val systemUiController = rememberSystemUiController() + LaunchedEffect(AppState.darkMode) { + systemUiController.setStatusBarColor(Color.Black, darkIcons = false) + } + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ShortVideo() + } +} + + +@Composable +fun Profile() { + val systemUiController = rememberSystemUiController() + val context = LocalContext.current + + LaunchedEffect(AppState.darkMode) { + systemUiController.setStatusBarColor(Color.Transparent, !AppState.darkMode) + } + + // 页面退出时清理个人资料相关资源 + DisposableEffect(Unit) { + onDispose { + ResourceCleanupManager.cleanupPageResources("profile") + } + } + + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + ProfileWrap() + } +} + +@Composable +fun Notifications() { + val systemUiController = rememberSystemUiController() + val context = LocalContext.current + + LaunchedEffect(AppState.darkMode) { + systemUiController.setStatusBarColor(Color.Transparent, !AppState.darkMode) + } + + // 页面退出时清理消息相关资源 + DisposableEffect(Unit) { + onDispose { + ResourceCleanupManager.cleanupPageResources("message") + } + } + + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + NotificationsScreen() + } +} + +@Composable +fun NavItem( + iconRes: Int, + label: String, + modifier: Modifier = Modifier, + rightContent: @Composable (() -> Unit)? = null +) { + val appColors = LocalAppTheme.current + Row( + modifier = modifier + .fillMaxWidth() + .padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier.size(24.dp), + colorFilter = ColorFilter.tint(appColors.text) + ) + Text( + text = label, + modifier = Modifier + .padding(start = 8.dp) + .weight(1f), + color = appColors.text, + ) + if (rightContent != null) { + rightContent() + } else { + Image( + painter = painterResource(id = R.drawable.rave_now_nav_right), + contentDescription = null, + modifier = Modifier.size(24.dp), + colorFilter = ColorFilter.tint(appColors.secondaryText) + ) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/IndexViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/IndexViewModel.kt new file mode 100644 index 0000000..cc8e5fc --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/IndexViewModel.kt @@ -0,0 +1,20 @@ +package com.aiosman.ravenow.ui.index + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel + +object IndexViewModel:ViewModel() { + var tabIndex by mutableStateOf(0) + + var openDrawer by mutableStateOf(false) + + var showCreateBottomSheet by mutableStateOf(false) + + fun ResetModel(){ + tabIndex = 0 + showCreateBottomSheet = false + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/NavigationItem.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/NavigationItem.kt new file mode 100644 index 0000000..020bfc1 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/NavigationItem.kt @@ -0,0 +1,53 @@ +package com.aiosman.ravenow.ui.index + +import android.graphics.Color +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import com.aiosman.ravenow.R + +sealed class NavigationItem( + val route: String, + val icon: @Composable () -> Int, + val selectedIcon: @Composable () -> Int = icon, + val label: @Composable () -> String +) { + + data object Home : NavigationItem("Home", + icon = { R.mipmap.bars_x_buttons_home_n_copy }, + selectedIcon = { R.mipmap.bars_x_buttons_home_n_copy_2 }, + label = { stringResource(R.string.main_ai) } + ) + + data object Ai : NavigationItem("Ai", + icon = { R.mipmap.bars_x_buttons_discover_bold}, + selectedIcon = { R.mipmap.bars_x_buttons_discover_fill }, + label = { stringResource(R.string.index_dynamic) } + ) +// data object Ai : NavigationItem("Ai", +// icon = { R.drawable.rider_pro_nav_ai }, +// selectedIcon = { R.mipmap.rider_pro_nav_ai_hl }, +// label = { stringResource(R.string.main_ai) } +// ) + + data object Add : NavigationItem("Add", + icon = { R.drawable.ic_nav_add }, + selectedIcon = { R.drawable.ic_nav_add }, + label = { stringResource(R.string.main_home) } + ) + + data object Notification : NavigationItem("Notification", + icon = { R.drawable.rider_pro_nav_notification }, + selectedIcon = { R.mipmap.bars_x_buttons_chat_s }, + label = { stringResource(R.string.main_message) } + ) + + data object Profile : NavigationItem("Profile", + icon = { R.drawable.rider_pro_nav_profile }, + selectedIcon = { R.mipmap.bars_x_buttons_user_s }, + label = { stringResource(R.string.main_profile) } + ) + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/add/AddPage.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/add/AddPage.kt new file mode 100644 index 0000000..2d1d690 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/add/AddPage.kt @@ -0,0 +1,63 @@ +package com.aiosman.ravenow.ui.index.tabs.add + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.ui.post.NewPostViewModel +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.composables.rememberDebouncedNavigation +import com.aiosman.ravenow.ui.composables.rememberDebouncedState +import com.aiosman.ravenow.ui.composables.rememberDebouncer + +@Composable +fun AddPage(){ + val navController = LocalNavController.current + val debouncer = rememberDebouncedNavigation() + Column(modifier = Modifier + .fillMaxSize() + .background(Color.Black)) { + AddBtn(icon = R.drawable.rider_pro_icon_rider_share, text = "Rave NowShare") { + NewPostViewModel.asNewPost() + debouncer { + navController.navigate("NewPost") + } + } +// AddBtn(icon = R.drawable.rider_pro_location_create, text = "Location Create") + } +} + +@Composable +fun AddBtn(@DrawableRes icon: Int, text: String,onClick: (() -> Unit)? = {}){ + val (isDebouncing, startDebounce, resetDebounce) = rememberDebouncedState() + Row (modifier = Modifier + .fillMaxWidth().padding(24.dp).clickable { + if (!isDebouncing) { + startDebounce() + onClick?.invoke() + } + }, + verticalAlignment = Alignment.CenterVertically){ + Image( + modifier = Modifier.size(40.dp), + painter = painterResource(id = icon), contentDescription = null) + Text(modifier = Modifier.padding(start = 24.dp),text = text, color = Color.White,fontSize = 22.sp, style = TextStyle(fontWeight = FontWeight.Bold)) + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/Agent.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/Agent.kt new file mode 100644 index 0000000..90f9c62 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/Agent.kt @@ -0,0 +1,991 @@ +package com.aiosman.ravenow.ui.index.tabs.ai + +import android.annotation.SuppressLint +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import com.aiosman.ravenow.AppStore +import com.aiosman.ravenow.GuestLoginCheckOut +import com.aiosman.ravenow.GuestLoginCheckOutScene +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.index.tabs.ai.tabs.mine.MineAgent +import com.aiosman.ravenow.ui.index.tabs.ai.tabs.hot.HotAgent +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.composables.TabItem +import com.aiosman.ravenow.ui.composables.TabSpacer +import com.aiosman.ravenow.ui.index.tabs.moment.CustomTabItem +import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.AgentItem +import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.ExploreViewModel +import com.aiosman.ravenow.utils.DebounceUtils +import com.aiosman.ravenow.utils.ResourceCleanupManager +import kotlinx.coroutines.launch +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.foundation.lazy.grid.items as gridItems +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.ui.draw.alpha + +// 检测是否接近列表底部的扩展函数 +fun LazyListState.isScrolledToEnd(buffer: Int = 3): Boolean { + val layoutInfo = this.layoutInfo + val totalItemsCount = layoutInfo.totalItemsCount + val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + + return lastVisibleItemIndex >= (totalItemsCount - buffer) +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun Agent() { + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + val navigationBarPaddings = + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 48.dp + val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues() + // 游客模式下只显示热门Agent,正常用户显示我的Agent和热门Agent + val tabCount = if (AppStore.isGuest) 1 else 2 + var pagerState = rememberPagerState { tabCount } + var scope = rememberCoroutineScope() + + val viewModel: AgentViewModel = viewModel() + + // 确保推荐Agent数据已加载 + LaunchedEffect(Unit) { + viewModel.ensureDataLoaded() + } + + // 防抖状态 + var lastClickTime by remember { mutableStateOf(0L) } + + // 页面退出时只清理必要的资源,不清理推荐Agent数据 + DisposableEffect(Unit) { + onDispose { + // 只清理子页面的资源,保留推荐Agent数据 + // ResourceCleanupManager.cleanupPageResources("ai") + } + } + + val agentItems = viewModel.agentItems + var selectedTabIndex by remember { mutableStateOf(0) } + + // 无限滚动状态 + val listState = rememberLazyListState() + + // 创建一个可观察的滚动到底部状态 + val isScrolledToEnd by remember { + derivedStateOf { + listState.isScrolledToEnd() + } + } + + // 检测滚动到底部并加载更多数据 + LaunchedEffect(isScrolledToEnd) { + if (isScrolledToEnd && !viewModel.isLoadingMore && agentItems.isNotEmpty() && viewModel.hasMoreData) { + viewModel.loadMoreAgents() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Image( + painter = painterResource(id = R.drawable.home_logo), + contentDescription = "Rave AI Logo", + modifier = Modifier + .height(44.dp) + .padding(top =9.dp,bottom=9.dp) + .wrapContentSize(), +// colorFilter = ColorFilter.tint(AppColors.text) + ) + }, + actions = { + Image( + painter = painterResource(id = R.drawable.rider_pro_nav_search), + contentDescription = "search", + modifier = Modifier + .size(44.dp) + .padding(top = 9.dp,bottom=9.dp) + .noRippleClickable { + navController.navigate(NavigationRoute.Search.route) + }, + colorFilter = ColorFilter.tint(AppColors.text) + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = AppColors.background + ), + windowInsets = WindowInsets(0, 0, 0, 0), + modifier = Modifier + .height(44.dp + statusBarPaddingValues.calculateTopPadding()) + .padding(top = statusBarPaddingValues.calculateTopPadding()) + ) + }, + containerColor = AppColors.background, + contentWindowInsets = WindowInsets(0, 0, 0, 0) + ) { paddingValues -> + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding( + bottom = navigationBarPaddings, + start = 8.dp, + end = 8.dp + ) + ) { + + // 类别标签页 - 吸顶 + stickyHeader(key = "category_tabs") { + Column( + modifier = Modifier + .fillMaxWidth() + .background(AppColors.background) + .padding(top = 4.dp, bottom = 8.dp) + ) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + item { + CustomTabItem( + text = stringResource(R.string.agent_recommend), + isSelected = selectedTabIndex == 0, + onClick = { + selectedTabIndex = 0 + viewModel.loadAllAgents() + } + ) + } + + item { + TabSpacer() + } + + // 动态添加分类标签 + viewModel.categories.forEachIndexed { index, category -> + item { + CustomTabItem( + text = category.name, + isSelected = selectedTabIndex == index + 1, + onClick = { + selectedTabIndex = index + 1 + viewModel.loadAgentsByCategory(category.id) + } + ) + } + + item { + TabSpacer() + } + } + } + } + } + // 推荐内容区域 + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + when { + selectedTabIndex == 0 -> { + AgentViewPagerSection(agentItems = viewModel.agentItems.take(15), viewModel) + } + selectedTabIndex in 1..viewModel.categories.size -> { + AgentViewPagerSection(agentItems = viewModel.agentItems.take(15), viewModel) + } + else -> { + val shuffledAgents = viewModel.agentItems.shuffled().take(15) + AgentViewPagerSection(agentItems = shuffledAgents, viewModel) + } + } + } + } + + // 热门聊天室 + stickyHeader(key = "hot_rooms_header") { + Row( + modifier = Modifier + .fillMaxWidth() + .background(AppColors.background) + .padding(top = 8.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.mipmap.rider_pro_hot_room), + contentDescription = "chat room", + modifier = Modifier.size(28.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + androidx.compose.material3.Text( + text = stringResource(R.string.hot_rooms), + fontSize = 16.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.W900, + color = AppColors.text + ) + } + } + + // 热门聊天室网格 + items(viewModel.chatRooms.chunked(2)) { rowRooms -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + rowRooms.forEach { chatRoom -> + ChatRoomCard( + chatRoom = chatRoom, + navController = LocalNavController.current, + modifier = Modifier.weight(1f) + ) + } + if (rowRooms.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + + item { Spacer(modifier = Modifier.height(20.dp)) } + + // "发现更多" 标题 - 吸顶 + stickyHeader(key = "discover_more") { + Row( + modifier = Modifier + .fillMaxWidth() + .background(AppColors.background) + .padding(top = 8.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.Bottom + ) { + Image( + painter = painterResource(R.mipmap.bars_x_buttons_home_n_copy_2), + contentDescription = "agent", + modifier = Modifier.size(28.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + androidx.compose.material3.Text( + text = stringResource(R.string.agent_find), + fontSize = 16.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.W900, + color = AppColors.text + ) + } + } + + // Agent网格 - 使用行式布局 + items( + items = agentItems.chunked(2), + key = { row -> row.firstOrNull()?.openId ?: "" } + ) { rowItems -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + rowItems.forEach { agentItem -> + Box( + modifier = Modifier.weight(1f) + ) { + AgentCardSquare( + agentItem = agentItem, + viewModel = viewModel, + navController = LocalNavController.current + ) + } + } + // 如果这一行只有一个item,添加一个空的占位符 + if (rowItems.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + + // 加载更多指示器 + if (viewModel.isLoadingMore) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + horizontalArrangement = Arrangement.Center + ) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = AppColors.text, + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(12.dp)) + androidx.compose.material3.Text( + text = "加载中...", + color = AppColors.secondaryText, + fontSize = 14.sp + ) + } + } + } + } + } +} + +@Composable +fun AgentGridLayout( + agentItems: List, + viewModel: AgentViewModel, + navController: NavHostController +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + // 将agentItems按两列分组 + agentItems.chunked(2).forEachIndexed { rowIndex, rowItems -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + top = if (rowIndex == 0) 30.dp else 20.dp, // 第一行添加更多顶部间距 + bottom = 20.dp + ), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 第一列 + Box( + modifier = Modifier.weight(1f) + ) { + AgentCardSquare( + agentItem = rowItems[0], + viewModel = viewModel, + navController = navController + ) + } + + // 第二列(如果存在) + if (rowItems.size > 1) { + Box( + modifier = Modifier.weight(1f) + ) { + AgentCardSquare( + agentItem = rowItems[1], + viewModel = viewModel, + navController = navController + ) + } + } else { + // 如果只有一列,添加空白占位 + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@SuppressLint("SuspiciousIndentation") +@Composable +fun AgentCardSquare( + agentItem: AgentItem, + viewModel: AgentViewModel, + navController: NavHostController +) { + val AppColors = LocalAppTheme.current + val cardHeight = 180.dp + val avatarSize = cardHeight / 3 // 头像大小为方块高度的三分之一 + + // 防抖状态 + var lastClickTime by remember { mutableStateOf(0L) } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(cardHeight) + .background(AppColors.secondaryBackground, RoundedCornerShape(12.dp)) + .clickable { + if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) { + viewModel.goToProfile(agentItem.openId, navController) + }) { + lastClickTime = System.currentTimeMillis() + } + }, + contentAlignment = Alignment.TopCenter + ) { + Box( + modifier = Modifier + .offset(y = 4.dp) + .size(avatarSize) + .background(AppColors.background, RoundedCornerShape(avatarSize / 2)) + .clip(RoundedCornerShape(avatarSize / 2)), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.mipmap.group_copy), + contentDescription = "默认头像", + modifier = Modifier.size(avatarSize), + ) + if (agentItem.avatar.isNotEmpty()) { + CustomAsyncImage( + imageUrl = agentItem.avatar, + contentDescription = "Agent头像", + modifier = Modifier + .size(avatarSize) + .clip(RoundedCornerShape(avatarSize / 2)), + contentScale = androidx.compose.ui.layout.ContentScale.Crop + ) + } + } + + // 内容区域(名称和描述) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp + avatarSize + 8.dp, start = 8.dp, end = 8.dp, bottom = 48.dp), // 为底部聊天按钮留出空间 + horizontalAlignment = Alignment.CenterHorizontally + ) { + androidx.compose.material3.Text( + text = agentItem.title, + fontSize = 14.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.W600, + color = AppColors.text, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(8.dp)) + + androidx.compose.material3.Text( + text = agentItem.desc, + fontSize = 12.sp, + color = AppColors.secondaryText, + maxLines = 2, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + } + + // 聊天按钮 + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 12.dp) + .width(60.dp) + .height(32.dp) + .background( + color = AppColors.text, + shape = RoundedCornerShape( + topStart = 14.dp, + topEnd = 14.dp, + bottomStart = 0.dp, + bottomEnd = 14.dp + ) + ) + .clickable { + if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + viewModel.createSingleChat(agentItem.openId) + viewModel.goToChatAi( + agentItem.openId, + navController = navController + ) + } + }) { + lastClickTime = System.currentTimeMillis() + } + }, + contentAlignment = Alignment.Center + ) { + androidx.compose.material3.Text( + text = stringResource(R.string.chat), + fontSize = 15.sp, + color = AppColors.background, + fontWeight = androidx.compose.ui.text.font.FontWeight.W500 + ) + } + } +} +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AgentViewPagerSection(agentItems: List,viewModel: AgentViewModel) { + val AppColors = LocalAppTheme.current + + // 每页显示5个agent + val itemsPerPage = 5 + val totalPages = (agentItems.size + itemsPerPage - 1) / itemsPerPage + + if (totalPages > 0) { + val pagerState = rememberPagerState(pageCount = { totalPages }) + + Column { + // Agent内容 + Box( + modifier = Modifier + .height(310.dp) + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp), + pageSpacing = 0.dp + ) { page -> + // 计算当前页面的偏移量 + val pageOffset = ( + (pagerState.currentPage - page) + pagerState + .currentPageOffsetFraction + ).coerceIn(-1f, 1f) + + // 根据偏移量计算缩放比例 + val scale = 1f - (0.1f * kotlin.math.abs(pageOffset)) + + AgentPage( + viewModel = viewModel, + agentItems = agentItems.drop(page * itemsPerPage).take(itemsPerPage), + page = page, + modifier = Modifier + .height(310.dp) + .graphicsLayer { + scaleX = scale + scaleY = scale + }, + navController = LocalNavController.current, + ) + } + } + + // 指示器 + Row( + modifier = Modifier + .fillMaxWidth() + .height(30.dp) + .padding(top = 12.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center + ) { + repeat(totalPages) { index -> + Box( + modifier = Modifier + .padding(horizontal = 4.dp) + .size(3.dp) + .background( + color = if (pagerState.currentPage == index) AppColors.text else AppColors.secondaryText.copy( + alpha = 0.3f + ), + shape = androidx.compose.foundation.shape.CircleShape + ) + ) + } + } + } + } +} + +@Composable +fun AgentPage(viewModel: AgentViewModel,agentItems: List, page: Int, modifier: Modifier = Modifier,navController: NavHostController) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 0.dp) + ) { + // 显示3个agent + agentItems.forEachIndexed { index, agentItem -> + AgentCard2(agentItem = agentItem, viewModel = viewModel, navController = LocalNavController.current) + if (index < agentItems.size - 1) { + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} + +@SuppressLint("SuspiciousIndentation") +@Composable +fun AgentCard2(viewModel: AgentViewModel,agentItem: AgentItem,navController: NavHostController) { + val AppColors = LocalAppTheme.current + + // 防抖状态 + var lastClickTime by remember { mutableStateOf(0L) } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 左侧头像 + Box( + modifier = Modifier + .size(48.dp) + .background(Color(0x00F5F5F5), RoundedCornerShape(24.dp)) + .clickable { + if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) { + viewModel.goToProfile(agentItem.openId, navController) + }) { + lastClickTime = System.currentTimeMillis() + } + }, + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.mipmap.group_copy), + contentDescription = "默认头像", + modifier = Modifier.size(48.dp), + ) + + if (agentItem.avatar.isNotEmpty()) { + CustomAsyncImage( + imageUrl = agentItem.avatar, + contentDescription = "Agent头像", + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(24.dp)), + contentScale = androidx.compose.ui.layout.ContentScale.Crop + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + // 中间文字内容 + Column( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) { + // 标题 + androidx.compose.material3.Text( + text = agentItem.title, + fontSize = 14.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.W600, + color = AppColors.text, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 描述 + androidx.compose.material3.Text( + text = agentItem.desc, + fontSize = 12.sp, + color = AppColors.secondaryText, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) + } + + // 右侧聊天按钮 + Box( + modifier = Modifier + .size(width = 60.dp, height = 32.dp) + .background( + color = Color(0X147c7480), + shape = RoundedCornerShape( + topStart = 14.dp, + topEnd = 14.dp, + bottomStart = 0.dp, + bottomEnd = 14.dp + ) + ) + .clickable { + if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + viewModel.createSingleChat(agentItem.openId) + viewModel.goToChatAi( + agentItem.openId, + navController = navController + ) + } + }) { + lastClickTime = System.currentTimeMillis() + } + }, + contentAlignment = Alignment.Center + ) { + androidx.compose.material3.Text( + text = stringResource(R.string.chat), + fontSize = 12.sp, + color = AppColors.text, + fontWeight = androidx.compose.ui.text.font.FontWeight.W500 + ) + } + } +} +@Composable +fun ChatRoomsSection( + chatRooms: List, + navController: NavHostController +) { + val AppColors = LocalAppTheme.current + + Column( + modifier = Modifier.fillMaxWidth() + ) { + // 标题 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.mipmap.rider_pro_hot_room), + contentDescription = "chat room", + modifier = Modifier.size(28.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + androidx.compose.material3.Text( + text = stringResource(R.string.hot_rooms), + fontSize = 16.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.W900, + color = AppColors.text + ) + } + + Column( + modifier = Modifier.fillMaxWidth() + ) { + chatRooms.chunked(2).forEach { rowRooms -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + rowRooms.forEach { chatRoom -> + ChatRoomCard( + chatRoom = chatRoom, + navController = navController, + modifier = Modifier.weight(1f) + ) + } + } + } + } + } +} + +@Composable +fun ChatRoomCard( + chatRoom: ChatRoom, + navController: NavHostController, + modifier: Modifier = Modifier +) { + val AppColors = LocalAppTheme.current + val cardSize = 180.dp + val viewModel: AgentViewModel = viewModel() + val context = LocalContext.current + + // 防抖状态 + var lastClickTime by remember { mutableStateOf(0L) } + + // Loading 对话框 + if (viewModel.isJoiningRoom) { + Dialog( + onDismissRequest = { /* 阻止用户关闭对话框 */ }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) { + Box( + modifier = Modifier + .size(120.dp) + .background( + color = AppColors.background, + shape = RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = AppColors.main + ) + Spacer(modifier = Modifier.height(12.dp)) + androidx.compose.material3.Text( + text = "加入中...", + fontSize = 14.sp, + color = AppColors.text + ) + } + } + } + } + + // 正方形卡片,文字重叠在底部 + Box( + modifier = modifier + .size(cardSize) + .background(AppColors.tabUnselectedBackground, RoundedCornerShape(12.dp)) + .clickable(enabled = !viewModel.isJoiningRoom) { + if (!viewModel.isJoiningRoom && DebounceUtils.simpleDebounceClick(lastClickTime, 500L) { + // 加入群聊房间 + viewModel.joinRoom( + id = chatRoom.id, + name = chatRoom.name, + avatar = chatRoom.avatar, + context = context, + navController = navController, + onSuccess = { + // 成功加入房间 + }, + onError = { errorMsg -> + // 处理错误,可以显示Toast或其他提示 + } + ) + }) { + lastClickTime = System.currentTimeMillis() + } + } + ) { + // 优先显示banner,如果没有banner则显示头像 + val imageUrl = if (chatRoom.banner.isNotEmpty()) chatRoom.banner else chatRoom.avatar + + if (imageUrl.isNotEmpty()) { + CustomAsyncImage( + imageUrl = imageUrl, + contentDescription = if (chatRoom.banner.isNotEmpty()) "房间banner" else "房间头像", + modifier = Modifier + .width(cardSize) + .height(120.dp) + .clip(RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp, + bottomStart = 0.dp, + bottomEnd = 0.dp)), + contentScale = androidx.compose.ui.layout.ContentScale.Crop + ) + } else { + // 默认房间图标 + Image( + painter = painterResource(R.mipmap.rider_pro_agent), + contentDescription = "默认房间图标", + modifier = Modifier.size(cardSize * 0.4f), + colorFilter = ColorFilter.tint(AppColors.secondaryText) + ) + } + + // 房间名称,重叠在底部 + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(bottom = 32.dp, start = 10.dp, end = 10.dp) + .clip(RoundedCornerShape(12.dp)) + + ) { + androidx.compose.material3.Text( + text = chatRoom.name, + fontSize = 14.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.W900, + color = AppColors.text, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + textAlign = androidx.compose.ui.text.style.TextAlign.Left + ) + } + // 显示人数 + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(bottom = 10.dp, start = 10.dp, end = 10.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(R.drawable.rider_pro_nav_profile), + contentDescription = "chat", + modifier = Modifier.size(16.dp), + colorFilter = ColorFilter.tint(AppColors.secondaryText) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${chatRoom.memberCount} ${stringResource(R.string.chatting_now)}", + fontSize = 12.sp, + modifier = Modifier.alpha(0.6f), + color = AppColors.text, + fontWeight = androidx.compose.ui.text.font.FontWeight.W500 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/AgentViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/AgentViewModel.kt new file mode 100644 index 0000000..9af9089 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/AgentViewModel.kt @@ -0,0 +1,459 @@ +package com.aiosman.ravenow.ui.index.tabs.ai + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController +import com.aiosman.ravenow.data.Agent +import com.aiosman.ravenow.data.ListContainer +import com.aiosman.ravenow.data.api.ApiClient +import com.aiosman.ravenow.data.api.CategoryTemplate +import com.aiosman.ravenow.data.api.RaveNowAPI +import com.aiosman.ravenow.data.api.SingleChatRequestBody +import com.aiosman.ravenow.ui.index.tabs.ai.tabs.mine.MineAgentViewModel.createGroup2ChatAi +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.index.tabs.message.MessageListViewModel.userService +import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.AgentItem +import kotlinx.coroutines.launch +import android.util.Log +import com.aiosman.ravenow.data.Room +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.AppStore +import com.aiosman.ravenow.ConstVars +import com.aiosman.ravenow.data.api.CreateGroupChatRequestBody +import com.aiosman.ravenow.ui.index.tabs.message.tab.GroupChatListViewModel.createGroupChat +import com.aiosman.ravenow.ui.navigateToGroupChat +import com.aiosman.ravenow.data.api.ApiErrorResponse +import com.google.gson.Gson +import android.content.Context +import android.widget.Toast +import kotlinx.coroutines.launch +import com.aiosman.ravenow.data.api.JoinGroupChatRequestBody + + +/** + * 缓存数据结构,用于存储每个分类的Agent列表 + */ +data class AgentCacheData( + val items: List, + val currentPage: Int, + val hasMoreData: Boolean +) +data class ChatRoom( + val id: Int, + val name: String, + val avatar: String = "", + val banner: String = "", + val memberCount: Int +) + +object AgentViewModel: ViewModel() { + + private val apiClient: RaveNowAPI = ApiClient.api + + var agentItems by mutableStateOf>(emptyList()) + private set + + var categories by mutableStateOf>(emptyList()) + private set + + var errorMessage by mutableStateOf(null) + private set + + var chatRooms by mutableStateOf>(emptyList()) + private set + + var rooms by mutableStateOf>(emptyList()) + private set + + var isRefreshing by mutableStateOf(false) + private set + + var isLoading by mutableStateOf(false) + private set + + // 分页相关状态 + var isLoadingMore by mutableStateOf(false) + private set + + var currentPage by mutableStateOf(1) + private set + + var hasMoreData by mutableStateOf(true) + private set + + var isJoiningRoom by mutableStateOf(false) + private set + + private val pageSize = 20 + private var currentCategoryId: Int? = null + + // 缓存:使用分类ID作为key,null表示推荐列表 + private val agentCache = mutableMapOf() + + init { + loadAgentData() + loadCategories() + loadChatRooms() + } + + private fun loadAgentData(categoryId: Int? = null, page: Int = 1, isLoadMore: Boolean = false, forceRefresh: Boolean = false) { + viewModelScope.launch { + // 如果不是强制刷新且不是加载更多,检查缓存 + if (!forceRefresh && !isLoadMore) { + val cached = agentCache[categoryId] + if (cached != null && cached.items.isNotEmpty()) { + // 使用缓存数据 + agentItems = cached.items + currentPage = cached.currentPage + hasMoreData = cached.hasMoreData + currentCategoryId = categoryId + println("使用缓存数据,分类ID: $categoryId, 数据数量: ${cached.items.size}") + return@launch + } + } + + if (isLoadMore) { + isLoadingMore = true + } else { + isLoading = true + // 重置分页状态 + currentPage = 1 + hasMoreData = true + currentCategoryId = categoryId + } + + errorMessage = null + try { + val response = if (categoryId != null) { + // 根据分类ID获取智能体 + apiClient.getAgent( + page = page, + pageSize = pageSize, + withWorkflow = 1, + categoryIds = listOf(categoryId), + random = 1 + ) + } else { + // 获取推荐智能体,使用random=1 + apiClient.getAgent( + page = page, + pageSize = pageSize, + withWorkflow = 1, + categoryIds = null, + random = 1 + ) + } + + if (response.isSuccessful) { + val responseData = response.body()?.data + val agents = responseData?.list ?: emptyList() + val newAgentItems = agents.map { agent -> + AgentItem.fromAgent(agent) + } + + if (isLoadMore) { + // 加载更多:追加到现有列表 + agentItems = agentItems + newAgentItems + currentPage = page + } else { + // 首次加载或刷新:替换整个列表 + agentItems = newAgentItems + currentPage = 1 + } + + // 检查是否还有更多数据 + hasMoreData = agents.size >= pageSize + + // 更新缓存 + agentCache[categoryId] = AgentCacheData( + items = agentItems, + currentPage = currentPage, + hasMoreData = hasMoreData + ) + println("更新缓存,分类ID: $categoryId, 数据数量: ${agentItems.size}") + + } else { + errorMessage = "获取Agent数据失败: ${response.code()}" + } + } catch (e: Exception) { + errorMessage = "网络请求失败: ${e.message}" + } finally { + if (isLoadMore) { + isLoadingMore = false + } else { + isLoading = false + } + } + } + } + + private fun loadChatRooms() { + viewModelScope.launch { + try { + val response = apiClient.getRooms( + page = 1, + pageSize = 20, + isRecommended = 1, + random = 1 + ) + if (response.isSuccessful) { + val allRooms = response.body()?.list ?: emptyList() + val targetCount = (allRooms.size / 2) * 2 + rooms = allRooms.take(targetCount) + + // 转换为ChatRoom格式用于兼容现有UI + chatRooms = rooms.map { room -> + ChatRoom( + id = room.id, + name = room.name, + avatar = room.avatar, + banner = ConstVars.BASE_SERVER + "/api/v1/outside/" + room.recommendBanner + "?token=${AppStore.token}", + memberCount = room.userCount + ) + } + } else { + + } + } catch (e: Exception) { + // 如果网络请求失败,使用默认数据 + + } + } + } + + private fun loadCategories() { + viewModelScope.launch { + // 如果分类已经加载,不重复请求 + if (categories.isNotEmpty()) { + println("使用已缓存的分类数据,数量: ${categories.size}") + return@launch + } + + try { + // 获取完整的语言标记(如 "zh-CN") + val sysLang = com.aiosman.ravenow.utils.Utils.getPreferredLanguageTag() + val response = apiClient.getCategories( + page = 1, + pageSize = 100, + isActive = true, + withChildren = false, + withParent = false, + withCount = true, + hideEmpty = true, + lang = sysLang + ) + println("分类数据请求完成,响应成功: ${response.isSuccessful}, 语言标记: $sysLang") + if (response.isSuccessful) { + val categoryList = response.body()?.list ?: emptyList() + println("获取到 ${categoryList.size} 个分类") + // 使用当前语言获取翻译后的分类名称 + categories = categoryList.map { category -> + CategoryItem.fromCategoryTemplate(category, sysLang) + } + println("成功处理并映射了 ${categories.size} 个分类") + } else { + errorMessage = "获取分类数据失败: ${response.code()}" + println("获取分类数据失败: ${response.code()}") + } + } catch (e: Exception) { + errorMessage = "获取分类数据失败: ${e.message}" + println("获取分类数据异常: ${e.message}") + e.printStackTrace() + } + } + } + + fun loadAgentsByCategory(categoryId: Int) { + loadAgentData(categoryId) + } + + fun loadAllAgents() { + loadAgentData() + } + + /** + * 加载更多Agent数据 + */ + fun loadMoreAgents() { + // 检查是否正在加载或没有更多数据 + if (isLoadingMore || !hasMoreData) { + return + } + + val nextPage = currentPage + 1 + loadAgentData( + categoryId = currentCategoryId, + page = nextPage, + isLoadMore = true + ) + } + fun createSingleChat( + openId: String, + ) { + viewModelScope.launch { + val response = ApiClient.api.createSingleChat(SingleChatRequestBody(agentOpenId = openId)) + } + + } + fun goToChatAi( + openId: String, + navController: NavHostController + ) { + viewModelScope.launch { + val profile = userService.getUserProfileByOpenId(openId) + createGroup2ChatAi(profile.trtcUserId,"ai_group",navController,profile.id) + } + } + + fun goToProfile( + openId: String, + navController: NavHostController + ) { + viewModelScope.launch { + try { + val profile = userService.getUserProfileByOpenId(openId) + // 从Agent列表点击进去的一定是智能体,直接传递isAiAccount = true + navController.navigate( + NavigationRoute.AccountProfile.route + .replace("{id}", profile.id.toString()) + .replace("{isAiAccount}", "true") + ) + } catch (e: Exception) { + // swallow error to avoid crash on navigation attempt failures + } + } + } + + /** + * 刷新当前分类的Agent数据(强制刷新,清除缓存) + */ + fun refreshAgentData() { + // 清除当前分类的缓存 + agentCache.remove(currentCategoryId) + loadAgentData(categoryId = currentCategoryId, forceRefresh = true) + } + + /** + * 检查数据是否为空,如果为空则重新加载 + */ + fun ensureDataLoaded() { + if (agentItems.isEmpty() && !isLoading) { + loadAgentData() + } + } + + /** + * 加入房间 + */ + fun joinRoom( + id: Int, + name: String, + avatar: String, + context: Context, + navController: NavHostController, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + // 防止重复点击 + if (isJoiningRoom) return + + viewModelScope.launch { + try { + isJoiningRoom = true + val response = apiClient.joinRoom(JoinGroupChatRequestBody(roomId = id)) + if (response.isSuccessful) { + // 打开房间 + val openRoomResponse = apiClient.createGroupChatAi( + roomId = id + ) + + if (openRoomResponse.isSuccessful){ + val respData = openRoomResponse.body() + respData?.let { + viewModelScope.launch { + try { + // 群聊直接使用群ID进行导航 + navController.navigateToGroupChat( + id = respData.data.trtcRoomId, + name = name, + avatar = avatar + ) + } catch (e: Exception) { + onError("加入房间失败") + e.printStackTrace() + } + } + } + } + onSuccess() + + } else { + // 处理错误响应 + try { + val errorBody = response.errorBody()?.string() + if (errorBody != null) { + val gson = Gson() + val errorResponse = gson.fromJson(errorBody, ApiErrorResponse::class.java) + + // 在主线程显示 Toast + Toast.makeText(context, errorResponse.error, Toast.LENGTH_LONG).show() + onError(errorResponse.error) + } else { + Toast.makeText(context, "加入房间失败", Toast.LENGTH_SHORT).show() + onError("加入房间失败") + } + } catch (parseException: Exception) { + // 如果解析错误响应失败,显示默认错误信息 + Toast.makeText(context, "加入房间失败", Toast.LENGTH_SHORT).show() + onError("加入房间失败") + } + } + } catch (e: Exception) { + Toast.makeText(context, "网络请求失败:${e.message}", Toast.LENGTH_SHORT).show() + onError("网络请求失败:${e.message}") + } finally { + isJoiningRoom = false + } + } + } + + /** + * 重置ViewModel状态,用于登出或切换账号时清理数据 + */ + fun ResetModel() { + agentItems = emptyList() + categories = emptyList() + errorMessage = null + isRefreshing = false + isLoading = false + isLoadingMore = false + currentPage = 1 + hasMoreData = true + currentCategoryId = null + // 清空缓存 + agentCache.clear() + } + +} +data class CategoryItem( + val id: Int, + val name: String, + val description: String, + val avatar: String, + val promptCount: Int? +) { + companion object { + fun fromCategoryTemplate(template: CategoryTemplate, lang: String): CategoryItem { + return CategoryItem( + id = template.id, + name = template.getLocalizedName(lang), + description = template.getLocalizedDescription(lang), + avatar = "${ApiClient.BASE_API_URL}${template.avatar}", + promptCount = template.promptCount + ) + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/BaseAgentModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/BaseAgentModel.kt new file mode 100644 index 0000000..655ac72 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/BaseAgentModel.kt @@ -0,0 +1,65 @@ +package com.aiosman.ravenow.ui.index.tabs.ai + +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.AgentService +import com.aiosman.ravenow.data.MomentService +import com.aiosman.ravenow.data.UserServiceImpl +import com.aiosman.ravenow.entity.AgentEntity +import com.aiosman.ravenow.entity.AgentLoader +import com.aiosman.ravenow.entity.AgentLoaderExtraArgs +import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus + +open class BaseAgentModel :ViewModel(){ + // private val agentService: AgentService = AgentServiceImpl() + + val agentLoader = AgentLoader().apply { + onListChanged = { + agentList = it + } + } + var refreshing by mutableStateOf(false) + var isFirstLoad = true + var agentList by mutableStateOf>(listOf()) + + open fun extraArgs(): AgentLoaderExtraArgs { + return AgentLoaderExtraArgs() + } + + fun refreshPager(pullRefresh: Boolean = false) { + if (!isFirstLoad && !pullRefresh) { + return + } + isFirstLoad = false + agentLoader.clear() + viewModelScope.launch { + agentLoader.loadData(extraArgs()) + } + } + + fun loadMore() { + viewModelScope.launch { + agentLoader.loadMore(extraArgs()) + agentList = agentLoader.list + } + } + + // 添加智能体到列表顶部,避免重新加载 + fun addAgentToList(agent: AgentEntity) { + agentList = listOf(agent) + agentList + } + + fun ResetModel() { + agentLoader.clear() + isFirstLoad = true + } + + override fun onCleared() { + super.onCleared() + EventBus.getDefault().unregister(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/tabs/hot/HotAgent.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/tabs/hot/HotAgent.kt new file mode 100644 index 0000000..6ec1a2b --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/tabs/hot/HotAgent.kt @@ -0,0 +1,179 @@ +package com.aiosman.ravenow.ui.index.tabs.ai.tabs.hot + +import androidx.compose.foundation.Image +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.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.GuestLoginCheckOut +import com.aiosman.ravenow.GuestLoginCheckOutScene +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.AgentCard +import com.aiosman.ravenow.ui.composables.rememberDebouncer + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun HotAgent() { + val AppColors = LocalAppTheme.current + val model = HotAgentViewModel + var agentList = model.agentList + val navController = LocalNavController.current + val scope = rememberCoroutineScope() + val state = rememberPullRefreshState(model.refreshing, onRefresh = { + model.refreshPager(pullRefresh = true) + }) + val listState = rememberLazyListState() + + // observe list scrolling + val reachedBottom by remember { + derivedStateOf { + val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() + lastVisibleItem?.index != 0 && lastVisibleItem?.index == listState.layoutInfo.totalItemsCount - 2 + } + } + + // load more if scrolled to bottom + LaunchedEffect(reachedBottom) { + if (reachedBottom && !model.isLoading && model.hasNext) { + model.loadMore() + } + } + + // 只在初始化页面时刷新 + LaunchedEffect(Unit) { + // 只有在列表完全为空且没有正在加载时才进行初始化刷新 + if (model.agentList.isEmpty() && !model.isLoading) { + model.refreshPager() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + if(agentList.isEmpty() && !model.isLoading) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.mipmap.rider_pro_following_empty), + contentDescription = null, + modifier = Modifier.size(140.dp) + ) + Spacer(modifier = Modifier.size(32.dp)) + Text( + text = "您还没有创建任何智能体", + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.size(16.dp)) + Text( + text = "点击开始创建您的第一个智能体", + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W400 + ) + } + } + } else { + Box(Modifier.pullRefresh(state)) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState + ) { + items( + agentList.size, + key = { idx -> idx } + ) { idx -> + val agentItem = agentList[idx] + val chatDebouncer = rememberDebouncer() + val avatarDebouncer = rememberDebouncer() + AgentCard( + agentEntity = agentItem, + onClick = { + chatDebouncer { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + model.createSingleChat(agentItem.openId) + model.goToChatAi(agentItem.openId, navController) + } + } + }, + onAvatarClick = { + avatarDebouncer { + model.goToProfile(agentItem.openId, navController) + } + } + ) + } + + // 加载更多指示器 + if (model.isLoading && agentList.isNotEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = AppColors.main + ) + } + } + } + } + PullRefreshIndicator(model.refreshing, state, Modifier.align(Alignment.TopCenter)) + } + } + + // 错误信息显示 + model.error?.let { error -> + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = error, + color = AppColors.error, + fontSize = 14.sp + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/tabs/hot/HotAgentViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/tabs/hot/HotAgentViewModel.kt new file mode 100644 index 0000000..44e11d2 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/tabs/hot/HotAgentViewModel.kt @@ -0,0 +1,178 @@ +package com.aiosman.ravenow.ui.index.tabs.ai.tabs.hot + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController +import com.aiosman.ravenow.data.api.ApiClient +import com.aiosman.ravenow.data.ServiceException +import com.aiosman.ravenow.data.api.SingleChatRequestBody +import com.aiosman.ravenow.entity.AgentEntity +import com.aiosman.ravenow.ui.index.tabs.ai.tabs.mine.MineAgentViewModel.createGroup2ChatAi +import com.aiosman.ravenow.ui.index.tabs.message.MessageListViewModel.userService +import com.aiosman.ravenow.ui.NavigationRoute +import kotlinx.coroutines.launch + +object HotAgentViewModel : ViewModel() { + var agentList by mutableStateOf>(emptyList()) + var refreshing by mutableStateOf(false) + var isLoading by mutableStateOf(false) + var hasNext by mutableStateOf(true) + var currentPage by mutableStateOf(1) + var error by mutableStateOf(null) + + // 记录已预加载的图片ID,避免重复加载 + private val preloadedImageIds = mutableSetOf() + + private val pageSize = 20 + + init { + // 延迟初始化,避免在页面切换时立即加载 + // refreshPager() + } + + fun refreshPager(pullRefresh: Boolean = false) { + if (isLoading && !pullRefresh) return + + viewModelScope.launch { + try { + isLoading = true + refreshing = pullRefresh + error = null + + // 清除预加载记录,强制重新加载图片 + if (pullRefresh) { + clearPreloadedImages() + } + + val response = ApiClient.api.getAgent( + page = 1, + pageSize = pageSize + ) + + val body = response.body() + if (body != null) { + val newAgents = body.data.list.map { it.toAgentEntity() } + // 只有在列表为空或者是下拉刷新时才替换整个列表 + if (agentList.isEmpty() || pullRefresh) { + agentList = newAgents + } else { + // 否则只添加新的智能体 + val existingIds = agentList.map { it.id }.toSet() + val newAgentsToAdd = newAgents.filter { it.id !in existingIds } + if (newAgentsToAdd.isNotEmpty()) { + agentList = agentList + newAgentsToAdd + } + } + currentPage = 1 + hasNext = newAgents.size == pageSize + } else { + throw ServiceException("Failed to load agents") + } + } catch (e: Exception) { + error = e.message ?: "加载失败" + e.printStackTrace() + } finally { + isLoading = false + refreshing = false + } + } + } + + fun loadMore() { + if (isLoading || !hasNext) return + + viewModelScope.launch { + try { + isLoading = true + error = null + + val response = ApiClient.api.getAgent( + page = currentPage + 1, + pageSize = pageSize + ) + + val body = response.body() + if (body != null) { + val newAgents = body.data.list.map { it.toAgentEntity() } + agentList = agentList + newAgents + currentPage += 1 + hasNext = newAgents.size == pageSize + } else { + throw ServiceException("Failed to load more agents") + } + } catch (e: Exception) { + error = e.message ?: "加载更多失败" + e.printStackTrace() + } finally { + isLoading = false + } + } + } + fun createSingleChat( + openId: String, + ) { + viewModelScope.launch { + val response = ApiClient.api.createSingleChat(SingleChatRequestBody(agentOpenId = openId)) + } + + } + fun goToChatAi( + openId: String, + navController: NavHostController + ) { + viewModelScope.launch { + val profile = userService.getUserProfileByOpenId(openId) + createGroup2ChatAi(profile.trtcUserId,"ai_group",navController,profile.id) + } + } + + fun goToProfile( + openId: String, + navController: NavHostController + ) { + viewModelScope.launch { + try { + val profile = userService.getUserProfileByOpenId(openId) + // 从Agent列表点击进去的一定是智能体,直接传递isAiAccount = true + navController.navigate( + NavigationRoute.AccountProfile.route + .replace("{id}", profile.id.toString()) + .replace("{isAiAccount}", "true") + ) + } catch (e: Exception) { + // swallow error to avoid crash on navigation attempt failures + } + } + } + + // 预加载图片,避免滑动时重复加载 + fun preloadImages(context: android.content.Context) { + viewModelScope.launch { + agentList.forEach { agent -> + if (agent.id !in preloadedImageIds && agent.avatar.isNotEmpty()) { + try { + // 预加载头像图片到缓存 + com.aiosman.ravenow.utils.Utils.getImageLoader(context).enqueue( + coil.request.ImageRequest.Builder(context) + .data(agent.avatar) + .memoryCachePolicy(coil.request.CachePolicy.ENABLED) + .diskCachePolicy(coil.request.CachePolicy.ENABLED) + .build() + ) + preloadedImageIds.add(agent.id) + } catch (e: Exception) { + // 忽略预加载错误 + } + } + } + } + } + + // 清除预加载记录(在刷新时调用) + fun clearPreloadedImages() { + preloadedImageIds.clear() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/tabs/mine/MineAgent.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/tabs/mine/MineAgent.kt new file mode 100644 index 0000000..b2510bd --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/tabs/mine/MineAgent.kt @@ -0,0 +1,173 @@ +package com.aiosman.ravenow.ui.index.tabs.ai.tabs.mine + +import androidx.compose.foundation.Image +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.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.LoadState +import com.aiosman.ravenow.GuestLoginCheckOut +import com.aiosman.ravenow.GuestLoginCheckOutScene +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.AgentCard +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import com.aiosman.ravenow.ui.index.tabs.message.tab.GroupChatItem +import com.aiosman.ravenow.ui.index.tabs.message.tab.GroupChatListViewModel +import java.util.UUID + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun MineAgent() { + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + val model = MineAgentViewModel + val agentList = model.agentList.collectAsLazyPagingItems() + val scope = rememberCoroutineScope() + val state = rememberPullRefreshState(model.refreshing, onRefresh = { + //model.refreshPager(pullRefresh = true) + }) + val listState = rememberLazyListState() + + // Paging 库会自动处理加载更多,无需手动监听滚动 + + // 只在初始化页面时刷新 + LaunchedEffect(Unit) { + // 只有在列表完全为空且没有正在加载时才进行初始化刷新 + if (agentList.itemCount == 0 && !model.isLoading) { + model.refreshPager() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + if(agentList.itemCount == 0 && !model.isLoading) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.mipmap.rider_pro_following_empty), + contentDescription = null, + modifier = Modifier.size(140.dp) + ) + Spacer(modifier = Modifier.size(32.dp)) + Text( + text = "您还没有创建任何智能体", + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.size(16.dp)) + Text( + text = "点击开始创建您的第一个智能体", + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W400 + ) + } + } + } else { + Box(Modifier.pullRefresh(state)) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState + ) { + + items(count = agentList.itemCount, key = { index -> agentList[index]?.id ?: index }) { index -> + agentList[index]?.let { agent -> + val chatDebouncer = rememberDebouncer() + val avatarDebouncer = rememberDebouncer() + + AgentCard( + agentEntity = agent, + onClick = { + chatDebouncer { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + model.createSingleChat(agent.openId) + model.goToChatAi(agent.openId,navController) + } + } + }, + onAvatarClick = { + avatarDebouncer { + model.goToProfile(agent.openId, navController) + } + } + ) + } + } + + + // 加载更多指示器 + if (agentList.loadState.append is LoadState.Loading) { + item { + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = AppColors.main + ) + } + } + } + } + PullRefreshIndicator(model.refreshing, state, Modifier.align(Alignment.TopCenter)) + } + } + + // 错误信息显示 + model.error?.let { error -> + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = error, + color = AppColors.error, + fontSize = 14.sp + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/tabs/mine/MineAgentViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/tabs/mine/MineAgentViewModel.kt new file mode 100644 index 0000000..c08d5ba --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/ai/tabs/mine/MineAgentViewModel.kt @@ -0,0 +1,147 @@ +package com.aiosman.ravenow.ui.index.tabs.ai.tabs.mine + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.aiosman.ravenow.data.Agent +import com.aiosman.ravenow.data.AgentService +import com.aiosman.ravenow.data.MomentService +import com.aiosman.ravenow.data.api.ApiClient +import com.aiosman.ravenow.data.ServiceException +import com.aiosman.ravenow.data.api.SingleChatRequestBody +import com.aiosman.ravenow.entity.AgentEntity +import com.aiosman.ravenow.entity.AgentPagingSource +import com.aiosman.ravenow.entity.AgentRemoteDataSource +import com.aiosman.ravenow.entity.AgentServiceImpl +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.entity.MomentPagingSource +import com.aiosman.ravenow.entity.MomentRemoteDataSource +import com.aiosman.ravenow.entity.MomentServiceImpl +import com.aiosman.ravenow.ui.index.tabs.message.MessageListViewModel.userService +import com.aiosman.ravenow.ui.navigateToChatAi +import com.aiosman.ravenow.ui.NavigationRoute +// OpenIM SDK 导入 - 会话分组功能在 OpenIM 中不支持,将直接跳转到聊天页面 +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +object MineAgentViewModel : ViewModel() { + private val agentService: AgentService = AgentServiceImpl() + + private val _agentList = + MutableStateFlow>(PagingData.empty()) + val agentList = _agentList.asStateFlow() + + var refreshing by mutableStateOf(false) + var isLoading by mutableStateOf(false) + var hasNext by mutableStateOf(true) + var currentPage by mutableStateOf(1) + var error by mutableStateOf(null) + var isFirstLoad by mutableStateOf(true) + + // 记录已预加载的图片ID,避免重复加载 + private val preloadedImageIds = mutableSetOf() + + private val pageSize = 20 + + + fun refreshPager() { + if (!isFirstLoad) { + return + } + isFirstLoad = false + viewModelScope.launch { + Pager( + config = PagingConfig(pageSize = 5, enablePlaceholders = false), + pagingSourceFactory = { + AgentPagingSource( + + AgentRemoteDataSource(agentService), + //trend = true + ) + } + ).flow.cachedIn(viewModelScope).collectLatest { + _agentList.value = it + } + } + } + + fun createGroup2ChatAi( + trtcUserId: String, + groupName: String, + navController: NavHostController, + id: Int + ) { + // TODO: OpenIM 不支持会话分组功能,直接跳转到聊天页面 + // OpenIM 不支持会话分组功能,直接跳转到聊天页面 + Log.d("MineAgentViewModel", "OpenIM 不支持会话分组,直接跳转到 AI 聊天页面") + navController.navigateToChatAi(id.toString()) + } + + + fun createSingleChat( + openId: String, + ) { + viewModelScope.launch { + val response = ApiClient.api.createSingleChat(SingleChatRequestBody(agentOpenId = openId)) + } + + } + fun goToChatAi( + openId: String, + navController: NavHostController + ) { + viewModelScope.launch { + val profile = userService.getUserProfileByOpenId(openId) + createGroup2ChatAi(profile.trtcUserId,"ai_group",navController,profile.id) + } + } + + fun goToProfile( + openId: String, + navController: NavHostController + ) { + viewModelScope.launch { + try { + val profile = userService.getUserProfileByOpenId(openId) + // 从Agent列表点击进去的一定是智能体,直接传递isAiAccount = true + navController.navigate( + NavigationRoute.AccountProfile.route + .replace("{id}", profile.id.toString()) + .replace("{isAiAccount}", "true") + ) + } catch (e: Exception) { + // swallow error to avoid crash on navigation attempt failures + } + } + } + + /** + * 重置ViewModel状态,用于登出或切换账号时清理数据 + */ + fun ResetModel() { + // 重置状态变量 + refreshing = false + isLoading = false + hasNext = true + currentPage = 1 + error = null + isFirstLoad = true + + // 清除预加载的图片ID + preloadedImageIds.clear() + + // 清空PagingData + _agentList.value = PagingData.empty() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/MessageList.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/MessageList.kt new file mode 100644 index 0000000..687fe10 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/MessageList.kt @@ -0,0 +1,540 @@ +package com.aiosman.ravenow.ui.index.tabs.message + +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.offset +import androidx.lifecycle.viewModelScope +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.AppStore +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.StatusBarSpacer +import com.aiosman.ravenow.ui.composables.TabItem +import com.aiosman.ravenow.ui.composables.TabSpacer +import com.aiosman.ravenow.ui.composables.rememberDebouncedNavigation +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import com.aiosman.ravenow.ui.follower.FollowerNoticeViewModel +import com.aiosman.ravenow.ui.index.tabs.message.tab.AgentChatListScreen +import com.aiosman.ravenow.ui.index.tabs.message.tab.AgentChatListViewModel +import com.aiosman.ravenow.ui.index.tabs.message.tab.FriendChatListScreen +import com.aiosman.ravenow.ui.index.tabs.message.tab.FriendChatListViewModel +import com.aiosman.ravenow.ui.index.tabs.message.tab.GroupChatListScreen +import com.aiosman.ravenow.ui.index.tabs.message.tab.GroupChatListViewModel +import com.aiosman.ravenow.ui.like.LikeNoticeViewModel +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.launch +import com.aiosman.ravenow.ui.index.tabs.message.tab.AllChatListScreen + +/** + * 消息列表界面 + */ +@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) +@Composable +fun NotificationsScreen() { + val debouncer = rememberDebouncer() + // 计算总未读消息数 + val totalUnreadCount = AgentChatListViewModel.totalUnreadCount + + GroupChatListViewModel.totalUnreadCount + + FriendChatListViewModel.totalUnreadCount + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + val systemUiController = rememberSystemUiController() + val context = LocalContext.current + var pagerState = rememberPagerState (pageCount = { 4 }) + var scope = rememberCoroutineScope() + val state = rememberPullRefreshState(MessageListViewModel.isLoading, onRefresh = { + MessageListViewModel.viewModelScope.launch { + MessageListViewModel.initData(context, force = true, loadChat = AppState.enableChat) + } + // 刷新群聊列表以更新未读消息数 + GroupChatListViewModel.refreshPager(context = context) + // 刷新智能体列表以更新未读消息数 + AgentChatListViewModel.refreshPager(context = context) + // 刷新朋友列表以更新未读消息数 + FriendChatListViewModel.refreshPager(context = context) + }) + val navigationBarPaddings = + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 48.dp + val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues() + LaunchedEffect(Unit) { + systemUiController.setNavigationBarColor(Color.Transparent) + MessageListViewModel.initData(context, loadChat = AppState.enableChat) + // 初始化群聊列表以获取未读消息数 + GroupChatListViewModel.refreshPager(context = context) + // 初始化智能体列表以获取未读消息数 + AgentChatListViewModel.refreshPager(context = context) + // 初始化朋友列表以获取未读消息数 + FriendChatListViewModel.refreshPager(context = context) + } + Column( + modifier = Modifier + .fillMaxSize() + .padding( + bottom = navigationBarPaddings, + + ), + ) { + StatusBarSpacer() + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .pullRefresh(state) + ) { + + Column( + modifier = Modifier.fillMaxSize(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.main_message), + fontSize = 20.sp, + fontWeight = FontWeight.W900, + color = AppColors.text + ) + + Spacer(modifier = Modifier.weight(1f)) + + Image( + painter = painterResource(id = R.drawable.rider_pro_nav_search), + contentDescription = "search", + modifier = Modifier + .size(24.dp) + .noRippleClickable { + // TODO: 实现搜索功能 + }, + colorFilter = ColorFilter.tint(AppColors.text) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Box { + Image( + painter = painterResource(id = R.drawable.rider_pro_notification), + contentDescription = "notifications", + modifier = Modifier + .size(24.dp) + .noRippleClickable { + navController.navigate(NavigationRoute.NotificationScreen.route) + }, + colorFilter = ColorFilter.tint(AppColors.text) + ) + + // 通知红点 + val totalNoticeCount = MessageListViewModel.likeNoticeCount + + MessageListViewModel.followNoticeCount + + MessageListViewModel.commentNoticeCount + + MessageListViewModel.favouriteNoticeCount + + if (totalNoticeCount > 0) { + Box( + modifier = Modifier + .size(8.dp) + .background( + color = Color(0xFFFF3B30), + shape = CircleShape + ) + .align(Alignment.TopEnd) + .offset(x = 8.dp, y = (-4).dp) + ) + } + } + } + + //创建群聊// +// Image( +// painter = painterResource(id = R.drawable.rider_pro_group), +// contentDescription = "add", +// modifier = Modifier +// .size(24.dp) +// .noRippleClickable { +// debouncer { +// navController.navigate(NavigationRoute.CreateGroupChat.route) +// } +// }, +// colorFilter = ColorFilter.tint(AppColors.text) +// ) + + +// // 搜索栏// +// Box( +// modifier = Modifier +// .fillMaxWidth() +// .padding(horizontal = 16.dp, vertical = 8.dp) +// .height(40.dp) +// +// .noRippleClickable { +// }, +// contentAlignment = Alignment.CenterStart +// ) +// { +// Row( +// modifier = Modifier +// .height(36.dp) +// .fillMaxWidth() +// .clip(shape = RoundedCornerShape(8.dp)) +// .background(AppColors.inputBackground) +// .padding(horizontal = 8.dp, vertical = 0.dp) +// .noRippleClickable { +// // 搜索框点击事件 +// }, +// verticalAlignment = Alignment.CenterVertically +// ) { +// Icon( +// painter = painterResource(id = R.drawable.rider_pro_nav_search), +// contentDescription = null, +// tint = AppColors.inputHint +// ) +// Box { +// androidx.compose.material.Text( +// text = stringResource(R.string.search), +// modifier = Modifier.padding(start = 8.dp), +// color = AppColors.inputHint, +// fontSize = 17.sp +// ) +// } +// } +// } + + //赞、粉丝、评论// +// Row( +// modifier = Modifier +// .fillMaxWidth() +// .padding(horizontal = 16.dp), +// horizontalArrangement = Arrangement.SpaceBetween, +// ) { +// val likeDebouncer = rememberDebouncer() +// val followDebouncer = rememberDebouncer() +// val commentDebouncer = rememberDebouncer() +// +// NotificationIndicator( +// MessageListViewModel.likeNoticeCount, +// R.mipmap.rider_pro_like, +// stringResource(R.string.like_upper), +// Color(0xFFFAFD5D) +// ) { +// likeDebouncer { +// if (MessageListViewModel.likeNoticeCount > 0) { +// // 刷新点赞消息列表 +// LikeNoticeViewModel.isFirstLoad = true +// // 清除点赞消息数量 +// MessageListViewModel.clearLikeNoticeCount() +// } +// navController.navigate(NavigationRoute.Likes.route) +// } +// } +// NotificationIndicator( +// MessageListViewModel.followNoticeCount, +// R.mipmap.rider_pro_followers, +// stringResource(R.string.followers_upper), +// Color(0xFFF470FE) +// ) { +// followDebouncer { +// if (MessageListViewModel.followNoticeCount > 0) { +// // 刷新关注消息列表 +// FollowerNoticeViewModel.isFirstLoad = true +// MessageListViewModel.clearFollowNoticeCount() +// } +// navController.navigate(NavigationRoute.Followers.route) +// } +// } +// NotificationIndicator( +// MessageListViewModel.commentNoticeCount, +// R.mipmap.rider_pro_comment, +// stringResource(R.string.comment).uppercase(), +// Color(0xFF6246FF) +// ) { +// commentDebouncer { +// navController.navigate(NavigationRoute.CommentNoticeScreen.route) +// } +// } +// } + Spacer(modifier = Modifier.height(23.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(start = 16.dp,bottom = 16.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.Bottom + ) { + val tabDebouncer = rememberDebouncer() + + Box { + TabItem( + text = stringResource(R.string.chat_all), + isSelected = pagerState.currentPage == 0, + onClick = { + tabDebouncer { + scope.launch { + pagerState.animateScrollToPage(0) + } + } + } + ) + + // 全部未读消息红点 + val totalUnreadCount = AgentChatListViewModel.totalUnreadCount + + GroupChatListViewModel.totalUnreadCount + + FriendChatListViewModel.totalUnreadCount + if (totalUnreadCount > 0) { + Box( + modifier = Modifier + .size(8.dp) + .background( + color = Color(0xFFFF3B30), + shape = CircleShape + ) + .align(Alignment.TopEnd) + .offset(x = 8.dp, y = (-4).dp) + ) + } + } + TabSpacer() + + Box { + TabItem( + text = stringResource(R.string.chat_ai), + isSelected = pagerState.currentPage == 1, + onClick = { + tabDebouncer { + scope.launch { + pagerState.animateScrollToPage(1) + } + } + } + ) + + // 智能体未读消息红点 + if (AgentChatListViewModel.totalUnreadCount > 0) { + Box( + modifier = Modifier + .size(8.dp) + .background( + color = Color(0xFFFF3B30), + shape = CircleShape + ) + .align(Alignment.TopEnd) + .offset(x = 8.dp, y = (-4).dp) + ) + } + } + TabSpacer() + Box { + TabItem( + text = stringResource(R.string.chat_group), + isSelected = pagerState.currentPage == 2, + onClick = { + tabDebouncer { + scope.launch { + pagerState.animateScrollToPage(2) + } + } + } + ) + + // 群聊未读消息红点 + if (GroupChatListViewModel.totalUnreadCount > 0) { + Box( + modifier = Modifier + .size(8.dp) + .background( + color = Color(0xFFFF3B30), + shape = CircleShape + ) + .align(Alignment.TopEnd) + .offset(x = 8.dp, y = (-4).dp) + ) + } + } + TabSpacer() + + Box { + TabItem( + text = stringResource(R.string.chat_friend), + isSelected = pagerState.currentPage == 3, + onClick = { + tabDebouncer { + scope.launch { + pagerState.animateScrollToPage(3) + } + } + } + ) + + // 朋友未读消息红点 + if (FriendChatListViewModel.totalUnreadCount > 0) { + Box( + modifier = Modifier + .size(8.dp) + .background( + color = Color(0xFFFF3B30), + shape = CircleShape + ) + .align(Alignment.TopEnd) + .offset(x = 8.dp, y = (-4).dp) + ) + } + } + } + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + when (it) { + 0 -> { + AllChatListScreen() + } + 1 -> { + AgentChatListScreen() + } + + 2 -> { + GroupChatListScreen() + } + + 3 -> { + FriendChatListScreen() + } + } + } + + } + } + + } +} + +@Composable +fun NotificationIndicator( + notificationCount: Int, + iconRes: Int, + label: String, + backgroundColor: Color = Color.Transparent, + onClick: () -> Unit +) { + val AppColors = LocalAppTheme.current + + Box( + modifier = Modifier + ) { + Box( + modifier = Modifier + .padding(16.dp) + .align(Alignment.TopCenter) + .noRippleClickable { + onClick() + } + ) { + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(69.dp) + .padding(5.dp) + .background(color = backgroundColor, + shape = RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = iconRes), + contentDescription = label, + modifier = Modifier.size(24.dp) + ) + } + Box( + modifier = Modifier + ) { + Text(label, modifier = Modifier.align(Alignment.Center).padding(top = 8.dp), color = AppColors.text) + } + + } + if (notificationCount > 0) { + Box( + modifier = Modifier + .background(AppColors.main, RoundedCornerShape(16.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp) + .align(Alignment.TopEnd) + ) { + Text( + text = if (notificationCount > 99) "99+" else notificationCount.toString(), + color = AppColors.mainText, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.Center) + ) + } + } + } + } + +} + + + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/MessageListViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/MessageListViewModel.kt new file mode 100644 index 0000000..52153d5 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/MessageListViewModel.kt @@ -0,0 +1,167 @@ +package com.aiosman.ravenow.ui.index.tabs.message + +import android.content.Context +import android.icu.util.Calendar +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import androidx.navigation.NavHostController +import androidx.paging.PagingData +import androidx.paging.map +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.ConstVars +import com.aiosman.ravenow.data.AccountNotice +import com.aiosman.ravenow.data.AccountService +import com.aiosman.ravenow.data.AccountServiceImpl +import com.aiosman.ravenow.data.UserService +import com.aiosman.ravenow.data.UserServiceImpl +import com.aiosman.ravenow.entity.CommentEntity +import com.aiosman.ravenow.exp.formatChatTime +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.navigateToChat +import com.aiosman.ravenow.utils.TrtcHelper +// OpenIM SDK 导入 +import io.openim.android.sdk.OpenIMClient +import io.openim.android.sdk.listener.OnBase +import io.openim.android.sdk.models.ConversationInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlin.coroutines.suspendCoroutine + +data class Conversation( + val id: String, + val trtcUserId: String, + val nickname: String, + val lastMessage: String, + val lastMessageTime: String, + val avatar: String = "", + val unreadCount: Int = 0, + val displayText: String, + val isSelf: Boolean +) { + companion object { + // TODO: 如果需要使用 Conversation 数据类,可以实现 OpenIM 版本的 convertToConversation 方法 + // fun convertToConversation(conversation: ConversationInfo, context: Context): Conversation { + // // OpenIM 版本的转换逻辑 + // } + } +} + +object MessageListViewModel : ViewModel() { + val accountService: AccountService = AccountServiceImpl() + val userService: UserService = UserServiceImpl() + var noticeInfo by mutableStateOf(null) + var chatList by mutableStateOf>(emptyList()) + private val _commentItemsFlow = MutableStateFlow>(PagingData.empty()) + var isLoading by mutableStateOf(false) + var unReadConversationCount by mutableStateOf(0L) + var isFirstLoad = true + suspend fun initData(context: Context, force: Boolean = false, loadChat: Boolean = false) { +// if (loadChat) { +// loadChatList(context) +// loadUnreadCount() +// } +// +// if (!isFirstLoad && !force) { +// return +// } +// if (force) { +// isLoading = true +// } +// isFirstLoad = false +// val info = accountService.getMyNoticeInfo() +// noticeInfo = info +// +// isLoading = false + + } + + val likeNoticeCount + get() = noticeInfo?.likeCount ?: 0 + val followNoticeCount + get() = noticeInfo?.followCount ?: 0 + val favouriteNoticeCount + get() = noticeInfo?.favoriteCount ?: 0 + val commentNoticeCount + get() = noticeInfo?.commentCount ?: 0 + + private fun updateIsRead(id: Int) { + val currentPagingData = _commentItemsFlow.value + val updatedPagingData = currentPagingData.map { commentEntity -> + if (commentEntity.id == id) { + commentEntity.copy(unread = false) + } else { + commentEntity + } + } + _commentItemsFlow.value = updatedPagingData + } + + + fun clearLikeNoticeCount() { + noticeInfo = noticeInfo?.copy(likeCount = 0) + } + + fun clearFollowNoticeCount() { + noticeInfo = noticeInfo?.copy(followCount = 0) + } + + fun clearFavouriteNoticeCount() { + noticeInfo = noticeInfo?.copy(favoriteCount = 0) + } + + fun updateUnReadCount(delta: Int) { + noticeInfo?.let { + noticeInfo = it.copy(commentCount = it.commentCount + delta) + } + } + + fun ResetModel() { + _commentItemsFlow.value = PagingData.empty() + noticeInfo = null + isLoading = false + isFirstLoad = true + } + + suspend fun loadChatList(context: Context) { + // 检查 OpenIM 是否已登录 + if (!com.aiosman.ravenow.AppState.enableChat) { + android.util.Log.w("MessageListViewModel", "OpenIM 未登录,跳过加载聊天列表") + return + } + + val result = suspendCoroutine { continuation -> + // OpenIM 获取所有会话列表 + OpenIMClient.getInstance().conversationManager.getAllConversationList( + object : OnBase> { + override fun onSuccess(data: List?) { + continuation.resumeWith(Result.success(data ?: emptyList())) + } + + override fun onError(code: Int, error: String?) { + continuation.resumeWith(Result.failure(Exception("Error $code: $error"))) + } + } + ) + } + + // 暂时注释掉,因为 convertToConversation 方法已被注释 + // chatList = result.map { conversation -> + // Conversation.convertToConversation(conversation, context) + // } + } + + suspend fun loadUnreadCount() { + try { + this.unReadConversationCount = TrtcHelper.loadUnreadCount() + } catch (e: Exception) { + e.printStackTrace() + this.unReadConversationCount = 0 + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/AgentChatListScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/AgentChatListScreen.kt new file mode 100644 index 0000000..5389f8b --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/AgentChatListScreen.kt @@ -0,0 +1,320 @@ +package com.aiosman.ravenow.ui.index.tabs.message.tab + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.res.stringResource +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.utils.NetworkUtils + +/** + * 智能体聊天列表页面 + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun AgentChatListScreen() { + val context = LocalContext.current + val navController = LocalNavController.current + val AppColors = LocalAppTheme.current + val model = AgentChatListViewModel + + val state = rememberPullRefreshState( + refreshing = AgentChatListViewModel.refreshing, + onRefresh = { + AgentChatListViewModel.refreshPager(pullRefresh = true, context = context) + } + ) + + // 初始化数据 + LaunchedEffect(Unit) { + AgentChatListViewModel.refreshPager(context = context) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) + ) { + // 聊天列表 + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(state) + ) { + if (AgentChatListViewModel.agentChatList.isEmpty() && !AgentChatListViewModel.isLoading) { + // 空状态 + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + + ) { + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(context) + + if (isNetworkAvailable) { + Spacer(modifier = Modifier.height(39.dp)) + Image( + painter = painterResource( + id = if(AppState.darkMode) R.mipmap.qs_znt_qs_as_img + else R.mipmap.invalid_name_5), + contentDescription = "null data", + modifier = Modifier + .size(181.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.agent_chat_empty_title), + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.agent_chat_empty_subtitle), + color = AppColors.secondaryText, + fontSize = 14.sp + ) + } + else { + Spacer(modifier = Modifier.height(39.dp)) + Image( + painter = painterResource(id = R.mipmap.invalid_name_10), + contentDescription = "network error", + modifier = Modifier + .size(181.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_title), + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_subtitle), + color = AppColors.secondaryText, + fontSize = 14.sp + ) + Spacer(modifier = Modifier.height(16.dp)) + ReloadButton( + onClick = { + AgentChatListViewModel.refreshPager(context = context) + } + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + itemsIndexed( + items = AgentChatListViewModel.agentChatList, + key = { _, item -> item.id } + ) { index, item -> + AgentChatItem( + conversation = item, + onUserAvatarClick = { conv -> + AgentChatListViewModel.goToUserDetail(conv, navController) + }, + onChatClick = { conv -> + if (NetworkUtils.isNetworkAvailable(context)) { + model.createSingleChat(conv.trtcUserId) + model.goToChatAi(conv.trtcUserId,navController) + } else { + android.widget.Toast.makeText(context, "网络连接异常,请检查网络设置", android.widget.Toast.LENGTH_SHORT).show() + } + } + ) + + } + + // 加载更多指示器 + if (AgentChatListViewModel.isLoading && AgentChatListViewModel.agentChatList.isNotEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = AppColors.main + ) + } + } + } + } + } + + // 下拉刷新指示器 + PullRefreshIndicator( + refreshing = AgentChatListViewModel.refreshing, + state = state, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + + // 错误信息显示 + AgentChatListViewModel.error?.let { error -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = error, + color = AppColors.error, + fontSize = 14.sp + ) + } + } + } +} + +@Composable +fun AgentChatItem( + conversation: AgentConversation, + onUserAvatarClick: (AgentConversation) -> Unit = {}, + onChatClick: (AgentConversation) -> Unit = {} +) { + val AppColors = LocalAppTheme.current + val chatDebouncer = rememberDebouncer() + val avatarDebouncer = rememberDebouncer() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .noRippleClickable { + chatDebouncer { + onChatClick(conversation) + } + }, + verticalAlignment = Alignment.CenterVertically + ) { + Box { + CustomAsyncImage( + context = LocalContext.current, + imageUrl = conversation.avatar, + contentDescription = conversation.nickname, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(48.dp)) + .noRippleClickable { + avatarDebouncer { + onUserAvatarClick(conversation) + } + } + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 12.dp, top = 2.dp), + verticalArrangement = Arrangement.Center + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = conversation.nickname, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = AppColors.text, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(6.dp)) + + Text( + text = conversation.lastMessageTime, + fontSize = 11.sp, + color = AppColors.secondaryText + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${if (conversation.isSelf) stringResource(R.string.friend_chat_me_prefix) else ""}${conversation.displayText}", + fontSize = 12.sp, + color = AppColors.secondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(10.dp)) + + if (conversation.unreadCount > 0) { + Box( + modifier = Modifier + .size(if (conversation.unreadCount > 99) 24.dp else 20.dp) + .background( + color = Color(0xFFFF3B30), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = if (conversation.unreadCount > 99) "99+" else conversation.unreadCount.toString(), + color = Color.White, + fontSize = if (conversation.unreadCount > 99) 11.sp else 12.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/AgentChatListViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/AgentChatListViewModel.kt new file mode 100644 index 0000000..4c11c78 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/AgentChatListViewModel.kt @@ -0,0 +1,210 @@ +package com.aiosman.ravenow.ui.index.tabs.message.tab + +import android.content.Context +import android.icu.util.Calendar +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.AppStore +import com.aiosman.ravenow.ConstVars +import com.aiosman.ravenow.R +import com.aiosman.ravenow.data.UserService +import com.aiosman.ravenow.data.UserServiceImpl +import com.aiosman.ravenow.data.api.ApiClient +import com.aiosman.ravenow.data.api.SingleChatRequestBody +import com.aiosman.ravenow.exp.formatChatTime +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.index.tabs.ai.tabs.mine.MineAgentViewModel.createGroup2ChatAi +import com.aiosman.ravenow.ui.index.tabs.message.MessageListViewModel +import com.aiosman.ravenow.ui.navigateToChatAi +import io.openim.android.sdk.OpenIMClient +import io.openim.android.sdk.listener.OnBase +import io.openim.android.sdk.models.ConversationInfo +import io.openim.android.sdk.models.Message +import kotlinx.coroutines.launch +import kotlin.coroutines.suspendCoroutine +import com.aiosman.ravenow.utils.MessageParser + +data class AgentConversation( + val id: String, + val trtcUserId: String, + val nickname: String, + val lastMessage: String, + val lastMessageTime: String, + val avatar: String = "", + val unreadCount: Int = 0, + val displayText: String, + val isSelf: Boolean +) { + companion object { + fun convertToAgentConversation(conversation: ConversationInfo, context: Context): AgentConversation { + val lastMessage = Calendar.getInstance().apply { + timeInMillis = conversation.latestMsgSendTime + } + + // 解析最新消息 + val (displayText, isSelf) = MessageParser.parseLatestMessage(conversation.latestMsg) + return AgentConversation( + id = conversation.conversationID, + nickname = conversation.showName ?: "", + lastMessage = displayText, // 使用解析后的显示文本 + lastMessageTime = lastMessage.time.formatChatTime(context), + avatar = "${ApiClient.BASE_API_URL+"/"}${conversation.faceURL}"+"?token="+"${AppStore.token}".replace("storage/avatars/", "/avatar/"), + unreadCount = conversation.unreadCount, + trtcUserId = conversation.userID ?: "", + displayText = displayText, + isSelf = isSelf // 使用解析后的发送者信息 + ) + } + } +} + +object AgentChatListViewModel : ViewModel() { + val userService: UserService = UserServiceImpl() + var agentChatList by mutableStateOf>(emptyList()) + var isLoading by mutableStateOf(false) + var refreshing by mutableStateOf(false) + var hasNext by mutableStateOf(true) + var currentPage by mutableStateOf(1) + var error by mutableStateOf(null) + + // 计算智能体总未读消息数 + val totalUnreadCount: Int + get() = agentChatList.sumOf { it.unreadCount } + + private val pageSize = 20 + + fun refreshPager(pullRefresh: Boolean = false, context: Context? = null) { + if (isLoading && !pullRefresh) return + viewModelScope.launch { + try { + isLoading = true + refreshing = pullRefresh + error = null + context?.let { loadAgentChatList(it) } + currentPage = 1 + } catch (e: Exception) { + error = e.message ?: context?.getString(R.string.agent_chat_load_failed) + e.printStackTrace() + } finally { + isLoading = false + refreshing = false + } + } + } + + fun loadMore() { + if (isLoading || !hasNext) return + viewModelScope.launch { + try { + isLoading = true + error = null + // 腾讯IM的会话列表是一次性获取的,这里模拟分页 + // 实际项目中可能需要根据时间戳或其他方式实现真正的分页 + hasNext = false + } catch (e: Exception) { + error = "" + e.printStackTrace() + } finally { + isLoading = false + } + } + } + + private suspend fun loadAgentChatList(context: Context) { + // 检查 OpenIM 是否已登录 + if (!com.aiosman.ravenow.AppState.enableChat) { + android.util.Log.w("AgentChatListViewModel", "OpenIM 未登录,跳过加载智能体聊天列表") + return + } + + val result = suspendCoroutine { continuation -> + // OpenIM 获取所有会话列表 + OpenIMClient.getInstance().conversationManager.getAllConversationList( + object : OnBase> { + override fun onSuccess(data: List?) { + // 过滤出智能体会话(单聊类型,且可能有特定标识) + val agentConversations = data?.filter { conversation -> + // 这里需要根据实际业务逻辑来过滤智能体会话 + // 可能通过会话类型、用户ID前缀、或其他标识来判断 + conversation.conversationType == 1 // 1 表示单聊 + // 可以添加更多过滤条件,比如: + // && conversation.userID?.startsWith("ai_") == true + } ?: emptyList() + continuation.resumeWith(Result.success(agentConversations)) + } + + override fun onError(code: Int, error: String?) { + continuation.resumeWith(Result.failure(Exception("Error $code: $error"))) + } + } + ) + } + + agentChatList = result.map { conversation -> + AgentConversation.convertToAgentConversation(conversation, context) + } + } + + fun goToChatAi( + conversation: AgentConversation, + navController: NavHostController + ) { + viewModelScope.launch { + try { + val profile = userService.getUserProfileByTrtcUserId(conversation.trtcUserId,1) + navController.navigateToChatAi(profile.id.toString()) + } catch (e: Exception) { + error = "" + e.printStackTrace() + } + } + } + + fun goToUserDetail( + conversation: AgentConversation, + navController: NavHostController + ) { + viewModelScope.launch { + try { + val profile = userService.getUserProfileByTrtcUserId(conversation.trtcUserId,1) + navController.navigate( + NavigationRoute.AccountProfile.route.replace( + "{id}", + profile.id.toString() + ) + ) + } catch (e: Exception) { + error = "" + e.printStackTrace() + } + } + } + + fun refreshConversation(context: Context, userId: String) { + viewModelScope.launch { + loadAgentChatList(context) + } + } + fun createSingleChat( + trtcId: String, + ) { + viewModelScope.launch { + val response = ApiClient.api.createSingleChat(SingleChatRequestBody(agentTrtcId = trtcId)) + } + + } + fun goToChatAi( + openId: String, + navController: NavHostController + ) { + viewModelScope.launch { + val profile = MessageListViewModel.userService.getUserProfileByTrtcUserId(openId,1) + navController.navigateToChatAi(profile.id.toString()) + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/AllChatListScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/AllChatListScreen.kt new file mode 100644 index 0000000..a7a04fe --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/AllChatListScreen.kt @@ -0,0 +1,390 @@ +package com.aiosman.ravenow.ui.index.tabs.message.tab + +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.utils.NetworkUtils +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.ui.text.font.FontFamily + +data class CombinedConversation( + val type: String, // "agent", "group", or "friend" + val agentConversation: AgentConversation? = null, + val groupConversation: GroupConversation? = null, + val friendConversation: FriendConversation? = null +) { + val id: String + get() = when (type) { + "agent" -> "agent_${agentConversation?.id ?: 0}" + "group" -> "group_${groupConversation?.id ?: 0}" + "friend" -> "friend_${friendConversation?.id ?: 0}" + else -> "" + } + + val avatar: String + get() = when (type) { + "agent" -> agentConversation?.avatar ?: "" + "group" -> groupConversation?.avatar ?: "" + "friend" -> friendConversation?.avatar ?: "" + else -> "" + } + + val name: String + get() = when (type) { + "agent" -> agentConversation?.nickname ?: "" + "group" -> groupConversation?.groupName ?: "" + "friend" -> friendConversation?.nickname ?: "" + else -> "" + } + + val lastMessageTime: String + get() = when (type) { + "agent" -> agentConversation?.lastMessageTime ?: "" + "group" -> groupConversation?.lastMessageTime ?: "" + "friend" -> friendConversation?.lastMessageTime ?: "" + else -> "" + } + + val displayText: String + get() = when (type) { + "agent" -> agentConversation?.displayText ?: "" + "group" -> groupConversation?.displayText ?: "" + "friend" -> friendConversation?.displayText ?: "" + else -> "" + } + + val unreadCount: Int + get() = when (type) { + "agent" -> agentConversation?.unreadCount ?: 0 + "group" -> groupConversation?.unreadCount ?: 0 + "friend" -> friendConversation?.unreadCount ?: 0 + else -> 0 + } + + val isSelf: Boolean + get() = when (type) { + "agent" -> agentConversation?.isSelf ?: false + "group" -> groupConversation?.isSelf ?: false + "friend" -> friendConversation?.isSelf ?: false + else -> false + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun AllChatListScreen() { + val context = LocalContext.current + val navController = LocalNavController.current + val AppColors = LocalAppTheme.current + + var allConversations by remember { mutableStateOf>(emptyList()) } + var refreshing by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + val state = rememberPullRefreshState( + refreshing = refreshing, + onRefresh = { + refreshing = true + refreshAllData(context, + onSuccess = { conversations -> + allConversations = conversations + refreshing = false + }, + onError = { errorMsg -> + error = errorMsg + refreshing = false + } + ) + } + ) + + LaunchedEffect(Unit) { + isLoading = true + refreshAllData(context, + onSuccess = { conversations -> + allConversations = conversations + isLoading = false + }, + onError = { errorMsg -> + error = errorMsg + isLoading = false + } + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(state) + ) { + if (allConversations.isEmpty() && !isLoading) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(context) + + if (isNetworkAvailable) { + Spacer(modifier = Modifier.height(39.dp)) + Image( + painter = painterResource( + id = if(AppState.darkMode) R.mipmap.qs_py_qs_as_img + else R.mipmap.invalid_name_2), + contentDescription = "null data", + modifier = Modifier + .size(181.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.friend_chat_empty_title), + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.friend_chat_empty_subtitle), + color = AppColors.secondaryText, + fontSize = 14.sp + ) + } else { + Spacer(modifier = Modifier.height(39.dp)) + Image( + painter = painterResource(id = R.mipmap.invalid_name_10), + contentDescription = "network error", + modifier = Modifier + .size(181.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_title), + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_subtitle), + color = AppColors.secondaryText, + fontSize = 14.sp + ) + Spacer(modifier = Modifier.height(16.dp)) + ReloadButton( + onClick = { + isLoading = true + refreshAllData(context, + onSuccess = { conversations -> + allConversations = conversations + isLoading = false + }, + onError = { errorMsg -> + error = errorMsg + isLoading = false + } + ) + } + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + itemsIndexed( + items = allConversations, + key = { _, item -> item.id } + ) { index, item -> + when (item.type) { + "agent" -> { + item.agentConversation?.let { agent -> + AgentChatItem( + conversation = agent, + onUserAvatarClick = { conv -> + AgentChatListViewModel.goToUserDetail(conv, navController) + }, + onChatClick = { conv -> + if (NetworkUtils.isNetworkAvailable(context)) { + AgentChatListViewModel.createSingleChat(conv.trtcUserId) + AgentChatListViewModel.goToChatAi(conv.trtcUserId, navController) + } else { + Toast.makeText(context, "网络连接异常,请检查网络设置", Toast.LENGTH_SHORT).show() + } + } + ) + } + } + "group" -> { + item.groupConversation?.let { group -> + GroupChatItem( + conversation = group, + onGroupAvatarClick = { conv -> + GroupChatListViewModel.goToGroupDetail(conv, navController) + }, + onChatClick = { conv -> + if (NetworkUtils.isNetworkAvailable(context)) { + GroupChatListViewModel.goToChat(conv, navController) + } else { + Toast.makeText(context, "网络连接异常,请检查网络设置", Toast.LENGTH_SHORT).show() + } + } + ) + } + } + "friend" -> { + item.friendConversation?.let { friend -> + FriendChatItem( + conversation = friend, + onUserAvatarClick = { conv -> + FriendChatListViewModel.goToUserDetail(conv, navController) + }, + onChatClick = { conv -> + if (NetworkUtils.isNetworkAvailable(context)) { + FriendChatListViewModel.goToChat(conv, navController) + } else { + Toast.makeText(context, "网络连接异常,请检查网络设置", Toast.LENGTH_SHORT).show() + } + } + ) + } + } + } + + // 分隔线 +// if (index < allConversations.size - 1) { +// HorizontalDivider( +// modifier = Modifier.padding(horizontal = 24.dp), +// color = AppColors.divider +// ) +// } + } + + if (isLoading && allConversations.isNotEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = AppColors.main + ) + } + } + } + } + } + + PullRefreshIndicator( + refreshing = refreshing, + state = state, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + + error?.let { errorMsg -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = errorMsg, + color = AppColors.error, + fontSize = 14.sp + ) + } + } + } +} + +fun refreshAllData( + context: android.content.Context, + onSuccess: (List) -> Unit, + onError: (String) -> Unit +) { + try { + // 同时刷新所有类型的数据 + AgentChatListViewModel.refreshPager(context = context) + GroupChatListViewModel.refreshPager(context = context) + FriendChatListViewModel.refreshPager(context = context) + + val combinedList = mutableListOf() + + AgentChatListViewModel.agentChatList.forEach { agent -> + combinedList.add(CombinedConversation(type = "agent", agentConversation = agent)) + } + + GroupChatListViewModel.groupChatList.forEach { group -> + combinedList.add(CombinedConversation(type = "group", groupConversation = group)) + } + + FriendChatListViewModel.friendChatList.forEach { friend -> + val isDuplicate = combinedList.any {//判断重复 + it.type == "agent" && it.agentConversation?.trtcUserId == friend.trtcUserId + } + + if (!isDuplicate) { + combinedList.add(CombinedConversation(type = "friend", friendConversation = friend)) + } + } + + // 按最后消息时间排序 + val sortedList = combinedList.sortedByDescending { + it.lastMessageTime + } + + onSuccess(sortedList) + } catch (e: Exception) { + onError("刷新数据失败: ${e.message}") + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/FriendChatListScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/FriendChatListScreen.kt new file mode 100644 index 0000000..2653633 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/FriendChatListScreen.kt @@ -0,0 +1,344 @@ +package com.aiosman.ravenow.ui.index.tabs.message.tab + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import com.aiosman.ravenow.ui.index.tabs.search.ReloadButton +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.utils.NetworkUtils +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.graphics.Brush +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.foundation.layout.PaddingValues + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun FriendChatListScreen() { + val context = LocalContext.current + val navController = LocalNavController.current + val AppColors = LocalAppTheme.current + val model = FriendChatListViewModel + + val state = rememberPullRefreshState( + refreshing = FriendChatListViewModel.refreshing, + onRefresh = { + FriendChatListViewModel.refreshPager(pullRefresh = true, context = context) + } + ) + + LaunchedEffect(Unit) { + FriendChatListViewModel.refreshPager(context = context) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(state) + ) { + if (FriendChatListViewModel.friendChatList.isEmpty() && !FriendChatListViewModel.isLoading) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + //verticalArrangement = Arrangement.Center + ) { + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(context) + + if (isNetworkAvailable) { + Spacer(modifier = Modifier.height(39.dp)) + Image( + painter = painterResource( + id = if(AppState.darkMode) R.mipmap.qs_py_qs_as_img + else R.mipmap.invalid_name_2), + contentDescription = "null data", + modifier = Modifier + .size(181.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.friend_chat_empty_title), + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.friend_chat_empty_subtitle), + color = AppColors.secondaryText, + fontSize = 14.sp + ) + }else { + Spacer(modifier = Modifier.height(39.dp)) + Image( + painter = painterResource(id = R.mipmap.invalid_name_10), + contentDescription = "network error", + modifier = Modifier + .size(181.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_title), + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_subtitle), + color = AppColors.secondaryText, + fontSize = 14.sp + ) + Spacer(modifier = Modifier.height(16.dp)) + ReloadButton( + onClick = { + FriendChatListViewModel.refreshPager(pullRefresh = true, context = context) + } + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + itemsIndexed( + items = FriendChatListViewModel.friendChatList, + key = { _, item -> item.id } + ) { index, item -> + FriendChatItem( + conversation = item, + onUserAvatarClick = { conv -> + FriendChatListViewModel.goToUserDetail(conv, navController) + }, + onChatClick = { conv -> + if (NetworkUtils.isNetworkAvailable(context)) { + FriendChatListViewModel.goToChat(conv, navController) + } else { + android.widget.Toast.makeText(context, "网络连接异常,请检查网络设置", android.widget.Toast.LENGTH_SHORT).show() + } + } + ) + + } + + if (FriendChatListViewModel.isLoading && FriendChatListViewModel.friendChatList.isNotEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = AppColors.main + ) + } + } + } + } + } + + PullRefreshIndicator( + refreshing = FriendChatListViewModel.refreshing, + state = state, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + + FriendChatListViewModel.error?.let { error -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = error, + color = AppColors.error, + fontSize = 14.sp + ) + } + } + } +} + +@Composable +fun FriendChatItem( + conversation: FriendConversation, + onUserAvatarClick: (FriendConversation) -> Unit = {}, + onChatClick: (FriendConversation) -> Unit = {} +) { + val AppColors = LocalAppTheme.current + val chatDebouncer = rememberDebouncer() + val avatarDebouncer = rememberDebouncer() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .noRippleClickable { + chatDebouncer { + onChatClick(conversation) + } + }, + verticalAlignment = Alignment.CenterVertically + ) { + Box { + CustomAsyncImage( + context = LocalContext.current, + imageUrl = conversation.avatar, + contentDescription = conversation.trtcUserId, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(48.dp)) + .noRippleClickable { + avatarDebouncer { + onUserAvatarClick(conversation) + } + } + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 12.dp, top = 2.dp), + verticalArrangement = Arrangement.Center + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = conversation.nickname, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = AppColors.text, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(6.dp)) + + Text( + text = conversation.lastMessageTime, + fontSize = 11.sp, + color = AppColors.secondaryText + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${if (conversation.isSelf) stringResource(R.string.friend_chat_me_prefix) else ""}${conversation.displayText}", + fontSize = 12.sp, + color = AppColors.secondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(10.dp)) + + if (conversation.unreadCount > 0) { + Box( + modifier = Modifier + .size(if (conversation.unreadCount > 99) 24.dp else 20.dp) + .background( + color = Color(0xFFFF3B30), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = if (conversation.unreadCount > 99) "99+" else conversation.unreadCount.toString(), + color = Color.White, + fontSize = if (conversation.unreadCount > 99) 11.sp else 12.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} +@Composable +fun ReloadButton( + onClick: () -> Unit +) { + val gradientBrush = Brush.linearGradient( + colors = listOf( + Color(0xFF7c45ed), + Color(0xFF7c68ef), + Color(0xFF7bd8f8) + ) + ) + + Button( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 120.dp) + .height(48.dp), + shape = RoundedCornerShape(30.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.Transparent + ), + contentPadding = PaddingValues(0.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(gradientBrush), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.Reload), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/FriendChatListViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/FriendChatListViewModel.kt new file mode 100644 index 0000000..394b9c7 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/FriendChatListViewModel.kt @@ -0,0 +1,192 @@ +package com.aiosman.ravenow.ui.index.tabs.message.tab + +import android.content.Context +import android.icu.util.Calendar +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.ConstVars +import com.aiosman.ravenow.data.UserService +import com.aiosman.ravenow.data.UserServiceImpl +import com.aiosman.ravenow.exp.formatChatTime +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.navigateToChat +// OpenIM SDK 导入 +import com.aiosman.ravenow.AppStore +import com.aiosman.ravenow.data.api.ApiClient +import io.openim.android.sdk.OpenIMClient +import io.openim.android.sdk.listener.OnBase +import io.openim.android.sdk.models.ConversationInfo +import io.openim.android.sdk.models.Message +import kotlinx.coroutines.launch +import kotlin.coroutines.suspendCoroutine +import com.aiosman.ravenow.utils.MessageParser + +data class FriendConversation( + val id: String, + val trtcUserId: String, + val nickname: String, + val lastMessage: String, + val lastMessageTime: String, + val avatar: String = "", + val unreadCount: Int = 0, + val displayText: String, + val isSelf: Boolean +) { + companion object { + fun convertToFriendConversation(conversation: ConversationInfo, context: Context): FriendConversation { + val lastMessage = Calendar.getInstance().apply { + timeInMillis = conversation.latestMsgSendTime + } + + // 解析最新消息 + val (displayText, isSelf) = MessageParser.parseLatestMessage(conversation.latestMsg) + + return FriendConversation( + id = conversation.conversationID, + nickname = conversation.showName ?: "", + lastMessage = displayText, // 使用解析后的显示文本 + lastMessageTime = lastMessage.time.formatChatTime(context), + avatar = "${ApiClient.BASE_API_URL+"/"}${conversation.faceURL}"+"?token="+"${AppStore.token}".replace("storage/avatars/", "/avatar/"), + unreadCount = conversation.unreadCount, + trtcUserId = conversation.userID ?: "", + displayText = displayText, + isSelf = isSelf // 使用解析后的发送者信息 + ) + } + } +} + +object FriendChatListViewModel : ViewModel() { + val userService: UserService = UserServiceImpl() + var friendChatList by mutableStateOf>(emptyList()) + var isLoading by mutableStateOf(false) + var refreshing by mutableStateOf(false) + var hasNext by mutableStateOf(true) + var currentPage by mutableStateOf(1) + var error by mutableStateOf(null) + + // 计算朋友总未读消息数 + val totalUnreadCount: Int + get() = friendChatList.sumOf { it.unreadCount } + + private val pageSize = 20 + + fun refreshPager(pullRefresh: Boolean = false, context: Context? = null) { + if (isLoading && !pullRefresh) return + viewModelScope.launch { + try { + isLoading = true + refreshing = pullRefresh + error = null + context?.let { loadFriendChatList(it) } + currentPage = 1 + } catch (e: Exception) { + error = "" + e.printStackTrace() + } finally { + isLoading = false + refreshing = false + } + } + } + + fun loadMore() { + if (isLoading || !hasNext) return + viewModelScope.launch { + try { + isLoading = true + error = null + // 腾讯IM的会话列表是一次性获取的,这里模拟分页 + // 实际项目中可能需要根据时间戳或其他方式实现真正的分页 + hasNext = false + } catch (e: Exception) { + error = "" + e.printStackTrace() + } finally { + isLoading = false + } + } + } + + private suspend fun loadFriendChatList(context: Context) { + // 检查 OpenIM 是否已登录 + if (!com.aiosman.ravenow.AppState.enableChat) { + android.util.Log.w("FriendChatListViewModel", "OpenIM 未登录,跳过加载朋友聊天列表") + return + } + + val result = suspendCoroutine { continuation -> + // OpenIM 获取所有会话列表 + OpenIMClient.getInstance().conversationManager.getAllConversationList( + object : OnBase> { + override fun onSuccess(data: List?) { + continuation.resumeWith(Result.success(data ?: emptyList())) + } + + override fun onError(code: Int, error: String?) { + continuation.resumeWith(Result.failure(Exception("Error $code: $error"))) + } + } + ) + } + + // 过滤出朋友会话(单聊类型,且排除 AI 智能体) + val filteredConversations = result.filter { conversation -> + // 1 表示单聊,排除 AI 智能体会话 + conversation.conversationType == 1 && + // 可以根据实际业务逻辑添加更多过滤条件 + // 比如排除 AI 智能体的 userID 前缀或标识 + !(conversation.userID?.startsWith("ai_") == true) + } + + friendChatList = filteredConversations.map { conversation -> + FriendConversation.convertToFriendConversation(conversation, context) + } + } + + fun goToChat( + conversation: FriendConversation, + navController: NavHostController + ) { + viewModelScope.launch { + try { + val profile = userService.getUserProfileByTrtcUserId(conversation.trtcUserId,0) + navController.navigateToChat(profile.id.toString()) + } catch (e: Exception) { + error = "" + e.printStackTrace() + } + } + } + + fun goToUserDetail( + conversation: FriendConversation, + navController: NavHostController + ) { + viewModelScope.launch { + try { + val profile = userService.getUserProfileByTrtcUserId(conversation.trtcUserId,0) + navController.navigate( + NavigationRoute.AccountProfile.route.replace( + "{id}", + profile.id.toString() + ) + ) + } catch (e: Exception) { + error = "" + e.printStackTrace() + } + } + } + + fun refreshConversation(context: Context, userId: String) { + viewModelScope.launch { + loadFriendChatList(context) + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/GroupChatListScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/GroupChatListScreen.kt new file mode 100644 index 0000000..4766c05 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/GroupChatListScreen.kt @@ -0,0 +1,302 @@ +package com.aiosman.ravenow.ui.index.tabs.message.tab + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.utils.NetworkUtils + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun GroupChatListScreen() { + val context = LocalContext.current + val navController = LocalNavController.current + val AppColors = LocalAppTheme.current + + val state = rememberPullRefreshState( + refreshing = GroupChatListViewModel.refreshing, + onRefresh = { + GroupChatListViewModel.refreshPager(pullRefresh = true, context = context) + } + ) + + LaunchedEffect(Unit) { + GroupChatListViewModel.refreshPager(context = context) + } + + + Column( + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(state) + ) { + if (GroupChatListViewModel.groupChatList.isEmpty() && !GroupChatListViewModel.isLoading) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(context) + + if (isNetworkAvailable) { + Spacer(modifier = Modifier.height(39.dp)) + Image( + painter = painterResource( + id = if(AppState.darkMode) R.mipmap.qs_ql_qs_as_img + else R.mipmap.invalid_name_12), + contentDescription = "null data", + modifier = Modifier + .size(181.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.group_chat_empty), + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.group_chat_empty_join), + color = AppColors.secondaryText, + fontSize = 14.sp + ) + }else { + Spacer(modifier = Modifier.height(39.dp)) + Image( + painter = painterResource(id = R.mipmap.invalid_name_10), + contentDescription = "network error", + modifier = Modifier + .size(181.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_title), + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_subtitle), + color = AppColors.secondaryText, + fontSize = 14.sp + ) + Spacer(modifier = Modifier.height(16.dp)) + ReloadButton( + onClick = { + GroupChatListViewModel.refreshPager(context = context) + } + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + itemsIndexed( + items = GroupChatListViewModel.groupChatList, + key = { _, item -> item.id } + ) { index, item -> + GroupChatItem( + conversation = item, + onGroupAvatarClick = { conv -> + GroupChatListViewModel.goToGroupDetail(conv, navController) + }, + onChatClick = { conv -> + if (NetworkUtils.isNetworkAvailable(context)) { + GroupChatListViewModel.goToChat(conv, navController) + } else { + android.widget.Toast.makeText(context, "网络连接异常,请检查网络设置", android.widget.Toast.LENGTH_SHORT).show() + } + } + ) + + if (index < GroupChatListViewModel.groupChatList.size - 1) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 24.dp), + color = AppColors.divider + ) + } + } + + if (GroupChatListViewModel.isLoading && GroupChatListViewModel.groupChatList.isNotEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = AppColors.main + ) + } + } + } + } + } + + PullRefreshIndicator( + refreshing = GroupChatListViewModel.refreshing, + state = state, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + + GroupChatListViewModel.error?.let { error -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = error, + color = AppColors.error, + fontSize = 14.sp + ) + } + } + } +} + +@Composable +fun GroupChatItem( + conversation: GroupConversation, + onGroupAvatarClick: (GroupConversation) -> Unit = {}, + onChatClick: (GroupConversation) -> Unit = {} +) { + val AppColors = LocalAppTheme.current + val chatDebouncer = rememberDebouncer() + val avatarDebouncer = rememberDebouncer() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp) + .noRippleClickable { + chatDebouncer { + onChatClick(conversation) + } + } + ) { + Box { + CustomAsyncImage( + context = LocalContext.current, + imageUrl = conversation.avatar, + contentDescription = conversation.groupName, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .noRippleClickable { + avatarDebouncer { + onGroupAvatarClick(conversation) + } + } + ) + } + + Column( + modifier = Modifier + + .weight(1f) + .padding(start = 12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = conversation.groupName, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = AppColors.text, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = conversation.lastMessageTime, + fontSize = 12.sp, + color = AppColors.secondaryText + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${if (conversation.isSelf) stringResource(R.string.friend_chat_me_prefix) else ""}${conversation.displayText}", + fontSize = 14.sp, + color = AppColors.secondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + if (conversation.unreadCount > 0) { + Box( + modifier = Modifier + .size(if (conversation.unreadCount > 99) 24.dp else 20.dp) + .background( + color = AppColors.main, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = if (conversation.unreadCount > 99) "99+" else conversation.unreadCount.toString(), + color = AppColors.mainText, + fontSize = if (conversation.unreadCount > 99) 9.sp else 10.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/GroupChatListViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/GroupChatListViewModel.kt new file mode 100644 index 0000000..f9c7367 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/GroupChatListViewModel.kt @@ -0,0 +1,282 @@ +package com.aiosman.ravenow.ui.index.tabs.message.tab + +import android.content.Context +import android.icu.util.Calendar +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.AppStore +import com.aiosman.ravenow.ConstVars +import com.aiosman.ravenow.data.UserService +import com.aiosman.ravenow.data.UserServiceImpl +import com.aiosman.ravenow.data.api.ApiClient +import com.aiosman.ravenow.data.api.GroupChatRequestBody +import com.aiosman.ravenow.exp.formatChatTime +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.navigateToChat +import com.aiosman.ravenow.ui.navigateToGroupChat +import com.aiosman.ravenow.ui.navigateToGroupInfo +// OpenIM SDK 导入 +import io.openim.android.sdk.OpenIMClient +import io.openim.android.sdk.listener.OnAdvanceMsgListener +import io.openim.android.sdk.listener.OnBase +import io.openim.android.sdk.listener.OnConversationListener +import io.openim.android.sdk.models.ConversationInfo +import io.openim.android.sdk.models.Message +import kotlinx.coroutines.launch +import kotlin.coroutines.suspendCoroutine +import com.aiosman.ravenow.utils.MessageParser + +data class GroupConversation( + val id: String, + val groupId: String, + val groupName: String, + val lastMessage: String, + val lastMessageTime: String, + val avatar: String = "", + val unreadCount: Int = 0, + val displayText: String, + val isSelf: Boolean, + val memberCount: Int = 0 +) { + companion object { + fun convertToGroupConversation(conversation: ConversationInfo, context: Context): GroupConversation { + val lastMessage = Calendar.getInstance().apply { + timeInMillis = conversation.latestMsgSendTime + } + + // 解析最新消息 + val (displayText, isSelf) = MessageParser.parseLatestMessage(conversation.latestMsg) + + return GroupConversation( + id = conversation.conversationID, + groupId = conversation.groupID ?: "", + groupName = conversation.showName ?: "", + lastMessage = displayText, // 使用解析后的显示文本 + lastMessageTime = lastMessage.time.formatChatTime(context), + avatar = if (conversation.faceURL.isNullOrEmpty()) { + // 将 groupId 转换为 Base64 + val groupIdBase64 = android.util.Base64.encodeToString( + (conversation.groupID ?: "").toByteArray(), + android.util.Base64.NO_WRAP + ) + "${ApiClient.RETROFIT_URL+"group/avatar?groupIdBase64="}${groupIdBase64}"+"&token="+"${AppStore.token}" + } else { + "${ApiClient.BASE_API_URL+"/outside/rooms/avatar/"}${conversation.faceURL}"+"?token="+"${AppStore.token}" + }, + unreadCount = conversation.unreadCount, + displayText = displayText, + isSelf = isSelf, // 使用解析后的发送者信息 + memberCount = 0 // TODO: 获取群组成员数量 + ) + } + } +} + +object GroupChatListViewModel : ViewModel() { + val userService: UserService = UserServiceImpl() + var groupChatList by mutableStateOf>(emptyList()) + var isLoading by mutableStateOf(false) + var refreshing by mutableStateOf(false) + var hasNext by mutableStateOf(true) + var currentPage by mutableStateOf(1) + var error by mutableStateOf(null) + + // 计算群聊总未读消息数 + val totalUnreadCount: Int + get() = groupChatList.sumOf { it.unreadCount } + + private val pageSize = 20 + + // 消息监听器 + private var messageListener: OnAdvanceMsgListener? = null + private var conversationListener: OnConversationListener? = null + + fun refreshPager(pullRefresh: Boolean = false, context: Context? = null) { + if (isLoading && !pullRefresh) return + viewModelScope.launch { + try { + isLoading = true + refreshing = pullRefresh + error = null + context?.let { loadGroupChatList(it) } + currentPage = 1 + } catch (e: Exception) { + error = "" + e.printStackTrace() + } finally { + isLoading = false + refreshing = false + } + } + } + + fun loadMore() { + if (isLoading || !hasNext) return + viewModelScope.launch { + try { + isLoading = true + error = null + // 腾讯IM的会话列表是一次性获取的,这里模拟分页 + // 实际项目中可能需要根据时间戳或其他方式实现真正的分页 + hasNext = false + } catch (e: Exception) { + error = "" + e.printStackTrace() + } finally { + isLoading = false + } + } + } + + private suspend fun loadGroupChatList(context: Context) { + // 检查 OpenIM 是否已登录 + if (!com.aiosman.ravenow.AppState.enableChat) { + android.util.Log.w("GroupChatListViewModel", "OpenIM 未登录,跳过加载群聊列表") + return + } + + val result = suspendCoroutine { continuation -> + // OpenIM 获取所有会话列表 + OpenIMClient.getInstance().conversationManager.getAllConversationList( + object : OnBase> { + override fun onSuccess(data: List?) { + continuation.resumeWith(Result.success(data ?: emptyList())) + } + + override fun onError(code: Int, error: String?) { + continuation.resumeWith(Result.failure(Exception("Error $code: $error"))) + } + } + ) + } + + // 过滤出群聊会话(群聊类型) + val filteredConversations = result.filter { conversation -> + // 2 表示群聊类型 + conversation.conversationType == 2 + } + + groupChatList = filteredConversations.map { conversation -> + GroupConversation.convertToGroupConversation(conversation, context) + } + } + + fun createGroupChat( + trtcGroupId: String? = null, + roomId: Int? = null + ) { + viewModelScope.launch { + val response = ApiClient.api.createGroupChatAi(trtcGroupId = trtcGroupId,roomId = roomId) + Log.d("debug",response.toString()) + } + + } + + fun goToChat( + conversation: GroupConversation, + navController: NavHostController + ) { + viewModelScope.launch { + try { + createGroupChat(trtcGroupId = conversation.groupId) + // 群聊直接使用群ID进行导航 + navController.navigateToGroupChat(conversation.groupId, conversation.groupName, conversation.avatar) + } catch (e: Exception) { + error = "" + e.printStackTrace() + } + } + } + + fun goToGroupDetail( + conversation: GroupConversation, + navController: NavHostController + ) { + viewModelScope.launch { + try { + // 导航到群聊详情页面 + navController.navigateToGroupInfo(conversation.groupId) + } catch (e: Exception) { + error = "" + e.printStackTrace() + } + } + } + + fun refreshConversation(context: Context, groupId: String) { + viewModelScope.launch { + loadGroupChatList(context) + } + } + + // 初始化消息监听器 + fun initMessageListener(context: Context) { + // 消息监听器 - 监听新消息 + messageListener = object : OnAdvanceMsgListener { + override fun onRecvNewMessage(msg: io.openim.android.sdk.models.Message?) { + msg?.let { message -> + if (!message.groupID.isNullOrEmpty()) { + // 收到群聊消息,刷新群聊列表 + android.util.Log.i("GroupChatList", "收到群聊消息,刷新列表") + refreshGroupChatList(context) + } + } + } + } + + // 会话监听器 - 监听会话变化 + conversationListener = object : OnConversationListener { + override fun onConversationChanged(conversationList: List?) { + // 会话发生变化,刷新群聊列表 + conversationList?.let { conversations -> + val hasGroupConversation = conversations.any { it.conversationType == 2 } + if (hasGroupConversation) { + android.util.Log.i("GroupChatList", "群聊会话发生变化,刷新列表") + refreshGroupChatList(context) + } + } + } + + override fun onNewConversation(conversationList: List?) { + // 新增会话,刷新群聊列表 + conversationList?.let { conversations -> + val hasGroupConversation = conversations.any { it.conversationType == 2 } + if (hasGroupConversation) { + android.util.Log.i("GroupChatList", "新增群聊会话,刷新列表") + refreshGroupChatList(context) + } + } + } + } + + // 注册监听器 + OpenIMClient.getInstance().messageManager.setAdvancedMsgListener(messageListener) + OpenIMClient.getInstance().conversationManager.setOnConversationListener(conversationListener) + } + + // 移除消息监听器 + fun removeMessageListener() { + // OpenIM SDK 不需要显式移除监听器,只需要设置为 null + messageListener = null + conversationListener = null + } + + // 刷新群聊列表 + private fun refreshGroupChatList(context: Context) { + viewModelScope.launch { + try { + loadGroupChatList(context) + } catch (e: Exception) { + android.util.Log.e("GroupChatList", "刷新群聊列表失败: ${e.message}") + } + } + } + + +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/BaseMomentModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/BaseMomentModel.kt new file mode 100644 index 0000000..b0f3cc0 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/BaseMomentModel.kt @@ -0,0 +1,143 @@ +package com.aiosman.ravenow.ui.index.tabs.moment + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +// import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.aiosman.ravenow.data.MomentService +import com.aiosman.ravenow.data.UserServiceImpl +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.entity.MomentLoader +import com.aiosman.ravenow.entity.MomentLoaderExtraArgs +import com.aiosman.ravenow.entity.MomentServiceImpl +import com.aiosman.ravenow.event.FollowChangeEvent +import com.aiosman.ravenow.event.MomentAddEvent +import com.aiosman.ravenow.event.MomentFavouriteChangeEvent +import com.aiosman.ravenow.event.MomentLikeChangeEvent +import com.aiosman.ravenow.event.MomentRemoveEvent +import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe + +open class BaseMomentModel :ViewModel(){ + private val momentService: MomentService = MomentServiceImpl() + private val userService = UserServiceImpl() + + + val momentLoader = MomentLoader().apply { + onListChanged = { + // 使用新实例触发 Compose 重组 + moments = it.toList() + } + } + var refreshing by mutableStateOf(false) + var isFirstLoad = true + var moments by mutableStateOf>(listOf()) + open fun extraArgs(): MomentLoaderExtraArgs { + return MomentLoaderExtraArgs() + } + fun refreshPager(pullRefresh: Boolean = false) { + if (!isFirstLoad && !pullRefresh) { + return + } + isFirstLoad = false + momentLoader.clear() + viewModelScope.launch { + momentLoader.loadData(extraArgs()) + } + } + + fun loadMore() { + viewModelScope.launch { + momentLoader.loadMore(extraArgs()) + // 使用新实例触发 Compose 重组 + moments = momentLoader.list.toList() + } + } + + + @Subscribe + fun onMomentLikeChangeEvent(event: MomentLikeChangeEvent) { + momentLoader.updateMomentLike(event.postId, event.isLike) + } + suspend fun likeMoment(id: Int) { + momentService.likeMoment(id) + momentLoader.updateMomentLike(id, true) + } + suspend fun dislikeMoment(id: Int) { + momentService.dislikeMoment(id) + momentLoader.updateMomentLike(id, false) + } + + + fun onAddComment(id: Int) { + momentLoader.updateCommentCount(id, +1) + } + + fun onDeleteComment(id: Int) { + momentLoader.updateCommentCount(id, -1) + } + + + + @Subscribe + fun onMomentFavoriteChangeEvent(event: MomentFavouriteChangeEvent) { + momentLoader.updateFavoriteCount(event.postId, event.isFavourite) + } + + suspend fun favoriteMoment(id: Int) { + momentService.favoriteMoment(id) + momentLoader.updateFavoriteCount(id, true) + } + + + suspend fun unfavoriteMoment(id: Int) { + momentService.unfavoriteMoment(id) + momentLoader.updateFavoriteCount(id, false) + + } + + @Subscribe + fun onRemoveMomentEvent(event: MomentRemoveEvent) { + momentLoader.removeMoment(event.postId) + } + + @Subscribe + fun onAddMomentEvent(event: MomentAddEvent) { + momentLoader.addMoment(event.moment) + } + + @Subscribe + fun onFollowChangeEvent(event: FollowChangeEvent) { + momentLoader.updateFollowStatus(event.userId, event.isFollow) + } + + + fun followAction(moment: MomentEntity) { + viewModelScope.launch { + try { + if (moment.followStatus) { + userService.unFollowUser(moment.authorId.toString()) + EventBus.getDefault().post(FollowChangeEvent(moment.authorId, false)) + } else { + userService.followUser(moment.authorId.toString()) + EventBus.getDefault().post(FollowChangeEvent(moment.authorId, true)) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun ResetModel() { + momentLoader.clear() + isFirstLoad = true + } + + override fun onCleared() { + super.onCleared() + EventBus.getDefault().unregister(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/Moment.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/Moment.kt new file mode 100644 index 0000000..665c1a3 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/Moment.kt @@ -0,0 +1,288 @@ +package com.aiosman.ravenow.ui.index.tabs.moment + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +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.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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.index.tabs.moment.tabs.dynamic.Dynamic +import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.Explore +import com.aiosman.ravenow.ui.index.tabs.moment.tabs.hot.HotMomentsList +import com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline.TimelineMomentsList +import com.aiosman.ravenow.ui.index.tabs.moment.tabs.news.NewsScreen +import com.aiosman.ravenow.ui.index.tabs.search.SearchViewModel +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import kotlinx.coroutines.launch +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import com.aiosman.ravenow.ui.composables.TabItem +import com.aiosman.ravenow.ui.composables.UnderlineTabItem +import com.aiosman.ravenow.ui.composables.rememberDebouncer + +/** + * 动态列表 + */ +@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) +@Composable +fun MomentsList() { + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + val navigationBarPaddings = + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 48.dp + val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues() + // 现在有6个tab:推荐、短视频、新闻、探索、关注、热门 + val tabCount = 6 + var pagerState = rememberPagerState { tabCount } + var scope = rememberCoroutineScope() + Column( + modifier = Modifier + .fillMaxSize() + .padding( + top = statusBarPaddingValues.calculateTopPadding(), + bottom = navigationBarPaddings + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // 顶部区域:可滚动的标签页 + 搜索按钮 + Row( + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // 可滚动的标签页行 + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + val tabDebouncer = rememberDebouncer() + + // 推荐标签 + UnderlineTabItem( + text = stringResource(R.string.tab_recommend), + isSelected = pagerState.currentPage == 0, + onClick = { + tabDebouncer { + scope.launch { + pagerState.animateScrollToPage(0) + } + } + } + ) + + + // 短视频标签 + UnderlineTabItem( + text = stringResource(R.string.tab_short_video), + isSelected = pagerState.currentPage == 1, + onClick = { + tabDebouncer { + scope.launch { + pagerState.animateScrollToPage(1) + } + } + } + ) + + + // 动态标签 + UnderlineTabItem( + text = stringResource(R.string.moment), + isSelected = pagerState.currentPage == 2, + onClick = { + tabDebouncer { + scope.launch { + pagerState.animateScrollToPage(2) + } + } + } + ) + + + // 只有非游客用户才显示"关注"tab + if (!AppStore.isGuest) { + UnderlineTabItem( + text = stringResource(R.string.index_following), + isSelected = pagerState.currentPage == 3, + onClick = { + tabDebouncer { + scope.launch { + pagerState.animateScrollToPage(3) + } + } + } + ) + + + // 热门标签 + UnderlineTabItem( + text = stringResource(R.string.index_hot), + isSelected = pagerState.currentPage == 4, + onClick = { + tabDebouncer { + scope.launch { + pagerState.animateScrollToPage(4) + } + } + } + ) + } else { + // 热门标签 (游客模式) + UnderlineTabItem( + text = stringResource(R.string.index_hot), + isSelected = pagerState.currentPage == 4, + onClick = { + tabDebouncer { + scope.launch { + pagerState.animateScrollToPage(4) + } + } + } + ) + } + + + // 新闻标签 + UnderlineTabItem( + text = stringResource(R.string.tab_news), + isSelected = pagerState.currentPage == 5, + onClick = { + tabDebouncer { + scope.launch { + pagerState.animateScrollToPage(5) + } + } + } + ) + } + + // 搜索按钮 + val lastClickTime = remember { mutableStateOf(0L) } + val clickDelay = 500L + Image( + painter = painterResource(id = R.drawable.rider_pro_nav_search), + contentDescription = "search", + modifier = Modifier + .size(24.dp) + .noRippleClickable { + val currentTime = System.currentTimeMillis() + if (currentTime - lastClickTime.value > clickDelay) { + lastClickTime.value = currentTime + navController.navigate(NavigationRoute.Search.route) + } + }, + colorFilter = ColorFilter.tint(AppColors.text) + ) + } + + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + when (it) { + 0 -> { + // 推荐页面 + NewsScreen() + } + 1 -> { + // 短视频页面 + } + 2 -> { + // 动态页面 - 暂时显示时间线内容 + Dynamic() + } + 3 -> { + // 关注页面 (仅非游客用户) 或 热门页面 (游客用户) + if (AppStore.isGuest) { + HotMomentsList() + } else { + TimelineMomentsList() + } + } + 4 -> { + // 热门页面 (仅非游客用户) + HotMomentsList() + } + 5 -> { + // 新闻页面 + NewsScreen() + } + } + } + } +} +@Composable +fun CustomTabItem( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val AppColors = LocalAppTheme.current + + Column( + modifier = modifier + .noRippleClickable { onClick() }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = text, + fontSize = 15.sp, + color = if (isSelected) AppColors.tabSelectedText else AppColors.tabUnselectedText, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(if (isSelected) AppColors.tabSelectedBackground else AppColors.tabUnselectedBackground) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + } +} + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/MomentViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/MomentViewModel.kt new file mode 100644 index 0000000..2411128 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/MomentViewModel.kt @@ -0,0 +1,8 @@ +package com.aiosman.ravenow.ui.index.tabs.moment + +import androidx.lifecycle.ViewModel + + +object MomentViewModel : ViewModel() { + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/dynamic/Dynamic.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/dynamic/Dynamic.kt new file mode 100644 index 0000000..04b2511 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/dynamic/Dynamic.kt @@ -0,0 +1,174 @@ +package com.aiosman.ravenow.ui.index.tabs.moment.tabs.dynamic + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.aiosman.ravenow.GuestLoginCheckOut +import com.aiosman.ravenow.GuestLoginCheckOutScene +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.MomentCard +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import kotlinx.coroutines.launch + +/** + * 动态列表 + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun Dynamic() { + val model = DynamicViewModel + val moments = model.moments + val navController = LocalNavController.current + + val scope = rememberCoroutineScope() + val state = rememberPullRefreshState(model.refreshing, onRefresh = { + model.refreshPager( + pullRefresh = true + ) + }) + val listState = rememberLazyListState() + + // 用于跟踪是否已经触发过加载更多 + var hasTriggeredLoadMore by remember { mutableStateOf(false) } + + // observe list scrolling with simplified logic + val reachedBottom by remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull() + val totalItems = layoutInfo.totalItemsCount + + if (lastVisibleItem == null || totalItems == 0) { + false + } else { + val isLastItemVisible = lastVisibleItem.index >= totalItems - 2 + + // 简化逻辑:只要滑动到底部且还没有触发过,就触发加载 + isLastItemVisible && !hasTriggeredLoadMore + } + } + } + + // load more if scrolled to bottom + LaunchedEffect(reachedBottom) { + if (reachedBottom) { + hasTriggeredLoadMore = true + model.loadMore() + } + } + LaunchedEffect(Unit) { + model.refreshPager() + } + + // 监听数据变化,重置加载状态 + LaunchedEffect(moments.size) { + if (moments.size > 0) { + // 延迟重置,确保数据已经稳定 + kotlinx.coroutines.delay(500) + hasTriggeredLoadMore = false + } + } + Column( + modifier = Modifier + .fillMaxSize() + + ) { + Box(Modifier.pullRefresh(state)) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState + ) { + items( + moments.size, + key = { idx -> idx } + ) { idx -> + //处理下标越界 + if (idx < 0 || idx >= moments.size) return@items + val momentItem = moments[idx] ?: return@items + + val commentDebouncer = rememberDebouncer() + val likeDebouncer = rememberDebouncer() + val favoriteDebouncer = rememberDebouncer() + val followDebouncer = rememberDebouncer() + + MomentCard( + momentEntity = momentItem, + onAddComment = { + commentDebouncer { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + scope.launch { + model.onAddComment(momentItem.id) + } + } + } + }, + onLikeClick = { + likeDebouncer { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + scope.launch { + if (momentItem.liked) { + model.dislikeMoment(momentItem.id) + } else { + model.likeMoment(momentItem.id) + } + } + } + } + }, + onFavoriteClick = { + favoriteDebouncer { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + scope.launch { + if (momentItem.isFavorite) { + model.unfavoriteMoment(momentItem.id) + } else { + model.favoriteMoment(momentItem.id) + } + } + } + } + }, + onFollowClick = { + followDebouncer { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.FOLLOW_USER)) { + navController.navigate(NavigationRoute.Login.route) + } else { + model.followAction(momentItem) + } + } + }, + showFollowButton = true + ) + } + } + PullRefreshIndicator(model.refreshing, state, Modifier.align(Alignment.TopCenter)) + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/dynamic/DynamicViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/dynamic/DynamicViewModel.kt new file mode 100644 index 0000000..c94b1c6 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/dynamic/DynamicViewModel.kt @@ -0,0 +1,17 @@ +package com.aiosman.ravenow.ui.index.tabs.moment.tabs.dynamic + +import com.aiosman.ravenow.entity.MomentLoaderExtraArgs +import com.aiosman.ravenow.ui.index.tabs.moment.BaseMomentModel +import org.greenrobot.eventbus.EventBus + + +object DynamicViewModel : BaseMomentModel() { + init { + EventBus.getDefault().register(this) + + } + override fun extraArgs(): MomentLoaderExtraArgs { + return MomentLoaderExtraArgs(explore = true) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/expolre/Explore.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/expolre/Explore.kt new file mode 100644 index 0000000..29a041a --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/expolre/Explore.kt @@ -0,0 +1,1023 @@ +package com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre + +import android.annotation.SuppressLint +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Card +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +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.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import androidx.lifecycle.viewmodel.compose.viewModel +import com.aiosman.ravenow.AppStore +import com.aiosman.ravenow.GuestLoginCheckOut +import com.aiosman.ravenow.GuestLoginCheckOutScene +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import kotlinx.coroutines.launch +import com.aiosman.ravenow.data.Room +import com.aiosman.ravenow.data.Agent +import com.aiosman.ravenow.data.api.ApiClient +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.AgentCard +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.post.NewPostViewModel + +// Banner数据类 +data class BannerItem( + val id: Int, + val title: String, + val subtitle: String, + val imageUrl: String, + val backgroundImageUrl: String, + val userCount: Int, + val agentName: String, + val trtcId: String, + val avatar: String +) { + companion object { + fun fromRoom(room: Room): BannerItem { + return BannerItem( + id = room.id, + title = room.name , + subtitle = room.description , + imageUrl = "${ApiClient.RETROFIT_URL}${room.creator.profile.avatar}"+"?token="+"${AppStore.token}" ?: "", + backgroundImageUrl = "${ApiClient.BASE_API_URL+"/outside"}${room.recommendBanner}"+"?token="+"${AppStore.token}" ?: "", + userCount = room.userCount, + agentName = room.creator.profile.nickname, + trtcId = room.trtcRoomId, + avatar = if (room.avatar.isNullOrEmpty()) { + // 将 groupId 转换为 Base64 + val groupIdBase64 = android.util.Base64.encodeToString( + room.trtcType.toByteArray(), + android.util.Base64.NO_WRAP + ) + "${ApiClient.RETROFIT_URL+"group/avatar?groupIdBase64="}${groupIdBase64}"+"&token="+"${AppStore.token}" + } else { + "${ApiClient.BASE_API_URL+"/outside/"}${room.avatar}"+"?token="+"${AppStore.token}" + } + ) + } + } +} + +// Agent数据类 +data class AgentItem( + val id: Int, + val title: String, + val desc: String, + val avatar: String, + val useCount: Int, + val openId: String +) { + companion object { + fun fromAgent(agent: Agent): AgentItem { + return AgentItem( + id = agent.id, + title = agent.title, + desc = agent.desc, + avatar = "${ApiClient.BASE_API_URL+"/outside"}${agent.avatar}"+"?token="+"${AppStore.token}", + useCount = agent.useCount, + openId = agent.openId, + ) + } + } +} + +/** + * 探索页面 + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun Explore() { + val navController = LocalNavController.current + val AppColors = LocalAppTheme.current + val scope = rememberCoroutineScope() + val viewModel: ExploreViewModel = viewModel() + + // 模拟刷新状态 + var isRefreshing by remember { mutableStateOf(false) } + + val enterSuccessText = stringResource(R.string.group_room_enter_success) + val enterFailText = stringResource(R.string.group_room_enter_fail) // 假设有这个资源 + + // 监听ViewModel的刷新状态 + LaunchedEffect(viewModel.isRefreshing) { + isRefreshing = viewModel.isRefreshing + } + + val pullRefreshState = rememberPullRefreshState( + refreshing = isRefreshing, + onRefresh = { + scope.launch { + // 重新请求数据 + viewModel.refreshBannerData() + viewModel.refreshAgentData() + } + } + ) + + @SuppressLint("SuspiciousIndentation") + @Composable + fun AgentCard2(agentItem: AgentItem) { + val AppColors = LocalAppTheme.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 左侧头像 + Box( + modifier = Modifier + .size(48.dp) + .background(Color(0xFFF5F5F5), RoundedCornerShape(24.dp)), + contentAlignment = Alignment.Center + ) { + if (agentItem.avatar.isNotEmpty()) { + CustomAsyncImage( + imageUrl = agentItem.avatar, + contentDescription = "Agent头像", + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(24.dp)), + contentScale = androidx.compose.ui.layout.ContentScale.Crop + ) + } else { + Image( + painter = painterResource(R.mipmap.rider_pro_agent), + contentDescription = "默认头像", + modifier = Modifier.size(24.dp), + colorFilter = ColorFilter.tint(AppColors.secondaryText) + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + // 中间文字内容 + Column( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) { + // 标题 + Text( + text = agentItem.title, + fontSize = 14.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.W600, + color = AppColors.text, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 描述 + Text( + text = agentItem.desc, + fontSize = 12.sp, + color = AppColors.secondaryText, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) + } + + // 右侧聊天按钮 + Box( + modifier = Modifier + .size(width = 60.dp, height = 32.dp) + .background( + color = Color(0X147c7480), + shape = RoundedCornerShape(8.dp) + ) + .clickable { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + viewModel.createSingleChat(agentItem.openId) + viewModel.goToChatAi(agentItem.openId, navController = navController) + } + }, + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.chat), + fontSize = 12.sp, + color = AppColors.text, + fontWeight = androidx.compose.ui.text.font.FontWeight.W500 + ) + } + } + } + + @Composable + fun AgentPage(agentItems: List, page: Int, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 0.dp) + ) { + // 显示3个agent + agentItems.forEachIndexed { index, agentItem -> + AgentCard2(agentItem = agentItem) + if (index < agentItems.size - 1) { + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + + @OptIn(ExperimentalFoundationApi::class) + @Composable + fun AgentViewPagerSection(agentItems: List) { + val AppColors = LocalAppTheme.current + + // 每页显示3个agent + val itemsPerPage = 3 + val totalPages = (agentItems.size + itemsPerPage - 1) / itemsPerPage + + if (totalPages > 0) { + val pagerState = rememberPagerState(pageCount = { totalPages }) + + Column { + // Agent内容 + Box( + modifier = Modifier + .fillMaxWidth() + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp), + pageSpacing = 0.dp + ) { page -> + // 计算当前页面的偏移量 + val pageOffset = ( + (pagerState.currentPage - page) + pagerState + .currentPageOffsetFraction + ).coerceIn(-1f, 1f) + + // 根据偏移量计算缩放比例 + val scale = 1f - (0.1f * kotlin.math.abs(pageOffset)) + + AgentPage( + agentItems = agentItems.drop(page * itemsPerPage).take(itemsPerPage), + page = page, + modifier = Modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + } + ) + } + } + + // 指示器 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center + ) { + repeat(totalPages) { index -> + Box( + modifier = Modifier + .padding(horizontal = 4.dp) + .size(3.dp) + .background( + color = if (pagerState.currentPage == index) AppColors.text else AppColors.secondaryText.copy(alpha = 0.3f), + shape = androidx.compose.foundation.shape.CircleShape + ) + ) + } + } + } + } + } + + @Composable + fun HotChatRoomGridItem(roomItem: BannerItem) { + val AppColors = LocalAppTheme.current + val context = LocalContext.current + Row( + modifier = Modifier + .padding(horizontal = 2.dp, vertical = 4.dp) + .background( when (roomItem.id % 6) { + 0 -> Color(0x285856d6) + 1 -> Color(0x2832ade6) + 2 -> Color(0x28ff9500) + 3 -> Color(0x28ff3b30) + 4 -> Color(0x28af52de) + else -> Color(0x28ffcc00) + }, + shape = RoundedCornerShape(12.dp)) + .clickable { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.JOIN_GROUP_CHAT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + // 调用加入房间接口 + viewModel.joinRoom( + trtcId = roomItem.trtcId.toString(), + name = roomItem.title, + avatar = roomItem.avatar, + context = context, + navController = navController, + onSuccess = { + Toast.makeText(context, enterSuccessText, Toast.LENGTH_SHORT).show() + }, + onError = { errorMessage -> + Toast.makeText(context, enterFailText, Toast.LENGTH_SHORT).show() + } + ) + } + }, + + verticalAlignment = Alignment.CenterVertically + ) { + // 左侧图标 + Spacer(modifier = Modifier.width(16.dp)) + Box( + modifier = Modifier + .size(24.dp), + contentAlignment = Alignment.Center + ) { + + Icon( + painter = painterResource(id = R.drawable.rider_pro_group_chat), + contentDescription = null, + modifier = Modifier + .size(24.dp), + tint = AppColors.text + ) + } + + Spacer(modifier = Modifier.width(6.dp)) + + // 右侧文字 + Text( + text = roomItem.title, + fontSize = 14.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.W500, + color = AppColors.text, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.padding(end = 16.dp, top = 8.dp, bottom = 8.dp) + ) + } + } + + @Composable + fun HotChatRoomGrid(roomItems: List) { + val AppColors = LocalAppTheme.current + + Column( + modifier = Modifier + .wrapContentWidth() + .padding(vertical = 2.dp) + ) { + repeat(3) { rowIndex -> + Row( + modifier = Modifier + .wrapContentWidth() + .padding(vertical = 2.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp) + ) { + repeat(3) { columnIndex -> + val itemIndex = rowIndex * 2 + columnIndex + if (itemIndex < roomItems.size) { + val roomItem = roomItems[itemIndex] + HotChatRoomGridItem(roomItem = roomItem) + } else { + // 填充空白占位 + //Spacer(modifier = Modifier.width(80.dp)) + } + } + } + } + } + } + + @Composable + fun HotChatRoomGridSection(roomItems: List) { + val AppColors = LocalAppTheme.current + + // 每行显示3个数据,总共3行 + val itemsPerRow = 3 + val totalRows = 3 + val totalItems = itemsPerRow * totalRows + + // 计算需要多少个完整的3x3网格 + val gridCount = (roomItems.size + totalItems - 1) / totalItems + + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp) + ) { + repeat(1) { gridIndex -> + item { + HotChatRoomGrid(roomItems = viewModel.bannerItems.take(8)) + } + } + } + } + + + + @Composable + fun BannerCard(bannerItem: BannerItem, viewModel: ExploreViewModel, modifier: Modifier = Modifier) { + val AppColors = LocalAppTheme.current + val context = LocalContext.current + val navController = LocalNavController.current + + Card( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 0.dp), + shape = RoundedCornerShape(20.dp), + elevation = androidx.compose.material3.CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + // 背景图片 + if (bannerItem.backgroundImageUrl.isNotEmpty()) { + CustomAsyncImage( + context = context, + imageUrl = bannerItem.backgroundImageUrl, + contentDescription = "背景图片", + modifier = Modifier.fillMaxSize(), + contentScale = androidx.compose.ui.layout.ContentScale.Crop + ) + } else { + // 如果没有背景图片,使用默认渐变背景 + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = androidx.compose.ui.graphics.Brush.linearGradient( + colors = listOf( + Color(0xFF4CAF50), + Color(0xFF4CAF50).copy(alpha = 0.8f) + ) + ) + ) + ) + } + + // 内容 + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween + ) { + // 顶部:用户数量 + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.rider_pro_nav_profile), + contentDescription = "chat", + modifier = Modifier.size(16.dp), + colorFilter = ColorFilter.tint(Color.White) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${bannerItem.userCount}人在聊", + fontSize = 12.sp, + color = Color.White, + fontWeight = androidx.compose.ui.text.font.FontWeight.W500 + ) + } + + // 底部:标题和描述 + Column { + Text( + text = bannerItem.title, + fontSize = 24.sp, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + color = Color.White, + modifier = Modifier.padding(start = 16.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = bannerItem.subtitle, + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.9f), + maxLines = 2, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 16.dp, end = 16.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth() + .background(brush = androidx.compose.ui.graphics.Brush.verticalGradient( + colors = listOf( + Color(0x00000000), // 底部颜色(透明) + Color(0x33000000), // 顶部颜色 + + ) + )), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically){ + // 左侧头像 + Box( + modifier = Modifier + .size(24.dp) + .background( + Color.White.copy(alpha = 0.2f), + RoundedCornerShape(16.dp) + ) + + ) { + CustomAsyncImage( + context = context, + imageUrl = bannerItem.imageUrl, + contentDescription = "agent Image", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + .clip(RoundedCornerShape(24.dp))) + } + + // 中间信息区域(占比1) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) { + Text( + text = bannerItem.agentName, + fontSize = 10.sp, + color = Color(0xfff5f5f5).copy(alpha = 0.6f), + fontWeight = androidx.compose.ui.text.font.FontWeight.W500 + ) + Text( + text = bannerItem.subtitle, + fontSize = 12.sp, + color = Color.White, + maxLines = 1, + fontWeight = androidx.compose.ui.text.font.FontWeight.W400, + modifier = Modifier.padding(top = 2.dp) + ) + } + + // 右侧进入按钮 + Box( + modifier = Modifier + .width(69.dp) + .height(29.dp) + .background( + color = Color(0x7dffffff), + shape = RoundedCornerShape(8.dp) + ) + .clickable { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.JOIN_GROUP_CHAT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + // 调用加入房间接口 + viewModel.joinRoom( + trtcId = bannerItem.trtcId.toString(), + name = bannerItem.title, + avatar = bannerItem.avatar, + context = context, + navController = navController, + onSuccess = { + Toast.makeText(context, enterSuccessText, Toast.LENGTH_SHORT).show() + }, + onError = { errorMessage -> + Toast.makeText(context, enterFailText, Toast.LENGTH_SHORT).show() + } + ) + } + }, + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.group_room_enter), + fontSize = 14.sp, + color = Color.White, + fontWeight = androidx.compose.ui.text.font.FontWeight.W600 + ) + } + + } + + } + } + } + } + } + + } + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp) + ) { + + + // 第一块区域 + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween + ) { + // 第一个 - 靠左显示 + Column( + modifier = Modifier + .clickable { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.JOIN_GROUP_CHAT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + navController.navigate(NavigationRoute.CreateGroupChat.route) + } + }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(64.dp) + .background(Color(0XFFC686FF), RoundedCornerShape(24.dp)), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.mipmap.rider_pro_group), + contentDescription = "创建群聊", + modifier = Modifier.size(24.dp), + + ) + } + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = "创建群聊", + fontSize = 12.sp, + color = AppColors.text, + fontWeight = androidx.compose.ui.text.font.FontWeight.W500 + ) + } + + // 中间两个 - 平均分布 + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceEvenly + ) { + // 第二个 + Column( + modifier = Modifier + .clickable { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CREATE_AGENT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + navController.navigate(NavigationRoute.AddAgent.route) + } + }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(64.dp) + .background(Color(0xFF94f9f2), RoundedCornerShape(24.dp)), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.mipmap.rider_pro_agent), + contentDescription = "创建智能体", + modifier = Modifier.size(24.dp), + + ) + } + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = "创建Agent", + fontSize = 12.sp, + color = AppColors.text, + fontWeight = androidx.compose.ui.text.font.FontWeight.W500 + ) + } + + // 第三个 + Column( + modifier = Modifier + .clickable { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CREATE_POST)) { + navController.navigate(NavigationRoute.Login.route) + } else { + NewPostViewModel.asNewPost() + navController.navigate("NewPost") + } + }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(64.dp) + .background(Color(0xFFfafd5d), RoundedCornerShape(24.dp)), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.mipmap.rider_pro_release), + contentDescription = "发布动态", + modifier = Modifier.size(24.dp), + + ) + } + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = "发布动态", + fontSize = 12.sp, + color = AppColors.text, + fontWeight = androidx.compose.ui.text.font.FontWeight.W500 + ) + } + } + + // 第四个 - 靠右显示 + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(64.dp) + .background(Color(0xFFfc724b), RoundedCornerShape(24.dp)), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.mipmap.rider_pro_fire), + contentDescription = "热门智能体", + modifier = Modifier.size(24.dp), + + ) + } + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = "热门Agents", + fontSize = 12.sp, + color = AppColors.text, + fontWeight = androidx.compose.ui.text.font.FontWeight.W500 + ) + } + } + } + + @OptIn(ExperimentalFoundationApi::class) + @Composable + fun BannerSection(bannerItems: List) { + val AppColors = LocalAppTheme.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val pagerState = rememberPagerState(pageCount = { bannerItems.size }) + + // 预加载banner图片 + LaunchedEffect(bannerItems) { + bannerItems.forEach { bannerItem -> + if (bannerItem.backgroundImageUrl.isNotEmpty()) { + // 预加载背景图片 + com.aiosman.ravenow.utils.Utils.getImageLoader(context).enqueue( + coil.request.ImageRequest.Builder(context) + .data(bannerItem.backgroundImageUrl) + .memoryCachePolicy(coil.request.CachePolicy.ENABLED) + .diskCachePolicy(coil.request.CachePolicy.ENABLED) + .build() + ) + } + if (bannerItem.imageUrl.isNotEmpty()) { + // 预加载头像图片 + com.aiosman.ravenow.utils.Utils.getImageLoader(context).enqueue( + coil.request.ImageRequest.Builder(context) + .data(bannerItem.imageUrl) + .memoryCachePolicy(coil.request.CachePolicy.ENABLED) + .diskCachePolicy(coil.request.CachePolicy.ENABLED) + .build() + ) + } + } + } + + Column { + // Banner内容 + Box( + modifier = Modifier + .fillMaxWidth() + .height(362.dp) + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp), + ) { page -> + val bannerItem = bannerItems[page] + + // 计算当前页面的偏移量 + val pageOffset = ( + (pagerState.currentPage - page) + pagerState + .currentPageOffsetFraction + ).coerceIn(-1f, 1f) + + // 根据偏移量计算缩放比例 + val scale = 1f - (0.1f * kotlin.math.abs(pageOffset)) + + BannerCard( + bannerItem = bannerItem, + viewModel = viewModel, + modifier = Modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + } + ) + } + } + + // 指示器 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center + ) { + bannerItems.forEachIndexed { index, _ -> + Box( + modifier = Modifier + .padding(horizontal = 4.dp) + .size(3.dp) + .background( + color = if (pagerState.currentPage == index) AppColors.text else AppColors.secondaryText.copy(alpha = 0.3f), + shape = androidx.compose.foundation.shape.CircleShape + ) + ) + } + } + } + } + + + // 第二块区域:Banner + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + // 标题 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 12.dp) + ) { + Image( + painter = painterResource(R.mipmap.rider_pro_fire2), + contentDescription = "fire", + modifier = Modifier.size(28.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "正在高能对话中", + fontSize = 16.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.W600, + color = AppColors.text + ) + } + + // Banner + BannerSection(bannerItems = viewModel.bannerItems) + } + } + + // 第三块区域:推荐Agent + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + // 标题 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 12.dp) + ) { + Image( + painter = painterResource(R.mipmap.rider_pro_agent2), + contentDescription = "agent", + modifier = Modifier.size(28.dp), + + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "推荐给你的智能体", + fontSize = 16.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.W600, + color = AppColors.text + ) + } + + // Agent ViewPager + AgentViewPagerSection(agentItems = viewModel.agentItems.take(9)) + } + } + + // 第四块区域:热门聊天室 + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + // 标题 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 12.dp) + ) { + Image( + painter = painterResource(R.mipmap.rider_pro_hot_room), + contentDescription = "chat room", + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "热门聊天室", + fontSize = 16.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.W600, + color = AppColors.text + ) + } + + // 热门聊天室列表 + HotChatRoomGridSection(roomItems = viewModel.bannerItems) + } + } + } + + PullRefreshIndicator( + refreshing = isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/expolre/ExploreViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/expolre/ExploreViewModel.kt new file mode 100644 index 0000000..bfec1e8 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/expolre/ExploreViewModel.kt @@ -0,0 +1,215 @@ +package com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController +import com.aiosman.ravenow.data.Room +import com.aiosman.ravenow.data.Agent +import com.aiosman.ravenow.data.api.ApiClient +import com.aiosman.ravenow.data.api.RaveNowAPI +import com.aiosman.ravenow.data.api.SingleChatRequestBody +import com.aiosman.ravenow.data.api.JoinGroupChatRequestBody +import com.aiosman.ravenow.ui.index.tabs.ai.tabs.mine.MineAgentViewModel.createGroup2ChatAi +import com.aiosman.ravenow.ui.index.tabs.message.MessageListViewModel.userService +import com.aiosman.ravenow.ui.index.tabs.message.tab.GroupChatListViewModel +import com.aiosman.ravenow.ui.index.tabs.message.tab.GroupChatListViewModel.createGroupChat +import com.aiosman.ravenow.ui.navigateToGroupChat +import com.aiosman.ravenow.data.api.ApiErrorResponse +import com.google.gson.Gson +import android.content.Context +import android.widget.Toast +import kotlinx.coroutines.launch + +class ExploreViewModel : ViewModel() { + + private val apiClient: RaveNowAPI = ApiClient.api + + var bannerItems by mutableStateOf>(emptyList()) + private set + + var agentItems by mutableStateOf>(emptyList()) + private set + + var isLoading by mutableStateOf(false) + private set + + var isRefreshing by mutableStateOf(false) + private set + + var errorMessage by mutableStateOf(null) + private set + + init { + loadBannerData() + loadAgentData() + } + + fun refreshBannerData() { + viewModelScope.launch { + isRefreshing = true + errorMessage = null + try { + val response = apiClient.getRooms(page = 1, pageSize = 12, isRecommended = 1) + if (response.isSuccessful) { + val rooms = response.body()?.list ?: emptyList() + bannerItems = rooms.map { room -> + BannerItem.fromRoom(room) + } + } else { + errorMessage = "获取数据失败: ${response.code()}" + } + } catch (e: Exception) { + errorMessage = "网络请求失败: ${e.message}" + } finally { + isRefreshing = false + } + } + } + + fun refreshAgentData() { + viewModelScope.launch { + isRefreshing = true + errorMessage = null + try { + val response = apiClient.getAgent(page = 1, pageSize = 20, withWorkflow = 1) + if (response.isSuccessful) { + val agents = response.body()?.data?.list ?: emptyList() + agentItems = agents.map { agent -> + AgentItem.fromAgent(agent) + } + } else { + errorMessage = "获取Agent数据失败: ${response.code()}" + } + } catch (e: Exception) { + errorMessage = "网络请求失败: ${e.message}" + } finally { + isRefreshing = false + } + } + } + + private fun loadBannerData() { + viewModelScope.launch { + isLoading = true + errorMessage = null + try { + val response = apiClient.getRooms(page = 1, pageSize = 10, isRecommended = 1) + if (response.isSuccessful) { + val rooms = response.body()?.list ?: emptyList() + bannerItems = rooms.map { room -> + BannerItem.fromRoom(room) + } + } else { + errorMessage = "获取数据失败: ${response.code()}" + } + } catch (e: Exception) { + errorMessage = "网络请求失败: ${e.message}" + } finally { + isLoading = false + } + } + } + + private fun loadAgentData() { + viewModelScope.launch { + isLoading = true + errorMessage = null + try { + val response = apiClient.getAgent(page = 1, pageSize = 20, withWorkflow = 1) + if (response.isSuccessful) { + val agents = response.body()?.data?.list ?: emptyList() + agentItems = agents.map { agent -> + AgentItem.fromAgent(agent) + } + } else { + errorMessage = "获取Agent数据失败: ${response.code()}" + } + } catch (e: Exception) { + errorMessage = "网络请求失败: ${e.message}" + } finally { + isLoading = false + } + } + } + + fun createSingleChat( + openId: String, + ) { + viewModelScope.launch { + val response = + ApiClient.api.createSingleChat(SingleChatRequestBody(agentOpenId = openId)) + } + + } + + fun goToChatAi( + openId: String, + navController: NavHostController + ) { + viewModelScope.launch { + val profile = userService.getUserProfileByOpenId(openId) + createGroup2ChatAi(profile.trtcUserId, "ai_group", navController, profile.id) + } + } + + fun joinRoom( + trtcId: String, + name: String, + avatar: String, + context: Context, + navController: NavHostController, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + viewModelScope.launch { + try { + val response = apiClient.joinRoom(JoinGroupChatRequestBody(trtcId = trtcId)) + if (response.isSuccessful) { + viewModelScope.launch { + try { + createGroupChat(trtcGroupId = trtcId) + // 群聊直接使用群ID进行导航 + navController.navigateToGroupChat( + id = trtcId, + name = name, + avatar = avatar + ) + } catch (e: Exception) { + onError("加入房间失败") + e.printStackTrace() + } + } + onSuccess() + + } else { + // 处理错误响应 + try { + val errorBody = response.errorBody()?.string() + if (errorBody != null) { + val gson = Gson() + val errorResponse = gson.fromJson(errorBody, ApiErrorResponse::class.java) + + // 在主线程显示 Toast + Toast.makeText(context, errorResponse.error, Toast.LENGTH_LONG).show() + onError(errorResponse.error) + } else { + Toast.makeText(context, "加入房间失败", Toast.LENGTH_SHORT).show() + onError("加入房间失败") + } + } catch (parseException: Exception) { + // 如果解析错误响应失败,显示默认错误信息 + Toast.makeText(context, "加入房间失败", Toast.LENGTH_SHORT).show() + onError("加入房间失败") + } + } + } catch (e: Exception) { + Toast.makeText(context, "网络请求失败:${e.message}", Toast.LENGTH_SHORT).show() + onError("网络请求失败:${e.message}") + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/HotMomentViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/HotMomentViewModel.kt new file mode 100644 index 0000000..3004958 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/HotMomentViewModel.kt @@ -0,0 +1,87 @@ +package com.aiosman.ravenow.ui.index.tabs.moment.tabs.hot + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.aiosman.ravenow.data.MomentService +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.entity.MomentServiceImpl +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + + +object HotMomentViewModel : ViewModel() { + private val momentService: MomentService = MomentServiceImpl() + private val _discoverMoments = MutableStateFlow>(emptyList()) + val discoverMoments = _discoverMoments.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing = _isRefreshing.asStateFlow() + + private var currentPage = 1 + private var hasMoreData = true + private val pageSize = 20 + + fun loadMoments() { + if (_isLoading.value || !hasMoreData) return + + viewModelScope.launch { + _isLoading.value = true + try { + val response = momentService.getMoments( + pageNumber = currentPage, + trend = true + ) + + if (response.list.isNotEmpty()) { + if (currentPage == 1) { + _discoverMoments.value = response.list + } else { + _discoverMoments.value = _discoverMoments.value + response.list + } + currentPage++ + hasMoreData = response.list.size >= response.pageSize + } else { + hasMoreData = false + } + } catch (e: Exception) { + // 处理错误 + e.printStackTrace() + } finally { + _isLoading.value = false + } + } + } + + fun refreshMoments() { + viewModelScope.launch { + _isRefreshing.value = true + currentPage = 1 + hasMoreData = true + try { + val response = momentService.getMoments( + pageNumber = 1, + trend = true + ) + _discoverMoments.value = response.list + currentPage = 2 + hasMoreData = response.list.size >= response.pageSize + } catch (e: Exception) { + e.printStackTrace() + } finally { + _isRefreshing.value = false + } + } + } + + fun resetModel() { + currentPage = 1 + hasMoreData = true + _discoverMoments.value = emptyList() + _isLoading.value = false + _isRefreshing.value = false + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/Moment.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/Moment.kt new file mode 100644 index 0000000..db0f8e1 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/Moment.kt @@ -0,0 +1,256 @@ +package com.aiosman.ravenow.ui.index.tabs.moment.tabs.hot + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.collectLatest +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.runtime.collectAsState +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.StatusBarSpacer +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import com.aiosman.ravenow.ui.index.tabs.search.SearchViewModel +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.navigateToPost + +/** + * 动态列表 + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun HotMomentsList() { + val model = HotMomentViewModel + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + val navigationBarPaddings = + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 48.dp + val isRefreshing by model.isRefreshing.collectAsState() + + LaunchedEffect(Unit) { + model.loadMoments() + } + + val state = rememberPullRefreshState(isRefreshing, onRefresh = { + model.refreshMoments() + }) + + Column( + modifier = Modifier + .fillMaxSize() + .pullRefresh(state) + + ) { + Column( + modifier = Modifier.fillMaxWidth().background( + AppColors.background).padding(bottom = 0.dp) + ) { + } + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(2.dp) + ) { + DiscoverView() + PullRefreshIndicator(isRefreshing, state, Modifier.align(Alignment.TopCenter)) + } + } +} + + +@Composable +fun DiscoverView() { + val model = HotMomentViewModel + val moments by model.discoverMoments.collectAsState() + val isLoading by model.isLoading.collectAsState() + val context = LocalContext.current + val navController = LocalNavController.current + val gridState = rememberLazyStaggeredGridState() + val AppColors = LocalAppTheme.current + // 监听滚动到底部,自动加载更多 + LaunchedEffect(gridState, moments.size) { + snapshotFlow { + val layoutInfo = gridState.layoutInfo + val visibleItemsInfo = layoutInfo.visibleItemsInfo + if (visibleItemsInfo.isNotEmpty() && moments.isNotEmpty()) { + val lastVisibleItemIndex = visibleItemsInfo.lastOrNull()?.index ?: 0 + lastVisibleItemIndex >= moments.size - 6 // 距离底部还有6个项目时开始加载 + } else { + false + } + }.collectLatest { shouldLoadMore -> + if (shouldLoadMore && !isLoading) { + model.loadMoments() + } + } + } + + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(2), + state = gridState, + modifier = Modifier.fillMaxSize().padding(bottom = 8.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 4.dp) + ) { + items(moments) { momentItem -> + val debouncer = rememberDebouncer() + + val textContent = momentItem.momentTextContent + val textLines = if (textContent.isNotEmpty()) { + val estimatedCharsPerLine = 20 + val estimatedLines = (textContent.length / estimatedCharsPerLine) + 1 + minOf(estimatedLines, 2) // 最多2行 + } else { + 0 + } + + val baseHeight = 200.dp + val singleLineTextHeight = 20.dp + val doubleLineTextHeight = 40.dp + val authorInfoHeight = 25.dp + val paddingHeight = 10.dp + val paddingHeight2 =3.dp + val totalHeight = baseHeight + when (textLines) { + 0 -> authorInfoHeight + paddingHeight + 1 -> singleLineTextHeight + authorInfoHeight + paddingHeight + else -> doubleLineTextHeight + authorInfoHeight +paddingHeight2 + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(totalHeight) + .padding(2.dp) + .noRippleClickable { + debouncer { + navController.navigateToPost( + id = momentItem.id, + highlightCommentId = 0, + initImagePagerIndex = 0 + ) + } + } + ) { + Column( + modifier = Modifier.fillMaxSize().background(AppColors.secondaryBackground, RoundedCornerShape(12.dp)) + ) { + CustomAsyncImage( + imageUrl = momentItem.images[0].thumbnail, + contentDescription = "", + modifier = Modifier + .fillMaxWidth() + .height(baseHeight) + .clip(RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp, + bottomStart = 0.dp, + bottomEnd = 0.dp)), + context = context, + showShimmer = true + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .height(totalHeight - baseHeight) + .padding(horizontal = 8.dp, vertical = 8.dp) + ) { + if (momentItem.momentTextContent.isNotEmpty()) { + androidx.compose.material3.Text( + text = momentItem.momentTextContent, + modifier = Modifier.fillMaxWidth(), + fontSize = 12.sp, + color = AppColors.text, + maxLines = 2, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CustomAsyncImage( + imageUrl = momentItem.avatar, + contentDescription = "", + modifier = Modifier + .size(16.dp) + .clip(RoundedCornerShape(8.dp)), + context = context, + showShimmer = true + ) + + androidx.compose.material3.Text( + text = momentItem.nickname, + modifier = Modifier.padding(start = 4.dp), + fontSize = 11.sp, + color = AppColors.text.copy(alpha = 0.6f), + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) + } + } + } + if (momentItem.images.size > 1) { + Box( + modifier = Modifier + .padding(top = 8.dp, end = 8.dp) + .align(Alignment.TopEnd) + ) { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.rider_pro_picture_more), + contentDescription = "", + ) + } + } + + + } + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/FullArticleModal.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/FullArticleModal.kt new file mode 100644 index 0000000..055172b --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/FullArticleModal.kt @@ -0,0 +1,205 @@ +package com.aiosman.ravenow.ui.index.tabs.moment.tabs.news + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.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.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +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.entity.MomentEntity +import com.aiosman.ravenow.exp.formatPostTime2 +import com.aiosman.ravenow.ui.composables.CustomAsyncImage + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FullArticleModal( + moment: MomentEntity, + onDismiss: () -> Unit +) { + val appColors = LocalAppTheme.current + val context = LocalContext.current + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + val sheetHeight = screenHeight * 0.9f // 90% 高度 + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), + modifier = Modifier + .fillMaxWidth() + .height(sheetHeight), + containerColor = appColors.background, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + windowInsets = androidx.compose.foundation.layout.WindowInsets(0) + ) { + Column( + modifier = Modifier + .fillMaxSize() + ) { + // 滚动内容 + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + // 新闻图片区域 - 固定高度和宽度 + Box( + modifier = Modifier + .fillMaxWidth() + .height(250.dp) + + .background(color = appColors.secondaryBackground) + ) { + if (moment.images.isNotEmpty()) { + val firstImage = moment.images[0] + CustomAsyncImage( + context = context, + imageUrl = firstImage.url, + contentDescription = "新闻图片", + contentScale = ContentScale.Fit, + blurHash = firstImage.blurHash, + modifier = Modifier.fillMaxSize() + ) + } else { + Image( + painter = androidx.compose.ui.res.painterResource(id = R.drawable.default_moment_img), + contentDescription = "默认图片", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize() + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 新闻标题 + Text( + text = if (moment.newsTitle.isNotEmpty()) moment.newsTitle else moment.nickname, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = appColors.text, + lineHeight = 28.sp, + modifier = Modifier.padding(horizontal = 10.dp) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 新闻来源和发布时间 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // 来源按钮 + Button( + onClick = { }, + modifier = Modifier.height(28.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF7c68ef) + ), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 4.dp), + shape = RoundedCornerShape(14.dp) + ) { + Text( + text = if (moment.newsSource.isNotEmpty()) moment.newsSource else moment.nickname, + fontSize = 12.sp, + color = Color.White, + ) + } + + // 发布时间 + Text( + text = moment.time.formatPostTime2(), + fontSize = 12.sp, + color = appColors.secondaryText + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 帖子内容 + NewsContent( + content = if (moment.newsContent.isNotEmpty()) moment.newsContent else moment.momentTextContent, + images = moment.images, + context = context + ) + Spacer(modifier = Modifier.height(200.dp)) + } + } + } +} + +@Composable +private fun NewsContent( + content: String, + images: List, + context: android.content.Context +) { + val appColors = LocalAppTheme.current + + Column( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text( + text = content, + fontSize = 16.sp, + color = appColors.text, + lineHeight = 24.sp + ) + + // 图片内容 + if (images.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + + images.forEach { image -> + Spacer(modifier = Modifier.height(12.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + ) { + CustomAsyncImage( + context = context, + imageUrl = image.url, + contentDescription = "内容图片", + contentScale = ContentScale.Fit, + blurHash = image.blurHash, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsCommentModal.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsCommentModal.kt new file mode 100644 index 0000000..d46c8f6 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsCommentModal.kt @@ -0,0 +1,306 @@ +package com.aiosman.ravenow.ui.index.tabs.moment.tabs.news + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.text.font.FontWeight +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.AppState +import com.aiosman.ravenow.GuestLoginCheckOut +import com.aiosman.ravenow.GuestLoginCheckOutScene +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.data.CommentService +import com.aiosman.ravenow.data.CommentServiceImpl +import com.aiosman.ravenow.entity.CommentEntity +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.EditCommentBottomModal +import com.aiosman.ravenow.ui.composables.debouncedClickable +import com.aiosman.ravenow.ui.composables.rememberDebouncedNavigation +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.post.CommentContent +import com.aiosman.ravenow.ui.post.CommentMenuModal +import com.aiosman.ravenow.ui.post.CommentsViewModel +import com.aiosman.ravenow.ui.post.OrderSelectionComponent +import kotlinx.coroutines.launch + + +class NewsCommentModalViewModel( + val postId: Int? +) : ViewModel() { + var commentsViewModel: CommentsViewModel = CommentsViewModel(postId.toString()) + var commentService: CommentService = CommentServiceImpl() + + init { + commentsViewModel.preTransit() + } + + fun likeComment(commentId: Int) { + viewModelScope.launch { + commentsViewModel.likeComment(commentId) + } + } + + fun unlikeComment(commentId: Int) { + viewModelScope.launch { + commentsViewModel.unlikeComment(commentId) + } + } + + fun createComment( + content: String, + parentCommentId: Int? = null, + replyUserId: Int? = null, + replyCommentId: Int? = null + ) { + viewModelScope.launch { + commentsViewModel.createComment( + content = content, + parentCommentId = parentCommentId, + replyUserId = replyUserId, + replyCommentId = replyCommentId + ) + } + } + + fun deleteComment(commentId: Int) { + commentsViewModel.deleteComment(commentId) + } +} + +// 新闻评论弹窗 +// @param postId 新闻帖子ID +// @param commentCount 评论数量 +// @param onDismiss 关闭回调 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewsCommentModal( + postId: Int? = null, + commentCount: Int = 0, + onDismiss: () -> Unit = {}, + onCommentAdded: () -> Unit = {}, + onCommentDeleted: () -> Unit = {} +) { + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + val debouncedNavigation = rememberDebouncedNavigation() + + // 实时评论数状态 + var currentCommentCount by remember { mutableStateOf(commentCount) } + + val model = viewModel( + key = "NewsCommentModalViewModel_$postId", + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return NewsCommentModalViewModel(postId) as T + } + } + ) + + val commentViewModel = model.commentsViewModel + var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + var showCommentMenu by remember { mutableStateOf(false) } + var contextComment by remember { mutableStateOf(null) } + var replyComment by remember { mutableStateOf(null) } + + // 菜单弹窗 + if (showCommentMenu) { + ModalBottomSheet( + onDismissRequest = { + showCommentMenu = false + }, + containerColor = AppColors.background, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), + dragHandle = {}, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + windowInsets = WindowInsets(0) + ) { + CommentMenuModal( + onDeleteClick = { + showCommentMenu = false + contextComment?.let { + model.deleteComment(it.id) + onCommentDeleted() + currentCommentCount = (currentCommentCount - 1).coerceAtLeast(0) + } + }, + commentEntity = contextComment, + onCloseClick = { + showCommentMenu = false + }, + isSelf = AppState.UserId?.toLong() == contextComment?.author, + onLikeClick = { + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } else { + showCommentMenu = false + contextComment?.let { + if (it.liked) { + model.unlikeComment(it.id) + } else { + model.likeComment(it.id) + } + } + } + }, + onReplyClick = { + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } else { + showCommentMenu = false + replyComment = contextComment + } + } + ) + } + } + + Column( + modifier = Modifier.background(AppColors.background) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${currentCommentCount}条评论", + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = AppColors.text + ) + + // 排序选择 + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + OrderSelectionComponent { + commentViewModel.order = it + commentViewModel.reloadComment() + } + } + } + + // 评论列表 + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Box( + modifier = Modifier.fillMaxWidth() + ) { + LazyColumn { + item { + CommentContent( + viewModel = commentViewModel, + onLongClick = { comment -> + showCommentMenu = true + contextComment = comment + }, + onReply = { parentComment, _, _, _ -> + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } else { + replyComment = parentComment + } + } + ) + } + } + } + } + + // 底部输入栏 + Column( + modifier = Modifier + .fillMaxWidth() + .background(AppColors.background) + ) { + HorizontalDivider(color = AppColors.inputBackground) + + EditCommentBottomModal( + replyComment = replyComment, + autoFocus = false + ) { + if (replyComment != null) { + if (replyComment?.parentCommentId != null) { + // 第三级评论 + model.createComment( + content = it, + parentCommentId = replyComment?.parentCommentId, + replyUserId = replyComment?.author?.toInt(), + replyCommentId = replyComment?.id + ) + } else { + // 子级评论 + model.createComment( + content = it, + parentCommentId = replyComment?.id, + replyCommentId = replyComment?.id + ) + } + } else { + // 顶级评论 + model.createComment(content = it) + } + replyComment = null + onCommentAdded() + currentCommentCount++ + } + + Spacer(modifier = Modifier.height(navBarHeight)) + } + } +} + \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsScreen.kt new file mode 100644 index 0000000..b6fde8d --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsScreen.kt @@ -0,0 +1,436 @@ +package com.aiosman.ravenow.ui.index.tabs.moment.tabs.news + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.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.statusBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.VerticalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +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.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.platform.LocalConfiguration +import com.aiosman.ravenow.GuestLoginCheckOut +import com.aiosman.ravenow.GuestLoginCheckOutScene +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.exp.timeAgo +import com.aiosman.ravenow.exp.formatPostTime2 +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import com.aiosman.ravenow.ui.index.tabs.moment.tabs.news.NewsViewModel +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun NewsScreen() { + val model = NewsViewModel + val moments = model.moments + val AppColors = LocalAppTheme.current + val context = LocalContext.current + val navController = LocalNavController.current + val scope = rememberCoroutineScope() + + // 评论弹窗状态 + var showCommentModal by remember { mutableStateOf(false) } + var selectedMoment by remember { mutableStateOf(null) } + + // 查看全文弹窗状态 + var showFullArticleModal by remember { mutableStateOf(false) } + var selectedArticleMoment by remember { mutableStateOf(null) } + // 垂直翻页状态 + val pagerState = rememberPagerState(pageCount = { moments.size }) + + // 防抖器 + val likeDebouncer = rememberDebouncer() + val favoriteDebouncer = rememberDebouncer() + + // 初始化加载数据 + LaunchedEffect(Unit) { + model.refreshPager() + } + + // 监听数据变化,重置加载状态 + LaunchedEffect(moments.size) { + // 当数据增加时,如果接近列表末尾,Pager会自动更新页数 + } + + // 当翻页接近末尾时加载更多 + LaunchedEffect(pagerState.currentPage, moments.size) { + if (moments.isNotEmpty() && pagerState.currentPage >= moments.size - 2) { + model.loadMore() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) + ) { + if (moments.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = "暂无新闻内容", color = AppColors.text, fontSize = 16.sp) + } + } else { + VerticalPager( + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + val momentItem = moments.getOrNull(page) ?: return@VerticalPager + NewsItem( + moment = momentItem, + modifier = Modifier.fillMaxSize(), + onCommentClick = { + selectedMoment = momentItem + showCommentModal = true + }, + onReadFullClick = { + selectedArticleMoment = momentItem + showFullArticleModal = true + }, + onLikeClick = { + likeDebouncer { + // 检查游客模式 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + scope.launch { + if (momentItem.liked) { + model.dislikeMoment(momentItem.id) + } else { + model.likeMoment(momentItem.id) + } + } + } + } + }, + onFavoriteClick = { + favoriteDebouncer { + // 检查游客模式 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + scope.launch { + if (momentItem.isFavorite) { + model.unfavoriteMoment(momentItem.id) + } else { + model.favoriteMoment(momentItem.id) + } + } + } + } + } + ) + } + } + + // 查看全文弹窗 + if (showFullArticleModal && selectedArticleMoment != null) { + FullArticleModal( + moment = selectedArticleMoment!!, + onDismiss = { + showFullArticleModal = false + } + ) + } + + // 评论弹窗 + if (showCommentModal && selectedMoment != null) { + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + val sheetHeight = screenHeight * 0.67f // 三分之二高度 + + ModalBottomSheet( + onDismissRequest = { + showCommentModal = false + }, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), + modifier = Modifier + .fillMaxWidth() + .height(sheetHeight), + containerColor = AppColors.background, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + windowInsets = androidx.compose.foundation.layout.WindowInsets(0) + ) { + NewsCommentModal( + postId = selectedMoment?.id, + commentCount = selectedMoment?.commentCount ?: 0, + onDismiss = { + showCommentModal = false + }, + onCommentAdded = { + selectedMoment?.id?.let { model.onAddComment(it) } + }, + onCommentDeleted = { + selectedMoment?.id?.let { model.onDeleteComment(it) } + } + ) + } + } + } +} + +//单个新闻项 +@Composable +fun NewsItem( + moment: MomentEntity, + modifier: Modifier = Modifier, + onCommentClick: () -> Unit = {}, + onReadFullClick: () -> Unit = {}, + onLikeClick: () -> Unit = {}, + onFavoriteClick: () -> Unit = {} +) { + val AppColors = LocalAppTheme.current + val context = LocalContext.current + + Column( + modifier = modifier + .fillMaxSize() + .background(AppColors.background) + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(bottom = 30.dp) + ) { + // 新闻图片 + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .padding(horizontal = 16.dp) + ) { + if (moment.images.isNotEmpty()) { + CustomAsyncImage( + context = context, + imageUrl = moment.images[0].thumbnail, + contentDescription = "新闻图片", + contentScale = ContentScale.Crop, + blurHash = moment.images[0].blurHash, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)) + ) + } else { + Image( + painter = androidx.compose.ui.res.painterResource(id = R.drawable.default_moment_img), + contentDescription = "默认图片", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 新闻标题 + Text( + text = if (moment.newsTitle.isNotEmpty()) moment.newsTitle else moment.nickname, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = AppColors.text, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 新闻内容(超出使用省略号) + Text( + text = if (moment.newsContent.isNotEmpty()) moment.newsContent else moment.momentTextContent, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + fontSize = 14.sp, + color = AppColors.text, + lineHeight = 20.sp, + maxLines = 6, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 新闻信息 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // 来源和时间(显示月份与具体时间) + Text( + text = if (moment.newsSource.isNotEmpty()) "${moment.newsSource} • ${moment.time.formatPostTime2()}" else "${moment.nickname} • ${moment.time.formatPostTime2()}", + fontSize = 12.sp, + color = AppColors.secondaryText, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + // 查看全文 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.noRippleClickable { onReadFullClick() } + ) { + Text( + text = stringResource(R.string.read_full_article), + fontSize = 13.sp, + fontWeight = FontWeight.W600, + color = Color(0xFF7c45ed) + ) + Spacer(modifier = Modifier.width(4.dp)) + Image( + painter = androidx.compose.ui.res.painterResource(id = R.mipmap.arrow), + contentDescription = "箭头", + modifier = Modifier.size(18.dp), + colorFilter = ColorFilter.tint(Color(0xFF7c45ed)) + ) + } + } + } + + // 互动栏 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 25.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + // 点赞 + NewsActionButton( + icon = if (moment.liked) R.drawable.rider_pro_moment_liked else R.drawable.rider_pro_moment_like, + count = moment.likeCount.toString(), + isActive = moment.liked, + modifier = Modifier.noRippleClickable { onLikeClick() } + ) + + // 评论 + NewsActionButton( + icon = R.mipmap.icon_comment, + count = moment.commentCount.toString(), + isActive = false, + modifier = Modifier.noRippleClickable { onCommentClick() } + ) + + // 收藏 + NewsActionButton( + icon = if (moment.isFavorite) R.mipmap.icon_variant_2 else R.mipmap.icon_collect, + count = moment.favoriteCount.toString(), + isActive = moment.isFavorite, + modifier = Modifier.noRippleClickable { onFavoriteClick() } + ) + + // 分享 + NewsActionButton( + icon = R.mipmap.icon_share, + count = "", + isActive = false, + text = stringResource(R.string.share), + textSize = 8.sp + ) + } + } +} + +// 互动栏按钮 +@Composable +fun NewsActionButton( + icon: Int, + count: String, + isActive: Boolean, + modifier: Modifier = Modifier, + text: String? = null, + textSize: androidx.compose.ui.unit.TextUnit = 12.sp +) { + val AppColors = LocalAppTheme.current + + Row( + modifier = modifier + .width(60.dp) + .background( + color = AppColors.secondaryBackground, + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Image( + painter = androidx.compose.ui.res.painterResource(id = icon), + contentDescription = "操作图标", + modifier = Modifier.size(16.dp) + ) + + if (count.isNotEmpty()) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = count, + fontSize = 12.sp, + color = AppColors.text + ) + } + if (text != null) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = text, + fontSize = textSize, + color = AppColors.text + ) + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsViewModel.kt new file mode 100644 index 0000000..d248b89 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsViewModel.kt @@ -0,0 +1,18 @@ +package com.aiosman.ravenow.ui.index.tabs.moment.tabs.news + +import com.aiosman.ravenow.entity.MomentLoaderExtraArgs +import com.aiosman.ravenow.ui.index.tabs.moment.BaseMomentModel + +object NewsViewModel : BaseMomentModel() { + override fun extraArgs(): MomentLoaderExtraArgs { + // 只拉取新闻 + return MomentLoaderExtraArgs( + explore = false, + timelineId = null, + authorId = null, + newsOnly = true + ) + } +} + + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/timeline/Moment.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/timeline/Moment.kt new file mode 100644 index 0000000..873a44e --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/timeline/Moment.kt @@ -0,0 +1,270 @@ +package com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline + +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.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.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.composables.MomentCard +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import kotlinx.coroutines.launch +import com.aiosman.ravenow.utils.NetworkUtils +import androidx.compose.ui.platform.LocalContext + +/** + * 动态列表 + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun TimelineMomentsList() { + val model = TimelineMomentViewModel + var moments = model.moments + val AppColors = LocalAppTheme.current + val scope = rememberCoroutineScope() + val state = rememberPullRefreshState(model.refreshing, onRefresh = { + model.refreshPager( + pullRefresh = true + ) + }) + LaunchedEffect(Unit) { + model.refreshPager() + } + val listState = rememberLazyListState() + + // observe list scrolling + val reachedBottom by remember { + derivedStateOf { + val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() + lastVisibleItem?.index != 0 && lastVisibleItem?.index == listState.layoutInfo.totalItemsCount - 2 + } + } + LaunchedEffect(reachedBottom) { + if (reachedBottom) { + model.loadMore() + } + } + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current) + + if (!isNetworkAvailable) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 188.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + val exploreDebouncer = rememberDebouncer() + Image( + painter = painterResource(id = R.mipmap.invalid_name_10), + contentDescription = "network error", + modifier = Modifier.size(140.dp) + ) + Spacer(modifier = Modifier.size(24.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_title), + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_subtitle), + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W400 + ) + Spacer(modifier = Modifier.size(16.dp)) + ExploreButton( + onClick = { + exploreDebouncer { + /* TODO: 添加点击事件处理 */ + } } + ) + } + } + } else if (moments.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 188.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + val exploreDebouncer = rememberDebouncer() + Image( + painter = painterResource( + id = if(AppState.darkMode) R.mipmap.qst_gz_qs_as_img + else R.mipmap.invalid_name_4), + contentDescription = null, + modifier = Modifier.size(140.dp) + ) + Spacer(modifier = Modifier.size(24.dp)) + Text( + text = "连接世界,从关注开始", + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = "不如从一个 Agent 开始认识这世界?", + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W400 + ) + Spacer(modifier = Modifier.size(16.dp)) + ExploreButton( + onClick = { + exploreDebouncer { + /* TODO: 添加点击事件处理 */ + } } + ) + } + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + ) { + Box(Modifier.pullRefresh(state)) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState + ) { + items( + moments.size, + key = { idx -> moments.getOrNull(idx)?.id ?: idx } + ) { idx -> + moments.getOrNull(idx)?.let { momentItem -> + val commentDebouncer = rememberDebouncer() + val likeDebouncer = rememberDebouncer() + val favoriteDebouncer = rememberDebouncer() + val followDebouncer = rememberDebouncer() + + MomentCard( + momentEntity = momentItem, + onAddComment = { + commentDebouncer { + scope.launch { + model.onAddComment(momentItem.id) + } + } + }, + onLikeClick = { + likeDebouncer { + scope.launch { + if (momentItem.liked) { + model.dislikeMoment(momentItem.id) + } else { + model.likeMoment(momentItem.id) + } + } + } + }, + onFavoriteClick = { + favoriteDebouncer { + scope.launch { + if (momentItem.isFavorite) { + model.unfavoriteMoment(momentItem.id) + } else { + model.favoriteMoment(momentItem.id) + } + } + } + }, + onFollowClick = { + followDebouncer { + model.followAction(momentItem) + } + }, + showFollowButton = false + ) + } + } + + } + PullRefreshIndicator(model.refreshing, state, Modifier.align(Alignment.TopCenter)) + } + } + } +} +@Composable +fun ExploreButton( + onClick: () -> Unit +) { + val gradientBrush = Brush.linearGradient( + colors = listOf( + Color(0xFF7c45ed), + Color(0xFF7c68ef), + Color(0xFF7bd8f8) + ) + ) + + Button( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 144.dp) + .height(48.dp), + shape = RoundedCornerShape(30.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.Transparent + ), + contentPadding = PaddingValues(0.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(gradientBrush), + contentAlignment = Alignment.Center + ) { + Text( + text = "去探索", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/timeline/TimelineMomentViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/timeline/TimelineMomentViewModel.kt new file mode 100644 index 0000000..f1ac085 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/timeline/TimelineMomentViewModel.kt @@ -0,0 +1,17 @@ +package com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline + +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.entity.MomentLoaderExtraArgs +import com.aiosman.ravenow.ui.index.tabs.moment.BaseMomentModel +import org.greenrobot.eventbus.EventBus + + +object TimelineMomentViewModel : BaseMomentModel() { + init { + EventBus.getDefault().register(this) + } + + override fun extraArgs(): MomentLoaderExtraArgs { + return MomentLoaderExtraArgs(timelineId = AppState.UserId!!) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/BlockConfirmDialog.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/BlockConfirmDialog.kt new file mode 100644 index 0000000..1d91671 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/BlockConfirmDialog.kt @@ -0,0 +1,178 @@ +package com.aiosman.ravenow.ui.index.tabs.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.entity.AccountProfileEntity +import com.aiosman.ravenow.ui.composables.CustomAsyncImage + +/** + * 拉黑确认弹窗 + */ +@Composable +fun BlockConfirmDialog( + userProfile: AccountProfileEntity?, + onConfirmBlock: () -> Unit = {}, + onDismiss: () -> Unit = {} +) { + val AppColors = LocalAppTheme.current + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = AppColors.background, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + ) + .padding(24.dp) + ) { + // 用户头像 + Box( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 16.dp) + ) { + CustomAsyncImage( + LocalContext.current, + userProfile?.avatar, + modifier = Modifier + .size(60.dp) + .clip(CircleShape) + .background( + color = AppColors.background, + shape = CircleShape + ), + contentDescription = "用户头像", + contentScale = ContentScale.Crop + ) + } + + // 确认文本 + Text( + text = stringResource(R.string.confirm_block_user, userProfile?.nickName ?: "该用户"), + + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = AppColors.text, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 24.dp) + ) + + // 说明信息 + Column( + modifier = Modifier.padding(bottom = 32.dp) + ) { + // 第一条说明 + Row( + modifier = Modifier.padding(bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.mipmap.icons_infor_off_eye), + contentDescription = "", + tint = AppColors.text, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.block_description_1), + fontSize = 14.sp, + color = AppColors.text, + lineHeight = 20.sp + ) + } + + // 第二条说明 + Row( + modifier = Modifier.padding(bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_notice_mute), + contentDescription = "", + tint = AppColors.text, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.block_description_2), + fontSize = 14.sp, + color = AppColors.text, + lineHeight = 20.sp + ) + } + + // 第三条说明 + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.mipmap.icons_infor_off_bell), + contentDescription = "", + tint = AppColors.text, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.block_description_3), + fontSize = 14.sp, + color = AppColors.text, + lineHeight = 20.sp + ) + } + } + + // 确认拉黑按钮 + androidx.compose.material3.Button( + onClick = { + onConfirmBlock() + onDismiss() + }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + colors = androidx.compose.material3.ButtonDefaults.buttonColors( + containerColor = AppColors.text + ), + shape = RoundedCornerShape(24.dp) + ) { + Text( + stringResource(R.string.block), + color = AppColors.background, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/MyProfileViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/MyProfileViewModel.kt new file mode 100644 index 0000000..b72b9f3 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/MyProfileViewModel.kt @@ -0,0 +1,248 @@ +package com.aiosman.ravenow.ui.index.tabs.profile + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.AppStore +import com.aiosman.ravenow.Messaging +import com.aiosman.ravenow.data.AccountService +import com.aiosman.ravenow.data.AccountServiceImpl +import com.aiosman.ravenow.data.MomentService +import com.aiosman.ravenow.data.UploadImage +import com.aiosman.ravenow.entity.AccountProfileEntity +import com.aiosman.ravenow.entity.AgentEntity +import com.aiosman.ravenow.entity.AgentLoader +import com.aiosman.ravenow.entity.AgentLoaderExtraArgs +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.entity.MomentLoader +import com.aiosman.ravenow.entity.MomentLoaderExtraArgs +import com.aiosman.ravenow.entity.MomentServiceImpl +import com.aiosman.ravenow.event.FollowChangeEvent +import com.aiosman.ravenow.event.MomentAddEvent +import com.aiosman.ravenow.event.MomentFavouriteChangeEvent +import com.aiosman.ravenow.event.MomentLikeChangeEvent +import com.aiosman.ravenow.event.MomentRemoveEvent +import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import java.io.File + +object MyProfileViewModel : ViewModel() { + val accountService: AccountService = AccountServiceImpl() + val momentService: MomentService = MomentServiceImpl() + var profile by mutableStateOf(null) + var moments by mutableStateOf>(emptyList()) + var agents by mutableStateOf>(emptyList()) + val momentLoader: MomentLoader = MomentLoader().apply { + pageSize = 20 // 设置与后端一致的页面大小 + onListChanged = { + moments = it + } + } + val agentLoader: AgentLoader = AgentLoader().apply { + onListChanged = { + agents = it + } + } + var refreshing by mutableStateOf(false) + var firstLoad = true + + init { + EventBus.getDefault().register(this) + } + + suspend fun loadUserProfile() { + // 游客模式下不获取用户资料 + if (AppStore.isGuest) { + MyProfileViewModel.profile = null + return + } + + val profile = accountService.getMyAccountProfile() + MyProfileViewModel.profile = profile + } + + fun loadProfile(pullRefresh: Boolean = false) { + if (!firstLoad && !pullRefresh) return + viewModelScope.launch { + if (pullRefresh) { + refreshing = true + } + firstLoad = false + loadUserProfile() + refreshing = false + + // 游客模式下不加载个人动态和智能体 + if (AppStore.isGuest) { + return@launch + } + + profile?.let { + try { + momentLoader.loadData(extra = MomentLoaderExtraArgs(authorId = it.id)) + // MyProfileViewModel 总是加载当前用户的数据,所以传递null以调用getMyAgent() + agentLoader.loadData(extra = AgentLoaderExtraArgs(authorId = null)) + } catch (e: Exception) { + Log.e("MyProfileViewModel", "loadProfile: ", e) + } + } + + } + } + + fun loadMoreMoment() { + // 游客模式下不加载更多动态 + if (AppStore.isGuest) { + Log.d("MyProfileViewModel", "loadMoreMoment: 游客模式下跳过加载更多动态") + return + } + + viewModelScope.launch { + profile?.let { profileData -> + try { + Log.d("MyProfileViewModel", "loadMoreMoment: 开始加载更多, 当前moments数量: ${moments.size}, hasNext: ${momentLoader.hasNext}") + momentLoader.loadMore(extra = MomentLoaderExtraArgs(authorId = profileData.id)) + Log.d("MyProfileViewModel", "loadMoreMoment: 加载完成, 新的moments数量: ${moments.size}") + } catch (e: Exception) { + Log.e("MyProfileViewModel", "loadMoreMoment: ", e) + } + } ?: Log.w("MyProfileViewModel", "loadMoreMoment: profile为null,无法加载更多") + } + } + + fun logout(context: Context) { + viewModelScope.launch { + // 只有非游客用户才需要取消注册推送设备 + if (!AppStore.isGuest) { + Messaging.unregisterDevice(context) + } + + AppStore.apply { + token = null + rememberMe = false + isGuest = false // 清除游客状态 + saveData() + } + // 删除推送渠道和重置应用状态 + AppState.ReloadAppState(context) + } + + } + + fun updateUserProfileBanner(bannerImageUrl: Uri?, file: File, context: Context) { + // 游客模式下不允许更新用户资料 + if (AppStore.isGuest) { + Log.d("MyProfileViewModel", "updateUserProfileBanner: 游客模式下无法更新用户资料") + return + } + + viewModelScope.launch { + val newBanner = bannerImageUrl?.let { + val cursor = context.contentResolver.query(it, null, null, null, null) + var newBanner: UploadImage? = null + cursor?.use { cur -> + val columnIndex = cur.getColumnIndex("_display_name") + if (cur.moveToFirst() && columnIndex != -1) { + val displayName = cur.getString(columnIndex) + val extension = displayName.substringAfterLast(".") + Log.d("Change banner", "File name: $displayName, extension: $extension") + // read as file + Log.d("Change banner", "File size: ${file.length()}") + newBanner = UploadImage(file, displayName, it.toString(), extension) + } + } + newBanner + } + accountService.updateProfile( + banner = newBanner, + avatar = null, + nickName = null, + bio = null + ) + profile = accountService.getMyAccountProfile() + } + } + + fun likeMoment(momentLMomentEntity: MomentEntity) { + // 游客模式下不允许点赞 + if (AppStore.isGuest) { + Log.d("MyProfileViewModel", "likeMoment: 游客模式下无法点赞") + return + } + + viewModelScope.launch { + if (momentLMomentEntity.liked) { + momentService.dislikeMoment(momentLMomentEntity.id) + EventBus.getDefault().post( + MomentLikeChangeEvent( + momentLMomentEntity.id, + likeCount = momentLMomentEntity.likeCount - 1, + isLike = false + ) + ) + } else { + momentService.likeMoment(momentLMomentEntity.id) + EventBus.getDefault().post( + MomentLikeChangeEvent( + momentLMomentEntity.id, + likeCount = momentLMomentEntity.likeCount + 1, + isLike = true + ) + ) + } + } + } + + + val bio get() = profile?.bio ?: "" + val nickName get() = profile?.nickName ?: "" + val avatar get() = profile?.avatar + + fun ResetModel() { + profile = null + momentLoader.clear() + agentLoader.clear() + firstLoad = true + } + + override fun onCleared() { + super.onCleared() + EventBus.getDefault().unregister(this) + } + + @Subscribe + fun onMomentLikeChangeEvent(event: MomentLikeChangeEvent) { + momentLoader.updateMomentLike(event.postId, event.isLike) + } + + @Subscribe + fun onMomentFavoriteChangeEvent(event: MomentFavouriteChangeEvent) { + momentLoader.updateFavoriteCount(event.postId, event.isFavourite) + } + + @Subscribe + fun onRemoveMomentEvent(event: MomentRemoveEvent) { + momentLoader.removeMoment(event.postId) + } + + @Subscribe + fun onAddMomentEvent(event: MomentAddEvent) { + momentLoader.addMoment(event.moment) + } + + @Subscribe + fun onMomentDelete(event: MomentRemoveEvent) { + momentLoader.removeMoment(event.postId) + } + + @Subscribe + fun onFollowChangeEvent(event: FollowChangeEvent) { + momentLoader.updateFollowStatus(event.userId, event.isFollow) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/ProfileV3.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/ProfileV3.kt new file mode 100644 index 0000000..8ee1612 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/ProfileV3.kt @@ -0,0 +1,920 @@ +package com.aiosman.ravenow.ui.index.tabs.profile + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.ConstVars +import com.aiosman.ravenow.GuestLoginCheckOut +import com.aiosman.ravenow.GuestLoginCheckOutScene +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.MainActivity +import com.aiosman.ravenow.R +import com.aiosman.ravenow.entity.AccountProfileEntity +import com.aiosman.ravenow.entity.AgentEntity +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.StatusBarSpacer +import com.aiosman.ravenow.ui.composables.pickupAndCompressLauncher +import com.aiosman.ravenow.ui.composables.toolbar.CollapsingToolbarScaffold +import com.aiosman.ravenow.ui.composables.toolbar.ScrollStrategy +import com.aiosman.ravenow.ui.composables.toolbar.rememberCollapsingToolbarScaffoldState +import com.aiosman.ravenow.ui.index.IndexViewModel +import com.aiosman.ravenow.ui.index.tabs.profile.composable.GalleryGrid +import com.aiosman.ravenow.ui.post.MenuActionItem +import com.aiosman.ravenow.ui.index.tabs.profile.composable.OtherProfileAction +import com.aiosman.ravenow.ui.index.tabs.profile.composable.SelfProfileAction +import com.aiosman.ravenow.ui.index.tabs.profile.composable.UserAgentsList +import com.aiosman.ravenow.ui.index.tabs.profile.composable.UserAgentsRow +import com.aiosman.ravenow.ui.index.tabs.profile.composable.UserContentPageIndicator +import com.aiosman.ravenow.ui.index.tabs.profile.composable.UserItem +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.navigateToPost +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.File +import androidx.compose.foundation.rememberScrollState +import androidx.compose.ui.res.stringResource + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ProfileV3( + onUpdateBanner: ((Uri, File, Context) -> Unit)? = null, + profile: AccountProfileEntity? = null, + onLogout: () -> Unit = {}, + onFollowClick: () -> Unit = {}, + onChatClick: () -> Unit = {}, + moments: List, + agents: List = emptyList(), + isSelf: Boolean = true, + isMain:Boolean = false, + isAiAccount: Boolean = false, // 新增参数判断是否为AI账户 + onLoadMore: () -> Unit = {}, + onLike: (MomentEntity) -> Unit = {}, + onComment: (MomentEntity) -> Unit = {}, + onAgentClick: (AgentEntity) -> Unit = {}, + postCount: Int? = null, // 新增参数用于传递帖子总数 +) { + val model = MyProfileViewModel + val pagerState = rememberPagerState(pageCount = { if (isAiAccount) 1 else 2 }) + val enabled by remember { mutableStateOf(true) } + val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues() + var expanded by remember { mutableStateOf(false) } + var minibarExpanded by remember { mutableStateOf(false) } + var showAgentMenu by remember { mutableStateOf(false) } + var contextAgent by remember { mutableStateOf(null) } + var showDeleteConfirmDialog by remember { mutableStateOf(false) } + var showOtherUserMenu by remember { mutableStateOf(false) } + var showBlockConfirmDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + val scope = rememberCoroutineScope() + val navController = LocalNavController.current + val bannerHeight = 400 + val pickBannerImageLauncher = pickupAndCompressLauncher( + context, + scope, + maxSize = ConstVars.BANNER_IMAGE_MAX_SIZE, + quality = 100 + ) { uri, file -> + onUpdateBanner?.invoke(uri, file, context) + } + val refreshState = rememberPullRefreshState(model.refreshing, onRefresh = { + model.loadProfile(pullRefresh = true) + }) + val agentMenuModalState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var miniToolbarHeight by remember { mutableStateOf(0) } + val density = LocalDensity.current + val appTheme = LocalAppTheme.current + val AppColors = appTheme + val systemUiController = rememberSystemUiController() + val listState = rememberLazyListState() + val gridState = rememberLazyGridState() + val scrollState = rememberScrollState() + + val toolbarAlpha by remember { + derivedStateOf { + if (!isSelf) { + 1f + } else { + val maxScroll = 500f // 最大滚动距离,可调整 + val progress = (scrollState.value.coerceAtMost(maxScroll.toInt()) / maxScroll).coerceIn(0f, 1f) + progress + } + } + } + + // observe list scrolling + val reachedListBottom by remember { + derivedStateOf { + val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() + lastVisibleItem?.index != 0 && lastVisibleItem?.index == listState.layoutInfo.totalItemsCount - 2 + } + } + + // observe grid scrolling for load more + val reachedGridBottom by remember { + derivedStateOf { + val layoutInfo = gridState.layoutInfo + val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull() + val totalItems = layoutInfo.totalItemsCount + val visibleItemsCount = layoutInfo.visibleItemsInfo.size + + Log.d("ProfileV3", "滚动状态检查 - totalItems: $totalItems, visibleItems: $visibleItemsCount, lastVisibleIndex: ${lastVisibleItem?.index}, moments.size: ${moments.size}, hasNext: ${model.momentLoader.hasNext}") + + // 如果没有可见item,不触发加载 + if (lastVisibleItem == null || totalItems == 0) { + Log.d("ProfileV3", "跳过加载 - 没有可见item或总数为0") + false + } else { + // 检查是否滚动到最后几个item(考虑到有Spacer item) + // 当接近底部时提前触发加载 + val triggerIndex = maxOf(0, totalItems - 2) + val result = lastVisibleItem.index >= triggerIndex && model.momentLoader.hasNext + Log.d("ProfileV3", "滚动检测结果 - lastVisibleIndex: ${lastVisibleItem.index}, triggerIndex: $triggerIndex, hasNext: ${model.momentLoader.hasNext}, shouldTrigger: $result") + result + } + } + } + + // load more if scrolled to bottom of list + LaunchedEffect(reachedListBottom) { + if (reachedListBottom) { + onLoadMore() + } + } + + // load more if scrolled to bottom of grid + LaunchedEffect(reachedGridBottom) { + Log.d("ProfileV3", "LaunchedEffect触发 - reachedGridBottom: $reachedGridBottom") + if (reachedGridBottom) { + Log.d("ProfileV3", "检测到网格滚动到底部,触发加载更多") + onLoadMore() + } + } + + fun switchTheme() { + // delay + scope.launch { + delay(200) + AppState.switchTheme() + systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode) + if (AppState.darkMode) { + (context as MainActivity).window.decorView.setBackgroundColor(android.graphics.Color.BLACK) + } else { + (context as MainActivity).window.decorView.setBackgroundColor(android.graphics.Color.WHITE) + } + } + + } + + // Agent菜单弹窗 + if (showAgentMenu) { + Log.d("ProfileV3", "Showing agent menu for: ${contextAgent?.title}") + ModalBottomSheet( + onDismissRequest = { + showAgentMenu = false + }, + containerColor = AppColors.background, + sheetState = agentMenuModalState, + dragHandle = {}, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + windowInsets = WindowInsets(0) + ) { + AgentMenuModal( + agent = contextAgent, + onDeleteClick = { + scope.launch { + agentMenuModalState.hide() + showAgentMenu = false + showDeleteConfirmDialog = true + } + }, + onCloseClick = { + scope.launch { + agentMenuModalState.hide() + showAgentMenu = false + } + }, + isSelf = isSelf + ) + } + } + + // 删除确认对话框 + if (showDeleteConfirmDialog) { + DeleteConfirmDialog( + agentName = contextAgent?.title ?: "", + onConfirm = { + // TODO: 实现删除逻辑 + contextAgent?.let { agent -> + // 调用删除API + scope.launch { + try { + // 这里应该调用删除智能体的API + // agentService.deleteAgent(agent.id) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + showDeleteConfirmDialog = false + contextAgent = null + }, + onDismiss = { + showDeleteConfirmDialog = false + contextAgent = null + } + ) + } + + Box( + modifier = Modifier.pullRefresh(refreshState) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .background(AppColors.profileBackground) + ) { + // Banner + val banner = profile?.banner + if (banner != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(bannerHeight.dp) + .background(AppColors.profileBackground) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(bannerHeight.dp - 24.dp) + .let { + if (isSelf && isMain) { + it.noRippleClickable { + Intent(Intent.ACTION_PICK).apply { + type = "image/*" + pickBannerImageLauncher.launch(this) + } + } + } else { + it + } + } + .shadow( + elevation = 6.dp, + shape = RoundedCornerShape( + bottomStart = 32.dp, + bottomEnd = 32.dp + ), + ) + ) { + CustomAsyncImage( + LocalContext.current, + banner, + modifier = Modifier.fillMaxSize(), + contentDescription = "", + contentScale = ContentScale.Crop + ) + } + } + } else { + Spacer(modifier = Modifier.height(100.dp)) + } + + // 用户信息 + Box( + modifier = Modifier + .fillMaxWidth() + .background(AppColors.profileBackground) + .padding(horizontal = 16.dp) + ) { + profile?.let { + UserItem( + accountProfileEntity = it, + postCount = postCount ?: if (isSelf) MyProfileViewModel.momentLoader.total else moments.size + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + // 操作按钮 + profile?.let { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + if (isSelf) { + SelfProfileAction( + onEditProfile = { + navController.navigate(NavigationRoute.AccountEdit.route) + }, + onPremiumClick = { + navController.navigate(NavigationRoute.VipSelPage.route) + } + ) + } else { + if (it.id != AppState.UserId) { + OtherProfileAction( + it, + onFollow = { + onFollowClick() + }, + onChat = { + onChatClick() + } + ) + } + } + } + } + + // 用户智能体行 + if (!isAiAccount) { + UserAgentsRow( + userId = if (isSelf) null else profile?.id, + modifier = Modifier.padding(top = 16.dp), + onMoreClick = { + // 导航到智能体列表页面 + }, + onAgentClick = { agent -> + // 导航到智能体详情页面 + }, + onAvatarClick = { agent -> + // 导航到智能体个人主页 + scope.launch { + try { + val userService = com.aiosman.ravenow.data.UserServiceImpl() + val profile = userService.getUserProfileByOpenId(agent.openId) + navController.navigate( + NavigationRoute.AccountProfile.route + .replace("{id}", profile.id.toString()) + .replace("{isAiAccount}", "true") + ) + } catch (e: Exception) { + // 处理错误 + } + } + }, + onAgentLongClick = { agent -> + Log.d("ProfileV3", "onAgentLongClick called for agent: ${agent.title}, isSelf: $isSelf") + if (isSelf) { // 只有自己的智能体才能长按 + Log.d("ProfileV3", "Setting contextAgent and showing menu") + contextAgent = agent + showAgentMenu = true + } + } + ) + } + + // 内容 + Column( + modifier = Modifier + .fillMaxWidth() + .background(AppColors.profileBackground) + .padding(top = 8.dp) + ) { + UserContentPageIndicator( + pagerState = pagerState, + showAgentTab = !isAiAccount + ) + Spacer(modifier = Modifier.height(8.dp)) + HorizontalPager( + state = pagerState, + modifier = Modifier.height(500.dp) // 固定滚动高度 + ) { idx -> + when (idx) { + 0 -> + GalleryGrid(moments = moments) + 1 -> + UserAgentsList( + agents = agents, + onAgentClick = onAgentClick, + onAvatarClick = { agent -> + // 导航到智能体个人主页,需要通过openId获取用户ID + scope.launch { + try { + val userService = com.aiosman.ravenow.data.UserServiceImpl() + val profile = userService.getUserProfileByOpenId(agent.openId) + navController.navigate( + NavigationRoute.AccountProfile.route + .replace("{id}", profile.id.toString()) + .replace("{isAiAccount}", "true") + ) + } catch (e: Exception) { + // 处理错误 + } + } + }, + modifier = Modifier.fillMaxSize() + ) + } + } + } + } + + // 顶部导航栏 + TopNavigationBar( + isMain = isMain, + isSelf = isSelf, + profile = profile, + navController = navController, + alpha = toolbarAlpha, + onMenuClick = { + showOtherUserMenu = true + } + ) + + PullRefreshIndicator( + model.refreshing, + refreshState, + Modifier.align(Alignment.TopCenter) + ) + + // 其他用户菜单弹窗 + if (showOtherUserMenu) { + ModalBottomSheet( + onDismissRequest = { showOtherUserMenu = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color.Transparent, // 设置容器背景透明 + contentColor = Color.Transparent, // 设置内容背景透明 + dragHandle = null, // 移除拖拽手柄 + windowInsets = androidx.compose.foundation.layout.WindowInsets(0) // 移除窗口边距 + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.Transparent) + ) { + OtherUserMenuModal( + onBlockClick = { + showBlockConfirmDialog = true + }, + onReportClick = { + // 实现举报逻辑 + }, + onCancelClick = { + showOtherUserMenu = false + }, + onDismiss = { showOtherUserMenu = false } + ) + } + } + + // 拉黑确认弹窗 + if (showBlockConfirmDialog) { + ModalBottomSheet( + onDismissRequest = { + showBlockConfirmDialog = false + }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color.Transparent, + contentColor = Color.Transparent, + dragHandle = null, + windowInsets = androidx.compose.foundation.layout.WindowInsets(0) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.Transparent) + ) { + BlockConfirmDialog( + userProfile = profile, + onConfirmBlock = { + // 实现拉黑逻辑 + }, + onDismiss = { + showBlockConfirmDialog = false + showOtherUserMenu = false + } + ) + } + } + } + } +} +} + +//顶部导航栏组件 +@Composable +fun TopNavigationBar( + isMain: Boolean, + isSelf: Boolean, + profile: AccountProfileEntity?, + navController: androidx.navigation.NavController, + alpha: Float, + onMenuClick: () -> Unit = {} +) { + val appColors = LocalAppTheme.current + Box( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { this.alpha = alpha } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(appColors.profileBackground) + ) { + StatusBarSpacer() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .noRippleClickable { + }, + verticalAlignment = Alignment.CenterVertically + ) { + if (!isMain) { + Image( + painter = painterResource(id = R.drawable.rider_pro_back_icon), + contentDescription = "Back", + modifier = Modifier + .noRippleClickable { + navController.navigateUp() + } + .size(24.dp), + colorFilter = ColorFilter.tint(appColors.text) + ) + Spacer(modifier = Modifier.width(8.dp)) + CustomAsyncImage( + LocalContext.current, + profile?.avatar, + modifier = Modifier + .size(32.dp) + .clip(CircleShape), + contentDescription = "", + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = profile?.nickName ?: "", + fontSize = 16.sp, + fontWeight = FontWeight.W600, + color = appColors.text + ) + } + Spacer(modifier = Modifier.weight(1f)) + if (isSelf && isMain) { + Box( + modifier = Modifier + .size(24.dp) + .padding(16.dp) + ) + } else if (!isSelf) { + Box( + modifier = Modifier + .noRippleClickable { + onMenuClick() + } + .padding(16.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_more_horizon), + contentDescription = "菜单", + tint = appColors.text, + modifier = Modifier.size(24.dp) + ) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + if (isSelf && isMain) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 32.dp, end = 16.dp) + .noRippleClickable { + IndexViewModel.openDrawer = true + } + ) { + Box( + modifier = Modifier.padding(16.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_more_horizon), + contentDescription = "", + tint = appColors.text + ) + } + } + } + } +} + +/** + * Agent菜单弹窗 + */ +@Composable +fun AgentMenuModal( + agent: AgentEntity?, + onDeleteClick: () -> Unit = {}, + onCloseClick: () -> Unit = {}, + isSelf: Boolean = true +) { + val AppColors = LocalAppTheme.current + + Column( + modifier = Modifier + .fillMaxWidth() + .background(AppColors.background) + .padding(vertical = 24.dp, horizontal = 20.dp) + ) { + Text( + "智能体", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = AppColors.text + ) + Spacer(modifier = Modifier.height(24.dp)) + + agent?.let { + Column( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(AppColors.nonActive) + .padding(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + ) { + CustomAsyncImage( + context = LocalContext.current, + imageUrl = it.avatar, + modifier = Modifier.fillMaxSize(), + contentDescription = "Avatar", + defaultRes = R.mipmap.rider_pro_agent + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + it.title, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = AppColors.text + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + it.desc.ifEmpty { "暂无描述" }, + maxLines = 2, + modifier = Modifier + .fillMaxWidth() + .padding(start = 32.dp), + overflow = TextOverflow.Ellipsis, + color = AppColors.text, + fontSize = 14.sp + ) + } + Spacer(modifier = Modifier.height(32.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isSelf) { + MenuActionItem( + icon = R.drawable.rider_pro_moment_delete, + text = stringResource(R.string.delete) + ) { + onDeleteClick() + } + Spacer(modifier = Modifier.width(48.dp)) + } + + MenuActionItem( + icon = R.drawable.rider_pro_more_horizon, + text = "更多" + ) { + // TODO: 实现更多功能 + onCloseClick() + } + } + Spacer(modifier = Modifier.height(48.dp)) + } +} + + + +/** + * 删除确认对话框 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeleteConfirmDialog( + agentName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + val AppColors = LocalAppTheme.current + + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "确认删除", + color = AppColors.text, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + }, + text = { + Text( + text = "确定要删除智能体「$agentName」吗?删除后无法恢复。", + color = AppColors.text, + fontSize = 14.sp + ) + }, + confirmButton = { + androidx.compose.material3.TextButton( + onClick = { + onConfirm() + } + ) { + Text( + "删除", + color = AppColors.error, + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + androidx.compose.material3.TextButton( + onClick = onDismiss + ) { + Text( + "取消", + color = AppColors.text + ) + } + }, + containerColor = AppColors.background + ) +} + +/** + * 其他用户主页菜单弹窗 + */ +@Composable +fun OtherUserMenuModal( + onBlockClick: () -> Unit = {}, + onReportClick: () -> Unit = {}, + onCancelClick: () -> Unit = {}, + onDismiss: () -> Unit = {} +) { + val AppColors = LocalAppTheme.current + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(bottom = 11.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = AppColors.background, + shape = RoundedCornerShape(8.dp) + ) + ) { + // 拉黑选项 + androidx.compose.material3.TextButton( + onClick = { + onBlockClick() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + stringResource(R.string.block), + color = AppColors.error, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + } + + // 分割线 + androidx.compose.material3.HorizontalDivider( + color = AppColors.divider, + thickness = 0.5.dp + ) + + // 举报选项 + androidx.compose.material3.TextButton( + onClick = { + onReportClick() + onDismiss() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + stringResource(R.string.report), + color = AppColors.error, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 取消按钮 + androidx.compose.material3.TextButton( + onClick = { + onCancelClick() + onDismiss() + }, + modifier = Modifier + .fillMaxWidth() + .background( + color = AppColors.background, + shape = RoundedCornerShape(8.dp) + ) + ) { + Text( + stringResource(R.string.cancel), + color = AppColors.text, + fontSize = 16.sp + ) + } + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/ProfileWrap.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/ProfileWrap.kt new file mode 100644 index 0000000..b321f96 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/ProfileWrap.kt @@ -0,0 +1,46 @@ +package com.aiosman.ravenow.ui.index.tabs.profile + +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.ui.navigateToPost + +@Composable +fun ProfileWrap( + +) { + val context = LocalContext.current + val navController = LocalNavController.current + LaunchedEffect(Unit) { + MyProfileViewModel.loadProfile() + } + ProfileV3( + isMain = true, + isAiAccount = MyProfileViewModel.profile?.aiAccount == true, // 传入AI账户判断 + postCount = MyProfileViewModel.momentLoader.total, + onUpdateBanner = { uri, file, context -> + MyProfileViewModel.updateUserProfileBanner(uri, file, context) + }, + onLogout = { + MyProfileViewModel.logout(context) + }, + profile = MyProfileViewModel.profile, + moments = MyProfileViewModel.moments, + agents = MyProfileViewModel.agents, + onLoadMore = { + Log.d("ProfileWrap", "onLoadMore被调用") + MyProfileViewModel.loadMoreMoment() + }, + onLike = { moments -> + MyProfileViewModel.likeMoment(moments) + }, + onComment = { + navController.navigateToPost(it.id) + }, + onAgentClick = { agent -> + // TODO: 处理Agent点击事件,导航到聊天页面 + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/GalleryItem.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/GalleryItem.kt new file mode 100644 index 0000000..2a945f9 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/GalleryItem.kt @@ -0,0 +1,269 @@ +package com.aiosman.ravenow.ui.index.tabs.profile.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.navigateToPost +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import androidx.compose.material3.Text +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel +import com.aiosman.ravenow.ui.network.ReloadButton +import com.aiosman.ravenow.utils.NetworkUtils +@Composable +fun GalleryItem( + moment: MomentEntity, + idx: Int = 0 +) { + val navController = LocalNavController.current + val AppColors = LocalAppTheme.current + val debouncer = rememberDebouncer() + + Box( + modifier = Modifier + .fillMaxWidth() + .let { + val firstImage = moment.images.firstOrNull() + if (firstImage?.width != null && + firstImage.height != null && + firstImage.width!! > 0 && + firstImage.height!! > 0 + ) { + val ratio = firstImage.width!!.toFloat() / firstImage.height!!.toFloat() + return@let it.aspectRatio(ratio.coerceIn(0.7f, 1.5f)) + } else { + return@let it.aspectRatio(if (idx % 3 == 0) 1.5f else 1f) + } + } + .clip(RoundedCornerShape(8.dp)) + .noRippleClickable { + debouncer { + navController.navigateToPost( + moment.id + ) + } + } + ) { + // 检查图片列表是否为空 + if (moment.images.isEmpty()) { + // 显示占位符内容,类似于EmptyAgentsView + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.mipmap.qs_dt_qs_img), + contentDescription = "暂无图片", + modifier = Modifier.size(181.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "故事还没开始", + fontSize = 16.sp, + color = AppColors.text, + fontWeight = FontWeight.W600 + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "发布一条动态,和世界打个招呼吧", + fontSize = 14.sp, + color = AppColors.secondaryText, + fontWeight = FontWeight.W400 + ) + } + } else { + CustomAsyncImage( + LocalContext.current, + moment.images[0].thumbnail, + modifier = Modifier + .fillMaxSize(), + contentDescription = "", + contentScale = ContentScale.Crop, + ) + } + } +} + + +@Composable +fun GalleryGrid( + moments: List +) { + val navController = LocalNavController.current + val AppColors = LocalAppTheme.current + val gridState = rememberLazyGridState() + val debouncer = rememberDebouncer() + var refreshKey by remember { mutableStateOf(0) } + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current) + + if (!isNetworkAvailable) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(vertical = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.mipmap.invalid_name_10), + contentDescription = "network error", + modifier = Modifier.size(181.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.friend_chat_no_network_title), + fontSize = 16.sp, + color = AppColors.text, + fontWeight = FontWeight.W600 + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.friend_chat_no_network_subtitle), + fontSize = 14.sp, + color = AppColors.secondaryText, + fontWeight = FontWeight.W400 + ) + Spacer(modifier = Modifier.height(16.dp)) + ReloadButton( + onClick = { + refreshKey++ + MyProfileViewModel.ResetModel() + MyProfileViewModel.loadProfile(pullRefresh = true) + } + ) + } + } else if (moments.isEmpty()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(vertical = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource( + id = if(AppState.darkMode) R.mipmap.qs_dt_qs_as_img + else R.mipmap.invalid_name_7), + contentDescription = "暂无图片", + modifier = Modifier.size(181.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "故事还没开始", + fontSize = 16.sp, + color = AppColors.text, + fontWeight = FontWeight.W600 + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "发布一条动态,和世界打个招呼吧", + fontSize = 14.sp, + color = AppColors.secondaryText, + fontWeight = FontWeight.W400 + ) + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + state = gridState, + modifier = Modifier.fillMaxSize().padding(bottom = 8.dp), + ) { + itemsIndexed(moments) { idx, moment -> + if (moment != null) { + val itemDebouncer = rememberDebouncer() + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .padding(2.dp) + .noRippleClickable { + itemDebouncer { + navController.navigateToPost( + id = moment.id, + highlightCommentId = 0, + initImagePagerIndex = 0 + ) + } + } + ) { + CustomAsyncImage( + imageUrl = moment.images[0].thumbnail, + contentDescription = "", + modifier = Modifier.fillMaxSize(), + context = LocalContext.current + ) + if (moment.images.size > 1) { + Box( + modifier = Modifier + .padding(top = 8.dp, end = 8.dp) + .align(Alignment.TopEnd) + ) { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.rider_pro_picture_more), + contentDescription = "", + ) + } + } + } + } + } + item { + Spacer(modifier = Modifier.height(120.dp)) + } + } + } +} + + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/MomentCard.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/MomentCard.kt new file mode 100644 index 0000000..776f650 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/MomentCard.kt @@ -0,0 +1,330 @@ +package com.aiosman.ravenow.ui.index.tabs.profile.composable + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.exp.formatPostTime2 +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.AnimatedLikeIcon +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.MomentOperateBtn +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.navigateToPost +import com.aiosman.ravenow.ui.post.NewPostViewModel + +@Composable +fun EmptyMomentPostUnit() { + TimeGroup(stringResource(R.string.empty_my_post_title)) + ProfileEmptyMomentCard() +} + +@Composable +fun ProfileEmptyMomentCard( + +) { + val AppColors = LocalAppTheme.current + var columnHeight by remember { mutableStateOf(0) } + val navController = LocalNavController.current + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 18.dp, end = 24.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + ) { + Canvas( + modifier = Modifier + .height(with(LocalDensity.current) { columnHeight.toDp() }) + .width(14.dp) + ) { + drawLine( + color = Color(0xff899DA9), + start = Offset(0f, 0f), + end = Offset(0f, size.height), + strokeWidth = 4f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 20f), 0f) + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Column( + modifier = Modifier + .weight(1f) + .onGloballyPositioned { coordinates -> + columnHeight = coordinates.size.height + } + ) { + Text(stringResource(R.string.empty_my_post_content), fontSize = 16.sp) + Spacer(modifier = Modifier.height(24.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(3f / 2f) + .background(Color.White) + .padding(16.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF5F5F5)) + .noRippleClickable { + NewPostViewModel.asNewPost() + navController.navigate(NavigationRoute.NewPost.route) + } + ) { + Icon( + Icons.Default.Add, + tint = Color(0xFFD8D8D8), + contentDescription = "New post", + modifier = Modifier + .size(32.dp) + .align(Alignment.Center) + ) + } + } + } + } + } +} + +@Composable +fun MomentPostUnit( + momentEntity: MomentEntity, + onCommentClick: () -> Unit = {}, + onLikeClick: () -> Unit = {} +) { + TimeGroup(momentEntity.time.formatPostTime2()) + ProfileMomentCard( + momentEntity.momentTextContent, + momentEntity.images[0].thumbnail, + momentEntity.likeCount.toString(), + momentEntity.commentCount.toString(), + momentEntity = momentEntity, + onCommentClick = onCommentClick, + onLikeClick = onLikeClick + ) +} + +@Composable +fun TimeGroup(time: String = "2024.06.08 12:23") { + val appColors = LocalAppTheme.current + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 40.dp, end = 24.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + modifier = Modifier + .height(16.dp) + .width(14.dp), + painter = painterResource(id = R.drawable.rider_pro_moment_time_flag), + contentDescription = "" + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = time, + fontSize = 16.sp, + color = appColors.text, + style = TextStyle(fontWeight = FontWeight.W600) + ) + } +} + +@Composable +fun ProfileMomentCard( + content: String, + imageUrl: String, + like: String, + comment: String, + momentEntity: MomentEntity, + onCommentClick: () -> Unit = {}, + onLikeClick: () -> Unit = {} +) { + var columnHeight by remember { mutableStateOf(0) } + val AppColors = LocalAppTheme.current + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 18.dp, end = 24.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + ) { + Canvas( + modifier = Modifier + .height(with(LocalDensity.current) { columnHeight.toDp() }) + .width(14.dp) + ) { + drawLine( + color = AppColors.divider, + start = Offset(0f, 0f), + end = Offset(0f, size.height), + strokeWidth = 4f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 20f), 0f) + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Column( + modifier = Modifier + .background( + AppColors.background) + .weight(1f) + .onGloballyPositioned { coordinates -> + columnHeight = coordinates.size.height + } + ) { + if (content.isNotEmpty()) { + MomentCardTopContent(content) + } + MomentCardPicture(imageUrl, momentEntity = momentEntity) + MomentCardOperation( + like = like, + isLike = momentEntity.liked, + onLikeClick = onLikeClick, + comment = comment, + onCommentClick = onCommentClick + ) + } + } + } +} + +@Composable +fun MomentCardTopContent(content: String) { + val AppColors = LocalAppTheme.current + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.padding(top = 16.dp, bottom = 0.dp, start = 16.dp, end = 16.dp), + text = content, fontSize = 16.sp, color = AppColors.text + ) + } +} + +@Composable +fun MomentCardPicture(imageUrl: String, momentEntity: MomentEntity) { + val navController = LocalNavController.current + val context = LocalContext.current + CustomAsyncImage( + context, + imageUrl, + modifier = Modifier + .fillMaxSize() + .aspectRatio(3f / 2f) + .padding(top = 16.dp) + .noRippleClickable { + navController.navigateToPost( + id = momentEntity.id, + highlightCommentId = 0, + initImagePagerIndex = 0 + ) + }, + contentDescription = "", + contentScale = ContentScale.Crop + ) + + +} + +@Composable +fun MomentCardOperation( + like: String, + isLike: Boolean, + onLikeClick: () -> Unit, + comment: String, + onCommentClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + MomentOperateBtn(count = like) { + AnimatedLikeIcon( + liked = isLike, + onClick = onLikeClick, + ) + } + + MomentOperateBtn(count = comment) { + Image( + painter = painterResource(id = R.drawable.rider_pro_moment_comment), + contentDescription = "", + colorFilter = ColorFilter.tint(LocalAppTheme.current.text), + modifier = Modifier.noRippleClickable { + onCommentClick() + } + + ) + } + } +} + +@Composable +fun MomentCardOperationItem(@DrawableRes drawable: Int, number: String, modifier: Modifier) { + val AppColors = LocalAppTheme.current + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + modifier = Modifier.padding(start = 16.dp, end = 8.dp), + painter = painterResource(id = drawable), contentDescription = "", + colorFilter = ColorFilter.tint(AppColors.text) + ) + Text(text = number, color = AppColors.text) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/OtherProfileAction.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/OtherProfileAction.kt new file mode 100644 index 0000000..30ee6bb --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/OtherProfileAction.kt @@ -0,0 +1,169 @@ +package com.aiosman.ravenow.ui.index.tabs.profile.composable + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.GuestLoginCheckOut +import com.aiosman.ravenow.GuestLoginCheckOutScene +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.entity.AccountProfileEntity +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + +@Composable +fun OtherProfileAction( + profile: AccountProfileEntity, + onFollow: (() -> Unit)? = null, + onChat: (() -> Unit)? = null +) { + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + + // 定义渐变色 + val followGradient = Brush.horizontalGradient( + colors = listOf( + Color(0xFF7c45ed), + Color(0x777c68ef) + ) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + // 关注按钮 - 渐变样式 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .let { modifier -> + if (profile.isFollowing) { + // 已关注状态 - 透明背景 + modifier.background(Color.Transparent) + } else { + // 未关注状态 - 渐变背景 + modifier.background(brush = followGradient) + } + } + .let { modifier -> + if (profile.isFollowing) { + modifier.border( + width = 1.dp, + color = AppColors.text.copy(alpha = 0.3f), + shape = RoundedCornerShape(8.dp) + ) + } else { + modifier + } + } + .padding(horizontal = 16.dp, vertical = 12.dp) + .noRippleClickable { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.FOLLOW_USER)) { + navController.navigate(NavigationRoute.Login.route) + } else { + onFollow?.invoke() + } + } + ) { + Text( + text = if (profile.isFollowing) "已关注" else stringResource(R.string.follow_upper), + fontSize = 14.sp, + fontWeight = FontWeight.W900, + color = if (profile.isFollowing) { + // 已关注状态 - 灰色文字 + AppColors.text.copy(alpha = 0.6f) + } else { + // 未关注状态 - 白色文字 + Color.White + }, + ) + } + + // 私信按钮 - 灰色背景样式 + if (AppState.enableChat) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .background(AppColors.nonActive) // 使用主题灰色背景 + .padding(horizontal = 16.dp, vertical = 12.dp) + .noRippleClickable { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + onChat?.invoke() + } + } + ) { + Text( + text = stringResource(R.string.chat_upper), + fontSize = 14.sp, + fontWeight = FontWeight.W900, + color = AppColors.text, // 使用主题文字颜色 + ) + } + } + + // 分享按钮 - 灰色背景样式 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .background(AppColors.nonActive) + .padding(horizontal = 16.dp, vertical = 12.dp) + .noRippleClickable { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + // TODO: 添加分享逻辑 + } + } + ) { + Text( + text = stringResource(R.string.share), + fontSize = 14.sp, + fontWeight = FontWeight.W900, + color = AppColors.text, // 使用主题文字颜色 + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/SelfProfileAction.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/SelfProfileAction.kt new file mode 100644 index 0000000..a8342ef --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/SelfProfileAction.kt @@ -0,0 +1,148 @@ +package com.aiosman.ravenow.ui.index.tabs.profile.composable + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + +@Composable +fun SelfProfileAction( + onEditProfile: () -> Unit, + onPremiumClick: (() -> Unit), + onShare: (() -> Unit)? = null +) { + val AppColors = LocalAppTheme.current + val editProfileDebouncer = rememberDebouncer() + val premiumClickDebouncer = rememberDebouncer() + val shareDebouncer = rememberDebouncer() + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + // 编辑个人资料按钮(左侧) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .width(60.dp).height(25.dp) + .clip(RoundedCornerShape(12.dp)) + .background(androidx.compose.ui.graphics.Color(0x229284BD)) + .noRippleClickable { + editProfileDebouncer { + onEditProfile() + } + } + ) { + Image( + painter = painterResource(id = R.mipmap.fill_and_sign), + contentDescription = "", + modifier = Modifier.size(12.dp), + colorFilter = ColorFilter.tint(androidx.compose.ui.graphics.Color(0xFF9284BD)) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.edit_profile), + fontSize = 12.sp, + fontWeight = FontWeight.W600, + color = androidx.compose.ui.graphics.Color(0xFF9284BD), + ) + } + +// // 预留按钮位置 +// Row( +// verticalAlignment = Alignment.CenterVertically, +// horizontalArrangement = Arrangement.Center, +// modifier = Modifier +// .weight(1f) +// .clip(RoundedCornerShape(10.dp)) +// .padding(horizontal = 16.dp, vertical = 12.dp) +// .noRippleClickable { +// +// } +// ) { +// Text( +// text = "", +// fontSize = 14.sp, +// fontWeight = FontWeight.W900, +// color = AppColors.text, +// ) +// } +// +// // 分享按钮 +// Row( +// verticalAlignment = Alignment.CenterVertically, +// horizontalArrangement = Arrangement.Center, +// modifier = Modifier +// .weight(1f) +// .clip(RoundedCornerShape(10.dp)) +// .background(AppColors.nonActive) +// .padding(horizontal = 16.dp, vertical = 12.dp) +// .noRippleClickable { +// shareDebouncer { +// // TODO: 添加分享逻辑 +// } +// } +// ) { +// Text( +// text = stringResource(R.string.share), +// fontSize = 14.sp, +// fontWeight = FontWeight.W900, +// color = AppColors.text, +// ) +// } + +// // Rave Premium 按钮(右侧) +// Row( +// verticalAlignment = Alignment.CenterVertically, +// horizontalArrangement = Arrangement.Center, +// modifier = Modifier +// .weight(1f) +// .clip(RoundedCornerShape(8.dp)) +// .background(AppColors.premiumBackground) +// .padding(horizontal = 16.dp, vertical = 12.dp) +// .noRippleClickable { +// premiumClickDebouncer { +// onPremiumClick?.invoke() +// } +// } +// ) { +// Image( +// painter = painterResource(id = R.drawable.ic_member), +// contentDescription = "", +// modifier = Modifier.size(18.dp), +// colorFilter = ColorFilter.tint(AppColors.premiumText) +// ) +// Spacer(modifier = Modifier.width(8.dp)) +// Text( +// text = "Rave Premium", +// fontSize = 14.sp, +// fontWeight = FontWeight.W600, +// color = AppColors.premiumText, +// ) +// } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/UserAgentsList.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/UserAgentsList.kt new file mode 100644 index 0000000..530cbcb --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/UserAgentsList.kt @@ -0,0 +1,271 @@ +package com.aiosman.ravenow.ui.index.tabs.profile.composable + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.GuestLoginCheckOut +import com.aiosman.ravenow.GuestLoginCheckOutScene +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.entity.AgentEntity +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel +import com.aiosman.ravenow.ui.network.ReloadButton +import com.aiosman.ravenow.utils.DebounceUtils +import com.aiosman.ravenow.utils.NetworkUtils + +@Composable +fun UserAgentsList( + agents: List, + onAgentClick: (AgentEntity) -> Unit = {}, + onAvatarClick: (AgentEntity) -> Unit = {}, + modifier: Modifier = Modifier +) { + val AppColors = LocalAppTheme.current + + LazyColumn( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (agents.isEmpty()) { + item { + EmptyAgentsView() + } + } else { + items(agents) { agent -> + UserAgentCard( + agent = agent, + onAgentClick = onAgentClick, + onAvatarClick = onAvatarClick + ) + } + } + + // 底部间距 + item { + Spacer(modifier = Modifier.height(120.dp)) + } + } +} + +@Composable +fun UserAgentCard( + agent: AgentEntity, + onAgentClick: (AgentEntity) -> Unit, + onAvatarClick: (AgentEntity) -> Unit = {} +) { + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + + // 防抖状态 + var lastClickTime by remember { mutableStateOf(0L) } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 左侧头像 + Box( + modifier = Modifier + .size(48.dp) + .background(AppColors.nonActive, RoundedCornerShape(24.dp)) + .clickable { + if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) { + onAvatarClick(agent) + }) { + lastClickTime = System.currentTimeMillis() + } + }, + contentAlignment = Alignment.Center + ) { + if (agent.avatar.isNotEmpty()) { + CustomAsyncImage( + imageUrl = agent.avatar, + contentDescription = "Agent头像", + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(24.dp)), + contentScale = ContentScale.Crop + ) + } else { + Image( + painter = painterResource(R.mipmap.rider_pro_agent), + contentDescription = "默认头像", + modifier = Modifier.size(24.dp), + colorFilter = ColorFilter.tint(AppColors.secondaryText) + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + // 中间文字内容 + Column( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) { + // 标题 + Text( + text = agent.title, + fontSize = 14.sp, + fontWeight = FontWeight.W600, + color = AppColors.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 描述 + Text( + text = agent.desc, + fontSize = 12.sp, + color = AppColors.secondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + // 右侧聊天按钮 + Box( + modifier = Modifier + .size(width = 60.dp, height = 32.dp) + .background( + color = AppColors.nonActive, + shape = RoundedCornerShape(8.dp) + ) + .clickable { + if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) { + navController.navigate(NavigationRoute.Login.route) + } else { + onAgentClick(agent) + } + }) { + lastClickTime = System.currentTimeMillis() + } + }, + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.chat), + fontSize = 12.sp, + color = AppColors.text, + fontWeight = FontWeight.W500 + ) + } + } +} + +@Composable +fun EmptyAgentsView() { + val AppColors = LocalAppTheme.current + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isNetworkAvailable) { + Image( + painter = painterResource( + id =if(AppState.darkMode) R.mipmap.qs_ai_qs_as_img + else R.mipmap.ai), + contentDescription = "暂无Agent", + modifier = Modifier.size(181.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "专属AI等你召唤", + fontSize = 16.sp, + color = AppColors.text, + fontWeight = FontWeight.W600 + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "AI将成为你的伙伴,而不是工具", + fontSize = 14.sp, + color = AppColors.secondaryText, + fontWeight = FontWeight.W400 + ) + } else { + Image( + painter = painterResource(id = R.mipmap.invalid_name_10), + contentDescription = "network error", + modifier = Modifier.size(181.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.friend_chat_no_network_title), + fontSize = 16.sp, + color = AppColors.text, + fontWeight = FontWeight.W600 + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.friend_chat_no_network_subtitle), + fontSize = 14.sp, + color = AppColors.secondaryText, + fontWeight = FontWeight.W400 + ) + Spacer(modifier = Modifier.height(16.dp)) + ReloadButton( + onClick = { + MyProfileViewModel.ResetModel() + MyProfileViewModel.loadProfile(pullRefresh = true) + } + ) + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/UserAgentsRow.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/UserAgentsRow.kt new file mode 100644 index 0000000..e7c3041 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/UserAgentsRow.kt @@ -0,0 +1,288 @@ +package com.aiosman.ravenow.ui.index.tabs.profile.composable + +import android.util.Log +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.R +import com.aiosman.ravenow.entity.AgentEntity +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + +@Composable +fun UserAgentsRow( + userId: Int?, + modifier: Modifier = Modifier, + onMoreClick: () -> Unit = {}, + onAgentClick: (AgentEntity) -> Unit = {}, + onAvatarClick: (AgentEntity) -> Unit = {}, + onAgentLongClick: (AgentEntity) -> Unit = {} +) { + val AppColors = LocalAppTheme.current + val viewModel: UserAgentsViewModel = viewModel(key = "UserAgentsViewModel_${userId ?: "self"}") + val isSelf = userId == null + + // 加载用户的智能体数据 + LaunchedEffect(userId) { + // 无论userId是否为null都加载数据 + // null表示加载当前用户自己的智能体 + viewModel.loadUserAgents(userId) + } + +// // 总是显示智能体区域,即使没有数据也显示标题和状态 +// Column( +// modifier = modifier +// .fillMaxWidth() +// .padding(horizontal = 16.dp) +// ) { +// Text( +// text = if (isSelf) "我的智能体" else "TA的智能体", +// fontSize = 16.sp, +// fontWeight = FontWeight.W600, +// color = AppColors.text, +// modifier = Modifier.padding(bottom = 12.dp) +// ) +// +// when { +// viewModel.isLoading -> { +// // 显示加载状态 +// Box( +// modifier = Modifier +// .fillMaxWidth() +// .height(60.dp), +// contentAlignment = Alignment.Center +// ) { +// Text( +// text = "加载中...", +// fontSize = 14.sp, +// color = AppColors.text.copy(alpha = 0.6f) +// ) +// } +// } +// viewModel.error != null -> { +// // 显示错误状态 +// Box( +// modifier = Modifier +// .fillMaxWidth() +// .height(60.dp), +// contentAlignment = Alignment.Center +// ) { +// Text( +// text = "加载失败: ${viewModel.error}", +// fontSize = 14.sp, +// color = AppColors.text.copy(alpha = 0.6f) +// ) +// } +// } +// viewModel.agents.isEmpty() -> { +// // 显示空状态 +// Box( +// modifier = Modifier +// .fillMaxWidth() +// .height(60.dp), +// contentAlignment = Alignment.Center +// ) { +// Text( +// text = if (isSelf) "您还没有创建智能体" else "TA还没有创建智能体", +// fontSize = 14.sp, +// color = AppColors.text.copy(alpha = 0.6f) +// ) +// } +// } +// else -> { +// // 显示智能体列表 +// LazyRow( +// horizontalArrangement = Arrangement.spacedBy(12.dp), +// modifier = Modifier.fillMaxWidth() +// ) { +// // 显示智能体项目 +// items(viewModel.agents) { agent -> +// AgentItem( +// agent = agent, +// onClick = { onAgentClick(agent) }, +// onAvatarClick = { onAvatarClick(agent) }, +// onLongClick = { onAgentLongClick(agent) } +// ) +// } +// +// // 添加"更多"按钮 +// item { +// MoreAgentItem( +// onClick = onMoreClick +// ) +// } +// } +// } +// } +// +// Spacer(modifier = Modifier.height(16.dp)) +// } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun AgentItem( + agent: AgentEntity, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + onAvatarClick: () -> Unit = {}, + onLongClick: () -> Unit = {} +) { + val AppColors = LocalAppTheme.current + val clickDebouncer = rememberDebouncer() + val avatarClickDebouncer = rememberDebouncer() + val longClickDebouncer = rememberDebouncer() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { + Log.d("AgentItem", "onClick triggered for agent: ${agent.title}") + clickDebouncer { + onClick() + } + }, + onLongClick = { + Log.d("AgentItem", "onLongClick triggered for agent: ${agent.title}") + longClickDebouncer { + onLongClick() + } + } + ) + ) { + // 头像 + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { + Log.d("AgentItem", "Avatar clicked for agent: ${agent.title}") + avatarClickDebouncer { + onAvatarClick() + } + }, + onLongClick = { + Log.d("AgentItem", "Avatar long clicked for agent: ${agent.title}") + longClickDebouncer { + onLongClick() + } + } + ) + ) { + CustomAsyncImage( + context = LocalContext.current, + imageUrl = agent.avatar, + contentDescription = agent.title, + modifier = Modifier.size(48.dp), + contentScale = ContentScale.Crop, + defaultRes = R.mipmap.rider_pro_agent + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 名字 + Text( + text = agent.title, + fontSize = 12.sp, + fontWeight = FontWeight.W500, + color = AppColors.text, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(48.dp) + ) + } +} + + +@Composable +private fun MoreAgentItem( + modifier: Modifier = Modifier, + onClick: () -> Unit = {} +) { + val AppColors = LocalAppTheme.current + val debouncer = rememberDebouncer() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.noRippleClickable { + debouncer { + onClick() + } + } + ) { + // 圆形右箭头按钮 + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(AppColors.background), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.rave_now_nav_right), + contentDescription = "更多", + tint = AppColors.text, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // "更多"文字 + Text( + text = "更多", + fontSize = 12.sp, + fontWeight = FontWeight.W500, + color = AppColors.text, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(48.dp) + ) + } +} + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/UserAgentsViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/UserAgentsViewModel.kt new file mode 100644 index 0000000..07fd5ba --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/UserAgentsViewModel.kt @@ -0,0 +1,52 @@ +package com.aiosman.ravenow.ui.index.tabs.profile.composable + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.aiosman.ravenow.entity.AgentEntity +import com.aiosman.ravenow.entity.AgentLoader +import com.aiosman.ravenow.entity.AgentLoaderExtraArgs +import kotlinx.coroutines.launch + +class UserAgentsViewModel : ViewModel() { + private val agentLoader = AgentLoader().apply { + onListChanged = { + agents = it + } + } + + var agents by mutableStateOf>(emptyList()) + private set + + var isLoading by mutableStateOf(false) + private set + + var error by mutableStateOf(null) + private set + + fun loadUserAgents(userId: Int?) { + viewModelScope.launch { + // 先清理之前的数据 + clearAgents() + isLoading = true + error = null + + try { + agentLoader.loadData(AgentLoaderExtraArgs(authorId = userId)) + } catch (e: Exception) { + error = e.message ?: "加载失败" + e.printStackTrace() + } finally { + isLoading = false + } + } + } + + fun clearAgents() { + agents = emptyList() + error = null + agentLoader.clear() + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/UserContentPageIndicator.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/UserContentPageIndicator.kt new file mode 100644 index 0000000..8e448a5 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/UserContentPageIndicator.kt @@ -0,0 +1,124 @@ +package com.aiosman.ravenow.ui.index.tabs.profile.composable + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.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.modifiers.noRippleClickable +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun UserContentPageIndicator( + pagerState: PagerState, + showAgentTab: Boolean = true +){ + val scope = rememberCoroutineScope() + val AppColors = LocalAppTheme.current + + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + // 图片/相册 Tab + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .noRippleClickable { + scope.launch { + pagerState.scrollToPage(0) + } + } + .padding(vertical = 12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_images), + contentDescription = "Gallery", + tint = if (pagerState.currentPage == 0) AppColors.text else AppColors.text.copy(alpha = 0.6f), + modifier = Modifier.size(24.dp) + ) + } + + // Agent Tab (只在非智能体用户时显示) + if (showAgentTab) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .noRippleClickable { + scope.launch { + pagerState.scrollToPage(1) + } + } + .padding(vertical = 12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_nav_ai), + contentDescription = "Agents", + tint = if (pagerState.currentPage == 1) AppColors.text else AppColors.text.copy(alpha = 0.6f), + modifier = Modifier.size(24.dp) + ) + } + } + } + + // 下划线指示器 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + .height(2.dp) + .background( + if (pagerState.currentPage == 0) AppColors.text else Color.Transparent + ) + ) + if (showAgentTab) { + Box( + modifier = Modifier + .weight(1f) + .height(2.dp) + .background( + if (pagerState.currentPage == 1) AppColors.text else Color.Transparent + ) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/UserItem.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/UserItem.kt new file mode 100644 index 0000000..89e72ae --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/composable/UserItem.kt @@ -0,0 +1,172 @@ +package com.aiosman.ravenow.ui.index.tabs.profile.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +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.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.entity.AccountProfileEntity +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + +@Composable +fun UserItem( + accountProfileEntity: AccountProfileEntity, + postCount: Int = 0 +) { + val navController = LocalNavController.current + val AppColors = LocalAppTheme.current + val followerDebouncer = rememberDebouncer() + val followingDebouncer = rememberDebouncer() + + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + // 头像 + CustomAsyncImage( + LocalContext.current, + accountProfileEntity.avatar, + modifier = Modifier + .clip(CircleShape) + .size(48.dp), + contentDescription = "", + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(32.dp)) + //个人统计 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + // 帖子数 + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f) + ) { + Text( + text = postCount.toString(), + fontWeight = FontWeight.W600, + fontSize = 16.sp, + color = AppColors.text + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "帖子", + color = AppColors.text + ) + } + + // 粉丝数 + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .noRippleClickable { + followerDebouncer { + navController.navigate( + NavigationRoute.FollowerList.route.replace( + "{id}", + accountProfileEntity.id.toString() + ) + ) + } + } + ) { + Text( + text = accountProfileEntity.followerCount.toString(), + fontWeight = FontWeight.W600, + fontSize = 16.sp, + color = AppColors.text + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "粉丝", + color = AppColors.text + ) + } + + // 关注数 + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .noRippleClickable { + followingDebouncer { + navController.navigate( + NavigationRoute.FollowingList.route.replace( + "{id}", + accountProfileEntity.id.toString() + ) + ) + } + } + ) { + Text( + text = accountProfileEntity.followingCount.toString(), + fontWeight = FontWeight.W600, + fontSize = 16.sp, + color = AppColors.text + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "关注", + color = AppColors.text + ) + } + } + } + Spacer(modifier = Modifier.height(12.dp)) + // 昵称 + Text( + text = accountProfileEntity.nickName, + fontWeight = FontWeight.W600, + fontSize = 16.sp, + color = AppColors.text + ) + Spacer(modifier = Modifier.height(4.dp)) + // 个人简介 + if (accountProfileEntity.bio.isNotEmpty()){ + Text( + text = accountProfileEntity.bio, + fontSize = 14.sp, + color = AppColors.secondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }else{ + Text( + text = "No bio here.", + fontSize = 14.sp, + color = AppColors.secondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/vip/VipSelPage.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/vip/VipSelPage.kt new file mode 100644 index 0000000..f6a5702 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/vip/VipSelPage.kt @@ -0,0 +1,289 @@ +package com.aiosman.ravenow.ui.index.tabs.profile.vip + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.border +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.Dp +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.data.api.ApiClient +import com.aiosman.ravenow.data.membership.MembershipConfigData +import com.aiosman.ravenow.data.membership.VipModelMapper +import com.aiosman.ravenow.data.membership.VipPageDataModel +import com.aiosman.ravenow.data.membership.VipPriceModel +import com.aiosman.ravenow.ui.composables.StatusBarSpacer +import com.aiosman.ravenow.R +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.text.SpanStyle +import com.aiosman.ravenow.ui.modifiers.noRippleClickable + +@Composable +fun VipSelPage() { + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + var config by remember { mutableStateOf(null) } + var selectedTabIndex by remember { mutableStateOf(0) } // 0 标准版, 1 专业版 + var selPrice by remember { mutableStateOf(null) } + + val prices: List = remember(config) { + config?.let { VipModelMapper.generatePriceDataList(it.configData.members) } ?: emptyList() + } + val pageDataList: List = remember(config) { + config?.let { VipModelMapper.generatePageDataList(it.configData.members) } ?: emptyList() + } + + LaunchedEffect(Unit) { + try { + val resp = ApiClient.api.getMembershipConfig() + val body = resp.body() + if (resp.isSuccessful && body != null) { + config = body.data + selPrice = VipModelMapper.generatePriceDataList(body.data.configData.members).firstOrNull() + isLoading = false + } else { + errorMessage = "加载失败" + isLoading = false + } + } catch (e: Exception) { + errorMessage = e.message + isLoading = false + } + } + + Column(modifier = Modifier.fillMaxSize().background(AppColors.profileBackground)) { + StatusBarSpacer() + // 顶部栏(居中标题 + 返回) + Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { + Image( + painter = painterResource(id = R.drawable.rider_pro_back_icon), + contentDescription = "back", + colorFilter = ColorFilter.tint(AppColors.text), + modifier = Modifier.align(Alignment.CenterStart).noRippleClickable { + navController.navigateUp() + } + ) + Text( + text = "Rave Premium", + fontSize = 18.sp, + fontWeight = FontWeight.W700, + color = AppColors.text, + modifier = Modifier.align(Alignment.Center) + ) + } + + when { + isLoading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + errorMessage != null -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = errorMessage ?: "错误", color = AppColors.error) + } + } + config != null -> { + Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(16.dp)) { + // 顶部分段选择 Premium / Standard + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Segment(text = "Premium", selected = selectedTabIndex == 1) { selectedTabIndex = 1 } + Segment(text = "Standard", selected = selectedTabIndex == 0) { selectedTabIndex = 0 } + } + + // 三列价格卡 + val displayedPrices = remember(prices, selectedTabIndex) { + prices.filter { it.id == VipPriceModel.MONTH_ID || it.id == VipPriceModel.YEAR_ID } + } + LaunchedEffect(selectedTabIndex, prices) { + selPrice = displayedPrices.firstOrNull() + } + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + displayedPrices.forEach { model -> + PriceCard( + model = model, + isPremium = selectedTabIndex == 1, + selected = selPrice?.id == model.id, + onClick = { selPrice = model } + ) + } + Spacer(modifier = Modifier.weight(1f)) + } + + // 权益表格(带圆角背景) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(16.dp)) + .background(AppColors.priceCardUnselectedBackground) + .border( + width = 1.dp, + color = AppColors.priceCardUnselectedBorder, + shape = RoundedCornerShape(16.dp) + ) + ) { + // 权益表头 + BenefitTableHeader(modifier = Modifier.padding(horizontal = 22.dp, vertical = 16.dp)) + + // 权益内容 + LazyColumn(modifier = Modifier.weight(1f)) { + items(pageDataList, key = { it.title }) { item -> + BenefitRow(item, selectedTabIndex, modifier = Modifier.padding(horizontal = 22.dp)) + } + item { + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + + // 底部订阅按钮(大按钮,浅黄色背景) + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 20.dp)) { + Button( + onClick = { + val goodsId = if (selectedTabIndex == 1) selPrice?.proGoodsId else selPrice?.standardGoodsId + // TODO: 接入 Billing 与 validate + }, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)), + colors = ButtonDefaults.buttonColors(containerColor = AppColors.premiumBackground, contentColor = AppColors.premiumText) + ) { + Text(text = "订阅", fontSize = 16.sp, fontWeight = FontWeight.W700) + } + } + } + } + } + } +} + +@Composable +private fun Segment(text: String, selected: Boolean, onClick: () -> Unit) { + val AppColors = LocalAppTheme.current + Box( + modifier = Modifier.clip(RoundedCornerShape(14.dp)) + .background(if (selected) AppColors.text else AppColors.nonActive) + .padding(horizontal = 12.dp, vertical = 6.dp) + .noRippleClickable { onClick() } + ) { + Text(text = text, color = if (selected) AppColors.background else AppColors.text, fontSize = 13.sp, fontWeight = FontWeight.W700) + } +} + +@Composable +private fun PriceCard(model: VipPriceModel, isPremium: Boolean, selected: Boolean, onClick: () -> Unit) { + val AppColors = LocalAppTheme.current + Column( + modifier = Modifier + .width(100.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) AppColors.priceCardSelectedBackground else AppColors.priceCardUnselectedBackground) + .border( + width = 1.dp, + color = if (selected) AppColors.priceCardSelectedBorder else AppColors.priceCardUnselectedBorder, + shape = RoundedCornerShape(12.dp) + ) + .padding(vertical = 16.dp, horizontal = 12.dp) + .noRippleClickable { onClick() } + ) { + Text(modifier = Modifier.fillMaxWidth(),text = model.title, fontSize = 13.sp, fontWeight = FontWeight.W600, color = AppColors.text, textAlign = TextAlign.Center) + Spacer(modifier = Modifier.height(16.dp)) + val price = if (isPremium) model.proPrice else model.standardPrice + val origin = if (isPremium) model.proDesc else model.standardDesc + Text( + modifier = Modifier.fillMaxWidth(), + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.W600)) { + append("US$") + } + withStyle(style = SpanStyle(fontSize = 18.sp, fontWeight = FontWeight.W800)) { + append(price) + } + }, + color = AppColors.text, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + + if (origin.isNotEmpty()) { + Text( + text = origin, + fontSize = 11.sp, + color = AppColors.nonActiveText, + textDecoration = TextDecoration.LineThrough, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun BenefitTableHeader(modifier: Modifier = Modifier) { + val AppColors = LocalAppTheme.current + Row(modifier = modifier.fillMaxWidth()) { + Text(text = "What you get", modifier = Modifier.weight(1f), fontSize = 13.sp, color = AppColors.secondaryText, fontWeight = FontWeight.W700) + Text(text = "Premium", modifier = Modifier.width(80.dp), fontSize = 13.sp, color = AppColors.secondaryText, fontWeight = FontWeight.W700) + Text(text = "Standard", modifier = Modifier.width(80.dp), fontSize = 13.sp, color = AppColors.secondaryText, fontWeight = FontWeight.W700) + } +} + +@Composable +private fun BenefitRow(item: VipPageDataModel, selectedTabIndex: Int, modifier: Modifier = Modifier) { + val AppColors = LocalAppTheme.current + Row(modifier = modifier.fillMaxWidth().padding(vertical = 14.dp)) { + Text(text = item.title, modifier = Modifier.weight(1f), fontSize = 14.sp, color = AppColors.text) + // Premium 列 + Text( + text = item.proDesc.ifEmpty { if (item.proHave == true) "✓" else "×" }, + color = if (item.proHave == true || item.proDesc.isNotEmpty()) AppColors.vipHave else AppColors.nonActiveText, + fontSize = 13.sp, + modifier = Modifier.width(80.dp) + ) + // Standard 列 + Text( + text = item.standardDesc.ifEmpty { if (item.standardHave == true) "✓" else "×" }, + color = if (item.standardHave == true || item.standardDesc.isNotEmpty()) AppColors.text else AppColors.nonActiveText, + fontSize = 13.sp, + modifier = Modifier.width(80.dp) + ) + } +} + + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/search/DiscoverScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/search/DiscoverScreen.kt new file mode 100644 index 0000000..266a683 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/search/DiscoverScreen.kt @@ -0,0 +1,192 @@ +package com.aiosman.ravenow.ui.index.tabs.search + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.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.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.StatusBarSpacer +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.navigateToPost + + +@OptIn( ExperimentalMaterialApi::class) +@Preview +@Composable +fun DiscoverScreen() { + val model = DiscoverViewModel + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + val navigationBarPaddings = + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 48.dp + LaunchedEffect(Unit) { + DiscoverViewModel.refreshPager() + } + var refreshing by remember { mutableStateOf(false) } + val state = rememberPullRefreshState(refreshing, onRefresh = { + model.refreshPager() + }) + + Column( + modifier = Modifier + .fillMaxSize() + .pullRefresh(state) + .padding(bottom = navigationBarPaddings) + ) { + Column( + modifier = Modifier.fillMaxWidth().background( + AppColors.background).padding(bottom = 10.dp) + ) { + StatusBarSpacer() + SearchButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, start = 16.dp, end = 16.dp), + ) { + SearchViewModel.requestFocus = true + navController.navigate(NavigationRoute.Search.route) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + DiscoverView() + PullRefreshIndicator(refreshing, state, Modifier.align(Alignment.TopCenter)) + } + } +} + +@Composable +fun SearchButton( + modifier: Modifier = Modifier, + clickAction: () -> Unit = {} +) { + val AppColors = LocalAppTheme.current + val context = LocalContext.current + Box( + modifier = modifier + .clip(shape = RoundedCornerShape(8.dp)) + .background( + AppColors.inputBackground) + .padding(horizontal = 16.dp, vertical = 12.dp) + .noRippleClickable { + clickAction() + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Search, + contentDescription = null, + tint = AppColors.inputHint + ) + Box { + Text( + text = context.getString(R.string.search), + modifier = Modifier.padding(start = 8.dp), + color = AppColors.inputHint, + fontSize = 18.sp + ) + } + } + } +} + +@Composable +fun DiscoverView() { + val model = DiscoverViewModel + var dataFlow = model.discoverMomentsFlow + var moments = dataFlow.collectAsLazyPagingItems() + val context = LocalContext.current + val navController = LocalNavController.current + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.fillMaxSize().padding(bottom = 8.dp), +// contentPadding = PaddingValues(8.dp) + ) { + items(moments.itemCount) { idx -> + val momentItem = moments[idx] ?: return@items + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .padding(2.dp) + .clip(RoundedCornerShape(8.dp)) + .noRippleClickable { + navController.navigateToPost( + id = momentItem.id, + highlightCommentId = 0, + initImagePagerIndex = 0 + ) + } + ) { + CustomAsyncImage( + imageUrl = momentItem.images[0].thumbnail, + contentDescription = "", + modifier = Modifier + .fillMaxSize(), + context = context + ) + if (momentItem.images.size > 1) { + Box( + modifier = Modifier + .padding(top = 8.dp, end = 8.dp) + .align(Alignment.TopEnd) + ) { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.rider_pro_picture_more), + contentDescription = "", + ) + } + } + + + } + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/search/DiscoverViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/search/DiscoverViewModel.kt new file mode 100644 index 0000000..fca9645 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/search/DiscoverViewModel.kt @@ -0,0 +1,47 @@ +package com.aiosman.ravenow.ui.index.tabs.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.aiosman.ravenow.data.MomentService +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.entity.MomentPagingSource +import com.aiosman.ravenow.entity.MomentRemoteDataSource +import com.aiosman.ravenow.entity.MomentServiceImpl +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +object DiscoverViewModel:ViewModel() { + private val momentService: MomentService = MomentServiceImpl() + private val _discoverMomentsFlow = + MutableStateFlow>(PagingData.empty()) + val discoverMomentsFlow = _discoverMomentsFlow.asStateFlow() + var firstLoad = true + fun refreshPager() { + if (!firstLoad) { + return + } + firstLoad = false + viewModelScope.launch { + Pager( + config = PagingConfig(pageSize = 5, enablePlaceholders = false), + pagingSourceFactory = { + MomentPagingSource( + MomentRemoteDataSource(momentService), + trend = true + ) + } + ).flow.cachedIn(viewModelScope).collectLatest { + _discoverMomentsFlow.value = it + } + } + } + fun ResetModel(){ + firstLoad = true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/search/SearchScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/search/SearchScreen.kt new file mode 100644 index 0000000..81d7cbd --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/search/SearchScreen.kt @@ -0,0 +1,606 @@ +package com.aiosman.ravenow.ui.index.tabs.search + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.entity.AccountProfileEntity +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.MomentCard +import com.aiosman.ravenow.ui.composables.TabItem +import com.aiosman.ravenow.ui.composables.TabSpacer +import com.aiosman.ravenow.ui.index.tabs.message.tab.AgentChatListViewModel +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.launch +import com.aiosman.ravenow.utils.NetworkUtils + + +@OptIn(ExperimentalFoundationApi::class) +@Preview +@Composable +fun SearchScreen() { + val AppColors = LocalAppTheme.current + val context = LocalContext.current + val model = SearchViewModel + val categories = listOf(context.getString(R.string.moment), context.getString(R.string.users)) + val coroutineScope = rememberCoroutineScope() + val pagerState = rememberPagerState(pageCount = { categories.size }) + val selectedTabIndex = remember { derivedStateOf { pagerState.currentPage } } + val keyboardController = LocalSoftwareKeyboardController.current + val systemUiController = rememberSystemUiController() + val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues() + val navigationBarPaddings = + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val focusRequester = remember { FocusRequester() } + val navController = LocalNavController.current + LaunchedEffect(Unit) { + systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode) + } + LaunchedEffect(Unit) { + if (model.requestFocus) { + focusRequester.requestFocus() + model.requestFocus = false + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) + .padding(bottom = navigationBarPaddings) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + AppColors.background + ) + .padding(bottom = 10.dp) + ) { + Spacer(modifier = Modifier.height(statusBarPaddingValues.calculateTopPadding())) + Row( + modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SearchInput( + modifier = Modifier + .weight(1f), + text = model.searchText, + onTextChange = { + model.searchText = it + }, + onSearch = { + model.search() + // hide ime + keyboardController?.hide() // Hide the keyboard + }, + focusRequester = focusRequester + ) + Spacer(modifier = Modifier.size(16.dp)) + Text( + stringResource(R.string.cancel), + fontSize = 16.sp, + modifier = Modifier.noRippleClickable { + navController.navigateUp() + }, + color = AppColors.text + ) + } + + // 添加user、dynamic和ai标签页 + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(start = 16.dp, top = 16.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.Bottom + ) { + Box { + TabItem( + text = stringResource(R.string.moment), + isSelected = pagerState.currentPage == 0, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(0) + } + } + ) + } + TabSpacer() + Box { + TabItem( + text = stringResource(R.string.users), + isSelected = pagerState.currentPage == 1, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(1) + } + } + ) + } + TabSpacer() + Box { + TabItem( + text = stringResource(R.string.chat_ai), + isSelected = pagerState.currentPage == 2, + onClick = { + // TODO: 实现点击逻辑 + } + ) + } + } + } + + if (model.showResult) { + TabRow( + selectedTabIndex = selectedTabIndex.value, + backgroundColor = AppColors.background, + contentColor = AppColors.text, + ) { + categories.forEachIndexed { index, category -> + Tab( + selected = selectedTabIndex.value == index, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { Text(category, color = AppColors.text) } + ) + } + } + SearchPager( + pagerState = pagerState + ) + } + } +} + +@Composable +fun SearchInput( + modifier: Modifier = Modifier, + text: String = "", + onTextChange: (String) -> Unit = {}, + onSearch: () -> Unit = {}, + focusRequester: FocusRequester = remember { FocusRequester() } +) { + val AppColors = LocalAppTheme.current + val context = LocalContext.current + Box( + modifier = modifier + .clip(shape = RoundedCornerShape(8.dp)) + .background( + AppColors.inputBackground + ) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Search, + contentDescription = null, + tint = AppColors.inputHint + ) + Box { + if (text.isEmpty()) { + Text( + text = context.getString(R.string.search), + modifier = Modifier.padding(start = 8.dp), + color = AppColors.inputHint, + fontSize = 18.sp + ) + } + BasicTextField( + value = text, + onValueChange = { + onTextChange(it) + }, + modifier = Modifier + .padding(start = 8.dp) + .fillMaxWidth() + .focusRequester(focusRequester), + singleLine = true, + textStyle = TextStyle( + fontSize = 18.sp, + color = AppColors.text + ), + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Search + ), + keyboardActions = KeyboardActions( + onSearch = { + onSearch() + } + ), + cursorBrush = SolidColor(AppColors.text) + ) + } + } + + + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SearchPager( + pagerState: PagerState, +) { + val AppColors = LocalAppTheme.current + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize().background(AppColors.background), + + ) { page -> + when (page) { + 0 -> MomentResultTab() + 1 -> UserResultTab() + } + } +} + +@Composable +fun MomentResultTab() { + val model = SearchViewModel + var dataFlow = model.momentsFlow + var moments = dataFlow.collectAsLazyPagingItems() + val AppColors = LocalAppTheme.current + val context = LocalContext.current + Box( + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) + ) { + if (moments.itemCount == 0 && model.showResult) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(context) + + if (isNetworkAvailable) { + androidx.compose.foundation.Image( + painter = painterResource( + id = if(AppState.darkMode) R.mipmap.syss_yh_qs_as_img + else R.mipmap.invalid_name_1), + contentDescription = "No Comment", + modifier = Modifier.size(140.dp) + ) + Text( + text = "咦,什么都没找到...", + color = LocalAppTheme.current.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "换个关键词试试吧,也许会有新发现!", + color = LocalAppTheme.current.secondaryText, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + } else { + androidx.compose.foundation.Image( + painter = painterResource(id = R.mipmap.invalid_name_10), + contentDescription = "network error", + modifier = Modifier.size(140.dp) + ) + Text( + text = stringResource(R.string.friend_chat_no_network_title), + color = LocalAppTheme.current.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_subtitle), + color = LocalAppTheme.current.secondaryText, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + Spacer(modifier = Modifier.size(16.dp)) + ReloadButton( + onClick = { + SearchViewModel.ResetModel() + SearchViewModel.search() + } + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(moments.itemCount) { idx -> + val momentItem = moments[idx] ?: return@items + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + ) { + MomentCard( + momentEntity = momentItem, + hideAction = true, + onFollowClick = { + model.momentFollowAction(momentItem) + } + ) + } +// Spacer(modifier = Modifier.padding(16.dp)) + } + } + } + } +} + +@Composable +fun UserResultTab() { + val model = SearchViewModel + val users = model.usersFlow.collectAsLazyPagingItems() + val scope = rememberCoroutineScope() + val context = LocalContext.current + Box( + modifier = Modifier.fillMaxSize() + ) { + if (users.itemCount == 0 && model.showResult) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(context) + + if (isNetworkAvailable) { + androidx.compose.foundation.Image( + painter = painterResource( + id = if(AppState.darkMode) R.mipmap.syss_yh_qs_as_img + else R.mipmap.invalid_name_1), + contentDescription = "No Comment", + modifier = Modifier.size(140.dp) + ) + Text( + text = "咦,什么都没找到...", + color = LocalAppTheme.current.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "换个关键词试试吧,也许会有新发现!", + color = LocalAppTheme.current.secondaryText, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + } else { + androidx.compose.foundation.Image( + painter = painterResource(id = R.mipmap.invalid_name_10), + contentDescription = "network error", + modifier = Modifier.size(140.dp) + ) + Text( + text = stringResource(R.string.friend_chat_no_network_title), + color = LocalAppTheme.current.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_subtitle), + color = LocalAppTheme.current.secondaryText, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + Spacer(modifier = Modifier.size(16.dp)) + ReloadButton( + onClick = { + SearchViewModel.ResetModel() + SearchViewModel.search() + } + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(users.itemCount) { idx -> + val userItem = users[idx] ?: return@items + UserItem(userItem) { + scope.launch { + if (userItem.isFollowing) { + model.unfollowUser(userItem.id) + } else { + model.followUser(userItem.id) + } + } + } + } + } + } + } +} + +@Composable +fun UserItem( + accountProfile: AccountProfileEntity, + onFollow: (AccountProfileEntity) -> Unit = {}, +) { + val context = LocalContext.current + val navController = LocalNavController.current + val AppColors = LocalAppTheme.current + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .noRippleClickable { + navController.navigate("AccountProfile/${accountProfile.id}") + }, + verticalAlignment = Alignment.CenterVertically + ) { + CustomAsyncImage( + context, + imageUrl = accountProfile.avatar, + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + contentDescription = null + ) + Spacer(modifier = Modifier.padding(8.dp)) + Row( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = accountProfile.nickName, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = AppColors.text + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = stringResource( + R.string.search_user_item_follower_count, + accountProfile.followerCount + ), fontSize = 14.sp, color = AppColors.secondaryText + ) + } + Spacer(modifier = Modifier.width(16.dp)) +// Box { +// if (accountProfile.id != AppState.UserId) { +// if (accountProfile.isFollowing) { +// ActionButton( +// text = stringResource(R.string.following_upper), +// backgroundColor = Color(0xFF9E9E9E), +// contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp), +// color = Color.White, +// fullWidth = false +// ) { +// onFollow(accountProfile) +// } +// } else { +// ActionButton( +// text = stringResource(R.string.follow_upper), +// backgroundColor = Color(0xffda3832), +// contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp), +// color = Color.White, +// fullWidth = false +// ) { +// onFollow(accountProfile) +// } +// } +// } +// } + } + } +} +@Composable +fun ReloadButton( + onClick: () -> Unit +) { + val gradientBrush = Brush.linearGradient( + colors = listOf( + Color(0xFF7c45ed), + Color(0xFF7c68ef), + Color(0xFF7bd8f8) + ) + ) + + Button( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 120.dp) + .height(48.dp), + shape = RoundedCornerShape(30.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.Transparent + ), + contentPadding = PaddingValues(0.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(gradientBrush), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.Reload), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/search/SearchViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/search/SearchViewModel.kt new file mode 100644 index 0000000..39fa695 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/search/SearchViewModel.kt @@ -0,0 +1,127 @@ +package com.aiosman.ravenow.ui.index.tabs.search + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.aiosman.ravenow.data.MomentService +import com.aiosman.ravenow.data.UserServiceImpl +import com.aiosman.ravenow.entity.AccountPagingSource +import com.aiosman.ravenow.entity.AccountProfileEntity +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.entity.MomentPagingSource +import com.aiosman.ravenow.entity.MomentRemoteDataSource +import com.aiosman.ravenow.entity.MomentServiceImpl +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +object SearchViewModel : ViewModel() { + var searchText by mutableStateOf("") + private val momentService: MomentService = MomentServiceImpl() + private val _momentsFlow = MutableStateFlow>(PagingData.empty()) + val momentsFlow = _momentsFlow.asStateFlow() + + private val userService = UserServiceImpl() + private val _usersFlow = MutableStateFlow>(PagingData.empty()) + val usersFlow = _usersFlow.asStateFlow() + var showResult by mutableStateOf(false) + var requestFocus by mutableStateOf(false) + fun search() { + if (searchText.isEmpty()) { + return + } + viewModelScope.launch { + Pager( + config = PagingConfig(pageSize = 5, enablePlaceholders = false), + pagingSourceFactory = { + MomentPagingSource( + MomentRemoteDataSource(momentService), + contentSearch = searchText + ) + } + ).flow.cachedIn(viewModelScope).collectLatest { + _momentsFlow.value = it + } + } + viewModelScope.launch { + Pager( + config = PagingConfig(pageSize = 5, enablePlaceholders = false), + pagingSourceFactory = { + AccountPagingSource( + userService, + nickname = searchText + ) + } + ).flow.cachedIn(viewModelScope).collectLatest { + _usersFlow.value = it + } + } + showResult = true + } + + suspend fun followUser(id:Int){ + userService.followUser(id.toString()) + val currentPagingData = _usersFlow.value + val updatedPagingData = currentPagingData.map { userItem -> + if (userItem.id == id) { + userItem.copy(isFollowing = true, followerCount = userItem.followerCount + 1) + } else { + userItem + } + } + _usersFlow.value = updatedPagingData + } + + suspend fun unfollowUser(id:Int){ + userService.unFollowUser(id.toString()) + val currentPagingData = _usersFlow.value + val updatedPagingData = currentPagingData.map { userItem -> + if (userItem.id == id) { + userItem.copy(isFollowing = false, followerCount = userItem.followerCount - 1) + } else { + userItem + } + } + _usersFlow.value = updatedPagingData + } + + fun ResetModel(){ + _momentsFlow.value = PagingData.empty() + _usersFlow.value = PagingData.empty() + showResult = false + } + + fun updateMomentFollowStatus(authorId:Int,isFollow:Boolean) { + val currentPagingData = _momentsFlow.value + val updatedPagingData = currentPagingData.map { momentItem -> + if (momentItem.authorId == authorId) { + momentItem.copy(followStatus = isFollow) + } else { + momentItem + } + } + _momentsFlow.value = updatedPagingData + } + fun momentFollowAction(moment: MomentEntity) { + viewModelScope.launch { + try { + if (moment.followStatus) { + userService.unFollowUser(moment.authorId.toString()) + } else { + userService.followUser(moment.authorId.toString()) + } + updateMomentFollowStatus(moment.authorId, !moment.followStatus) + } catch (e: Exception) { + e.printStackTrace() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/shorts/Pager.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/shorts/Pager.kt new file mode 100644 index 0000000..2beec2f --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/shorts/Pager.kt @@ -0,0 +1,231 @@ +package com.aiosman.ravenow.ui.index.tabs.shorts + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.unit.Density +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.roundToInt + +class PagerState( + currentPage: Int = 0, + minPage: Int = 0, + maxPage: Int = 0 +) { + private var _minPage by mutableStateOf(minPage) + var minPage: Int + get() = _minPage + set(value) { + _minPage = value.coerceAtMost(_maxPage) + _currentPage = _currentPage.coerceIn(_minPage, _maxPage) + } + + private var _maxPage by mutableStateOf(maxPage, structuralEqualityPolicy()) + var maxPage: Int + get() = _maxPage + set(value) { + _maxPage = value.coerceAtLeast(_minPage) + _currentPage = _currentPage.coerceIn(_minPage, maxPage) + } + + private var _currentPage by mutableStateOf(currentPage.coerceIn(minPage, maxPage)) + var currentPage: Int + get() = _currentPage + set(value) { + _currentPage = value.coerceIn(minPage, maxPage) + } + + enum class SelectionState { Selected, Undecided } + + var selectionState by mutableStateOf(SelectionState.Selected) + + suspend inline fun selectPage(block: PagerState.() -> R): R = try { + selectionState = SelectionState.Undecided + block() + } finally { + selectPage() + } + + suspend fun selectPage() { + currentPage -= currentPageOffset.roundToInt() + snapToOffset(0f) + selectionState = SelectionState.Selected + } + + private var _currentPageOffset = Animatable(0f).apply { + updateBounds(-1f, 1f) + } + val currentPageOffset: Float + get() = _currentPageOffset.value + + suspend fun snapToOffset(offset: Float) { + val max = if (currentPage == minPage) 0f else 1f + val min = if (currentPage == maxPage) 0f else -1f + _currentPageOffset.snapTo(offset.coerceIn(min, max)) + } + + suspend fun fling(velocity: Float) { + if (velocity < 0 && currentPage == maxPage) return + if (velocity > 0 && currentPage == minPage) return + + // 根据 fling 的方向滑动到下一页或上一页 + _currentPageOffset.animateTo(velocity) + selectPage() + } + + override fun toString(): String = "PagerState{minPage=$minPage, maxPage=$maxPage, " + + "currentPage=$currentPage, currentPageOffset=$currentPageOffset}" +} + +@Immutable +private data class PageData(val page: Int) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any? = this@PageData +} + +private val Measurable.page: Int + get() = (parentData as? PageData)?.page ?: error("no PageData for measurable $this") + +@Composable +fun Pager( + modifier: Modifier = Modifier, + state: PagerState, + orientation: Orientation = Orientation.Horizontal, + offscreenLimit: Int = 2, + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, // 新增水平对齐参数 + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, // 新增垂直对齐参数 + content: @Composable PagerScope.() -> Unit +) { + var pageSize by remember { mutableStateOf(0) } + val coroutineScope = rememberCoroutineScope() + + Layout( + content = { + // 根据 offscreenLimit 计算页面范围 + val minPage = maxOf(state.currentPage - offscreenLimit, state.minPage) + val maxPage = minOf(state.currentPage + offscreenLimit, state.maxPage) + + for (page in minPage..maxPage) { + val pageData = PageData(page) + val scope = PagerScope(state, page) + key(pageData) { + Column(modifier = pageData) { + scope.content() + } + } + } + }, + modifier = modifier.draggable( + orientation = orientation, + onDragStarted = { + state.selectionState = PagerState.SelectionState.Undecided + }, + onDragStopped = { velocity -> + coroutineScope.launch { + // 根据速度判断是否滑动到下一页 + val threshold = 1000f // 速度阈值,可调整 + if (velocity > threshold) { + state.fling(1f) // 向右滑动 + } else if (velocity < -threshold) { + state.fling(-1f) // 向左滑动 + } else { + state.fling(0f) // 保持当前页 + } + } + }, + state = rememberDraggableState { dy -> + coroutineScope.launch { + with(state) { + val pos = pageSize * currentPageOffset + val max = if (currentPage == minPage) 0 else pageSize + val min = if (currentPage == maxPage) 0 else -pageSize + + // 直接将手指的位移应用到 currentPageOffset + val newPos = (pos + dy).coerceIn(min.toFloat(), max.toFloat()) + snapToOffset(newPos / pageSize) + } + } + }, + ) + ) { measurables, constraints -> + layout(constraints.maxWidth, constraints.maxHeight) { + val currentPage = state.currentPage + val offset = state.currentPageOffset + val childConstraints = constraints.copy(minWidth = 0, minHeight = 0) + + measurables.forEach { measurable -> + val placeable = measurable.measure(childConstraints) + val page = measurable.page + + // 根据对齐参数计算 x 和 y 位置 + val xPosition = when (horizontalAlignment) { + Alignment.Start -> 0 + Alignment.CenterHorizontally -> (constraints.maxWidth - placeable.width) / 2 + Alignment.End -> constraints.maxWidth - placeable.width + else -> 0 + } + + val yPosition = when (verticalAlignment) { + Alignment.Top -> 0 + Alignment.CenterVertically -> (constraints.maxHeight - placeable.height) / 2 + Alignment.Bottom -> constraints.maxHeight - placeable.height + else -> 0 + } + + if (currentPage == page) { // 只在当前页面设置 pageSize,避免不必要的设置 + pageSize = if (orientation == Orientation.Horizontal) { + placeable.width + } else { + placeable.height + } + } + + val isVisible = abs(page - (currentPage - offset)) <= 1 + + if (isVisible) { + // 修正 x 的计算 + val xOffset = if (orientation == Orientation.Horizontal) { + ((page - currentPage) * pageSize + offset * pageSize).roundToInt() + } else { + 0 + } + + // 使用 placeRelative 进行放置 + placeable.placeRelative( + x = xPosition + xOffset, + y = yPosition + if (orientation == Orientation.Vertical) ((page - (currentPage - offset)) * placeable.height).roundToInt() else 0 + ) + } + } + + } + } +} + +class PagerScope( + private val state: PagerState, + val page: Int +) { + val currentPage: Int + get() = state.currentPage + + val currentPageOffset: Float + get() = state.currentPageOffset + + val selectionState: PagerState.SelectionState + get() = state.selectionState +} + + + + + + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/shorts/ShortVideo.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/shorts/ShortVideo.kt new file mode 100644 index 0000000..8d2152b --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/shorts/ShortVideo.kt @@ -0,0 +1,69 @@ +package com.aiosman.ravenow.ui.index.tabs.shorts + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import com.aiosman.ravenow.ui.theme.RaveNowTheme + +val videoUrls = listOf( + "https://api.rider-pro.com/test/shorts/1.mp4", + "https://api.rider-pro.com/test/shorts/2.mp4", + "https://api.rider-pro.com/test/shorts/3.mp4", + "https://api.rider-pro.com/test/shorts/4.mp4", + "https://api.rider-pro.com/test/shorts/5.webm", + "https://api.rider-pro.com/test/shorts/6.webm", + "https://api.rider-pro.com/test/shorts/7.webm", + "https://api.rider-pro.com/test/shorts/8.webm", + "https://api.rider-pro.com/test/shorts/9.webm", + "https://api.rider-pro.com/test/shorts/10.webm", + "https://api.rider-pro.com/test/shorts/11.webm", + "https://api.rider-pro.com/test/shorts/12.webm", + "https://api.rider-pro.com/test/shorts/13.webm", + "https://api.rider-pro.com/test/shorts/14.webm", + "https://api.rider-pro.com/test/shorts/15.webm", + "https://api.rider-pro.com/test/shorts/16.webm", + "https://api.rider-pro.com/test/shorts/17.webm", + "https://api.rider-pro.com/test/shorts/18.webm", + "https://api.rider-pro.com/test/shorts/19.webm", + "https://api.rider-pro.com/test/shorts/20.webm", + "https://api.rider-pro.com/test/shorts/21.webm", + "https://api.rider-pro.com/test/shorts/22.webm", + "https://api.rider-pro.com/test/shorts/23.webm", + "https://api.rider-pro.com/test/shorts/24.webm", + "https://api.rider-pro.com/test/shorts/25.webm", + "https://api.rider-pro.com/test/shorts/26.webm", + "https://api.rider-pro.com/test/shorts/27.webm", + "https://api.rider-pro.com/test/shorts/28.webm", + "https://api.rider-pro.com/test/shorts/29.webm", + "https://api.rider-pro.com/test/shorts/30.webm", + "https://api.rider-pro.com/test/shorts/31.webm", + "https://api.rider-pro.com/test/shorts/32.webm", + "https://api.rider-pro.com/test/shorts/33.webm", + "https://api.rider-pro.com/test/shorts/34.webm", + "https://api.rider-pro.com/test/shorts/35.webm", + "https://api.rider-pro.com/test/shorts/36.webm", + "https://api.rider-pro.com/test/shorts/37.webm", + "https://api.rider-pro.com/test/shorts/38.webm", + "https://api.rider-pro.com/test/shorts/39.webm", + "https://api.rider-pro.com/test/shorts/40.webm", + "https://api.rider-pro.com/test/shorts/41.webm", + + + + + + +) + +@Composable +fun ShortVideo() { + RaveNowTheme { + Surface(color = MaterialTheme.colorScheme.background) { + ShortViewCompose( + videoItemsUrl = videoUrls, + clickItemPosition = 0 + ) + } + } +} + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/shorts/ShortViewCompose.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/shorts/ShortViewCompose.kt new file mode 100644 index 0000000..0915651 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/shorts/ShortViewCompose.kt @@ -0,0 +1,392 @@ +@file:kotlin.OptIn(ExperimentalMaterial3Api::class) + +package com.aiosman.ravenow.ui.index.tabs.shorts + +import android.net.Uri +import android.view.Gravity +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.annotation.DrawableRes +import androidx.annotation.OptIn +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSourceFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.comment.CommentModalContent +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun ShortViewCompose( + videoItemsUrl: List, + clickItemPosition: Int = 0, + videoHeader: @Composable () -> Unit = {}, + videoBottom: @Composable () -> Unit = {} +) { + val pagerState: PagerState = run { + remember { + PagerState(clickItemPosition, 0, videoItemsUrl.size - 1) + } + } + val initialLayout = remember { + mutableStateOf(true) + } + val pauseIconVisibleState = remember { + mutableStateOf(false) + } + Pager( + state = pagerState, + orientation = Orientation.Vertical, + offscreenLimit = 1 + ) { + pauseIconVisibleState.value = false + SingleVideoItemContent( + videoItemsUrl[page], + pagerState, + page, + initialLayout, + pauseIconVisibleState, + videoHeader, + videoBottom + ) + } + + LaunchedEffect(clickItemPosition) { + delay(300) + initialLayout.value = false + } + +} + +@Composable +private fun SingleVideoItemContent( + videoUrl: String, + pagerState: PagerState, + pager: Int, + initialLayout: MutableState, + pauseIconVisibleState: MutableState, + VideoHeader: @Composable() () -> Unit, + VideoBottom: @Composable() () -> Unit, +) { + Box(modifier = Modifier.fillMaxSize()) { + VideoPlayer(videoUrl, pagerState, pager, pauseIconVisibleState) + VideoHeader.invoke() + Box(modifier = Modifier.align(Alignment.BottomStart)) { + VideoBottom.invoke() + } + if (initialLayout.value) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = Color.Black) + ) + } + } +} + +@OptIn(UnstableApi::class) +@Composable +fun VideoPlayer( + videoUrl: String, + pagerState: PagerState, + pager: Int, + pauseIconVisibleState: MutableState, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val lifecycleOwner = LocalLifecycleOwner.current + var showCommentModal by remember { mutableStateOf(false) } + var sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + val exoPlayer = remember { + ExoPlayer.Builder(context) + .build() + .apply { + val dataSourceFactory: DataSource.Factory = DefaultDataSourceFactory( + context, + Util.getUserAgent(context, context.packageName) + ) + val source = ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource(MediaItem.fromUri(Uri.parse(videoUrl))) + + this.prepare(source) + } + } + if (pager == pagerState.currentPage) { + exoPlayer.playWhenReady = true + exoPlayer.play() + } else { + exoPlayer.pause() + } + exoPlayer.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT + exoPlayer.repeatMode = Player.REPEAT_MODE_ONE + // player box + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + var playerView by remember { mutableStateOf(null) } // Store reference to PlayerView + + AndroidView( + factory = { context -> + // 创建一个 FrameLayout 作为容器 + FrameLayout(context).apply { + // 设置背景颜色为黑色,用于显示黑边 + setBackgroundColor(Color.Black.toArgb()) + + // 创建 PlayerView 并添加到 FrameLayout 中 + val view = PlayerView(context).apply { + hideController() + useController = false + player = exoPlayer + resizeMode = + AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT // 或 RESIZE_MODE_ZOOM + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + Gravity.CENTER + ) + } + addView(view) + } + }, + modifier = Modifier.noRippleClickable { + pauseIconVisibleState.value = true + exoPlayer.pause() + scope.launch { + delay(100) + if (exoPlayer.isPlaying) { + exoPlayer.pause() + } else { + pauseIconVisibleState.value = false + exoPlayer.play() + } + } + } + ) + + if (pauseIconVisibleState.value) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .size(80.dp) + ) + } + + // Release ExoPlayer when the videoUrl changes or the composable leaves composition + DisposableEffect(videoUrl) { + onDispose { + exoPlayer.release() + playerView?.player = null + playerView = null // Release the reference to the PlayerView + } + } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> { + exoPlayer.pause() // 应用进入后台时暂停 + } + + Lifecycle.Event.ON_RESUME -> { + if (pager == pagerState.currentPage) { + exoPlayer.play() // 返回前台且为当前页面时恢复播放 + } + } + + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + + } + // action buttons + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomEnd + ) { + Column( + modifier = Modifier.padding(bottom = 72.dp, end = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + UserAvatar() + VideoBtn(icon = R.drawable.rider_pro_video_like, text = "975.9k") + VideoBtn(icon = R.drawable.rider_pro_video_comment, text = "1896") { + showCommentModal = true + } + VideoBtn(icon = R.drawable.rider_pro_video_favor, text = "234") + VideoBtn(icon = R.drawable.rider_pro_video_share, text = "677k") + } + } + // info + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomStart + ) { + Column(modifier = Modifier.padding(start = 16.dp, bottom = 16.dp)) { + Row( + modifier = Modifier + .padding(bottom = 8.dp) + .background(color = Color.Gray), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + Image( + modifier = Modifier + .size(20.dp) + .padding(start = 4.dp, end = 6.dp), + painter = painterResource(id = R.drawable.rider_pro_video_location), + contentDescription = "" + ) + Text( + modifier = Modifier.padding(end = 4.dp), + text = "USA", + fontSize = 12.sp, + color = Color.White, + style = TextStyle(fontWeight = FontWeight.Bold) + ) + } + Text( + text = "@Kevinlinpr", + fontSize = 16.sp, + color = Color.White, + style = TextStyle(fontWeight = FontWeight.Bold) + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), // 确保Text占用可用宽度 + text = "Pedro Acosta to join KTM in 2025 on a multi-year deal! \uD83D\uDFE0", + fontSize = 16.sp, + color = Color.White, + style = TextStyle(fontWeight = FontWeight.Bold), + overflow = TextOverflow.Ellipsis, // 超出范围时显示省略号 + maxLines = 2 // 最多显示两行 + ) + } + } + + if (showCommentModal) { + ModalBottomSheet( + onDismissRequest = { showCommentModal = false }, + containerColor = Color.White, + sheetState = sheetState + ) { + CommentModalContent() { + + } + } + } +} + +@Composable +fun UserAvatar() { + Image( + modifier = Modifier + .padding(bottom = 16.dp) + .size(40.dp) + .border(width = 3.dp, color = Color.White, shape = RoundedCornerShape(40.dp)) + .clip( + RoundedCornerShape(40.dp) + ), painter = painterResource(id = R.drawable.default_avatar), contentDescription = "" + ) +} + +@Composable +fun VideoBtn(@DrawableRes icon: Int, text: String, onClick: (() -> Unit)? = null) { + Column( + modifier = Modifier + .padding(bottom = 16.dp) + .clickable { + onClick?.invoke() + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + modifier = Modifier.size(36.dp), + painter = painterResource(id = icon), + contentDescription = "" + ) + Text( + text = text, + fontSize = 11.sp, + color = Color.White, + style = TextStyle(fontWeight = FontWeight.Bold) + ) + } +} + + + + + + + + + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/street/Street.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/street/Street.kt new file mode 100644 index 0000000..a1d6c68 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/street/Street.kt @@ -0,0 +1,229 @@ +package com.aiosman.ravenow.ui.index.tabs.street + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +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.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.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.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavOptions +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.test.countries +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapProperties +import com.google.maps.android.compose.MapUiSettings +import com.google.maps.android.compose.MarkerComposable +import com.google.maps.android.compose.MarkerState +import com.google.maps.android.compose.rememberCameraPositionState + +@Composable +fun StreetPage() { + val navController = LocalNavController.current + var currentLocation by remember { mutableStateOf(null) } + val navigationBarHeight = with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(currentLocation ?: LatLng(0.0, 0.0), 10f) + } + var hasLocationPermission by remember { mutableStateOf(false) } + var searchText by remember { mutableStateOf("") } + + + LaunchedEffect(Unit) { + + } + LaunchedEffect(currentLocation) { + cameraPositionState.position = + CameraPosition.fromLatLngZoom( + currentLocation ?: LatLng( + countries[0].lat, + countries[0].lng + ), 5f + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 56.dp + navigationBarHeight) + ) { + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + properties = MapProperties( + isMyLocationEnabled = hasLocationPermission, + ), + uiSettings = MapUiSettings( + compassEnabled = true, + myLocationButtonEnabled = false, + zoomControlsEnabled = false + ) + ) { + // pins + countries.forEach { position -> + MarkerComposable( + state = MarkerState(position = LatLng(position.lat, position.lng)), + onClick = { + val screenLocation = + cameraPositionState.projection?.toScreenLocation(it.position) + val x = screenLocation?.x ?: 0 + val y = screenLocation?.y ?: 0 + + navController.navigate("LocationDetail/${x}/${y}",NavOptions.Builder() + .setEnterAnim(0) + .setExitAnim(0) + .setPopEnterAnim(0) + .setPopExitAnim(0) + .build()) + true + }, + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_map_mark), + contentDescription = "", + ) + } + } + + + } + Image( + painter = painterResource(id = R.drawable.rider_pro_my_location), + contentDescription = "", + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 16.dp, bottom = 16.dp + navigationBarHeight) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + currentLocation?.let { + cameraPositionState.position = + CameraPosition.fromLatLngZoom(it, cameraPositionState.position.zoom) + } + } + ) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 16.dp + navigationBarHeight) + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(color = Color(0xffda3832)) + + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_new_post_add_pic), + contentDescription = "", + modifier = Modifier + .align(Alignment.Center) + .size(36.dp), + colorFilter = ColorFilter.tint(Color.White) + + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .padding(top = 64.dp, start = 16.dp, end = 16.dp) + ) { + Box( + modifier = Modifier + .background(Color.White) + .padding(16.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xfff7f7f7)) + .padding(vertical = 8.dp, horizontal = 16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_search_location), + contentDescription = "", + tint = Color(0xffc6c6c6), + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + ) { + if (searchText.isEmpty()) { + Text( + text = "Please enter a search location", + color = Color(0xffc6c6c6), + fontSize = 16.sp, + modifier = Modifier.padding(start = 8.dp) + ) + } + + BasicTextField( + value = searchText, + onValueChange = { + searchText = it + }, + modifier = Modifier + .fillMaxWidth(), + textStyle = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + ) + } + + } + + } + } + } + + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/like/LikeNoticeViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/like/LikeNoticeViewModel.kt new file mode 100644 index 0000000..9ddd6ab --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/like/LikeNoticeViewModel.kt @@ -0,0 +1,58 @@ +package com.aiosman.ravenow.ui.like + +import android.icu.util.Calendar +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.aiosman.ravenow.entity.AccountLikeEntity +import com.aiosman.ravenow.data.AccountService +import com.aiosman.ravenow.entity.LikeItemPagingSource +import com.aiosman.ravenow.data.AccountServiceImpl +import com.aiosman.ravenow.data.api.ApiClient +import com.aiosman.ravenow.data.api.UpdateNoticeRequestBody +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + + +object LikeNoticeViewModel : ViewModel() { + private val accountService: AccountService = AccountServiceImpl() + private val _likeItemsFlow = MutableStateFlow>(PagingData.empty()) + val likeItemsFlow = _likeItemsFlow.asStateFlow() + var isFirstLoad = true + fun reload(force: Boolean = false) { + if (!isFirstLoad && !force) { + return + } + isFirstLoad = false + viewModelScope.launch { + Pager( + config = PagingConfig(pageSize = 5, enablePlaceholders = false), + pagingSourceFactory = { + LikeItemPagingSource( + accountService + ) + } + ).flow.cachedIn(viewModelScope).collectLatest { + _likeItemsFlow.value = it + } + } + } + + suspend fun updateNotice() { + var now = Calendar.getInstance().time + accountService.updateNotice( + UpdateNoticeRequestBody( + lastLookLikeTime = ApiClient.formatTime(now) + ) + ) + } + + fun ResetModel() { + isFirstLoad = true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/like/LikePage.kt b/app/src/main/java/com/aiosman/ravenow/ui/like/LikePage.kt new file mode 100644 index 0000000..31177ce --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/like/LikePage.kt @@ -0,0 +1,375 @@ +package com.aiosman.ravenow.ui.like + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.entity.AccountLikeEntity +import com.aiosman.ravenow.exp.timeAgo +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.navigateToPost +import java.util.Date +import com.aiosman.ravenow.utils.NetworkUtils +import com.aiosman.ravenow.ui.network.ReloadButton +@Preview +@Composable +fun LikeNoticeScreen() { + val model = LikeNoticeViewModel + val listState = rememberLazyListState() + var dataFlow = model.likeItemsFlow + var likes = dataFlow.collectAsLazyPagingItems() + val AppColors = LocalAppTheme.current + + LaunchedEffect(Unit) { + model.reload() + model.updateNotice() + } + + StatusBarMaskLayout( + darkIcons = !AppState.darkMode, + maskBoxBackgroundColor = AppColors.background + ) { + Column( + modifier = Modifier + .weight(1f) + .background(color = AppColors.background) + .padding(horizontal = 16.dp) + ) { + + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current) + + if (!isNetworkAvailable) { + Box( + modifier = Modifier.fillMaxSize() + .padding(top=149.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.mipmap.invalid_name_10), + contentDescription = "network error", + modifier = Modifier.size(181.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_title), + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600, + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.friend_chat_no_network_subtitle), + color = AppColors.text, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + Spacer(modifier = Modifier.height(16.dp)) + ReloadButton( + onClick = { + LikeNoticeViewModel.reload(force = true) + } + ) + } + } + } else if (likes.itemCount == 0) { + Box( + modifier = Modifier.fillMaxSize() + .padding(top=149.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource( + id =if(AppState.darkMode) R.mipmap.qst_z_qs_as_img + else R.mipmap.invalid_name_6), + contentDescription = "No Notice", + modifier = Modifier.size(181.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "你的赞在路上~", + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600, + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = "智能体和朋友看到后,说不定马上就来~", + color = AppColors.text, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + } + } + }else{ + LazyColumn( + modifier = Modifier.weight(1f), + state = listState, + ) { + items(likes.itemCount) { + val likeItem = likes[it] + if (likeItem != null) { + likeItem.post?.let { post -> + ActionPostNoticeItem( + avatar = likeItem.user.avatar, + nickName = likeItem.user.nickName, + likeTime = likeItem.likeTime, + thumbnail = post.images[0].thumbnail, + action = "like", + userId = likeItem.user.id, + postId = post.id + ) + } + likeItem.comment?.let { comment -> + LikeCommentNoticeItem(likeItem) + } + } + } + item { + BottomNavigationPlaceholder() + } + } + } + + } + } +} + +@Composable +fun ActionPostNoticeItem( + avatar: String, + nickName: String, + likeTime: Date, + thumbnail: String, + action: String, + userId: Int, + postId: Int +) { + val context = LocalContext.current + val navController = LocalNavController.current + val AppColors = LocalAppTheme.current + + Box( + modifier = Modifier.padding(vertical = 16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + CustomAsyncImage( + context, + imageUrl = avatar, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .noRippleClickable { + navController.navigate( + NavigationRoute.AccountProfile.route.replace( + "{id}", + userId.toString() + ) + ) + }, + contentDescription = action, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier + .weight(1f) + .noRippleClickable { + navController.navigateToPost( + id = postId, + highlightCommentId = 0, + initImagePagerIndex = 0 + ) + } + ) { + Text(nickName, fontWeight = FontWeight.Bold, fontSize = 16.sp, color = AppColors.text) + Spacer(modifier = Modifier.height(2.dp)) + when (action) { + "like" -> Text(stringResource(R.string.like_your_post), color = AppColors.text) + "favourite" -> Text(stringResource(R.string.favourite_your_post), color = AppColors.text) + } + Spacer(modifier = Modifier.height(2.dp)) + Row { + Text(likeTime.timeAgo(context), fontSize = 12.sp, color = AppColors.secondaryText) + } + } + CustomAsyncImage( + context, + imageUrl = thumbnail, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(8.dp)), + contentDescription = action, + ) + } + } +} + +@Composable +fun LikeCommentNoticeItem( + item: AccountLikeEntity +) { + val navController = LocalNavController.current + val context = LocalContext.current + val AppColors = LocalAppTheme.current + + Box( + modifier = Modifier + .padding(vertical = 16.dp) + .noRippleClickable { + item.comment?.postId.let { + navController.navigateToPost( + id = it ?: 0, + highlightCommentId = item.comment?.id ?: 0, + initImagePagerIndex = 0 + ) + } + } + ) { + Row { + Column( + modifier = Modifier.weight(1f) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + CustomAsyncImage( + imageUrl = item.user.avatar, + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + contentDescription = stringResource(R.string.like_your_comment) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier + .weight(1f) + ) { + Text(item.user.nickName, fontWeight = FontWeight.Bold, fontSize = 16.sp, color = AppColors.text) + Spacer(modifier = Modifier.height(2.dp)) + Text(stringResource(R.string.like_your_comment), color = AppColors.text) + Spacer(modifier = Modifier.height(2.dp)) + Row { + Text( + item.likeTime.timeAgo(context), + fontSize = 12.sp, + color = AppColors.secondaryText + ) + } + } + } + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.padding(start = 48.dp) + ) { + Box( + modifier = Modifier + .size(24.dp) + .background(Color.Gray.copy(alpha = 0.1f)) + ) { + CustomAsyncImage( + context = context, + imageUrl = AppState.profile?.avatar ?: "", + contentDescription = "Comment Profile Picture", + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(24.dp)), + contentScale = ContentScale.Crop + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = AppState.profile?.nickName ?: "", + fontWeight = FontWeight.W600, + fontSize = 14.sp, + color = AppColors.text + ) + Text( + text = item.comment?.content ?: "", + fontSize = 12.sp, + color = AppColors.secondaryText, + maxLines = 2 + ) + } + } + } + Spacer(modifier = Modifier.width(16.dp)) + if (item.comment?.replyComment?.post != null) { + item.comment.replyComment.post.let { + CustomAsyncImage( + context = context, + imageUrl = it.images[0].thumbnail, + contentDescription = "Post Thumbnail", + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + } + } else { + item.comment?.post?.let { + CustomAsyncImage( + context = context, + imageUrl = it.images[0].thumbnail, + contentDescription = "Post Thumbnail", + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + } + } + + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/location/LocationDetailScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/location/LocationDetailScreen.kt new file mode 100644 index 0000000..fcafef7 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/location/LocationDetailScreen.kt @@ -0,0 +1,456 @@ +package com.aiosman.ravenow.ui.location + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberBottomSheetScaffoldState +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.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R + +data class OfficialGalleryItem( + val id: Int, + val resId: Int, +) + +fun getOfficialGalleryItems(): List { + return listOf( + OfficialGalleryItem(1, R.drawable.default_moment_img), + OfficialGalleryItem(2, R.drawable.default_moment_img), + OfficialGalleryItem(3, R.drawable.default_moment_img), + OfficialGalleryItem(4, R.drawable.default_moment_img), + OfficialGalleryItem(5, R.drawable.default_moment_img), + OfficialGalleryItem(6, R.drawable.default_moment_img), + OfficialGalleryItem(7, R.drawable.default_moment_img), + OfficialGalleryItem(8, R.drawable.default_moment_img), + OfficialGalleryItem(9, R.drawable.default_moment_img), + OfficialGalleryItem(10, R.drawable.default_moment_img), + OfficialGalleryItem(11, R.drawable.default_moment_img), + OfficialGalleryItem(12, R.drawable.default_moment_img), + OfficialGalleryItem(13, R.drawable.default_moment_img), + OfficialGalleryItem(14, R.drawable.default_moment_img), + OfficialGalleryItem(15, R.drawable.default_moment_img), + OfficialGalleryItem(16, R.drawable.default_moment_img), + OfficialGalleryItem(17, R.drawable.default_moment_img), + OfficialGalleryItem(18, R.drawable.default_moment_img), + OfficialGalleryItem(19, R.drawable.default_moment_img), + OfficialGalleryItem(20, R.drawable.default_moment_img), + ) +} + +data class FeedItem( + val id: Int, + val resId: Int, +) + +fun getFeedItems(): List { + val image_pickups = listOf( + R.drawable.default_moment_img, + R.drawable.default_avatar, + R.drawable.rider_pro_moment_demo_1, + R.drawable.rider_pro_moment_demo_2, + R.drawable.rider_pro_moment_demo_3, + ) + return (1..100).map { + FeedItem(it, image_pickups.random()) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun LocationDetailScreen(x: Float, y: Float) { + val scope = rememberCoroutineScope() + val scaffoldState = rememberBottomSheetScaffoldState( + SheetState( + skipPartiallyExpanded = false, + density = LocalDensity.current, initialValue = SheetValue.PartiallyExpanded, + skipHiddenState = true + ) + ) + val configuration = LocalConfiguration.current + val officialGalleryItems = getOfficialGalleryItems() + val feedItems = getFeedItems() + val navController = LocalNavController.current + + // 2/3 height of the screen + fun getPeekHeight(): Dp { + val screenHeight = configuration.screenHeightDp + val peekHeight = (screenHeight * 2 / 3).dp + return peekHeight + } + + fun getNoPeekHeight(): Dp { + val screenHeight = configuration.screenHeightDp + val peekHeight = (screenHeight * 1 / 3).dp + return peekHeight + } + val view = LocalView.current + +// LaunchedEffect(key1 = Unit) { +// val locationOnScreen = IntArray(2).apply { +// view.getLocationOnScreen(this) +// } +// val startX = x - locationOnScreen[0] +// val startY = y - locationOnScreen[1] +// val radius = hypot(view.width.toDouble(), view.height.toDouble()).toFloat() +// +// val anim = ViewAnimationUtils.createCircularReveal(view, startX.toInt(), startY.toInt(), 0f, radius).apply { +// duration = 600 +// start() +// } +// +// } + + + + + val staggeredGridState = rememberLazyStaggeredGridState() + val coroutineScope = rememberCoroutineScope() + var showGalleryAndInfo by remember { mutableStateOf(true) } + LaunchedEffect(staggeredGridState) { + snapshotFlow { staggeredGridState.firstVisibleItemIndex } + .collect { index -> + // Assuming index 0 corresponds to the top of the feed + showGalleryAndInfo = index == 0 + } + + } + Box( + modifier = Modifier.fillMaxSize().background(Color.Transparent) + ) { + Image( + painter = painterResource(id = R.drawable.default_moment_img), + contentDescription = "Location Image", + modifier = Modifier + .fillMaxWidth() + .height(getNoPeekHeight() + 100.dp), + contentScale = ContentScale.Crop + ) + val bottomSheetScaffoldState = rememberBottomSheetScaffoldState() + BottomSheetScaffold( + scaffoldState = scaffoldState, + sheetPeekHeight = getPeekHeight(), + sheetShadowElevation = 0.dp, + sheetContainerColor = Color.Transparent, + sheetShape = RoundedCornerShape(16.dp, 16.dp, 0.dp, 0.dp), + sheetDragHandle = null, + sheetContent = { + Column( + Modifier + .fillMaxWidth() + .background(color = Color.White) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(12.dp)) + // 自定义短线 + Box( + modifier = Modifier + .width(32.dp) // 修改宽度 + .height(4.dp) // 修改高度 + .background( + Color(0f, 0f, 0f, 0.4f), + RoundedCornerShape(3.dp) + ) // 修改颜色和圆角 + .padding(top = 12.dp) // 调整位置 + .align(Alignment.CenterHorizontally) + + ) + + } + Spacer(modifier = Modifier.height(16.dp)) + + GalleryAndInfo(showGalleryAndInfo) + // feed + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp) + .animateContentSize() + ) { + AnimatedVisibility(visible = !showGalleryAndInfo) { + Row { + Icon( + Icons.Filled.LocationOn, + contentDescription = "Location", + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("在云龟山景区的", fontSize = 16.sp) + } + } + Text( + "车友动态", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + } + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(2), // Set to 2 columns + modifier = Modifier.fillMaxSize(), + state = staggeredGridState, + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + flingBehavior = ScrollableDefaults.flingBehavior(), + + ) { + items(feedItems) { item -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + Image( + painter = painterResource(id = item.resId), + contentDescription = "Feed", + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .clickable { + navController.navigate("Post") + }, + contentScale = ContentScale.FillWidth + ) + Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + Box( + modifier = Modifier.fillMaxWidth() + ) { + Text("Some text") + + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.default_avatar), + contentDescription = "Avatar", + modifier = Modifier + .size(18.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Username", + color = Color(0xFF666666), + fontSize = 14.sp + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + Icons.Filled.Favorite, + contentDescription = "Location" + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("100K", fontSize = 14.sp) + } + } + + } + + } + + } + + + } + + }, + ) { + + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun GalleryAndInfo(showGalleryAndInfo: Boolean) { + val navController = LocalNavController.current + Column(modifier = Modifier.animateContentSize()) { + AnimatedVisibility(visible = showGalleryAndInfo) { + Column { + // info panel + Column( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text( + "Location Name", + modifier = Modifier.padding(top = 24.dp), + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + repeat(10) { + Box( + modifier = Modifier + .background(Color(0xFFF5F5F5)) + .padding(horizontal = 7.dp, vertical = 2.dp) + + + ) { + Text("Tag $it", color = Color(0xFFb2b2b2), fontSize = 12.sp) + } + } + } + HorizontalDivider( + modifier = Modifier.padding(top = 16.dp), + color = Color(0xFFF5F5F5) + ) + Row( + modifier = Modifier.padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + "Location name", + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text("距离46KM,骑行时间77分钟", fontSize = 12.sp) + } + Spacer(modifier = Modifier.weight(1f)) + Image( + painter = painterResource(id = R.drawable.rider_pro_location_map), + contentDescription = "" + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .background(Color(0xFFF5F5F5)) + ) + // official gallery + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 18.dp) + ) { + Row { + Text("官方摄影师作品", fontSize = 15.sp, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.weight(1f)) + Image( + painter = painterResource(id = R.drawable.rider_pro_nav_next), + contentDescription = "Next", + modifier = Modifier + .size(24.dp) + .clickable { + navController.navigate("OfficialPhoto") + } + ) + } + + Spacer(modifier = Modifier.height(17.dp)) + Row( + modifier = Modifier.height(232.dp) + ) { + Box( + modifier = Modifier.weight(1f) + ) { + Image( + painter = painterResource(id = R.drawable.default_avatar), + contentDescription = "Avatar", + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(16.dp)), + contentScale = ContentScale.Crop, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Box( + modifier = Modifier.weight(1f) + ) { + Image( + painter = painterResource(id = R.drawable.default_avatar), + contentDescription = "Avatar", + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(16.dp)), + contentScale = ContentScale.Crop + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .background(Color(0xFFF5F5F5)) + ) + } + } + } +} + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/login/emailsignup.kt b/app/src/main/java/com/aiosman/ravenow/ui/login/emailsignup.kt new file mode 100644 index 0000000..e0c6450 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/login/emailsignup.kt @@ -0,0 +1,277 @@ +package com.aiosman.ravenow.ui.login + +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.AppStore +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.data.api.ErrorCode +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.data.AccountService +import com.aiosman.ravenow.data.ServiceException +import com.aiosman.ravenow.data.AccountServiceImpl +import com.aiosman.ravenow.data.api.getErrorMessageCode +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.CheckboxWithLabel +import com.aiosman.ravenow.ui.composables.PolicyCheckbox +import com.aiosman.ravenow.ui.composables.StatusBarSpacer +import com.aiosman.ravenow.ui.composables.TextInputField +import com.aiosman.ravenow.utils.PasswordValidator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun EmailSignupScreen() { + var appColor = LocalAppTheme.current + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + var rememberMe by remember { mutableStateOf(false) } + var acceptTerms by remember { mutableStateOf(false) } + var acceptPromotions by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val navController = LocalNavController.current + val context = LocalContext.current + val accountService: AccountService = AccountServiceImpl() + var emailError by remember { mutableStateOf(null) } + var passwordError by remember { mutableStateOf(null) } + var confirmPasswordError by remember { mutableStateOf(null) } + var termsError by remember { mutableStateOf(false) } + var promotionsError by remember { mutableStateOf(false) } + fun validateForm(): Boolean { + emailError = when { + // 非空 + email.isEmpty() -> context.getString(R.string.text_error_email_required) + // 邮箱格式 + !android.util.Patterns.EMAIL_ADDRESS.matcher(email) + .matches() -> context.getString(R.string.text_error_email_format) + + else -> null + } + // 使用通用密码校验器 + val passwordValidation = PasswordValidator.validatePassword(password, context) + passwordError = if (!passwordValidation.isValid) passwordValidation.errorMessage else null + + // 使用通用密码确认校验器 + val confirmPasswordValidation = PasswordValidator.validatePasswordConfirmation(password, confirmPassword, context) + confirmPasswordError = if (!confirmPasswordValidation.isValid) confirmPasswordValidation.errorMessage else null + if (!acceptTerms) { + scope.launch(Dispatchers.Main) { + Toast.makeText( + context, + context.getString(R.string.error_not_accept_term), + Toast.LENGTH_SHORT + ).show() + } + termsError = true + return false + } else { + termsError = false + } + if (!acceptPromotions) { + scope.launch(Dispatchers.Main) { + Toast.makeText( + context, + context.getString(R.string.error_not_accept_recive_notice), + Toast.LENGTH_SHORT + ).show() + } + promotionsError = true + return false + } else { + promotionsError = false + } + + return emailError == null && passwordError == null && confirmPasswordError == null + } + + suspend fun registerUser() { + if (!validateForm()) return + // 注册 + try { + accountService.registerUserWithPassword(email, password) + } catch (e: ServiceException) { + scope.launch(Dispatchers.Main) { + if (e.code == ErrorCode.USER_EXIST.code) { + emailError = context.getString(R.string.error_10001_user_exist) + return@launch + } + Toast.makeText(context, context.getErrorMessageCode(e.code), Toast.LENGTH_SHORT) + .show() + } + return + } catch (e: Exception) { + scope.launch(Dispatchers.Main) { + Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() + } + return + } + // 获取 token + val authResp = accountService.loginUserWithPassword(email, password) + if (authResp.token != null) { + scope.launch(Dispatchers.Main) { + Toast.makeText(context, "Successfully registered", Toast.LENGTH_SHORT).show() + } + } + AppStore.apply { + token = authResp.token + this.rememberMe = rememberMe + isGuest = false // 清除游客状态 + saveData() + } + // 获取token 信息 + try { + AppState.initWithAccount(scope, context) + } catch (e: ServiceException) { + scope.launch(Dispatchers.Main) { + Toast.makeText(context, "Failed to get account", Toast.LENGTH_SHORT).show() + } + } + scope.launch(Dispatchers.Main) { + navController.navigate(NavigationRoute.Index.route) { + popUpTo(NavigationRoute.Login.route) { inclusive = true } + } + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .background(appColor.background) + ) { + StatusBarSpacer() + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, start = 16.dp, end = 16.dp) + ) { + NoticeScreenHeader(stringResource(R.string.sign_up_upper), moreIcon = false) + } + Spacer(modifier = Modifier.padding(32.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 24.dp) + ) { + TextInputField( + modifier = Modifier + .fillMaxWidth(), + text = email, + onValueChange = { + email = it + }, + hint = stringResource(R.string.text_hint_email), + error = emailError + ) + Spacer(modifier = Modifier.padding(4.dp)) + TextInputField( + modifier = Modifier + .fillMaxWidth(), + text = password, + onValueChange = { + password = it + }, + password = true, + hint = stringResource(R.string.text_hint_password), + error = passwordError + ) + Spacer(modifier = Modifier.padding(4.dp)) + TextInputField( + modifier = Modifier + .fillMaxWidth(), + text = confirmPassword, + onValueChange = { + confirmPassword = it + }, + password = true, + hint = stringResource(R.string.text_hint_confirm_password), + error = confirmPasswordError + ) + Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + CheckboxWithLabel( + checked = rememberMe, + checkSize = 16, + fontSize = 12, + label = stringResource(R.string.remember_me), + ) { + rememberMe = it + } + Spacer(modifier = Modifier.height(16.dp)) + PolicyCheckbox( + checked = acceptTerms, + error = termsError + ) { + acceptTerms = it + // 当用户勾选时,立即清除错误状态 + if (it) { + termsError = false + } + } + Spacer(modifier = Modifier.height(16.dp)) + CheckboxWithLabel( + checked = acceptPromotions, + checkSize = 16, + fontSize = 12, + label = stringResource(R.string.agree_promotion), + error = promotionsError + ) { + acceptPromotions = it + // 当用户勾选时,立即清除错误状态 + if (it) { + promotionsError = false + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + ActionButton( + modifier = Modifier + .width(345.dp), + text = stringResource(R.string.lets_ride_upper), + backgroundColor = Color(0xffda3832), + color = Color.White + ) { + scope.launch(Dispatchers.IO) { + registerUser() + } + } + } + + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/login/login.kt b/app/src/main/java/com/aiosman/ravenow/ui/login/login.kt new file mode 100644 index 0000000..9898650 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/login/login.kt @@ -0,0 +1,658 @@ +package com.aiosman.ravenow.ui.login + +import android.content.ContentValues.TAG +import android.content.res.Resources +import android.util.Log +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.rememberScrollableState +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.AppStore +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.AccountServiceImpl +import com.aiosman.ravenow.data.DictService +import com.aiosman.ravenow.data.DictServiceImpl +import com.aiosman.ravenow.data.ServiceException +import com.aiosman.ravenow.data.api.getErrorMessageCode +import com.aiosman.ravenow.data.api.showToast +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.ActionButton +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.utils.GoogleLogin +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.rememberLottieComposition + +@Preview +@Composable +fun LoginPage() { + val navController = LocalNavController.current + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val accountService = AccountServiceImpl() + val statusBarController = rememberSystemUiController() + val AppColors = LocalAppTheme.current + val dictService: DictService = DictServiceImpl() + var showGoogleLogin by remember { + mutableStateOf(false) + } + suspend fun checkGoogleLoginEnable() { + try { + val result = dictService.getDistList( + listOf( + ConstVars.DICT_KEY_ENABLE_GOOGLE_LOGIN, + ConstVars.DICT_KEY_GOOGLE_LOGIN_CLIENT_ID + ) + ) + val enableDictItem = result.find { it.key == ConstVars.DICT_KEY_ENABLE_GOOGLE_LOGIN } + val clientIdDictItem = result.find { it.key == ConstVars.DICT_KEY_GOOGLE_LOGIN_CLIENT_ID } + if (enableDictItem != null && clientIdDictItem != null) { + AppState.googleClientId = clientIdDictItem.value as? String + showGoogleLogin = enableDictItem.value == true + } else { + showGoogleLogin = false + } + AppState.enableGoogleLogin = showGoogleLogin + } catch (_: Exception) { + + } + } + LaunchedEffect(Unit) { + statusBarController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode) + checkGoogleLoginEnable() + } + + + fun googleLogin() { + val clientId = AppState.googleClientId + if (clientId == null) { + Toast.makeText(context, "Google login is not enabled", Toast.LENGTH_SHORT).show() + return + } + coroutineScope.launch { + try { + GoogleLogin(context, clientId) { + coroutineScope.launch { + try { + accountService.regiterUserWithGoogleAccount(it) + } catch (e: ServiceException) { + when (e.errorType) { + ErrorCode.USER_EXIST -> + Toast.makeText( + context, + context.getErrorMessageCode(e.errorType.code), + Toast.LENGTH_SHORT + ).show() + + else -> { + e.errorType.showToast(context) + } + } + Log.e(TAG, "Failed to register with google", e) + return@launch + } catch (e: Exception) { + Log.e(TAG, "Failed to register with google", e) + return@launch + } + // 获取用户信息 + // 获取 token + val authResp = accountService.loginUserWithGoogle(it) + if (authResp.token != null) { + coroutineScope.launch(Dispatchers.Main) { + Toast.makeText( + context, + "Successfully registered", + Toast.LENGTH_SHORT + ) + .show() + } + } + AppStore.apply { + token = authResp.token + this.rememberMe = true + isGuest = false // 清除游客状态 + saveData() + } + // 获取token 信息 + try { + AppState.initWithAccount(coroutineScope, context) + } catch (e: Exception) { + Log.e(TAG, "Failed to init with account", e) + } catch (e: ServiceException) { + coroutineScope.launch(Dispatchers.Main) { + Toast.makeText(context, "Failed to get account", Toast.LENGTH_SHORT) + .show() + } + } + coroutineScope.launch(Dispatchers.Main) { + navController.navigate(NavigationRoute.Index.route) { + popUpTo(NavigationRoute.Login.route) { inclusive = true } + } + } + } + + } + } catch (e: Exception) { + Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() + } + + } + } + + fun guestLogin() { + coroutineScope.launch { + try { + // 生成设备ID + val deviceId = android.provider.Settings.Secure.getString( + context.contentResolver, + android.provider.Settings.Secure.ANDROID_ID + ) ?: "unknown_device" + + // 获取设备信息 + val deviceInfo = "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}" + + // 调用游客登录API + val authResp = accountService.guestLogin(deviceId, deviceInfo) + + // 保存token和游客状态 + AppStore.apply { + token = authResp.token + isGuest = true + rememberMe = true + saveData() + } + +// // 显示成功提示 +// coroutineScope.launch(Dispatchers.Main) { +// Toast.makeText( +// context, +// "游客登录成功", +// Toast.LENGTH_SHORT +// ).show() +// } + + // 初始化应用状态(游客模式会自动跳过推送和TRTC初始化) + try { + AppState.initWithAccount(coroutineScope, context) + } catch (e: Exception) { + Log.e(TAG, "Failed to init with guest account", e) + // 游客模式下初始化失败不是致命错误,可以继续 + } + + // 导航到主页 + coroutineScope.launch(Dispatchers.Main) { + navController.navigate(NavigationRoute.Index.route) { + popUpTo(NavigationRoute.Login.route) { inclusive = true } + } + } + + } catch (e: ServiceException) { + coroutineScope.launch(Dispatchers.Main) { + Toast.makeText( + context, + "游客登录失败: ${e.message}", + Toast.LENGTH_SHORT + ).show() + } + Log.e(TAG, "Guest login failed", e) + } catch (e: Exception) { + coroutineScope.launch(Dispatchers.Main) { + Toast.makeText( + context, + "游客登录失败", + Toast.LENGTH_SHORT + ).show() + } + Log.e(TAG, "Guest login failed", e) + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 60.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(30.dp) + .background( + color = AppColors.text.copy(alpha = 0.1f), + shape = androidx.compose.foundation.shape.CircleShape + ) + .noRippleClickable { + guestLogin() + }, + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_close), + contentDescription = "Close", + modifier = Modifier.size(16.dp), + colorFilter = ColorFilter.tint(AppColors.text) + ) + } + } + + + Spacer(modifier = Modifier.height(20.dp)) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(400.dp) + + ) { + val lottieFile = if (AppState.darkMode) "login.lottie" else "login_light.lottie" + LottieAnimation( + composition = rememberLottieComposition(LottieCompositionSpec.Asset(lottieFile)).value, + iterations = LottieConstants.IterateForever, + modifier = Modifier.fillMaxSize() + ) + } + // 减少下方间距,使文本上移 + Spacer(modifier = Modifier.height(16.dp)) // 原来可能是24dp或32dp + Text( + text = stringResource(R.string.join_party_carnival), + fontSize = 17.sp, + fontWeight = FontWeight.Bold, // 加粗字体 + color = AppColors.text + ) + Spacer(modifier = Modifier.height(24.dp)) // 调整后续间距 +// Image( +// painter = painterResource(id = R.mipmap.invalid_name), +// contentDescription = "Rave Now", +// modifier = Modifier +// .size(52.dp) +// .clip(RoundedCornerShape(10.dp)) +// ) +// Spacer(modifier = Modifier.height(8.dp)) +// Text( +// "Rave Now", +// fontSize = 28.sp, +// fontWeight = FontWeight.W900, +// color = AppColors.text +// ) +// Spacer(modifier = Modifier.height(16.dp)) +// Text( +// "Your Night Starts Here", +// fontSize = 20.sp, +// fontWeight = FontWeight.W700, +// color = AppColors.text +// ) + //注册tab + ActionButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.sign_up_upper), + color = Color.White, // 文字颜色保持白色 + backgroundColor = Color(0xFF4169E1) // 改为皇家蓝(Royal Blue) + ) { + navController.navigate( + NavigationRoute.EmailSignUp.route, + ) + } + //谷歌登录tab + Spacer(modifier = Modifier.height(16.dp)) + ActionButton( + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + .border( + width = 1.5.dp, + color = if (AppState.darkMode) Color.White else Color.Black, + shape = RoundedCornerShape(24.dp) + ), + text = stringResource(R.string.sign_in_with_google), + color = if (AppState.darkMode) Color.White else Color.Black, + backgroundColor = if (AppState.darkMode) Color.Black else Color.White, + leading = { + Image( + painter = painterResource(id = R.mipmap.rider_pro_signup_google), + contentDescription = "Google", + modifier = Modifier.size(18.dp), + ) + }, + expandText = true, + contentPadding = PaddingValues(vertical = 8.dp, horizontal = 10.dp) + ) { + googleLogin() + } + + //登录tab + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.login_upper), + color = AppColors.text.copy(alpha = 0.5f), + fontSize = 16.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .noRippleClickable { + navController.navigate( + NavigationRoute.UserAuth.route, + ) + } + ) +// // 游客登录按钮 +// Spacer(modifier = Modifier.height(16.dp)) +// ActionButton( +// modifier = Modifier.fillMaxWidth(), +// text = "游客模式", +// color = AppColors.text.copy(alpha = 0.7f), +// ) { +// guestLogin() +// } + Spacer(modifier = Modifier.height(70.dp)) + } + } +} + + +@Composable +fun MovingImageWall(resources: Resources) { + val AppColors = LocalAppTheme.current + val imageList1 = remember { + mutableStateListOf( + R.drawable.wall_1_1, + R.drawable.wall_1_2, + R.drawable.wall_1_3, + R.drawable.wall_1_1, + R.drawable.wall_1_2, + R.drawable.wall_1_3 + ) + } + val imageList2 = remember { + mutableStateListOf( + R.drawable.wall_2_1, + R.drawable.wall_2_2, + R.drawable.wall_2_3, + R.drawable.wall_2_1, + R.drawable.wall_2_2, + R.drawable.wall_2_3 + ) + } + val imageList3 = remember { + mutableStateListOf( + R.drawable.wall_3_1, + R.drawable.wall_3_2, + R.drawable.wall_3_3, + R.drawable.wall_3_1, + R.drawable.wall_3_2, + R.drawable.wall_3_3 + ) + } + + val density = resources.displayMetrics.density // 获取屏幕密度 + val imageHeight = 208.dp + val imageHeightPx = imageHeight.value * density // 将 dp 转换为像素 + val resetThreshold = imageHeightPx + // 使用 remember 保存动画状态,并在应用停止时重置 + // 每次 recomposition 时重置 offset + var offset1 by remember { mutableFloatStateOf(-resetThreshold * 3) } + var offset2 by remember { mutableFloatStateOf(0f) } + var offset3 by remember { mutableFloatStateOf(-resetThreshold * 3) } + + val coroutineScope = rememberCoroutineScope() + + // 使用 LaunchedEffect 在每次 recomposition 时启动动画 + LaunchedEffect(Unit) { + coroutineScope.launch { + animateImageWall(imageList1, offset1, speed = 1f, resources = resources) { + offset1 = it + } + } + coroutineScope.launch { + animateImageWall( + imageList2, + offset2, + speed = 1.5f, + reverse = true, + resources = resources + ) { offset2 = it } + } + coroutineScope.launch { + animateImageWall(imageList3, offset3, speed = 2f, resources = resources) { + offset3 = it + } + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + ) { + val scrollState1 = rememberScrollState() + val scrollableState1 = rememberScrollableState { delta -> + // 消耗所有滚动事件,阻止用户滚动 + delta + } + val scrollState2 = rememberScrollState() + val scrollableState2 = rememberScrollableState { delta -> + // 消耗所有滚动事件,阻止用户滚动 + delta + } + val scrollState3 = rememberScrollState() + val scrollableState3 = rememberScrollableState { delta -> + // 消耗所有滚动事件,阻止用户滚动 + delta + } + Row( + modifier = Modifier + .fillMaxWidth() + ) { + // 第1列 + ImageColumn( + imageList1, offset1, + Modifier + .verticalScroll(scrollState1) + .scrollable( + state = scrollableState1, + orientation = Orientation.Vertical + ) + .weight(1f) + ) + // 第2列 + ImageColumn( + imageList2, offset2, + Modifier + .verticalScroll(scrollState2) + .scrollable( + state = scrollableState2, + orientation = Orientation.Vertical + ) + .weight(1f), reverse = true + ) + // 第3列 + ImageColumn( + imageList3, offset3, + Modifier + .verticalScroll(scrollState3) + .scrollable( + state = scrollableState3, + orientation = Orientation.Vertical + ) + .weight(1f) + ) + } + + // 白色叠加层 + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .background(AppColors.background.copy(alpha = 0.3f)), + contentAlignment = Alignment.BottomCenter + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(500.dp) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + AppColors.background + ), + startY = 0f, + endY = 500f + ) + ) + ) + } + } +} + +@Composable +fun ImageColumn( + imageList: List, + offset: Float, + modifier: Modifier, + reverse: Boolean = false +) { + val imageCount = imageList.size + val imageHeight = 208.dp + var currentImage by remember { mutableStateOf(0) } + val totalHeight = imageHeight.value * imageCount // 计算总高度 + Column(modifier = modifier) { + for (i in 0 until imageCount) { + Box( + modifier = Modifier + .width(156.dp) + .height(208.dp) + .scale(1f) + .graphicsLayer { + val translation = if (reverse) { + offset + } else { + offset + } + translationY = translation + } + .clip(RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center) { + Image( + painter = painterResource(id = imageList[(currentImage + i) % imageCount]), + contentDescription = "背景图片", + modifier = Modifier + .width(156.dp) + .height(208.dp) + .scale(0.9f) + .clip(RoundedCornerShape(16.dp)), + contentScale = ContentScale.Crop + ) + } + + } + } +} + +suspend fun animateImageWall( + imageList: MutableList, + initialOffset: Float, + speed: Float, // speed 现在以像素为单位 + reverse: Boolean = false, + resources: Resources, // 添加 resources 参数 + onUpdate: (Float) -> Unit, +) { + val density = resources.displayMetrics.density // 获取屏幕密度 + var currentOffset = initialOffset + val imageCount = imageList.size + val imageHeight = 208.dp + val imageHeightPx = imageHeight.value * density // 将 dp 转换为像素 + val resetThreshold = imageHeightPx + Log.d(TAG, "speed: $speed") + Log.d(TAG, "resetThreshold: $resetThreshold") + while (true) { + onUpdate(currentOffset) + if (reverse) { + currentOffset -= speed +// Log.d(TAG, "currentOffset: $currentOffset") + if (currentOffset <= -resetThreshold * 3) { // 检查是否向上超出阈值 + // 使用 imageHeightPx 进行计算 + // 复位 + currentOffset = initialOffset + } + } else { + currentOffset += speed + + if (currentOffset >= 0) { // 检查是否向上超出阈值 + Log.d(TAG, "currentOffset: $currentOffset") + // 使用 imageHeightPx 进行计算 + // 复位 + currentOffset = initialOffset + } + // 使用 imageHeightPx 进行计算 + } + delay(16) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/login/signup.kt b/app/src/main/java/com/aiosman/ravenow/ui/login/signup.kt new file mode 100644 index 0000000..63e3852 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/login/signup.kt @@ -0,0 +1,263 @@ +package com.aiosman.ravenow.ui.login + +import android.content.ContentValues.TAG +import android.util.Log +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.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.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.data.AccountService +import com.aiosman.ravenow.data.AccountServiceImpl +import com.aiosman.ravenow.data.ServiceException +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.ActionButton +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.utils.GoogleLogin +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + + +@Composable +fun SignupScreen() { + val navController = LocalNavController.current + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val accountService: AccountService = AccountServiceImpl() + val appColor = LocalAppTheme.current + var enableGoogleLogin by remember { mutableStateOf(AppState.enableGoogleLogin) } + fun googleLogin() { + val clientId = AppState.googleClientId + if (clientId == null) { + Toast.makeText(context, "Google client id is not set", Toast.LENGTH_SHORT).show() + return + } + coroutineScope.launch { + try { + + GoogleLogin(context,clientId) { + coroutineScope.launch { + try { + accountService.regiterUserWithGoogleAccount(it) + } catch (e: Exception) { + Log.e(TAG, "Failed to register with google", e) + return@launch + } + // 获取用户信息 + // 获取 token + val authResp = accountService.loginUserWithGoogle(it) + if (authResp.token != null) { + coroutineScope.launch(Dispatchers.Main) { + Toast.makeText( + context, + "Successfully registered", + Toast.LENGTH_SHORT + ) + .show() + } + } + AppStore.apply { + token = authResp.token + this.rememberMe = true + isGuest = false // 清除游客状态 + saveData() + } + // 获取token 信息 + try { + AppState.initWithAccount(coroutineScope, context) + } catch (e: Exception) { + Log.e(TAG, "Failed to init with account", e) + } catch (e: ServiceException) { + coroutineScope.launch(Dispatchers.Main) { + Toast.makeText(context, "Failed to get account", Toast.LENGTH_SHORT) + .show() + } + } + coroutineScope.launch(Dispatchers.Main) { + navController.navigate(NavigationRoute.Index.route) { + popUpTo(NavigationRoute.Login.route) { inclusive = true } + } + } + } + + } + } catch (e: Exception) { + Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() + } + + } + } + + + Scaffold( + modifier = Modifier + .fillMaxSize() + .background(appColor.background) + ) { + it + Box( + modifier = Modifier.fillMaxSize() + ) { + // to bottom + Box( + contentAlignment = Alignment.TopCenter, + modifier = Modifier.padding(top = 211.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.mipmap.rider_pro_logo), + contentDescription = "Rave Now", + modifier = Modifier + .width(108.dp) + .height(45.dp) + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + "Rave Now".uppercase(), + fontSize = 28.sp, + fontWeight = FontWeight.Bold + ) + Text("Your Night Starts Here".uppercase(), fontSize = 20.sp, fontWeight = FontWeight.W700) + } + + } + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 82.dp, start = 24.dp, end = 24.dp) + + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ActionButton( + modifier = Modifier + .width(345.dp) + .height(48.dp), + text = stringResource(R.string.sign_in_with_email), + leading = { + Image( + painter = painterResource(id = R.mipmap.rider_pro_email), + contentDescription = "Email", + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + ) { + navController.navigate(NavigationRoute.EmailSignUp.route) + } +// Spacer(modifier = Modifier.height(16.dp)) +// ActionButton( +// modifier = Modifier +// .width(345.dp) +// .height(48.dp), +// text = "CONTINUE WITH FACEBOOK".uppercase(), +// backgroundImage = R.mipmap.rider_pro_signup_facebook_bg, +// leading = { +// Image( +// painter = painterResource(id = R.mipmap.rider_pro_signup_facebook), +// contentDescription = "Facebook", +// modifier = Modifier.size(24.dp) +// ) +// Spacer(modifier = Modifier.width(8.dp)) +// } +// ) + if (enableGoogleLogin) { + Spacer(modifier = Modifier.height(16.dp)) + ActionButton( + modifier = Modifier + .width(345.dp) + .height(48.dp), + color = Color.Black, + leading = { + Image( + painter = painterResource(id = R.mipmap.rider_pro_signup_google), + contentDescription = "Google", + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + }, + text = stringResource(R.string.sign_in_with_google), + ) { + googleLogin() + + } + } + +// Spacer(modifier = Modifier.height(16.dp)) +// Box( +// modifier = Modifier +// .width(345.dp) +// .height(48.dp) +// .clip(RoundedCornerShape(8.dp)) +// .background(Color.Black) +// +// ) { +// Text( +// "Sign in with Apple", +// color = Color.White, +// modifier = Modifier.align(Alignment.Center), +// fontSize = 16.sp, +// fontWeight = FontWeight.Bold +// ) +// } + Spacer(modifier = Modifier.height(40.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.noRippleClickable { + navController.navigateUp() + } + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_back_icon), + contentDescription = "Back", + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(R.string.back_upper), + color = Color.Black, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/login/userauth.kt b/app/src/main/java/com/aiosman/ravenow/ui/login/userauth.kt new file mode 100644 index 0000000..9b9b0e4 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/login/userauth.kt @@ -0,0 +1,343 @@ +package com.aiosman.ravenow.ui.login + +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.AppStore +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.CaptchaService +import com.aiosman.ravenow.data.CaptchaServiceImpl +import com.aiosman.ravenow.data.ServiceException +import com.aiosman.ravenow.data.api.CaptchaInfo +import com.aiosman.ravenow.data.api.CaptchaResponseBody +import com.aiosman.ravenow.data.api.DotPosition +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.ClickCaptchaDialog +import com.aiosman.ravenow.ui.composables.StatusBarSpacer +import com.aiosman.ravenow.ui.composables.TextInputField +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.utils.GoogleLogin +import com.aiosman.ravenow.utils.PasswordValidator +import kotlinx.coroutines.launch + + +@Composable +fun UserAuthScreen() { + val AppColors = LocalAppTheme.current + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var rememberMe by remember { mutableStateOf(true) } + val accountService: AccountService = AccountServiceImpl() + val captchaService: CaptchaService = CaptchaServiceImpl() + val scope = rememberCoroutineScope() + val navController = LocalNavController.current + val context = LocalContext.current + var emailError by remember { mutableStateOf(null) } + var passwordError by remember { mutableStateOf(null) } + var captchaInfo by remember { mutableStateOf(null) } + fun validateForm(): Boolean { + emailError = + if (email.isEmpty()) context.getString(R.string.text_error_email_required) else null + + // 使用通用密码校验器 + val passwordValidation = PasswordValidator.validateCurrentPassword(password, context) + passwordError = if (!passwordValidation.isValid) passwordValidation.errorMessage else null + + return emailError == null && passwordError == null + } + + var captchaData by remember { mutableStateOf(null) } + fun loadLoginCaptcha() { + scope.launch { + try { + captchaData = captchaService.generateLoginCaptcha(email) + captchaData?.let { + captchaInfo = CaptchaInfo( + id = it.id, + dot = emptyList() + ) + } + + } catch (e: ServiceException) { + Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() + } + } + } + + fun onLogin(captchaInfo: CaptchaInfo? = null) { + if (!validateForm()) { + return + } + scope.launch { + try { + // 检查是否需要验证码 + if (captchaInfo == null && captchaService.checkLoginCaptcha(email)) { + loadLoginCaptcha() + return@launch + } + // 获取用户凭证 + val authResp = accountService.loginUserWithPassword(email, password, captchaInfo) + if (authResp.token != null) { + AppStore.apply { + token = authResp.token + this.rememberMe = rememberMe + isGuest = false // 清除游客状态 + saveData() + } + AppState.initWithAccount(scope, context) + navController.navigate(NavigationRoute.Index.route) { + popUpTo(NavigationRoute.Login.route) { inclusive = true } + } + } + } catch (e: ServiceException) { + // handle error + when (e.code) { + 12005 -> { + emailError = context.getString(R.string.error_invalidate_username_password) + passwordError = context.getString(R.string.error_invalidate_username_password) + } + ErrorCode.InvalidateCaptcha.code -> { + loadLoginCaptcha() + Toast.makeText( + context, + context.getString(R.string.incorrect_captcha_please_try_again), + Toast.LENGTH_SHORT + ).show() + } + else -> { + Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() + } + } + } catch (e: Exception) { + Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() + } finally { + captchaData = null + } + } + + } + + + fun googleLogin() { + val clientId = AppState.googleClientId + if (clientId == null) { + Toast.makeText(context, "Google login not supported", Toast.LENGTH_SHORT).show() + return + } + scope.launch { + try { + GoogleLogin(context,clientId) { + scope.launch { + try { + val authResp = accountService.loginUserWithGoogle(it) + if (authResp.token != null) { + AppStore.apply { + token = authResp.token + this.rememberMe = rememberMe + isGuest = false // 清除游客状态 + saveData() + } + navController.navigate(NavigationRoute.Index.route) { + popUpTo(NavigationRoute.Login.route) { inclusive = true } + } + } + } catch (e: ServiceException) { + // handle error + e.printStackTrace() + Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() + } + } + } + } catch (e: Exception) { + Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() + } + } + + } + captchaData?.let { + ClickCaptchaDialog( + onDismissRequest = { + captchaData = null + }, + captchaData = it, + onLoadCaptcha = { + loadLoginCaptcha() + }, + onPositionClicked = { offset -> + captchaInfo?.let { info -> + val dots = info.dot.toMutableList() + val lastDotIndex = dots.size - 1 + dots += DotPosition( + index = lastDotIndex + 1, + x = offset.x.toInt(), + y = offset.y.toInt() + ) + captchaInfo = info.copy(dot = dots) + // 检查是否完成 + if (dots.size == it.count) { + onLogin(captchaInfo) + } + } + }, + ) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) + ) { + StatusBarSpacer() + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, start = 16.dp, end = 16.dp) + ) { + NoticeScreenHeader(stringResource(R.string.login_upper), moreIcon = false) + } + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + + ) { + StatusBarSpacer() + TextInputField( + modifier = Modifier + .fillMaxWidth(), + text = email, + onValueChange = { + email = it + }, + hint = stringResource(R.string.text_hint_email), + error = emailError + ) + Spacer(modifier = Modifier.padding(4.dp)) + TextInputField( + modifier = Modifier + .fillMaxWidth(), + text = password, + onValueChange = { + password = it + }, + password = true, + hint = stringResource(R.string.text_hint_password), + error = passwordError + ) + Spacer(modifier = Modifier.height(32.dp)) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + com.aiosman.ravenow.ui.composables.Checkbox( + checked = rememberMe, + onCheckedChange = { + rememberMe = it + }, + size = 18 + ) + Text( + stringResource(R.string.remember_me), + modifier = Modifier.padding(start = 8.dp), + fontSize = 12.sp, + style = TextStyle( + fontWeight = FontWeight.W500 + ), + color = AppColors.text + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + stringResource(R.string.forgot_password), + fontSize = 12.sp, + modifier = Modifier.noRippleClickable { + navController.navigate(NavigationRoute.ResetPassword.route) + }, + style = TextStyle( + fontWeight = FontWeight.W500 + ), + color = AppColors.text + ) + + } + Spacer(modifier = Modifier.height(46.dp)) + ActionButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.lets_ride_upper), + backgroundBrush = Brush.linearGradient( + colors = listOf( + Color(0xFF7c45ed), + Color(0x777c68ef), + Color(0x777bd8f8) + ) + ), + color = AppColors.mainText, + ) { + onLogin() + } +// if (AppState.enableGoogleLogin) { +// Spacer(modifier = Modifier.height(16.dp)) +// Text(stringResource(R.string.or_login_with), color = AppColors.secondaryText) +// Spacer(modifier = Modifier.height(16.dp)) +// ActionButton( +// modifier = Modifier.fillMaxWidth(), +// text = stringResource(R.string.sign_in_with_google), +// color = AppColors.text, +// leading = { +// Image( +// painter = painterResource(id = R.drawable.rider_pro_google), +// contentDescription = "Google", +// modifier = Modifier.size(36.dp) +// ) +// }, +// expandText = true, +// contentPadding = PaddingValues(vertical = 8.dp, horizontal = 8.dp) +// ) { +// googleLogin() +// } +// } + + } + + } + + +} + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/message/Message.kt b/app/src/main/java/com/aiosman/ravenow/ui/message/Message.kt new file mode 100644 index 0000000..5b377f2 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/message/Message.kt @@ -0,0 +1,228 @@ +package com.aiosman.ravenow.ui.message + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.paging.LoadState +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.ravenow.ui.composables.MomentListLoading +import com.aiosman.ravenow.R +import com.aiosman.ravenow.model.ChatNotificationData +import com.aiosman.ravenow.model.TestChatBackend + +val chatNotificationData = ChatNotificationData( + R.drawable.default_avatar, + "Tokunaga Yae", + "Memphis", + "3 minutes ago", + 6 +) + +private val ChatData = (0..10).toList().map { chatNotificationData } + +@Composable +fun MessagePage(){ + val myBackend = remember { TestChatBackend(ChatData) } + val pager = remember { + Pager( + PagingConfig( + pageSize = myBackend.DataBatchSize, + enablePlaceholders = true, + maxSize = 200 + ) + ) { + myBackend.getAllData() + } + } + val lazyPagingItems = pager.flow.collectAsLazyPagingItems() + MessageTopSwitchBtnGroup() + MessageBarrierLine() + MessageNotification() + LazyColumn ( + modifier = Modifier.padding(bottom = 20.dp) + ){ + if (lazyPagingItems.loadState.refresh == LoadState.Loading) { + item { + MomentListLoading() + } + } + items(count = lazyPagingItems.itemCount) { index -> + val item = lazyPagingItems[index] + if (item != null) { + ChatItem(item) + } + } + if (lazyPagingItems.loadState.append == LoadState.Loading) { + item { + MomentListLoading() + } + } + } + +} + +@Composable +fun MessageTopSwitchBtnGroup(){ + Column ( + modifier = Modifier + .fillMaxWidth() + .height(113.dp) + ) { + Row(modifier = Modifier.fillMaxSize()){ + val notificationBtnModifier = Modifier + .fillMaxHeight() + .weight(1f) + NotificationBtn(notificationBtnModifier,drawableId = R.drawable.rider_pro_like, + displayText = "LIKE") + NotificationBtn(notificationBtnModifier,drawableId = R.drawable.rider_pro_followers, + displayText = "FOLLOWERS") + NotificationBtn(notificationBtnModifier,drawableId = R.drawable.rider_pro_comments, + displayText = "COMMENTS") + } + } +} + +@Composable +fun MessageBarrierLine(){ + Box(modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .padding(start = 24.dp, end = 24.dp) + .border(width = 1.dp, Color(0f, 0f, 0f, 0.1f)) + ) +} + +@Composable +fun NotificationBtn(modifier: Modifier, drawableId: Int, displayText: String){ + Box(modifier = modifier, + contentAlignment = Alignment.Center){ + Column(modifier = Modifier + .size(width = 79.dp, height = 88.dp), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally){ + Box(modifier = Modifier + .padding(top = 8.dp) + .size(width = 55.dp, height = 55.dp) + .shadow( + spotColor = Color.White, + ambientColor = Color(0f, 0f, 0f, 0.4f), + elevation = 12.dp, + ), + contentAlignment = Alignment.Center, + ){ + Image( + modifier = Modifier + .size(width = 24.dp, height = 24.dp), + painter = painterResource(id = drawableId), + contentDescription = "" + ) + } + Text( + modifier = Modifier.padding(top = 8.dp), + text = displayText, + fontSize = 12.sp, style = TextStyle(fontWeight = FontWeight.Bold) + ) + } + } +} + +@Composable +fun MessageNotification(){ + Row(modifier = Modifier + .fillMaxWidth() + .height(88.dp) + .padding(start = 22.dp, top = 20.dp, bottom = 20.dp, end = 24.dp), + verticalAlignment = Alignment.CenterVertically){ + Box(modifier = Modifier + .size(width = 48.dp, height = 48.dp) + .border(width = 1.dp, Color(0f, 0f, 0f, 0.1f), RoundedCornerShape(2.dp)), + contentAlignment = Alignment.Center){ + Icon( + modifier = Modifier + .size(width = 24.dp, height = 24.dp), + painter = painterResource(R.drawable.rider_pro_notification), + contentDescription = "" + ) + } + Text(text = "NOTIFICATIONS", fontSize = 18.sp, modifier = Modifier.padding(start = 12.dp), style = TextStyle(fontWeight = FontWeight.Bold)) + Box(modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterEnd){ + Box(modifier = Modifier + .height(18.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color.Red), + contentAlignment = Alignment.Center){ + Text(text = "18", fontSize = 10.sp, color = Color.White, modifier = Modifier.padding(start = 6.dp, end = 6.dp, top = 2.dp, bottom = 2.dp)) + } + } + } +} + +@Composable +fun ChatItem(chatNotificationData: ChatNotificationData){ + Row(modifier = Modifier + .fillMaxWidth() + .height(88.dp) + .padding(start = 22.dp, top = 20.dp, bottom = 20.dp, end = 24.dp), + verticalAlignment = Alignment.CenterVertically){ + Image(modifier = Modifier + .size(width = 48.dp, height = 48.dp) + .clip(RoundedCornerShape(2.dp)), + painter = painterResource(chatNotificationData.avatar), + contentDescription = "") + Column ( + modifier = Modifier.fillMaxHeight().padding(start = 12.dp), + verticalArrangement = Arrangement.SpaceAround + ){ + Text(text = chatNotificationData.name, fontSize = 18.sp, style = TextStyle(fontWeight = FontWeight.Bold)) + Text(text = chatNotificationData.message, fontSize = 14.sp, + color = Color(0f, 0f, 0f, 0.6f)) + } + + Box(modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterEnd){ + Column ( + modifier = Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.SpaceAround, + horizontalAlignment = Alignment.End + ){ + Text(text = chatNotificationData.time, fontSize = 12.sp,color = Color(0f, 0f, 0f, 0.4f)) + Box(modifier = Modifier + .height(18.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color.Red), + contentAlignment = Alignment.Center){ + Text(text = chatNotificationData.unread.toString(), fontSize = 10.sp, color = Color.White, modifier = Modifier.padding(start = 6.dp, end = 6.dp, top = 2.dp, bottom = 2.dp)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/modification/AddModification.kt b/app/src/main/java/com/aiosman/ravenow/ui/modification/AddModification.kt new file mode 100644 index 0000000..8197625 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/modification/AddModification.kt @@ -0,0 +1,241 @@ +package com.aiosman.ravenow.ui.modification + +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.ui.post.NewPostViewModel +import com.aiosman.ravenow.ui.comment.NoticeScreenHeader +import com.aiosman.ravenow.R +import com.aiosman.ravenow.utils.Utils + +@Preview +@Composable +fun EditModificationScreen() { + val model = NewPostViewModel + Column( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFf8f8f8)) + ) { + Box( + modifier = Modifier.padding(vertical = 16.dp, horizontal = 18.dp) + ) { + NoticeScreenHeader("Modification List") + } + LazyColumn( + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp) + ) { + items(NewPostViewModel.modificationList) { mod -> + AddModificationItem(mod) { updatedMod -> + NewPostViewModel.modificationList = NewPostViewModel.modificationList.map { existingMod -> + if (existingMod.key == updatedMod.key) updatedMod else existingMod + }.toMutableList() + } + Spacer(modifier = Modifier.height(16.dp)) + } + item { + AddModificationButton { + NewPostViewModel.modificationList += Modification( + key = Utils.generateRandomString(4), + name = "", + price = "0.0" + ) + } + } + } + } +} + +data class Modification( + val key: String, + val name: String, + val price: String +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddModificationItem(modification: Modification, onUpdate: (Modification) -> Unit) { + var isEditPriceBottomModalVisible by remember { mutableStateOf(false) } + if (isEditPriceBottomModalVisible) { + ModalBottomSheet( + onDismissRequest = { + isEditPriceBottomModalVisible = false + }, + containerColor = Color.White, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), + dragHandle = {}, + scrimColor = Color.Transparent, + shape = RectangleShape + ) { + EditPriceBottomModal { + isEditPriceBottomModalVisible = false + onUpdate( + modification.copy(price = it) + ) + } + } + } + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + .padding(vertical = 13.dp, horizontal = 16.dp), + + + ) { + if (modification.name.isEmpty()) { + Text("Please enter the name", fontSize = 14.sp, color = Color(0xFFd6d6d6)) + } + BasicTextField( + value = modification.name, + onValueChange = { + onUpdate( + modification.copy(name = it) + ) + }, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + ) + } + Spacer(modifier = Modifier.height(1.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + .padding(top = 13.dp, bottom = 13.dp, start = 16.dp, end = 8.dp) + .clickable { + isEditPriceBottomModalVisible = true + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text("Price", fontSize = 16.sp) + Spacer(modifier = Modifier.weight(1f)) + Text(modification.price, fontSize = 16.sp, color = Color(0xffda3832)) + Spacer(modifier = Modifier.width(6.dp)) + Image( + painter = painterResource(id = R.drawable.rider_pro_nav_next), + contentDescription = "Edit", + modifier = Modifier.size(24.dp) + ) + } + } + } +} + +@Composable +fun AddModificationButton(onClick: () -> Unit = {}) { + val stroke = Stroke( + width = 2f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .drawBehind { + drawRoundRect(color = Color(0xFFd6d6d6), style = stroke) + } + .clickable { + onClick() + } + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_new_post_add_pic), + contentDescription = "Add", + modifier = Modifier + .size(24.dp) + .align(Alignment.Center), + ) + } +} + +@Composable +fun EditPriceBottomModal(onOkClick: (String) -> Unit = {}) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + var text by remember { mutableStateOf("") } + + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + + // Modal content including BasicTextField + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + .padding(16.dp) + ) { + Text("Price", fontSize = 16.sp) + Spacer(modifier = Modifier.width(16.dp)) + BasicTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + singleLine = true, + keyboardActions = KeyboardActions( + onDone = { + keyboardController?.hide() + // Logic to close the dialog/modal + onOkClick(text) + } + ), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/modification/ModificationList.kt b/app/src/main/java/com/aiosman/ravenow/ui/modification/ModificationList.kt new file mode 100644 index 0000000..ac848fb --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/modification/ModificationList.kt @@ -0,0 +1,99 @@ +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder +import com.aiosman.ravenow.ui.comment.NoticeScreenHeader +import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout +import com.google.accompanist.systemuicontroller.rememberSystemUiController + +@Preview +@Composable +fun ModificationListScreen() { + val systemUiController = rememberSystemUiController() + LaunchedEffect(Unit) { + systemUiController.setNavigationBarColor(Color.Transparent) + } + val modifications = getModifications() + StatusBarMaskLayout { + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + .background(Color(0xFFF8F8F8)) + + ) { + NoticeScreenHeader("Modification List") + Spacer(modifier = Modifier.height(8.dp)) + LazyColumn(modifier = Modifier.padding(16.dp)) { + items(modifications.size) { index -> + val modification = modifications[index] + ModificationItem(name = modification.name, price = modification.price) + Spacer(modifier = Modifier.height(8.dp)) + } + item { + BottomNavigationPlaceholder() + } + } + } + } +} + +data class Modification(val name: String, val price: String) + +fun getModifications(): List { + return listOf( + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + Modification("Modification name", "$74.00"), + ) +} + +@Composable +fun ModificationItem(name: String, price: String) { + Card( + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = Color.White, + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = name, fontSize = 16.sp, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = price, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.Red) + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/modifiers/ModifierExp.kt b/app/src/main/java/com/aiosman/ravenow/ui/modifiers/ModifierExp.kt new file mode 100644 index 0000000..6d55491 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/modifiers/ModifierExp.kt @@ -0,0 +1,39 @@ +package com.aiosman.ravenow.ui.modifiers + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +inline fun Modifier.noRippleClickable(crossinline onClick: () -> Unit): Modifier = composed { + this.clickable(indication = null, + interactionSource = remember { MutableInteractionSource() }) { + onClick() + } +} + + +inline fun Modifier.noRippleClickable( + debounceTime: Long = 300L, + crossinline onClick: () -> Unit +): Modifier = composed { + var job: Job? = null + val scope = rememberCoroutineScope() + this.clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + job?.cancel() + job = scope.launch { + delay(debounceTime) + onClick() + } + } +} + + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/network/Reload.kt b/app/src/main/java/com/aiosman/ravenow/ui/network/Reload.kt new file mode 100644 index 0000000..dc998a4 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/network/Reload.kt @@ -0,0 +1,64 @@ +package com.aiosman.ravenow.ui.network + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +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.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +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.R +@Composable +fun ReloadButton( + onClick: () -> Unit +) { + val gradientBrush = Brush.linearGradient( + colors = listOf( + Color(0xFF7c45ed), + Color(0xFF7c68ef), + Color(0xFF7bd8f8) + ) + ) + + Button( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 120.dp) + .height(48.dp), + shape = RoundedCornerShape(30.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.Transparent + ), + contentPadding = PaddingValues(0.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(gradientBrush), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.Reload), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/notification/Notification.kt b/app/src/main/java/com/aiosman/ravenow/ui/notification/Notification.kt new file mode 100644 index 0000000..05b117a --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/notification/Notification.kt @@ -0,0 +1,144 @@ +package com.aiosman.ravenow.ui.notification + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.comment.notice.CommentNoticeScreen +import com.aiosman.ravenow.ui.composables.StatusBarSpacer +import com.aiosman.ravenow.ui.composables.TabItem +import com.aiosman.ravenow.ui.composables.TabSpacer +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import com.aiosman.ravenow.ui.follower.FollowerNoticeScreen +import com.aiosman.ravenow.ui.like.LikeNoticeScreen +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun NotificationScreen() { + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState(pageCount = { 3 }) + val Debouncer = rememberDebouncer() + Column( + modifier = Modifier + .fillMaxSize() + .background(color = AppColors.background) + ) { + StatusBarSpacer() + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_back_icon), + contentDescription = "Back", + modifier = Modifier + .size(24.dp) + .noRippleClickable { + Debouncer { + navController.popBackStack() + } + }, + colorFilter = ColorFilter.tint(AppColors.text) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = stringResource(R.string.group_info_notice_setting), + fontSize = 20.sp, + fontWeight = FontWeight.W900, + color = AppColors.text + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(start = 16.dp, top = 8.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.Bottom + ) { + TabItem( + text = stringResource(R.string.like), + isSelected = pagerState.currentPage == 0, + onClick = { + scope.launch { + pagerState.animateScrollToPage(0) + } + } + ) + + TabSpacer() + + TabItem( + text = stringResource(R.string.followers_upper), + isSelected = pagerState.currentPage == 1, + onClick = { + scope.launch { + pagerState.animateScrollToPage(1) + } + } + ) + + TabSpacer() + + TabItem( + text = stringResource(R.string.comment).uppercase(), + isSelected = pagerState.currentPage == 2, + onClick = { + scope.launch { + pagerState.animateScrollToPage(2) + } + } + ) + } + + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { page -> + when (page) { + 0 -> LikeNoticeScreen() + 1 -> FollowerNoticeScreen() + 2 -> CommentNoticeScreen() + } + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/post/CommentsViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/post/CommentsViewModel.kt new file mode 100644 index 0000000..bc7037a --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/post/CommentsViewModel.kt @@ -0,0 +1,320 @@ +package com.aiosman.ravenow.ui.post + +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.CommentService +import com.aiosman.ravenow.data.CommentServiceImpl +import com.aiosman.ravenow.entity.CommentEntity +import kotlinx.coroutines.launch + +class CommentsViewModel( + var postId: String = 0.toString(), +) : ViewModel() { + var commentService: CommentService = CommentServiceImpl() + var commentsList by mutableStateOf>(emptyList()) + var order: String by mutableStateOf("like") + var addedCommentList by mutableStateOf>(emptyList()) + var subCommentLoadingMap by mutableStateOf(mutableMapOf()) + var highlightCommentId by mutableStateOf(null) + var highlightComment by mutableStateOf(null) + var isLoading by mutableStateOf(false) + var hasError by mutableStateOf(false) + + /** + * 预加载,在跳转到 PostScreen 之前设置好内容 + */ + fun preTransit() { + viewModelScope.launch { + try { + isLoading = true + val response = commentService.getComments( + pageNumber = 1, + postId = postId.toInt(), + pageSize = 10 + ) + commentsList = response.list + hasError = false + } catch (e: Exception) { + e.printStackTrace() + hasError = true + } finally { + isLoading = false + } + } + } + + /** + * 加载评论 + */ + fun reloadComment() { + viewModelScope.launch { + try { + isLoading = true + val response = commentService.getComments( + pageNumber = 1, + postId = postId.toInt(), + order = order, + pageSize = 50 + ) + commentsList = response.list + hasError = false + } catch (e: Exception) { + e.printStackTrace() + hasError = true + } finally { + isLoading = false + } + } + } + + + suspend fun highlightComment(commentId: Int) { + highlightCommentId = commentId + val resp = commentService.getCommentById(commentId) + highlightComment = resp + } + + /** + * 更新高亮评论点赞状态 + */ + private fun updateHighlightCommentLike(commentId: Int, isLike: Boolean): Boolean { + var isUpdate = false + highlightComment?.let { + if (it.id == commentId) { + highlightComment = + it.copy(liked = isLike, likes = if (isLike) it.likes + 1 else it.likes - 1) + isUpdate = true + } + highlightComment = it.copy( + reply = it.reply.map { replyComment -> + if (replyComment.id == commentId) { + isUpdate = true + replyComment.copy( + liked = isLike, + likes = if (isLike) replyComment.likes + 1 else replyComment.likes - 1 + ) + } else { + replyComment + } + } + ) + } + return isUpdate + } + + /** + * 更新添加的评论点赞状态 + */ + private fun updateAddedCommentLike(commentId: Int, isLike: Boolean): Boolean { + var isUpdate = false + addedCommentList = addedCommentList.map { + if (it.id == commentId) { + isUpdate = true + it.copy(liked = isLike, likes = if (isLike) it.likes + 1 else it.likes - 1) + } else { + it + } + } + return isUpdate + } + + /** + * 更新评论点赞状态 + */ + private fun updateCommentLike(commentId: Int, isLike: Boolean) { + commentsList = commentsList.map { comment -> + if (comment.id == commentId) { + comment.copy( + liked = isLike, + likes = if (isLike) comment.likes + 1 else comment.likes - 1 + ) + } else { + // 可能是回复的评论 + comment.copy(reply = comment.reply.map { replyComment -> + if (replyComment.id == commentId) { + replyComment.copy( + liked = isLike, + likes = if (isLike) replyComment.likes + 1 else replyComment.likes - 1 + ) + } else { + replyComment + } + }) + } + } + } + + // 用于防止重复点赞的状态集合 + private val _pendingLikeOperations = mutableSetOf() + + /** + * 点赞评论 - 使用乐观更新策略 + */ + suspend fun likeComment(commentId: Int) { + // 防止重复操作 + if (_pendingLikeOperations.contains(commentId)) { + return + } + + _pendingLikeOperations.add(commentId) + + try { + // 乐观更新:先更新UI状态 + val previousState = getCurrentCommentLikeState(commentId) + updateCommentLikeState(commentId, true) + + // 然后调用API + commentService.likeComment(commentId) + } catch (e: Exception) { + e.printStackTrace() + // 如果API调用失败,回滚UI状态 + updateCommentLikeState(commentId, false) + } finally { + _pendingLikeOperations.remove(commentId) + } + } + + /** + * 取消点赞评论 - 使用乐观更新策略 + */ + suspend fun unlikeComment(commentId: Int) { + // 防止重复操作 + if (_pendingLikeOperations.contains(commentId)) { + return + } + + _pendingLikeOperations.add(commentId) + + try { + // 乐观更新:先更新UI状态 + val previousState = getCurrentCommentLikeState(commentId) + updateCommentLikeState(commentId, false) + + // 然后调用API + commentService.dislikeComment(commentId) + } catch (e: Exception) { + e.printStackTrace() + // 如果API调用失败,回滚UI状态 + updateCommentLikeState(commentId, true) + } finally { + _pendingLikeOperations.remove(commentId) + } + } + + /** + * 获取当前评论的点赞状态 + */ + private fun getCurrentCommentLikeState(commentId: Int): Boolean { + // 检查高亮评论 + highlightComment?.let { comment -> + if (comment.id == commentId) { + return comment.liked + } + comment.reply.find { it.id == commentId }?.let { return it.liked } + } + + // 检查添加的评论列表 + addedCommentList.find { it.id == commentId }?.let { return it.liked } + + // 检查分页数据(这里简化处理,实际项目中可能需要更复杂的逻辑) + return false + } + + /** + * 统一的评论点赞状态更新方法 + */ + private fun updateCommentLikeState(commentId: Int, isLike: Boolean) { + // 更新高亮评论点赞状态 + if (updateHighlightCommentLike(commentId, isLike)) { + return + } + // 更新添加的评论点赞状态 + if (updateAddedCommentLike(commentId, isLike)) { + return + } + // 更新评论点赞状态 + updateCommentLike(commentId, isLike) + } + + suspend fun createComment( + content: String, + parentCommentId: Int? = null, + replyUserId: Int? = null, + replyCommentId: Int? = null + ) { + val comment = + commentService.createComment( + postId = postId.toInt(), + content = content, + parentCommentId = parentCommentId, + replyUserId = replyUserId, + replyCommentId = replyCommentId + ) +// TimelineMomentViewModel.updateCommentCount(postId.toInt()) + // add to first + addedCommentList = listOf(comment) + addedCommentList + } + + fun deleteComment(commentId: Int) { + viewModelScope.launch { + commentService.DeleteComment(commentId) + // 如果是刚刚创建的评论,则从addedCommentList中删除 + if (addedCommentList.any { it.id == commentId }) { + addedCommentList = addedCommentList.filter { it.id != commentId } + } else { + commentsList = commentsList.filter { it.id != commentId } + } + } + } + + fun loadMoreSubComments(commentId: Int) { + if (highlightComment?.id == commentId) { + // 高亮的评论,更新高亮评论的回复 + highlightComment?.let { + viewModelScope.launch { + val subCommentList = commentService.getComments( + postId = postId.toInt(), + parentCommentId = commentId, + pageNumber = it.replyPage + 1, + pageSize = 3, + ).list + highlightComment = it.copy( + reply = it.reply.plus(subCommentList), + replyPage = it.replyPage + 1 + ) + } + } + } else { + // 普通评论 + viewModelScope.launch { + commentsList = commentsList.map { comment -> + if (comment.id == commentId) { + try { + subCommentLoadingMap[commentId] = true + val subCommentList = commentService.getComments( + postId = postId.toInt(), + parentCommentId = commentId, + pageNumber = comment.replyPage + 1, + pageSize = 3, + order = "earliest" + ).list + return@map comment.copy( + reply = comment.reply.plus(subCommentList), + replyPage = comment.replyPage + 1 + ) + } catch (e: Exception) { + return@map comment.copy() + } finally { + subCommentLoadingMap[commentId] = false + } + } + comment + } + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/post/NewPost.kt b/app/src/main/java/com/aiosman/ravenow/ui/post/NewPost.kt new file mode 100644 index 0000000..93fbb63 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/post/NewPost.kt @@ -0,0 +1,617 @@ +package com.aiosman.ravenow.ui.post + +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon + +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.lifecycle.viewModelScope +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.DraggableGrid +import com.aiosman.ravenow.ui.composables.RelPostCard +import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout +import com.aiosman.ravenow.ui.composables.rememberDebouncer +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.launch +import java.io.File + +/** + * 发布动态 + */ +@Preview +@Composable +fun NewPostScreen() { + val AppColors = LocalAppTheme.current + var isAiEnabled by remember { mutableStateOf(false) } + var isRotating by remember { mutableStateOf(false) } + var isRequesting by remember { mutableStateOf(false) } + val keyboardController = LocalSoftwareKeyboardController.current + + val model = NewPostViewModel + val systemUiController = rememberSystemUiController() + LaunchedEffect(Unit) { + systemUiController.setNavigationBarColor(color = Color.Transparent) + model.init() + } + + + StatusBarMaskLayout( + darkIcons = !AppState.darkMode, + modifier = Modifier + .fillMaxSize() + .background( + AppColors.background + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) + ) { + NewPostTopBar { + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + model.relMoment?.let { + Text("Share with") + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(color = AppColors.basicMain) + .padding(24.dp) + ) { + RelPostCard( + momentEntity = it, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + AddImageGrid() + NewPostTextField(stringResource(R.string.moment_content_hint), NewPostViewModel.textContent) { + NewPostViewModel.textContent = it + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(AppColors.divider) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.mipmap.rider_pro_moment_ai), + contentDescription = null, + modifier = Modifier + .size(24.dp) + + ) + Text( + text = stringResource(R.string.moment_ai_co), + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + modifier = Modifier + .padding(start = 8.dp) + .weight(1f), + color = AppColors.text, + ) + Switch( + checked = isAiEnabled, + onCheckedChange = { + isChecked -> + isAiEnabled = isChecked + if (isChecked) { + // 收起键盘 + keyboardController?.hide() + isRequesting = true + isRotating = true + model.viewModelScope.launch { + try { + model.agentMoment(model.textContent) + } catch (e: Exception) { + e.printStackTrace() + }finally { + isRequesting = false + isRotating = false + isAiEnabled = false + } + } + + } else { + + } + }, + enabled = !isRequesting && model.textContent.isNotEmpty(), + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = AppColors.brandColorsColor, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = AppColors.nonActive, + uncheckedBorderColor = Color.White, + disabledCheckedTrackColor = AppColors.brandColorsColor.copy(alpha = 0.8f), + disabledCheckedThumbColor= Color.White, + disabledUncheckedTrackColor = AppColors.nonActive, + disabledUncheckedThumbColor= Color.White + + ), + modifier = Modifier.scale(0.8f) + + ) + } + + Column( + modifier = Modifier.fillMaxWidth() + ) { + BasicTextField( + value = model.aiTextContent, + onValueChange = { newValue -> + model.aiTextContent = newValue + }, + modifier = Modifier + .height(160.dp) + .heightIn(160.dp) + .padding(horizontal = 16.dp, vertical = 10.dp) + .fillMaxWidth(), + cursorBrush = SolidColor(AppColors.text), + textStyle = TextStyle( + lineHeight = 24.sp, + color = AppColors.text, + ), + readOnly = true + ) + if (model.aiTextContent.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.End // 靠右对齐 + ) { + // 删除按钮 + Row( + modifier = Modifier + .noRippleClickable { + model.aiTextContent = "" + } + .background( + color = AppColors.basicMain, + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_moment_delete), + contentDescription = "delete", + modifier = Modifier.size(16.dp), + tint = AppColors.text + ) + Text( + text = stringResource(R.string.moment_ai_delete), + fontSize = 12.sp, + color = AppColors.text, + modifier = Modifier.padding(start = 4.dp) + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + //应用生成文案 + Row( + modifier = Modifier + .noRippleClickable { + if (model.aiTextContent.isNotEmpty()) { + model.textContent = model.aiTextContent + } + } + .background( + color = AppColors.basicMain, + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_moment_apply), + contentDescription = "apply", + modifier = Modifier.size(16.dp), + tint = AppColors.text + ) + Text( + text = stringResource(R.string.moment_ai_apply), + fontSize = 12.sp, + color = AppColors.text, + modifier = Modifier.padding(start = 4.dp) + ) + } + } + } + } + + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewPostTopBar(onSendClick: () -> Unit = {}) { + val AppColors = LocalAppTheme.current + + var uploading by remember { mutableStateOf(false) } + var lastBackClickTime by remember { mutableStateOf(0L) } + var lastSendClickTime by remember { mutableStateOf(0L) } + val debounceTime = 500L // 500毫秒防抖时间 + // 上传进度 + if (uploading) { + BasicAlertDialog( + onDismissRequest = { }, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(64.dp)).shadow(elevation = 4.dp) + .background(AppColors.background).padding(16.dp), + contentAlignment = Alignment.CenterStart + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = AppColors.main + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Uploading", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = AppColors.text) + } + } + } + val navController = LocalNavController.current + val context = LocalContext.current + val model = NewPostViewModel + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 10.dp) + ) { + Row( + modifier = Modifier.align(Alignment.CenterStart), + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_close), + contentDescription = "Back", + modifier = Modifier + .size(24.dp) + .noRippleClickable { + // 添加防抖逻辑 + val currentTime = System.currentTimeMillis() + if (currentTime - lastSendClickTime > debounceTime) { + lastSendClickTime = currentTime + navController.popBackStack() + } + }, + colorFilter = ColorFilter.tint(AppColors.text) + ) + Spacer(modifier = Modifier.weight(1f)) + Image( + painter = painterResource(id = R.mipmap.rider_pro_moment_post), + contentDescription = "Send", + modifier = Modifier + .size(24.dp) + .noRippleClickable { + // 检查输入 + val errorMessage = model.validateMoment() + if (errorMessage != null) { + Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show() + return@noRippleClickable + } + model.viewModelScope.launch { + try { + uploading = true + model.createMoment(context = context) { progress -> + // 更新进度条 + } + navController.popBackStack() + }catch (e:Exception) { + e.printStackTrace() + }finally { + uploading = false + } + + + + } + } + ) + } + + } +} + +@Composable +fun NewPostTextField(hint: String, value: String, onValueChange: (String) -> Unit) { + val AppColors = LocalAppTheme.current + + Box(modifier = Modifier.fillMaxWidth()) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .heightIn(160.dp) + .padding(horizontal = 16.dp, vertical = 10.dp), + cursorBrush = SolidColor(AppColors.text), + textStyle = TextStyle( + lineHeight = 24.sp, + color = AppColors.text, + + ), + + ) + if (value.isEmpty()) { + Text( + text = hint, + color = AppColors.inputHint, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp) + ) + } + } +} + + +@Composable +fun AddImageGrid() { + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + val context = LocalContext.current + val model = NewPostViewModel + val scope = model.viewModelScope + val pickImagesLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents() + ) { uris -> + if (uris.isNotEmpty()) { + scope.launch { + val currentCount = model.imageList.size + val remainingSlots = 9 - currentCount + + if (remainingSlots <= 0) { + Toast.makeText(context, "最多只能选择9张图片", Toast.LENGTH_SHORT).show() + return@launch + } + + val urisToProcess = if (uris.size > remainingSlots) { + Toast.makeText(context, "已选择${uris.size}张图片,但只能添加前${remainingSlots}张", Toast.LENGTH_SHORT).show() + uris.take(remainingSlots) + } else { + uris + } + + for (uri in urisToProcess) { + ImageItem.fromUri(context, uri.toString())?.let { + model.imageList += it + } + } + } + } + } + + val takePictureLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture() + ) { success -> + if (success) { + scope.launch { + if (model.imageList.size >= 9) { + Toast.makeText(context, "最多只能选择9张图片", Toast.LENGTH_SHORT).show() + return@launch + } + + ImageItem.fromUri(context, model.currentPhotoUri.toString())?.let { + model.imageList += it + } + } + } + } + + val addImageDebouncer = rememberDebouncer() + val takePhotoDebouncer = rememberDebouncer() + + val stroke = Stroke( + width = 2f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) + ) + DraggableGrid( + items = NewPostViewModel.imageList, + onMove = { from, to -> + NewPostViewModel.imageList = NewPostViewModel.imageList.toMutableList().apply { + add(to, removeAt(from)) + } + }, + lockedIndices = listOf( + + ), + onDragModeEnd = {}, + onDragModeStart = {}, + additionalItems = listOf( + + ), + getItemId = { it.id } + ) { item, isDrag -> + Box( + modifier = Modifier + ) { + CustomAsyncImage( + LocalContext.current, + item.bitmap, + contentDescription = "Image", + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .noRippleClickable { + navController.navigate(NavigationRoute.NewPostImageGrid.route) + }, + contentScale = ContentScale.Crop + ) + if (isDrag) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)) + ) + } + } + } + val canAddMoreImages = model.imageList.size < 9 + + LazyVerticalGrid( + columns = GridCells.Fixed(5), + contentPadding = PaddingValues(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (canAddMoreImages) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) // 设置圆角 + .background(AppColors.basicMain) // 设置背景色 + .noRippleClickable { + addImageDebouncer { + if (model.imageList.size < 9) { + pickImagesLauncher.launch("image/*") + } else { + Toast.makeText( + context, + "最多只能选择9张图片", + Toast.LENGTH_SHORT + ).show() + } + } + }, + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_new_post_add_pic), + contentDescription = "Add Image", + modifier = Modifier + .size(24.dp) + .align(Alignment.Center), + tint = AppColors.nonActiveText + + ) + } + } + item { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) // 设置圆角 + .background(AppColors.basicMain) // 设置背景色 + .noRippleClickable { + if (model.imageList.size < 9) { + val photoFile = File(context.cacheDir, "photo.jpg") + val photoUri: Uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + photoFile + ) + model.currentPhotoUri = photoUri + takePictureLauncher.launch(photoUri) + } else { + Toast.makeText(context, "最多只能选择9张图片", Toast.LENGTH_SHORT).show() + } + }, + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_camera), + contentDescription = "Take Photo", + modifier = Modifier + .size(24.dp) + .align(Alignment.Center), + tint = AppColors.nonActiveText + ) + } + } + } + } +} + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/post/NewPostImageGrid.kt b/app/src/main/java/com/aiosman/ravenow/ui/post/NewPostImageGrid.kt new file mode 100644 index 0000000..c6b4cc9 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/post/NewPostImageGrid.kt @@ -0,0 +1,117 @@ +package com.aiosman.ravenow.ui.post + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.rememberAsyncImagePainter +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.google.accompanist.systemuicontroller.rememberSystemUiController + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun NewPostImageGridScreen() { + val model = NewPostViewModel + val imageList = model.imageList + val pagerState = rememberPagerState(pageCount = { imageList.size }) + val systemUiController = rememberSystemUiController() + val paddingValues = WindowInsets.systemBars.asPaddingValues() + val navController = LocalNavController.current + val title = "${pagerState.currentPage + 1}/${imageList.size}" + + LaunchedEffect(Unit) { + systemUiController.setStatusBarColor(Color.Transparent, darkIcons = false) + } + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF2e2e2e)) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + Column { + Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding())) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + contentDescription = "back", + modifier = Modifier + .size(24.dp) + .noRippleClickable { + navController.popBackStack() + }, + tint = Color.White + ) + Text( + title, + color = Color.White, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontSize = 18.sp, + ) + Icon( + Icons.Default.Delete, + contentDescription = "delete", + modifier = Modifier + .size(24.dp) + .noRippleClickable { + model.deleteImage(pagerState.currentPage) + }, + tint = Color.White + ) + } + + } + + } + HorizontalPager( + state = pagerState, + ) { page -> + val imageUrl = imageList[page] + Image( + painter = rememberAsyncImagePainter(model = imageUrl.bitmap), + contentDescription = "Image $page", + modifier = Modifier.fillMaxSize() + ) + } + } + + + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/post/NewPostViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/post/NewPostViewModel.kt new file mode 100644 index 0000000..cda495f --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/post/NewPostViewModel.kt @@ -0,0 +1,202 @@ +package com.aiosman.ravenow.ui.post + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.ExifInterface +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.aiosman.ravenow.data.MomentService +import com.aiosman.ravenow.data.ServiceException +import com.aiosman.ravenow.data.UploadImage +import com.aiosman.ravenow.data.api.ApiClient +import com.aiosman.ravenow.entity.AccountProfileEntity +import com.aiosman.ravenow.entity.AgentEntity +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.entity.MomentServiceImpl +import com.aiosman.ravenow.entity.createMultipartBody +import com.aiosman.ravenow.event.MomentAddEvent +import com.aiosman.ravenow.exp.rotate +import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel +import com.aiosman.ravenow.ui.modification.Modification +import com.aiosman.ravenow.utils.FileUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.greenrobot.eventbus.EventBus +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.util.UUID + +data class ImageItem( + val uri: String, + val id: String, + val bitmap: Bitmap, + val file: File +) { + companion object { + fun fromUri(context: Context,uri: String): ImageItem? { + // 保存图片文件到临时文件夹 + context.contentResolver.openInputStream(Uri.parse(uri))?.use { inputStream -> + val tempFileName = UUID.randomUUID().toString() + val tempFile = File.createTempFile(tempFileName, null, context.cacheDir) + FileOutputStream(tempFile).use { outputStream -> + inputStream.copyTo(outputStream) + } + + // 读取图片文件为 Bitmap + var bitmap = BitmapFactory.decodeFile(tempFile.absolutePath) + // 读取文件exif,修正旋转 + val exif = ExifInterface(tempFile.absolutePath) + bitmap = when (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) { + ExifInterface.ORIENTATION_ROTATE_90 -> bitmap.rotate(90) + ExifInterface.ORIENTATION_ROTATE_180 -> bitmap.rotate(180) + ExifInterface.ORIENTATION_ROTATE_270 -> bitmap.rotate(270) + else -> bitmap + } + // 保存 bitmap 到临时文件夹 + try { + val savedBitmapFilename = UUID.randomUUID().toString() + val bitmapFile = File.createTempFile(savedBitmapFilename, ".jpg", context.cacheDir) + FileOutputStream(bitmapFile).use { os -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, os) + } + // 清理临时文件 + tempFile.delete() + return ImageItem( + uri = Uri.fromFile(bitmapFile).toString(), + id = savedBitmapFilename, + bitmap = bitmap, + file = bitmapFile + ) + } catch (e: IOException) { + Log.e("NewPost", "Failed to save bitmap to file", e) + null + } + } + return null + } + } +} +data class DraftImageItem( + val uri: String, + val id: String, + val filename: String +){ + companion object { + fun fromImageItem(imageItem: ImageItem): DraftImageItem { + return DraftImageItem(imageItem.uri, imageItem.id, imageItem.file.name) + } + } +} +data class Draft( + val textContent: String, + val imageList: List +) +object NewPostViewModel : ViewModel() { + var momentService: MomentService = MomentServiceImpl() + var textContent by mutableStateOf("") + var aiTextContent by mutableStateOf("") + var searchPlaceAddressResult by mutableStateOf(null) + var modificationList by mutableStateOf>(listOf()) + var imageList by mutableStateOf(listOf()) + var relPostId by mutableStateOf(null) + var relMoment by mutableStateOf(null) + var currentPhotoUri: Uri? = null + var draft: Draft? = null + // watch textContent change and save draft +// fun saveDraft() { +// draft = Draft(textContent, imageList.map { +// DraftImageItem(it.uri, it.id, it.bitmap) +// }) +// } + fun asNewPost() { + textContent = "" + aiTextContent = "" + searchPlaceAddressResult = null + modificationList = listOf() + imageList = listOf() + relPostId = null + } + + fun asNewPostWithImageUris(imageUris: List) { + textContent = "" + searchPlaceAddressResult = null + modificationList = listOf() +// imageList = imageUris.map { +// ImageItem(it, UUID.randomUUID().toString()) +// } + relPostId = null + } + + suspend fun uriToFile(context: Context, uri: Uri): File { + val inputStream: InputStream? = context.contentResolver.openInputStream(uri) + val tempFile = withContext(Dispatchers.IO) { + File.createTempFile("temp", null, context.cacheDir) + } + inputStream?.use { input -> + FileOutputStream(tempFile).use { output -> + input.copyTo(output) + } + } + return tempFile + } + + fun validateMoment(): String? { + if (imageList.isEmpty()) { + return "Please select at least one image" + } + return null + } + + suspend fun createMoment(context: Context, onUploadProgress: (Float) -> Unit) { + val uploadImageList = emptyList().toMutableList() + var index = 0 + for (item in imageList) { + // 保存图片到本地 + FileUtil.bitmapToJPG(context, item.bitmap, UUID.randomUUID().toString()) + ?.let { savedImageUri -> + // 读取保存的图片文件 + uriToFile(context, savedImageUri).let { file -> + uploadImageList += UploadImage(file, file.name, item.uri, "jpg") + } + } + // 在上传过程中调用 onUploadProgress 更新进度 + onUploadProgress(((index / imageList.size).toFloat())) // progressValue 是当前上传进度,例如 0.5 表示 50% + index += 1 + } + aiTextContent = "" + val result = momentService.createMoment(textContent, 1, uploadImageList, relPostId) + // 刷新个人动态 + MyProfileViewModel.loadProfile(pullRefresh = true) + EventBus.getDefault().post(MomentAddEvent(result)) + } + + suspend fun agentMoment(textContent: String,) { + aiTextContent = momentService.agentMoment(textContent) + } + + suspend fun init() { + relPostId?.let { + val moment = momentService.getMomentById(it) + relMoment = moment + } + } + + fun deleteImage(index: Int) { + if (index >= 0 && index < imageList.size) { + imageList = imageList.toMutableList().apply { + removeAt(index) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/post/Post.kt b/app/src/main/java/com/aiosman/ravenow/ui/post/Post.kt new file mode 100644 index 0000000..8316153 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/post/Post.kt @@ -0,0 +1,2149 @@ +package com.aiosman.ravenow.ui.post + +import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.ConstVars +import com.aiosman.ravenow.GuestLoginCheckOut +import com.aiosman.ravenow.GuestLoginCheckOutScene +import com.aiosman.ravenow.LocalAppTheme +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.R +import com.aiosman.ravenow.data.CommonService +import com.aiosman.ravenow.data.CommonServiceImpl +import com.aiosman.ravenow.data.DictService +import com.aiosman.ravenow.data.DictServiceImpl +import com.aiosman.ravenow.data.ReportReasonList +import com.aiosman.ravenow.entity.CommentEntity +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.entity.MomentImageEntity +import com.aiosman.ravenow.entity.ReportReasons +import com.aiosman.ravenow.exp.formatPostTime +import com.aiosman.ravenow.exp.timeAgo +import com.aiosman.ravenow.ui.NavigationRoute +import com.aiosman.ravenow.ui.comment.NoticeScreenHeader +import com.aiosman.ravenow.ui.composables.ActionButton +import com.aiosman.ravenow.ui.composables.AnimatedFavouriteIcon +import com.aiosman.ravenow.ui.composables.AnimatedLikeIcon +import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder +import com.aiosman.ravenow.ui.composables.CustomAsyncImage +import com.aiosman.ravenow.ui.composables.CustomClickableText +import com.aiosman.ravenow.ui.composables.EditCommentBottomModal +import com.aiosman.ravenow.ui.composables.FollowButton +import com.aiosman.ravenow.ui.composables.StatusBarSpacer +import com.aiosman.ravenow.ui.modifiers.noRippleClickable +import com.aiosman.ravenow.ui.composables.debouncedClickable +import com.aiosman.ravenow.ui.composables.rememberDebouncedNavigation +import com.aiosman.ravenow.utils.FileUtil.saveImageToGallery +import com.google.gson.Gson +import kotlinx.coroutines.launch +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable + +/** + * 动态详情 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PostScreen( + id: String, + highlightCommentId: Int?, + initImagePagerIndex: Int? +) { + val viewModel = viewModel( + key = "PostViewModel_$id", + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + Log.d("PostViewModel", "Create PostViewModel with id: $id") + return PostViewModel(id) as T + } + } + ) + val commentsViewModel = viewModel.commentsViewModel + val scope = rememberCoroutineScope() + val navController = LocalNavController.current + val debouncedNavigation = rememberDebouncedNavigation() + var showCommentMenu by remember { mutableStateOf(false) } + var contextComment by remember { mutableStateOf(null) } + var replyComment by remember { mutableStateOf(null) } + var showCommentModal by remember { mutableStateOf(false) } + var commentModalState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var editCommentModalState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + var showReportDialog by remember { mutableStateOf(false) } + val AppColors = LocalAppTheme.current + + LaunchedEffect(Unit) { + viewModel.initData( + highlightCommentId = highlightCommentId.let { + if (it == 0) null else it + } + ) + } + + if (showCommentMenu) { + ModalBottomSheet( + onDismissRequest = { + showCommentMenu = false + }, + containerColor = AppColors.background, + sheetState = commentModalState, + dragHandle = {}, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + windowInsets = WindowInsets(0) + ) { + CommentMenuModal( + onDeleteClick = { + scope.launch { + commentModalState.hide() + showCommentMenu = false + } + contextComment?.let { + viewModel.deleteComment(it.id) + } + }, + commentEntity = contextComment, + onCloseClick = { + scope.launch { + commentModalState.hide() + showCommentMenu = false + } + }, + isSelf = AppState.UserId?.toLong() == contextComment?.author, + onLikeClick = { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) { + scope.launch { + commentModalState.hide() + showCommentMenu = false + } + debouncedNavigation { + debouncedNavigation { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } + } + } else { + scope.launch { + commentModalState.hide() + showCommentMenu = false + } + contextComment?.let { + // 防抖机制已在ViewModel中实现 + viewModel.viewModelScope.launch { + if (it.liked) { + viewModel.unlikeComment(it.id) + } else { + viewModel.likeComment(it.id) + } + } + } + } + + }, + onReplyClick = { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) { + scope.launch { + commentModalState.hide() + showCommentMenu = false + } + debouncedNavigation { + debouncedNavigation { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } + } + } else { + scope.launch { + commentModalState.hide() + showCommentMenu = false + replyComment = contextComment + showCommentModal = true + } + } + } + ) + } + } + if (showCommentModal) { + ModalBottomSheet( + onDismissRequest = { + showCommentModal = false + }, + containerColor = AppColors.background, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), + dragHandle = {}, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + windowInsets = WindowInsets(0) + ) { + EditCommentBottomModal(replyComment) { + viewModel.viewModelScope.launch { + if (replyComment != null) { + if (replyComment?.parentCommentId != null) { + // 第三级评论 + viewModel.createComment( + content = it, + parentCommentId = replyComment?.parentCommentId, + replyUserId = replyComment?.author?.toInt(), + replyCommentId = replyComment?.id + ) + } else { + // 子级评论 + viewModel.createComment( + content = it, + parentCommentId = replyComment?.id, + replyCommentId = replyComment?.id + ) + } + } else { + // 顶级评论 + viewModel.createComment( + content = it + ) + } + + editCommentModalState.hide() + showCommentModal = false + } + + } + } + } + if (showReportDialog && viewModel.moment != null) { + ModalBottomSheet( + onDismissRequest = { + showReportDialog = false + }, + containerColor = AppColors.background, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), + dragHandle = {}, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + + ) { + ReportModal( + momentId = viewModel.moment!!.id, + onClose = { + showReportDialog = false + } + ) + } + } + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + if (!viewModel.isError) { + PostBottomBar( + onLikeClick = { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) { + debouncedNavigation { + debouncedNavigation { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } + } + } else { + scope.launch { + if (viewModel.moment?.liked == true) { + viewModel.dislikeMoment() + } else { + viewModel.likeMoment() + } + } + } + }, + onCreateCommentClick = { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) { + debouncedNavigation { + debouncedNavigation { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } + } + } else { + replyComment = null + showCommentModal = true + } + }, + onFavoriteClick = { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) { + debouncedNavigation { + debouncedNavigation { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } + } + } else { + scope.launch { + if (viewModel.moment?.isFavorite == true) { + viewModel.unfavoriteMoment() + } else { + viewModel.favoriteMoment() + } + } + } + }, + momentEntity = viewModel.moment + ) + } + } + ) { + it + Column( + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) + ) { + StatusBarSpacer() + if (viewModel.isError) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + NoticeScreenHeader("Post", moreIcon = false) + } + + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Umm, post are not found.", + style = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + color = AppColors.text + ) + } + } else { + Header( + avatar = viewModel.avatar, + nickname = viewModel.nickname, + userId = viewModel.moment?.authorId, + isFollowing = viewModel.moment?.followStatus == true, + onFollowClick = { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.FOLLOW_USER)) { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } else { + scope.launch { + if (viewModel.moment?.followStatus == true) { + viewModel.unfollowUser() + } else { + viewModel.followUser() + } + } + } + }, + onDeleteClick = { + viewModel.deleteMoment { + debouncedNavigation { + navController.navigateUp() + } + } + }, + onReportClick = { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.REPORT_CONTENT)) { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } else { + showReportDialog = true + } + } + ) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .animateContentSize( + animationSpec = tween( + durationMillis = 500 + ) + ) + ) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(383f / 527f) + ) { + PostImageView( + viewModel.moment?.images ?: emptyList(), + initialPage = initImagePagerIndex + ) + } + PostDetails( + viewModel.moment + ) + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource( + R.string.comment_count, + (viewModel.moment?.commentCount ?: 0) + ), fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = AppColors.nonActiveText + ) + Spacer(modifier = Modifier.weight(1f)) + OrderSelectionComponent() { + commentsViewModel.order = it + viewModel.reloadComment() + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + item { + CommentContent( + viewModel = commentsViewModel, + onLongClick = { comment -> + showCommentMenu = true + contextComment = comment + }, + onReply = { parentComment, _, _, _ -> + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) { + debouncedNavigation { + debouncedNavigation { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } + } + } else { + replyComment = parentComment + showCommentModal = true + } + } + ) + } + item { + Spacer(modifier = Modifier.height(120.dp)) + } + + } + } + + } + } + +} + +@Composable +fun CommentContent( + viewModel: CommentsViewModel, + onLongClick: (CommentEntity) -> Unit, + onReply: (CommentEntity, Long?, String?, String?) -> Unit +) { + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + val debouncedNavigation = rememberDebouncedNavigation() + + val commentsList = viewModel.commentsList + val addedTopLevelComment = viewModel.addedCommentList.filter { + it.parentCommentId == null + } + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .animateContentSize(animationSpec = tween(durationMillis = 500)) + ) { + viewModel.highlightComment?.let { + CommentItem( + it, + onLike = { comment -> + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) { + debouncedNavigation { + debouncedNavigation { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } + } + } else { + // 使用防抖机制避免重复点击 + viewModel.viewModelScope.launch { + if (comment.liked) { + viewModel.unlikeComment(comment.id) + } else { + viewModel.likeComment(comment.id) + } + } + } + }, + onLongClick = { comment -> + onLongClick(comment) + }, + onReply = { parentComment, _, _, _ -> + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) { + debouncedNavigation { + debouncedNavigation { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } + } + } else { + onReply( + parentComment, + parentComment.author, + parentComment.name, + parentComment.avatar + ) + } + }, + onLoadMoreSubComments = { + viewModel.viewModelScope.launch { + viewModel.loadMoreSubComments(it.id) + } + }, + + ) + } + + } + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .animateContentSize(animationSpec = tween(durationMillis = 500)) + ) { + for (item in addedTopLevelComment) { + AnimatedVisibility( + visible = true, + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically() + ) { + Box { + CommentItem( + item, + onLike = { comment -> + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) { + debouncedNavigation { + debouncedNavigation { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } + } + } else { + // 防抖机制已在ViewModel中实现 + viewModel.viewModelScope.launch { + if (comment.liked) { + viewModel.unlikeComment(comment.id) + } else { + viewModel.likeComment(comment.id) + } + } + } + }, + onLongClick = { comment -> + onLongClick(comment) + }, + onReply = { parentComment, _, _, _ -> + onReply( + parentComment, + parentComment.author, + parentComment.name, + parentComment.avatar + ) + }, + onLoadMoreSubComments = { + viewModel.viewModelScope.launch { + viewModel.loadMoreSubComments(it.id) + } + }, + addedCommentList = viewModel.addedCommentList + ) + } + } + } + } + + commentsList.forEach { item -> + if ( + item.id != viewModel.highlightCommentId && + viewModel.addedCommentList.none { it.id == item.id } + ) { + AnimatedVisibility( + visible = true, + enter = slideInVertically(), + exit = slideOutVertically() + ) { + Box( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + CommentItem( + item, + onLike = { comment -> + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) { + debouncedNavigation { + debouncedNavigation { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } + } + } else { + // 防抖机制已在ViewModel中实现 + viewModel.viewModelScope.launch { + if (comment.liked) { + viewModel.unlikeComment(comment.id) + } else { + viewModel.likeComment(comment.id) + } + } + } + }, + onLongClick = { comment -> + onLongClick(comment) + }, + onReply = { parentComment, _, _, _ -> + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) { + debouncedNavigation { + debouncedNavigation { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } + } + } else { + onReply( + parentComment, + parentComment.author, + parentComment.name, + parentComment.avatar + ) + } + }, + onLoadMoreSubComments = { + viewModel.viewModelScope.launch { + viewModel.loadMoreSubComments(it.id) + } + }, + addedCommentList = viewModel.addedCommentList + ) + } + } + } + } + + // 加载状态处理 + if (viewModel.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .height(120.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + LinearProgressIndicator( + modifier = Modifier.width(160.dp), + color = AppColors.main + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Loading...", + fontSize = 14.sp + ) + } + } + return + } + + // 错误状态处理 + if (viewModel.hasError) { + Box( + modifier = Modifier + .fillMaxSize() + .height(120.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Failed to load comments, click to retry", + fontSize = 14.sp, + modifier = Modifier.noRippleClickable { + viewModel.reloadComment() + } + ) + } + return + } + + // 评论为空 + if (commentsList.isEmpty() && addedTopLevelComment.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 1.dp), + contentAlignment = Alignment.TopCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(id = R.mipmap.invalid_name_3), + contentDescription = null, + modifier = Modifier.size(181.dp) + ) + Spacer(modifier = Modifier.size(24.dp)) + androidx.compose.material.Text( + text = "没有人说话…", + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.size(8.dp)) + androidx.compose.material.Text( + text = "留下评论,等AI和朋友回应你吧", + color = AppColors.secondaryText, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Header( + avatar: String?, + nickname: String?, + userId: Int?, + isFollowing: Boolean, + onFollowClick: () -> Unit, + onDeleteClick: () -> Unit = {}, + onReportClick: () -> Unit = {} +) { + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + val context = LocalContext.current + val debouncedNavigation = rememberDebouncedNavigation() + var expanded by remember { mutableStateOf(false) } + if (expanded) { + ModalBottomSheet( + onDismissRequest = { + expanded = false + }, + containerColor = AppColors.background, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), + dragHandle = {}, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + windowInsets = WindowInsets(0) + ) { + PostMenuModal( + + onDeleteClick = { + onDeleteClick() + expanded = false + }, + onReportClick = { + onReportClick() + expanded = false + }, + userId = userId + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_back_icon), // Replace with your image resource + contentDescription = "Back", + modifier = Modifier + .debouncedClickable { + debouncedNavigation { + navController.navigateUp() + } + } + .size(24.dp), + colorFilter = ColorFilter.tint(AppColors.text) + ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(AppColors.secondaryText.copy(alpha = 0.1f)) + ) { + CustomAsyncImage( + context, + avatar, + contentDescription = "Profile Picture", + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .debouncedClickable(debounceTime = 1000L) { + userId?.let { + debouncedNavigation { + navController.navigate( + NavigationRoute.AccountProfile.route.replace( + "{id}", + userId.toString() + ) + ) + } + } + }, + contentScale = ContentScale.Crop + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = nickname ?: "", + fontWeight = FontWeight.Bold, + modifier = Modifier + .weight(1f) + .debouncedClickable(debounceTime = 1000L) { + userId?.let { + debouncedNavigation { + navController.navigate( + NavigationRoute.AccountProfile.route.replace( + "{id}", + userId.toString() + ) + ) + } + } + }, + color = AppColors.text, + fontSize = 17.sp + ) + if (AppState.UserId != userId && !isFollowing) { + FollowButton( + isFollowing = false, + onFollowClick = onFollowClick, + fontSize = 12.sp + ) + Spacer(modifier = Modifier.width(8.dp)) + } + + Box { + Image( + modifier = Modifier + .height(24.dp) + .padding(end = 8.dp) + .noRippleClickable { + if(GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.REPORT_CONTENT)){ + debouncedNavigation{ + navController.navigate(NavigationRoute.Login.route) + } + }else { + expanded = true + } + }, + painter = painterResource(id = R.drawable.rider_pro_more_horizon), + contentDescription = "", + colorFilter = ColorFilter.tint(AppColors.text) + ) + } + + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ImageViewerDialog( + images: List, + initialPage: Int, + onDismiss: () -> Unit +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + var currentPage by remember { mutableStateOf(initialPage) } + val navigationBarPaddings = + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp + val showRawImageStates = remember { mutableStateListOf(*Array(images.size) { false }) } + var isDownloading by remember { mutableStateOf(false) } + + BasicAlertDialog( + onDismissRequest = { + onDismiss() + }, + modifier = Modifier.fillMaxWidth(), + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false, + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(0.8f) + ) { + val zoomState = rememberZoomState() + CustomAsyncImage( + context, + if (showRawImageStates[currentPage]) images[currentPage].url else images[currentPage].thumbnail, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .zoomable( + zoomState = zoomState, + onTap = { + onDismiss() + } + ), + contentScale = ContentScale.Fit, + ) + + // Navigation arrows + if (images.size > 1) { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Left arrow + Box( + modifier = Modifier + .size(48.dp) + .noRippleClickable { + if (currentPage > 0) { + currentPage-- + } + }, + contentAlignment = Alignment.Center + ) { + if (currentPage > 0) { + Text("<", color = Color.White, fontSize = 24.sp) + } + } + + // Right arrow + Box( + modifier = Modifier + .size(48.dp) + .noRippleClickable { + if (currentPage < images.size - 1) { + currentPage++ + } + }, + contentAlignment = Alignment.Center + ) { + if (currentPage < images.size - 1) { + Text(">", color = Color.White, fontSize = 24.sp) + } + } + } + } + } + + Box(modifier = Modifier.padding(top = 10.dp, bottom = 10.dp)) { + if (images.size > 1) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xff333333).copy(alpha = 0.6f)) + .padding(vertical = 4.dp, horizontal = 24.dp) + ) { + androidx.compose.material.Text( + text = "${currentPage + 1}/${images.size}", + color = Color.White, + ) + } + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .weight(0.2f) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black + ), + ) + ) + .padding(start = 16.dp, end = 16.dp, bottom = navigationBarPaddings), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 72.dp, end = 72.dp) + .padding(top = 16.dp), + horizontalArrangement = Arrangement.Center + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .noRippleClickable { + if (isDownloading) { + return@noRippleClickable + } + isDownloading = true + scope.launch { + saveImageToGallery(context, images[currentPage].url) + isDownloading = false + } + } + ) { + if (isDownloading) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = Color.White + ) + } else { + androidx.compose.material.Icon( + painter = painterResource(id = R.drawable.rider_pro_download_icon), + contentDescription = "", + modifier = Modifier.size(32.dp), + tint = Color.White + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + androidx.compose.material.Text( + stringResource(R.string.download), + color = Color.White + ) + } + if (!showRawImageStates[currentPage]) { + Spacer(modifier = Modifier.weight(1f)) + } + if (!showRawImageStates[currentPage]) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.noRippleClickable { + showRawImageStates[currentPage] = true + } + ) { + androidx.compose.material.Icon( + painter = painterResource(id = R.drawable.rider_pro_original_raw), + contentDescription = "", + modifier = Modifier.size(32.dp), + tint = Color.White + ) + Spacer(modifier = Modifier.height(4.dp)) + androidx.compose.material.Text( + stringResource(R.string.original), + color = Color.White + ) + } + } + } + } + } + } +} + +@Composable +fun PostImageView( + images: List, + initialPage: Int? = 0 +) { + val context = LocalContext.current + var isImageViewerDialog by remember { mutableStateOf(false) } + var currentImageIndex by remember { mutableStateOf(initialPage ?: 0) } + + DisposableEffect(Unit) { + onDispose { + isImageViewerDialog = false + } + } + + if (isImageViewerDialog) { + ImageViewerDialog( + images = images, + initialPage = currentImageIndex + ) { + isImageViewerDialog = false + } + } + + Column( + modifier = Modifier + ) { + if (images.isNotEmpty()) { + CustomAsyncImage( + context, + images[currentImageIndex].thumbnail, + contentDescription = "Image", + contentScale = ContentScale.Crop, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onTap = { + isImageViewerDialog = true + } + ) + } + .background(Color.Gray.copy(alpha = 0.1f)) + ) + } + + // 图片导航控件 + if (images.size > 1) { + Row( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Previous button + Text( + text = "Previous", + modifier = Modifier + .padding(8.dp) + .noRippleClickable { + if (currentImageIndex > 0) { + currentImageIndex-- + } + }, + color = if (currentImageIndex > 0) Color.Blue else Color.Gray + ) + + // Indicators + Row( + horizontalArrangement = Arrangement.Center + ) { + images.forEachIndexed { index, _ -> + Box( + modifier = Modifier + .size(4.dp) + .clip(CircleShape) + .background( + if (currentImageIndex == index) Color.Red else Color.Gray.copy( + alpha = 0.5f + ) + ) + .padding(4.dp) + ) + if (index < images.size - 1) { + Spacer(modifier = Modifier.width(8.dp)) + } + } + } + + // Next button + Text( + text = "Next", + modifier = Modifier + .padding(8.dp) + .noRippleClickable { + if (currentImageIndex < images.size - 1) { + currentImageIndex++ + } + }, + color = if (currentImageIndex < images.size - 1) Color.Blue else Color.Gray + ) + } + } + } +} + + +@Composable +fun PostDetails( + momentEntity: MomentEntity? +) { + val AppColors = LocalAppTheme.current + Column( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, top = 12.dp) + .fillMaxWidth() + .wrapContentHeight() + ) { + if (!momentEntity?.momentTextContent.isNullOrEmpty()) { + Text( + text = momentEntity?.momentTextContent ?: "", + fontSize = 14.sp, + color = AppColors.text, + ) + } + Text( + modifier = Modifier.padding(top = 16.dp), + text = "${momentEntity?.time?.formatPostTime()}", + color = AppColors.nonActiveText, + fontSize = 12.sp) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CommentItem( + commentEntity: CommentEntity, + isChild: Boolean = false, + onLike: (commentEntity: CommentEntity) -> Unit = {}, + onReply: ( + parentComment: CommentEntity, + replyUserId: Long?, + replyUserNickname: String?, + replyUserAvatar: String? + ) -> Unit = { _, _, _, _ -> }, + onLoadMoreSubComments: ((CommentEntity) -> Unit)? = {}, + onLongClick: (CommentEntity) -> Unit = {}, + addedCommentList: List = emptyList() +) { + val AppColors = LocalAppTheme.current + val context = LocalContext.current + val navController = LocalNavController.current + val debouncedNavigation = rememberDebouncedNavigation() + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Row(modifier = Modifier.padding(vertical = 8.dp)) { + Box( + modifier = Modifier + .size(if (isChild) 24.dp else 32.dp) + .clip(CircleShape) + .background(Color.Gray.copy(alpha = 0.1f)) + ) { + CustomAsyncImage( + context = context, + imageUrl = commentEntity.avatar, + contentDescription = "Comment Profile Picture ${commentEntity.name}", + modifier = Modifier + .size(if (isChild) 24.dp else 32.dp) + .clip(CircleShape) + .debouncedClickable(debounceTime = 1000L) { + debouncedNavigation { + navController.navigate( + NavigationRoute.AccountProfile.route.replace( + "{id}", + commentEntity.author.toString() + ) + ) + } + }, + contentScale = ContentScale.Crop + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Column( + modifier = Modifier + .weight(1f) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onLongClick = { + onLongClick(commentEntity) + } + ) {} + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = commentEntity.name, + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + color = AppColors.text + ) + Column( + horizontalAlignment = Alignment.End + ) { + AnimatedLikeIcon( + liked = commentEntity.liked, + onClick = { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } else { + onLike(commentEntity) + } + }, + modifier = Modifier.size(16.dp) + ) + Text( + text = commentEntity.likes.toString(), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = AppColors.text, + modifier = Modifier.padding(top = 4.dp,end = 4.dp) + ) + } + } + + + Text( + text = commentEntity.comment, + fontSize = 13.sp, + maxLines = Int.MAX_VALUE, + softWrap = true, + lineHeight = 20.sp, + color = AppColors.text, + modifier = Modifier + .fillMaxWidth() + .padding(end = 50.dp) + .padding(top = 0.dp) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onLongClick = { + onLongClick( + commentEntity + ) + }, + ) { + + } + ) + + + Row ( + modifier = Modifier.padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ){ + Text( + text = commentEntity.date.timeAgo(context), + fontSize = 12.sp, + color = Color.Gray + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = stringResource(R.string.reply), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = AppColors.nonActiveText, + modifier = Modifier.noRippleClickable { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } else { + onReply( + commentEntity, + commentEntity.replyUserId, + commentEntity.replyUserNickname, + commentEntity.replyUserAvatar + ) + } + }, + ) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = Modifier + .padding(start = 12.dp + 40.dp) + .animateContentSize( + animationSpec = tween( + durationMillis = 500 + ) + ) + ) { + val addedCommentList = + addedCommentList.filter { it.parentCommentId == commentEntity.id } + addedCommentList.forEach { addedComment -> + CommentItem( + addedComment, + isChild = true, + onLike = onLike, + onReply = onReply, + onLongClick = onLongClick + ) + } + commentEntity.reply.forEach { reply -> + CommentItem( + reply, + isChild = true, + onLike = onLike, + onReply = onReply, + onLongClick = { comment -> + onLongClick(comment) + } + ) + } + if (commentEntity.replyCount > 0 && !isChild && commentEntity.reply.size < commentEntity.replyCount) { + val remaining = commentEntity.replyCount - commentEntity.reply.size + Text( + text = stringResource(R.string.view_more_reply, remaining), + fontSize = 14.sp, + color = AppColors.nonActiveText, + modifier = Modifier.noRippleClickable { + onLoadMoreSubComments?.invoke(commentEntity) + } + ) + } + + } + } +} + + +@Composable +fun PostBottomBar( + onCreateCommentClick: () -> Unit = {}, + onLikeClick: () -> Unit = {}, + onFavoriteClick: () -> Unit = {}, + momentEntity: MomentEntity? +) { + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + val debouncedNavigation = rememberDebouncedNavigation() + + Column( + modifier = Modifier.background( + AppColors.background + ) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(AppColors.inputBackground) + ) { + + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 13.dp) + .background(AppColors.background) + ) { + // grey round box + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(AppColors.inputBackground) + .weight(1f) + .height(31.dp) + .padding(8.dp) + .noRippleClickable { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } else { + onCreateCommentClick() + } + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Image( + ImageVector.vectorResource(R.drawable.rider_pro_new_comment), + contentDescription = "Send", + colorFilter = ColorFilter.tint(AppColors.text), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.post_comment_hint), + fontSize = 12.sp, + color = AppColors.text + ) + } + } + Spacer(modifier = Modifier.width(16.dp)) + AnimatedFavouriteIcon( + isFavourite = momentEntity?.isFavorite == true, + onClick = { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } else { + onFavoriteClick() + } + }, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(4.dp)) + Text(text = momentEntity?.favoriteCount.toString(), color = AppColors.text) + Spacer(modifier = Modifier.width(16.dp)) + AnimatedLikeIcon( + liked = momentEntity?.liked == true, + onClick = { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } else { + onLikeClick() + } + }, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = momentEntity?.likeCount.toString(), color = AppColors.text) + } + BottomNavigationPlaceholder( + color = AppColors.background + ) + } + +} + + +@Composable +fun PostMenuModal( + onDeleteClick: () -> Unit = {}, + onReportClick: () -> Unit = {}, + userId: Int? = 0 +) { + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + val debouncedNavigation = rememberDebouncedNavigation() + + Row( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .background(AppColors.background) + .padding(vertical = 47.dp, horizontal = 20.dp) + ) { + if(AppState.UserId == userId){ + Row( + modifier = Modifier + .size(60.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.padding(end = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .clip(CircleShape) + .noRippleClickable { + onDeleteClick() + } + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_moment_delete), + contentDescription = "", + modifier = Modifier.size(24.dp), + colorFilter = ColorFilter.tint( + AppColors.text + ) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.delete), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = AppColors.text + ) + } + } + } + + Row( + modifier = Modifier + .size(60.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.padding(end = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .clip(CircleShape) + .noRippleClickable { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.REPORT_CONTENT)) { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } else { + onReportClick() + } + } + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_moment_report), + contentDescription = "", + modifier = Modifier.size(24.dp), + colorFilter = ColorFilter.tint( + AppColors.error + ) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.report), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = AppColors.error + ) + } + } + + } +} + + +@Composable +fun MenuActionItem( + icon: Int? = null, + text: String, + content: @Composable() (() -> Unit)? = null, + onClick: () -> Unit +) { + val AppColors = LocalAppTheme.current + + Column( + modifier = Modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .clip(CircleShape) + .noRippleClickable { + onClick() + } + ) { + content?.invoke() + if (icon != null) { + Icon( + painter = painterResource(id = icon), + contentDescription = "", + modifier = Modifier.size(24.dp), + tint = AppColors.text + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = text, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = AppColors.text + ) + } +} + +/** + * 评论菜单弹窗 + */ +@Composable +fun CommentMenuModal( + onDeleteClick: () -> Unit = {}, + commentEntity: CommentEntity? = null, + onCloseClick: () -> Unit = {}, + onLikeClick: () -> Unit = {}, + onReplyClick: () -> Unit = {}, + isSelf: Boolean = false +) { + val clipboard = LocalClipboardManager.current + val AppColors = LocalAppTheme.current + val navController = LocalNavController.current + val debouncedNavigation = rememberDebouncedNavigation() + + fun copyToClipboard() { + commentEntity?.let { + clipboard.setText( + AnnotatedString( + text = it.comment, + ) + ) + } + } + Column( + modifier = Modifier + .fillMaxWidth() + .background(AppColors.background) + .padding(vertical = 24.dp, horizontal = 20.dp) + ) { + Text( + stringResource(R.string.comment), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = AppColors.text + ) + Spacer(modifier = Modifier.height(24.dp)) + commentEntity?.let { + Column( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background( + AppColors.nonActive + ) + + .padding(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + + ) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + ) { + CustomAsyncImage( + imageUrl = it.avatar, + modifier = Modifier.fillMaxSize(), + contentDescription = "Avatar", + ) + } + Spacer(modifier = Modifier.width(8.dp)) + androidx.compose.material.Text( + it.name, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = AppColors.text + ) + } + Spacer(modifier = Modifier.height(4.dp)) + androidx.compose.material.Text( + it.comment, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .padding(start = 32.dp), + overflow = TextOverflow.Ellipsis, + color = AppColors.text + ) + } + Spacer(modifier = Modifier.height(32.dp)) + } + + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isSelf) { + MenuActionItem( + icon = R.drawable.rider_pro_moment_delete, + text = stringResource(R.string.delete) + ) { + onDeleteClick() + } + + Spacer(modifier = Modifier.width(48.dp)) + } + MenuActionItem( + icon = R.drawable.rider_pro_copy, + text = stringResource(R.string.copy) + ) { + copyToClipboard() + onCloseClick() + } + commentEntity?.let { + Spacer(modifier = Modifier.width(48.dp)) + MenuActionItem( + text = stringResource(R.string.like), + content = { + AnimatedLikeIcon( + liked = it.liked, + onClick = { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } else { + onLikeClick() + } + }, + modifier = Modifier.size(24.dp) + ) + } + ) { + onCloseClick() + } + } + //自己也可以回复自己 + Spacer(modifier = Modifier.width(48.dp)) + MenuActionItem( + icon = R.drawable.rider_pro_comment, + text = stringResource(R.string.reply) + ) { + // 检查游客模式,如果是游客则跳转登录 + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) + } + } else { + onReplyClick() + } + } + + } + Spacer(modifier = Modifier.height(48.dp)) + } +} + + + +@Composable +fun OrderSelectionComponent( + onSelected: (String) -> Unit = {} +) { + val AppColors = LocalAppTheme.current + + var selectedOrder by remember { mutableStateOf("like") } + val orders = listOf( + "like" to stringResource(R.string.order_comment_default), + "earliest" to stringResource(R.string.order_comment_earliest), + "latest" to stringResource(R.string.order_comment_latest) + ) + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(AppColors.nonActive) + + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(4.dp) + .clip(RoundedCornerShape(6.dp)) + ) { + orders.forEach { order -> + Box( + modifier = Modifier + .noRippleClickable { + selectedOrder = order.first + onSelected(order.first) + } + .background( + if ( + selectedOrder == order.first + ) AppColors.background else Color.Transparent + ) + .padding(vertical = 2.dp, horizontal = 8.dp), + ) { + Text( + text = order.second, + color = AppColors.text, + fontSize = 12.sp + ) + } + + } + } + } + +} + +@Composable +fun ReportModal( + momentId: Int, + onClose: () -> Unit = {} +) { + val AppColors = LocalAppTheme.current + val commonService : CommonService = CommonServiceImpl() + val scope = rememberCoroutineScope() + val context = LocalContext.current + var reasonMapping by remember { mutableStateOf(mutableMapOf()) } + var result:Boolean? by remember { mutableStateOf(null) } + fun loadReportOptions() { + scope.launch { + val reportOptions = commonService.getReportReasons() + val newReasonMapping :MutableMap = mutableMapOf() + reportOptions.reasons.forEach { option -> + option.getReasonText(context)?.let { + newReasonMapping[option.id] = it + } + } + reasonMapping = newReasonMapping + } + } + fun createReport(code:Int) { + scope.launch { + try { + commonService.createReport( + reportReasonId = code, + reportType = "post", + reportId = momentId + ) + result = true + }catch (e:Exception) { + e.printStackTrace() + result = false + } + + } + } + LaunchedEffect(Unit) { + loadReportOptions() + } + Column( + modifier = Modifier + .fillMaxWidth() + .background(AppColors.background) + .padding(start = 24.dp, end = 24.dp, bottom = 64.dp) + ) { + Box( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 16.dp), + contentAlignment = Alignment.Center + ) { + Text( + stringResource(R.string.report), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = AppColors.text + ) + } + + // divider + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(AppColors.divider) + ) { + + } + + if (result == null) { + + Column( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp, bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + stringResource(R.string.report_title), + fontSize = 20.sp, + color = AppColors.text, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + stringResource(R.string.report_description), + fontSize = 14.sp, + color = AppColors.secondaryText, + textAlign = TextAlign.Center + ) + + } + + // report options,scroll list + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + reasonMapping.forEach { (id, reason) -> + Column( + modifier = Modifier.noRippleClickable { + createReport(id) + } + ) { + // divider + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(AppColors.divider) + ) { + + } + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = reason, + fontSize = 14.sp, + color = AppColors.text + ) + // right icon + Icon( + painter = painterResource(id = R.drawable.rider_pro_nav_next), + contentDescription = "", + modifier = Modifier.size(24.dp), + tint = AppColors.text + ) + + } + } + + } + } + }else{ + Column( + modifier = Modifier.fillMaxWidth().weight(1f) + + ) { + Box( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp, bottom = 24.dp), + contentAlignment = Alignment.Center + ) { + if (result == true) { + Text( + stringResource(R.string.report_success_desc), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = AppColors.text + ) + } + if (result == false) { + Text( + stringResource(R.string.report_fail_desc), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = AppColors.text + ) + } + } + Spacer( + modifier = Modifier.weight(1f) + ) + ActionButton( + text = stringResource(R.string.close), + click = { + onClose() + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/post/PostViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/post/PostViewModel.kt new file mode 100644 index 0000000..e66583b --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/post/PostViewModel.kt @@ -0,0 +1,227 @@ +package com.aiosman.ravenow.ui.post + +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.MomentService +import com.aiosman.ravenow.data.UserService +import com.aiosman.ravenow.data.UserServiceImpl +import com.aiosman.ravenow.entity.AccountProfileEntity +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.entity.MomentServiceImpl +import com.aiosman.ravenow.event.MomentFavouriteChangeEvent +import com.aiosman.ravenow.event.MomentLikeChangeEvent +import com.aiosman.ravenow.event.MomentRemoveEvent +import com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline.TimelineMomentViewModel +import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel +import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe + + +class PostViewModel( + val postId: String +) : ViewModel() { + var service: MomentService = MomentServiceImpl() + var userService: UserService = UserServiceImpl() + + + var accountProfileEntity by mutableStateOf(null) + var moment by mutableStateOf(null) + var accountService: AccountService = AccountServiceImpl() + var commentsViewModel: CommentsViewModel = CommentsViewModel(postId) + var isError by mutableStateOf(false) + var isFirstLoad by mutableStateOf(true) + init { + EventBus.getDefault().register(this) + } + + fun reloadComment() { + commentsViewModel.reloadComment() + } + + suspend fun initData(highlightCommentId: Int? = null) { + if (!isFirstLoad) { + return + } + isFirstLoad = false + try { + moment = service.getMomentById(postId.toInt()) + } catch (e: Exception) { + isError = true + return + } + highlightCommentId?.let { + commentsViewModel.highlightComment(it) + } + commentsViewModel.reloadComment() + + } + + suspend fun likeComment(commentId: Int) { + commentsViewModel.likeComment(commentId) + } + + suspend fun unlikeComment(commentId: Int) { + commentsViewModel.unlikeComment(commentId) + } + + suspend fun createComment( + content: String, + parentCommentId: Int? = null, + replyUserId: Int? = null, + replyCommentId: Int? = null + ) { + commentsViewModel.createComment( + content = content, + parentCommentId = parentCommentId, + replyUserId = replyUserId, + replyCommentId = replyCommentId + ) + moment = moment?.copy(commentCount = moment?.commentCount?.plus(1) ?: 0) + } + + @Subscribe + fun onMomentLikeChangeEvent(event: MomentLikeChangeEvent) { + moment?.let { + if (event.postId == it.id) { + moment = it.copy(likeCount = event.likeCount ?: it.likeCount, liked = event.isLike) + } + } + } + + suspend fun likeMoment() { + moment?.let { + service.likeMoment(it.id) + EventBus.getDefault().post( + MomentLikeChangeEvent( + postId = it.id, + likeCount = it.likeCount + 1, + isLike = true + ) + ) + } + } + + suspend fun dislikeMoment() { + moment?.let { + service.dislikeMoment(it.id) + EventBus.getDefault().post( + MomentLikeChangeEvent( + postId = it.id, + likeCount = it.likeCount - 1, + isLike = false + ) + ) + } + } + + @Subscribe + fun onMomentFavouriteChangeEvent(event: MomentFavouriteChangeEvent) { + moment?.let { + if (event.postId == it.id) { + val favouriteCount = if (event.isFavourite) { + it.favoriteCount + 1 + } else { + it.favoriteCount - 1 + } + moment = it.copy( + favoriteCount = favouriteCount, + isFavorite = event.isFavourite + ) + } + } + } + + suspend fun favoriteMoment() { + moment?.let { + service.favoriteMoment(it.id) + EventBus.getDefault().post( + MomentFavouriteChangeEvent( + postId = it.id, + isFavourite = true + ) + ) + } + } + + suspend fun unfavoriteMoment() { + moment?.let { + service.unfavoriteMoment(it.id) + EventBus.getDefault().post( + MomentFavouriteChangeEvent( + postId = it.id, + isFavourite = false + ) + ) + + } + } + + suspend fun followUser() { + moment?.let { + userService.followUser(it.authorId.toString()) + moment = moment?.copy(followStatus = true) + // 更新我的关注页面的关注数 + } + } + + suspend fun unfollowUser() { + moment?.let { + userService.unFollowUser(it.authorId.toString()) + moment = moment?.copy(followStatus = false) + // 更新我的关注页面的关注数 + } + } + + fun deleteComment(commentId: Int) { + commentsViewModel.deleteComment(commentId) + moment = moment?.copy(commentCount = moment?.commentCount?.minus(1) ?: 0) + moment?.let { + } + } + + var avatar: String? = null + get() { + accountProfileEntity?.avatar?.let { + return it + } + moment?.avatar?.let { + return it + } + return field + } + var nickname: String? = null + get() { + accountProfileEntity?.nickName?.let { + return it + } + moment?.nickname?.let { + return it + } + return field + } + + fun deleteMoment(callback: () -> Unit) { + viewModelScope.launch { + moment?.let { + service.deleteMoment(it.id) + EventBus.getDefault().post(MomentRemoveEvent(it.id)) + } + callback() + } + } + + fun loadMoreSubComments(commentId: Int) { + commentsViewModel.loadMoreSubComments(commentId) + } + + override fun onCleared() { + super.onCleared() + EventBus.getDefault().unregister(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/post/SelectLocationModal.kt b/app/src/main/java/com/aiosman/ravenow/ui/post/SelectLocationModal.kt new file mode 100644 index 0000000..a64bdd9 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/post/SelectLocationModal.kt @@ -0,0 +1,217 @@ +package com.aiosman.ravenow.ui.post + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.ravenow.R + + +data class SearchPlaceAddressResult( + val name: String, + val address: String +) + +@Composable +fun SelectLocationModal( + onClose: () -> Unit, + onSelectedLocation: (SearchPlaceAddressResult) -> Unit +) { + val context = LocalContext.current + var queryString by remember { mutableStateOf("") } + var searchPlaceAddressResults by remember { + mutableStateOf>( + emptyList() + ) + } + +// fun searchAddrWithGoogleMap(query: String) { +// val placesClient: PlacesClient = Places.createClient(context) +// val placeFields: List = +// listOf(Place.Field.ID, Place.Field.NAME, Place.Field.ADDRESS) +// val request = SearchByTextRequest.newInstance(query, placeFields) +// placesClient.searchByText(request) +// .addOnSuccessListener { response -> +// val place = response.places +// searchPlaceAddressResults = place.map { +// SearchPlaceAddressResult(it.name ?: "", it.address ?: "") +// } +// +// }.addOnFailureListener { exception -> +// if (exception is ApiException) { +// Log.e("SelectLocationModal", "Place not found: ${exception.statusCode}") +// } +// } +// } + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp) + ) { + Text( + "Check In", + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.Center), + fontSize = 16.sp + ) + Text( + "Cancel", + modifier = Modifier + .align(Alignment.CenterEnd) + .clickable { + onClose() + }, + fontSize = 16.sp + ) + } + LocationSearchTextInput(queryString, onQueryClick = { +// searchAddrWithGoogleMap(queryString) + }) { + queryString = it + } + LazyColumn( + modifier = Modifier + .weight(1f) + .padding(top = 28.dp) + ) { + item { + for (searchPlaceAddressResult in searchPlaceAddressResults) { + LocationItem(searchPlaceAddressResult) { + onSelectedLocation(searchPlaceAddressResult) + } + } + } + } + + } +} + +@Composable +fun LocationSearchTextInput( + value: String, + onQueryClick: () -> Unit, + onValueChange: (String) -> Unit +) { + val keyboardController = LocalSoftwareKeyboardController.current + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .clip(shape = RoundedCornerShape(16.dp)) + .background(Color(0xffF5F5F5)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_search_location), + contentDescription = "Search", + modifier = Modifier + .size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + if (value.isEmpty()) { + Text( + "search", + modifier = Modifier.padding(vertical = 16.dp), + color = Color(0xffA0A0A0) + ) + } + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Search + ), + keyboardActions = KeyboardActions( + onSearch = { + onQueryClick() + // hide keyboard + keyboardController?.hide() + + } + ) + + ) + } + + } + +} + +@Composable +fun LocationItem( + searchPlaceAddressResult: SearchPlaceAddressResult, + onLocationItemClick: () -> Unit = {} +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + .clickable { + onLocationItemClick() + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp) + ) { + Text(searchPlaceAddressResult.name, fontWeight = FontWeight.Bold) + Text(searchPlaceAddressResult.address, color = Color(0xFF9a9a9a)) + } + Image( + painter = painterResource(id = R.drawable.rider_pro_nav_next), + contentDescription = "Next", + modifier = Modifier.size(24.dp) + ) + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/profile/AccountProfileV2.kt b/app/src/main/java/com/aiosman/ravenow/ui/profile/AccountProfileV2.kt new file mode 100644 index 0000000..da14a2f --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/profile/AccountProfileV2.kt @@ -0,0 +1,63 @@ +package com.aiosman.ravenow.ui.profile + +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.viewmodel.compose.viewModel +import com.aiosman.ravenow.LocalNavController +import com.aiosman.ravenow.exp.viewModelFactory +import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel +import com.aiosman.ravenow.ui.index.tabs.profile.ProfileV3 +import com.aiosman.ravenow.ui.navigateToChat +import com.aiosman.ravenow.ui.navigateToPost + +@Composable +fun AccountProfileV2(id: String, isAiAccount: Boolean = false){ + val model: AccountProfileViewModel = viewModel(factory = viewModelFactory { + AccountProfileViewModel() + }, key = "viewModel_${id}") + val navController = LocalNavController.current + LaunchedEffect(Unit) { + model.loadProfile(id) + MyProfileViewModel.loadProfile() + } + var isSelf = false + if (id == MyProfileViewModel.profile?.id.toString()) { + isSelf = true + } + ProfileV3( + moments = model.moments, + agents = model.agents, + profile = model.profile, + isSelf = isSelf, + isAiAccount = isAiAccount, // 从参数传入AI账户判断 + postCount = model.momentLoader.total, + onLoadMore = { + Log.d("AccountProfileV2", "onLoadMore被调用") + model.loadMoreMoment() + }, + onLike = { moment -> + // TODO: 实现点赞逻辑 + }, + onComment = { moment -> + navController.navigateToPost(moment.id) + }, + onChatClick = { + model.profile?.let { + navController.navigateToChat(it.id.toString()) + } + }, + onFollowClick = { + model.profile?.let { + if (it.isFollowing) { + model.unFollowUser(id) + } else { + model.followUser(id) + } + } + }, + onAgentClick = { agent -> + // TODO: 处理Agent点击事件,导航到聊天页面 + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/profile/AccountProfileViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/profile/AccountProfileViewModel.kt new file mode 100644 index 0000000..2e45a21 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/profile/AccountProfileViewModel.kt @@ -0,0 +1,123 @@ +package com.aiosman.ravenow.ui.profile + +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.aiosman.ravenow.data.AccountService +import com.aiosman.ravenow.data.AccountServiceImpl +import com.aiosman.ravenow.data.UserServiceImpl +import com.aiosman.ravenow.entity.AccountProfileEntity +import com.aiosman.ravenow.entity.AgentEntity +import com.aiosman.ravenow.entity.AgentLoader +import com.aiosman.ravenow.entity.AgentLoaderExtraArgs +import com.aiosman.ravenow.entity.MomentEntity +import com.aiosman.ravenow.entity.MomentLoader +import com.aiosman.ravenow.entity.MomentLoaderExtraArgs +import com.aiosman.ravenow.event.FollowChangeEvent +import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe + + +class AccountProfileViewModel : ViewModel() { + var profileId by mutableStateOf(0) + val accountService: AccountService = AccountServiceImpl() + val userService = UserServiceImpl() + var profile by mutableStateOf(null) + var refreshing by mutableStateOf(false) + var momentLoader = MomentLoader().apply { + pageSize = 20 // 设置与后端一致的页面大小 + onListChanged = { + moments = it + } + } + var moments by mutableStateOf>(listOf()) + + var agentLoader = AgentLoader().apply { + onListChanged = { + agents = it + } + } + var agents by mutableStateOf>(listOf()) + init { + EventBus.getDefault().register(this) + } + + fun loadProfile(id: String, pullRefresh: Boolean = false) { + viewModelScope.launch { + if (pullRefresh) { + refreshing = true + } + if (profileId == profile?.id) { + return@launch + } + try { + profile = userService.getUserProfile(id) + } catch (e: Exception) { + e.printStackTrace() + } + + refreshing = false + profile?.let { + try { + momentLoader.loadData(MomentLoaderExtraArgs(authorId = it.id)) + // 根据是否是当前用户来决定传递authorId + // 如果是当前用户,传递null以调用getMyAgent() + // 如果是其他用户,传递用户ID以调用getAgent(authorId) + val isSelf = it.id.toString() == com.aiosman.ravenow.AppState.UserId.toString() + val authorId = if (isSelf) null else it.id + agentLoader.loadData(AgentLoaderExtraArgs(authorId = authorId)) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + + fun loadMoreMoment() { + viewModelScope.launch { + profile?.let { profileData -> + try { + Log.d("AccountProfileViewModel", "loadMoreMoment: 开始加载更多, 当前moments数量: ${moments.size}, hasNext: ${momentLoader.hasNext}") + momentLoader.loadMore(extra = MomentLoaderExtraArgs(authorId = profileData.id)) + Log.d("AccountProfileViewModel", "loadMoreMoment: 加载完成, 新的moments数量: ${moments.size}") + } catch (e: Exception) { + Log.e("AccountProfileViewModel", "loadMoreMoment: ", e) + } + } ?: Log.w("AccountProfileViewModel", "loadMoreMoment: profile为null,无法加载更多") + } + } + + @Subscribe + fun onFollowChangeEvent(event: FollowChangeEvent) { + if (event.userId == profile?.id) { + profile = profile?.copy(followerCount = profile!!.followerCount + if (event.isFollow) 1 else -1, isFollowing = event.isFollow) + } + } + fun followUser(userId: String) { + viewModelScope.launch { + userService.followUser(userId) + EventBus.getDefault().post(FollowChangeEvent(userId.toInt(), true)) + } + } + + fun unFollowUser(userId: String) { + viewModelScope.launch { + userService.unFollowUser(userId) + EventBus.getDefault().post(FollowChangeEvent(userId.toInt(), false)) + } + } + + val bio get() = profile?.bio ?: "" + val nickName get() = profile?.nickName ?: "" + val avatar get() = profile?.avatar + + override fun onCleared() { + super.onCleared() + EventBus.getDefault().unregister(this) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/search/search.kt b/app/src/main/java/com/aiosman/ravenow/ui/search/search.kt new file mode 100644 index 0000000..6470d9a --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/search/search.kt @@ -0,0 +1,67 @@ +package com.aiosman.ravenow.ui.search + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier + +@Composable +fun SearchScreen() { + Scaffold { paddingValues -> + var tabIndex by remember { mutableStateOf(0) } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + ) { + TextField( + value = "", + onValueChange = {}, + label = { Text("Search") }, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + } + ) + TabRow(selectedTabIndex = tabIndex) { + Tab(text = { Text("Post") }, + selected = tabIndex == 0, + onClick = { tabIndex = 0 } + ) + Tab(text = { Text("User") }, + selected = tabIndex == 1, + onClick = { tabIndex = 1 } + ) + } + when (tabIndex) { + 0 -> SearchPostResults() + 1 -> SearchUserResults() + } + } + } +} + +@Composable +fun SearchPostResults() { + Text("Post Results") +} +@Composable +fun SearchUserResults(){ + Text("User Results") +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/splash/splash.kt b/app/src/main/java/com/aiosman/ravenow/ui/splash/splash.kt new file mode 100644 index 0000000..8d2e3a3 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/splash/splash.kt @@ -0,0 +1,56 @@ +package com.aiosman.ravenow.ui.splash + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.R + +@Composable +fun SplashScreen() { + Box( + modifier = Modifier.fillMaxSize() + ) { + // 居中的图标 + Image( + painter = painterResource(id = R.mipmap.invalid_name), + contentDescription = "App Logo", + modifier = Modifier + .align(Alignment.Center) + .size(120.dp) + ) + + // 底部文字 + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Bottom, + modifier = Modifier + .fillMaxSize() + .padding(bottom = 80.dp) + ) { + Image( + painterResource(id = R.mipmap.kp_p_img), + contentDescription = "", + modifier = Modifier.size(85.dp, 25.dp) + ) + Spacer(modifier = Modifier.padding(top = 16.dp)) + Text( + stringResource(R.string.splash_title), + fontSize = 13.sp + ) + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/theme/Color.kt b/app/src/main/java/com/aiosman/ravenow/ui/theme/Color.kt new file mode 100644 index 0000000..666b23b --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.aiosman.ravenow.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/theme/Theme.kt b/app/src/main/java/com/aiosman/ravenow/ui/theme/Theme.kt new file mode 100644 index 0000000..165f5b3 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/theme/Theme.kt @@ -0,0 +1,57 @@ +package com.aiosman.ravenow.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun RaveNowTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/theme/Type.kt b/app/src/main/java/com/aiosman/ravenow/ui/theme/Type.kt new file mode 100644 index 0000000..56c67fc --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.aiosman.ravenow.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/utils/BlurHashDecoder.kt b/app/src/main/java/com/aiosman/ravenow/utils/BlurHashDecoder.kt new file mode 100644 index 0000000..05f5806 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/utils/BlurHashDecoder.kt @@ -0,0 +1,218 @@ +package com.aiosman.ravenow.utils + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.collection.SparseArrayCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.withSign + +internal object BlurHashDecoder { + + // cache Math.cos() calculations to improve performance. + // The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps + // the cache is enabled by default, it is recommended to disable it only when just a few images are displayed + private val cacheCosinesX = SparseArrayCompat() + private val cacheCosinesY = SparseArrayCompat() + + /** + * Clear calculations stored in memory cache. + * The cache is not big, but will increase when many image sizes are used, + * if the app needs memory it is recommended to clear it. + */ + private fun clearCache() { + cacheCosinesX.clear() + cacheCosinesY.clear() + } + + /** + * Decode a blur hash into a new bitmap. + * + * @param useCache use in memory cache for the calculated math, reused by images with same size. + * if the cache does not exist yet it will be created and populated with new calculations. + * By default it is true. + */ + @Suppress("ReturnCount") + internal fun decode( + blurHash: String?, + width: Int, + height: Int, + punch: Float = 1f, + useCache: Boolean = true + ): Bitmap? { + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + val maxAcEnc = decode83(blurHash, 1, 2) + val maxAc = (maxAcEnc + 1) / 166f + val colors = Array(numCompX * numCompY) { i -> + if (i == 0) { + val colorEnc = decode83(blurHash, 2, 6) + decodeDc(colorEnc) + } else { + val from = 4 + i * 2 + val colorEnc = decode83(blurHash, from, from + 2) + decodeAc(colorEnc, maxAc * punch) + } + } + return composeBitmap(width, height, numCompX, numCompY, colors, useCache) + } + + private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int { + var result = 0 + for (i in from until to) { + val index = charMap[str[i]] ?: -1 + if (index != -1) { + result = result * 83 + index + } + } + return result + } + + private fun decodeDc(colorEnc: Int): FloatArray { + val r = colorEnc shr 16 + val g = (colorEnc shr 8) and 255 + val b = colorEnc and 255 + return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + } + + private fun srgbToLinear(colorEnc: Int): Float { + val v = colorEnc / 255f + return if (v <= 0.04045f) { + (v / 12.92f) + } else { + ((v + 0.055f) / 1.055f).pow(2.4f) + } + } + + private fun decodeAc(value: Int, maxAc: Float): FloatArray { + val r = value / (19 * 19) + val g = (value / 19) % 19 + val b = value % 19 + return floatArrayOf( + signedPow2((r - 9) / 9.0f) * maxAc, + signedPow2((g - 9) / 9.0f) * maxAc, + signedPow2((b - 9) / 9.0f) * maxAc + ) + } + + private fun signedPow2(value: Float) = value.pow(2f).withSign(value) + + private fun composeBitmap( + width: Int, + height: Int, + numCompX: Int, + numCompY: Int, + colors: Array, + useCache: Boolean + ): Bitmap { + // use an array for better performance when writing pixel colors + val imageArray = IntArray(width * height) + val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX) + val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) + val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) + val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) + runBlocking { + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + val tasks = ArrayList>() + tasks.add( + async { + for (y in 0 until height) { + for (x in 0 until width) { + var r = 0f + var g = 0f + var b = 0f + for (j in 0 until numCompY) { + for (i in 0 until numCompX) { + val cosX = + cosinesX.getCos(calculateCosX, i, numCompX, x, width) + val cosY = + cosinesY.getCos(calculateCosY, j, numCompY, y, height) + val basis = (cosX * cosY).toFloat() + val color = colors[j * numCompX + i] + r += color[0] * basis + g += color[1] * basis + b += color[2] * basis + } + } + imageArray[x + width * y] = + Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) + } + } + return@async + } + ) + tasks.forEach { it.await() } + }.join() + } + + return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + } + + private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when { + calculate -> { + DoubleArray(height * numCompY).also { + cacheCosinesY.put(height * numCompY, it) + } + } + + else -> { + cacheCosinesY.get(height * numCompY)!! + } + } + + private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when { + calculate -> { + DoubleArray(width * numCompX).also { + cacheCosinesX.put(width * numCompX, it) + } + } + + else -> cacheCosinesX.get(width * numCompX)!! + } + + private fun DoubleArray.getCos( + calculate: Boolean, + x: Int, + numComp: Int, + y: Int, + size: Int + ): Double { + if (calculate) { + this[x + numComp * y] = cos(Math.PI * y * x / size) + } + return this[x + numComp * y] + } + + private fun linearToSrgb(value: Float): Int { + val v = value.coerceIn(0f, 1f) + return if (v <= 0.0031308f) { + (v * 12.92f * 255f + 0.5f).toInt() + } else { + ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() + } + } + + private val charMap = listOf( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', + 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', + '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' + ) + .mapIndexed { i, c -> c to i } + .toMap() +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/utils/DebounceUtils.kt b/app/src/main/java/com/aiosman/ravenow/utils/DebounceUtils.kt new file mode 100644 index 0000000..a28dad5 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/utils/DebounceUtils.kt @@ -0,0 +1,78 @@ +package com.aiosman.ravenow.utils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean + +/** + * 防抖工具类 + * 用于防止用户快速重复点击 + */ +object DebounceUtils { + + /** + * 防抖点击处理 + * @param scope 协程作用域 + * @param delayMillis 防抖延迟时间(毫秒),默认500ms + * @param action 要执行的操作 + */ + fun debounceClick( + scope: CoroutineScope, + delayMillis: Long = 500L, + action: () -> Unit + ) { + scope.launch { + delay(delayMillis) + action() + } + } + + /** + * 带状态检查的防抖点击处理 + * @param scope 协程作用域 + * @param delayMillis 防抖延迟时间(毫秒),默认500ms + * @param isProcessing 是否正在处理中的状态 + * @param action 要执行的操作 + */ + fun debounceClickWithState( + scope: CoroutineScope, + delayMillis: Long = 500L, + isProcessing: AtomicBoolean, + action: () -> Unit + ) { + if (isProcessing.get()) { + return + } + + isProcessing.set(true) + scope.launch { + delay(delayMillis) + try { + action() + } finally { + isProcessing.set(false) + } + } + } + + /** + * 简单的防抖点击处理(无协程) + * @param lastClickTime 上次点击时间 + * @param delayMillis 防抖延迟时间(毫秒),默认500ms + * @param action 要执行的操作 + * @return 是否执行了操作 + */ + fun simpleDebounceClick( + lastClickTime: Long, + delayMillis: Long = 500L, + action: () -> Unit + ): Boolean { + val currentTime = System.currentTimeMillis() + if (currentTime - lastClickTime < delayMillis) { + return false + } + action() + return true + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/utils/FileUtil.kt b/app/src/main/java/com/aiosman/ravenow/utils/FileUtil.kt new file mode 100644 index 0000000..6e95cc8 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/utils/FileUtil.kt @@ -0,0 +1,121 @@ +package com.aiosman.ravenow.utils + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.widget.Toast +import coil.request.ImageRequest +import coil.request.SuccessResult +import com.aiosman.ravenow.utils.Utils.getImageLoader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream + +object FileUtil { + suspend fun saveImageToGallery(context: Context, url: String) { + val loader = getImageLoader(context) + + val request = ImageRequest.Builder(context) + .data(url) + .allowHardware(false) // Disable hardware bitmaps. + .build() + + val result = (loader.execute(request) as SuccessResult).drawable + val bitmap = (result as BitmapDrawable).bitmap + + val contentValues = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "image_${System.currentTimeMillis()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES) + } + + val uri = context.contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues + ) + uri?.let { + val outputStream: OutputStream? = context.contentResolver.openOutputStream(it) + outputStream.use { stream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream!!) + } + withContext(Dispatchers.Main) { + Toast.makeText(context, "Image saved to gallery", Toast.LENGTH_SHORT).show() + } + } + } + + fun saveImageToMediaStore(context: Context, displayName: String, bitmap: Bitmap): Uri? { + val imageCollections = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + } else { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + + val imageDetails = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, displayName) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpg") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Images.Media.IS_PENDING, 1) + } + } + + val resolver = context.applicationContext.contentResolver + val imageContentUri = resolver.insert(imageCollections, imageDetails) ?: return null + + return try { + resolver.openOutputStream(imageContentUri, "w").use { os -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, os!!) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + imageDetails.clear() + imageDetails.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(imageContentUri, imageDetails, null, null) + } + + imageContentUri + } catch (e: FileNotFoundException) { + // Some legacy devices won't create directory for the Uri if dir not exist, resulting in + // a FileNotFoundException. To resolve this issue, we should use the File API to save the + // image, which allows us to create the directory ourselves. + null + } + } + + fun getRealPathFromUri(context: Context, uri: Uri): String? { + var realPath: String? = null + val projection = arrayOf(MediaStore.Images.Media.DATA) + val cursor: Cursor? = context.contentResolver.query(uri, projection, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA) + realPath = it.getString(columnIndex) + } + } + return realPath + } + + suspend fun bitmapToJPG(context: Context, bitmap: Bitmap, displayName: String): Uri? { + return withContext(Dispatchers.IO) { + try { + val tempFile = File.createTempFile(displayName, ".jpg", context.cacheDir) + FileOutputStream(tempFile).use { os -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, os) + } + Uri.fromFile(tempFile) + } catch (e: IOException) { + null + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/utils/GoogleLogin.kt b/app/src/main/java/com/aiosman/ravenow/utils/GoogleLogin.kt new file mode 100644 index 0000000..e669ab9 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/utils/GoogleLogin.kt @@ -0,0 +1,37 @@ +package com.aiosman.ravenow.utils + +import android.content.Context +import androidx.credentials.Credential +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential + + +fun handleGoogleSignIn(result: GetCredentialResponse, onLoginWithGoogle: (String) -> Unit) { + val credential: Credential = result.credential + + if (credential is CustomCredential) { + if (GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL.equals(credential.type)) { + val googleIdTokenCredential: GoogleIdTokenCredential = + GoogleIdTokenCredential.createFrom(credential.data) + onLoginWithGoogle(googleIdTokenCredential.idToken) + } + } +} + +suspend fun GoogleLogin(context: Context,clientId:String, onLoginWithGoogle: (String) -> Unit) { + val credentialManager = CredentialManager.create(context) + val googleIdOption: GetGoogleIdOption = GetGoogleIdOption.Builder() + .setServerClientId(clientId) + .setFilterByAuthorizedAccounts(false) + .build() + val request = GetCredentialRequest.Builder().addCredentialOption(googleIdOption) + .build() + + credentialManager.getCredential(context, request).let { + handleGoogleSignIn(it, onLoginWithGoogle) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/utils/MessageParser.kt b/app/src/main/java/com/aiosman/ravenow/utils/MessageParser.kt new file mode 100644 index 0000000..f8c7bcf --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/utils/MessageParser.kt @@ -0,0 +1,69 @@ +package com.aiosman.ravenow.utils + +import com.aiosman.ravenow.AppState +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import io.openim.android.sdk.models.Message + +/** + * OpenIM 消息解析工具类 + * 用于解析 ConversationInfo 中的 latestMsg JSON 字符串 + */ +object MessageParser { + + /** + * 解析最新消息的显示文本和发送者信息 + * @param latestMsgJson 最新消息的JSON字符串 + * @return Pair 显示文本和是否是自己发送的消息 + */ + fun parseLatestMessage(latestMsgJson: String?): Pair { + var displayText = "" + var isSelf = false + + try { + if (!latestMsgJson.isNullOrEmpty()) { + val gson = Gson() + val message = gson.fromJson(latestMsgJson, Message::class.java) + + // 判断是否是自己发送的消息 + isSelf = message.sendID == AppState.profile?.trtcUserId + + // 根据消息类型生成显示文本 + displayText = getMessageDisplayText(message) + } else { + displayText = "[暂无消息]" + } + } catch (e: JsonSyntaxException) { + // JSON 解析失败,使用原始文本 + displayText = latestMsgJson ?: "[消息解析失败]" + } catch (e: Exception) { + // 其他异常 + displayText = "[消息]" + } + + return Pair(displayText, isSelf) + } + + /** + * 根据消息类型获取显示文本 + * @param message OpenIM Message 对象 + * @return 消息的显示文本 + */ + private fun getMessageDisplayText(message: Message): String { + return when (message.contentType) { + 101 -> { // TEXT + message.textElem?.content ?: "[文本消息]" + } + 102 -> "[图片]" // IMAGE + 103 -> "[语音]" // AUDIO + 104 -> "[视频]" // VIDEO + 105 -> "[文件]" // FILE + 106 -> "[位置]" // LOCATION + 107 -> "[自定义消息]" // CUSTOM + 108 -> "[合并消息]" // MERGE + 109 -> "[名片]" // CARD + 110 -> "[引用消息]" // QUOTE + else -> "[消息]" + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/utils/NetworkUtils.kt b/app/src/main/java/com/aiosman/ravenow/utils/NetworkUtils.kt new file mode 100644 index 0000000..fb21f1f --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/utils/NetworkUtils.kt @@ -0,0 +1,71 @@ +package com.aiosman.ravenow.utils + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged + +object NetworkUtils { + + /** + * 检查当前网络是否可用 + */ + fun isNetworkAvailable(context: Context): Boolean { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork ?: return false + val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false + + activeNetwork.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + activeNetwork.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } else { + @Suppress("DEPRECATION") + val networkInfo = connectivityManager.activeNetworkInfo + @Suppress("DEPRECATION") + networkInfo?.isConnected == true + } + } + + /** + * 获取网络状态变化的Flow + */ + fun getNetworkStateFlow(context: Context): Flow = callbackFlow { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(true) + } + + override fun onLost(network: Network) { + trySend(false) + } + + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + val hasInternet = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + trySend(hasInternet) + } + } + + val networkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + connectivityManager.registerNetworkCallback(networkRequest, networkCallback) + + // 发送初始状态 + trySend(isNetworkAvailable(context)) + + awaitClose { + connectivityManager.unregisterNetworkCallback(networkCallback) + } + }.distinctUntilChanged() +} diff --git a/app/src/main/java/com/aiosman/ravenow/utils/PasswordValidator.kt b/app/src/main/java/com/aiosman/ravenow/utils/PasswordValidator.kt new file mode 100644 index 0000000..f159065 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/utils/PasswordValidator.kt @@ -0,0 +1,125 @@ +package com.aiosman.ravenow.utils + +import android.content.Context +import com.aiosman.ravenow.R + +/** + * 密码校验工具类 + * 提供统一的密码校验规则和错误信息 + */ +object PasswordValidator { + + /** + * 密码最大长度限制 + */ + const val MAX_PASSWORD_LENGTH = 64 + + /** + * 密码最小长度限制 + */ + const val MIN_PASSWORD_LENGTH = 6 + + /** + * 密码校验结果 + */ + data class ValidationResult( + val isValid: Boolean, + val errorMessage: String? = null + ) + + /** + * 校验密码格式 + * @param password 密码 + * @param context 上下文,用于获取本地化错误信息 + * @return 校验结果 + */ + fun validatePassword(password: String, context: Context): ValidationResult { + return when { + // 检查是否为空 + password.isEmpty() -> ValidationResult( + false, + context.getString(R.string.text_error_password_required) + ) + + // 检查长度不能超过64个字符 + password.length > MAX_PASSWORD_LENGTH -> ValidationResult( + false, + context.getString(R.string.text_error_password_too_long, MAX_PASSWORD_LENGTH) + ) + + // 检查最小长度 + password.length < MIN_PASSWORD_LENGTH -> ValidationResult( + false, + context.getString(R.string.text_error_password_format) + ) + + // 检查是否包含至少一个数字 + !password.matches(Regex(".*\\d.*")) -> ValidationResult( + false, + context.getString(R.string.text_error_password_format) + ) + + // 检查是否包含字母 + !password.matches(Regex(".*[a-zA-Z].*")) -> ValidationResult( + false, + context.getString(R.string.text_error_password_format) + ) + + else -> ValidationResult(true) + } + } + + /** + * 校验密码确认 + * @param password 原密码 + * @param confirmPassword 确认密码 + * @param context 上下文 + * @return 校验结果 + */ + fun validatePasswordConfirmation( + password: String, + confirmPassword: String, + context: Context + ): ValidationResult { + return when { + confirmPassword.isEmpty() -> ValidationResult( + false, + context.getString(R.string.text_error_confirm_password_required) + ) + + confirmPassword.length > MAX_PASSWORD_LENGTH -> ValidationResult( + false, + context.getString(R.string.text_error_password_too_long, MAX_PASSWORD_LENGTH) + ) + + password != confirmPassword -> ValidationResult( + false, + context.getString(R.string.text_error_confirm_password_mismatch) + ) + + else -> ValidationResult(true) + } + } + + /** + * 校验当前密码(用于更改密码和删除账户场景) + * @param currentPassword 当前密码 + * @param context 上下文 + * @return 校验结果 + */ + fun validateCurrentPassword(currentPassword: String, context: Context): ValidationResult { + return when { + currentPassword.isEmpty() -> ValidationResult( + false, + context.getString(R.string.current_password_tip1) + ) + + currentPassword.length > MAX_PASSWORD_LENGTH -> ValidationResult( + false, + context.getString(R.string.text_error_password_too_long, MAX_PASSWORD_LENGTH) + ) + + else -> ValidationResult(true) + } + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/utils/ResourceCleanupManager.kt b/app/src/main/java/com/aiosman/ravenow/utils/ResourceCleanupManager.kt new file mode 100644 index 0000000..4171909 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/utils/ResourceCleanupManager.kt @@ -0,0 +1,262 @@ +package com.aiosman.ravenow.utils + +import android.content.Context +import android.content.Intent +import com.aiosman.ravenow.AppState +import com.aiosman.ravenow.ui.index.IndexViewModel +import com.aiosman.ravenow.ui.index.tabs.ai.AgentViewModel +import com.aiosman.ravenow.ui.index.tabs.ai.tabs.hot.HotAgentViewModel +import com.aiosman.ravenow.ui.index.tabs.ai.tabs.mine.MineAgentViewModel +import com.aiosman.ravenow.ui.index.tabs.moment.tabs.dynamic.DynamicViewModel +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.message.MessageListViewModel +import com.aiosman.ravenow.ui.like.LikeNoticeViewModel +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.post.NewPostViewModel +import com.aiosman.ravenow.ui.imageviewer.ImageViewerViewModel +import org.greenrobot.eventbus.EventBus + +/** + * 资源清理管理器 + * 用于在Index页面退出时清理所有相关资源 + */ +object ResourceCleanupManager { + + /** + * 清理所有Index页面相关的资源 + * @param context 上下文 + */ + fun cleanupAllResources(context: Context) { + try { + // 1. 清理ViewModel资源 + cleanupViewModels() + + // 2. 清理EventBus注册 + cleanupEventBus() + + // 3. 清理服务 + cleanupServices(context) + + // 4. 清理缓存和临时数据 + cleanupCacheAndTempData() + + // 5. 重置应用状态 + resetAppState() + + } catch (e: Exception) { + // 记录错误但不抛出异常,确保清理过程不会中断 + e.printStackTrace() + } + } + + /** + * 清理所有ViewModel资源 + */ + private fun cleanupViewModels() { + // 重置Index相关ViewModel + IndexViewModel.ResetModel() + + // 重置AI相关ViewModel(完全清理,包括推荐数据) + AgentViewModel.ResetModel() + + HotAgentViewModel.let { + it.agentList = emptyList() + it.refreshing = false + it.isLoading = false + it.hasNext = true + it.currentPage = 1 + it.error = null + it.clearPreloadedImages() + } + + MineAgentViewModel.ResetModel() + + // 重置动态相关ViewModel + TimelineMomentViewModel.ResetModel() + DynamicViewModel.ResetModel() + HotMomentViewModel.resetModel() + + // 重置个人资料相关ViewModel + MyProfileViewModel.ResetModel() + + // 重置编辑资料ViewModel - 暂时注释掉 + // AccountEditViewModel.ResetModel() + + // 重置搜索相关ViewModel + // DiscoverViewModel的属性是私有的,无法直接访问,通过其他方式清理 + + SearchViewModel.ResetModel() + + // 重置消息相关ViewModel + MessageListViewModel.ResetModel() + + // 重置通知相关ViewModel + LikeNoticeViewModel.ResetModel() + FavouriteNoticeViewModel.ResetModel() + FollowerNoticeViewModel.ResetModel() + + // 重置收藏相关ViewModel + FavouriteListViewModel.ResetModel() + + // 重置发布相关ViewModel + NewPostViewModel.asNewPost() + + // 重置图片查看器ViewModel + ImageViewerViewModel.let { + it.imageList.clear() + it.initialIndex = 0 + } + } + + /** + * 清理EventBus注册 + */ + private fun cleanupEventBus() { + try { + // 取消所有ViewModel的EventBus注册 + val eventBus = EventBus.getDefault() + + // 检查并取消注册各种ViewModel + if (eventBus.isRegistered(TimelineMomentViewModel)) { + eventBus.unregister(TimelineMomentViewModel) + } + if (eventBus.isRegistered(DynamicViewModel)) { + eventBus.unregister(DynamicViewModel) + } + if (eventBus.isRegistered(HotMomentViewModel)) { + eventBus.unregister(HotMomentViewModel) + } + if (eventBus.isRegistered(MyProfileViewModel)) { + eventBus.unregister(MyProfileViewModel) + } + if (eventBus.isRegistered(DiscoverViewModel)) { + eventBus.unregister(DiscoverViewModel) + } + if (eventBus.isRegistered(SearchViewModel)) { + eventBus.unregister(SearchViewModel) + } + if (eventBus.isRegistered(MessageListViewModel)) { + eventBus.unregister(MessageListViewModel) + } + if (eventBus.isRegistered(LikeNoticeViewModel)) { + eventBus.unregister(LikeNoticeViewModel) + } + if (eventBus.isRegistered(FavouriteListViewModel)) { + eventBus.unregister(FavouriteListViewModel) + } + if (eventBus.isRegistered(FavouriteNoticeViewModel)) { + eventBus.unregister(FavouriteNoticeViewModel) + } + if (eventBus.isRegistered(FollowerNoticeViewModel)) { + eventBus.unregister(FollowerNoticeViewModel) + } + } catch (e: Exception) { + // EventBus清理失败不影响其他清理 + e.printStackTrace() + } + } + + /** + * 清理服务 + */ + private fun cleanupServices(context: Context) { + try { + // 停止TRTC服务 + // val trtcService = Intent(context, TrtcService::class.java) + // context.stopService(trtcService) + } catch (e: Exception) { + // 服务停止失败不影响其他清理 + e.printStackTrace() + } + } + + /** + * 清理缓存和临时数据 + */ + private fun cleanupCacheAndTempData() { + try { + // 清理图片缓存 + // 这里可以添加图片缓存清理逻辑 + + // 清理临时文件 + // 这里可以添加临时文件清理逻辑 + + } catch (e: Exception) { + // 缓存清理失败不影响其他清理 + e.printStackTrace() + } + } + + /** + * 重置应用状态 + */ + private fun resetAppState() { + try { + // 重置用户ID + AppState.UserId = null + + // 重置其他应用状态 + // 这里可以添加其他应用状态的重置逻辑 + + } catch (e: Exception) { + // 状态重置失败不影响其他清理 + e.printStackTrace() + } + } + + /** + * 清理特定页面的资源 + * @param pageType 页面类型 + * @param preserveRecommendedData 是否保留推荐数据,默认为true + */ + fun cleanupPageResources(pageType: String, preserveRecommendedData: Boolean = true) { + when (pageType) { + "ai" -> { + // 如果需要保留推荐数据,则不重置AgentViewModel(它包含推荐Agent数据) + if (!preserveRecommendedData) { + AgentViewModel.ResetModel() + } + HotAgentViewModel.let { + it.agentList = emptyList() + it.refreshing = false + it.isLoading = false + it.hasNext = true + it.currentPage = 1 + it.error = null + it.clearPreloadedImages() + } + MineAgentViewModel.ResetModel() + } + "moment" -> { + TimelineMomentViewModel.ResetModel() + DynamicViewModel.ResetModel() + HotMomentViewModel.resetModel() + } + "profile" -> { + MyProfileViewModel.ResetModel() + // AccountEditViewModel.ResetModel() - 暂时注释掉 + } + "search" -> { + // DiscoverViewModel的属性是私有的,无法直接访问 + SearchViewModel.ResetModel() + } + "message" -> { + MessageListViewModel.ResetModel() + } + } + } + + /** + * 完全清理AI页面资源(包括推荐数据),用于登出等场景 + */ + fun cleanupAiPageCompletely() { + cleanupPageResources("ai", preserveRecommendedData = false) + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/utils/TrtcHelper.kt b/app/src/main/java/com/aiosman/ravenow/utils/TrtcHelper.kt new file mode 100644 index 0000000..6be148d --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/utils/TrtcHelper.kt @@ -0,0 +1,47 @@ +package com.aiosman.ravenow.utils + +import io.openim.android.sdk.OpenIMClient +import io.openim.android.sdk.listener.OnBase +import io.openim.android.sdk.models.UserInfo +import io.openim.android.sdk.models.UserInfoReq +import kotlin.coroutines.suspendCoroutine + +object TrtcHelper { + suspend fun loadUnreadCount(): Long { + return suspendCoroutine { continuation -> + OpenIMClient.getInstance().conversationManager + .getTotalUnreadMsgCount(object : OnBase { + override fun onSuccess(data: String?) { + // OpenIM 返回的是字符串格式的数字 + val count = data?.toLongOrNull() ?: 0L + continuation.resumeWith(Result.success(count)) + } + + override fun onError(code: Int, error: String?) { + continuation.resumeWith(Result.failure(Exception("Error $code: $error"))) + } + }) + } + } + + suspend fun updateTrtcProfile( + avatar: String?, + nickName: String? + ) { + val infoReq = UserInfoReq() + nickName?.let { infoReq.nickname = it } + avatar?.let { infoReq.faceURL = it } + return suspendCoroutine { continuation -> + //(OnBase base, UserInfoReq userInfoReq + OpenIMClient.getInstance().userInfoManager.setSelfInfo(object : OnBase { + override fun onError(code: Int, error: String?) { + continuation.resumeWith(Result.failure(Exception("Error $code: $error"))) + } + + override fun onSuccess(data: String?) { + continuation.resumeWith(Result.success(Unit)) + } + },infoReq) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/utils/Utils.kt b/app/src/main/java/com/aiosman/ravenow/utils/Utils.kt new file mode 100644 index 0000000..7ed2cce --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/utils/Utils.kt @@ -0,0 +1,110 @@ +package com.aiosman.ravenow.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import coil.ImageLoader +import coil.request.CachePolicy +import com.aiosman.ravenow.data.api.AuthInterceptor +import com.aiosman.ravenow.data.api.getSafeOkHttpClient +import java.io.File +import java.io.FileOutputStream +import java.util.Date +import java.util.Locale +import java.util.UUID +import java.util.concurrent.TimeUnit + +object Utils { + // 全局共享的 ImageLoader,避免每次创建导致内存缓存不共享 + private var sharedImageLoader: ImageLoader? = null + fun generateRandomString(length: Int): String { + val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + return (1..length) + .map { allowedChars.random() } + .joinToString("") + } + + fun getImageLoader(context: Context): ImageLoader { + val appContext = context.applicationContext + val existing = sharedImageLoader + if (existing != null) return existing + + val okHttpClient = getSafeOkHttpClient(authInterceptor = AuthInterceptor()) + val loader = ImageLoader.Builder(appContext) + .okHttpClient(okHttpClient) + .memoryCachePolicy(CachePolicy.ENABLED) + .diskCachePolicy(CachePolicy.ENABLED) + .build() + sharedImageLoader = loader + return loader + } + + fun getTimeAgo(date: Date): String { + val now = Date() + val diffInMillis = now.time - date.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 -> "$seconds seconds ago" + minutes < 60 -> "$minutes minutes ago" + hours < 24 -> "$hours hours ago" + days < 365 -> "$days days ago" + else -> "$years years ago" + } + } + + fun getCurrentLanguage(): String { + return Locale.getDefault().language + } + + /** + * 获取完整的语言标记,如 "zh-CN", "en-US" + * 优先使用完整的 BCP-47 语言标记,提升与后端 translations 键的匹配率 + */ + fun getPreferredLanguageTag(): String { + val locale = Locale.getDefault() + val language = locale.language + val country = locale.country + + // 如果有国家/地区代码,返回完整的语言标记 + return if (country.isNotEmpty()) { + "$language-$country" + } else { + language + } + } + + fun compressImage(context: Context, uri: Uri, maxSize: Int = 512, quality: Int = 85): File { + val inputStream = context.contentResolver.openInputStream(uri) + val originalBitmap = BitmapFactory.decodeStream(inputStream) + val (width, height) = originalBitmap.width to originalBitmap.height + + val (newWidth, newHeight) = if (width > height) { + maxSize to (height * maxSize / width) + } else { + (width * maxSize / height) to maxSize + } + + val scaledBitmap = Bitmap.createScaledBitmap(originalBitmap, newWidth, newHeight, true) + val uuidImageName = UUID.randomUUID().toString().let { "$it.jpg" } + val compressedFile = File(context.cacheDir, uuidImageName) + val outputStream = FileOutputStream(compressedFile) + if (quality > 100) { + throw IllegalArgumentException("Quality must be less than 100") + } + if (quality < 0) { + throw IllegalArgumentException("Quality must be greater than 0") + } + scaledBitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) + outputStream.flush() + outputStream.close() + + return compressedFile + } +} \ No newline at end of file diff --git a/app/src/main/res/LOGO.png b/app/src/main/res/LOGO.png new file mode 100644 index 0000000..7364953 Binary files /dev/null and b/app/src/main/res/LOGO.png differ diff --git a/app/src/main/res/drawable/avatar_bold.png b/app/src/main/res/drawable/avatar_bold.png new file mode 100644 index 0000000..ad61107 Binary files /dev/null and b/app/src/main/res/drawable/avatar_bold.png differ diff --git a/app/src/main/res/drawable/default_avatar.jpeg b/app/src/main/res/drawable/default_avatar.jpeg new file mode 100644 index 0000000..86f1993 Binary files /dev/null and b/app/src/main/res/drawable/default_avatar.jpeg differ diff --git a/app/src/main/res/drawable/default_moment_img.png b/app/src/main/res/drawable/default_moment_img.png new file mode 100644 index 0000000..e234801 Binary files /dev/null and b/app/src/main/res/drawable/default_moment_img.png differ diff --git a/app/src/main/res/drawable/default_profile_moto.png b/app/src/main/res/drawable/default_profile_moto.png new file mode 100644 index 0000000..6c27d41 Binary files /dev/null and b/app/src/main/res/drawable/default_profile_moto.png differ diff --git a/app/src/main/res/drawable/dynamic.png b/app/src/main/res/drawable/dynamic.png new file mode 100644 index 0000000..3e86b20 Binary files /dev/null and b/app/src/main/res/drawable/dynamic.png differ diff --git a/app/src/main/res/drawable/follow_bg.png b/app/src/main/res/drawable/follow_bg.png new file mode 100644 index 0000000..e47f12c Binary files /dev/null and b/app/src/main/res/drawable/follow_bg.png differ diff --git a/app/src/main/res/drawable/group_info_edit.xml b/app/src/main/res/drawable/group_info_edit.xml new file mode 100644 index 0000000..fee041b --- /dev/null +++ b/app/src/main/res/drawable/group_info_edit.xml @@ -0,0 +1,35 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/group_info_exit.xml b/app/src/main/res/drawable/group_info_exit.xml new file mode 100644 index 0000000..efc4b16 --- /dev/null +++ b/app/src/main/res/drawable/group_info_exit.xml @@ -0,0 +1,25 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/group_info_notice_setting.xml b/app/src/main/res/drawable/group_info_notice_setting.xml new file mode 100644 index 0000000..628020e --- /dev/null +++ b/app/src/main/res/drawable/group_info_notice_setting.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/group_info_users.xml b/app/src/main/res/drawable/group_info_users.xml new file mode 100644 index 0000000..2ccb738 --- /dev/null +++ b/app/src/main/res/drawable/group_info_users.xml @@ -0,0 +1,38 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/home_logo.xml b/app/src/main/res/drawable/home_logo.xml new file mode 100644 index 0000000..1136fe7 --- /dev/null +++ b/app/src/main/res/drawable/home_logo.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_create_ai.xml b/app/src/main/res/drawable/ic_create_ai.xml new file mode 100644 index 0000000..2b53d35 --- /dev/null +++ b/app/src/main/res/drawable/ic_create_ai.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_create_close.xml b/app/src/main/res/drawable/ic_create_close.xml new file mode 100644 index 0000000..ba97b4d --- /dev/null +++ b/app/src/main/res/drawable/ic_create_close.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_create_group_chat.xml b/app/src/main/res/drawable/ic_create_group_chat.xml new file mode 100644 index 0000000..d872100 --- /dev/null +++ b/app/src/main/res/drawable/ic_create_group_chat.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_create_monent.xml b/app/src/main/res/drawable/ic_create_monent.xml new file mode 100644 index 0000000..7c4fea7 --- /dev/null +++ b/app/src/main/res/drawable/ic_create_monent.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_member.webp b/app/src/main/res/drawable/ic_member.webp new file mode 100644 index 0000000..33447c5 Binary files /dev/null and b/app/src/main/res/drawable/ic_member.webp differ diff --git a/app/src/main/res/drawable/ic_nav_add.xml b/app/src/main/res/drawable/ic_nav_add.xml new file mode 100644 index 0000000..63d816c --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_add.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rave_now_nav_about.xml b/app/src/main/res/drawable/rave_now_nav_about.xml new file mode 100644 index 0000000..cba0a8b --- /dev/null +++ b/app/src/main/res/drawable/rave_now_nav_about.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/rave_now_nav_account.xml b/app/src/main/res/drawable/rave_now_nav_account.xml new file mode 100644 index 0000000..0afefa8 --- /dev/null +++ b/app/src/main/res/drawable/rave_now_nav_account.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/rave_now_nav_logout.xml b/app/src/main/res/drawable/rave_now_nav_logout.xml new file mode 100644 index 0000000..24193c9 --- /dev/null +++ b/app/src/main/res/drawable/rave_now_nav_logout.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rave_now_nav_night.xml b/app/src/main/res/drawable/rave_now_nav_night.xml new file mode 100644 index 0000000..04262aa --- /dev/null +++ b/app/src/main/res/drawable/rave_now_nav_night.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/rave_now_nav_right.xml b/app/src/main/res/drawable/rave_now_nav_right.xml new file mode 100644 index 0000000..fe98789 --- /dev/null +++ b/app/src/main/res/drawable/rave_now_nav_right.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rave_now_nav_switch.xml b/app/src/main/res/drawable/rave_now_nav_switch.xml new file mode 100644 index 0000000..065cbc2 --- /dev/null +++ b/app/src/main/res/drawable/rave_now_nav_switch.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/rave_now_profile_backgrount_demo_1.png b/app/src/main/res/drawable/rave_now_profile_backgrount_demo_1.png new file mode 100644 index 0000000..1e72a55 Binary files /dev/null and b/app/src/main/res/drawable/rave_now_profile_backgrount_demo_1.png differ diff --git a/app/src/main/res/drawable/rider_pro_add_location.xml b/app/src/main/res/drawable/rider_pro_add_location.xml new file mode 100644 index 0000000..a281eb4 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_add_location.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_add_other.xml b/app/src/main/res/drawable/rider_pro_add_other.xml new file mode 100644 index 0000000..4c303e7 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_add_other.xml @@ -0,0 +1,30 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_back_icon.xml b/app/src/main/res/drawable/rider_pro_back_icon.xml new file mode 100644 index 0000000..56e3fd9 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_back_icon.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/rider_pro_camera.xml b/app/src/main/res/drawable/rider_pro_camera.xml new file mode 100644 index 0000000..1d349b0 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_camera.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/rider_pro_close.xml b/app/src/main/res/drawable/rider_pro_close.xml new file mode 100644 index 0000000..cfa0192 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_close.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_comment.xml b/app/src/main/res/drawable/rider_pro_comment.xml new file mode 100644 index 0000000..0afb034 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_comment.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_comments.xml b/app/src/main/res/drawable/rider_pro_comments.xml new file mode 100644 index 0000000..f43cf5c --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_comments.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_copy.xml b/app/src/main/res/drawable/rider_pro_copy.xml new file mode 100644 index 0000000..fb84e00 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_copy.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_delete.xml b/app/src/main/res/drawable/rider_pro_delete.xml new file mode 100644 index 0000000..5eaf9e8 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_delete.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_download.xml b/app/src/main/res/drawable/rider_pro_download.xml new file mode 100644 index 0000000..2c7b7ac --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_download.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_download_icon.xml b/app/src/main/res/drawable/rider_pro_download_icon.xml new file mode 100644 index 0000000..9e54bf5 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_download_icon.xml @@ -0,0 +1,30 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_eye.xml b/app/src/main/res/drawable/rider_pro_eye.xml new file mode 100644 index 0000000..0f4e8b9 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_eye.xml @@ -0,0 +1,22 @@ + + + + diff --git a/app/src/main/res/drawable/rider_pro_favourite.xml b/app/src/main/res/drawable/rider_pro_favourite.xml new file mode 100644 index 0000000..33d8fec --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_favourite.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_favourited.xml b/app/src/main/res/drawable/rider_pro_favourited.xml new file mode 100644 index 0000000..4f039f0 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_favourited.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_followers.xml b/app/src/main/res/drawable/rider_pro_followers.xml new file mode 100644 index 0000000..4f99b14 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_followers.xml @@ -0,0 +1,38 @@ + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_google.xml b/app/src/main/res/drawable/rider_pro_google.xml new file mode 100644 index 0000000..a6bd629 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_google.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_group.xml b/app/src/main/res/drawable/rider_pro_group.xml new file mode 100644 index 0000000..4ebb84f --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_group.xml @@ -0,0 +1,33 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_group_chat.xml b/app/src/main/res/drawable/rider_pro_group_chat.xml new file mode 100644 index 0000000..b92ab84 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_group_chat.xml @@ -0,0 +1,28 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_home.xml b/app/src/main/res/drawable/rider_pro_home.xml new file mode 100644 index 0000000..35ee254 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_home.xml @@ -0,0 +1,22 @@ + + + + diff --git a/app/src/main/res/drawable/rider_pro_home_filed.xml b/app/src/main/res/drawable/rider_pro_home_filed.xml new file mode 100644 index 0000000..7164c60 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_home_filed.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_icon_rider_share.png b/app/src/main/res/drawable/rider_pro_icon_rider_share.png new file mode 100644 index 0000000..e22e1ac Binary files /dev/null and b/app/src/main/res/drawable/rider_pro_icon_rider_share.png differ diff --git a/app/src/main/res/drawable/rider_pro_images.xml b/app/src/main/res/drawable/rider_pro_images.xml new file mode 100644 index 0000000..d70a354 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_images.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_like.xml b/app/src/main/res/drawable/rider_pro_like.xml new file mode 100644 index 0000000..1d37203 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_like.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/rider_pro_location.xml b/app/src/main/res/drawable/rider_pro_location.xml new file mode 100644 index 0000000..be8248b --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_location.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_location_create.png b/app/src/main/res/drawable/rider_pro_location_create.png new file mode 100644 index 0000000..1c5b949 Binary files /dev/null and b/app/src/main/res/drawable/rider_pro_location_create.png differ diff --git a/app/src/main/res/drawable/rider_pro_location_filed.xml b/app/src/main/res/drawable/rider_pro_location_filed.xml new file mode 100644 index 0000000..3408f28 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_location_filed.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_location_map.xml b/app/src/main/res/drawable/rider_pro_location_map.xml new file mode 100644 index 0000000..960d29e --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_location_map.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_logo_red.xml b/app/src/main/res/drawable/rider_pro_logo_red.xml new file mode 100644 index 0000000..7261a7d --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_logo_red.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_map_mark.png b/app/src/main/res/drawable/rider_pro_map_mark.png new file mode 100644 index 0000000..cf12fde Binary files /dev/null and b/app/src/main/res/drawable/rider_pro_map_mark.png differ diff --git a/app/src/main/res/drawable/rider_pro_message.xml b/app/src/main/res/drawable/rider_pro_message.xml new file mode 100644 index 0000000..9c54dc9 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_message.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/rider_pro_message_comments.xml b/app/src/main/res/drawable/rider_pro_message_comments.xml new file mode 100644 index 0000000..a52d8ab --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_message_comments.xml @@ -0,0 +1,38 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_message_fans.xml b/app/src/main/res/drawable/rider_pro_message_fans.xml new file mode 100644 index 0000000..f31dcb3 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_message_fans.xml @@ -0,0 +1,50 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_message_like.xml b/app/src/main/res/drawable/rider_pro_message_like.xml new file mode 100644 index 0000000..484aa1d --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_message_like.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_message_title.png b/app/src/main/res/drawable/rider_pro_message_title.png new file mode 100644 index 0000000..02d9dd6 Binary files /dev/null and b/app/src/main/res/drawable/rider_pro_message_title.png differ diff --git a/app/src/main/res/drawable/rider_pro_modification.xml b/app/src/main/res/drawable/rider_pro_modification.xml new file mode 100644 index 0000000..d5f2cef --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_modification.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_moment_add.xml b/app/src/main/res/drawable/rider_pro_moment_add.xml new file mode 100644 index 0000000..2cca8eb --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_moment_add.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_moment_apply.xml b/app/src/main/res/drawable/rider_pro_moment_apply.xml new file mode 100644 index 0000000..1ef5f36 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_moment_apply.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_moment_comment.xml b/app/src/main/res/drawable/rider_pro_moment_comment.xml new file mode 100644 index 0000000..778e3a4 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_moment_comment.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/rider_pro_moment_delete.xml b/app/src/main/res/drawable/rider_pro_moment_delete.xml new file mode 100644 index 0000000..b6ad98f --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_moment_delete.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_moment_demo_1.jpg b/app/src/main/res/drawable/rider_pro_moment_demo_1.jpg new file mode 100644 index 0000000..01e5e7c Binary files /dev/null and b/app/src/main/res/drawable/rider_pro_moment_demo_1.jpg differ diff --git a/app/src/main/res/drawable/rider_pro_moment_demo_2.jpg b/app/src/main/res/drawable/rider_pro_moment_demo_2.jpg new file mode 100644 index 0000000..916f6bb Binary files /dev/null and b/app/src/main/res/drawable/rider_pro_moment_demo_2.jpg differ diff --git a/app/src/main/res/drawable/rider_pro_moment_demo_3.jpg b/app/src/main/res/drawable/rider_pro_moment_demo_3.jpg new file mode 100644 index 0000000..02625f1 Binary files /dev/null and b/app/src/main/res/drawable/rider_pro_moment_demo_3.jpg differ diff --git a/app/src/main/res/drawable/rider_pro_moment_like.xml b/app/src/main/res/drawable/rider_pro_moment_like.xml new file mode 100644 index 0000000..11bceee --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_moment_like.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_moment_liked.xml b/app/src/main/res/drawable/rider_pro_moment_liked.xml new file mode 100644 index 0000000..ebc97eb --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_moment_liked.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_moment_report.xml b/app/src/main/res/drawable/rider_pro_moment_report.xml new file mode 100644 index 0000000..2a53209 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_moment_report.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_moment_time_flag.xml b/app/src/main/res/drawable/rider_pro_moment_time_flag.xml new file mode 100644 index 0000000..756242e --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_moment_time_flag.xml @@ -0,0 +1,18 @@ + + + + diff --git a/app/src/main/res/drawable/rider_pro_more_horizon.xml b/app/src/main/res/drawable/rider_pro_more_horizon.xml new file mode 100644 index 0000000..b89ec6e --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_more_horizon.xml @@ -0,0 +1,32 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_my_location.xml b/app/src/main/res/drawable/rider_pro_my_location.xml new file mode 100644 index 0000000..b85edd4 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_my_location.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_nav_ai.xml b/app/src/main/res/drawable/rider_pro_nav_ai.xml new file mode 100644 index 0000000..c4fbe39 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_nav_ai.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_nav_ai_hl.png b/app/src/main/res/drawable/rider_pro_nav_ai_hl.png new file mode 100644 index 0000000..6813ed5 Binary files /dev/null and b/app/src/main/res/drawable/rider_pro_nav_ai_hl.png differ diff --git a/app/src/main/res/drawable/rider_pro_nav_back.png b/app/src/main/res/drawable/rider_pro_nav_back.png new file mode 100644 index 0000000..3693500 Binary files /dev/null and b/app/src/main/res/drawable/rider_pro_nav_back.png differ diff --git a/app/src/main/res/drawable/rider_pro_nav_home.xml b/app/src/main/res/drawable/rider_pro_nav_home.xml new file mode 100644 index 0000000..b818929 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_nav_home.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_nav_home_hl.xml b/app/src/main/res/drawable/rider_pro_nav_home_hl.xml new file mode 100644 index 0000000..b818929 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_nav_home_hl.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_nav_home_hl2.png b/app/src/main/res/drawable/rider_pro_nav_home_hl2.png new file mode 100644 index 0000000..fc3ac08 Binary files /dev/null and b/app/src/main/res/drawable/rider_pro_nav_home_hl2.png differ diff --git a/app/src/main/res/drawable/rider_pro_nav_message.xml b/app/src/main/res/drawable/rider_pro_nav_message.xml new file mode 100644 index 0000000..cd57db4 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_nav_message.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_nav_next.xml b/app/src/main/res/drawable/rider_pro_nav_next.xml new file mode 100644 index 0000000..b554463 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_nav_next.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/rider_pro_nav_notification.xml b/app/src/main/res/drawable/rider_pro_nav_notification.xml new file mode 100644 index 0000000..3771158 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_nav_notification.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_nav_notification_hl.xml b/app/src/main/res/drawable/rider_pro_nav_notification_hl.xml new file mode 100644 index 0000000..4b494e3 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_nav_notification_hl.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/rider_pro_nav_post_hl.xml b/app/src/main/res/drawable/rider_pro_nav_post_hl.xml new file mode 100644 index 0000000..cf03acf --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_nav_post_hl.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_nav_profile.xml b/app/src/main/res/drawable/rider_pro_nav_profile.xml new file mode 100644 index 0000000..e16501e --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_nav_profile.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_nav_profile_hl.xml b/app/src/main/res/drawable/rider_pro_nav_profile_hl.xml new file mode 100644 index 0000000..88c9568 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_nav_profile_hl.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/rider_pro_nav_search.xml b/app/src/main/res/drawable/rider_pro_nav_search.xml new file mode 100644 index 0000000..18cd464 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_nav_search.xml @@ -0,0 +1,22 @@ + + + + diff --git a/app/src/main/res/drawable/rider_pro_nav_search_hl.xml b/app/src/main/res/drawable/rider_pro_nav_search_hl.xml new file mode 100644 index 0000000..fd4c303 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_nav_search_hl.xml @@ -0,0 +1,22 @@ + + + + diff --git a/app/src/main/res/drawable/rider_pro_new_comment.xml b/app/src/main/res/drawable/rider_pro_new_comment.xml new file mode 100644 index 0000000..125931a --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_new_comment.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_new_post_add_pic.xml b/app/src/main/res/drawable/rider_pro_new_post_add_pic.xml new file mode 100644 index 0000000..b0825ec --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_new_post_add_pic.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_notice_active.xml b/app/src/main/res/drawable/rider_pro_notice_active.xml new file mode 100644 index 0000000..09fde48 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_notice_active.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_notice_mute.xml b/app/src/main/res/drawable/rider_pro_notice_mute.xml new file mode 100644 index 0000000..24d57e9 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_notice_mute.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rider_pro_notification.xml b/app/src/main/res/drawable/rider_pro_notification.xml new file mode 100644 index 0000000..22bff98 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_notification.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/rider_pro_original_raw.xml b/app/src/main/res/drawable/rider_pro_original_raw.xml new file mode 100644 index 0000000..7693ae2 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_original_raw.xml @@ -0,0 +1,28 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_picture_more.xml b/app/src/main/res/drawable/rider_pro_picture_more.xml new file mode 100644 index 0000000..f2bcd0c --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_picture_more.xml @@ -0,0 +1,18 @@ + + + + diff --git a/app/src/main/res/drawable/rider_pro_profile.xml b/app/src/main/res/drawable/rider_pro_profile.xml new file mode 100644 index 0000000..7633f6d --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_profile.xml @@ -0,0 +1,22 @@ + + + + diff --git a/app/src/main/res/drawable/rider_pro_profile_filed.xml b/app/src/main/res/drawable/rider_pro_profile_filed.xml new file mode 100644 index 0000000..bc08add --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_profile_filed.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_profile_follow.png b/app/src/main/res/drawable/rider_pro_profile_follow.png new file mode 100644 index 0000000..b9bc9ba Binary files /dev/null and b/app/src/main/res/drawable/rider_pro_profile_follow.png differ diff --git a/app/src/main/res/drawable/rider_pro_profile_line.xml b/app/src/main/res/drawable/rider_pro_profile_line.xml new file mode 100644 index 0000000..1b854dc --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_profile_line.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_profile_message.png b/app/src/main/res/drawable/rider_pro_profile_message.png new file mode 100644 index 0000000..c3b903c Binary files /dev/null and b/app/src/main/res/drawable/rider_pro_profile_message.png differ diff --git a/app/src/main/res/drawable/rider_pro_raw.xml b/app/src/main/res/drawable/rider_pro_raw.xml new file mode 100644 index 0000000..8016091 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_raw.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_search_location.xml b/app/src/main/res/drawable/rider_pro_search_location.xml new file mode 100644 index 0000000..8f17209 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_search_location.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_send.xml b/app/src/main/res/drawable/rider_pro_send.xml new file mode 100644 index 0000000..8ad71a7 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_send.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_send_disable.xml b/app/src/main/res/drawable/rider_pro_send_disable.xml new file mode 100644 index 0000000..2e25ffc --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_send_disable.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_send_post.xml b/app/src/main/res/drawable/rider_pro_send_post.xml new file mode 100644 index 0000000..d804dc0 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_send_post.xml @@ -0,0 +1,22 @@ + + + + diff --git a/app/src/main/res/drawable/rider_pro_share.xml b/app/src/main/res/drawable/rider_pro_share.xml new file mode 100644 index 0000000..3a4cbfe --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_share.xml @@ -0,0 +1,46 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_street.xml b/app/src/main/res/drawable/rider_pro_street.xml new file mode 100644 index 0000000..4e1468a --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_street.xml @@ -0,0 +1,30 @@ + + + + + diff --git a/app/src/main/res/drawable/rider_pro_style_line.png b/app/src/main/res/drawable/rider_pro_style_line.png new file mode 100644 index 0000000..917c8a8 Binary files /dev/null and b/app/src/main/res/drawable/rider_pro_style_line.png differ diff --git a/app/src/main/res/drawable/rider_pro_style_wrapper.xml b/app/src/main/res/drawable/rider_pro_style_wrapper.xml new file mode 100644 index 0000000..21910ba --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_style_wrapper.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/rider_pro_theme_mode.xml b/app/src/main/res/drawable/rider_pro_theme_mode.xml new file mode 100644 index 0000000..c6bf2e5 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_theme_mode.xml @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_theme_mode_light.xml b/app/src/main/res/drawable/rider_pro_theme_mode_light.xml new file mode 100644 index 0000000..e81c892 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_theme_mode_light.xml @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_video.xml b/app/src/main/res/drawable/rider_pro_video.xml new file mode 100644 index 0000000..3af611a --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_video.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/rider_pro_video_comment.xml b/app/src/main/res/drawable/rider_pro_video_comment.xml new file mode 100644 index 0000000..477deb2 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_video_comment.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/rider_pro_video_favor.xml b/app/src/main/res/drawable/rider_pro_video_favor.xml new file mode 100644 index 0000000..dfc6bc3 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_video_favor.xml @@ -0,0 +1,16 @@ + + + diff --git a/app/src/main/res/drawable/rider_pro_video_like.xml b/app/src/main/res/drawable/rider_pro_video_like.xml new file mode 100644 index 0000000..b85592b --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_video_like.xml @@ -0,0 +1,16 @@ + + + diff --git a/app/src/main/res/drawable/rider_pro_video_location.xml b/app/src/main/res/drawable/rider_pro_video_location.xml new file mode 100644 index 0000000..bdacc3d --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_video_location.xml @@ -0,0 +1,18 @@ + + + + diff --git a/app/src/main/res/drawable/rider_pro_video_outline.xml b/app/src/main/res/drawable/rider_pro_video_outline.xml new file mode 100644 index 0000000..afa1fc7 --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_video_outline.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/rider_pro_video_share.xml b/app/src/main/res/drawable/rider_pro_video_share.xml new file mode 100644 index 0000000..15c222f --- /dev/null +++ b/app/src/main/res/drawable/rider_pro_video_share.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/drawable/tab_indicator_unselected.xml b/app/src/main/res/drawable/tab_indicator_unselected.xml new file mode 100644 index 0000000..58a7927 --- /dev/null +++ b/app/src/main/res/drawable/tab_indicator_unselected.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/wall_1_1.jpg b/app/src/main/res/drawable/wall_1_1.jpg new file mode 100644 index 0000000..64d6ecd Binary files /dev/null and b/app/src/main/res/drawable/wall_1_1.jpg differ diff --git a/app/src/main/res/drawable/wall_1_2.jpg b/app/src/main/res/drawable/wall_1_2.jpg new file mode 100644 index 0000000..312ad71 Binary files /dev/null and b/app/src/main/res/drawable/wall_1_2.jpg differ diff --git a/app/src/main/res/drawable/wall_1_3.jpg b/app/src/main/res/drawable/wall_1_3.jpg new file mode 100644 index 0000000..6586d57 Binary files /dev/null and b/app/src/main/res/drawable/wall_1_3.jpg differ diff --git a/app/src/main/res/drawable/wall_2_1.jpg b/app/src/main/res/drawable/wall_2_1.jpg new file mode 100644 index 0000000..9511a09 Binary files /dev/null and b/app/src/main/res/drawable/wall_2_1.jpg differ diff --git a/app/src/main/res/drawable/wall_2_2.jpg b/app/src/main/res/drawable/wall_2_2.jpg new file mode 100644 index 0000000..ddaa4cd Binary files /dev/null and b/app/src/main/res/drawable/wall_2_2.jpg differ diff --git a/app/src/main/res/drawable/wall_2_3.jpg b/app/src/main/res/drawable/wall_2_3.jpg new file mode 100644 index 0000000..b88001f Binary files /dev/null and b/app/src/main/res/drawable/wall_2_3.jpg differ diff --git a/app/src/main/res/drawable/wall_3_1.jpg b/app/src/main/res/drawable/wall_3_1.jpg new file mode 100644 index 0000000..f7d8643 Binary files /dev/null and b/app/src/main/res/drawable/wall_3_1.jpg differ diff --git a/app/src/main/res/drawable/wall_3_2.jpg b/app/src/main/res/drawable/wall_3_2.jpg new file mode 100644 index 0000000..8fab867 Binary files /dev/null and b/app/src/main/res/drawable/wall_3_2.jpg differ diff --git a/app/src/main/res/drawable/wall_3_3.jpg b/app/src/main/res/drawable/wall_3_3.jpg new file mode 100644 index 0000000..25a3214 Binary files /dev/null and b/app/src/main/res/drawable/wall_3_3.jpg differ diff --git a/app/src/main/res/drawable/wall_demo.jpg b/app/src/main/res/drawable/wall_demo.jpg new file mode 100644 index 0000000..b626775 Binary files /dev/null and b/app/src/main/res/drawable/wall_demo.jpg differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/rider_pro_log.xml b/app/src/main/res/mipmap-anydpi-v26/rider_pro_log.xml new file mode 100644 index 0000000..0f72421 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/rider_pro_log.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/rider_pro_log_round.xml b/app/src/main/res/mipmap-anydpi-v26/rider_pro_log_round.xml new file mode 100644 index 0000000..0f72421 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/rider_pro_log_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/rider_pro_logo_next.xml b/app/src/main/res/mipmap-anydpi-v26/rider_pro_logo_next.xml new file mode 100644 index 0000000..8c1be40 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/rider_pro_logo_next.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/rider_pro_logo_next_round.xml b/app/src/main/res/mipmap-anydpi-v26/rider_pro_logo_next_round.xml new file mode 100644 index 0000000..8c1be40 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/rider_pro_logo_next_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/rider_pro_logo_red.xml b/app/src/main/res/mipmap-anydpi-v26/rider_pro_logo_red.xml new file mode 100644 index 0000000..a0c0a95 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/rider_pro_logo_red.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/rider_pro_logo_red_round.xml b/app/src/main/res/mipmap-anydpi-v26/rider_pro_logo_red_round.xml new file mode 100644 index 0000000..a0c0a95 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/rider_pro_logo_red_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ai.png b/app/src/main/res/mipmap-hdpi/ai.png new file mode 100644 index 0000000..fffb5a6 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ai.png differ diff --git a/app/src/main/res/mipmap-hdpi/apple_logo_medium.png b/app/src/main/res/mipmap-hdpi/apple_logo_medium.png new file mode 100644 index 0000000..80b01c3 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/apple_logo_medium.png differ diff --git a/app/src/main/res/mipmap-hdpi/arrow.png b/app/src/main/res/mipmap-hdpi/arrow.png new file mode 100644 index 0000000..57dbd4f Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/arrow.png differ diff --git a/app/src/main/res/mipmap-hdpi/bars_x_buttons_chat_s.png b/app/src/main/res/mipmap-hdpi/bars_x_buttons_chat_s.png new file mode 100644 index 0000000..b6ba30d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/bars_x_buttons_chat_s.png differ diff --git a/app/src/main/res/mipmap-hdpi/bars_x_buttons_discover_bold.png b/app/src/main/res/mipmap-hdpi/bars_x_buttons_discover_bold.png new file mode 100644 index 0000000..3e86b20 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/bars_x_buttons_discover_bold.png differ diff --git a/app/src/main/res/mipmap-hdpi/bars_x_buttons_discover_fill.png b/app/src/main/res/mipmap-hdpi/bars_x_buttons_discover_fill.png new file mode 100644 index 0000000..e4b6cd3 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/bars_x_buttons_discover_fill.png differ diff --git a/app/src/main/res/mipmap-hdpi/bars_x_buttons_home_n_copy.png b/app/src/main/res/mipmap-hdpi/bars_x_buttons_home_n_copy.png new file mode 100644 index 0000000..45a52e8 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/bars_x_buttons_home_n_copy.png differ diff --git a/app/src/main/res/mipmap-hdpi/bars_x_buttons_home_n_copy_2.png b/app/src/main/res/mipmap-hdpi/bars_x_buttons_home_n_copy_2.png new file mode 100644 index 0000000..9331a5f Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/bars_x_buttons_home_n_copy_2.png differ diff --git a/app/src/main/res/mipmap-hdpi/bars_x_buttons_home_s.png b/app/src/main/res/mipmap-hdpi/bars_x_buttons_home_s.png new file mode 100644 index 0000000..515a4be Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/bars_x_buttons_home_s.png differ diff --git a/app/src/main/res/mipmap-hdpi/bars_x_buttons_user_s.png b/app/src/main/res/mipmap-hdpi/bars_x_buttons_user_s.png new file mode 100644 index 0000000..749892e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/bars_x_buttons_user_s.png differ diff --git a/app/src/main/res/mipmap-hdpi/btn.png b/app/src/main/res/mipmap-hdpi/btn.png new file mode 100644 index 0000000..e2845f6 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/btn.png differ diff --git a/app/src/main/res/mipmap-hdpi/dynamic_hl.png b/app/src/main/res/mipmap-hdpi/dynamic_hl.png new file mode 100644 index 0000000..3fea35c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/dynamic_hl.png differ diff --git a/app/src/main/res/mipmap-hdpi/fill_and_sign.png b/app/src/main/res/mipmap-hdpi/fill_and_sign.png new file mode 100644 index 0000000..4b40ca3 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/fill_and_sign.png differ diff --git a/app/src/main/res/mipmap-hdpi/group_copy.png b/app/src/main/res/mipmap-hdpi/group_copy.png new file mode 100644 index 0000000..4edd5bc Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/group_copy.png differ diff --git a/app/src/main/res/mipmap-hdpi/h_cj_rw_icon.png b/app/src/main/res/mipmap-hdpi/h_cj_rw_icon.png new file mode 100644 index 0000000..45e5cad Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/h_cj_rw_icon.png differ diff --git a/app/src/main/res/mipmap-hdpi/h_cj_x_img.png b/app/src/main/res/mipmap-hdpi/h_cj_x_img.png new file mode 100644 index 0000000..cdbdb1d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/h_cj_x_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-hdpi/icon_collect.png b/app/src/main/res/mipmap-hdpi/icon_collect.png new file mode 100644 index 0000000..7cf1b34 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icon_collect.png differ diff --git a/app/src/main/res/mipmap-hdpi/icon_comment.png b/app/src/main/res/mipmap-hdpi/icon_comment.png new file mode 100644 index 0000000..258dc6a Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icon_comment.png differ diff --git a/app/src/main/res/mipmap-hdpi/icon_share.png b/app/src/main/res/mipmap-hdpi/icon_share.png new file mode 100644 index 0000000..1995954 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icon_share.png differ diff --git a/app/src/main/res/mipmap-hdpi/icon_variant_2.png b/app/src/main/res/mipmap-hdpi/icon_variant_2.png new file mode 100644 index 0000000..303eea1 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icon_variant_2.png differ diff --git a/app/src/main/res/mipmap-hdpi/icons_circle_ai.png b/app/src/main/res/mipmap-hdpi/icons_circle_ai.png new file mode 100644 index 0000000..6af43db Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icons_circle_ai.png differ diff --git a/app/src/main/res/mipmap-hdpi/icons_circle_camera.png b/app/src/main/res/mipmap-hdpi/icons_circle_camera.png new file mode 100644 index 0000000..01dfe5d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icons_circle_camera.png differ diff --git a/app/src/main/res/mipmap-hdpi/icons_info_magic.png b/app/src/main/res/mipmap-hdpi/icons_info_magic.png new file mode 100644 index 0000000..a715c48 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icons_info_magic.png differ diff --git a/app/src/main/res/mipmap-hdpi/icons_infor_edit.png b/app/src/main/res/mipmap-hdpi/icons_infor_edit.png new file mode 100644 index 0000000..8d6b9ad Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icons_infor_edit.png differ diff --git a/app/src/main/res/mipmap-hdpi/icons_infor_off_bell.png b/app/src/main/res/mipmap-hdpi/icons_infor_off_bell.png new file mode 100644 index 0000000..0420e0c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icons_infor_off_bell.png differ diff --git a/app/src/main/res/mipmap-hdpi/icons_infor_off_eye.png b/app/src/main/res/mipmap-hdpi/icons_infor_off_eye.png new file mode 100644 index 0000000..70a3d87 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icons_infor_off_eye.png differ diff --git a/app/src/main/res/mipmap-hdpi/invalid_name.png b/app/src/main/res/mipmap-hdpi/invalid_name.png new file mode 100644 index 0000000..63c8943 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/invalid_name.png differ diff --git a/app/src/main/res/mipmap-hdpi/invalid_name_1.png b/app/src/main/res/mipmap-hdpi/invalid_name_1.png new file mode 100644 index 0000000..a7d294d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/invalid_name_1.png differ diff --git a/app/src/main/res/mipmap-hdpi/invalid_name_10.png b/app/src/main/res/mipmap-hdpi/invalid_name_10.png new file mode 100644 index 0000000..1643971 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/invalid_name_10.png differ diff --git a/app/src/main/res/mipmap-hdpi/invalid_name_11.png b/app/src/main/res/mipmap-hdpi/invalid_name_11.png new file mode 100644 index 0000000..3d32527 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/invalid_name_11.png differ diff --git a/app/src/main/res/mipmap-hdpi/invalid_name_12.png b/app/src/main/res/mipmap-hdpi/invalid_name_12.png new file mode 100644 index 0000000..255627c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/invalid_name_12.png differ diff --git a/app/src/main/res/mipmap-hdpi/invalid_name_2.png b/app/src/main/res/mipmap-hdpi/invalid_name_2.png new file mode 100644 index 0000000..121d398 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/invalid_name_2.png differ diff --git a/app/src/main/res/mipmap-hdpi/invalid_name_3.png b/app/src/main/res/mipmap-hdpi/invalid_name_3.png new file mode 100644 index 0000000..cb7579e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/invalid_name_3.png differ diff --git a/app/src/main/res/mipmap-hdpi/invalid_name_4.png b/app/src/main/res/mipmap-hdpi/invalid_name_4.png new file mode 100644 index 0000000..b2997a6 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/invalid_name_4.png differ diff --git a/app/src/main/res/mipmap-hdpi/invalid_name_5.png b/app/src/main/res/mipmap-hdpi/invalid_name_5.png new file mode 100644 index 0000000..8e01af3 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/invalid_name_5.png differ diff --git a/app/src/main/res/mipmap-hdpi/invalid_name_6.png b/app/src/main/res/mipmap-hdpi/invalid_name_6.png new file mode 100644 index 0000000..f5c97d9 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/invalid_name_6.png differ diff --git a/app/src/main/res/mipmap-hdpi/invalid_name_7.png b/app/src/main/res/mipmap-hdpi/invalid_name_7.png new file mode 100644 index 0000000..5c68d2d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/invalid_name_7.png differ diff --git a/app/src/main/res/mipmap-hdpi/invalid_name_8.png b/app/src/main/res/mipmap-hdpi/invalid_name_8.png new file mode 100644 index 0000000..5fc8c63 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/invalid_name_8.png differ diff --git a/app/src/main/res/mipmap-hdpi/invalid_name_9.png b/app/src/main/res/mipmap-hdpi/invalid_name_9.png new file mode 100644 index 0000000..cf221d4 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/invalid_name_9.png differ diff --git a/app/src/main/res/mipmap-hdpi/kp_bj_img.png b/app/src/main/res/mipmap-hdpi/kp_bj_img.png new file mode 100644 index 0000000..3aa4e38 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/kp_bj_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/kp_logo_img.png b/app/src/main/res/mipmap-hdpi/kp_logo_img.png new file mode 100644 index 0000000..a0d543d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/kp_logo_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/kp_p_img.png b/app/src/main/res/mipmap-hdpi/kp_p_img.png new file mode 100644 index 0000000..acf9857 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/kp_p_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qs_ai_qs_as_img.png b/app/src/main/res/mipmap-hdpi/qs_ai_qs_as_img.png new file mode 100644 index 0000000..44bb40d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qs_ai_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qs_ai_qs_img.png b/app/src/main/res/mipmap-hdpi/qs_ai_qs_img.png new file mode 100644 index 0000000..0f477f4 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qs_ai_qs_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qs_dt_qs_as_img.png b/app/src/main/res/mipmap-hdpi/qs_dt_qs_as_img.png new file mode 100644 index 0000000..6267e26 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qs_dt_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qs_dt_qs_img.png b/app/src/main/res/mipmap-hdpi/qs_dt_qs_img.png new file mode 100644 index 0000000..7b7e130 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qs_dt_qs_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qs_dt_qs_img_2.png b/app/src/main/res/mipmap-hdpi/qs_dt_qs_img_2.png new file mode 100644 index 0000000..4b765d0 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qs_dt_qs_img_2.png differ diff --git a/app/src/main/res/mipmap-hdpi/qs_plq_qs_img.png b/app/src/main/res/mipmap-hdpi/qs_plq_qs_img.png new file mode 100644 index 0000000..00a2701 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qs_plq_qs_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qs_py_qs_as_img.png b/app/src/main/res/mipmap-hdpi/qs_py_qs_as_img.png new file mode 100644 index 0000000..4385471 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qs_py_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qs_py_qs_img.png b/app/src/main/res/mipmap-hdpi/qs_py_qs_img.png new file mode 100644 index 0000000..d691048 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qs_py_qs_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qs_ql_qs_as_img.png b/app/src/main/res/mipmap-hdpi/qs_ql_qs_as_img.png new file mode 100644 index 0000000..3ae351f Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qs_ql_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qs_ql_qs_as_img_2.png b/app/src/main/res/mipmap-hdpi/qs_ql_qs_as_img_2.png new file mode 100644 index 0000000..853c850 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qs_ql_qs_as_img_2.png differ diff --git a/app/src/main/res/mipmap-hdpi/qs_ql_qs_img.png b/app/src/main/res/mipmap-hdpi/qs_ql_qs_img.png new file mode 100644 index 0000000..7293cbf Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qs_ql_qs_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qs_znt_qs_as_img.png b/app/src/main/res/mipmap-hdpi/qs_znt_qs_as_img.png new file mode 100644 index 0000000..74884b1 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qs_znt_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qs_znt_qs_img.png b/app/src/main/res/mipmap-hdpi/qs_znt_qs_img.png new file mode 100644 index 0000000..21257f6 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qs_znt_qs_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qst_fs_qs_as_img.png b/app/src/main/res/mipmap-hdpi/qst_fs_qs_as_img.png new file mode 100644 index 0000000..8a58e3a Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qst_fs_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qst_fs_qs_img.png b/app/src/main/res/mipmap-hdpi/qst_fs_qs_img.png new file mode 100644 index 0000000..2fc57ca Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qst_fs_qs_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qst_gz_qs_as_img.png b/app/src/main/res/mipmap-hdpi/qst_gz_qs_as_img.png new file mode 100644 index 0000000..2dbdf2a Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qst_gz_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qst_gz_qs_as_img_my.png b/app/src/main/res/mipmap-hdpi/qst_gz_qs_as_img_my.png new file mode 100644 index 0000000..b5e85f5 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qst_gz_qs_as_img_my.png differ diff --git a/app/src/main/res/mipmap-hdpi/qst_gz_qs_img.png b/app/src/main/res/mipmap-hdpi/qst_gz_qs_img.png new file mode 100644 index 0000000..a7fc7e9 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qst_gz_qs_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qst_gz_qs_img_my.png b/app/src/main/res/mipmap-hdpi/qst_gz_qs_img_my.png new file mode 100644 index 0000000..c23ab05 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qst_gz_qs_img_my.png differ diff --git a/app/src/main/res/mipmap-hdpi/qst_pl_qs_as_img.png b/app/src/main/res/mipmap-hdpi/qst_pl_qs_as_img.png new file mode 100644 index 0000000..f74dfb7 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qst_pl_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qst_pl_qs_img.png b/app/src/main/res/mipmap-hdpi/qst_pl_qs_img.png new file mode 100644 index 0000000..f74dfb7 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qst_pl_qs_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qst_wldk_qs_img.png b/app/src/main/res/mipmap-hdpi/qst_wldk_qs_img.png new file mode 100644 index 0000000..302647c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qst_wldk_qs_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qst_z_qs_as_img.png b/app/src/main/res/mipmap-hdpi/qst_z_qs_as_img.png new file mode 100644 index 0000000..912018d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qst_z_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/qst_z_qs_img.png b/app/src/main/res/mipmap-hdpi/qst_z_qs_img.png new file mode 100644 index 0000000..5f3a8cc Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/qst_z_qs_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_blue_bg_big.webp b/app/src/main/res/mipmap-hdpi/rider_pro_blue_bg_big.webp new file mode 100644 index 0000000..d437efd Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_blue_bg_big.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_btn_bg_grey.png b/app/src/main/res/mipmap-hdpi/rider_pro_btn_bg_grey.png new file mode 100644 index 0000000..389055e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_btn_bg_grey.png differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_change_password.png b/app/src/main/res/mipmap-hdpi/rider_pro_change_password.png new file mode 100644 index 0000000..e1a3c82 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_change_password.png differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_color_logo.webp b/app/src/main/res/mipmap-hdpi/rider_pro_color_logo.webp new file mode 100644 index 0000000..30baf4c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_color_logo.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_email.webp b/app/src/main/res/mipmap-hdpi/rider_pro_email.webp new file mode 100644 index 0000000..05d3ca3 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_email.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_follow_grey.png b/app/src/main/res/mipmap-hdpi/rider_pro_follow_grey.png new file mode 100644 index 0000000..389055e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_follow_grey.png differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_follow_red.png b/app/src/main/res/mipmap-hdpi/rider_pro_follow_red.png new file mode 100644 index 0000000..fa8e520 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_follow_red.png differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_followers_empty.png b/app/src/main/res/mipmap-hdpi/rider_pro_followers_empty.png new file mode 100644 index 0000000..600391e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_followers_empty.png differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_following_empty.png b/app/src/main/res/mipmap-hdpi/rider_pro_following_empty.png new file mode 100644 index 0000000..90880e9 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_following_empty.png differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_grey_bg_big.webp b/app/src/main/res/mipmap-hdpi/rider_pro_grey_bg_big.webp new file mode 100644 index 0000000..0e1573f Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_grey_bg_big.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_input_error.png b/app/src/main/res/mipmap-hdpi/rider_pro_input_error.png new file mode 100644 index 0000000..116b2b1 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_input_error.png differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_like_empty.png b/app/src/main/res/mipmap-hdpi/rider_pro_like_empty.png new file mode 100644 index 0000000..b6ddd11 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_like_empty.png differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_log.webp b/app/src/main/res/mipmap-hdpi/rider_pro_log.webp new file mode 100644 index 0000000..aa034ff Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_log.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_log_foreground.webp b/app/src/main/res/mipmap-hdpi/rider_pro_log_foreground.webp new file mode 100644 index 0000000..6673b8f Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_log_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_log_round.webp b/app/src/main/res/mipmap-hdpi/rider_pro_log_round.webp new file mode 100644 index 0000000..e4743cb Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_log_round.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_login_bg.webp b/app/src/main/res/mipmap-hdpi/rider_pro_login_bg.webp new file mode 100644 index 0000000..dde9324 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_login_bg.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_logo.png b/app/src/main/res/mipmap-hdpi/rider_pro_logo.png new file mode 100644 index 0000000..fcb6ffa Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_logo.png differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_logo_next.webp b/app/src/main/res/mipmap-hdpi/rider_pro_logo_next.webp new file mode 100644 index 0000000..a502dcc Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_logo_next.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_logo_next_background.webp b/app/src/main/res/mipmap-hdpi/rider_pro_logo_next_background.webp new file mode 100644 index 0000000..645e1f0 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_logo_next_background.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_logo_next_foreground.webp b/app/src/main/res/mipmap-hdpi/rider_pro_logo_next_foreground.webp new file mode 100644 index 0000000..83fdd8f Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_logo_next_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_logo_next_round.webp b/app/src/main/res/mipmap-hdpi/rider_pro_logo_next_round.webp new file mode 100644 index 0000000..3356293 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_logo_next_round.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_logo_red.webp b/app/src/main/res/mipmap-hdpi/rider_pro_logo_red.webp new file mode 100644 index 0000000..abf9d81 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_logo_red.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_logo_red_foreground.webp b/app/src/main/res/mipmap-hdpi/rider_pro_logo_red_foreground.webp new file mode 100644 index 0000000..101f204 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_logo_red_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_logo_red_round.webp b/app/src/main/res/mipmap-hdpi/rider_pro_logo_red_round.webp new file mode 100644 index 0000000..a624fbd Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_logo_red_round.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_logout.png b/app/src/main/res/mipmap-hdpi/rider_pro_logout.png new file mode 100644 index 0000000..aafc928 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_logout.png differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_red_bg_big.webp b/app/src/main/res/mipmap-hdpi/rider_pro_red_bg_big.webp new file mode 100644 index 0000000..08b8720 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_red_bg_big.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_signup_facebook.webp b/app/src/main/res/mipmap-hdpi/rider_pro_signup_facebook.webp new file mode 100644 index 0000000..be0bb27 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_signup_facebook.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_signup_facebook_bg.webp b/app/src/main/res/mipmap-hdpi/rider_pro_signup_facebook_bg.webp new file mode 100644 index 0000000..d437efd Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_signup_facebook_bg.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_signup_google.webp b/app/src/main/res/mipmap-hdpi/rider_pro_signup_google.webp new file mode 100644 index 0000000..e26a45b Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_signup_google.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_signup_red_bg.webp b/app/src/main/res/mipmap-hdpi/rider_pro_signup_red_bg.webp new file mode 100644 index 0000000..f965e8a Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_signup_red_bg.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_signup_white_bg.webp b/app/src/main/res/mipmap-hdpi/rider_pro_signup_white_bg.webp new file mode 100644 index 0000000..71350e0 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_signup_white_bg.webp differ diff --git a/app/src/main/res/mipmap-hdpi/rider_pro_update_header.png b/app/src/main/res/mipmap-hdpi/rider_pro_update_header.png new file mode 100644 index 0000000..b7ac5bb Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/rider_pro_update_header.png differ diff --git a/app/src/main/res/mipmap-hdpi/syss_yh_qs_as_img.png b/app/src/main/res/mipmap-hdpi/syss_yh_qs_as_img.png new file mode 100644 index 0000000..c874e60 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/syss_yh_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/syss_yh_qs_img.png b/app/src/main/res/mipmap-hdpi/syss_yh_qs_img.png new file mode 100644 index 0000000..5c948c2 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/syss_yh_qs_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/underline.png b/app/src/main/res/mipmap-hdpi/underline.png new file mode 100644 index 0000000..a6a5ede Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/underline.png differ diff --git a/app/src/main/res/mipmap-mdpi/ai.png b/app/src/main/res/mipmap-mdpi/ai.png new file mode 100644 index 0000000..8a9ac0d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ai.png differ diff --git a/app/src/main/res/mipmap-mdpi/apple_logo_medium.png b/app/src/main/res/mipmap-mdpi/apple_logo_medium.png new file mode 100644 index 0000000..2140eeb Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/apple_logo_medium.png differ diff --git a/app/src/main/res/mipmap-mdpi/arrow.png b/app/src/main/res/mipmap-mdpi/arrow.png new file mode 100644 index 0000000..ac2cc19 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/arrow.png differ diff --git a/app/src/main/res/mipmap-mdpi/bars_x_buttons_chat_s.png b/app/src/main/res/mipmap-mdpi/bars_x_buttons_chat_s.png new file mode 100644 index 0000000..29acc61 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/bars_x_buttons_chat_s.png differ diff --git a/app/src/main/res/mipmap-mdpi/bars_x_buttons_discover_bold.png b/app/src/main/res/mipmap-mdpi/bars_x_buttons_discover_bold.png new file mode 100644 index 0000000..e7f5616 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/bars_x_buttons_discover_bold.png differ diff --git a/app/src/main/res/mipmap-mdpi/bars_x_buttons_discover_fill.png b/app/src/main/res/mipmap-mdpi/bars_x_buttons_discover_fill.png new file mode 100644 index 0000000..a7cd9de Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/bars_x_buttons_discover_fill.png differ diff --git a/app/src/main/res/mipmap-mdpi/bars_x_buttons_home_n_copy.png b/app/src/main/res/mipmap-mdpi/bars_x_buttons_home_n_copy.png new file mode 100644 index 0000000..ed1c6c3 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/bars_x_buttons_home_n_copy.png differ diff --git a/app/src/main/res/mipmap-mdpi/bars_x_buttons_home_n_copy_2.png b/app/src/main/res/mipmap-mdpi/bars_x_buttons_home_n_copy_2.png new file mode 100644 index 0000000..ed956f3 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/bars_x_buttons_home_n_copy_2.png differ diff --git a/app/src/main/res/mipmap-mdpi/bars_x_buttons_home_s.png b/app/src/main/res/mipmap-mdpi/bars_x_buttons_home_s.png new file mode 100644 index 0000000..5fe74dd Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/bars_x_buttons_home_s.png differ diff --git a/app/src/main/res/mipmap-mdpi/bars_x_buttons_user_s.png b/app/src/main/res/mipmap-mdpi/bars_x_buttons_user_s.png new file mode 100644 index 0000000..20a9cb7 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/bars_x_buttons_user_s.png differ diff --git a/app/src/main/res/mipmap-mdpi/btn.png b/app/src/main/res/mipmap-mdpi/btn.png new file mode 100644 index 0000000..1995d1a Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/btn.png differ diff --git a/app/src/main/res/mipmap-mdpi/fill_and_sign.png b/app/src/main/res/mipmap-mdpi/fill_and_sign.png new file mode 100644 index 0000000..4df82ab Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/fill_and_sign.png differ diff --git a/app/src/main/res/mipmap-mdpi/group_copy.png b/app/src/main/res/mipmap-mdpi/group_copy.png new file mode 100644 index 0000000..a41a9b0 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/group_copy.png differ diff --git a/app/src/main/res/mipmap-mdpi/h_cj_rw_icon.png b/app/src/main/res/mipmap-mdpi/h_cj_rw_icon.png new file mode 100644 index 0000000..fd7f900 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/h_cj_rw_icon.png differ diff --git a/app/src/main/res/mipmap-mdpi/h_cj_x_img.png b/app/src/main/res/mipmap-mdpi/h_cj_x_img.png new file mode 100644 index 0000000..b43f6bb Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/h_cj_x_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/icon_collect.png b/app/src/main/res/mipmap-mdpi/icon_collect.png new file mode 100644 index 0000000..26ca05a Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icon_collect.png differ diff --git a/app/src/main/res/mipmap-mdpi/icon_comment.png b/app/src/main/res/mipmap-mdpi/icon_comment.png new file mode 100644 index 0000000..5b37ed3 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icon_comment.png differ diff --git a/app/src/main/res/mipmap-mdpi/icon_share.png b/app/src/main/res/mipmap-mdpi/icon_share.png new file mode 100644 index 0000000..ef60463 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icon_share.png differ diff --git a/app/src/main/res/mipmap-mdpi/icon_variant_2.png b/app/src/main/res/mipmap-mdpi/icon_variant_2.png new file mode 100644 index 0000000..061bade Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icon_variant_2.png differ diff --git a/app/src/main/res/mipmap-mdpi/icons_circle_ai.png b/app/src/main/res/mipmap-mdpi/icons_circle_ai.png new file mode 100644 index 0000000..3e0dd81 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icons_circle_ai.png differ diff --git a/app/src/main/res/mipmap-mdpi/icons_circle_camera.png b/app/src/main/res/mipmap-mdpi/icons_circle_camera.png new file mode 100644 index 0000000..1440067 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icons_circle_camera.png differ diff --git a/app/src/main/res/mipmap-mdpi/icons_info_magic.png b/app/src/main/res/mipmap-mdpi/icons_info_magic.png new file mode 100644 index 0000000..f7e6144 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icons_info_magic.png differ diff --git a/app/src/main/res/mipmap-mdpi/icons_infor_edit.png b/app/src/main/res/mipmap-mdpi/icons_infor_edit.png new file mode 100644 index 0000000..e23fb35 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icons_infor_edit.png differ diff --git a/app/src/main/res/mipmap-mdpi/icons_infor_off_bell.png b/app/src/main/res/mipmap-mdpi/icons_infor_off_bell.png new file mode 100644 index 0000000..5523b18 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icons_infor_off_bell.png differ diff --git a/app/src/main/res/mipmap-mdpi/icons_infor_off_eye.png b/app/src/main/res/mipmap-mdpi/icons_infor_off_eye.png new file mode 100644 index 0000000..897fe10 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icons_infor_off_eye.png differ diff --git a/app/src/main/res/mipmap-mdpi/invalid_name.png b/app/src/main/res/mipmap-mdpi/invalid_name.png new file mode 100644 index 0000000..b87ad16 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/invalid_name.png differ diff --git a/app/src/main/res/mipmap-mdpi/invalid_name_1.png b/app/src/main/res/mipmap-mdpi/invalid_name_1.png new file mode 100644 index 0000000..84cb62f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/invalid_name_1.png differ diff --git a/app/src/main/res/mipmap-mdpi/invalid_name_10.png b/app/src/main/res/mipmap-mdpi/invalid_name_10.png new file mode 100644 index 0000000..3e6178f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/invalid_name_10.png differ diff --git a/app/src/main/res/mipmap-mdpi/invalid_name_11.png b/app/src/main/res/mipmap-mdpi/invalid_name_11.png new file mode 100644 index 0000000..015bf94 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/invalid_name_11.png differ diff --git a/app/src/main/res/mipmap-mdpi/invalid_name_12.png b/app/src/main/res/mipmap-mdpi/invalid_name_12.png new file mode 100644 index 0000000..e4337ff Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/invalid_name_12.png differ diff --git a/app/src/main/res/mipmap-mdpi/invalid_name_2.png b/app/src/main/res/mipmap-mdpi/invalid_name_2.png new file mode 100644 index 0000000..f662697 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/invalid_name_2.png differ diff --git a/app/src/main/res/mipmap-mdpi/invalid_name_3.png b/app/src/main/res/mipmap-mdpi/invalid_name_3.png new file mode 100644 index 0000000..abc4cef Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/invalid_name_3.png differ diff --git a/app/src/main/res/mipmap-mdpi/invalid_name_4.png b/app/src/main/res/mipmap-mdpi/invalid_name_4.png new file mode 100644 index 0000000..2da73ee Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/invalid_name_4.png differ diff --git a/app/src/main/res/mipmap-mdpi/invalid_name_5.png b/app/src/main/res/mipmap-mdpi/invalid_name_5.png new file mode 100644 index 0000000..43fb66e Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/invalid_name_5.png differ diff --git a/app/src/main/res/mipmap-mdpi/invalid_name_6.png b/app/src/main/res/mipmap-mdpi/invalid_name_6.png new file mode 100644 index 0000000..d3c59f7 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/invalid_name_6.png differ diff --git a/app/src/main/res/mipmap-mdpi/invalid_name_7.png b/app/src/main/res/mipmap-mdpi/invalid_name_7.png new file mode 100644 index 0000000..56d7f14 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/invalid_name_7.png differ diff --git a/app/src/main/res/mipmap-mdpi/invalid_name_8.png b/app/src/main/res/mipmap-mdpi/invalid_name_8.png new file mode 100644 index 0000000..d77b87e Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/invalid_name_8.png differ diff --git a/app/src/main/res/mipmap-mdpi/invalid_name_9.png b/app/src/main/res/mipmap-mdpi/invalid_name_9.png new file mode 100644 index 0000000..08af793 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/invalid_name_9.png differ diff --git a/app/src/main/res/mipmap-mdpi/kp_bj_img.png b/app/src/main/res/mipmap-mdpi/kp_bj_img.png new file mode 100644 index 0000000..32282e1 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/kp_bj_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/kp_logo_img.png b/app/src/main/res/mipmap-mdpi/kp_logo_img.png new file mode 100644 index 0000000..eb97cef Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/kp_logo_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/kp_p_img.png b/app/src/main/res/mipmap-mdpi/kp_p_img.png new file mode 100644 index 0000000..e348b5d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/kp_p_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qs_ai_qs_as_img.png b/app/src/main/res/mipmap-mdpi/qs_ai_qs_as_img.png new file mode 100644 index 0000000..094a3ba Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qs_ai_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qs_ai_qs_img.png b/app/src/main/res/mipmap-mdpi/qs_ai_qs_img.png new file mode 100644 index 0000000..7e7b224 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qs_ai_qs_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qs_dt_qs_as_img.png b/app/src/main/res/mipmap-mdpi/qs_dt_qs_as_img.png new file mode 100644 index 0000000..1f0342c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qs_dt_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qs_dt_qs_img.png b/app/src/main/res/mipmap-mdpi/qs_dt_qs_img.png new file mode 100644 index 0000000..3c812e2 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qs_dt_qs_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qs_dt_qs_img_2.png b/app/src/main/res/mipmap-mdpi/qs_dt_qs_img_2.png new file mode 100644 index 0000000..718aca6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qs_dt_qs_img_2.png differ diff --git a/app/src/main/res/mipmap-mdpi/qs_plq_qs_img.png b/app/src/main/res/mipmap-mdpi/qs_plq_qs_img.png new file mode 100644 index 0000000..22df75f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qs_plq_qs_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qs_py_qs_as_img.png b/app/src/main/res/mipmap-mdpi/qs_py_qs_as_img.png new file mode 100644 index 0000000..2e15631 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qs_py_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qs_py_qs_img.png b/app/src/main/res/mipmap-mdpi/qs_py_qs_img.png new file mode 100644 index 0000000..1db24a6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qs_py_qs_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qs_ql_qs_as_img.png b/app/src/main/res/mipmap-mdpi/qs_ql_qs_as_img.png new file mode 100644 index 0000000..23a1771 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qs_ql_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qs_ql_qs_as_img_2.png b/app/src/main/res/mipmap-mdpi/qs_ql_qs_as_img_2.png new file mode 100644 index 0000000..fb261b8 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qs_ql_qs_as_img_2.png differ diff --git a/app/src/main/res/mipmap-mdpi/qs_ql_qs_img.png b/app/src/main/res/mipmap-mdpi/qs_ql_qs_img.png new file mode 100644 index 0000000..7098d4a Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qs_ql_qs_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qs_znt_qs_as_img.png b/app/src/main/res/mipmap-mdpi/qs_znt_qs_as_img.png new file mode 100644 index 0000000..1b87f89 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qs_znt_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qs_znt_qs_img.png b/app/src/main/res/mipmap-mdpi/qs_znt_qs_img.png new file mode 100644 index 0000000..66f1bc1 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qs_znt_qs_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qst_fs_qs_as_img.png b/app/src/main/res/mipmap-mdpi/qst_fs_qs_as_img.png new file mode 100644 index 0000000..3e58ec6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qst_fs_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qst_fs_qs_img.png b/app/src/main/res/mipmap-mdpi/qst_fs_qs_img.png new file mode 100644 index 0000000..b2a55a4 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qst_fs_qs_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qst_gz_qs_as_img.png b/app/src/main/res/mipmap-mdpi/qst_gz_qs_as_img.png new file mode 100644 index 0000000..11ed22b Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qst_gz_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qst_gz_qs_as_img_my.png b/app/src/main/res/mipmap-mdpi/qst_gz_qs_as_img_my.png new file mode 100644 index 0000000..2a8d33d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qst_gz_qs_as_img_my.png differ diff --git a/app/src/main/res/mipmap-mdpi/qst_gz_qs_img.png b/app/src/main/res/mipmap-mdpi/qst_gz_qs_img.png new file mode 100644 index 0000000..ff44dcd Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qst_gz_qs_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qst_gz_qs_img_my.png b/app/src/main/res/mipmap-mdpi/qst_gz_qs_img_my.png new file mode 100644 index 0000000..af9f179 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qst_gz_qs_img_my.png differ diff --git a/app/src/main/res/mipmap-mdpi/qst_pl_qs_as_img.png b/app/src/main/res/mipmap-mdpi/qst_pl_qs_as_img.png new file mode 100644 index 0000000..2e6a1d7 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qst_pl_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qst_pl_qs_img.png b/app/src/main/res/mipmap-mdpi/qst_pl_qs_img.png new file mode 100644 index 0000000..2e6a1d7 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qst_pl_qs_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qst_wldk_qs_img.png b/app/src/main/res/mipmap-mdpi/qst_wldk_qs_img.png new file mode 100644 index 0000000..fc9bebe Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qst_wldk_qs_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qst_z_qs_as_img.png b/app/src/main/res/mipmap-mdpi/qst_z_qs_as_img.png new file mode 100644 index 0000000..48ab241 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qst_z_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/qst_z_qs_img.png b/app/src/main/res/mipmap-mdpi/qst_z_qs_img.png new file mode 100644 index 0000000..4cedc04 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/qst_z_qs_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_blue_bg_big.webp b/app/src/main/res/mipmap-mdpi/rider_pro_blue_bg_big.webp new file mode 100644 index 0000000..2f4cc4c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_blue_bg_big.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_btn_bg_grey.png b/app/src/main/res/mipmap-mdpi/rider_pro_btn_bg_grey.png new file mode 100644 index 0000000..d93789f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_btn_bg_grey.png differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_change_password.png b/app/src/main/res/mipmap-mdpi/rider_pro_change_password.png new file mode 100644 index 0000000..15bde69 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_change_password.png differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_color_logo.webp b/app/src/main/res/mipmap-mdpi/rider_pro_color_logo.webp new file mode 100644 index 0000000..0c0af28 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_color_logo.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_color_logo_next.png b/app/src/main/res/mipmap-mdpi/rider_pro_color_logo_next.png new file mode 100644 index 0000000..7364953 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_color_logo_next.png differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_email.webp b/app/src/main/res/mipmap-mdpi/rider_pro_email.webp new file mode 100644 index 0000000..54daedd Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_email.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_follow_grey.png b/app/src/main/res/mipmap-mdpi/rider_pro_follow_grey.png new file mode 100644 index 0000000..d93789f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_follow_grey.png differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_follow_red.png b/app/src/main/res/mipmap-mdpi/rider_pro_follow_red.png new file mode 100644 index 0000000..0f13f89 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_follow_red.png differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_followers_empty.png b/app/src/main/res/mipmap-mdpi/rider_pro_followers_empty.png new file mode 100644 index 0000000..b61560a Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_followers_empty.png differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_following_empty.png b/app/src/main/res/mipmap-mdpi/rider_pro_following_empty.png new file mode 100644 index 0000000..3be3743 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_following_empty.png differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_grey_bg_big.webp b/app/src/main/res/mipmap-mdpi/rider_pro_grey_bg_big.webp new file mode 100644 index 0000000..daddfd2 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_grey_bg_big.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_input_error.png b/app/src/main/res/mipmap-mdpi/rider_pro_input_error.png new file mode 100644 index 0000000..c04316c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_input_error.png differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_like_empty.png b/app/src/main/res/mipmap-mdpi/rider_pro_like_empty.png new file mode 100644 index 0000000..b446bc7 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_like_empty.png differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_log.webp b/app/src/main/res/mipmap-mdpi/rider_pro_log.webp new file mode 100644 index 0000000..6c50b9e Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_log.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_log_foreground.webp b/app/src/main/res/mipmap-mdpi/rider_pro_log_foreground.webp new file mode 100644 index 0000000..4d84f7e Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_log_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_log_round.webp b/app/src/main/res/mipmap-mdpi/rider_pro_log_round.webp new file mode 100644 index 0000000..ae74c0f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_log_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_login_bg.webp b/app/src/main/res/mipmap-mdpi/rider_pro_login_bg.webp new file mode 100644 index 0000000..18d22b9 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_login_bg.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_logo.png b/app/src/main/res/mipmap-mdpi/rider_pro_logo.png new file mode 100644 index 0000000..af61b75 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_logo.png differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_logo_next.webp b/app/src/main/res/mipmap-mdpi/rider_pro_logo_next.webp new file mode 100644 index 0000000..73f4992 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_logo_next.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_logo_next_background.webp b/app/src/main/res/mipmap-mdpi/rider_pro_logo_next_background.webp new file mode 100644 index 0000000..81ba09f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_logo_next_background.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_logo_next_foreground.webp b/app/src/main/res/mipmap-mdpi/rider_pro_logo_next_foreground.webp new file mode 100644 index 0000000..bef178a Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_logo_next_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_logo_next_round.webp b/app/src/main/res/mipmap-mdpi/rider_pro_logo_next_round.webp new file mode 100644 index 0000000..f20131d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_logo_next_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_logo_red.webp b/app/src/main/res/mipmap-mdpi/rider_pro_logo_red.webp new file mode 100644 index 0000000..c1cfa28 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_logo_red.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_logo_red_foreground.webp b/app/src/main/res/mipmap-mdpi/rider_pro_logo_red_foreground.webp new file mode 100644 index 0000000..15f7330 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_logo_red_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_logo_red_round.webp b/app/src/main/res/mipmap-mdpi/rider_pro_logo_red_round.webp new file mode 100644 index 0000000..efa25f8 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_logo_red_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_logout.png b/app/src/main/res/mipmap-mdpi/rider_pro_logout.png new file mode 100644 index 0000000..3e9c383 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_logout.png differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_red_bg_big.webp b/app/src/main/res/mipmap-mdpi/rider_pro_red_bg_big.webp new file mode 100644 index 0000000..c19c6e7 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_red_bg_big.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_signup_facebook.webp b/app/src/main/res/mipmap-mdpi/rider_pro_signup_facebook.webp new file mode 100644 index 0000000..f45a419 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_signup_facebook.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_signup_facebook_bg.webp b/app/src/main/res/mipmap-mdpi/rider_pro_signup_facebook_bg.webp new file mode 100644 index 0000000..2f4cc4c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_signup_facebook_bg.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_signup_google.webp b/app/src/main/res/mipmap-mdpi/rider_pro_signup_google.webp new file mode 100644 index 0000000..0dd9190 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_signup_google.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_signup_red_bg.webp b/app/src/main/res/mipmap-mdpi/rider_pro_signup_red_bg.webp new file mode 100644 index 0000000..3798f02 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_signup_red_bg.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_signup_white_bg.webp b/app/src/main/res/mipmap-mdpi/rider_pro_signup_white_bg.webp new file mode 100644 index 0000000..73104b2 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_signup_white_bg.webp differ diff --git a/app/src/main/res/mipmap-mdpi/rider_pro_update_header.png b/app/src/main/res/mipmap-mdpi/rider_pro_update_header.png new file mode 100644 index 0000000..e3e20d0 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/rider_pro_update_header.png differ diff --git a/app/src/main/res/mipmap-mdpi/syss_yh_qs_as_img.png b/app/src/main/res/mipmap-mdpi/syss_yh_qs_as_img.png new file mode 100644 index 0000000..e4f1d5d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/syss_yh_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/syss_yh_qs_img.png b/app/src/main/res/mipmap-mdpi/syss_yh_qs_img.png new file mode 100644 index 0000000..2c0f0aa Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/syss_yh_qs_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/underline.png b/app/src/main/res/mipmap-mdpi/underline.png new file mode 100644 index 0000000..2724725 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/underline.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ai.png b/app/src/main/res/mipmap-xhdpi/ai.png new file mode 100644 index 0000000..3b7e01a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ai.png differ diff --git a/app/src/main/res/mipmap-xhdpi/apple_logo_medium.png b/app/src/main/res/mipmap-xhdpi/apple_logo_medium.png new file mode 100644 index 0000000..d140b5c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/apple_logo_medium.png differ diff --git a/app/src/main/res/mipmap-xhdpi/arrow.png b/app/src/main/res/mipmap-xhdpi/arrow.png new file mode 100644 index 0000000..3ce527b Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/arrow.png differ diff --git a/app/src/main/res/mipmap-xhdpi/bars_x_buttons_chat_s.png b/app/src/main/res/mipmap-xhdpi/bars_x_buttons_chat_s.png new file mode 100644 index 0000000..3d87ccd Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/bars_x_buttons_chat_s.png differ diff --git a/app/src/main/res/mipmap-xhdpi/bars_x_buttons_discover_bold.png b/app/src/main/res/mipmap-xhdpi/bars_x_buttons_discover_bold.png new file mode 100644 index 0000000..95437db Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/bars_x_buttons_discover_bold.png differ diff --git a/app/src/main/res/mipmap-xhdpi/bars_x_buttons_discover_fill.png b/app/src/main/res/mipmap-xhdpi/bars_x_buttons_discover_fill.png new file mode 100644 index 0000000..70c4caf Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/bars_x_buttons_discover_fill.png differ diff --git a/app/src/main/res/mipmap-xhdpi/bars_x_buttons_home_n_copy.png b/app/src/main/res/mipmap-xhdpi/bars_x_buttons_home_n_copy.png new file mode 100644 index 0000000..c50fc24 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/bars_x_buttons_home_n_copy.png differ diff --git a/app/src/main/res/mipmap-xhdpi/bars_x_buttons_home_n_copy_2.png b/app/src/main/res/mipmap-xhdpi/bars_x_buttons_home_n_copy_2.png new file mode 100644 index 0000000..376e1af Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/bars_x_buttons_home_n_copy_2.png differ diff --git a/app/src/main/res/mipmap-xhdpi/bars_x_buttons_home_s.png b/app/src/main/res/mipmap-xhdpi/bars_x_buttons_home_s.png new file mode 100644 index 0000000..9b83c9f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/bars_x_buttons_home_s.png differ diff --git a/app/src/main/res/mipmap-xhdpi/bars_x_buttons_user_s.png b/app/src/main/res/mipmap-xhdpi/bars_x_buttons_user_s.png new file mode 100644 index 0000000..975b48b Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/bars_x_buttons_user_s.png differ diff --git a/app/src/main/res/mipmap-xhdpi/btn.png b/app/src/main/res/mipmap-xhdpi/btn.png new file mode 100644 index 0000000..46e749f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/btn.png differ diff --git a/app/src/main/res/mipmap-xhdpi/fill_and_sign.png b/app/src/main/res/mipmap-xhdpi/fill_and_sign.png new file mode 100644 index 0000000..a9c0f3a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/fill_and_sign.png differ diff --git a/app/src/main/res/mipmap-xhdpi/group_copy.png b/app/src/main/res/mipmap-xhdpi/group_copy.png new file mode 100644 index 0000000..95d297b Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/group_copy.png differ diff --git a/app/src/main/res/mipmap-xhdpi/h_cj_rw_icon.png b/app/src/main/res/mipmap-xhdpi/h_cj_rw_icon.png new file mode 100644 index 0000000..9263e18 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/h_cj_rw_icon.png differ diff --git a/app/src/main/res/mipmap-xhdpi/h_cj_x_img.png b/app/src/main/res/mipmap-xhdpi/h_cj_x_img.png new file mode 100644 index 0000000..395e3f5 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/h_cj_x_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_agent.png b/app/src/main/res/mipmap-xhdpi/ic_agent.png new file mode 100644 index 0000000..31ee9df Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_agent.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/icon_agent_chat_empty.png b/app/src/main/res/mipmap-xhdpi/icon_agent_chat_empty.png new file mode 100644 index 0000000..e9cf51c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_agent_chat_empty.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icon_collect.png b/app/src/main/res/mipmap-xhdpi/icon_collect.png new file mode 100644 index 0000000..a9034b4 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_collect.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icon_comment.png b/app/src/main/res/mipmap-xhdpi/icon_comment.png new file mode 100644 index 0000000..18ca55b Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_comment.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icon_friend_chat_empty.png b/app/src/main/res/mipmap-xhdpi/icon_friend_chat_empty.png new file mode 100644 index 0000000..c4b2ce6 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_friend_chat_empty.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icon_group_chat_empty.png b/app/src/main/res/mipmap-xhdpi/icon_group_chat_empty.png new file mode 100644 index 0000000..9fa076a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_group_chat_empty.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icon_share.png b/app/src/main/res/mipmap-xhdpi/icon_share.png new file mode 100644 index 0000000..bc9b0b9 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_share.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icon_variant_2.png b/app/src/main/res/mipmap-xhdpi/icon_variant_2.png new file mode 100644 index 0000000..b42784f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_variant_2.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icons_circle_ai.png b/app/src/main/res/mipmap-xhdpi/icons_circle_ai.png new file mode 100644 index 0000000..437c152 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icons_circle_ai.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icons_circle_camera.png b/app/src/main/res/mipmap-xhdpi/icons_circle_camera.png new file mode 100644 index 0000000..3212181 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icons_circle_camera.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icons_info_magic.png b/app/src/main/res/mipmap-xhdpi/icons_info_magic.png new file mode 100644 index 0000000..baa1ba9 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icons_info_magic.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icons_infor_edit.png b/app/src/main/res/mipmap-xhdpi/icons_infor_edit.png new file mode 100644 index 0000000..a50f2f7 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icons_infor_edit.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icons_infor_off_bell.png b/app/src/main/res/mipmap-xhdpi/icons_infor_off_bell.png new file mode 100644 index 0000000..8af06a7 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icons_infor_off_bell.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icons_infor_off_eye.png b/app/src/main/res/mipmap-xhdpi/icons_infor_off_eye.png new file mode 100644 index 0000000..2e9c078 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icons_infor_off_eye.png differ diff --git a/app/src/main/res/mipmap-xhdpi/invalid_name.png b/app/src/main/res/mipmap-xhdpi/invalid_name.png new file mode 100644 index 0000000..30ad96a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/invalid_name.png differ diff --git a/app/src/main/res/mipmap-xhdpi/invalid_name_1.png b/app/src/main/res/mipmap-xhdpi/invalid_name_1.png new file mode 100644 index 0000000..b9699b3 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/invalid_name_1.png differ diff --git a/app/src/main/res/mipmap-xhdpi/invalid_name_10.png b/app/src/main/res/mipmap-xhdpi/invalid_name_10.png new file mode 100644 index 0000000..090213c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/invalid_name_10.png differ diff --git a/app/src/main/res/mipmap-xhdpi/invalid_name_11.png b/app/src/main/res/mipmap-xhdpi/invalid_name_11.png new file mode 100644 index 0000000..d0bf98d Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/invalid_name_11.png differ diff --git a/app/src/main/res/mipmap-xhdpi/invalid_name_12.png b/app/src/main/res/mipmap-xhdpi/invalid_name_12.png new file mode 100644 index 0000000..3098629 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/invalid_name_12.png differ diff --git a/app/src/main/res/mipmap-xhdpi/invalid_name_2.png b/app/src/main/res/mipmap-xhdpi/invalid_name_2.png new file mode 100644 index 0000000..5f5b7c5 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/invalid_name_2.png differ diff --git a/app/src/main/res/mipmap-xhdpi/invalid_name_3.png b/app/src/main/res/mipmap-xhdpi/invalid_name_3.png new file mode 100644 index 0000000..2f969e3 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/invalid_name_3.png differ diff --git a/app/src/main/res/mipmap-xhdpi/invalid_name_4.png b/app/src/main/res/mipmap-xhdpi/invalid_name_4.png new file mode 100644 index 0000000..8b59b29 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/invalid_name_4.png differ diff --git a/app/src/main/res/mipmap-xhdpi/invalid_name_5.png b/app/src/main/res/mipmap-xhdpi/invalid_name_5.png new file mode 100644 index 0000000..0f5f889 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/invalid_name_5.png differ diff --git a/app/src/main/res/mipmap-xhdpi/invalid_name_6.png b/app/src/main/res/mipmap-xhdpi/invalid_name_6.png new file mode 100644 index 0000000..8e72b4a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/invalid_name_6.png differ diff --git a/app/src/main/res/mipmap-xhdpi/invalid_name_7.png b/app/src/main/res/mipmap-xhdpi/invalid_name_7.png new file mode 100644 index 0000000..e300acc Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/invalid_name_7.png differ diff --git a/app/src/main/res/mipmap-xhdpi/invalid_name_8.png b/app/src/main/res/mipmap-xhdpi/invalid_name_8.png new file mode 100644 index 0000000..ecd1b38 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/invalid_name_8.png differ diff --git a/app/src/main/res/mipmap-xhdpi/invalid_name_9.png b/app/src/main/res/mipmap-xhdpi/invalid_name_9.png new file mode 100644 index 0000000..41f5713 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/invalid_name_9.png differ diff --git a/app/src/main/res/mipmap-xhdpi/kp_bj_img.png b/app/src/main/res/mipmap-xhdpi/kp_bj_img.png new file mode 100644 index 0000000..d08cb03 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/kp_bj_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/kp_logo_img.png b/app/src/main/res/mipmap-xhdpi/kp_logo_img.png new file mode 100644 index 0000000..cd21f1a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/kp_logo_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/kp_p_img.png b/app/src/main/res/mipmap-xhdpi/kp_p_img.png new file mode 100644 index 0000000..642fcec Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/kp_p_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qs_ai_qs_as_img.png b/app/src/main/res/mipmap-xhdpi/qs_ai_qs_as_img.png new file mode 100644 index 0000000..98e40c2 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qs_ai_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qs_ai_qs_img.png b/app/src/main/res/mipmap-xhdpi/qs_ai_qs_img.png new file mode 100644 index 0000000..dd0597a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qs_ai_qs_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qs_dt_qs_as_img.png b/app/src/main/res/mipmap-xhdpi/qs_dt_qs_as_img.png new file mode 100644 index 0000000..05776f0 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qs_dt_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qs_dt_qs_img.png b/app/src/main/res/mipmap-xhdpi/qs_dt_qs_img.png new file mode 100644 index 0000000..4493e37 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qs_dt_qs_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qs_dt_qs_img_2.png b/app/src/main/res/mipmap-xhdpi/qs_dt_qs_img_2.png new file mode 100644 index 0000000..606c4f6 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qs_dt_qs_img_2.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qs_plq_qs_img.png b/app/src/main/res/mipmap-xhdpi/qs_plq_qs_img.png new file mode 100644 index 0000000..55dd526 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qs_plq_qs_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qs_py_qs_as_img.png b/app/src/main/res/mipmap-xhdpi/qs_py_qs_as_img.png new file mode 100644 index 0000000..c3a3953 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qs_py_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qs_py_qs_img.png b/app/src/main/res/mipmap-xhdpi/qs_py_qs_img.png new file mode 100644 index 0000000..0208985 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qs_py_qs_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qs_ql_qs_as_img.png b/app/src/main/res/mipmap-xhdpi/qs_ql_qs_as_img.png new file mode 100644 index 0000000..dfe3fd9 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qs_ql_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qs_ql_qs_as_img_2.png b/app/src/main/res/mipmap-xhdpi/qs_ql_qs_as_img_2.png new file mode 100644 index 0000000..a23dfd2 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qs_ql_qs_as_img_2.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qs_ql_qs_img.png b/app/src/main/res/mipmap-xhdpi/qs_ql_qs_img.png new file mode 100644 index 0000000..fe87b32 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qs_ql_qs_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qs_znt_qs_as_img.png b/app/src/main/res/mipmap-xhdpi/qs_znt_qs_as_img.png new file mode 100644 index 0000000..5c5bf19 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qs_znt_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qs_znt_qs_img.png b/app/src/main/res/mipmap-xhdpi/qs_znt_qs_img.png new file mode 100644 index 0000000..0faa1df Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qs_znt_qs_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qst_fs_qs_as_img.png b/app/src/main/res/mipmap-xhdpi/qst_fs_qs_as_img.png new file mode 100644 index 0000000..234eef8 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qst_fs_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qst_fs_qs_img.png b/app/src/main/res/mipmap-xhdpi/qst_fs_qs_img.png new file mode 100644 index 0000000..f4b1958 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qst_fs_qs_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qst_gz_qs_as_img.png b/app/src/main/res/mipmap-xhdpi/qst_gz_qs_as_img.png new file mode 100644 index 0000000..7d81370 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qst_gz_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qst_gz_qs_as_img_my.png b/app/src/main/res/mipmap-xhdpi/qst_gz_qs_as_img_my.png new file mode 100644 index 0000000..d785555 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qst_gz_qs_as_img_my.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qst_gz_qs_img.png b/app/src/main/res/mipmap-xhdpi/qst_gz_qs_img.png new file mode 100644 index 0000000..7d17153 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qst_gz_qs_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qst_gz_qs_img_my.png b/app/src/main/res/mipmap-xhdpi/qst_gz_qs_img_my.png new file mode 100644 index 0000000..fc83d46 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qst_gz_qs_img_my.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qst_pl_qs_as_img.png b/app/src/main/res/mipmap-xhdpi/qst_pl_qs_as_img.png new file mode 100644 index 0000000..734d8c4 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qst_pl_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qst_pl_qs_img.png b/app/src/main/res/mipmap-xhdpi/qst_pl_qs_img.png new file mode 100644 index 0000000..734d8c4 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qst_pl_qs_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qst_wldk_qs_img.png b/app/src/main/res/mipmap-xhdpi/qst_wldk_qs_img.png new file mode 100644 index 0000000..67c4df9 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qst_wldk_qs_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qst_z_qs_as_img.png b/app/src/main/res/mipmap-xhdpi/qst_z_qs_as_img.png new file mode 100644 index 0000000..aefdb7f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qst_z_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/qst_z_qs_img.png b/app/src/main/res/mipmap-xhdpi/qst_z_qs_img.png new file mode 100644 index 0000000..25b5774 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/qst_z_qs_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_agent.png b/app/src/main/res/mipmap-xhdpi/rider_pro_agent.png new file mode 100644 index 0000000..3c797e8 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_agent.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_agent2.png b/app/src/main/res/mipmap-xhdpi/rider_pro_agent2.png new file mode 100644 index 0000000..85b68c2 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_agent2.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_agent_avatar.png b/app/src/main/res/mipmap-xhdpi/rider_pro_agent_avatar.png new file mode 100644 index 0000000..a21f923 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_agent_avatar.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_bg_add_agent_.png b/app/src/main/res/mipmap-xhdpi/rider_pro_bg_add_agent_.png new file mode 100644 index 0000000..8029a2c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_bg_add_agent_.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_blue_bg_big.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_blue_bg_big.webp new file mode 100644 index 0000000..b8f432a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_blue_bg_big.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_btn_bg_grey.png b/app/src/main/res/mipmap-xhdpi/rider_pro_btn_bg_grey.png new file mode 100644 index 0000000..daeb87a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_btn_bg_grey.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_change_password.png b/app/src/main/res/mipmap-xhdpi/rider_pro_change_password.png new file mode 100644 index 0000000..c14da34 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_change_password.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_color_logo.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_color_logo.webp new file mode 100644 index 0000000..775e4a6 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_color_logo.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_color_logo_next.png b/app/src/main/res/mipmap-xhdpi/rider_pro_color_logo_next.png new file mode 100644 index 0000000..b46fe7e Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_color_logo_next.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_comment.png b/app/src/main/res/mipmap-xhdpi/rider_pro_comment.png new file mode 100644 index 0000000..7ce9729 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_comment.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_email.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_email.webp new file mode 100644 index 0000000..103b30c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_email.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_fire.png b/app/src/main/res/mipmap-xhdpi/rider_pro_fire.png new file mode 100644 index 0000000..68413a5 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_fire.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_fire2.png b/app/src/main/res/mipmap-xhdpi/rider_pro_fire2.png new file mode 100644 index 0000000..87abc1f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_fire2.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_follow_grey.png b/app/src/main/res/mipmap-xhdpi/rider_pro_follow_grey.png new file mode 100644 index 0000000..daeb87a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_follow_grey.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_follow_red.png b/app/src/main/res/mipmap-xhdpi/rider_pro_follow_red.png new file mode 100644 index 0000000..f821178 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_follow_red.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_followers.png b/app/src/main/res/mipmap-xhdpi/rider_pro_followers.png new file mode 100644 index 0000000..1ccd3b9 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_followers.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_followers_empty.png b/app/src/main/res/mipmap-xhdpi/rider_pro_followers_empty.png new file mode 100644 index 0000000..563eb1e Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_followers_empty.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_following_empty.png b/app/src/main/res/mipmap-xhdpi/rider_pro_following_empty.png new file mode 100644 index 0000000..45aec75 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_following_empty.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_grey_bg_big.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_grey_bg_big.webp new file mode 100644 index 0000000..b1dba47 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_grey_bg_big.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_group.png b/app/src/main/res/mipmap-xhdpi/rider_pro_group.png new file mode 100644 index 0000000..b0f0e29 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_group.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_hot_room.png b/app/src/main/res/mipmap-xhdpi/rider_pro_hot_room.png new file mode 100644 index 0000000..2a6fc09 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_hot_room.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_im_image.png b/app/src/main/res/mipmap-xhdpi/rider_pro_im_image.png new file mode 100644 index 0000000..2036ace Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_im_image.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_im_send.png b/app/src/main/res/mipmap-xhdpi/rider_pro_im_send.png new file mode 100644 index 0000000..dd9eaa7 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_im_send.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_input_error.png b/app/src/main/res/mipmap-xhdpi/rider_pro_input_error.png new file mode 100644 index 0000000..dba7f9c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_input_error.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_like.png b/app/src/main/res/mipmap-xhdpi/rider_pro_like.png new file mode 100644 index 0000000..3e5e342 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_like.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_like_empty.png b/app/src/main/res/mipmap-xhdpi/rider_pro_like_empty.png new file mode 100644 index 0000000..8f14ab1 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_like_empty.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_log.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_log.webp new file mode 100644 index 0000000..647da8c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_log.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_log_foreground.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_log_foreground.webp new file mode 100644 index 0000000..d919cc9 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_log_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_log_round.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_log_round.webp new file mode 100644 index 0000000..a11ad99 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_log_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_login_bg.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_login_bg.webp new file mode 100644 index 0000000..e548147 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_login_bg.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_logo.png b/app/src/main/res/mipmap-xhdpi/rider_pro_logo.png new file mode 100644 index 0000000..53e217c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_logo.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_logo_next.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_logo_next.webp new file mode 100644 index 0000000..7933b25 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_logo_next.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_logo_next_background.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_logo_next_background.webp new file mode 100644 index 0000000..8307570 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_logo_next_background.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_logo_next_foreground.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_logo_next_foreground.webp new file mode 100644 index 0000000..29a9c33 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_logo_next_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_logo_next_round.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_logo_next_round.webp new file mode 100644 index 0000000..892c563 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_logo_next_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_logo_red.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_logo_red.webp new file mode 100644 index 0000000..0fc8206 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_logo_red.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_logo_red_foreground.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_logo_red_foreground.webp new file mode 100644 index 0000000..1a8bb73 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_logo_red_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_logo_red_round.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_logo_red_round.webp new file mode 100644 index 0000000..c47b219 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_logo_red_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_logout.png b/app/src/main/res/mipmap-xhdpi/rider_pro_logout.png new file mode 100644 index 0000000..25d434a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_logout.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_moment_ai.png b/app/src/main/res/mipmap-xhdpi/rider_pro_moment_ai.png new file mode 100644 index 0000000..851c14a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_moment_ai.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_moment_post.png b/app/src/main/res/mipmap-xhdpi/rider_pro_moment_post.png new file mode 100644 index 0000000..e64d7ab Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_moment_post.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_nav_ai_hl.png b/app/src/main/res/mipmap-xhdpi/rider_pro_nav_ai_hl.png new file mode 100644 index 0000000..6813ed5 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_nav_ai_hl.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_nav_home_hl.png b/app/src/main/res/mipmap-xhdpi/rider_pro_nav_home_hl.png new file mode 100644 index 0000000..fc3ac08 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_nav_home_hl.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_nav_message_hl.png b/app/src/main/res/mipmap-xhdpi/rider_pro_nav_message_hl.png new file mode 100644 index 0000000..fdf4490 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_nav_message_hl.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_nav_profile_hl.png b/app/src/main/res/mipmap-xhdpi/rider_pro_nav_profile_hl.png new file mode 100644 index 0000000..a666892 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_nav_profile_hl.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_red_bg_big.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_red_bg_big.webp new file mode 100644 index 0000000..824bb93 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_red_bg_big.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_release.png b/app/src/main/res/mipmap-xhdpi/rider_pro_release.png new file mode 100644 index 0000000..d6cff80 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_release.png differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_signup_facebook.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_signup_facebook.webp new file mode 100644 index 0000000..853e57c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_signup_facebook.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_signup_facebook_bg.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_signup_facebook_bg.webp new file mode 100644 index 0000000..b8f432a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_signup_facebook_bg.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_signup_google.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_signup_google.webp new file mode 100644 index 0000000..3645c65 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_signup_google.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_signup_red_bg.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_signup_red_bg.webp new file mode 100644 index 0000000..ccfba97 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_signup_red_bg.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_signup_white_bg.webp b/app/src/main/res/mipmap-xhdpi/rider_pro_signup_white_bg.webp new file mode 100644 index 0000000..6d7fdee Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_signup_white_bg.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/rider_pro_update_header.png b/app/src/main/res/mipmap-xhdpi/rider_pro_update_header.png new file mode 100644 index 0000000..b7987a3 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/rider_pro_update_header.png differ diff --git a/app/src/main/res/mipmap-xhdpi/syss_yh_qs_as_img.png b/app/src/main/res/mipmap-xhdpi/syss_yh_qs_as_img.png new file mode 100644 index 0000000..f8eb896 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/syss_yh_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/syss_yh_qs_img.png b/app/src/main/res/mipmap-xhdpi/syss_yh_qs_img.png new file mode 100644 index 0000000..ba24fa6 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/syss_yh_qs_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/tab_indicator_selected.png b/app/src/main/res/mipmap-xhdpi/tab_indicator_selected.png new file mode 100644 index 0000000..3028065 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/tab_indicator_selected.png differ diff --git a/app/src/main/res/mipmap-xhdpi/underline.png b/app/src/main/res/mipmap-xhdpi/underline.png new file mode 100644 index 0000000..afe1c4e Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/underline.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ai.png b/app/src/main/res/mipmap-xxhdpi/ai.png new file mode 100644 index 0000000..06e3dcb Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ai.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/apple_logo_medium.png b/app/src/main/res/mipmap-xxhdpi/apple_logo_medium.png new file mode 100644 index 0000000..23db6f5 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/apple_logo_medium.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/arrow.png b/app/src/main/res/mipmap-xxhdpi/arrow.png new file mode 100644 index 0000000..9fed4fc Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/arrow.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_chat_s.png b/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_chat_s.png new file mode 100644 index 0000000..ffa2f2d Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_chat_s.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_discover_bold.png b/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_discover_bold.png new file mode 100644 index 0000000..10048ab Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_discover_bold.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_discover_fill.png b/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_discover_fill.png new file mode 100644 index 0000000..5f877f0 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_discover_fill.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_home_n_copy.png b/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_home_n_copy.png new file mode 100644 index 0000000..80ce98a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_home_n_copy.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_home_n_copy_2.png b/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_home_n_copy_2.png new file mode 100644 index 0000000..4a6d2ac Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_home_n_copy_2.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_home_s.png b/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_home_s.png new file mode 100644 index 0000000..c36f323 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_home_s.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_user_s.png b/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_user_s.png new file mode 100644 index 0000000..0668d81 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/bars_x_buttons_user_s.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/btn.png b/app/src/main/res/mipmap-xxhdpi/btn.png new file mode 100644 index 0000000..187c890 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/btn.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/fill_and_sign.png b/app/src/main/res/mipmap-xxhdpi/fill_and_sign.png new file mode 100644 index 0000000..0728ca8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/fill_and_sign.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/group_copy.png b/app/src/main/res/mipmap-xxhdpi/group_copy.png new file mode 100644 index 0000000..0674017 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/group_copy.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/h_cj_rw_icon.png b/app/src/main/res/mipmap-xxhdpi/h_cj_rw_icon.png new file mode 100644 index 0000000..2c44a97 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/h_cj_rw_icon.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/h_cj_x_img.png b/app/src/main/res/mipmap-xxhdpi/h_cj_x_img.png new file mode 100644 index 0000000..3982c89 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/h_cj_x_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_collect.png b/app/src/main/res/mipmap-xxhdpi/icon_collect.png new file mode 100644 index 0000000..b391726 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_collect.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_comment.png b/app/src/main/res/mipmap-xxhdpi/icon_comment.png new file mode 100644 index 0000000..f12e394 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_comment.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_share.png b/app/src/main/res/mipmap-xxhdpi/icon_share.png new file mode 100644 index 0000000..4af7cd7 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_share.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_variant_2.png b/app/src/main/res/mipmap-xxhdpi/icon_variant_2.png new file mode 100644 index 0000000..dcb5f87 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_variant_2.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icons_circle_ai.png b/app/src/main/res/mipmap-xxhdpi/icons_circle_ai.png new file mode 100644 index 0000000..1604630 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icons_circle_ai.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icons_circle_camera.png b/app/src/main/res/mipmap-xxhdpi/icons_circle_camera.png new file mode 100644 index 0000000..c873963 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icons_circle_camera.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icons_info_magic.png b/app/src/main/res/mipmap-xxhdpi/icons_info_magic.png new file mode 100644 index 0000000..15882f8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icons_info_magic.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icons_infor_edit.png b/app/src/main/res/mipmap-xxhdpi/icons_infor_edit.png new file mode 100644 index 0000000..490d87a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icons_infor_edit.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icons_infor_off_bell.png b/app/src/main/res/mipmap-xxhdpi/icons_infor_off_bell.png new file mode 100644 index 0000000..43ffbde Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icons_infor_off_bell.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icons_infor_off_eye.png b/app/src/main/res/mipmap-xxhdpi/icons_infor_off_eye.png new file mode 100644 index 0000000..b63f5cf Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icons_infor_off_eye.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/invalid_name.png b/app/src/main/res/mipmap-xxhdpi/invalid_name.png new file mode 100644 index 0000000..b6a878f Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/invalid_name.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/invalid_name_1.png b/app/src/main/res/mipmap-xxhdpi/invalid_name_1.png new file mode 100644 index 0000000..c0cd1d9 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/invalid_name_1.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/invalid_name_10.png b/app/src/main/res/mipmap-xxhdpi/invalid_name_10.png new file mode 100644 index 0000000..35cc871 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/invalid_name_10.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/invalid_name_11.png b/app/src/main/res/mipmap-xxhdpi/invalid_name_11.png new file mode 100644 index 0000000..20394f6 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/invalid_name_11.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/invalid_name_12.png b/app/src/main/res/mipmap-xxhdpi/invalid_name_12.png new file mode 100644 index 0000000..96a10ba Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/invalid_name_12.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/invalid_name_2.png b/app/src/main/res/mipmap-xxhdpi/invalid_name_2.png new file mode 100644 index 0000000..0225b2c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/invalid_name_2.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/invalid_name_3.png b/app/src/main/res/mipmap-xxhdpi/invalid_name_3.png new file mode 100644 index 0000000..2681ce8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/invalid_name_3.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/invalid_name_4.png b/app/src/main/res/mipmap-xxhdpi/invalid_name_4.png new file mode 100644 index 0000000..672c06a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/invalid_name_4.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/invalid_name_5.png b/app/src/main/res/mipmap-xxhdpi/invalid_name_5.png new file mode 100644 index 0000000..5bd8eda Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/invalid_name_5.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/invalid_name_6.png b/app/src/main/res/mipmap-xxhdpi/invalid_name_6.png new file mode 100644 index 0000000..7e76873 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/invalid_name_6.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/invalid_name_7.png b/app/src/main/res/mipmap-xxhdpi/invalid_name_7.png new file mode 100644 index 0000000..53c7091 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/invalid_name_7.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/invalid_name_8.png b/app/src/main/res/mipmap-xxhdpi/invalid_name_8.png new file mode 100644 index 0000000..9753234 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/invalid_name_8.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/invalid_name_9.png b/app/src/main/res/mipmap-xxhdpi/invalid_name_9.png new file mode 100644 index 0000000..0d6892e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/invalid_name_9.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/kp_bj_img.png b/app/src/main/res/mipmap-xxhdpi/kp_bj_img.png new file mode 100644 index 0000000..a9e345f Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/kp_bj_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/kp_logo_img.png b/app/src/main/res/mipmap-xxhdpi/kp_logo_img.png new file mode 100644 index 0000000..37a46d4 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/kp_logo_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/kp_p_img.png b/app/src/main/res/mipmap-xxhdpi/kp_p_img.png new file mode 100644 index 0000000..13c75e7 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/kp_p_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qs_ai_qs_as_img.png b/app/src/main/res/mipmap-xxhdpi/qs_ai_qs_as_img.png new file mode 100644 index 0000000..760bef6 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qs_ai_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qs_ai_qs_img.png b/app/src/main/res/mipmap-xxhdpi/qs_ai_qs_img.png new file mode 100644 index 0000000..ec5deaf Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qs_ai_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qs_dt_qs_as_img.png b/app/src/main/res/mipmap-xxhdpi/qs_dt_qs_as_img.png new file mode 100644 index 0000000..9336df4 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qs_dt_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qs_dt_qs_img.png b/app/src/main/res/mipmap-xxhdpi/qs_dt_qs_img.png new file mode 100644 index 0000000..1ffd1d8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qs_dt_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qs_dt_qs_img_2.png b/app/src/main/res/mipmap-xxhdpi/qs_dt_qs_img_2.png new file mode 100644 index 0000000..cfbb8c4 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qs_dt_qs_img_2.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qs_plq_qs_img.png b/app/src/main/res/mipmap-xxhdpi/qs_plq_qs_img.png new file mode 100644 index 0000000..de0c321 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qs_plq_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qs_py_qs_as_img.png b/app/src/main/res/mipmap-xxhdpi/qs_py_qs_as_img.png new file mode 100644 index 0000000..f5b6066 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qs_py_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qs_py_qs_img.png b/app/src/main/res/mipmap-xxhdpi/qs_py_qs_img.png new file mode 100644 index 0000000..a5312fd Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qs_py_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qs_ql_qs_as_img.png b/app/src/main/res/mipmap-xxhdpi/qs_ql_qs_as_img.png new file mode 100644 index 0000000..3019906 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qs_ql_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qs_ql_qs_as_img_2.png b/app/src/main/res/mipmap-xxhdpi/qs_ql_qs_as_img_2.png new file mode 100644 index 0000000..42656eb Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qs_ql_qs_as_img_2.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qs_ql_qs_img.png b/app/src/main/res/mipmap-xxhdpi/qs_ql_qs_img.png new file mode 100644 index 0000000..6b3d14f Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qs_ql_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qs_znt_qs_as_img.png b/app/src/main/res/mipmap-xxhdpi/qs_znt_qs_as_img.png new file mode 100644 index 0000000..d884edd Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qs_znt_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qs_znt_qs_img.png b/app/src/main/res/mipmap-xxhdpi/qs_znt_qs_img.png new file mode 100644 index 0000000..ffeeede Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qs_znt_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qst_fs_qs_as_img.png b/app/src/main/res/mipmap-xxhdpi/qst_fs_qs_as_img.png new file mode 100644 index 0000000..50c07ca Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qst_fs_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qst_fs_qs_img.png b/app/src/main/res/mipmap-xxhdpi/qst_fs_qs_img.png new file mode 100644 index 0000000..4722258 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qst_fs_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qst_gz_qs_as_img.png b/app/src/main/res/mipmap-xxhdpi/qst_gz_qs_as_img.png new file mode 100644 index 0000000..f64345d Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qst_gz_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qst_gz_qs_as_img_my.png b/app/src/main/res/mipmap-xxhdpi/qst_gz_qs_as_img_my.png new file mode 100644 index 0000000..ab6c408 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qst_gz_qs_as_img_my.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qst_gz_qs_img.png b/app/src/main/res/mipmap-xxhdpi/qst_gz_qs_img.png new file mode 100644 index 0000000..2da7a4c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qst_gz_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qst_gz_qs_img_my.png b/app/src/main/res/mipmap-xxhdpi/qst_gz_qs_img_my.png new file mode 100644 index 0000000..23b6b8e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qst_gz_qs_img_my.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qst_pl_qs_as_img.png b/app/src/main/res/mipmap-xxhdpi/qst_pl_qs_as_img.png new file mode 100644 index 0000000..e5238c6 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qst_pl_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qst_pl_qs_img.png b/app/src/main/res/mipmap-xxhdpi/qst_pl_qs_img.png new file mode 100644 index 0000000..e5238c6 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qst_pl_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qst_wldk_qs_img.png b/app/src/main/res/mipmap-xxhdpi/qst_wldk_qs_img.png new file mode 100644 index 0000000..fed98a9 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qst_wldk_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qst_z_qs_as_img.png b/app/src/main/res/mipmap-xxhdpi/qst_z_qs_as_img.png new file mode 100644 index 0000000..38f6a08 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qst_z_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/qst_z_qs_img.png b/app/src/main/res/mipmap-xxhdpi/qst_z_qs_img.png new file mode 100644 index 0000000..3ede801 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/qst_z_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_blue_bg_big.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_blue_bg_big.webp new file mode 100644 index 0000000..e8687c8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_blue_bg_big.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_btn_bg_grey.png b/app/src/main/res/mipmap-xxhdpi/rider_pro_btn_bg_grey.png new file mode 100644 index 0000000..ebc1d9a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_btn_bg_grey.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_change_password.png b/app/src/main/res/mipmap-xxhdpi/rider_pro_change_password.png new file mode 100644 index 0000000..16f1b5c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_change_password.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_color_logo.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_color_logo.webp new file mode 100644 index 0000000..ea6fa87 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_color_logo.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_color_logo_next.png b/app/src/main/res/mipmap-xxhdpi/rider_pro_color_logo_next.png new file mode 100644 index 0000000..3289ec5 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_color_logo_next.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_email.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_email.webp new file mode 100644 index 0000000..85f4378 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_email.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_follow_grey.png b/app/src/main/res/mipmap-xxhdpi/rider_pro_follow_grey.png new file mode 100644 index 0000000..ebc1d9a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_follow_grey.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_follow_red.png b/app/src/main/res/mipmap-xxhdpi/rider_pro_follow_red.png new file mode 100644 index 0000000..dcf50de Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_follow_red.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_followers_empty.png b/app/src/main/res/mipmap-xxhdpi/rider_pro_followers_empty.png new file mode 100644 index 0000000..4dce9c2 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_followers_empty.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_following_empty.png b/app/src/main/res/mipmap-xxhdpi/rider_pro_following_empty.png new file mode 100644 index 0000000..8691202 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_following_empty.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_grey_bg_big.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_grey_bg_big.webp new file mode 100644 index 0000000..8d4a4e8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_grey_bg_big.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_input_error.png b/app/src/main/res/mipmap-xxhdpi/rider_pro_input_error.png new file mode 100644 index 0000000..52895af Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_input_error.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_like_empty.png b/app/src/main/res/mipmap-xxhdpi/rider_pro_like_empty.png new file mode 100644 index 0000000..34722b7 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_like_empty.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_log.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_log.webp new file mode 100644 index 0000000..00fd127 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_log.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_log_foreground.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_log_foreground.webp new file mode 100644 index 0000000..2272676 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_log_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_log_round.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_log_round.webp new file mode 100644 index 0000000..0129736 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_log_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_login_bg.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_login_bg.webp new file mode 100644 index 0000000..27d43c6 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_login_bg.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_logo.png b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo.png new file mode 100644 index 0000000..857e872 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_next.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_next.webp new file mode 100644 index 0000000..d4bdd70 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_next.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_next_background.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_next_background.webp new file mode 100644 index 0000000..914298b Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_next_background.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_next_foreground.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_next_foreground.webp new file mode 100644 index 0000000..6a1bb5e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_next_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_next_round.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_next_round.webp new file mode 100644 index 0000000..18c110d Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_next_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_red.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_red.webp new file mode 100644 index 0000000..c802516 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_red.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_red_foreground.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_red_foreground.webp new file mode 100644 index 0000000..be1049f Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_red_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_red_round.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_red_round.webp new file mode 100644 index 0000000..4f06ad1 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_logo_red_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_logout.png b/app/src/main/res/mipmap-xxhdpi/rider_pro_logout.png new file mode 100644 index 0000000..59196ad Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_logout.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_red_bg_big.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_red_bg_big.webp new file mode 100644 index 0000000..c9d57fa Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_red_bg_big.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_signup_facebook.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_signup_facebook.webp new file mode 100644 index 0000000..c617fb5 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_signup_facebook.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_signup_facebook_bg.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_signup_facebook_bg.webp new file mode 100644 index 0000000..e8687c8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_signup_facebook_bg.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_signup_google.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_signup_google.webp new file mode 100644 index 0000000..ccf699a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_signup_google.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_signup_red_bg.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_signup_red_bg.webp new file mode 100644 index 0000000..362fec1 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_signup_red_bg.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_signup_white_bg.webp b/app/src/main/res/mipmap-xxhdpi/rider_pro_signup_white_bg.webp new file mode 100644 index 0000000..1f27512 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_signup_white_bg.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/rider_pro_update_header.png b/app/src/main/res/mipmap-xxhdpi/rider_pro_update_header.png new file mode 100644 index 0000000..b9e6c62 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/rider_pro_update_header.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/syss_yh_qs_as_img.png b/app/src/main/res/mipmap-xxhdpi/syss_yh_qs_as_img.png new file mode 100644 index 0000000..85cd656 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/syss_yh_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/syss_yh_qs_img.png b/app/src/main/res/mipmap-xxhdpi/syss_yh_qs_img.png new file mode 100644 index 0000000..a74c141 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/syss_yh_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/underline.png b/app/src/main/res/mipmap-xxhdpi/underline.png new file mode 100644 index 0000000..8b255d2 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/underline.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ai.png b/app/src/main/res/mipmap-xxxhdpi/ai.png new file mode 100644 index 0000000..094bc6a Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ai.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/apple_logo_medium.png b/app/src/main/res/mipmap-xxxhdpi/apple_logo_medium.png new file mode 100644 index 0000000..11149ee Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/apple_logo_medium.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/arrow.png b/app/src/main/res/mipmap-xxxhdpi/arrow.png new file mode 100644 index 0000000..05cef12 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/arrow.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_chat_s.png b/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_chat_s.png new file mode 100644 index 0000000..0279c66 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_chat_s.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_discover_bold.png b/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_discover_bold.png new file mode 100644 index 0000000..44436c6 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_discover_bold.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_discover_fill.png b/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_discover_fill.png new file mode 100644 index 0000000..8de4978 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_discover_fill.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_home_n_copy.png b/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_home_n_copy.png new file mode 100644 index 0000000..fad36d9 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_home_n_copy.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_home_n_copy_2.png b/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_home_n_copy_2.png new file mode 100644 index 0000000..bec9a4d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_home_n_copy_2.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_home_s.png b/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_home_s.png new file mode 100644 index 0000000..0a26fec Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_home_s.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_user_s.png b/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_user_s.png new file mode 100644 index 0000000..0051e0c Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/bars_x_buttons_user_s.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/btn.png b/app/src/main/res/mipmap-xxxhdpi/btn.png new file mode 100644 index 0000000..fd3f3fe Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/btn.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/fill_and_sign.png b/app/src/main/res/mipmap-xxxhdpi/fill_and_sign.png new file mode 100644 index 0000000..39fcdd4 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/fill_and_sign.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/group_copy.png b/app/src/main/res/mipmap-xxxhdpi/group_copy.png new file mode 100644 index 0000000..2475564 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/group_copy.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/h_cj_rw_icon.png b/app/src/main/res/mipmap-xxxhdpi/h_cj_rw_icon.png new file mode 100644 index 0000000..226e990 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/h_cj_rw_icon.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/h_cj_x_img.png b/app/src/main/res/mipmap-xxxhdpi/h_cj_x_img.png new file mode 100644 index 0000000..842fae4 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/h_cj_x_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icon_collect.png b/app/src/main/res/mipmap-xxxhdpi/icon_collect.png new file mode 100644 index 0000000..ea7e9b6 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icon_collect.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icon_comment.png b/app/src/main/res/mipmap-xxxhdpi/icon_comment.png new file mode 100644 index 0000000..b9554b2 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icon_comment.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icon_share.png b/app/src/main/res/mipmap-xxxhdpi/icon_share.png new file mode 100644 index 0000000..a85fee6 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icon_share.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icon_variant_2.png b/app/src/main/res/mipmap-xxxhdpi/icon_variant_2.png new file mode 100644 index 0000000..ddf6bfd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icon_variant_2.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icons_circle_ai.png b/app/src/main/res/mipmap-xxxhdpi/icons_circle_ai.png new file mode 100644 index 0000000..8ef8a92 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icons_circle_ai.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icons_circle_camera.png b/app/src/main/res/mipmap-xxxhdpi/icons_circle_camera.png new file mode 100644 index 0000000..1ed33e2 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icons_circle_camera.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icons_info_magic.png b/app/src/main/res/mipmap-xxxhdpi/icons_info_magic.png new file mode 100644 index 0000000..d0884d7 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icons_info_magic.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icons_infor_edit.png b/app/src/main/res/mipmap-xxxhdpi/icons_infor_edit.png new file mode 100644 index 0000000..a666aa8 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icons_infor_edit.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icons_infor_off_bell.png b/app/src/main/res/mipmap-xxxhdpi/icons_infor_off_bell.png new file mode 100644 index 0000000..ab35f42 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icons_infor_off_bell.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icons_infor_off_eye.png b/app/src/main/res/mipmap-xxxhdpi/icons_infor_off_eye.png new file mode 100644 index 0000000..7469434 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icons_infor_off_eye.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/invalid_name.png b/app/src/main/res/mipmap-xxxhdpi/invalid_name.png new file mode 100644 index 0000000..488e931 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/invalid_name.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/invalid_name_1.png b/app/src/main/res/mipmap-xxxhdpi/invalid_name_1.png new file mode 100644 index 0000000..e0c1d72 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/invalid_name_1.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/invalid_name_10.png b/app/src/main/res/mipmap-xxxhdpi/invalid_name_10.png new file mode 100644 index 0000000..b61d18b Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/invalid_name_10.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/invalid_name_11.png b/app/src/main/res/mipmap-xxxhdpi/invalid_name_11.png new file mode 100644 index 0000000..b28c39e Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/invalid_name_11.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/invalid_name_12.png b/app/src/main/res/mipmap-xxxhdpi/invalid_name_12.png new file mode 100644 index 0000000..9daa139 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/invalid_name_12.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/invalid_name_2.png b/app/src/main/res/mipmap-xxxhdpi/invalid_name_2.png new file mode 100644 index 0000000..b79896e Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/invalid_name_2.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/invalid_name_3.png b/app/src/main/res/mipmap-xxxhdpi/invalid_name_3.png new file mode 100644 index 0000000..d43471f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/invalid_name_3.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/invalid_name_4.png b/app/src/main/res/mipmap-xxxhdpi/invalid_name_4.png new file mode 100644 index 0000000..3422bdc Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/invalid_name_4.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/invalid_name_5.png b/app/src/main/res/mipmap-xxxhdpi/invalid_name_5.png new file mode 100644 index 0000000..4b3e278 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/invalid_name_5.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/invalid_name_6.png b/app/src/main/res/mipmap-xxxhdpi/invalid_name_6.png new file mode 100644 index 0000000..691f90d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/invalid_name_6.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/invalid_name_7.png b/app/src/main/res/mipmap-xxxhdpi/invalid_name_7.png new file mode 100644 index 0000000..889ea62 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/invalid_name_7.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/invalid_name_8.png b/app/src/main/res/mipmap-xxxhdpi/invalid_name_8.png new file mode 100644 index 0000000..02f255c Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/invalid_name_8.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/invalid_name_9.png b/app/src/main/res/mipmap-xxxhdpi/invalid_name_9.png new file mode 100644 index 0000000..8700a83 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/invalid_name_9.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/kp_bj_img.png b/app/src/main/res/mipmap-xxxhdpi/kp_bj_img.png new file mode 100644 index 0000000..a0eba33 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/kp_bj_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/kp_logo_img.png b/app/src/main/res/mipmap-xxxhdpi/kp_logo_img.png new file mode 100644 index 0000000..695c577 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/kp_logo_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/kp_p_img.png b/app/src/main/res/mipmap-xxxhdpi/kp_p_img.png new file mode 100644 index 0000000..eaf44ac Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/kp_p_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qs_ai_qs_as_img.png b/app/src/main/res/mipmap-xxxhdpi/qs_ai_qs_as_img.png new file mode 100644 index 0000000..e06192d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qs_ai_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qs_ai_qs_img.png b/app/src/main/res/mipmap-xxxhdpi/qs_ai_qs_img.png new file mode 100644 index 0000000..5bf9755 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qs_ai_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qs_dt_qs_as_img.png b/app/src/main/res/mipmap-xxxhdpi/qs_dt_qs_as_img.png new file mode 100644 index 0000000..6aecdeb Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qs_dt_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qs_dt_qs_img.png b/app/src/main/res/mipmap-xxxhdpi/qs_dt_qs_img.png new file mode 100644 index 0000000..78ae11a Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qs_dt_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qs_dt_qs_img_2.png b/app/src/main/res/mipmap-xxxhdpi/qs_dt_qs_img_2.png new file mode 100644 index 0000000..c2e2cba Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qs_dt_qs_img_2.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qs_plq_qs_img.png b/app/src/main/res/mipmap-xxxhdpi/qs_plq_qs_img.png new file mode 100644 index 0000000..3120c25 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qs_plq_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qs_py_qs_as_img.png b/app/src/main/res/mipmap-xxxhdpi/qs_py_qs_as_img.png new file mode 100644 index 0000000..78ab3b3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qs_py_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qs_py_qs_img.png b/app/src/main/res/mipmap-xxxhdpi/qs_py_qs_img.png new file mode 100644 index 0000000..9d1867d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qs_py_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qs_ql_qs_as_img.png b/app/src/main/res/mipmap-xxxhdpi/qs_ql_qs_as_img.png new file mode 100644 index 0000000..648b74e Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qs_ql_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qs_ql_qs_as_img_2.png b/app/src/main/res/mipmap-xxxhdpi/qs_ql_qs_as_img_2.png new file mode 100644 index 0000000..b98c46d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qs_ql_qs_as_img_2.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qs_ql_qs_img.png b/app/src/main/res/mipmap-xxxhdpi/qs_ql_qs_img.png new file mode 100644 index 0000000..062deaa Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qs_ql_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qs_znt_qs_as_img.png b/app/src/main/res/mipmap-xxxhdpi/qs_znt_qs_as_img.png new file mode 100644 index 0000000..bc14818 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qs_znt_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qs_znt_qs_img.png b/app/src/main/res/mipmap-xxxhdpi/qs_znt_qs_img.png new file mode 100644 index 0000000..0035b6f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qs_znt_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qst_fs_qs_as_img.png b/app/src/main/res/mipmap-xxxhdpi/qst_fs_qs_as_img.png new file mode 100644 index 0000000..694691f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qst_fs_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qst_fs_qs_img.png b/app/src/main/res/mipmap-xxxhdpi/qst_fs_qs_img.png new file mode 100644 index 0000000..ca6ddde Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qst_fs_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qst_gz_qs_as_img.png b/app/src/main/res/mipmap-xxxhdpi/qst_gz_qs_as_img.png new file mode 100644 index 0000000..2e21e42 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qst_gz_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qst_gz_qs_as_img_my.png b/app/src/main/res/mipmap-xxxhdpi/qst_gz_qs_as_img_my.png new file mode 100644 index 0000000..5d57133 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qst_gz_qs_as_img_my.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qst_gz_qs_img.png b/app/src/main/res/mipmap-xxxhdpi/qst_gz_qs_img.png new file mode 100644 index 0000000..52af086 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qst_gz_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qst_gz_qs_img_my.png b/app/src/main/res/mipmap-xxxhdpi/qst_gz_qs_img_my.png new file mode 100644 index 0000000..128d946 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qst_gz_qs_img_my.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qst_pl_qs_as_img.png b/app/src/main/res/mipmap-xxxhdpi/qst_pl_qs_as_img.png new file mode 100644 index 0000000..bf471f7 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qst_pl_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qst_pl_qs_img.png b/app/src/main/res/mipmap-xxxhdpi/qst_pl_qs_img.png new file mode 100644 index 0000000..bf471f7 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qst_pl_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qst_wldk_qs_img.png b/app/src/main/res/mipmap-xxxhdpi/qst_wldk_qs_img.png new file mode 100644 index 0000000..4061b75 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qst_wldk_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qst_z_qs_as_img.png b/app/src/main/res/mipmap-xxxhdpi/qst_z_qs_as_img.png new file mode 100644 index 0000000..ff77dd8 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qst_z_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/qst_z_qs_img.png b/app/src/main/res/mipmap-xxxhdpi/qst_z_qs_img.png new file mode 100644 index 0000000..a5e2db5 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/qst_z_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_blue_bg_big.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_blue_bg_big.webp new file mode 100644 index 0000000..e9e2e8b Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_blue_bg_big.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_btn_bg_grey.png b/app/src/main/res/mipmap-xxxhdpi/rider_pro_btn_bg_grey.png new file mode 100644 index 0000000..09eb245 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_btn_bg_grey.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_change_password.png b/app/src/main/res/mipmap-xxxhdpi/rider_pro_change_password.png new file mode 100644 index 0000000..36fed51 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_change_password.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_color_logo.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_color_logo.webp new file mode 100644 index 0000000..f5a12b8 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_color_logo.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_color_logo_next.png b/app/src/main/res/mipmap-xxxhdpi/rider_pro_color_logo_next.png new file mode 100644 index 0000000..c2b96d1 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_color_logo_next.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_email.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_email.webp new file mode 100644 index 0000000..b93bf26 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_email.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_follow_grey.png b/app/src/main/res/mipmap-xxxhdpi/rider_pro_follow_grey.png new file mode 100644 index 0000000..09eb245 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_follow_grey.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_follow_red.png b/app/src/main/res/mipmap-xxxhdpi/rider_pro_follow_red.png new file mode 100644 index 0000000..6d0972f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_follow_red.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_followers_empty.png b/app/src/main/res/mipmap-xxxhdpi/rider_pro_followers_empty.png new file mode 100644 index 0000000..a65bfc0 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_followers_empty.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_following_empty.png b/app/src/main/res/mipmap-xxxhdpi/rider_pro_following_empty.png new file mode 100644 index 0000000..33ba0d5 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_following_empty.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_grey_bg_big.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_grey_bg_big.webp new file mode 100644 index 0000000..60c1495 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_grey_bg_big.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_input_error.png b/app/src/main/res/mipmap-xxxhdpi/rider_pro_input_error.png new file mode 100644 index 0000000..f717b52 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_input_error.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_like_empty.png b/app/src/main/res/mipmap-xxxhdpi/rider_pro_like_empty.png new file mode 100644 index 0000000..35b7c37 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_like_empty.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_log.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_log.webp new file mode 100644 index 0000000..5044acc Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_log.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_log_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_log_foreground.webp new file mode 100644 index 0000000..730a2f0 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_log_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_log_round.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_log_round.webp new file mode 100644 index 0000000..47c3aa3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_log_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_login_bg.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_login_bg.webp new file mode 100644 index 0000000..33c4157 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_login_bg.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo.png b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo.png new file mode 100644 index 0000000..ed0fec6 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_next.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_next.webp new file mode 100644 index 0000000..6d44be5 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_next.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_next_background.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_next_background.webp new file mode 100644 index 0000000..0de2d65 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_next_background.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_next_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_next_foreground.webp new file mode 100644 index 0000000..f7e8b47 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_next_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_next_round.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_next_round.webp new file mode 100644 index 0000000..e0a4b7a Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_next_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_red.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_red.webp new file mode 100644 index 0000000..f5a9231 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_red.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_red_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_red_foreground.webp new file mode 100644 index 0000000..090b73b Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_red_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_red_round.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_red_round.webp new file mode 100644 index 0000000..b7ec37f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logo_red_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_logout.png b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logout.png new file mode 100644 index 0000000..575d1de Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_logout.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_red_bg_big.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_red_bg_big.webp new file mode 100644 index 0000000..ef49a97 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_red_bg_big.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_signup_facebook.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_signup_facebook.webp new file mode 100644 index 0000000..7813324 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_signup_facebook.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_signup_facebook_bg.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_signup_facebook_bg.webp new file mode 100644 index 0000000..e9e2e8b Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_signup_facebook_bg.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_signup_google.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_signup_google.webp new file mode 100644 index 0000000..c2ceebe Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_signup_google.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_signup_red_bg.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_signup_red_bg.webp new file mode 100644 index 0000000..0de90c2 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_signup_red_bg.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_signup_white_bg.webp b/app/src/main/res/mipmap-xxxhdpi/rider_pro_signup_white_bg.webp new file mode 100644 index 0000000..ee9106e Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_signup_white_bg.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/rider_pro_update_header.png b/app/src/main/res/mipmap-xxxhdpi/rider_pro_update_header.png new file mode 100644 index 0000000..4467f91 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/rider_pro_update_header.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/syss_yh_qs_as_img.png b/app/src/main/res/mipmap-xxxhdpi/syss_yh_qs_as_img.png new file mode 100644 index 0000000..0ae50bd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/syss_yh_qs_as_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/syss_yh_qs_img.png b/app/src/main/res/mipmap-xxxhdpi/syss_yh_qs_img.png new file mode 100644 index 0000000..8ec04bd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/syss_yh_qs_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/underline.png b/app/src/main/res/mipmap-xxxhdpi/underline.png new file mode 100644 index 0000000..18d23e5 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/underline.png differ diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..d849e3a --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,252 @@ + + + Rave Now + ワールドワイド + %1d分前 + %1d秒前 + %1d日前 + %1d時間前 + %1d年前 + 検索 + モーメント + ユーザー + いいね + フォロワー + お気に入り + あれ、何もない。.. + 通知 + フォロー中 + フォロー解除 + %d コメント + 何か言ってください + フォロー + ログイン + レッツ・レヴ・ナウ + または + ログイン状態を保持する + パスワードをお忘れですか? + パスワード + メールアドレス + メールアドレスは必須です + パスワードは必須です + メールアドレスを入力してください + パスワードを入力してください + サインアップ + メールで接続 + Googleで接続 + 戻る + パスワードを再入力してください + パスワードの確認 + はい、RaveNowのプライバシーポリシーを読み、同意します。 + はい、Rave Nowのメーリングリストに追加されたいです。 + 無効なメールアドレス + 6文字以上で、文字と数字を含めてください。 + 入力されたパスワードが一致していることを確認してください。 + パスワードの確認は必須です + ユーザーが存在します + 不明なエラー + よりパーソナライズされた体験を提供するために、関連する通知の送信を許可してください。 + 最高のサービスを提供するために、登録前に利用規約を読み、同意してください。 + まだ投稿がありません + 今すぐモーメントを投稿 + プロフィールを編集 + シェア + ログアウト + パスワードを変更 + 現在のパスワードを入力してください + パスワードは8文字以上である必要があります + パスワードには少なくとも1つの数字を含める必要があります + パスワードには少なくとも1つの大文字を含める必要があります + パスワードが一致しません + 現在のパスワード + 現在のパスワードを入力してください + 新しいパスワード + 新しいパスワードを入力してください + 新しいパスワードを確認 + 新しいパスワードを確認してください + キャンセル + 署名 + 名前 + コメント + デフォルト + 最新 + 最も古い + ダウンロード + オリジナル + お気に入り + ダークモード + ライトモード + 新しいバージョン + 今すぐ更新 + 後で + 削除 + コピー + いいね + 返信 + %1d件の返信をさらに表示 + 無効なメールアドレスまたはパスワード + アカウントを復元 + 復元 + 登録されているメールアドレスにメールが送信されました。受信トレイを確認し、指示に従ってパスワードをリセットしてください。 + メールの送信に失敗しました。ネットワーク接続を確認するか、後でもう一度お試しください。 + %1d秒前 + %1d分前 + 同意する + Rave Nowのプライバシーポリシー + ギャラリー + チャット + あなたの投稿にいいねしました + あなたの投稿をお気に入りにしました + あなたのコメントにいいねしました + %sで再送信 + ユーザーが存在しません + 正しい順序で画像の点をクリックしてください。 + キャプチャ + 更新 + クリア + キャプチャが正しくありません。もう一度お試しください + %d フォロワー + 古いパスワードが正しくありません + 報告 + あなたの報告は匿名です。誰かが危険にさらされている場合は、すぐに地域の緊急サービスに連絡してください。 + 報告が成功しました + 報告に失敗しました。もう一度お試しください + この投稿を報告する理由は? + 閉じる + ブロック済み + フィードバック + Rave Nowについて + アカウントとセキュリティ + アカウントを削除 + 本当にアカウントを削除しますか?この操作は元に戻せません。 + 確認のためパスワードを入力してください + バージョン %1$s + ワールドワイド + ダイナミック + フォロー中 + ホット + ホーム + エージェント + メッセージ + プロフィール + マイ + ホット + おすすめ + その他 + AIエージェントを作成 + 名前 + 名前を入力してください + 説明を設定 + 例:経験豊富な営業担当者で、ユーモアと生きた事例を使って、複雑な製品を顧客が理解しやすい形に変えるのが得意 + エージェントを作成 + 投稿のインスピレーションが必要ですか?AIがお手伝いします! + AIの文案最適化 + 削除 + 適用 + AI + グループ + 友達 + すべて + 人はおしゃべりをしている… + AIエージェントチャット + AIエージェントチャットがありません + AIエージェントと対話してみましょう + 私: + [画像] + [音声] + [動画] + [ファイル] + [メッセージ] + 読み込みに失敗しました + さらに読み込むのに失敗しました + ユーザー情報の取得に失敗しました: %s + グループチャットがありません + まだどのグループチャットにも参加していません + グループチャットメッセージのない宇宙は静かすぎます + ホームで興味のあるテーマルームを探してみましょう + まだ友達とチャットしていません~ + 友達のアバターをクリックして、すぐにチャットを始めましょう。 + 私: + 読み込みに失敗しました + グループチャットを作成 + クイック作成 + グループ名 + グループチャットの名前を入力してください + 検索 + 他の人を追加 + 通知 + 退出 + プロフィールを編集 + グループチャット情報 + 人気チャットルーム + チャット + あなたにおすすめのエージェント + ハイエネルギーの対話中 + エージェントを作成 + 確認 + ダイナミックを公開 + 人気エージェント + 入室 + ルームへの参加に成功しました + ルームへの参加に失敗しました + 作成中… + 発見 + パスワードは%1$d文字を超えることはできません + ブロック + 全文を読む + + + + 作成 + AI + グループチャット + モーメント + 閉じる + + + こんにちは!今日は何を作りたいですか? + たった一言で、あなた専用のAIがここで生まれます。 + 詩を書くAI、あなたの笑いを理解するAI… + AI美化 + アイデアを考え中 + アバター + 手動でAIを作成 + 一言でAIを作成 + 名前を付けて、あなただけの特別な存在にしましょう + スマートボディの作成が完了しました! + + + 社交はA Iと人間に属する + + + オフラインだ.. + ネットワークを確認して、この宇宙に接続してください + 再ロード + + + パーティーに参加して、一緒に盛り上がろう + + + おすすめ + ショート動画 + ニュース + + + %1$sをブロックしますか? + 相手はあなたにメッセージを送信したり、あなたのプロフィールやコンテンツを見つけることができなくなります + 相手はあなたにブロックされた通知を受け取りません + 「設定」でいつでもブロックを解除できます + + + チャット設定 + チャットテーマを設定 + カスタム背景 + ギャラリーから選択 + おすすめ背景 + カスタム背景をプレビュー中 + 各テーマには独自の体験があります + 「適用」を選択してこのテーマを使用 + 「キャンセル」をタップして他のテーマをプレビュー + + + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml new file mode 100644 index 0000000..d082a5f --- /dev/null +++ b/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,253 @@ + + + Rave Now + 世界 + %1d分钟前 + %1d秒前 + %1d天前 + %1d小时前 + %1d年前 + 搜索 + 动态 + 用户 + + 粉丝 + 收藏 + 消息 + 关注 + 取消关注 + %d条评论 + 快来互动吧... + 关注 + 登录 + 确认 + 其他账号登录 + 记住我 + 忘记密码 + 密码 + 邮箱 + 邮箱是必填项 + 密码是必填项 + 输入邮箱 + 输入密码 + 注册 + 使用邮箱注册 + 使用Google账号登录 + 返回 + 再次输入密码 + 再次输入密码 + 我已阅读用户协议 + 我同意 Rave Now 推送消息 + 邮箱格式错误 + 至少6位,包含字母、数字 + 密码和确认密码必须相同 + 请输入确认密码 + 用户已存在 + 服务端未知错误 + 为了为您提供更个性化的服务,请允许我们向您推送相关信息。 + "为了提供更好的服务,请您在注册前仔细阅读并同意《用户协议》。 " + 还没有发布任何动态 + 发布一个动态吧 + 编辑 + 分享 + 登出 + 修改密码 + 请输入当前密码 + 密码长度必须至少为 8 个字符 + 密码必须至少包含一位数字 + 密码必须至少包含一个大写字母 + 密码不匹配 + 当前密码 + 请输入旧密码 + 新密码 + 请输入新密码 + 确认新密码 + 请确认新密码 + + + 取消 + 个性签名 + 昵称 + 评论 + 默认 + 最新 + 最早 + 下载 + 原始图片 + 收藏 + 暗黑模式 + 明亮模式 + 发现新版本 + 立即更新 + 稍后再说 + 删除 + 复制 + 点赞 + 回复 + 查看更多%1d条回复 + 错误的用户名或密码 + 找回密码 + 找回 + 邮件已发送!请查收您的邮箱,按照邮件中的指示重置密码。 + 邮件发送失败,请检查您的网络连接或稍后重试。 + %1d秒前 + %1d分钟前 + 同意 + 用户协议 + 图片 + 私信 + 喜欢了你的动态 + 收藏了你的动态 + 喜欢了你的评论 + 重新发送 %s + 用户不存在 + 请依次点击图片中的元素 + 验证码 + 刷新 + 清除 + 验证码错误,请重试 + %d 粉丝 + 旧密码不正确 + 举报 + 你的举报是匿名的,如果有人正面临危险,请立即联系当地应急服务,不要耽搁 + 举报成功 + 举报失败,可以尝试重试 + 举报这篇帖子的原因是? + 关闭 + 关于Rave Now + 已拉黑 + 反馈 + 账户与安全 + 删除账户 + 注销账号为不可逆的操作,请确认 + 输入密码以确认 + 版本 %1$s + 探索 + 动态 + 关注 + 热门 + 首页 + AI + 消息 + 我的 + 我的 + 热门 + 推荐 + 其他 + 创建AI + 名称 + 请输入名称 + 设定描述 + 示例: 一位经验丰富的销售员,擅长通过幽默风趣的语言和生动的案例,将复杂的产品转化为客户易于理解并感兴趣的话题 + 创建智能体 + 好的,就它了 + 需要一些灵感来写文章吗?让人工智能来帮你! + AI文案优化 + 删除 + 应用 + 智能体 + 群聊 + 朋友 + 全部 + 咦,什么都没有... + + 智能体聊天 + AI 在等你的开场白 + 去首页探索一下,主动发起对话! + 我: + [图片] + [语音] + [视频] + [文件] + [消息] + 加载失败 + 加载更多失败 + 获取用户信息失败: %s + 没有群聊,宇宙好安静 + 没有群聊消息的宇宙太安静了 + 在首页探索感兴趣的主题房间 + 去首页探索感兴趣的高能对话 + 和朋友,还没有对话哦~ + 点击好友头像,即刻发起聊天 + 我: + 加载失败 + 创建群聊 + 一键创建 + 群聊名称 + 请输入群聊名称 + 搜索 + 添加其他人 + 通知 + 退出 + 编辑资料 + 群聊信息 + 热门聊天室 + 聊天 + 推荐给你的智能体 + 正在高能对话中 + 创建智能体 + 发布动态 + 热门智能体 + 进入 + 成功加入房间 + 加入房间失败 + 创建中… + 发现 + 密码不能超过 %1$d 个字符 + 人正在热聊… + 拉黑 + 查看全文 + + + + 创建 + AI + 群聊 + 动态 + 关闭 + + + 你好呀!今天想创造什么? + 只需要一句话,你的专属AI将在这里诞生。 + 一个会写诗的AI,一个懂你笑点的AI... + AI美化 + 正在为你构思 + 头像 + 手动创造Ai + 一句话创造Ai + 给它取个名字,让它成为独一无二的你 + 智能体创建完成! + + + 社 交 属 于 A I 和 人 类 + + + 掉线啦... + 确认一下网络,连接这个宇宙 + 重新加载 + + + 加入派派,一起狂欢 + + + 推荐 + 短视频 + 新闻 + + + 确认拉黑%s? + 对方将无法发消息给你,也无法找到你的主页或内容 + 对方不会收到自己被你拉黑的通知 + 你可以随时"设置"中取消拉黑TA + + + 聊天设置 + 设置聊天主题 + 自定义背景 + 从相册选择 + 精选背景 + 正在预览自定义背景 + 每个主题都有自己独特的体验 + 选择"应用"可选中这个主题 + 轻触"取消"可预览其他主题 + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..e022723 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,12 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + #FFFFFFFF + #ED1C24 + \ No newline at end of file diff --git a/app/src/main/res/values/rider_pro_log_background.xml b/app/src/main/res/values/rider_pro_log_background.xml new file mode 100644 index 0000000..778f8e1 --- /dev/null +++ b/app/src/main/res/values/rider_pro_log_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/rider_pro_logo_red_background.xml b/app/src/main/res/values/rider_pro_logo_red_background.xml new file mode 100644 index 0000000..cffac79 --- /dev/null +++ b/app/src/main/res/values/rider_pro_logo_red_background.xml @@ -0,0 +1,4 @@ + + + #E52027 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..02549e6 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,250 @@ + + Rave Now + Worldwide + %1d minute ago + %1d second ago + %1d days ago + %1d hour ago + %1d year ago + Search + Moment + Users + LIKE + FOLLOWERS + FAVOURITES + Well,nothing + NOTIFICATIONS + FOLLOWING + UNFOLLOW + %d Comments + Say something… + FOLLOW + Log in + Let\'s Rave Now + or + Remember me. + Forgot password? + What\'s your password + What\'s your email + Email is required + Password is required + Enter your email + Enter your password + Sign Up + Connect with Email + Continue with Google + BACK + Enter your password again + Confirm password + Yes, I have read and agree to RaveNow’s Privacy Policy. + Yes, I want to be added to the Rave Now mailing list. + Invalid email + At least 6 characters and contain letters, and numbers. + Please ensure that the passwords entered twice are consistent. + Confirm password is required + User existed + Unknown error + To provide you with a more personalized experience, please allow us to send you relevant notifications. + To provide you with the best service, please read and agree to our User Agreement before registering. + You haven\'t left any tracks yet + Post a moment now + Edit profile + share + Logout + Change password + Please enter your current password + Password must be at least 8 characters long + Password must contain at least one digit + Password must contain at least one uppercase letter + Passwords do not match + Current password + Enter your current password + New password + Enter your new password + Confirm new password + Confirm new password + Cancel + Signature + Name + COMMENTS + Default + Latest + Earliest + Download + Original + Favourite + Dark Mode + Light Mode + New version + Update Now + Later + Delete + Copy + Like + Reply + View %1d more replies + Invalid email or password + RCOVER ACCOUNT + Recover + An email has been sent to your registered email address. Please check your inbox and follow the instructions to reset your password. + Failed to send email. Please check your network connection or try again later. + %1d seconds ago + %1d minutes ago + I agree to the + Rave Now’s Privacy Policy + Gallery + CHAT + Like your post + Favourite your post + Like your comment + Resend in %s + user not exist + Please click on the dots in the image in the correct order. + Chaptcha + Refresh + Clear + incorrect captcha,please try again + %d followers + Incorrect old password + Report + Your report is anonymous, if anyone is in danger please contact local emergency services immediately without delay + Reported successfully + Failed to report, please try again + Reason for reporting this post? + Close + Blocked + Feedback + About Rave Now + Account and security + Remove Account + Are you sure you want to remove your account? This action cannot be undone. + Please enter your password to confirm + Version %1$s + Worldwide + Dynamic + Following + Hot + Home + AI + Message + Profile + Mine + Hot + Recommend + Other + Create AI Agent + Name + Please enter a name + Description + Example: An experienced salesperson who is good at transforming complex products into topics that customers can easily understand and be interested in through humorous language and vivid cases + Create Agent + Need some inspiration for your post? Let AI assist you! + AI copywriting optimization + Delete + Apply + Ai + Group + Friends + people chatting now… + All + Agent Chat + No Agent Chat + Start chatting with agents + Me: + [Image] + [Voice] + [Video] + [File] + [Message] + Failed to load + Failed to load more + Failed to get user info: %s + No group chats + You have not joined any group chats yet + The universe is too quiet without group chat messages + Explore interesting theme rooms on the homepage + Have not chatted with friends yet~ + Click on the avatar of friend to start chatting instantly. + Me: + Failed to load + Create Group Chat + Quick Create + Group Name + Please enter the name of the group chat + Search + Add Others + Notification + Exit + Edit Profile + Group Chat Info + Popular Chat Rooms + Chat + Recommended Agents For You + High-energy conversation in progress + Create Agent + Confirm + Publish Dynamic + Popular Agents + Enter + Successfully joined the room + Failed to join the room + Creating… + Discover + Password cannot exceed %1$d characters + Block + Read full article + + + Create + AI + Group Chat + Moment + Close + + + Hello ! What would you like to create today? + Just one sentence, and your exclusive AI will be born here. + An AI that writes poetry, an AI that understands your sense of humor… + AI Beautify + Brainstorming for you + Avatar + Create AI Manually + Create AI with One Sentence + Give it a name to make it uniquely yours + Agent creation completed! + + + Social interaction belongs to AI and humans + + + Offline… + Check your network to connect to this universe + Reload + + + Join the party, let\'s celebrate together + + + Recommend + Short Video + News + + + Confirm block %1$s? + They won\'t be able to send you messages or find your profile or content + They won\'t receive a notification that you blocked them + You can unblock them anytime in "Settings" + + + Chat Settings + Set Chat Theme + Custom Background + Select from Gallery + Featured Backgrounds + Previewing Custom Background + Each theme has its own unique experience + Select "Apply" to use this theme + Tap "Cancel" to preview other themes + + + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..09ae1dc --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fa0f996 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..63c7656 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/test/java/com/aiosman/ravenow/ExampleUnitTest.kt b/app/src/test/java/com/aiosman/ravenow/ExampleUnitTest.kt new file mode 100644 index 0000000..f8cc548 --- /dev/null +++ b/app/src/test/java/com/aiosman/ravenow/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.aiosman.ravenow + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..0d60e7b --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,10 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.jetbrains.kotlin.android) apply false + id("com.google.gms.google-services") version "4.4.2" apply false + id("com.google.firebase.crashlytics") version "3.0.2" apply false + id("com.google.firebase.firebase-perf") version "1.4.2" apply false + +} + diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..c0233e0 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,25 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +org.gradle.daemon=true +ja \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..4bb9db8 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,104 @@ +[versions] +accompanistSystemuicontroller = "0.27.0" +agp = "8.4.0" +animation = "1.7.0-beta05" +coil = "2.7.0" +composeImageBlurhash = "3.0.2" +converterGson = "2.11.0" +imSdk = "3.8.3.2" +imcoreSdk = "3.8.3-patch10" +coreSplashscreen = "1.0.1" +credentialsPlayServicesAuth = "1.2.2" +eventbus = "3.3.1" +firebaseBom = "33.2.0" +gson = "2.12.1" +imagecropview = "3.0.1" +jpushGoogle = "5.4.0" +jwtdecode = "2.0.2" +kotlin = "1.9.10" +coreKtx = "1.10.1" +junit = "4.13.2" +junitVersion = "1.1.5" +espressoCore = "3.5.1" +kotlinFaker = "2.0.0-rc.5" +lifecycleRuntimeKtx = "2.6.1" +activityCompose = "1.8.0" +composeBom = "2024.06.00" +lifecycleRuntimeKtxVersion = "2.6.2" +mapsCompose = "4.3.3" +material = "1.6.8" +material3Android = "1.2.1" +media3Exoplayer = "1.3.1" +navigationCompose = "2.7.7" +pagingRuntime = "3.3.0" +activityKtx = "1.9.0" +lifecycleCommonJvm = "2.8.2" +places = "3.3.0" +googleid = "1.1.1" +identityCredential = "20231002" +lifecycleProcess = "2.8.4" +playServicesAuth = "21.4.0" +rendering = "1.17.1" +zoomable = "1.6.1" + +[libraries] +accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" } +androidx-animation = { module = "androidx.compose.animation:animation", version.ref = "animation" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } +androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "credentialsPlayServicesAuth" } +androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentialsPlayServicesAuth" } +androidx-lifecycle-runtime-ktx-v262 = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtxVersion" } +androidx-material = { module = "androidx.compose.material:material", version.ref = "material" } +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } +androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3Exoplayer" } +androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Exoplayer" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingRuntime" } +androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "pagingRuntime" } +coil = { module = "io.coil-kt:coil", version.ref = "coil" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +compose-image-blurhash = { module = "com.github.orlando-dev-code:compose-image-blurhash", version.ref = "composeImageBlurhash" } +converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } +eventbus = { module = "org.greenrobot:eventbus", version.ref = "eventbus" } +firebase-analytics = { module = "com.google.firebase:firebase-analytics" } +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } +firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } +firebase-messaging-ktx = { module = "com.google.firebase:firebase-messaging-ktx" } +firebase-perf = { module = "com.google.firebase:firebase-perf" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +imagecropview = { module = "io.github.rroohit:ImageCropView", version.ref = "imagecropview" } +jpush-google = { module = "cn.jiguang.sdk:jpush-google", version.ref = "jpushGoogle" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version = "1.9.0" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } +im-sdk = { module = "io.openim:android-sdk", version.ref = "imSdk" } +im-core-sdk = { module = "io.openim:core-sdk", version.ref = "imcoreSdk" } +jwtdecode = { module = "com.auth0.android:jwtdecode", version.ref = "jwtdecode" } +kotlin-faker = { module = "io.github.serpro69:kotlin-faker", version.ref = "kotlinFaker" } +maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "mapsCompose" } +androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } +androidx-lifecycle-common-jvm = { group = "androidx.lifecycle", name = "lifecycle-common-jvm", version.ref = "lifecycleCommonJvm" } +places = { module = "com.google.android.libraries.places:places", version.ref = "places" } +googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" } +identity-credential = { group = "com.android.identity", name = "identity-credential", version.ref = "identityCredential" } +androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" } +play-services-auth = { module = "com.google.android.gms:play-services-auth", version.ref = "playServicesAuth" } +rendering = { group = "com.google.ar.sceneform", name = "rendering", version.ref = "rendering" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "converterGson" } +zoomable = { module = "net.engawapg.lib:zoomable", version.ref = "zoomable" } +lottie = { module="com.airbnb.android:lottie-compose", version="6.6.10"} +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..537d502 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 14 03:23:01 CST 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..d16227b --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven(url = "https://jitpack.io") + } +} + +rootProject.name = "RaveNow" +include(":app")