89 Commits

Author SHA1 Message Date
8a76bd11d9 feat: 增加Google支付及优化UI与性能
- **支付功能**:
  - 在应用启动时初始化Google Play Billing Client,为应用内购买做准备。
  - 添加了`billing-ktx`依赖。

- **动态和个人主页**:
  - 动态推荐页:用户头像和昵称区域支持点击跳转到对应的个人资料页。
  - 个人资料页:优化了用户资料加载逻辑,使其同时支持通过用户ID和OpenID加载。
  - 评论功能:优化了评论交互,评论成功后才更新评论数。
  - 数据模型:调整动态图片,优先使用`smallDirectUrl`以优化加载速度。

- **AI智能体页面**:
  - 移除API请求中的`random`参数,以改善数据缓存和一致性。
  - 优化了导航到AI智能体主页的逻辑,直接传递`openId`,简化了数据请求。
  - 清理了部分未使用的代码和布局。

- **群聊列表UI**:
  - 调整了群聊列表项的布局、字体大小和颜色,优化了视觉样式。
  - 移除了列表项之间的分割线。
2025-11-19 23:51:43 +08:00
f1e91f7639 Merge branch 'main' into atm2 2025-11-18 21:46:59 +08:00
2f602e37b1 Merge pull request #82 from Kevinlinpr/zhong_1
UI调整
2025-11-18 21:46:08 +08:00
58b008db91 Merge pull request #84 from Kevinlinpr/nagisa
修复我的-顶部状态栏在完全变色后切换深色模式不会变色
2025-11-18 21:45:45 +08:00
2173347d96 修复我的-顶部状态栏在完全变色后切换深色模式不会变色
调整选择星座界面ui以及添加缺省图
2025-11-18 18:55:27 +08:00
6a299a8a2c 修改加载动画
将加载动画替换为star_Loader.lottie动画
2025-11-18 18:42:21 +08:00
cf62a61195 Merge branch 'main' into zhong_1 2025-11-18 10:08:08 +08:00
4feca77924 Merge branch 'main' into atm2
# Conflicts:
#	app/src/main/java/com/aiosman/ravenow/ui/index/tabs/message/tab/AllChatListScreen.kt
2025-11-18 01:29:34 +08:00
5d5c65a5cb Merge pull request #83 from Kevinlinpr/nagisa
缺省图、bug修改
2025-11-17 22:32:52 +08:00
e797ac93a7 添加收藏界面、粉丝关注列表、点赞关注评论界面、动态智能体群聊界面、消息中的全部ai群聊好友界面缺省图以及文案
优化动态标签栏选中标签时的文本变大效果
添加我屏蔽的用户界面以及缺省图
修复我的派币按钮在深色模式下下滑时这个按钮会变白
2025-11-17 18:47:45 +08:00
bec33c165e 按钮宽度调整:
实现按钮宽度规则:默认 140dp,只有内容宽度超过 140dp 时直接扩展到 250dp。
2025-11-17 18:10:53 +08:00
2f8cc8832a UI调整
动态草稿箱为每项添加分割线;
修改发布动态页面暗色模式下显示异常;
新增新闻评论界面缺省图;
调整各界面组件适应语言切换;
2025-11-17 17:52:04 +08:00
8e1d39d049 Merge pull request #81 from Kevinlinpr/nagisa
修改若干bug、调整动态标签栏、添加文本等
2025-11-17 14:17:15 +08:00
f981efd58d 修改若干bug、调整动态标签栏、添加文本等
修复修改用户头像以及壁纸不保存,头像仍然修改成功

动态-新闻界面暗色模式调整

新增动态标签栏选中标签时会有文本变大效果

搜索后的动态界面增加点赞/收藏/评论/转发动态按钮

修复搜索智能体后点击智能体不能跳转至智能体主页

修复进入消息-全部列表,消息界面显示为空

我的派币-历史记录添加文本资源
2025-11-14 17:30:51 +08:00
b12f359da1 优化会话列表的数据加载和合并逻辑
- 在 `AndroidManifest.xml` 中添加 `com.android.vending.BILLING` 权限。
- 重构 `AllChatListScreen.kt` 的数据加载机制,通过 `LaunchedEffect` 监听各个 `ViewModel` 的列表变化,并自动合并数据。
- 将原有的数据刷新和合并逻辑拆分为 `refreshAllData` 和 `combineAllData` 两个函数,提高了代码的清晰度和复用性。
- 优化了下拉刷新和初次加载的逻辑,确保在所有数据源加载完成后才更新UI状态,提升了用户体验。
2025-11-14 16:47:15 +08:00
8901792561 新增通知类消息展示及解析逻辑
- 新增 `OpenIMMessageType.kt`,用于统一管理 OpenIM 的消息类型常量,并提供判断是否为通知类型消息的辅助函数。
- 新增 `NotificationMessageHelper.kt`,用于根据不同的通知类型生成用户友好的提示文本。
- 新增 `NotificationMessageItem.kt` Composable 组件,用于在聊天界面中展示居中样式的通知消息。
- 在 `ChatItem` 实体中增加 `isNotification` 字段,以标识消息是否为通知类型。
- 更新 `MessageParser` 和 `ChatItem` 的转换逻辑,以正确解析和处理通知消息,确保其在会话列表和聊天界面中正确显示。
- 在 `GroupChatScreen`, `ChatScreen`, `ChatAiScreen` 中,根据 `isNotification` 字段调用新的 `NotificationMessageItem` 组件来渲染通知消息。
- 修正获取群组会话 ID 时可能存在的 `s` 前缀缺失问题。
2025-11-14 15:05:11 +08:00
6d38b3c549 Merge pull request #79 from Kevinlinpr/nagisa
调整我的派币界面ui,添加缺省图,修复bug,以及其他更改
2025-11-14 12:04:09 +08:00
aa8ef1faaf Merge pull request #80 from Kevinlinpr/zhong_1
发布动态页面调整
2025-11-14 12:03:50 +08:00
238b7dfb75 发布动态页面调整 2025-11-13 18:55:05 +08:00
eb58263ca8 淇PointsBottomSheet.kt涓殑閲嶅瀵煎叆鍐茬獊 2025-11-13 17:41:49 +08:00
a9687d5be8 Merge origin/main into nagisa - keep both versions 2025-11-13 17:23:59 +08:00
578a5b0de6 Merge pull request #78 from Kevinlinpr/atm2
优化个人主页的导航栏交互和视觉
2025-11-13 17:08:39 +08:00
dbaa2f4c22 调整我的派币界面ui,添加缺省图,修复bug,以及其他更改
移除其他用户界面右上角的积分显示

修复暂停视频后点赞/关注视频动态,暂停图标会消失以及关注/取消关注、收藏/取消收藏视频动态时用户头像会闪烁

修复发布动态/评论时' " & < >符号会变为&#39; &#34; &amp; &lt; &gt

修复短视频/新闻/推荐界面点击评论图标打开评论弹框显示异常

删除我的界面点壁纸可以更换壁纸的功能

根据设计图调整我的派币界面,增加返回按钮,添加缺省图,优化标签间的切换
2025-11-13 17:02:32 +08:00
82b991b91e 修复积分弹窗列表滚动穿透及优化加载更多
- 通过`nestedScroll`修复`LazyColumn`在`ModalBottomSheet`中滚动时导致底部工作表(BottomSheet)也跟着滚动的问题。
- 移除手动点击的“加载更多”按钮,改为滚动到底部时自动加载更多积分记录。
- 引入`debounce`和`distinctUntilChanged`来优化滚动加载逻辑,防止重复触发。
- 在积分列表底部添加了“加载中”和“已经到底”的提示文本。
- 调整了Tab项的布局,使用`SpaceBetween`使其均匀分布。
2025-11-13 16:37:36 +08:00
96d804b4c7 优化个人主页的导航栏交互和视觉
- 优化导航栏的透明度过渡效果,使其在滚动时更早达到完全不透明,并适配深色/亮色模式。
- 调整导航栏图标和边框颜色逻辑,使其在深色模式下始终为白色,在亮色模式下根据背景透明度在白色和黑色之间切换。
- 将互动数据卡片和分享按钮调整为仅在“我的”主页显示。
- 将页面内容(`HorizontalPager`)的高度从 `500.dp` 增加到 `650.dp`。
- 更新了导航栏右侧的菜单图标。
2025-11-13 16:15:40 +08:00
83ef3e8dce Merge pull request #77 from Kevinlinpr/atm2
优化个人主页并实现列表位置记忆
2025-11-13 14:09:45 +08:00
689a4761ce 优化个人主页并实现列表位置记忆
- **实现列表滚动位置记忆**: 在`MyProfileViewModel`中添加状态变量,用于记录动态、智能体和群聊列表的滚动位置,以及当前所在的标签页。当用户离开并返回个人主页时,能够恢复到上次浏览的位置,提升用户体验。
- **添加智能体列表分页加载**: 新增`loadMoreAgent`方法,并结合`derivedStateOf`在列表滚动到底部时自动加载更多智能体数据。
- **统一列表滚动处理**: 引入`NestedScrollConnection`,以协调处理个人主页头部区域与下方标签页中各列表(动态、智能体、群聊)之间的嵌套滚动,解决了滚动冲突问题。
- **列表加载与空状态优化**: 为智能体列表添加了加载中(loading)和“没有更多”的提示,并优化了空状态下的布局显示。
- **导航栏样式优化**: 调整了个人主页顶部导航栏的背景和图标颜色过渡效果,使其在滚动时更加平滑和美观。
2025-11-13 14:07:57 +08:00
c5e6843b35 Merge pull request #76 from Kevinlinpr/atm2
Refactor: 群记忆重构为房间规则并新增私密群组付费功能
2025-11-13 10:12:20 +08:00
1953553277 feat: 新增智能体记忆管理功能
- 新增 Agent 记忆管理界面,允许用户对 Agent 的记忆进行增、删、改、查(CRUD)操作。
- 实现添加记忆的弹窗、记忆列表展示、编辑和删除功能。
- 在 Agent 个人资料页的操作菜单中添加入口,仅对自己创建的 Agent 可见。
- 集成积分系统,添加记忆需要消耗相应积分,并提供支付确认对话框。
2025-11-12 23:29:26 +08:00
fe09463416 Merge pull request #75 from Kevinlinpr/zhong_1
Zhong 1
2025-11-12 22:47:12 +08:00
842a02c63a Merge pull request #74 from Kevinlinpr/nagisa
Nagisa
2025-11-12 22:46:43 +08:00
2f08a7b2b6 Refactor: 群记忆重构为房间规则并新增私密群组付费功能
- **群记忆重构:**
    - 将群记忆(Group Memory)的底层实现从 `AgentRule`(智能体规则)重构为 `RoomRule`(房间规则)。
    - ViewModel 中相关的数据和服务调用已全部更新,以使用 `RoomService` 和 `RoomRuleEntity`。
    - 移除了获取智能体 `openId` 的相关逻辑。

- **私密群组付费:**
    - 新增设置群组为私密时的派币付费流程。
    - 用户设置私密群组时,若未支付过费用,将弹出付费确认对话框,显示所需费用和账户余额。
    - `GroupInfo` 实体新增 `trtcType` 和 `privateFeePaid` 字段,用于判断群组可见性状态和付费状态。
    - UI 逻辑更新,根据付费状态显示不同的提示信息(如 "待解锁"、派币费用)。

- **UI 优化:**
    - 移除群信息页中已废弃的 "解锁群扩展" 横幅。
    - 记忆管理弹窗现在会立即展开到全屏,优化了显示动画。
    - 动态显示添加群记忆所需的派币消耗。
2025-11-12 22:45:41 +08:00
d8ae9186d8 时间格式修改
确保月、天、小时和秒显示两位数
2025-11-12 18:47:36 +08:00
bc647119df 修改若干bug调整暗色模式适配以及界面尺寸修改
修复进入应用后的深色模式下的底部导航框在不同标签下会出现透明

动态-动态/关注界面暗色模式适配

修复上下滑动切换短视频,部分视频会被缩放

修复点击底部导航栏的标签切换界面后视频还在播放

修改短视频评论框大小以及我的派币界面大小
2025-11-12 18:24:42 +08:00
3a92c588c3 Merge branch 'main' into zhong_1 2025-11-12 18:16:48 +08:00
88968c7437 Merge remote-tracking branch 'origin/main' into nagisa 2025-11-12 18:15:49 +08:00
bf48ccdb82 发布动态提示词
逻辑:添加图片可发布,只有文字不可发布
2025-11-12 18:14:18 +08:00
afc3570fea Merge pull request #73 from Kevinlinpr/atm2
新增创建群聊的费用和人数上限功能
2025-11-12 18:12:20 +08:00
bb9b262219 Merge pull request #72 from Kevinlinpr/zhong_1
首页、登录、找回密码、注册界面UI调整
2025-11-12 18:11:19 +08:00
464d0adb19 新增创建群聊的费用和人数上限功能
- **创建群聊费用:**
  - 创建群聊现在会根据后台配置的积分规则扣除相应费用(派币)。
  - 在创建页面会显示当前余额和所需费用。
  - 创建时会弹出确认弹窗,显示费用、当前余额和扣除后余额。
  - 如果余额不足,将无法创建。

- **群聊人数上限:**
  - 新增创建群聊时的初始成员人数上限,该上限从后台动态获取。
  - 在选择成员界面会显示当前已选人数和上限(例如 `5/10`)。
  - 如果选择的成员超过上限,会提示错误并且无法创建。

- **后台数据加载:**
  - 新增了从外部字典表 (`/outside/dict`) 获取配置的接口和逻辑,用于加载积分规则和群聊人数限制。
  - App启动时会预加载这些配置,以确保创建群聊时能正确显示费用和人数限制。
2025-11-12 18:10:40 +08:00
24393025bb 文本资源文件 2025-11-12 18:03:20 +08:00
fbc4184ed0 Merge pull request #71 from Kevinlinpr/atm2
feat: 新增AI智能体编辑功能和群聊搜索
2025-11-12 17:58:27 +08:00
941cede86c 首页、登录、找回密码、注册界面UI调整
切换首页界面lotti图
调整登录、找回密码、注册界面暗黑模式下组件颜色
2025-11-12 17:55:42 +08:00
ca16d54823 新增创建群聊的费用和人数上限功能
- **创建群聊费用:**
  - 创建群聊现在会根据后台配置的积分规则扣除相应费用(派币)。
  - 在创建页面会显示当前余额和所需费用。
  - 创建时会弹出确认弹窗,显示费用、当前余额和扣除后余额。
  - 如果余额不足,将无法创建。

- **群聊人数上限:**
  - 新增创建群聊时的初始成员人数上限,该上限从后台动态获取。
  - 在选择成员界面会显示当前已选人数和上限(例如 `5/10`)。
  - 如果选择的成员超过上限,会提示错误并且无法创建。

- **后台数据加载:**
  - 新增了从外部字典表 (`/outside/dict`) 获取配置的接口和逻辑,用于加载积分规则和群聊人数限制。
  - App启动时会预加载这些配置,以确保创建群聊时能正确显示费用和人数限制。
2025-11-12 17:23:20 +08:00
4135583758 feat: 新增AI智能体编辑功能和群聊搜索
- **AI智能体编辑**
  - 新增AI智能体编辑页面(`AiPromptEditScreen`),允许创建者修改智能体的头像、名称、描述和公开/私有状态。
  - 在AI个人主页为创建者添加入口,可进入编辑页面。
  - 新增`updatePrompt`和`getPromptDetail`接口,用于获取和更新智能体信息。
  - 完善头像裁剪逻辑,使其同时支持创建和编辑两种模式。

- **群聊搜索**
  - 在全局搜索中新增“群聊”分类,用户可以搜索公开群聊。

- **优化**
  - AI个人主页(`AiProfileV3`)数据加载逻辑优化,以正确获取创建者信息。
  - 修复了当群聊头像为空时,无法正确显示默认头像的问题。
2025-11-12 14:19:26 +08:00
03fa627798 Merge remote-tracking branch 'origin/main' into nagisa 2025-11-12 10:46:48 +08:00
6ba3e5c4b3 Merge pull request #68 from Kevinlinpr/nagisa
修复动态-短视频界面的各种bug并优化ui
2025-11-12 10:32:54 +08:00
1996a9ca5a Merge pull request #69 from Kevinlinpr/zhong_1
Zhong 1
2025-11-12 10:32:33 +08:00
9e463bf096 Merge remote-tracking branch 'origin/main' into nagisa 2025-11-11 18:50:05 +08:00
28c3e286ba Merge branch 'main' into zhong_1 2025-11-11 18:49:50 +08:00
b69c607fe5 修改动态页面标签行效果
实现切换标签页时,标签行自动滚动
2025-11-11 18:48:05 +08:00
0e5b2ee22e Merge pull request #70 from Kevinlinpr/atm2
Atm2
2025-11-11 18:47:20 +08:00
45c5aa29b0 Refactor: Upgrade Coil to v3 and update dependencies
- Upgraded image loading library from Coil 2 to Coil 3, updating related APIs across the app.
- Migrated `viewModel()` to a singleton pattern for `AgentViewModel` to optimize instantiation.
- Updated various dependencies, including Android Gradle Plugin, Kotlin, Compose, and other libraries.
- Upgraded Gradle wrapper to version 8.11.1.
- Removed deprecated `windowInsets` and `animateItemPlacement` parameters in Compose components to align with latest API versions.
2025-11-11 18:44:01 +08:00
8d5e9f7201 调整首页展示顺序
先展示”热门聊天室“再展示”发现“
2025-11-11 17:36:53 +08:00
9a2de74b22 添加群成员功能 2025-11-11 17:06:13 +08:00
71718ee9c9 修复动态内容为空时的崩溃问题并优化UI
- 将`Moment`实体中的`momentTextContent`字段类型从`String`修改为`String?`,以允许其为空,修复了多处因空内容引发的崩溃。
- 在多个UI组件中(如新闻、短视频、推荐等)添加了对`momentTextContent`的空值检查。
- 优化了“发现”页中智能体(Agent)卡片的UI样式,使用大图背景和渐变效果,并调整了按钮和文本布局。
- 为图片加载组件(`CustomAsyncImage`)增加了默认占位图,提升了加载过程中的用户体验。
- 在热门动态列表中,过滤掉没有图片的动态,确保UI显示正常。
- 修复了Prompt推荐页面的用户资料和AI聊天导航逻辑,并增加了防崩溃处理。
2025-11-11 17:00:57 +08:00
58944bd091 修复动态-短视频界面的各种bug
修复动态-短视频界面点赞/收藏后图标未改变

修改动态-短视频界面点赞/收藏/评论/分享前后图标

修复系统字体大小设置为150%出现字体堆叠

修复收藏视频动态后点击收藏夹必现闪退

修复动态-短视频界面每次点击评论图标,评论总数都会+1以及评论弹框中总评论数为0以及评论弹框中无法回复评论以及无法点击头像进入用户主页

评论界面做了深色模式适配
2025-11-11 16:58:21 +08:00
e524b28eab 添加群成员功能 2025-11-11 16:51:13 +08:00
4e5ddabde5 Merge pull request #67 from Kevinlinpr/atm2
修复动态内容为空时的崩溃问题并优化UI
2025-11-11 16:00:18 +08:00
791f5c4c96 修复动态内容为空时的崩溃问题并优化UI
- 将`Moment`实体中的`momentTextContent`字段类型从`String`修改为`String?`,以允许其为空,修复了多处因空内容引发的崩溃。
- 在多个UI组件中(如新闻、短视频、推荐等)添加了对`momentTextContent`的空值检查。
- 优化了“发现”页中智能体(Agent)卡片的UI样式,使用大图背景和渐变效果,并调整了按钮和文本布局。
- 为图片加载组件(`CustomAsyncImage`)增加了默认占位图,提升了加载过程中的用户体验。
- 在热门动态列表中,过滤掉没有图片的动态,确保UI显示正常。
- 修复了Prompt推荐页面的用户资料和AI聊天导航逻辑,并增加了防崩溃处理。
2025-11-11 15:23:32 +08:00
9a9d497fa8 Merge pull request #66 from Kevinlinpr/atm2
feat: 新增AI智能体主页
2025-11-11 14:26:43 +08:00
6f1b911625 Merge pull request #65 from Kevinlinpr/nagisa
修复若干bug
2025-11-11 14:23:16 +08:00
904cda3ae8 feat: 新增AI智能体主页
- 新增全新设计的AI智能体主页界面(`AiProfileV3`),包括个人信息卡片、操作按钮和动态列表。
- 添加相应的 `AiProfileViewModel` 来处理数据加载、关注/取关以及动态列表分页逻辑。
- 创建 `AiProfileWrap` 作为页面入口,并根据 `isAiAccount` 参数在导航中分发至新的AI主页。
- 在 `AccountProfileEntity` 和 `Account` 数据模型中增加了AI角色背景图字段(`aiRoleAvatar`, `aiRoleAvatarMedium`, `aiRoleAvatarLarge`)。
2025-11-11 14:22:09 +08:00
7195f74ed8 修复若干bug
修复动态-关注界面存在视频动态时,所有动态均不再显示

调整动态界面按钮的间距

将动态关注界面的图片指示器从操作按钮区域移到图片下方、文案上方

修复英文模式下文字未全部切换成英文
2025-11-11 14:21:07 +08:00
7c5ee2d15f Merge pull request #64 from Kevinlinpr/atm2
Atm2
2025-11-11 11:43:20 +08:00
9f2dcffe90 新增用户类型缓存及会话列表过滤
- 新增`TrtcUserTypeRepository`,用于缓存用户是否为AI账号。
- 实现三级缓存策略(内存、Room数据库、网络),以优化`trtcId`对应的用户类型(是否为AI)的获取性能。
- 在`Agent`和`Friend`聊天列表中,根据缓存的用户类型对会话进行过滤,确保正确分类。
- 在消息列表加载时,增加用户类型缓存的预热机制,提升进入会话列表的加载速度。
- 为`UserService`和`RiderProAPI`添加通过`trtcUserIds`批量获取用户信息的接口。
- 为`PointService`新增积分定价规则的解析和缓存功能。
- 在项目构建配置中,添加`Room`数据库和`KSP`的相关依赖。
2025-11-11 11:41:37 +08:00
0540293bff 修复群聊会话类型和优化智能体头像URL
- 修复 `GroupChatListViewModel.kt` 中群聊会话类型的过滤条件,将 `conversationType` 的值从 `2` 修正为 `3`。
- 简化 `AgentChatListViewModel.kt` 中智能体头像(avatar)URL的构建逻辑。
2025-11-11 10:46:47 +08:00
a6af38a6ca Merge pull request #63 from Kevinlinpr/atm2
Atm2
2025-11-11 10:15:45 +08:00
f63b421915 新增扫码功能
- 新增 `ScanQrScreen.kt` 文件,用于实现二维码扫描界面。
- 使用 CameraX 和 ML Kit Barcode Scanning 实现二维码识别。
- 请求相机权限,并在权限被拒绝时显示提示信息。
- 扫描成功后,通过 `savedStateHandle` 将结果返回给上一个界面并关闭当前屏幕。
2025-11-11 10:13:00 +08:00
e01b2d9e8f feat: 新增扫码功能
- 添加 CameraX 和 ML Kit Barcode Scanning 依赖,用于实现二维码扫描。
- 在 AndroidManifest.xml 中添加相机权限声明。
- 新增 `ScanQrScreen` 扫码页面及相应的 `ScanQr` 导航路由。
- 在首页右上角菜单中,为“扫一扫”按钮添加跳转到扫码页面的功能。
2025-11-11 00:47:57 +08:00
784064b386 修复热门聊天室列表为空时仍然显示标题的问题
当热门聊天室数据为空时,隐藏其对应的标题和列表视图。
2025-11-11 00:28:25 +08:00
803b14139f feat: 新增搜索历史与AI智能体搜索功能
- 新增搜索历史记录功能,使用 SharedPreferences + JSON 进行本地存储。
- 搜索页在无搜索结果时展示历史记录,支持点击搜索、长按删除单个记录和清空全部历史。
- 新增 "AI" 搜索标签页,用于根据关键字搜索智能体(Agent)。
- 搜索页离开时自动重置搜索状态和文本,返回后显示历史记录。
- 优化了搜索逻辑,在输入文本为空时自动隐藏搜索结果并显示历史记录。
2025-11-11 00:24:09 +08:00
2f41c61b7e 优化智能体(Agent)展示轮播
- 将原有的分页网格布局,重构为全屏卡片式轮播。
- 每个智能体以带有背景大图的卡片展示,增强视觉效果。
- 在卡片底部增加了渐变遮罩、标题和描述,并在底部中央添加了“聊天”按钮,以改善用户交互和界面美观度。
- 调整了轮播的尺寸和间距,使其自适应屏幕宽度。
2025-11-10 23:34:35 +08:00
1b70cb5cdb 新增“我的派币”功能
- **API & 数据层**: 新增积分(Points)相关的数据实体、API接口定义 (`getMyPointsBalance`, `getMyPointsChangeLogs`) 和 `PointService` 服务,用于管理和获取用户派币余额及交易记录。
- **UI & 交互**:
    - 新增“我的派币”底部弹窗 (`PointsBottomSheet`),展示当前余额、累计收支、交易历史和如何赚取列表。
    - 新增全局的弹窗管理器 `PointsSheetManager` 和 `PointsBottomSheetHost`,用于在应用内任何位置唤起派币弹窗。
- **功能集成**:
    - 在用户个人主页的卡片上显示派币余额,并添加点击事件以打开弹窗。
    - 应用启动和用户切换时刷新和清理派币数据,确保数据准确性。
2025-11-10 22:53:35 +08:00
dba0ffd826 新增房间成员管理功能
- 新增了批量添加/移除用户和智能体(Agent)到房间的API接口。
- 定义了相关的数据传输对象(DTOs),包括请求体、响应体以及成功/失败/跳过的项目。
- 在服务层(`RoomService`)实现了对这些新API的调用逻辑。
- 添加了API数据模型到领域实体模型(Entity)的转换扩展函数。
2025-11-10 21:45:40 +08:00
6516f5e75d Merge pull request #61 from Kevinlinpr/zhong_1
Zhong 1
2025-11-10 21:28:21 +08:00
9d9d0297c8 Merge branch 'main' into zhong_1 2025-11-10 21:08:24 +08:00
4ee048199b Merge pull request #60 from Kevinlinpr/atm2
feat: 新增推荐信息流功能
2025-11-10 21:07:10 +08:00
4f84eed876 Merge branch 'main' into zhong_1 2025-11-10 21:06:07 +08:00
5a71c8f363 . 2025-11-10 21:02:02 +08:00
14b4e4c54b 添加文本资源文件 2025-11-10 20:59:37 +08:00
4184c23f86 群成员显示;添加群成员UI;群资料设置UI 2025-11-10 20:46:38 +08:00
76b238f180 Merge pull request #58 from Kevinlinpr/feat/pr-20251104-154907-clean
增加英文日语翻译 修改编辑资料界面无法更改星座MBTI
2025-11-10 20:32:18 +08:00
6b5f831271 feat: 新增推荐信息流功能
- 新增推荐(Recommend)信息流,支持多种内容类型,并替换原有的新闻(News)页面入口。
- 实现推荐服务(`RecommendationService`)及相关数据模型,用于从后端获取和解析推荐数据。
- 实现了三种推荐内容卡片:
    - `PromptRecommendationItem`: AI Agent 推荐卡片。
    - `PostRecommendationItem`: 普通图文动态推荐卡片。
    - `VideoRecommendationItem`: 短视频动态推荐卡片。
- 在 `RecommendViewModel` 中实现了统一的数据加载、状态管理和用户交互逻辑(如点赞、收藏)。
- 扩展了 `MomentEntity` 和 `MomentImageEntity` 等数据模型,以支持更丰富的图片URL和处理空值情况。
2025-11-10 20:31:13 +08:00
234587afc9 缁х画瀹屽杽鍔熻兘鏇存柊 2025-11-10 20:30:31 +08:00
b855acd8d3 Merge remote-tracking branch 'origin/main' into feat/pr-20251104-154907-clean 2025-11-10 19:48:35 +08:00
391f841f45 Merge pull request #59 from Kevinlinpr/atm2
feat: 新增短视频功能
2025-11-10 19:47:34 +08:00
2ed5639cbc feat: 新增短视频功能
- 新增短视频信息流页面,支持上下滑动切换视频。
- 实现视频播放、暂停、加载、空状态及错误处理等基础功能。
- 在视频页面中集成点赞、评论、收藏等互动操作。
- 后端接口新增 `videoFilter` 参数,用于仅获取包含视频的动态。
- 扩展了 `MomentEntity` 和相关数据模型,以支持视频数据。
- 将短视频页面集成到动态(Moment)Tab中。
2025-11-10 17:55:33 +08:00
2cbd2a975f Reapply "增加英文日语翻译 修改编辑资料界面无法更改星座mbit"
This reverts commit a057f7f7fd.
2025-11-10 15:26:31 +08:00
283 changed files with 17835 additions and 2513 deletions

View File

@@ -4,10 +4,10 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-11-05T12:24:27.034893100Z"> <DropdownSelection timestamp="2025-11-11T06:03:31.167121900Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=c328a150" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=f800b364" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

31
.idea/gradle.xml generated
View File

@@ -1,20 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" /> <component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" /> <option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" /> <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
</set> </set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option> </option>
</component> </GradleProjectSettings>
</option>
</component>
</project> </project>

2
.idea/kotlinc.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.10" /> <option name="version" value="2.2.21" />
</component> </component>
</project> </project>

View File

@@ -1,21 +1,24 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.compose.compiler)
id("com.google.gms.google-services") id("com.google.gms.google-services")
id("com.google.firebase.crashlytics") id("com.google.firebase.crashlytics")
id("com.google.firebase.firebase-perf") id("com.google.firebase.firebase-perf")
id("org.jetbrains.kotlin.kapt")
alias(libs.plugins.ksp)
} }
android { android {
namespace = "com.aiosman.ravenow" namespace = "com.aiosman.ravenow"
compileSdk = 34 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "com.aiosman.ravenow" applicationId = "com.aiosman.ravenow"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 35
versionCode = 1000019 versionCode = 1000021
versionName = "1.0.000.19" versionName = "1.0.000.21"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -44,19 +47,16 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = "1.8" jvmTarget = "17"
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true buildConfig = true
} }
composeOptions {
kotlinCompilerExtensionVersion = "1.5.3"
}
packaging { packaging {
resources { resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -97,11 +97,13 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.androidx.animation) implementation(libs.androidx.animation)
implementation(libs.coil.compose)
implementation(libs.coil) implementation(libs.coil)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.play.services.auth) implementation(libs.play.services.auth)
implementation(libs.kotlin.faker) implementation(libs.kotlin.faker)
implementation(libs.androidx.material) implementation(libs.androidx.material)
implementation(libs.androidx.material.icons.extended)
implementation(libs.zoomable) implementation(libs.zoomable)
implementation(libs.retrofit) implementation(libs.retrofit)
implementation(libs.converter.gson) implementation(libs.converter.gson)
@@ -127,5 +129,19 @@ dependencies {
implementation (libs.eventbus) implementation (libs.eventbus)
implementation(libs.lottie) implementation(libs.lottie)
// CameraX + ML Kit版本在 libs.versions.toml
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.mlkit.barcode.scanning)
// Room 持久化
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
// Google Play Billing
implementation(libs.billing.ktx)
} }

View File

@@ -7,6 +7,9 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" /> <uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="com.android.vending.BILLING" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<application <application
android:name=".RaveNowApplication" android:name=".RaveNowApplication"
@@ -51,7 +54,8 @@
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.App.Starting" android:theme="@style/Theme.App.Starting"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize"
android:configChanges="fontScale|orientation|screenSize|keyboardHidden">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />

Binary file not shown.

Binary file not shown.

View File

@@ -12,6 +12,7 @@ import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.DictService import com.aiosman.ravenow.data.DictService
import com.aiosman.ravenow.data.DictServiceImpl import com.aiosman.ravenow.data.DictServiceImpl
import com.aiosman.ravenow.data.PointService
import com.aiosman.ravenow.entity.AccountProfileEntity import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.ui.favourite.FavouriteListViewModel import com.aiosman.ravenow.ui.favourite.FavouriteListViewModel
import com.aiosman.ravenow.ui.favourite.FavouriteNoticeViewModel import com.aiosman.ravenow.ui.favourite.FavouriteNoticeViewModel
@@ -31,6 +32,7 @@ import com.aiosman.ravenow.ui.index.tabs.ai.tabs.mine.MineAgentViewModel
import com.aiosman.ravenow.ui.like.LikeNoticeViewModel import com.aiosman.ravenow.ui.like.LikeNoticeViewModel
import com.aiosman.ravenow.utils.Utils import com.aiosman.ravenow.utils.Utils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import com.aiosman.ravenow.im.OpenIMManager import com.aiosman.ravenow.im.OpenIMManager
import io.openim.android.sdk.OpenIMClient import io.openim.android.sdk.OpenIMClient
@@ -50,7 +52,7 @@ object AppState {
suspend fun initWithAccount(scope: CoroutineScope, context: Context) { suspend fun initWithAccount(scope: CoroutineScope, context: Context) {
// 如果是游客模式,使用简化的初始化流程 // 如果是游客模式,使用简化的初始化流程
if (AppStore.isGuest) { if (AppStore.isGuest) {
initWithGuestAccount() initWithGuestAccount(scope)
return return
} }
@@ -81,18 +83,58 @@ object AppState {
// 注册 JPush // 注册 JPush
Messaging.registerDevice(scope, context) Messaging.registerDevice(scope, context)
initChat(context) initChat(context)
// 设置当前用户并刷新积分信息(完成登录态初始化后)
PointService.setCurrentUser(UserId)
try {
PointService.refreshMyPointsBalance()
} catch (e: Exception) {
Log.e("AppState", "刷新积分失败: ${e.message}")
}
// 并行加载积分规则和房间规则配置(不阻塞主流程)
scope.launch {
try {
PointService.refreshPointsRules()
} catch (e: Exception) {
Log.e("AppState", "加载积分规则失败: ${e.message}")
}
}
scope.launch {
try {
PointService.refreshRoomMaxMembers()
} catch (e: Exception) {
Log.e("AppState", "加载房间规则失败: ${e.message}")
}
}
} }
/** /**
* 游客模式的简化初始化 * 游客模式的简化初始化
*/ */
private fun initWithGuestAccount() { private fun initWithGuestAccount(scope: CoroutineScope) {
// 游客模式下不初始化推送和TRTC // 游客模式下不初始化推送和TRTC
// 设置默认的用户信息 // 设置默认的用户信息
UserId = 0 UserId = 0
profile = null profile = null
enableChat = false enableChat = false
Log.d("AppState", "Guest mode initialized without push notifications and TRTC") Log.d("AppState", "Guest mode initialized without push notifications and TRTC")
// 游客模式下也加载规则配置(用于查看费用信息)
scope.launch {
try {
PointService.refreshPointsRules()
} catch (e: Exception) {
Log.e("AppState", "加载积分规则失败: ${e.message}")
}
}
scope.launch {
try {
PointService.refreshRoomMaxMembers()
} catch (e: Exception) {
Log.e("AppState", "加载房间规则失败: ${e.message}")
}
}
} }
private suspend fun initChat(context: Context){ private suspend fun initChat(context: Context){
@@ -228,6 +270,8 @@ object AppState {
AgentViewModel.ResetModel() AgentViewModel.ResetModel()
MineAgentViewModel.ResetModel() MineAgentViewModel.ResetModel()
UserId = null UserId = null
// 清空积分全局状态,避免用户切换串号
PointService.clear()
// 清除游客状态 // 清除游客状态
AppStore.isGuest = false AppStore.isGuest = false

View File

@@ -111,7 +111,7 @@ class DarkThemeColors : AppThemeData(
chatActionColor = Color(0xFF3D3D3D), chatActionColor = Color(0xFF3D3D3D),
brandColorsColor = Color(0xffD80264), brandColorsColor = Color(0xffD80264),
tabSelectedBackground = Color(0xffffffff), tabSelectedBackground = Color(0xffffffff),
tabUnselectedBackground = Color(0x2E7C7480), tabUnselectedBackground = Color(0xFF1C1C1C),
tabSelectedText = Color(0xff000000), tabSelectedText = Color(0xff000000),
tabUnselectedText = Color(0xffffffff), tabUnselectedText = Color(0xffffffff),
bubbleBackground = Color(0xff2d2c2e), bubbleBackground = Color(0xff2d2c2e),

View File

@@ -7,11 +7,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter import coil3.ImageLoader
import coil.request.ImageRequest import coil3.compose.rememberAsyncImagePainter
import coil.ImageLoader import coil3.disk.DiskCache
import coil.disk.DiskCache import coil3.memory.MemoryCache
import coil.memory.MemoryCache import coil3.request.ImageRequest
import coil3.request.crossfade
import okio.Path.Companion.toPath
data class ImageItem(val url: String) data class ImageItem(val url: String)
@@ -53,14 +55,15 @@ fun ImageItem(item: ImageItem, imageLoader: ImageLoader, context: Context) { //
fun getImageLoader(context: Context): ImageLoader { fun getImageLoader(context: Context): ImageLoader {
return ImageLoader.Builder(context) return ImageLoader.Builder(context)
.memoryCache { .memoryCache {
MemoryCache.Builder(context) MemoryCache.Builder()
.maxSizePercent(0.25) // 设置内存缓存大小为可用内存的 25% .maxSizePercent(context,0.25) // 设置内存缓存大小为可用内存的 25%
.build() .build()
} }
.diskCache { .diskCache {
val cacheDir = context.cacheDir.resolve("image_cache")
DiskCache.Builder() DiskCache.Builder()
.directory(context.cacheDir.resolve("image_cache")) .directory(cacheDir.absolutePath.toPath())
.maxSizePercent(0.02) // 设置磁盘缓存大小为可用存储空间的 2% .maxSizeBytes(250L * 1024 * 1024) // 250MB
.build() .build()
} }
.build() .build()

View File

@@ -7,6 +7,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -22,6 +23,8 @@ import androidx.compose.animation.SharedTransitionScope
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
@@ -37,6 +40,7 @@ import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.dialogs.CheckUpdateDialog import com.aiosman.ravenow.ui.dialogs.CheckUpdateDialog
import com.aiosman.ravenow.ui.navigateToPost import com.aiosman.ravenow.ui.navigateToPost
import com.aiosman.ravenow.ui.post.NewPostViewModel import com.aiosman.ravenow.ui.post.NewPostViewModel
import com.aiosman.ravenow.ui.points.PointsBottomSheetHost
import com.google.firebase.Firebase import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.analytics import com.google.firebase.analytics.analytics
@@ -56,6 +60,21 @@ class MainActivity : ComponentActivity() {
private val scope = CoroutineScope(Dispatchers.Main) private val scope = CoroutineScope(Dispatchers.Main)
val context = this val context = this
override fun attachBaseContext(newBase: Context) {
// 禁用字体缩放,固定字体大小为系统默认大小
val configuration = Configuration(newBase.resources.configuration)
configuration.fontScale = 1.0f
val context = newBase.createConfigurationContext(configuration)
super.attachBaseContext(context)
}
override fun onConfigurationChanged(newConfig: Configuration) {
// 确保配置变化时字体缩放保持为 1.0
val config = Configuration(newConfig)
config.fontScale = 1.0f
super.onConfigurationChanged(config)
}
// 请求通知权限 // 请求通知权限
private val requestPermissionLauncher = registerForActivityResult( private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
@@ -127,6 +146,15 @@ class MainActivity : ComponentActivity() {
} }
setContent { setContent {
// 强制字体缩放为 1.0 - 通过覆盖 Density 来实现
val density = LocalDensity.current
val fixedDensity = remember {
androidx.compose.ui.unit.Density(
density = density.density,
fontScale = 1.0f
)
}
var showSplash by remember { mutableStateOf(true) } var showSplash by remember { mutableStateOf(true) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -138,9 +166,12 @@ class MainActivity : ComponentActivity() {
SplashScreen() SplashScreen()
} else { } else {
CompositionLocalProvider( CompositionLocalProvider(
LocalAppTheme provides AppState.appTheme LocalAppTheme provides AppState.appTheme,
LocalDensity provides fixedDensity
) { ) {
CheckUpdateDialog() CheckUpdateDialog()
// 全局挂载积分底部弹窗 Host
PointsBottomSheetHost()
Navigation(startDestination) { navController -> Navigation(startDestination) { navController ->
// 处理带有 postId 的通知点击 // 处理带有 postId 的通知点击

View File

@@ -2,7 +2,12 @@ package com.aiosman.ravenow
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.res.Configuration
import android.util.Log import android.util.Log
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PurchasesUpdatedListener
import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseApp
import com.google.firebase.perf.FirebasePerformance import com.google.firebase.perf.FirebasePerformance
@@ -11,6 +16,16 @@ import com.google.firebase.perf.FirebasePerformance
*/ */
class RaveNowApplication : Application() { class RaveNowApplication : Application() {
private var billingClient: BillingClient? = null
override fun attachBaseContext(base: Context) {
// 禁用字体缩放,固定字体大小为系统默认大小
val configuration = Configuration(base.resources.configuration)
configuration.fontScale = 1.0f
val context = base.createConfigurationContext(configuration)
super.attachBaseContext(context)
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -39,6 +54,53 @@ class RaveNowApplication : Application() {
} catch (e: Exception) { } catch (e: Exception) {
Log.e("RaveNowApplication", "Firebase初始化失败在进程 $processName", e) Log.e("RaveNowApplication", "Firebase初始化失败在进程 $processName", e)
} }
// 初始化 Google Play Billing
initBillingClient()
}
/**
* 初始化 Google Play Billing Client
*/
private fun initBillingClient() {
val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
// 处理购买成功
Log.d("RaveNowApplication", "购买成功: ${purchases.size} 个商品")
} else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {
// 用户取消购买
Log.d("RaveNowApplication", "用户取消购买")
} else {
// 处理其他错误
Log.e("RaveNowApplication", "购买失败: ${billingResult.debugMessage}")
}
}
billingClient = BillingClient.newBuilder(this)
.setListener(purchasesUpdatedListener)
.build()
billingClient?.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
Log.d("RaveNowApplication", "BillingClient 初始化成功")
} else {
Log.e("RaveNowApplication", "BillingClient 初始化失败: ${billingResult.debugMessage}")
}
}
override fun onBillingServiceDisconnected() {
Log.w("RaveNowApplication", "BillingClient 连接断开,尝试重新连接")
// 可以在这里实现重连逻辑
}
})
}
/**
* 获取 BillingClient 实例
*/
fun getBillingClient(): BillingClient? {
return billingClient
} }
/** /**

View File

@@ -64,6 +64,15 @@ data class AccountProfile(
val aiAccount: Boolean, val aiAccount: Boolean,
val chatAIId: String, val chatAIId: String,
// AI角色背景图
val aiRoleAvatar: String? = null,
val aiRoleAvatarMedium: String? = null,
val aiRoleAvatarLarge: String? = null,
// 创建者信息仅AI账号有
@SerializedName("creatorProfile")
val creatorProfile: com.aiosman.ravenow.data.CreatorProfile? = null,
) { ) {
/** /**
* 转换为Entity * 转换为Entity
@@ -89,7 +98,17 @@ data class AccountProfile(
chatToken = openImToken, chatToken = openImToken,
aiAccount = aiAccount, aiAccount = aiAccount,
rawAvatar = avatar, rawAvatar = avatar,
chatAIId = chatAIId chatAIId = chatAIId,
aiRoleAvatar = aiRoleAvatar?.let {
if (it.isNotEmpty()) "${ApiClient.BASE_SERVER}$it" else null
},
aiRoleAvatarMedium = aiRoleAvatarMedium?.let {
if (it.isNotEmpty()) "${ApiClient.BASE_SERVER}$it" else null
},
aiRoleAvatarLarge = aiRoleAvatarLarge?.let {
if (it.isNotEmpty()) "${ApiClient.BASE_SERVER}$it" else null
},
creatorProfile = creatorProfile?.toCreatorProfileEntity()
) )
} }
} }
@@ -417,15 +436,16 @@ interface AccountService {
* @param page 页码 * @param page 页码
* @param pageSize 每页数量 * @param pageSize 每页数量
*/ */
suspend fun getAgent(page: Int, pageSize: Int): retrofit2.Response<DataContainer<ListContainer<Agent>>> suspend fun getAgent(page: Int, pageSize: Int, excludeRoomId: Int? = null, title: String? = null, desc: String? = null): retrofit2.Response<DataContainer<ListContainer<Agent>>>
/** /**
* 创建群聊 * 创建群聊
* @param name 群聊名称 * @param name 群聊名称
* @param userIds 用户ID列表 * @param userIds 用户ID列表
* @param promptIds AI智能体ID列表 * @param promptIds AI智能体ID列表
* @param roomId 房间ID如果提供则添加成员到现有群聊否则创建新群聊
*/ */
suspend fun createGroupChat(name: String, userIds: List<String>, promptIds: List<String>): retrofit2.Response<DataContainer<Unit>> suspend fun createGroupChat(name: String, userIds: List<String>, promptIds: List<String>, roomId: Int? = null): retrofit2.Response<DataContainer<Unit>>
} }
class AccountServiceImpl : AccountService { class AccountServiceImpl : AccountService {
@@ -630,15 +650,15 @@ class AccountServiceImpl : AccountService {
} }
} }
override suspend fun getAgent(page: Int, pageSize: Int): retrofit2.Response<DataContainer<ListContainer<Agent>>> { override suspend fun getAgent(page: Int, pageSize: Int, excludeRoomId: Int?, title: String?, desc: String?): retrofit2.Response<DataContainer<ListContainer<Agent>>> {
return ApiClient.api.getAgent(page, pageSize) return ApiClient.api.getAgent(page, pageSize, excludeRoomId = excludeRoomId, title = title, desc = desc)
} }
override suspend fun createGroupChat(name: String, userIds: List<String>, promptIds: List<String>): retrofit2.Response<DataContainer<Unit>> { override suspend fun createGroupChat(name: String, userIds: List<String>, promptIds: List<String>, roomId: Int?): retrofit2.Response<DataContainer<Unit>> {
val requestBody = com.aiosman.ravenow.data.api.CreateGroupChatRequestBody( val requestBody = com.aiosman.ravenow.data.api.CreateGroupChatRequestBody(
name = name, name = name,
userIds = userIds, userIds = userIds,
promptIds = promptIds promptIds = promptIds,
) )
return ApiClient.api.createGroupChat(requestBody) return ApiClient.api.createGroupChat(requestBody)
} }

View File

@@ -73,7 +73,7 @@ data class Profile(
@SerializedName("nickname") @SerializedName("nickname")
val nickname: String, val nickname: String,
@SerializedName("trtcUserId") @SerializedName("trtcUserId")
val trtcUserId: String, val trtcUserId: String? = null,
@SerializedName("username") @SerializedName("username")
val username: String val username: String
){ ){
@@ -85,7 +85,7 @@ data class Profile(
avatar = "${ApiClient.BASE_SERVER}$avatar", avatar = "${ApiClient.BASE_SERVER}$avatar",
bio = bio, bio = bio,
banner = "${ApiClient.BASE_SERVER}$banner", banner = "${ApiClient.BASE_SERVER}$banner",
trtcUserId = trtcUserId, trtcUserId = trtcUserId ?: "",
chatAIId = chatAIId, chatAIId = chatAIId,
aiAccount = aiAccount aiAccount = aiAccount
) )
@@ -108,6 +108,15 @@ interface AgentService {
authorId: Int? = null authorId: Int? = null
): ListContainer<AgentEntity>? ): ListContainer<AgentEntity>?
/**
* 根据标题关键字搜索智能体
*/
suspend fun searchAgentByTitle(
pageNumber: Int,
pageSize: Int = 20,
title: String
): ListContainer<AgentEntity>?
} }

View File

@@ -123,12 +123,16 @@ data class Comment(
fun toCommentEntity(): CommentEntity { fun toCommentEntity(): CommentEntity {
return CommentEntity( return CommentEntity(
id = id, id = id,
name = user.nickName, name = user.nickName ?: "未知用户",
comment = content, comment = content,
date = ApiClient.dateFromApiString(createdAt), date = ApiClient.dateFromApiString(createdAt),
likes = likeCount, likes = likeCount,
postId = postId, postId = postId,
avatar = "${ApiClient.BASE_SERVER}${user.avatar}", avatar = if (user.avatar != null && user.avatar.isNotEmpty()) {
"${ApiClient.BASE_SERVER}${user.avatar}"
} else {
""
},
author = user.id, author = user.id,
liked = isLiked, liked = isLiked,
unread = isUnread, unread = isUnread,

View File

@@ -14,6 +14,16 @@ interface DictService {
* 获取字典列表 * 获取字典列表
*/ */
suspend fun getDistList(keys: List<String>): List<DictItem> suspend fun getDistList(keys: List<String>): List<DictItem>
/**
* 获取外部字典项
*/
suspend fun getOutsideDictByKey(key: String): DictItem
/**
* 获取外部字典列表
*/
suspend fun getOutsideDistList(keys: List<String>): List<DictItem>
} }
class DictServiceImpl : DictService { class DictServiceImpl : DictService {
@@ -26,4 +36,13 @@ class DictServiceImpl : DictService {
val resp = ApiClient.api.getDicts(keys.joinToString(",")) val resp = ApiClient.api.getDicts(keys.joinToString(","))
return resp.body()?.list ?: throw Exception("failed to get dict list") return resp.body()?.list ?: throw Exception("failed to get dict list")
} }
override suspend fun getOutsideDictByKey(key: String): DictItem {
val resp = ApiClient.api.getOutsideDict(key)
return resp.body()?.data ?: throw Exception("failed to get outside dict")
}
override suspend fun getOutsideDistList(keys: List<String>): List<DictItem> {
val resp = ApiClient.api.getOutsideDicts(keys.joinToString(","))
return resp.body()?.list ?: throw Exception("failed to get outside dict list")
}
} }

View File

@@ -4,6 +4,7 @@ import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.api.ApiClient import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.entity.MomentEntity import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.entity.MomentImageEntity import com.aiosman.ravenow.entity.MomentImageEntity
import com.aiosman.ravenow.entity.MomentVideoEntity
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.io.File import java.io.File
@@ -12,8 +13,12 @@ data class Moment(
val id: Long, val id: Long,
@SerializedName("textContent") @SerializedName("textContent")
val textContent: String, val textContent: String,
@SerializedName("url")
val url: String? = null,
@SerializedName("images") @SerializedName("images")
val images: List<Image>, val images: List<Image>? = null,
@SerializedName("videos")
val videos: List<Video>? = null,
@SerializedName("user") @SerializedName("user")
val user: User, val user: User,
@SerializedName("likeCount") @SerializedName("likeCount")
@@ -24,12 +29,16 @@ data class Moment(
val favoriteCount: Long, val favoriteCount: Long,
@SerializedName("isFavorite") @SerializedName("isFavorite")
val isFavorite: Boolean, val isFavorite: Boolean,
@SerializedName("shareCount") @SerializedName("isCommented")
val isCommented: Boolean, val isCommented: Boolean,
@SerializedName("commentCount") @SerializedName("commentCount")
val commentCount: Long, val commentCount: Long,
@SerializedName("time") @SerializedName("time")
val time: String, val time: String?,
@SerializedName("createdAt")
val createdAt: String? = null,
@SerializedName("location")
val location: String? = null,
@SerializedName("isFollowed") @SerializedName("isFollowed")
val isFollowed: Boolean, val isFollowed: Boolean,
// 新闻相关字段 // 新闻相关字段
@@ -47,14 +56,30 @@ data class Moment(
val newsLanguage: String? = null, val newsLanguage: String? = null,
@SerializedName("newsContent") @SerializedName("newsContent")
val newsContent: String? = null, val newsContent: String? = null,
@SerializedName("hasFullText")
val hasFullText: Boolean = false,
@SerializedName("summary")
val summary: String? = null,
@SerializedName("publishedAt")
val publishedAt: String? = null,
@SerializedName("imageCached")
val imageCached: Boolean = false
) { ) {
fun toMomentItem(): MomentEntity { fun toMomentItem(): MomentEntity {
return MomentEntity( return MomentEntity(
id = id.toInt(), id = id.toInt(),
avatar = "${ApiClient.BASE_SERVER}${user.avatar}", avatar = if (user.avatar != null && user.avatar.isNotEmpty()) {
nickname = user.nickName, "${ApiClient.BASE_SERVER}${user.avatar}"
location = "Worldwide", } else {
time = ApiClient.dateFromApiString(time), "" // 如果头像为空,使用空字符串
},
nickname = user.nickName ?: "未知用户", // 如果昵称为空,使用默认值
location = location ?: "Worldwide",
time = when {
createdAt != null && createdAt.isNotEmpty() -> ApiClient.dateFromApiString(createdAt)
time != null && time.isNotEmpty() -> ApiClient.dateFromApiString(time)
else -> java.util.Date() // 如果时间为空,使用当前时间作为默认值
},
followStatus = isFollowed, followStatus = isFollowed,
momentTextContent = textContent, momentTextContent = textContent,
momentPicture = R.drawable.default_moment_img, momentPicture = R.drawable.default_moment_img,
@@ -62,19 +87,46 @@ data class Moment(
commentCount = commentCount.toInt(), commentCount = commentCount.toInt(),
shareCount = 0, shareCount = 0,
favoriteCount = favoriteCount.toInt(), favoriteCount = favoriteCount.toInt(),
images = images.map { images = images?.map {
MomentImageEntity( MomentImageEntity(
url = "${ApiClient.BASE_SERVER}${it.url}",
thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}",
id = it.id, id = it.id,
url = "${ApiClient.BASE_SERVER}${it.url}",
originalUrl = it.originalUrl,
directUrl = it.directUrl,
thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}",
thumbnailDirectUrl = it.thumbnailDirectUrl,
small = it.small,
smallDirectUrl = it.smallDirectUrl,
medium = it.medium,
mediumDirectUrl = it.mediumDirectUrl,
large = it.large,
largeDirectUrl = it.largeDirectUrl,
blurHash = it.blurHash, blurHash = it.blurHash,
width = it.width, width = it.width,
height = it.height height = it.height
) )
}, } ?: emptyList(),
authorId = user.id.toInt(), authorId = user.id.toInt(),
liked = isLiked, liked = isLiked,
isFavorite = isFavorite, isFavorite = isFavorite,
url = url,
videos = videos?.map {
MomentVideoEntity(
id = it.id,
url = "${ApiClient.BASE_SERVER}${it.url}",
originalUrl = it.originalUrl,
directUrl = it.directUrl,
thumbnailUrl = it.thumbnailUrl?.let { thumb -> "${ApiClient.BASE_SERVER}$thumb" },
thumbnailDirectUrl = it.thumbnailDirectUrl,
duration = it.duration,
width = it.width,
height = it.height,
size = it.size,
format = it.format,
bitrate = it.bitrate,
frameRate = it.frameRate
)
},
// 新闻相关字段 // 新闻相关字段
isNews = isNews, isNews = isNews,
newsTitle = newsTitle ?: "", newsTitle = newsTitle ?: "",
@@ -82,7 +134,11 @@ data class Moment(
newsSource = newsSource ?: "", newsSource = newsSource ?: "",
newsCategory = newsCategory ?: "", newsCategory = newsCategory ?: "",
newsLanguage = newsLanguage ?: "", newsLanguage = newsLanguage ?: "",
newsContent = newsContent ?: "" newsContent = newsContent ?: "",
hasFullText = hasFullText,
summary = summary,
publishedAt = publishedAt,
imageCached = imageCached
) )
} }
} }
@@ -92,8 +148,26 @@ data class Image(
val id: Long, val id: Long,
@SerializedName("url") @SerializedName("url")
val url: String, val url: String,
@SerializedName("original_url")
val originalUrl: String? = null,
@SerializedName("directUrl")
val directUrl: String? = null,
@SerializedName("thumbnail") @SerializedName("thumbnail")
val thumbnail: String, val thumbnail: String,
@SerializedName("thumbnailDirectUrl")
val thumbnailDirectUrl: String? = null,
@SerializedName("small")
val small: String? = null,
@SerializedName("smallDirectUrl")
val smallDirectUrl: String? = null,
@SerializedName("medium")
val medium: String? = null,
@SerializedName("mediumDirectUrl")
val mediumDirectUrl: String? = null,
@SerializedName("large")
val large: String? = null,
@SerializedName("largeDirectUrl")
val largeDirectUrl: String? = null,
@SerializedName("blurHash") @SerializedName("blurHash")
val blurHash: String?, val blurHash: String?,
@SerializedName("width") @SerializedName("width")
@@ -102,13 +176,68 @@ data class Image(
val height: Int? val height: Int?
) )
data class Video(
@SerializedName("id")
val id: Long,
@SerializedName("url")
val url: String,
@SerializedName("original_url")
val originalUrl: String? = null,
@SerializedName("directUrl")
val directUrl: String? = null,
@SerializedName("thumbnailUrl")
val thumbnailUrl: String? = null,
@SerializedName("thumbnailDirectUrl")
val thumbnailDirectUrl: String? = null,
@SerializedName("duration")
val duration: Int? = null,
@SerializedName("width")
val width: Int? = null,
@SerializedName("height")
val height: Int? = null,
@SerializedName("size")
val size: Long? = null,
@SerializedName("format")
val format: String? = null,
@SerializedName("bitrate")
val bitrate: Int? = null,
@SerializedName("frameRate")
val frameRate: String? = null
)
data class User( data class User(
@SerializedName("id") @SerializedName("id")
val id: Long, val id: Long,
@SerializedName("nickName") @SerializedName("nickname")
val nickName: String, val nickName: String?,
@SerializedName("avatar") @SerializedName("avatar")
val avatar: String val avatar: String?,
@SerializedName("avatarMedium")
val avatarMedium: String? = null,
@SerializedName("avatarLarge")
val avatarLarge: String? = null,
@SerializedName("originAvatar")
val originAvatar: String? = null,
@SerializedName("avatarDirectUrl")
val avatarDirectUrl: String? = null,
@SerializedName("avatarMediumDirectUrl")
val avatarMediumDirectUrl: String? = null,
@SerializedName("avatarLargeDirectUrl")
val avatarLargeDirectUrl: String? = null,
@SerializedName("aiAccount")
val aiAccount: Boolean = false,
@SerializedName("aiRoleAvatar")
val aiRoleAvatar: String? = null,
@SerializedName("aiRoleAvatarMedium")
val aiRoleAvatarMedium: String? = null,
@SerializedName("aiRoleAvatarLarge")
val aiRoleAvatarLarge: String? = null,
@SerializedName("aiRoleAvatarDirectUrl")
val aiRoleAvatarDirectUrl: String? = null,
@SerializedName("aiRoleAvatarMediumDirectUrl")
val aiRoleAvatarMediumDirectUrl: String? = null,
@SerializedName("aiRoleAvatarLargeDirectUrl")
val aiRoleAvatarLargeDirectUrl: String? = null
) )
data class UploadImage( data class UploadImage(

View File

@@ -0,0 +1,414 @@
package com.aiosman.ravenow.data
import android.content.Context
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.DictItem
import com.aiosman.ravenow.data.api.PointsBalance
import com.aiosman.ravenow.data.api.PointsChangeLogsResponse
import com.google.gson.Gson
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
/**
* 积分服务
*
* 提供积分余额查询和积分变更日志查询功能
*/
object PointService {
// ========== 定价表(字典 points_rules相关 ==========
private val dictService: DictService = DictServiceImpl()
private val gson = Gson()
/**
* 积分规则key常量
* 对应积分规则JSON中的key值
*/
object PointsRuleKey {
// 获得积分类型add
/** 每日登录奖励 */
const val DAILY_LOGIN = "daily_login"
/** 用户注册奖励 */
const val USER_REGISTER = "user_register"
// 消费积分类型sub
/** 添加Agent记忆 */
const val ADD_AGENT_MEMORY = "add_agent_memory"
/** 增加房间容量 */
const val ADD_ROOM_CAP = "add_room_cap"
/** 创建房间 */
const val CREATE_ROOM = "create_room"
/** 创建定时事件 */
const val CREATE_SCHEDULE_EVENT = "create_schedule_event"
/** 房间私密模式 */
const val ROOM_PRIVATE = "room_private"
/** Agent私密模式 */
const val SPEND_AGENT_PRIVATE = "spend_agent_private"
/** 自定义聊天背景 */
const val SPEND_CHAT_BACKGROUND = "spend_chat_background"
/** 房间记忆添加 */
const val SPEND_ROOM_MEMORY = "spend_room_memory"
}
sealed class RuleAmount {
data class Fixed(val value: Int) : RuleAmount()
data class Range(val min: Int, val max: Int) : RuleAmount()
}
data class PointsRules(
val add: Map<String, RuleAmount>,
val sub: Map<String, RuleAmount>
)
private val _pointsRules = MutableStateFlow<PointsRules?>(null)
val pointsRules: StateFlow<PointsRules?> = _pointsRules.asStateFlow()
suspend fun refreshPointsRules(key: String = "points_rules") {
withContext(Dispatchers.IO) {
try {
val dict = dictService.getDictByKey(key)
val rules = parsePointsRules(dict)
_pointsRules.value = rules
} catch (_: Exception) {
_pointsRules.value = null
}
}
}
// ========== 群聊人数限制(字典 points-rule相关 ==========
/**
* 群聊人数限制配置
* @param defaultMaxTotal 初始最大人数(默认值)
* @param maxTotal 最大人数(上限)
*/
data class RoomMaxMembers(
val defaultMaxTotal: Int,
val maxTotal: Int
)
private val _roomMaxMembers = MutableStateFlow<RoomMaxMembers?>(null)
val roomMaxMembers: StateFlow<RoomMaxMembers?> = _roomMaxMembers.asStateFlow()
/**
* 刷新群聊人数限制配置(从外部字典表加载 points-rule
* 加载时机与 refreshPointsRules 一致
*/
suspend fun refreshRoomMaxMembers(key: String = "points-rule") {
withContext(Dispatchers.IO) {
try {
val dict = dictService.getOutsideDictByKey(key)
val config = parseRoomMaxMembers(dict)
_roomMaxMembers.value = config
} catch (_: Exception) {
_roomMaxMembers.value = null
}
}
}
/**
* 解析群聊人数限制配置
* 解析格式:{"room":{"default":{"max-total":5},"max":{"max-total":200}}}
*/
private fun parseRoomMaxMembers(dict: DictItem): RoomMaxMembers? {
val raw = dict.value
val jsonStr = when (raw) {
is String -> raw
else -> gson.toJson(raw)
}
return try {
val root = JsonParser.parseString(jsonStr).asJsonObject
val roomObj = root.getAsJsonObject("room")
val defaultObj = roomObj?.getAsJsonObject("default")
val maxObj = roomObj?.getAsJsonObject("max")
val defaultMaxTotal = defaultObj?.get("max-total")?.takeIf { it.isJsonPrimitive }?.asInt ?: 5
val maxTotal = maxObj?.get("max-total")?.takeIf { it.isJsonPrimitive }?.asInt ?: 200
RoomMaxMembers(
defaultMaxTotal = defaultMaxTotal,
maxTotal = maxTotal
)
} catch (_: Exception) {
null
}
}
private fun parsePointsRules(dict: DictItem): PointsRules? {
val raw = dict.value
val jsonStr = when (raw) {
is String -> raw
else -> gson.toJson(raw)
}
return try {
val root = JsonParser.parseString(jsonStr).asJsonObject
fun parseBlock(block: JsonObject?): Map<String, RuleAmount> {
if (block == null) return emptyMap()
return block.entrySet().associate { entry ->
val key = entry.key
val v: JsonElement = entry.value
val amount: RuleAmount = when {
v.isJsonPrimitive && v.asJsonPrimitive.isNumber -> {
RuleAmount.Fixed(v.asInt)
}
v.isJsonPrimitive && v.asJsonPrimitive.isString -> {
val s = v.asString.trim()
if (s.contains("-")) {
val parts = s.split("-")
.map { it.trim() }
.filter { it.isNotEmpty() }
val min = parts.getOrNull(0)?.toIntOrNull()
val max = parts.getOrNull(1)?.toIntOrNull()
when {
min != null && max != null -> RuleAmount.Range(min, max)
min != null -> RuleAmount.Fixed(min)
else -> RuleAmount.Fixed(0)
}
} else {
RuleAmount.Fixed(s.toIntOrNull() ?: 0)
}
}
v.isJsonObject -> {
val obj = v.asJsonObject
val min = obj.get("min")?.takeIf { it.isJsonPrimitive }?.asInt
val max = obj.get("max")?.takeIf { it.isJsonPrimitive }?.asInt
val value = obj.get("value")?.takeIf { it.isJsonPrimitive }?.asInt
when {
min != null && max != null -> RuleAmount.Range(min, max)
value != null -> RuleAmount.Fixed(value)
else -> RuleAmount.Fixed(0)
}
}
else -> RuleAmount.Fixed(0)
}
key to amount
}
}
val addObj = root.getAsJsonObject("add")
val subObj = root.getAsJsonObject("sub")
PointsRules(
add = parseBlock(addObj),
sub = parseBlock(subObj)
)
} catch (_: Exception) {
null
}
}
// 全局可观察的积分余额(仅内存,不落盘)
private val _pointsBalance = MutableStateFlow<PointsBalance?>(null)
val pointsBalance: StateFlow<PointsBalance?> = _pointsBalance.asStateFlow()
// 当前已加载的用户ID用于处理用户切换
@Volatile private var currentUserId: Int? = null
/** 设置当前用户ID当用户切换时会清空旧的积分数据以避免串号 */
fun setCurrentUser(userId: Int?) {
if (currentUserId != userId) {
currentUserId = userId
_pointsBalance.value = null
}
}
/** 清空内存中的积分状态(用于登出或用户切换) */
fun clear() {
_pointsBalance.value = null
currentUserId = null
}
/**
* 刷新当前用户的积分余额(进入应用并完成登录态初始化后调用)
* - 若为游客或无 token则清空并返回
*/
suspend fun refreshMyPointsBalance(includeStatistics: Boolean = true) {
withContext(Dispatchers.IO) {
if (AppStore.isGuest || AppStore.token == null) {
clear()
return@withContext
}
val balance = getMyPointsBalance(includeStatistics)
_pointsBalance.value = balance
}
}
/**
* 获取当前用户积分余额
*
* @param includeStatistics 是否包含统计信息(累计获得和累计消费),默认 true
* @return 积分余额信息,包含当前余额和可选的统计数据
* @throws Exception 网络请求失败或服务器返回错误
*
* 示例:
* ```kotlin
* try {
* // 获取包含统计信息的积分余额
* val balance = PointService.getMyPointsBalance()
* println("当前余额: ${balance.balance}")
* println("累计获得: ${balance.totalEarned}")
* println("累计消费: ${balance.totalSpent}")
*
* // 仅获取当前余额
* val simpleBalance = PointService.getMyPointsBalance(includeStatistics = false)
* println("当前余额: ${simpleBalance.balance}")
* } catch (e: Exception) {
* println("获取积分余额失败: ${e.message}")
* }
* ```
*/
suspend fun getMyPointsBalance(includeStatistics: Boolean = true): PointsBalance {
return withContext(Dispatchers.IO) {
val response = ApiClient.api.getMyPointsBalance(includeStatistics)
if (response.isSuccessful) {
response.body()?.data ?: throw Exception("响应数据为空")
} else {
throw Exception("获取积分余额失败: ${response.code()} ${response.message()}")
}
}
}
/**
* 获取当前用户积分变更日志列表
*
* @param page 页码,默认 1
* @param pageSize 每页数量,默认 20
* @param changeType 变更类型筛选("add": 增加, "subtract": 减少, "adjust": 调整null 表示不筛选
* @param startTime 开始时间格式YYYY-MM-DDnull 表示不限制
* @param endTime 结束时间格式YYYY-MM-DDnull 表示不限制
* @return 积分变更日志列表响应,包含日志列表和分页信息
* @throws Exception 网络请求失败或服务器返回错误
*
* 示例:
* ```kotlin
* try {
* // 获取最近的积分变更日志
* val logs = PointService.getMyPointsChangeLogs(page = 1, pageSize = 20)
* println("总记录数: ${logs.total}")
* logs.list.forEach { log ->
* println("${log.createdAt}: ${log.changeType} ${log.amount} (${log.reason})")
* }
*
* // 筛选积分增加记录
* val earnLogs = PointService.getMyPointsChangeLogs(changeType = "add")
*
* // 查询指定时间范围的记录
* val rangeLogs = PointService.getMyPointsChangeLogs(
* startTime = "2024-01-01",
* endTime = "2024-01-31"
* )
* } catch (e: Exception) {
* println("获取积分变更日志失败: ${e.message}")
* }
* ```
*/
suspend fun getMyPointsChangeLogs(
page: Int = 1,
pageSize: Int = 20,
changeType: String? = null,
startTime: String? = null,
endTime: String? = null
): PointsChangeLogsResponse {
return withContext(Dispatchers.IO) {
val response =
ApiClient.api.getMyPointsChangeLogs(
page = page,
pageSize = pageSize,
changeType = changeType,
startTime = startTime,
endTime = endTime
)
if (response.isSuccessful) {
response.body() ?: throw Exception("响应数据为空")
} else {
throw Exception("获取积分变更日志失败: ${response.code()} ${response.message()}")
}
}
}
/** 积分变更类型常量 */
object ChangeType {
/** 积分增加 */
const val ADD = "add"
/** 积分减少 */
const val SUBTRACT = "subtract"
/** 积分调整 */
const val ADJUST = "adjust"
}
/** 积分变更原因常量 */
object ChangeReason {
// 获得积分类型
/** 新用户注册奖励 */
const val EARN_REGISTER = "earn_register"
/** 每日签到奖励 */
const val EARN_DAILY = "earn_daily"
/** 任务完成奖励 */
const val EARN_TASK = "earn_task"
/** 邀请好友奖励 */
const val EARN_INVITE = "earn_invite"
/** 充值获得 */
const val EARN_RECHARGE = "earn_recharge"
// 消费积分类型
/** 创建群聊 */
const val SPEND_GROUP_CREATE = "spend_group_create"
/** 扩容群聊 */
const val SPEND_GROUP_EXPAND = "spend_group_expand"
/** Agent 私密模式 */
const val SPEND_AGENT_PRIVATE = "spend_agent_private"
/** Agent 记忆添加 */
const val SPEND_AGENT_MEMORY = "spend_agent_memory"
/** 房间记忆添加 */
const val SPEND_ROOM_MEMORY = "spend_room_memory"
/** 自定义聊天背景 */
const val SPEND_CHAT_BACKGROUND = "spend_chat_background"
/** 定时事件解锁 */
const val SPEND_SCHEDULE_EVENT = "spend_schedule_event"
}
/**
* 获取变更原因的描述(支持多语言)
*
* @param context Context 用于获取资源
* @param reason 变更原因代码
* @return 本地化描述
*/
fun getReasonDescription(context: Context, reason: String): String {
val resourceId = when (reason) {
ChangeReason.EARN_REGISTER -> R.string.earn_register
ChangeReason.EARN_DAILY -> R.string.earn_daily
ChangeReason.EARN_TASK -> R.string.earn_task
ChangeReason.EARN_INVITE -> R.string.earn_invite
ChangeReason.EARN_RECHARGE -> R.string.earn_recharge
ChangeReason.SPEND_GROUP_CREATE -> R.string.spend_group_create
ChangeReason.SPEND_GROUP_EXPAND -> R.string.spend_group_expand
ChangeReason.SPEND_AGENT_PRIVATE -> R.string.spend_agent_private
ChangeReason.SPEND_AGENT_MEMORY -> R.string.spend_agent_memory
ChangeReason.SPEND_ROOM_MEMORY -> R.string.spend_room_memory
ChangeReason.SPEND_CHAT_BACKGROUND -> R.string.spend_chat_background
ChangeReason.SPEND_SCHEDULE_EVENT -> R.string.spend_schedule_event
else -> null
}
return resourceId?.let { context.getString(it) } ?: reason // 未知原因,返回原始代码
}
/**
* 获取变更类型的中文描述
*
* @param changeType 变更类型代码
* @return 中文描述
*/
fun getChangeTypeDescription(changeType: String): String {
return when (changeType) {
ChangeType.ADD -> "增加"
ChangeType.SUBTRACT -> "减少"
ChangeType.ADJUST -> "调整"
else -> changeType
}
}
}

View File

@@ -0,0 +1,351 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.data.api.ApiClient
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import com.google.gson.annotations.SerializedName
/**
* 推荐接口响应
*/
data class RecommendationsResponse(
@SerializedName("success")
val success: Boolean,
@SerializedName("data")
val data: List<RecommendationItem>
)
/**
* 推荐项
*/
data class RecommendationItem(
@SerializedName("type")
val type: String,
@SerializedName("id")
val id: Long,
@SerializedName("data")
val data: Any // 根据type字段动态解析为不同类型
) {
/**
* 将data字段转换为PromptRecommendationData
*/
fun toPromptData(): PromptRecommendationData? {
if (type != "prompt") return null
return try {
val gson = Gson()
val jsonString = gson.toJson(data)
gson.fromJson(jsonString, PromptRecommendationData::class.java)
} catch (e: JsonSyntaxException) {
null
} catch (e: Exception) {
null
}
}
/**
* 将data字段转换为PostRecommendationData (Moment)
*/
fun toPostData(): PostRecommendationData? {
if (type !in listOf("post_normal", "post_video", "post_news", "post_music")) return null
return try {
val gson = Gson()
val jsonString = gson.toJson(data)
gson.fromJson(jsonString, PostRecommendationData::class.java)
} catch (e: JsonSyntaxException) {
null
} catch (e: Exception) {
null
}
}
/**
* 将data字段转换为RoomRecommendationData
*/
fun toRoomData(): RoomRecommendationData? {
if (type != "room") return null
return try {
val gson = Gson()
val jsonString = gson.toJson(data)
gson.fromJson(jsonString, RoomRecommendationData::class.java)
} catch (e: JsonSyntaxException) {
null
} catch (e: Exception) {
null
}
}
}
/**
* Prompt类型推荐数据
*/
data class PromptRecommendationData(
@SerializedName("id")
val id: Long,
@SerializedName("title")
val title: String,
@SerializedName("desc")
val desc: String,
@SerializedName("createdAt")
val createdAt: String,
@SerializedName("updatedAt")
val updatedAt: String,
@SerializedName("avatar")
val avatar: String? = null,
@SerializedName("avatarDirectUrl")
val avatarDirectUrl: String? = null,
@SerializedName("author")
val author: String? = null,
@SerializedName("isPublic")
val isPublic: Boolean = false,
@SerializedName("openId")
val openId: String,
@SerializedName("breakMode")
val breakMode: Boolean = false,
@SerializedName("useCount")
val useCount: Int? = null,
@SerializedName("translations")
val translations: Map<String, Map<String, String>>? = null,
@SerializedName("translation")
val translation: Map<String, String>? = null,
@SerializedName("details")
val details: PromptDetails? = null,
@SerializedName("aiUserProfile")
val aiUserProfile: AIUserProfile? = null,
@SerializedName("creatorProfile")
val creatorProfile: CreatorProfile? = null
)
/**
* Prompt详细信息
*/
data class PromptDetails(
@SerializedName("gender")
val gender: String? = null,
@SerializedName("age")
val age: Int? = null,
@SerializedName("mbti")
val mbti: String? = null,
@SerializedName("constellation")
val constellation: String? = null,
@SerializedName("nickname")
val nickname: String? = null,
@SerializedName("birthday")
val birthday: String? = null,
@SerializedName("signature")
val signature: String? = null,
@SerializedName("nationality")
val nationality: String? = null,
@SerializedName("mainLanguage")
val mainLanguage: String? = null,
@SerializedName("worldview")
val worldview: String? = null,
@SerializedName("habits")
val habits: String? = null,
@SerializedName("hobbies")
val hobbies: String? = null,
@SerializedName("occupation")
val occupation: String? = null,
@SerializedName("expertise")
val expertise: String? = null,
@SerializedName("socialActivityLvl")
val socialActivityLvl: String? = null
)
/**
* AI用户资料
*/
data class AIUserProfile(
@SerializedName("id")
val id: Long,
@SerializedName("username")
val username: String? = null,
@SerializedName("nickname")
val nickname: String,
@SerializedName("avatar")
val avatar: String? = null,
@SerializedName("avatarMedium")
val avatarMedium: String? = null,
@SerializedName("avatarLarge")
val avatarLarge: String? = null,
@SerializedName("avatarDirectUrl")
val avatarDirectUrl: String? = null,
@SerializedName("avatarMediumDirectUrl")
val avatarMediumDirectUrl: String? = null,
@SerializedName("avatarLargeDirectUrl")
val avatarLargeDirectUrl: String? = null,
@SerializedName("bio")
val bio: String? = null,
@SerializedName("trtcUserId")
val trtcUserId: String? = null,
@SerializedName("chatAIId")
val chatAIId: String? = null,
@SerializedName("aiAccount")
val aiAccount: Boolean = true,
@SerializedName("aiRoleAvatar")
val aiRoleAvatar: String? = null,
@SerializedName("aiRoleAvatarMedium")
val aiRoleAvatarMedium: String? = null,
@SerializedName("aiRoleAvatarLarge")
val aiRoleAvatarLarge: String? = null,
@SerializedName("aiRoleAvatarDirectUrl")
val aiRoleAvatarDirectUrl: String? = null,
@SerializedName("aiRoleAvatarMediumDirectUrl")
val aiRoleAvatarMediumDirectUrl: String? = null,
@SerializedName("aiRoleAvatarLargeDirectUrl")
val aiRoleAvatarLargeDirectUrl: String? = null
)
/**
* 创建者资料
*/
data class CreatorProfile(
@SerializedName("id")
val id: Long,
@SerializedName("username")
val username: String? = null,
@SerializedName("nickname")
val nickname: String,
@SerializedName("avatar")
val avatar: String? = null,
@SerializedName("avatarMedium")
val avatarMedium: String? = null,
@SerializedName("avatarLarge")
val avatarLarge: String? = null,
@SerializedName("avatarDirectUrl")
val avatarDirectUrl: String? = null,
@SerializedName("avatarMediumDirectUrl")
val avatarMediumDirectUrl: String? = null,
@SerializedName("avatarLargeDirectUrl")
val avatarLargeDirectUrl: String? = null,
@SerializedName("bio")
val bio: String? = null,
@SerializedName("trtcUserId")
val trtcUserId: String? = null,
@SerializedName("chatAIId")
val chatAIId: String? = null,
@SerializedName("aiAccount")
val aiAccount: Boolean = false,
@SerializedName("aiRoleAvatar")
val aiRoleAvatar: String? = null,
@SerializedName("aiRoleAvatarMedium")
val aiRoleAvatarMedium: String? = null,
@SerializedName("aiRoleAvatarLarge")
val aiRoleAvatarLarge: String? = null,
@SerializedName("aiRoleAvatarDirectUrl")
val aiRoleAvatarDirectUrl: String? = null,
@SerializedName("aiRoleAvatarMediumDirectUrl")
val aiRoleAvatarMediumDirectUrl: String? = null,
@SerializedName("aiRoleAvatarLargeDirectUrl")
val aiRoleAvatarLargeDirectUrl: String? = null
)
/**
* Post类型推荐数据复用Moment中的结构
* 支持 post_normal, post_video, post_news, post_music
*/
typealias PostRecommendationData = Moment
/**
* Room类型推荐数据
*/
data class RoomRecommendationData(
@SerializedName("id")
val id: Long,
@SerializedName("name")
val name: String,
@SerializedName("description")
val description: String,
@SerializedName("createdAt")
val createdAt: String? = null,
@SerializedName("updatedAt")
val updatedAt: String? = null,
@SerializedName("cover")
val cover: String? = null,
@SerializedName("coverDirectUrl")
val coverDirectUrl: String? = null,
@SerializedName("avatar")
val avatar: String? = null,
@SerializedName("avatarDirectUrl")
val avatarDirectUrl: String? = null,
@SerializedName("recommendBanner")
val recommendBanner: String? = null,
@SerializedName("trtcRoomId")
val trtcRoomId: String? = null,
@SerializedName("trtcType")
val trtcType: String? = null,
@SerializedName("isRecommended")
val isRecommended: Boolean = false,
@SerializedName("allowInHot")
val allowInHot: Boolean = false,
@SerializedName("language")
val language: String? = null,
@SerializedName("maxTotal")
val maxTotal: Int? = null,
@SerializedName("privateFeePaid")
val privateFeePaid: Boolean = false
)
/**
* 推荐Service接口
*/
interface RecommendationService {
/**
* 获取推荐列表
* @param pool 推荐池名称(可选)
* @param count 返回数量可选默认10最大50
* @param lang 语言代码(可选)
* @param promptReplaceTrans 是否用翻译覆盖原字段可选仅对prompt类型生效
* @return 推荐项列表
*/
suspend fun getRecommendations(
pool: String? = null,
count: Int? = null,
lang: String? = null,
promptReplaceTrans: Boolean? = null
): List<RecommendationItem>
}
/**
* 推荐Service实现
*/
class RecommendationServiceImpl : RecommendationService {
override suspend fun getRecommendations(
pool: String?,
count: Int?,
lang: String?,
promptReplaceTrans: Boolean?
): List<RecommendationItem> {
val resp = ApiClient.api.getRecommendations(
pool = pool,
count = count,
lang = lang,
promptReplaceTrans = promptReplaceTrans
)
if (resp.isSuccessful) {
val body = resp.body()
if (body != null && body.success) {
return body.data
}
}
throw ServiceException("Failed to get recommendations")
}
}
/**
* CreatorProfile 扩展函数,转换为 CreatorProfileEntity
*/
fun CreatorProfile.toCreatorProfileEntity(): com.aiosman.ravenow.entity.CreatorProfileEntity {
return com.aiosman.ravenow.entity.CreatorProfileEntity(
id = id,
username = username,
nickname = nickname,
avatar = avatar?.let {
if (it.isNotEmpty()) "${ApiClient.BASE_SERVER}$it" else null
},
bio = bio,
trtcUserId = trtcUserId,
chatAIId = chatAIId,
aiAccount = aiAccount
)
}

View File

@@ -6,7 +6,19 @@ import com.aiosman.ravenow.data.api.UpdateRoomRuleRequestBody
import com.aiosman.ravenow.data.api.RoomRuleQuota import com.aiosman.ravenow.data.api.RoomRuleQuota
import com.aiosman.ravenow.data.api.RoomRule import com.aiosman.ravenow.data.api.RoomRule
import com.aiosman.ravenow.data.api.RoomRuleCreator import com.aiosman.ravenow.data.api.RoomRuleCreator
import com.aiosman.ravenow.entity.AddAgentToRoomFailedItemEntity
import com.aiosman.ravenow.entity.AddAgentToRoomItemEntity
import com.aiosman.ravenow.entity.AddAgentToRoomResultEntity
import com.aiosman.ravenow.entity.AddUserToRoomFailedItemEntity
import com.aiosman.ravenow.entity.AddUserToRoomItemEntity
import com.aiosman.ravenow.entity.AddUserToRoomResultEntity
import com.aiosman.ravenow.entity.CreatorEntity import com.aiosman.ravenow.entity.CreatorEntity
import com.aiosman.ravenow.entity.RemoveAgentFromRoomFailedItemEntity
import com.aiosman.ravenow.entity.RemoveAgentFromRoomItemEntity
import com.aiosman.ravenow.entity.RemoveAgentFromRoomResultEntity
import com.aiosman.ravenow.entity.RemoveUserFromRoomFailedItemEntity
import com.aiosman.ravenow.entity.RemoveUserFromRoomItemEntity
import com.aiosman.ravenow.entity.RemoveUserFromRoomResultEntity
import com.aiosman.ravenow.entity.RoomEntity import com.aiosman.ravenow.entity.RoomEntity
import com.aiosman.ravenow.entity.RoomRuleEntity import com.aiosman.ravenow.entity.RoomRuleEntity
import com.aiosman.ravenow.entity.RoomRuleCreatorEntity import com.aiosman.ravenow.entity.RoomRuleCreatorEntity
@@ -14,6 +26,22 @@ import com.aiosman.ravenow.entity.RoomRuleQuotaEntity
import com.aiosman.ravenow.entity.UsersEntity import com.aiosman.ravenow.entity.UsersEntity
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
/**
* 房间内的智能体信息PromptTemplate
*/
data class PromptTemplate(
@SerializedName("id")
val id: Int,
@SerializedName("openId")
val openId: String,
@SerializedName("title")
val title: String,
@SerializedName("desc")
val desc: String,
@SerializedName("avatar")
val avatar: String
)
data class Room( data class Room(
@SerializedName("id") @SerializedName("id")
val id: Int, val id: Int,
@@ -39,12 +67,26 @@ data class Room(
val creator: Creator, val creator: Creator,
@SerializedName("userCount") @SerializedName("userCount")
val userCount: Int, val userCount: Int,
@SerializedName("totalMemberCount")
val totalMemberCount: Int? = null,
@SerializedName("maxMemberLimit") @SerializedName("maxMemberLimit")
val maxMemberLimit: Int, val maxMemberLimit: Int,
@SerializedName("maxTotal")
val maxTotal: Int? = null,
@SerializedName("systemMaxTotal")
val systemMaxTotal: Int? = null,
@SerializedName("canJoin") @SerializedName("canJoin")
val canJoin: Boolean, val canJoin: Boolean,
@SerializedName("canJoinCode") @SerializedName("canJoinCode")
val canJoinCode: Int, val canJoinCode: Int,
@SerializedName("privateFeePaid")
val privateFeePaid: Boolean? = null,
@SerializedName("prompts")
val prompts: List<PromptTemplate>? = null,
@SerializedName("createdAt")
val createdAt: String? = null,
@SerializedName("updatedAt")
val updatedAt: String? = null,
@SerializedName("users") @SerializedName("users")
val users: List<Users> val users: List<Users>
@@ -63,9 +105,24 @@ data class Room(
allowInHot = allowInHot, allowInHot = allowInHot,
creator = creator.toCreatorEntity(), creator = creator.toCreatorEntity(),
userCount = userCount, userCount = userCount,
totalMemberCount = totalMemberCount,
maxMemberLimit = maxMemberLimit, maxMemberLimit = maxMemberLimit,
maxTotal = maxTotal,
systemMaxTotal = systemMaxTotal,
canJoin = canJoin, canJoin = canJoin,
canJoinCode = canJoinCode, canJoinCode = canJoinCode,
privateFeePaid = privateFeePaid ?: false,
prompts = prompts?.map {
com.aiosman.ravenow.entity.PromptTemplateEntity(
id = it.id,
openId = it.openId,
title = it.title,
desc = it.desc,
avatar = it.avatar
)
} ?: emptyList(),
createdAt = createdAt,
updatedAt = updatedAt,
users = users.map { it.toUsersEntity() } users = users.map { it.toUsersEntity() }
) )
} }
@@ -78,7 +135,7 @@ data class Creator(
@SerializedName("userId") @SerializedName("userId")
val userId: String, val userId: String,
@SerializedName("trtcUserId") @SerializedName("trtcUserId")
val trtcUserId: String, val trtcUserId: String? = null,
@SerializedName("profile") @SerializedName("profile")
val profile: Profile val profile: Profile
){ ){
@@ -86,7 +143,7 @@ data class Creator(
return CreatorEntity( return CreatorEntity(
id = id, id = id,
userId = userId, userId = userId,
trtcUserId = trtcUserId, trtcUserId = trtcUserId ?: "",
profile = profile.toProfileEntity() profile = profile.toProfileEntity()
) )
} }
@@ -98,7 +155,7 @@ data class Users(
@SerializedName("userId") @SerializedName("userId")
val userId: String, val userId: String,
@SerializedName("trtcUserId") @SerializedName("trtcUserId")
val trtcUserId: String, val trtcUserId: String? = null,
@SerializedName("profile") @SerializedName("profile")
val profile: Profile val profile: Profile
){ ){
@@ -173,6 +230,68 @@ interface RoomService {
roomId: Int? = null, roomId: Int? = null,
trtcId: String? = null trtcId: String? = null
): RoomRuleQuotaEntity ): RoomRuleQuotaEntity
// ========== Room Member Management ==========
/**
* 添加用户到房间
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param openIds 要添加的用户OpenID列表
* @return 添加结果实体
* @throws ServiceException 添加失败时抛出异常
*/
suspend fun addUserToRoom(
roomId: Int? = null,
trtcId: String? = null,
openIds: List<String>
): AddUserToRoomResultEntity
/**
* 添加智能体到房间
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param agentOpenIds 要添加的智能体OpenID列表
* @return 添加结果实体
* @throws ServiceException 添加失败时抛出异常
*/
suspend fun addAgentToRoom(
roomId: Int? = null,
trtcId: String? = null,
agentOpenIds: List<String>
): AddAgentToRoomResultEntity
/**
* 从房间移除智能体
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param agentOpenIds 要移除的智能体OpenID列表
* @return 移除结果实体
* @throws ServiceException 移除失败时抛出异常
*/
suspend fun removeAgentFromRoom(
roomId: Int? = null,
trtcId: String? = null,
agentOpenIds: List<String>
): RemoveAgentFromRoomResultEntity
/**
* 从房间移除用户
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param userIds 要移除的用户ID列表OpenID
* @return 移除结果实体
* @throws ServiceException 移除失败时抛出异常
*/
suspend fun removeUserFromRoom(
roomId: Int? = null,
trtcId: String? = null,
userIds: List<String>
): RemoveUserFromRoomResultEntity
} }
/** /**
@@ -253,6 +372,78 @@ class RoomServiceImpl : RoomService {
return data.toRoomRuleQuotaEntity() return data.toRoomRuleQuotaEntity()
} }
override suspend fun addUserToRoom(
roomId: Int?,
trtcId: String?,
openIds: List<String>
): AddUserToRoomResultEntity {
val resp = ApiClient.api.addUserToRoom(
com.aiosman.ravenow.data.api.AddUserToRoomRequestBody(
roomId = roomId,
trtcId = trtcId,
openIds = openIds
)
)
val body = resp.body() ?: throw ServiceException("添加用户到房间失败")
val data = body.data ?: throw ServiceException("添加用户响应数据为空")
return data.result.toAddUserToRoomResultEntity()
}
override suspend fun addAgentToRoom(
roomId: Int?,
trtcId: String?,
agentOpenIds: List<String>
): AddAgentToRoomResultEntity {
val resp = ApiClient.api.addAgentToRoom(
com.aiosman.ravenow.data.api.AddAgentToRoomRequestBody(
roomId = roomId,
trtcId = trtcId,
agentOpenIds = agentOpenIds
)
)
val body = resp.body() ?: throw ServiceException("添加智能体到房间失败")
val data = body.data ?: throw ServiceException("添加智能体响应数据为空")
return data.result.toAddAgentToRoomResultEntity()
}
override suspend fun removeAgentFromRoom(
roomId: Int?,
trtcId: String?,
agentOpenIds: List<String>
): RemoveAgentFromRoomResultEntity {
val resp = ApiClient.api.removeAgentFromRoom(
com.aiosman.ravenow.data.api.RemoveAgentFromRoomRequestBody(
roomId = roomId,
trtcId = trtcId,
agentOpenIds = agentOpenIds
)
)
val body = resp.body() ?: throw ServiceException("从房间移除智能体失败")
val data = body.data ?: throw ServiceException("移除智能体响应数据为空")
return data.toRemoveAgentFromRoomResultEntity()
}
override suspend fun removeUserFromRoom(
roomId: Int?,
trtcId: String?,
userIds: List<String>
): RemoveUserFromRoomResultEntity {
val resp = ApiClient.api.removeUserFromRoom(
com.aiosman.ravenow.data.api.RemoveUserFromRoomRequestBody(
roomId = roomId,
trtcId = trtcId,
userIds = userIds
)
)
val body = resp.body() ?: throw ServiceException("从房间移除用户失败")
val data = body.data ?: throw ServiceException("移除用户响应数据为空")
return data.toRemoveUserFromRoomResultEntity()
}
} }
/** /**
@@ -277,7 +468,12 @@ fun RoomRuleCreator.toRoomRuleCreatorEntity(): RoomRuleCreatorEntity {
return RoomRuleCreatorEntity( return RoomRuleCreatorEntity(
id = id, id = id,
nickname = nickname, nickname = nickname,
avatar = avatar avatar = avatar,
avatarMedium = avatarMedium,
avatarLarge = avatarLarge,
avatarDirectUrl = avatarDirectUrl,
avatarMediumDirectUrl = avatarMediumDirectUrl,
avatarLargeDirectUrl = avatarLargeDirectUrl
) )
} }
@@ -295,6 +491,128 @@ fun RoomRuleQuota.toRoomRuleQuotaEntity(): RoomRuleQuotaEntity {
) )
} }
// ========== Room Member Management 扩展函数 ==========
/**
* AddUserToRoomResult 扩展函数,转换为 AddUserToRoomResultEntity
*/
fun com.aiosman.ravenow.data.api.AddUserToRoomResult.toAddUserToRoomResultEntity(): AddUserToRoomResultEntity {
return AddUserToRoomResultEntity(
totalCount = totalCount,
successCount = successCount,
failedCount = failedCount,
skippedCount = skippedCount,
successItems = successItems.map { it.toAddUserToRoomItemEntity() },
failedItems = failedItems.map { it.toAddUserToRoomFailedItemEntity() },
skippedItems = skippedItems.map { it.toAddUserToRoomItemEntity() }
)
}
fun com.aiosman.ravenow.data.api.AddUserToRoomItem.toAddUserToRoomItemEntity(): AddUserToRoomItemEntity {
return AddUserToRoomItemEntity(
userId = userId,
type = type
)
}
fun com.aiosman.ravenow.data.api.AddUserToRoomFailedItem.toAddUserToRoomFailedItemEntity(): AddUserToRoomFailedItemEntity {
return AddUserToRoomFailedItemEntity(
userId = userId,
type = type,
error = error
)
}
/**
* AddAgentToRoomResult 扩展函数,转换为 AddAgentToRoomResultEntity
*/
fun com.aiosman.ravenow.data.api.AddAgentToRoomResult.toAddAgentToRoomResultEntity(): AddAgentToRoomResultEntity {
return AddAgentToRoomResultEntity(
totalCount = totalCount,
successCount = successCount,
failedCount = failedCount,
skippedCount = skippedCount,
successItems = successItems.map { it.toAddAgentToRoomItemEntity() },
failedItems = failedItems.map { it.toAddAgentToRoomFailedItemEntity() },
skippedItems = skippedItems.map { it.toAddAgentToRoomItemEntity() }
)
}
fun com.aiosman.ravenow.data.api.AddAgentToRoomItem.toAddAgentToRoomItemEntity(): AddAgentToRoomItemEntity {
return AddAgentToRoomItemEntity(
agentOpenId = agentOpenId,
type = type
)
}
fun com.aiosman.ravenow.data.api.AddAgentToRoomFailedItem.toAddAgentToRoomFailedItemEntity(): AddAgentToRoomFailedItemEntity {
return AddAgentToRoomFailedItemEntity(
agentOpenId = agentOpenId,
type = type,
error = error
)
}
/**
* RemoveAgentFromRoomResult 扩展函数,转换为 RemoveAgentFromRoomResultEntity
*/
fun com.aiosman.ravenow.data.api.RemoveAgentFromRoomResult.toRemoveAgentFromRoomResultEntity(): RemoveAgentFromRoomResultEntity {
return RemoveAgentFromRoomResultEntity(
totalCount = totalCount,
successCount = successCount,
failedCount = failedCount,
skippedCount = skippedCount,
successItems = successItems.map { it.toRemoveAgentFromRoomItemEntity() },
failedItems = failedItems.map { it.toRemoveAgentFromRoomFailedItemEntity() },
skippedItems = skippedItems.map { it.toRemoveAgentFromRoomItemEntity() }
)
}
fun com.aiosman.ravenow.data.api.RemoveAgentFromRoomItem.toRemoveAgentFromRoomItemEntity(): RemoveAgentFromRoomItemEntity {
return RemoveAgentFromRoomItemEntity(
agentOpenId = agentOpenId,
type = type
)
}
fun com.aiosman.ravenow.data.api.RemoveAgentFromRoomFailedItem.toRemoveAgentFromRoomFailedItemEntity(): RemoveAgentFromRoomFailedItemEntity {
return RemoveAgentFromRoomFailedItemEntity(
agentOpenId = agentOpenId,
type = type,
error = error
)
}
/**
* RemoveUserFromRoomResult 扩展函数,转换为 RemoveUserFromRoomResultEntity
*/
fun com.aiosman.ravenow.data.api.RemoveUserFromRoomResult.toRemoveUserFromRoomResultEntity(): RemoveUserFromRoomResultEntity {
return RemoveUserFromRoomResultEntity(
totalCount = totalCount,
successCount = successCount,
failedCount = failedCount,
skippedCount = skippedCount,
successItems = successItems.map { it.toRemoveUserFromRoomItemEntity() },
failedItems = failedItems.map { it.toRemoveUserFromRoomFailedItemEntity() },
skippedItems = skippedItems.map { it.toRemoveUserFromRoomItemEntity() }
)
}
fun com.aiosman.ravenow.data.api.RemoveUserFromRoomItem.toRemoveUserFromRoomItemEntity(): RemoveUserFromRoomItemEntity {
return RemoveUserFromRoomItemEntity(
userId = userId,
type = type
)
}
fun com.aiosman.ravenow.data.api.RemoveUserFromRoomFailedItem.toRemoveUserFromRoomFailedItemEntity(): RemoveUserFromRoomFailedItemEntity {
return RemoveUserFromRoomFailedItemEntity(
userId = userId,
type = type,
error = error
)
}

View File

@@ -1,6 +1,7 @@
package com.aiosman.ravenow.data package com.aiosman.ravenow.data
import com.aiosman.ravenow.data.api.ApiClient import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.BatchTrtcUserIdRequestBody
import com.aiosman.ravenow.entity.AccountProfileEntity import com.aiosman.ravenow.entity.AccountProfileEntity
data class UserAuth( data class UserAuth(
@@ -46,7 +47,8 @@ interface UserService {
page: Int = 1, page: Int = 1,
nickname: String? = null, nickname: String? = null,
followerId: Int? = null, followerId: Int? = null,
followingId: Int? = null followingId: Int? = null,
roomId: Int? = null
): ListContainer<AccountProfileEntity> ): ListContainer<AccountProfileEntity>
@@ -66,6 +68,16 @@ interface UserService {
suspend fun getUserProfileByOpenId(id: String):AccountProfileEntity suspend fun getUserProfileByOpenId(id: String):AccountProfileEntity
/**
* 批量通过 TRTC 用户ID 获取用户信息列表
* @param ids TRTC 用户ID列表最多100个
* @param includeAI 是否包含AI账号默认 false
* @return 用户信息实体列表
*/
suspend fun getUserProfilesByTrtcUserIds(
ids: List<String>,
includeAI: Boolean = false
): List<AccountProfileEntity>
} }
class UserServiceImpl : UserService { class UserServiceImpl : UserService {
@@ -90,7 +102,8 @@ class UserServiceImpl : UserService {
page: Int, page: Int,
nickname: String?, nickname: String?,
followerId: Int?, followerId: Int?,
followingId: Int? followingId: Int?,
roomId: Int?
): ListContainer<AccountProfileEntity> { ): ListContainer<AccountProfileEntity> {
val resp = ApiClient.api.getUsers( val resp = ApiClient.api.getUsers(
page = page, page = page,
@@ -98,7 +111,7 @@ class UserServiceImpl : UserService {
search = nickname, search = nickname,
followerId = followerId, followerId = followerId,
followingId = followingId, followingId = followingId,
includeAI = true includeAI = true,
) )
val body = resp.body() ?: throw ServiceException("Failed to get account") val body = resp.body() ?: throw ServiceException("Failed to get account")
return ListContainer<AccountProfileEntity>( return ListContainer<AccountProfileEntity>(
@@ -120,4 +133,18 @@ class UserServiceImpl : UserService {
val body = resp.body() ?: throw ServiceException("Failed to get account") val body = resp.body() ?: throw ServiceException("Failed to get account")
return body.data.toAccountProfileEntity() return body.data.toAccountProfileEntity()
} }
override suspend fun getUserProfilesByTrtcUserIds(
ids: List<String>,
includeAI: Boolean
): List<AccountProfileEntity> {
val resp = ApiClient.api.getAccountProfilesByTrtcBatch(
BatchTrtcUserIdRequestBody(
trtcUserIds = ids,
includeAI = includeAI
)
)
val body = resp.body() ?: throw ServiceException("Failed to get accounts")
return body.data.map { it.toAccountProfileEntity() }
}
} }

View File

@@ -10,6 +10,7 @@ import com.aiosman.ravenow.data.Comment
import com.aiosman.ravenow.data.DataContainer import com.aiosman.ravenow.data.DataContainer
import com.aiosman.ravenow.data.ListContainer import com.aiosman.ravenow.data.ListContainer
import com.aiosman.ravenow.data.Moment import com.aiosman.ravenow.data.Moment
import com.aiosman.ravenow.data.RecommendationsResponse
import com.aiosman.ravenow.data.Room import com.aiosman.ravenow.data.Room
import com.aiosman.ravenow.entity.ChatNotification import com.aiosman.ravenow.entity.ChatNotification
import com.aiosman.ravenow.data.membership.MembershipConfigData import com.aiosman.ravenow.data.membership.MembershipConfigData
@@ -86,6 +87,13 @@ data class JoinGroupChatRequestBody(
val roomId: Int? = null, val roomId: Int? = null,
) )
data class BatchTrtcUserIdRequestBody(
@SerializedName("trtcUserIds")
val trtcUserIds: List<String>,
@SerializedName("includeAI")
val includeAI: Boolean? = null,
)
data class LoginUserRequestBody( data class LoginUserRequestBody(
@SerializedName("username") @SerializedName("username")
val username: String? = null, val username: String? = null,
@@ -273,6 +281,260 @@ data class RemoveAccountRequestBody(
val password: String, val password: String,
) )
// ========== Room Member Management 相关数据类 ==========
/**
* 添加用户到房间请求体
* @param roomId 房间ID与trtcId互斥二者必须提供其一
* @param trtcId TRTC群组ID与roomId互斥二者必须提供其一
* @param openIds 要添加的用户OpenID列表
*/
data class AddUserToRoomRequestBody(
@SerializedName("roomId")
val roomId: Int? = null,
@SerializedName("trtcId")
val trtcId: String? = null,
@SerializedName("openIds")
val openIds: List<String>
)
/**
* 添加智能体到房间请求体
* @param roomId 房间ID与trtcId互斥二者必须提供其一
* @param trtcId TRTC群组ID与roomId互斥二者必须提供其一
* @param agentOpenIds 要添加的智能体OpenID列表
*/
data class AddAgentToRoomRequestBody(
@SerializedName("roomId")
val roomId: Int? = null,
@SerializedName("trtcId")
val trtcId: String? = null,
@SerializedName("agentOpenIds")
val agentOpenIds: List<String>
)
/**
* 从房间移除智能体请求体
* @param roomId 房间ID与trtcId互斥二者必须提供其一
* @param trtcId TRTC群组ID与roomId互斥二者必须提供其一
* @param agentOpenIds 要移除的智能体OpenID列表
*/
data class RemoveAgentFromRoomRequestBody(
@SerializedName("roomId")
val roomId: Int? = null,
@SerializedName("trtcId")
val trtcId: String? = null,
@SerializedName("agentOpenIds")
val agentOpenIds: List<String>
)
/**
* 从房间移除用户请求体
* @param roomId 房间ID与trtcId互斥二者必须提供其一
* @param trtcId TRTC群组ID与roomId互斥二者必须提供其一
* @param userIds 要移除的用户ID列表OpenID
*/
data class RemoveUserFromRoomRequestBody(
@SerializedName("roomId")
val roomId: Int? = null,
@SerializedName("trtcId")
val trtcId: String? = null,
@SerializedName("userIds")
val userIds: List<String>
)
/**
* 添加用户成功项目
*/
data class AddUserToRoomItem(
@SerializedName("userId")
val userId: String,
@SerializedName("type")
val type: String
)
/**
* 添加用户失败项目
*/
data class AddUserToRoomFailedItem(
@SerializedName("userId")
val userId: String,
@SerializedName("type")
val type: String,
@SerializedName("error")
val error: String
)
/**
* 添加用户到房间的结果
*/
data class AddUserToRoomResult(
@SerializedName("totalCount")
val totalCount: Int,
@SerializedName("successCount")
val successCount: Int,
@SerializedName("failedCount")
val failedCount: Int,
@SerializedName("skippedCount")
val skippedCount: Int,
@SerializedName("successItems")
val successItems: List<AddUserToRoomItem>,
@SerializedName("failedItems")
val failedItems: List<AddUserToRoomFailedItem>,
@SerializedName("skippedItems")
val skippedItems: List<AddUserToRoomItem>
)
/**
* 添加用户到房间响应
*/
data class AddUserToRoomResponse(
@SerializedName("message")
val message: String,
@SerializedName("operationType")
val operationType: String,
@SerializedName("result")
val result: AddUserToRoomResult
)
/**
* 添加智能体成功项目
*/
data class AddAgentToRoomItem(
@SerializedName("agentOpenId")
val agentOpenId: String,
@SerializedName("type")
val type: String
)
/**
* 添加智能体失败项目
*/
data class AddAgentToRoomFailedItem(
@SerializedName("agentOpenId")
val agentOpenId: String,
@SerializedName("type")
val type: String,
@SerializedName("error")
val error: String
)
/**
* 添加智能体到房间的结果
*/
data class AddAgentToRoomResult(
@SerializedName("totalCount")
val totalCount: Int,
@SerializedName("successCount")
val successCount: Int,
@SerializedName("failedCount")
val failedCount: Int,
@SerializedName("skippedCount")
val skippedCount: Int,
@SerializedName("successItems")
val successItems: List<AddAgentToRoomItem>,
@SerializedName("failedItems")
val failedItems: List<AddAgentToRoomFailedItem>,
@SerializedName("skippedItems")
val skippedItems: List<AddAgentToRoomItem>
)
/**
* 添加智能体到房间响应
*/
data class AddAgentToRoomResponse(
@SerializedName("message")
val message: String,
@SerializedName("operationType")
val operationType: String,
@SerializedName("result")
val result: AddAgentToRoomResult
)
/**
* 移除智能体成功项目
*/
data class RemoveAgentFromRoomItem(
@SerializedName("agentOpenId")
val agentOpenId: String,
@SerializedName("type")
val type: String
)
/**
* 移除智能体失败项目
*/
data class RemoveAgentFromRoomFailedItem(
@SerializedName("agentOpenId")
val agentOpenId: String,
@SerializedName("type")
val type: String,
@SerializedName("error")
val error: String
)
/**
* 从房间移除智能体的结果
*/
data class RemoveAgentFromRoomResult(
@SerializedName("totalCount")
val totalCount: Int,
@SerializedName("successCount")
val successCount: Int,
@SerializedName("failedCount")
val failedCount: Int,
@SerializedName("skippedCount")
val skippedCount: Int,
@SerializedName("successItems")
val successItems: List<RemoveAgentFromRoomItem>,
@SerializedName("failedItems")
val failedItems: List<RemoveAgentFromRoomFailedItem>,
@SerializedName("skippedItems")
val skippedItems: List<RemoveAgentFromRoomItem>
)
/**
* 移除用户成功项目
*/
data class RemoveUserFromRoomItem(
@SerializedName("userId")
val userId: String,
@SerializedName("type")
val type: String
)
/**
* 移除用户失败项目
*/
data class RemoveUserFromRoomFailedItem(
@SerializedName("userId")
val userId: String,
@SerializedName("type")
val type: String,
@SerializedName("error")
val error: String
)
/**
* 从房间移除用户的结果
*/
data class RemoveUserFromRoomResult(
@SerializedName("totalCount")
val totalCount: Int,
@SerializedName("successCount")
val successCount: Int,
@SerializedName("failedCount")
val failedCount: Int,
@SerializedName("skippedCount")
val skippedCount: Int,
@SerializedName("successItems")
val successItems: List<RemoveUserFromRoomItem>,
@SerializedName("failedItems")
val failedItems: List<RemoveUserFromRoomFailedItem>,
@SerializedName("skippedItems")
val skippedItems: List<RemoveUserFromRoomItem>
)
// API 错误响应(用于加入房间等接口的错误处理) // API 错误响应(用于加入房间等接口的错误处理)
data class ApiErrorResponse( data class ApiErrorResponse(
@SerializedName("err") @SerializedName("err")
@@ -651,6 +913,11 @@ data class UpdateRoomRuleRequestBody(
* @param id 创建者ID * @param id 创建者ID
* @param nickname 创建者昵称 * @param nickname 创建者昵称
* @param avatar 创建者头像文件名 * @param avatar 创建者头像文件名
* @param avatarMedium 中等头像文件名
* @param avatarLarge 大头像文件名
* @param avatarDirectUrl 小头像直接访问URL
* @param avatarMediumDirectUrl 中等头像直接访问URL
* @param avatarLargeDirectUrl 大头像直接访问URL
*/ */
data class RoomRuleCreator( data class RoomRuleCreator(
@SerializedName("id") @SerializedName("id")
@@ -658,7 +925,17 @@ data class RoomRuleCreator(
@SerializedName("nickname") @SerializedName("nickname")
val nickname: String, val nickname: String,
@SerializedName("avatar") @SerializedName("avatar")
val avatar: String val avatar: String,
@SerializedName("avatarMedium")
val avatarMedium: String? = null,
@SerializedName("avatarLarge")
val avatarLarge: String? = null,
@SerializedName("avatarDirectUrl")
val avatarDirectUrl: String? = null,
@SerializedName("avatarMediumDirectUrl")
val avatarMediumDirectUrl: String? = null,
@SerializedName("avatarLargeDirectUrl")
val avatarLargeDirectUrl: String? = null
) )
/** /**
@@ -761,6 +1038,71 @@ data class InsufficientBalanceError(
val traceId: String? val traceId: String?
) )
// ========== Points 相关数据类 ==========
/**
* 积分余额信息
* @param balance 当前积分余额
* @param totalEarned 累计获得积分(可选)
* @param totalSpent 累计消费积分(可选)
*/
data class PointsBalance(
@SerializedName("balance")
val balance: Int,
@SerializedName("totalEarned")
val totalEarned: Int? = null,
@SerializedName("totalSpent")
val totalSpent: Int? = null
)
/**
* 积分变更日志
* @param id 日志记录ID
* @param changeType 变更类型add: 增加, subtract: 减少, adjust: 调整)
* @param before 变更前余额
* @param after 变更后余额
* @param amount 本次变更数量(增加为正数,减少为负数)
* @param reason 变更原因代码
* @param createdAt 创建时间格式YYYY-MM-DD HH:mm:ss
*/
data class PointsChangeLog(
@SerializedName("id")
val id: Int,
@SerializedName("changeType")
val changeType: String,
@SerializedName("before")
val before: Int,
@SerializedName("after")
val after: Int,
@SerializedName("amount")
val amount: Int,
@SerializedName("reason")
val reason: String,
@SerializedName("createdAt")
val createdAt: String
)
/**
* 积分变更日志列表响应
* @param success 请求是否成功
* @param list 积分变更日志列表
* @param total 总记录数
* @param page 当前页码
* @param pageSize 每页数量
*/
data class PointsChangeLogsResponse(
@SerializedName("success")
val success: Boolean,
@SerializedName("list")
val list: List<PointsChangeLog>,
@SerializedName("total")
val total: Int,
@SerializedName("page")
val page: Int,
@SerializedName("pageSize")
val pageSize: Int
)
interface RaveNowAPI { interface RaveNowAPI {
@GET("membership/config") @GET("membership/config")
@retrofit2.http.Headers("X-Requires-Auth: true") @retrofit2.http.Headers("X-Requires-Auth: true")
@@ -800,6 +1142,7 @@ interface RaveNowAPI {
@Query("favouriteUserId") favouriteUserId: Int? = null, @Query("favouriteUserId") favouriteUserId: Int? = null,
@Query("explore") explore: String? = null, @Query("explore") explore: String? = null,
@Query("newsFilter") newsFilter: String? = null, @Query("newsFilter") newsFilter: String? = null,
@Query("videoFilter") videoFilter: String? = null,
): Response<ListContainer<Moment>> ): Response<ListContainer<Moment>>
@Multipart @Multipart
@@ -937,6 +1280,11 @@ interface RaveNowAPI {
@Path("id") id: String @Path("id") id: String
): Response<DataContainer<AccountProfile>> ): Response<DataContainer<AccountProfile>>
@POST("profile/trtc/batch")
suspend fun getAccountProfilesByTrtcBatch(
@Body body: BatchTrtcUserIdRequestBody
): Response<DataContainer<List<AccountProfile>>>
@POST("user/{id}/follow") @POST("user/{id}/follow")
suspend fun followUser( suspend fun followUser(
@Path("id") id: Int @Path("id") id: Int
@@ -1002,6 +1350,16 @@ interface RaveNowAPI {
@Query("keys") keys: String @Query("keys") keys: String
): Response<ListContainer<DictItem>> ): Response<ListContainer<DictItem>>
@GET("/outside/dict")
suspend fun getOutsideDict(
@Query("key") key: String
): Response<DataContainer<DictItem>>
@GET("/outside/dicts")
suspend fun getOutsideDicts(
@Query("keys") keys: String
): Response<ListContainer<DictItem>>
@POST("captcha/generate") @POST("captcha/generate")
suspend fun generateCaptcha( suspend fun generateCaptcha(
@Body body: CaptchaRequestBody @Body body: CaptchaRequestBody
@@ -1045,6 +1403,9 @@ interface RaveNowAPI {
@Query("authorId") authorId: Int? = null, @Query("authorId") authorId: Int? = null,
@Query("categoryIds") categoryIds: List<Int>? = null, @Query("categoryIds") categoryIds: List<Int>? = null,
@Query("random") random: Int? = null, @Query("random") random: Int? = null,
@Query("title") title: String? = null,
@Query("desc") desc: String? = null,
@Query("excludeRoomId") excludeRoomId: Int? = null,
): Response<DataContainer<ListContainer<Agent>>> ): Response<DataContainer<ListContainer<Agent>>>
@GET("outside/my/prompts") @GET("outside/my/prompts")
@@ -1087,11 +1448,39 @@ interface RaveNowAPI {
@POST("outside/rooms") @POST("outside/rooms")
suspend fun createGroupChat(@Body body: CreateGroupChatRequestBody): Response<DataContainer<Unit>> suspend fun createGroupChat(@Body body: CreateGroupChatRequestBody): Response<DataContainer<Unit>>
/**
* 获取房间列表
*
* 支持游客和认证用户访问,根据用户类型返回不同的房间数据。
* 游客模式返回公开推荐房间列表,认证用户模式返回用户可访问的群聊列表。
*
* @param page 页码,默认 1
* @param pageSize 每页数量,默认 20游客模式最大50
* @param roomId 房间ID用于精确查询特定房间仅认证用户
* @param includeUsers 是否包含用户列表默认false仅认证用户
* @param isRecommended 是否推荐过滤器1=推荐0=非推荐null=不过滤(仅认证用户)
* @param roomType 房间类型过滤all=公有私有都显示, public=只显示公有, private=只显示私有, created=只显示自己创建的, joined=只显示自己加入的(仅认证用户)
* @param search 搜索关键字,支持房间名称、描述、智能体名称模糊匹配
* @param random 是否随机排序字符串长度不为0则为true传任意非空字符串即可
* @param ownerSessionId 创建者用户IDChatAIID用于过滤特定创建者的房间
* @param showPublic 是否显示公有房间只有明确设置为true时才生效优先级高于roomType仅认证用户
* @param showCreated 是否显示自己创建的房间只有明确设置为true时才生效优先级高于roomType仅认证用户
* @param showJoined 是否显示自己加入的房间只有明确设置为true时才生效优先级高于roomType仅认证用户
*/
@GET("outside/rooms") @GET("outside/rooms")
suspend fun getRooms(@Query("page") page: Int = 1, suspend fun getRooms(
@Query("pageSize") pageSize: Int = 20, @Query("page") page: Int = 1,
@Query("isRecommended") isRecommended: Int = 1, @Query("pageSize") pageSize: Int = 20,
@Query("random") random: Int? = null, @Query("roomId") roomId: Long? = null,
@Query("includeUsers") includeUsers: Boolean? = null,
@Query("isRecommended") isRecommended: Int? = null,
@Query("roomType") roomType: String? = null,
@Query("search") search: String? = null,
@Query("random") random: String? = null,
@Query("ownerSessionId") ownerSessionId: String? = null,
@Query("showPublic") showPublic: Boolean? = null,
@Query("showCreated") showCreated: Boolean? = null,
@Query("showJoined") showJoined: Boolean? = null,
): Response<ListContainer<Room>> ): Response<ListContainer<Room>>
@GET("outside/rooms/detail") @GET("outside/rooms/detail")
@@ -1136,6 +1525,35 @@ interface RaveNowAPI {
@Query("pageSize") pageSize: Int? = null @Query("pageSize") pageSize: Int? = null
): Response<ListContainer<Agent>> ): Response<ListContainer<Agent>>
/**
* 获取Prompt详情支持ID或OpenId
* @param promptId Prompt ID或OpenIdUUID格式
*/
@GET("outside/prompt/{promptId}")
suspend fun getPromptDetail(
@Path("promptId") promptId: String
): Response<DataContainer<Agent>>
/**
* 更新Prompt支持ID或OpenId
* @param promptId Prompt ID或OpenIdUUID格式
* @param avatar 头像文件(可选)
* @param title 标题(可选)
* @param desc 描述(可选)
* @param value 内容(可选)
* @param isPublic 是否公开(可选)
*/
@Multipart
@PATCH("outside/prompt/{promptId}")
suspend fun updatePrompt(
@Path("promptId") promptId: String,
@Part avatar: MultipartBody.Part?,
@Part("title") title: RequestBody?,
@Part("desc") desc: RequestBody?,
@Part("value") value: RequestBody?,
@Part("public") isPublic: RequestBody?,
): Response<DataContainer<Agent>>
// ========== Agent Rule API ========== // ========== Agent Rule API ==========
/** /**
@@ -1592,5 +2010,217 @@ interface RaveNowAPI {
@GET("recommendations")
suspend fun getRecommendations(
@Query("pool") pool: String? = null,
@Query("count") count: Int? = null,
@Query("lang") lang: String? = null,
@Query("promptReplaceTrans") promptReplaceTrans: Boolean? = null
): Response<RecommendationsResponse>
// ========== Points API ==========
/**
* 获取我的积分余额
*
* 功能说明:
* - 获取当前登录用户的积分余额
* - 可选返回累计获得和累计消费统计信息
*
* @param includeStatistics 是否包含统计信息(累计获得和累计消费),默认 true
*
* @return 返回积分余额和统计信息
*
* 响应数据说明:
* - balance: 当前积分余额
* - totalEarned: 累计获得积分(仅当 includeStatistics 为 true 时返回)
* - totalSpent: 累计消费积分(仅当 includeStatistics 为 true 时返回)
*
* 示例:
* ```kotlin
* // 获取包含统计信息的积分余额
* val response1 = api.getMyPointsBalance()
*
* // 仅获取当前余额
* val response2 = api.getMyPointsBalance(includeStatistics = false)
* ```
*/
@GET("account/my/points")
suspend fun getMyPointsBalance(
@Query("includeStatistics") includeStatistics: Boolean? = null
): Response<DataContainer<PointsBalance>>
/**
* 获取我的积分变更日志
*
* 功能说明:
* - 获取当前登录用户的积分变更日志列表
* - 支持分页、时间范围筛选和变更类型筛选
*
* @param page 页码,默认 1
* @param pageSize 每页数量,默认 20
* @param changeType 变更类型筛选add: 增加, subtract: 减少, adjust: 调整)
* @param startTime 开始时间格式YYYY-MM-DD
* @param endTime 结束时间格式YYYY-MM-DD
*
* @return 返回分页的积分变更日志列表
*
* 响应数据说明:
* - list: 积分变更日志列表
* - total: 总记录数
* - page: 当前页码
* - pageSize: 每页数量
*
* 示例:
* ```kotlin
* // 获取最近的积分变更日志
* val response1 = api.getMyPointsChangeLogs(page = 1, pageSize = 20)
*
* // 筛选积分增加记录
* val response2 = api.getMyPointsChangeLogs(changeType = "add")
*
* // 查询指定时间范围的记录
* val response3 = api.getMyPointsChangeLogs(
* startTime = "2024-01-01",
* endTime = "2024-01-31"
* )
* ```
*/
@GET("account/my/points/logs")
suspend fun getMyPointsChangeLogs(
@Query("page") page: Int = 1,
@Query("pageSize") pageSize: Int = 20,
@Query("changeType") changeType: String? = null,
@Query("startTime") startTime: String? = null,
@Query("endTime") endTime: String? = null
): Response<PointsChangeLogsResponse>
// ========== Room Member Management API ==========
/**
* 添加用户到房间
*
* 功能说明:
* - 向房间批量添加用户
* - 支持通过房间ID或TRTC群组ID添加
* - 只有房间创建者可以使用此接口
* - 添加数量受房间容量限制
* - 成功添加后会自动扣除房间创建者的积分用于扩容
*
* @param body 添加用户请求体
* - roomId: 房间ID与 trtcId 二选一)
* - trtcId: TRTC群组ID与 roomId 二选一)
* - openIds: 要添加的用户OpenID列表
*
* @return 成功时返回操作结果,包含成功、失败、跳过的用户列表
*
* 示例:
* ```kotlin
* val request = AddUserToRoomRequestBody(
* roomId = 123,
* openIds = listOf("user_openid_1", "user_openid_2")
* )
* val response = api.addUserToRoom(request)
* ```
*/
@POST("outside/rooms/add-user")
suspend fun addUserToRoom(
@Body body: AddUserToRoomRequestBody
): Response<DataContainer<AddUserToRoomResponse>>
/**
* 添加智能体到房间
*
* 功能说明:
* - 向房间批量添加智能体Agent
* - 支持通过房间ID或TRTC群组ID添加
* - 房间创建者可以使用此接口
* - 当容量不足时会自动扩容并扣除房间创建者的积分
* - 如果积分不足以支付扩容费用,将返回错误
*
* @param body 添加智能体请求体
* - roomId: 房间ID与 trtcId 二选一)
* - trtcId: TRTC群组ID与 roomId 二选一)
* - agentOpenIds: 要添加的智能体OpenID列表
*
* @return 成功时返回操作结果,包含成功、失败、跳过的智能体列表
*
* 示例:
* ```kotlin
* val request = AddAgentToRoomRequestBody(
* roomId = 123,
* agentOpenIds = listOf("agent_openid_1", "agent_openid_2")
* )
* val response = api.addAgentToRoom(request)
* ```
*/
@POST("outside/rooms/add-agent")
suspend fun addAgentToRoom(
@Body body: AddAgentToRoomRequestBody
): Response<DataContainer<AddAgentToRoomResponse>>
/**
* 从房间移除智能体
*
* 功能说明:
* - 从房间批量移除智能体Agent
* - 支持通过房间ID或TRTC群组ID操作
* - 只有房间创建者可以使用此接口
* - 单聊房间不支持移除智能体
* - 移除操作会同步到OpenIM系统
*
* @param body 移除智能体请求体
* - roomId: 房间ID与 trtcId 二选一)
* - trtcId: TRTC群组ID与 roomId 二选一)
* - agentOpenIds: 要移除的智能体OpenID列表
*
* @return 成功时返回操作结果,包含成功、失败、跳过的智能体列表
*
* 示例:
* ```kotlin
* val request = RemoveAgentFromRoomRequestBody(
* roomId = 123,
* agentOpenIds = listOf("agent_openid_1", "agent_openid_2")
* )
* val response = api.removeAgentFromRoom(request)
* ```
*/
@POST("outside/rooms/remove-agent")
suspend fun removeAgentFromRoom(
@Body body: RemoveAgentFromRoomRequestBody
): Response<DataContainer<RemoveAgentFromRoomResult>>
/**
* 从房间移除用户
*
* 功能说明:
* - 从房间批量移除用户
* - 支持通过房间ID或TRTC群组ID操作
* - 只有房间创建者可以使用此接口
* - 单聊房间不支持移除用户
* - 群主不能移除自己
* - 移除操作会同步到OpenIM系统
*
* @param body 移除用户请求体
* - roomId: 房间ID与 trtcId 二选一)
* - trtcId: TRTC群组ID与 roomId 二选一)
* - userIds: 要移除的用户ID列表OpenID
*
* @return 成功时返回操作结果,包含成功、失败、跳过的用户列表
*
* 示例:
* ```kotlin
* val request = RemoveUserFromRoomRequestBody(
* roomId = 123,
* userIds = listOf("user_openid_1", "user_openid_2")
* )
* val response = api.removeUserFromRoom(request)
* ```
*/
@POST("outside/rooms/remove-user")
suspend fun removeUserFromRoom(
@Body body: RemoveUserFromRoomRequestBody
): Response<DataContainer<RemoveUserFromRoomResult>>
} }

View File

@@ -0,0 +1,58 @@
package com.aiosman.ravenow.data.db
import android.content.Context
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.Upsert
@Entity(tableName = "trtc_participant_cache")
data class TrtcParticipantCache(
@PrimaryKey val trtcId: String,
val isAI: Boolean,
val updatedAt: Long
)
@Dao
interface TrtcParticipantCacheDao {
@Query("SELECT * FROM trtc_participant_cache WHERE trtcId = :trtcId LIMIT 1")
suspend fun get(trtcId: String): TrtcParticipantCache?
@Query("SELECT * FROM trtc_participant_cache WHERE trtcId IN (:ids)")
suspend fun getMany(ids: List<String>): List<TrtcParticipantCache>
@Upsert
suspend fun upsertAll(items: List<TrtcParticipantCache>)
}
@Database(
entities = [TrtcParticipantCache::class],
version = 1,
exportSchema = false
)
abstract class MessageCacheDatabase : RoomDatabase() {
abstract fun trtcParticipantCacheDao(): TrtcParticipantCacheDao
companion object {
@Volatile
private var INSTANCE: MessageCacheDatabase? = null
fun getInstance(context: Context): MessageCacheDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
MessageCacheDatabase::class.java,
"message_cache.db"
).fallbackToDestructiveMigration()
.build()
.also { INSTANCE = it }
}
}
}
}

View File

@@ -0,0 +1,133 @@
package com.aiosman.ravenow.data.repo
import android.content.Context
import android.util.Log
import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.data.db.MessageCacheDatabase
import com.aiosman.ravenow.data.db.TrtcParticipantCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
object TrtcUserTypeRepository {
private const val TAG = "TrtcUserTypeRepo"
private const val MAX_BATCH = 100
private const val TTL_MS: Long = 5L * 24 * 60 * 60 * 1000 // 5 天
private val memoryCache = ConcurrentHashMap<String, CacheEntry>()
private var initialized = false
private lateinit var userService: UserService
private lateinit var db: com.aiosman.ravenow.data.db.MessageCacheDatabase
data class CacheEntry(
val isAI: Boolean,
val updatedAt: Long
) {
fun isExpired(now: Long): Boolean = now - updatedAt > TTL_MS
}
private fun ensureInit(context: Context) {
if (!initialized) {
db = MessageCacheDatabase.getInstance(context)
userService = UserServiceImpl()
initialized = true
}
}
fun getCachedType(trtcId: String): Boolean? {
val entry = memoryCache[trtcId] ?: return null
return if (!entry.isExpired(System.currentTimeMillis())) entry.isAI else null
}
suspend fun getType(context: Context, trtcId: String): Boolean? {
ensureInit(context)
val now = System.currentTimeMillis()
val mem = memoryCache[trtcId]
if (mem != null && !mem.isExpired(now)) return mem.isAI
return withContext(Dispatchers.IO) {
val dao = db.trtcParticipantCacheDao()
val entity = dao.get(trtcId)
if (entity != null && now - entity.updatedAt <= TTL_MS) {
memoryCache[trtcId] = CacheEntry(entity.isAI, entity.updatedAt)
entity.isAI
} else {
null
}
}
}
suspend fun ensureTypes(context: Context, trtcIds: List<String>) {
ensureInit(context)
if (trtcIds.isEmpty()) return
val now = System.currentTimeMillis()
// 从内存/本地命中
val toFetch = withContext(Dispatchers.IO) {
val dao = db.trtcParticipantCacheDao()
val result = mutableSetOf<String>()
val needFromDb = mutableListOf<String>()
trtcIds.forEach { id ->
val mem = memoryCache[id]
if (mem == null || mem.isExpired(now)) {
needFromDb.add(id)
}
}
if (needFromDb.isNotEmpty()) {
val entities = dao.getMany(needFromDb)
val fromDbSet = entities.toMutableList()
// 写回内存并决定是否需要网络
val dbValid = mutableSetOf<String>()
fromDbSet.forEach { e ->
if (now - e.updatedAt <= TTL_MS) {
memoryCache[e.trtcId] = CacheEntry(e.isAI, e.updatedAt)
dbValid.add(e.trtcId)
}
}
needFromDb.forEach { id ->
if (!dbValid.contains(id)) {
result.add(id)
}
}
}
result.toList()
}
if (toFetch.isEmpty()) return
// 批量分片请求
val chunks = toFetch.chunked(MAX_BATCH)
for (chunk in chunks) {
try {
val profiles = withContext(Dispatchers.IO) {
userService.getUserProfilesByTrtcUserIds(chunk, includeAI = true)
}
// 将返回的 profile 按 trtcUserId -> isAI 映射
val upserts = profiles.mapNotNull { profile ->
val id = profile.trtcUserId
if (id.isNullOrEmpty()) null
else TrtcParticipantCache(
trtcId = id,
isAI = profile.aiAccount,
updatedAt = System.currentTimeMillis()
)
}
// 落库 + 内存
withContext(Dispatchers.IO) {
db.trtcParticipantCacheDao().upsertAll(upserts)
}
upserts.forEach { e ->
memoryCache[e.trtcId] = CacheEntry(e.isAI, e.updatedAt)
}
Log.d(TAG, "Fetched types: size=${upserts.size}, batch=${chunk.size}")
} catch (e: Exception) {
Log.w(TAG, "ensureTypes fetch failed: ${e.message}")
}
}
}
}

View File

@@ -67,6 +67,14 @@ data class AccountProfileEntity(
val rawAvatar: String, val rawAvatar: String,
val chatAIId: String, val chatAIId: String,
// AI角色背景图
val aiRoleAvatar: String? = null,
val aiRoleAvatarMedium: String? = null,
val aiRoleAvatarLarge: String? = null,
// 创建者信息仅AI账号有
val creatorProfile: CreatorProfileEntity? = null,
) )
/** /**
@@ -110,6 +118,28 @@ data class NoticeUserEntity(
val avatar: String, val avatar: String,
) )
/**
* 创建者信息
*/
data class CreatorProfileEntity(
// 用户ID
val id: Long,
// 用户名
val username: String? = null,
// 昵称
val nickname: String,
// 头像
val avatar: String? = null,
// 个人简介
val bio: String? = null,
// trtcUserId
val trtcUserId: String? = null,
// chatAIId
val chatAIId: String? = null,
// 是否为AI账号
val aiAccount: Boolean = false,
)
/** /**
* 用户点赞消息分页数据加载器 * 用户点赞消息分页数据加载器
*/ */

View File

@@ -90,6 +90,35 @@ class AgentPagingSource(
} }
/**
* 智能体搜索分页加载器(按标题关键字)
*/
class AgentSearchPagingSource(
private val agentRemoteDataSource: AgentRemoteDataSource,
private val keyword: String,
) : PagingSource<Int, AgentEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, AgentEntity> {
return try {
val currentPage = params.key ?: 1
val agents = agentRemoteDataSource.searchAgentByTitle(
pageNumber = currentPage,
title = keyword
)
LoadResult.Page(
data = agents?.list ?: listOf(),
prevKey = if (currentPage == 1) null else currentPage - 1,
nextKey = if (agents?.list?.isNotEmpty() == true) currentPage + 1 else null
)
} catch (exception: IOException) {
LoadResult.Error(exception)
}
}
override fun getRefreshKey(state: PagingState<Int, AgentEntity>): Int? {
return state.anchorPosition
}
}
class AgentRemoteDataSource( class AgentRemoteDataSource(
private val agentService: AgentService, private val agentService: AgentService,
@@ -103,6 +132,16 @@ class AgentRemoteDataSource(
authorId = authorId authorId = authorId
) )
} }
suspend fun searchAgentByTitle(
pageNumber: Int,
title: String
): ListContainer<AgentEntity>? {
return agentService.searchAgentByTitle(
pageNumber = pageNumber,
title = title
)
}
} }
class AgentServiceImpl() : AgentService { class AgentServiceImpl() : AgentService {
@@ -118,6 +157,17 @@ class AgentServiceImpl() : AgentService {
authorId = authorId authorId = authorId
) )
} }
override suspend fun searchAgentByTitle(
pageNumber: Int,
pageSize: Int,
title: String
): ListContainer<AgentEntity>? {
return agentBackend.searchAgentByTitle(
pageNumber = pageNumber,
title = title
)
}
} }
class AgentBackend { class AgentBackend {
@@ -175,6 +225,27 @@ class AgentBackend {
) )
} }
} }
suspend fun searchAgentByTitle(
pageNumber: Int,
title: String
): ListContainer<AgentEntity>? {
val resp = ApiClient.api.getAgent(
page = pageNumber,
pageSize = DataBatchSize,
withWorkflow = 1,
title = title
)
val body = resp.body() ?: return null
val dataContainer = body as DataContainer<ListContainer<Agent>>
val listContainer = dataContainer.data
return ListContainer(
total = listContainer.total,
page = pageNumber,
pageSize = DataBatchSize,
list = listContainer.list.map { it.toAgentEntity() }
)
}
} }
data class AgentEntity( data class AgentEntity(

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.icu.util.Calendar import android.icu.util.Calendar
import com.aiosman.ravenow.ConstVars import com.aiosman.ravenow.ConstVars
import com.aiosman.ravenow.exp.formatChatTime import com.aiosman.ravenow.exp.formatChatTime
import com.aiosman.ravenow.utils.NotificationMessageHelper
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import io.openim.android.sdk.models.Message import io.openim.android.sdk.models.Message
import io.openim.android.sdk.models.PictureElem import io.openim.android.sdk.models.PictureElem
@@ -21,7 +22,8 @@ data class ChatItem(
val textDisplay: String = "", val textDisplay: String = "",
val msgId: String, // Add this property val msgId: String, // Add this property
var showTimestamp: Boolean = false, var showTimestamp: Boolean = false,
var showTimeDivider: Boolean = false var showTimeDivider: Boolean = false,
val isNotification: Boolean = false // 标识是否为通知类型消息
) { ) {
companion object { companion object {
// OpenIM 消息类型常量 // OpenIM 消息类型常量
@@ -36,6 +38,32 @@ data class ChatItem(
val calendar = Calendar.getInstance() val calendar = Calendar.getInstance()
calendar.timeInMillis = timestamp calendar.timeInMillis = timestamp
// 检查是否为通知类型消息
// 1. 检查消息类型是否为通知类型
// 2. 检查发送者ID是否为系统账户如 "imAdmin"、"administrator" 等)
val sendID = message.sendID ?: ""
val isSystemAccount = sendID == "imAdmin" || sendID == "administrator" || sendID.isEmpty()
val isNotificationType = OpenIMMessageType.isNotification(message.contentType)
val isNotification = isNotificationType || isSystemAccount
// 如果是通知类型消息,使用特殊处理
if (isNotification) {
val notificationText = NotificationMessageHelper.getNotificationText(message)
return ChatItem(
message = notificationText,
avatar = "", // 通知消息不显示头像
time = calendar.time.formatChatTime(context),
userId = sendID.ifEmpty { "system" },
nickname = "", // 通知消息不显示昵称
timestamp = timestamp,
imageList = emptyList<PictureInfo>().toMutableList(),
messageType = message.contentType,
textDisplay = notificationText,
msgId = message.clientMsgID,
isNotification = true
)
}
var faceAvatar = avatar var faceAvatar = avatar
if (faceAvatar == null) { if (faceAvatar == null) {
faceAvatar = "${ConstVars.BASE_SERVER}${message.senderFaceUrl}" faceAvatar = "${ConstVars.BASE_SERVER}${message.senderFaceUrl}"
@@ -62,7 +90,8 @@ data class ChatItem(
).toMutableList(), ).toMutableList(),
messageType = MESSAGE_TYPE_IMAGE, messageType = MESSAGE_TYPE_IMAGE,
textDisplay = "Image", textDisplay = "Image",
msgId = message.clientMsgID msgId = message.clientMsgID,
isNotification = false
) )
} }
return null return null
@@ -79,7 +108,8 @@ data class ChatItem(
imageList = emptyList<PictureInfo>().toMutableList(), imageList = emptyList<PictureInfo>().toMutableList(),
messageType = MESSAGE_TYPE_TEXT, messageType = MESSAGE_TYPE_TEXT,
textDisplay = message.textElem?.content ?: "Unsupported message type", textDisplay = message.textElem?.content ?: "Unsupported message type",
msgId = message.clientMsgID msgId = message.clientMsgID,
isNotification = false
) )
} }

View File

@@ -13,4 +13,6 @@ data class GroupInfo(
val groupAvatar: String, val groupAvatar: String,
val memberCount: Int, val memberCount: Int,
val isCreator: Boolean = false, val isCreator: Boolean = false,
val trtcType: String = "Public",
val privateFeePaid: Boolean = false,
) )

View File

@@ -250,8 +250,26 @@ data class MomentImageEntity(
val id: Long, val id: Long,
// 图片URL // 图片URL
val url: String, val url: String,
// 原始图片URL
val originalUrl: String? = null,
// 直接访问URL
val directUrl: String? = null,
// 缩略图URL // 缩略图URL
val thumbnail: String, val thumbnail: String,
// 缩略图直接访问URL
val thumbnailDirectUrl: String? = null,
// 小尺寸图片URL
val small: String? = null,
// 小尺寸图片直接访问URL
val smallDirectUrl: String? = null,
// 中尺寸图片URL
val medium: String? = null,
// 中尺寸图片直接访问URL
val mediumDirectUrl: String? = null,
// 大尺寸图片URL
val large: String? = null,
// 大尺寸图片直接访问URL
val largeDirectUrl: String? = null,
// 图片BlurHash // 图片BlurHash
val blurHash: String? = null, val blurHash: String? = null,
// 宽度 // 宽度
@@ -260,6 +278,38 @@ data class MomentImageEntity(
var height: Int? = null var height: Int? = null
) )
/**
* 动态视频
*/
data class MomentVideoEntity(
// 视频ID
val id: Long,
// 视频URL
val url: String,
// 原始文件名
val originalUrl: String? = null,
// 直接访问URL
val directUrl: String? = null,
// 视频缩略图URL
val thumbnailUrl: String? = null,
// 视频缩略图直接访问URL
val thumbnailDirectUrl: String? = null,
// 视频时长(秒)
val duration: Int? = null,
// 宽度
val width: Int? = null,
// 高度
val height: Int? = null,
// 文件大小(字节)
val size: Long? = null,
// 视频格式
val format: String? = null,
// 视频比特率kbps
val bitrate: Int? = null,
// 帧率
val frameRate: String? = null
)
/** /**
* 动态 * 动态
*/ */
@@ -277,7 +327,7 @@ data class MomentEntity(
// 是否关注 // 是否关注
val followStatus: Boolean, val followStatus: Boolean,
// 动态内容 // 动态内容
val momentTextContent: String, val momentTextContent: String?,
// 动态图片 // 动态图片
@DrawableRes val momentPicture: Int, @DrawableRes val momentPicture: Int,
// 点赞数 // 点赞数
@@ -300,6 +350,10 @@ data class MomentEntity(
var relMoment: MomentEntity? = null, var relMoment: MomentEntity? = null,
// 是否收藏 // 是否收藏
var isFavorite: Boolean = false, var isFavorite: Boolean = false,
// 外部链接
val url: String? = null,
// 动态视频列表
val videos: List<MomentVideoEntity>? = null,
// 新闻相关字段 // 新闻相关字段
val isNews: Boolean = false, val isNews: Boolean = false,
val newsTitle: String = "", val newsTitle: String = "",
@@ -307,13 +361,22 @@ data class MomentEntity(
val newsSource: String = "", val newsSource: String = "",
val newsCategory: String = "", val newsCategory: String = "",
val newsLanguage: String = "", val newsLanguage: String = "",
val newsContent: String = "" val newsContent: String = "",
// 是否已获取完整正文
val hasFullText: Boolean = false,
// 新闻摘要
val summary: String? = null,
// 新闻发布时间
val publishedAt: String? = null,
// 是否已缓存图片
val imageCached: Boolean = false
) )
class MomentLoaderExtraArgs( class MomentLoaderExtraArgs(
val explore: Boolean? = false, val explore: Boolean? = false,
val timelineId: Int? = null, val timelineId: Int? = null,
val authorId : Int? = null, val authorId : Int? = null,
val newsOnly: Boolean? = null val newsOnly: Boolean? = null,
val videoOnly: Boolean? = null
) )
class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() { class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
override suspend fun fetchData( override suspend fun fetchData(
@@ -327,7 +390,8 @@ class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
explore = if (extra.explore == true) "true" else "", explore = if (extra.explore == true) "true" else "",
timelineId = extra.timelineId, timelineId = extra.timelineId,
authorId = extra.authorId, authorId = extra.authorId,
newsFilter = if (extra.newsOnly == true) "news_only" else "" newsFilter = if (extra.newsOnly == true) "news_only" else "",
videoFilter = if (extra.videoOnly == true) "video_only" else ""
) )
val data = result.body()?.let { val data = result.body()?.let {
ListContainer( ListContainer(

View File

@@ -0,0 +1,198 @@
package com.aiosman.ravenow.entity
/**
* OpenIM 消息类型常量
* 对应 OpenIM SDK 的 ContentType 枚举值
*/
object OpenIMMessageType {
// ========== 基础消息类型 ==========
/** 文本消息 */
const val TEXT = 101
/** 图片消息 */
const val IMAGE = 102
/** 语音消息 */
const val VOICE = 103
/** 视频消息 */
const val VIDEO = 104
/** 文件消息 */
const val FILE = 105
/** @消息 */
const val AT = 106
/** 合并消息 */
const val MERGE = 107
/** 名片消息 */
const val CARD = 108
/** 位置消息 */
const val LOCATION = 109
/** 自定义消息 */
const val CUSTOM = 110
/** 输入状态 */
const val TYPING = 113
/** 引用消息 */
const val QUOTE = 114
/** 表情消息 */
const val EMOJI = 115
// ========== 通知消息类型 ==========
/** 双方成为好友通知 */
const val FRIEND_ADDED = 1201
/** 系统通知 */
const val SYSTEM_NOTIFICATION = 1400
// ========== 群通知消息类型 ==========
/** 群创建通知 */
const val GROUP_CREATED = 1501
/** 群信息改变通知 */
const val GROUP_INFO_CHANGED = 1502
/** 群成员退出通知 */
const val GROUP_MEMBER_QUIT = 1504
/** 群主更换通知 */
const val GROUP_OWNER_CHANGED = 1507
/** 群成员被踢通知 */
const val GROUP_MEMBER_KICKED = 1508
/** 邀请群成员通知 */
const val GROUP_MEMBER_INVITED = 1509
/** 群成员进群通知 */
const val GROUP_MEMBER_JOINED = 1510
/** 解散群通知 */
const val GROUP_DISMISSED = 1511
/** 群成员禁言通知 */
const val GROUP_MEMBER_MUTED = 1512
/** 取消群成员禁言通知 */
const val GROUP_MEMBER_UNMUTED = 1513
/** 群禁言通知 */
const val GROUP_MUTED = 1514
/** 取消群禁言通知 */
const val GROUP_UNMUTED = 1515
/** 群公告改变通知 */
const val GROUP_ANNOUNCEMENT_CHANGED = 1519
/** 群名称改变通知 */
const val GROUP_NAME_CHANGED = 1520
// ========== 其他通知类型 ==========
/** 阅后即焚开启或关闭通知 */
const val SNAPCHAT_TOGGLE = 1701
/** 撤回消息通知 */
const val MESSAGE_REVOKED = 2101
/**
* 获取消息类型的描述
*/
fun getDescription(type: Int): String {
return when (type) {
TEXT -> "文本消息"
IMAGE -> "图片消息"
VOICE -> "语音消息"
VIDEO -> "视频消息"
FILE -> "文件消息"
AT -> "@消息"
MERGE -> "合并消息"
CARD -> "名片消息"
LOCATION -> "位置消息"
CUSTOM -> "自定义消息"
TYPING -> "输入状态"
QUOTE -> "引用消息"
EMOJI -> "表情消息"
FRIEND_ADDED -> "双方成为好友通知"
SYSTEM_NOTIFICATION -> "系统通知"
GROUP_CREATED -> "群创建通知"
GROUP_INFO_CHANGED -> "群信息改变通知"
GROUP_MEMBER_QUIT -> "群成员退出通知"
GROUP_OWNER_CHANGED -> "群主更换通知"
GROUP_MEMBER_KICKED -> "群成员被踢通知"
GROUP_MEMBER_INVITED -> "邀请群成员通知"
GROUP_MEMBER_JOINED -> "群成员进群通知"
GROUP_DISMISSED -> "解散群通知"
GROUP_MEMBER_MUTED -> "群成员禁言通知"
GROUP_MEMBER_UNMUTED -> "取消群成员禁言通知"
GROUP_MUTED -> "群禁言通知"
GROUP_UNMUTED -> "取消群禁言通知"
GROUP_ANNOUNCEMENT_CHANGED -> "群公告改变通知"
GROUP_NAME_CHANGED -> "群名称改变通知"
SNAPCHAT_TOGGLE -> "阅后即焚开启或关闭通知"
MESSAGE_REVOKED -> "撤回消息通知"
else -> "未知消息类型($type)"
}
}
/**
* 判断是否为通知类型消息
*/
fun isNotification(type: Int): Boolean {
return type in listOf(
FRIEND_ADDED,
SYSTEM_NOTIFICATION,
GROUP_CREATED,
GROUP_INFO_CHANGED,
GROUP_MEMBER_QUIT,
GROUP_OWNER_CHANGED,
GROUP_MEMBER_KICKED,
GROUP_MEMBER_INVITED,
GROUP_MEMBER_JOINED,
GROUP_DISMISSED,
GROUP_MEMBER_MUTED,
GROUP_MEMBER_UNMUTED,
GROUP_MUTED,
GROUP_UNMUTED,
GROUP_ANNOUNCEMENT_CHANGED,
GROUP_NAME_CHANGED,
SNAPCHAT_TOGGLE,
MESSAGE_REVOKED
)
}
/**
* 判断是否为群通知类型消息
*/
fun isGroupNotification(type: Int): Boolean {
return type in listOf(
GROUP_CREATED,
GROUP_INFO_CHANGED,
GROUP_MEMBER_QUIT,
GROUP_OWNER_CHANGED,
GROUP_MEMBER_KICKED,
GROUP_MEMBER_INVITED,
GROUP_MEMBER_JOINED,
GROUP_DISMISSED,
GROUP_MEMBER_MUTED,
GROUP_MEMBER_UNMUTED,
GROUP_MUTED,
GROUP_UNMUTED,
GROUP_ANNOUNCEMENT_CHANGED,
GROUP_NAME_CHANGED
)
}
}

View File

@@ -1,13 +1,28 @@
package com.aiosman.ravenow.entity package com.aiosman.ravenow.entity
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.aiosman.ravenow.data.ListContainer import com.aiosman.ravenow.data.ListContainer
import com.aiosman.ravenow.data.Room
import com.aiosman.ravenow.data.ServiceException import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.data.api.ApiClient import com.aiosman.ravenow.data.api.ApiClient
import java.io.IOException
/** /**
* 群聊房间 * 群聊房间
*/ */
/**
* 房间内的智能体信息实体
*/
data class PromptTemplateEntity(
val id: Int,
val openId: String,
val title: String,
val desc: String,
val avatar: String
)
data class RoomEntity( data class RoomEntity(
val id: Int, val id: Int,
val name: String, val name: String,
@@ -21,9 +36,16 @@ data class RoomEntity(
val allowInHot: Boolean, val allowInHot: Boolean,
val creator: CreatorEntity, val creator: CreatorEntity,
val userCount: Int, val userCount: Int,
val totalMemberCount: Int? = null,
val maxMemberLimit: Int, val maxMemberLimit: Int,
val maxTotal: Int? = null,
val systemMaxTotal: Int? = null,
val canJoin: Boolean, val canJoin: Boolean,
val canJoinCode: Int, val canJoinCode: Int,
val privateFeePaid: Boolean = false,
val prompts: List<PromptTemplateEntity> = emptyList(),
val createdAt: String? = null,
val updatedAt: String? = null,
val users: List<UsersEntity>, val users: List<UsersEntity>,
) )
@@ -58,7 +80,12 @@ data class ProfileEntity(
data class RoomRuleCreatorEntity( data class RoomRuleCreatorEntity(
val id: Int, val id: Int,
val nickname: String, val nickname: String,
val avatar: String val avatar: String,
val avatarMedium: String? = null,
val avatarLarge: String? = null,
val avatarDirectUrl: String? = null,
val avatarMediumDirectUrl: String? = null,
val avatarLargeDirectUrl: String? = null
) )
/** /**
@@ -86,6 +113,128 @@ data class RoomRuleQuotaEntity(
val usagePercent: Double val usagePercent: Double
) )
// ========== Room Member Management 实体类 ==========
/**
* 添加用户成功项目
*/
data class AddUserToRoomItemEntity(
val userId: String,
val type: String
)
/**
* 添加用户失败项目
*/
data class AddUserToRoomFailedItemEntity(
val userId: String,
val type: String,
val error: String
)
/**
* 添加用户到房间的结果
*/
data class AddUserToRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<AddUserToRoomItemEntity>,
val failedItems: List<AddUserToRoomFailedItemEntity>,
val skippedItems: List<AddUserToRoomItemEntity>
)
/**
* 添加智能体成功项目
*/
data class AddAgentToRoomItemEntity(
val agentOpenId: String,
val type: String
)
/**
* 添加智能体失败项目
*/
data class AddAgentToRoomFailedItemEntity(
val agentOpenId: String,
val type: String,
val error: String
)
/**
* 添加智能体到房间的结果
*/
data class AddAgentToRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<AddAgentToRoomItemEntity>,
val failedItems: List<AddAgentToRoomFailedItemEntity>,
val skippedItems: List<AddAgentToRoomItemEntity>
)
/**
* 移除智能体成功项目
*/
data class RemoveAgentFromRoomItemEntity(
val agentOpenId: String,
val type: String
)
/**
* 移除智能体失败项目
*/
data class RemoveAgentFromRoomFailedItemEntity(
val agentOpenId: String,
val type: String,
val error: String
)
/**
* 从房间移除智能体的结果
*/
data class RemoveAgentFromRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<RemoveAgentFromRoomItemEntity>,
val failedItems: List<RemoveAgentFromRoomFailedItemEntity>,
val skippedItems: List<RemoveAgentFromRoomItemEntity>
)
/**
* 移除用户成功项目
*/
data class RemoveUserFromRoomItemEntity(
val userId: String,
val type: String
)
/**
* 移除用户失败项目
*/
data class RemoveUserFromRoomFailedItemEntity(
val userId: String,
val type: String,
val error: String
)
/**
* 从房间移除用户的结果
*/
data class RemoveUserFromRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<RemoveUserFromRoomItemEntity>,
val failedItems: List<RemoveUserFromRoomFailedItemEntity>,
val skippedItems: List<RemoveUserFromRoomItemEntity>
)
class RoomLoader : DataLoader<AgentEntity,AgentLoaderExtraArgs>() { class RoomLoader : DataLoader<AgentEntity,AgentLoaderExtraArgs>() {
override suspend fun fetchData( override suspend fun fetchData(
page: Int, page: Int,
@@ -112,4 +261,59 @@ class RoomLoader : DataLoader<AgentEntity,AgentLoaderExtraArgs>() {
} }
}
/**
* 房间远程数据源
*/
class RoomRemoteDataSource {
suspend fun searchRooms(
pageNumber: Int,
pageSize: Int = 20,
search: String
): ListContainer<RoomEntity>? {
val resp = ApiClient.api.getRooms(
page = pageNumber,
pageSize = pageSize,
search = search,
roomType = "public" // 搜索时只显示公有房间
)
val body = resp.body() ?: return null
return ListContainer(
total = body.total,
page = pageNumber,
pageSize = pageSize,
list = body.list.map { it.toRoomtEntity() }
)
}
}
/**
* 房间搜索分页加载器
*/
class RoomSearchPagingSource(
private val roomRemoteDataSource: RoomRemoteDataSource,
private val keyword: String,
) : PagingSource<Int, RoomEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, RoomEntity> {
return try {
val currentPage = params.key ?: 1
val rooms = roomRemoteDataSource.searchRooms(
pageNumber = currentPage,
pageSize = params.loadSize,
search = keyword
)
LoadResult.Page(
data = rooms?.list ?: listOf(),
prevKey = if (currentPage == 1) null else currentPage - 1,
nextKey = if (rooms?.list?.isNotEmpty() == true) currentPage + 1 else null
)
} catch (exception: IOException) {
LoadResult.Error(exception)
}
}
override fun getRefreshKey(state: PagingState<Int, RoomEntity>): Int? {
return state.anchorPosition
}
} }

View File

@@ -48,7 +48,7 @@ fun Date.formatPostTime(): String {
} }
/** /**
* YYYY.DD.MM HH:MM * yyyy-MM-dd HH:mm
*/ */
fun Date.formatPostTime2(): String { fun Date.formatPostTime2(): String {
val calendar = Calendar.getInstance() val calendar = Calendar.getInstance()
@@ -58,7 +58,14 @@ fun Date.formatPostTime2(): String {
val day = calendar.get(Calendar.DAY_OF_MONTH) val day = calendar.get(Calendar.DAY_OF_MONTH)
val hour = calendar.get(Calendar.HOUR_OF_DAY) val hour = calendar.get(Calendar.HOUR_OF_DAY)
val minute = calendar.get(Calendar.MINUTE) val minute = calendar.get(Calendar.MINUTE)
return "$year.$month.$day $hour:$minute"
// 确保两位数
val monthStr = String.format("%02d", month)
val dayStr = String.format("%02d", day)
val hourStr = String.format("%02d", hour)
val minuteStr = String.format("%02d", minute)
return "$year-$monthStr-$dayStr $hourStr:$minuteStr"
} }
fun Date.formatChatTime(context: Context): String { fun Date.formatChatTime(context: Context): String {

View File

@@ -35,12 +35,14 @@ import com.aiosman.ravenow.LocalSharedTransitionScope
import com.aiosman.ravenow.ui.about.AboutScreen import com.aiosman.ravenow.ui.about.AboutScreen
import com.aiosman.ravenow.ui.account.AccountEditScreen2 import com.aiosman.ravenow.ui.account.AccountEditScreen2
import com.aiosman.ravenow.ui.account.AccountSetting import com.aiosman.ravenow.ui.account.AccountSetting
import com.aiosman.ravenow.ui.account.BlockedUsersScreen
import com.aiosman.ravenow.ui.account.MbtiSelectScreen import com.aiosman.ravenow.ui.account.MbtiSelectScreen
import com.aiosman.ravenow.ui.account.RemoveAccountScreen import com.aiosman.ravenow.ui.account.RemoveAccountScreen
import com.aiosman.ravenow.ui.account.ResetPasswordScreen import com.aiosman.ravenow.ui.account.ResetPasswordScreen
import com.aiosman.ravenow.ui.account.ZodiacSelectScreen import com.aiosman.ravenow.ui.account.ZodiacSelectScreen
import com.aiosman.ravenow.ui.agent.AddAgentScreen import com.aiosman.ravenow.ui.agent.AddAgentScreen
import com.aiosman.ravenow.ui.agent.AgentImageCropScreen import com.aiosman.ravenow.ui.agent.AgentImageCropScreen
import com.aiosman.ravenow.ui.agent.AiPromptEditScreen
import com.aiosman.ravenow.ui.group.CreateGroupChatScreen import com.aiosman.ravenow.ui.group.CreateGroupChatScreen
import com.aiosman.ravenow.ui.chat.ChatAiScreen import com.aiosman.ravenow.ui.chat.ChatAiScreen
import com.aiosman.ravenow.ui.chat.ChatSettingScreen import com.aiosman.ravenow.ui.chat.ChatSettingScreen
@@ -59,6 +61,9 @@ import com.aiosman.ravenow.ui.gallery.OfficialGalleryScreen
import com.aiosman.ravenow.ui.gallery.OfficialPhotographerScreen import com.aiosman.ravenow.ui.gallery.OfficialPhotographerScreen
import com.aiosman.ravenow.ui.gallery.ProfileTimelineScreen import com.aiosman.ravenow.ui.gallery.ProfileTimelineScreen
import com.aiosman.ravenow.ui.group.GroupChatInfoScreen import com.aiosman.ravenow.ui.group.GroupChatInfoScreen
import com.aiosman.ravenow.ui.group.GroupMembersScreen
import com.aiosman.ravenow.ui.group.AddGroupMemberScreen
import com.aiosman.ravenow.ui.group.GroupProfileSettingsScreen
import com.aiosman.ravenow.ui.index.IndexScreen import com.aiosman.ravenow.ui.index.IndexScreen
import com.aiosman.ravenow.ui.index.tabs.message.NotificationsScreen import com.aiosman.ravenow.ui.index.tabs.message.NotificationsScreen
import com.aiosman.ravenow.ui.index.tabs.search.SearchScreen import com.aiosman.ravenow.ui.index.tabs.search.SearchScreen
@@ -73,8 +78,10 @@ import com.aiosman.ravenow.ui.post.NewPostImageGridScreen
import com.aiosman.ravenow.ui.post.NewPostScreen import com.aiosman.ravenow.ui.post.NewPostScreen
import com.aiosman.ravenow.ui.post.PostScreen import com.aiosman.ravenow.ui.post.PostScreen
import com.aiosman.ravenow.ui.profile.AccountProfileV2 import com.aiosman.ravenow.ui.profile.AccountProfileV2
import com.aiosman.ravenow.ui.profile.AiProfileWrap
import com.aiosman.ravenow.ui.index.tabs.profile.vip.VipSelPage import com.aiosman.ravenow.ui.index.tabs.profile.vip.VipSelPage
import com.aiosman.ravenow.ui.notification.NotificationScreen import com.aiosman.ravenow.ui.notification.NotificationScreen
import com.aiosman.ravenow.ui.scan.ScanQrScreen
sealed class NavigationRoute( sealed class NavigationRoute(
val route: String, val route: String,
@@ -119,11 +126,17 @@ sealed class NavigationRoute(
data object AddAgent : NavigationRoute("AddAgent") data object AddAgent : NavigationRoute("AddAgent")
data object CreateGroupChat : NavigationRoute("CreateGroupChat") data object CreateGroupChat : NavigationRoute("CreateGroupChat")
data object GroupInfo : NavigationRoute("GroupInfo/{id}") data object GroupInfo : NavigationRoute("GroupInfo/{id}")
data object GroupMembers : NavigationRoute("GroupMembers/{id}")
data object AddGroupMember : NavigationRoute("AddGroupMember/{groupId}/{groupName}")
data object GroupProfileSettings : NavigationRoute("GroupProfileSettings/{id}")
data object VipSelPage : NavigationRoute("VipSelPage") data object VipSelPage : NavigationRoute("VipSelPage")
data object RemoveAccountScreen: NavigationRoute("RemoveAccount") data object RemoveAccountScreen: NavigationRoute("RemoveAccount")
data object NotificationScreen : NavigationRoute("NotificationScreen") data object NotificationScreen : NavigationRoute("NotificationScreen")
data object MbtiSelect : NavigationRoute("MbtiSelect") data object MbtiSelect : NavigationRoute("MbtiSelect")
data object ZodiacSelect : NavigationRoute("ZodiacSelect") data object ZodiacSelect : NavigationRoute("ZodiacSelect")
data object ScanQr : NavigationRoute("ScanQr")
data object AiPromptEdit : NavigationRoute("AiPromptEdit/{chatAIId}")
data object BlockedUsersScreen : NavigationRoute("BlockedUsersScreen")
} }
@@ -337,7 +350,13 @@ fun NavigationController(
) { ) {
val id = it.arguments?.getString("id")!! val id = it.arguments?.getString("id")!!
val isAiAccount = it.arguments?.getBoolean("isAiAccount") ?: false val isAiAccount = it.arguments?.getBoolean("isAiAccount") ?: false
AccountProfileV2(id, isAiAccount)
// 根据isAiAccount参数分发到不同的Profile页面
if (isAiAccount) {
AiProfileWrap(id)
} else {
AccountProfileV2(id, isAiAccount)
}
} }
} }
composable( composable(
@@ -422,6 +441,9 @@ fun NavigationController(
composable(route = NavigationRoute.ChangePasswordScreen.route) { composable(route = NavigationRoute.ChangePasswordScreen.route) {
ChangePasswordScreen() ChangePasswordScreen()
} }
composable(route = NavigationRoute.BlockedUsersScreen.route) {
BlockedUsersScreen()
}
composable(route = NavigationRoute.RemoveAccountScreen.route) { composable(route = NavigationRoute.RemoveAccountScreen.route) {
RemoveAccountScreen() RemoveAccountScreen()
} }
@@ -447,6 +469,9 @@ fun NavigationController(
SearchScreen() SearchScreen()
} }
} }
composable(route = NavigationRoute.ScanQr.route) {
ScanQrScreen()
}
composable( composable(
route = NavigationRoute.FollowerList.route, route = NavigationRoute.FollowerList.route,
arguments = listOf(navArgument("id") { type = NavType.IntType }) arguments = listOf(navArgument("id") { type = NavType.IntType })
@@ -613,6 +638,51 @@ fun NavigationController(
} }
} }
composable(
route = NavigationRoute.GroupMembers.route,
arguments = listOf(navArgument("id") { type = NavType.StringType })
) {
val encodedId = it.arguments?.getString("id")
val decodedId = encodedId?.let { java.net.URLDecoder.decode(it, "UTF-8") }
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
GroupMembersScreen(decodedId ?: "")
}
}
composable(
route = NavigationRoute.AddGroupMember.route,
arguments = listOf(
navArgument("groupId") { type = NavType.StringType },
navArgument("groupName") { type = NavType.StringType }
)
) {
val encodedGroupId = it.arguments?.getString("groupId")
val decodedGroupId = encodedGroupId?.let { java.net.URLDecoder.decode(it, "UTF-8") }
val encodedGroupName = it.arguments?.getString("groupName")
val decodedGroupName = encodedGroupName?.let { java.net.URLDecoder.decode(it, "UTF-8") }
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
AddGroupMemberScreen(decodedGroupId ?: "", decodedGroupName)
}
}
composable(
route = NavigationRoute.GroupProfileSettings.route,
arguments = listOf(navArgument("id") { type = NavType.StringType })
) {
val encodedId = it.arguments?.getString("id")
val decodedId = encodedId?.let { java.net.URLDecoder.decode(it, "UTF-8") }
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
GroupProfileSettingsScreen(decodedId ?: "")
}
}
composable(route = NavigationRoute.NotificationScreen.route) { composable(route = NavigationRoute.NotificationScreen.route) {
CompositionLocalProvider( CompositionLocalProvider(
LocalAnimatedContentScope provides this, LocalAnimatedContentScope provides this,
@@ -620,6 +690,18 @@ fun NavigationController(
NotificationScreen() NotificationScreen()
} }
} }
composable(
route = NavigationRoute.AiPromptEdit.route,
arguments = listOf(navArgument("chatAIId") { type = NavType.StringType })
) {
val chatAIId = it.arguments?.getString("chatAIId") ?: ""
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
AiPromptEditScreen(chatAIId = chatAIId)
}
}
} }
@@ -701,6 +783,34 @@ fun NavHostController.navigateToGroupInfo(id: String) {
) )
} }
fun NavHostController.navigateToGroupMembers(id: String) {
val encodedId = java.net.URLEncoder.encode(id, "UTF-8")
navigate(
route = NavigationRoute.GroupMembers.route
.replace("{id}", encodedId)
)
}
fun NavHostController.navigateToAddGroupMember(groupId: String, groupName: String?) {
val encodedGroupId = java.net.URLEncoder.encode(groupId, "UTF-8")
val encodedGroupName = java.net.URLEncoder.encode(groupName ?: "", "UTF-8")
navigate(
route = NavigationRoute.AddGroupMember.route
.replace("{groupId}", encodedGroupId)
.replace("{groupName}", encodedGroupName)
)
}
fun NavHostController.navigateToGroupProfileSettings(id: String) {
val encodedId = java.net.URLEncoder.encode(id, "UTF-8")
navigate(
route = NavigationRoute.GroupProfileSettings.route
.replace("{id}", encodedId)
)
}

View File

@@ -71,7 +71,7 @@ fun AboutScreen() {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// app version // app version
Text( Text(
text = stringResource(R.string.version_text, versionText), text = stringResource(R.string.version_text, versionText ?: ""),
fontSize = 16.sp, fontSize = 16.sp,
color = appColors.secondaryText, color = appColors.secondaryText,
fontWeight = FontWeight.Normal fontWeight = FontWeight.Normal

View File

@@ -21,6 +21,8 @@ object AccountEditViewModel : ViewModel() {
var name by mutableStateOf("") var name by mutableStateOf("")
var bio by mutableStateOf("") var bio by mutableStateOf("")
var imageUrl by mutableStateOf<Uri?>(null) var imageUrl by mutableStateOf<Uri?>(null)
var bannerImageUrl by mutableStateOf<Uri?>(null)
var bannerFile by mutableStateOf<File?>(null)
val accountService: AccountService = AccountServiceImpl() val accountService: AccountService = AccountServiceImpl()
var profile by mutableStateOf<AccountProfileEntity?>(null) var profile by mutableStateOf<AccountProfileEntity?>(null)
var croppedBitmap by mutableStateOf<Bitmap?>(null) var croppedBitmap by mutableStateOf<Bitmap?>(null)
@@ -29,7 +31,12 @@ object AccountEditViewModel : ViewModel() {
// 本地扩展字段 // 本地扩展字段
var mbti by mutableStateOf<String?>(null) var mbti by mutableStateOf<String?>(null)
var zodiac by mutableStateOf<String?>(null) var zodiac by mutableStateOf<String?>(null)
suspend fun reloadProfile(updateTrtcProfile:Boolean = false) { // 保存原始值,用于取消时恢复
private var originalName: String = ""
private var originalBio: String = ""
private var originalMbti: String? = null
private var originalZodiac: String? = null
suspend fun reloadProfile(updateTrtcProfile:Boolean = false, clearCroppedBitmap: Boolean = false) {
Log.d("AccountEditViewModel", "reloadProfile: 开始加载用户资料") Log.d("AccountEditViewModel", "reloadProfile: 开始加载用户资料")
isLoading = true isLoading = true
try { try {
@@ -39,13 +46,23 @@ object AccountEditViewModel : ViewModel() {
profile = it profile = it
name = it.nickName name = it.nickName
bio = it.bio bio = it.bio
// 清除之前裁剪的图片 // 保存原始值,用于取消时恢复
croppedBitmap = null originalName = it.nickName
originalBio = it.bio
// 只在明确要求时清除之前裁剪的图片(例如保存成功后)
if (clearCroppedBitmap) {
croppedBitmap = null
}
// 读取本地扩展字段 // 读取本地扩展字段
try { try {
val uid = it.id // 使用 profile 的 id确保非空 val uid = it.id // 使用 profile 的 id确保非空
mbti = com.aiosman.ravenow.AppStore.getUserMbti(uid) val loadedMbti = com.aiosman.ravenow.AppStore.getUserMbti(uid)
zodiac = com.aiosman.ravenow.AppStore.getUserZodiac(uid) val loadedZodiac = com.aiosman.ravenow.AppStore.getUserZodiac(uid)
mbti = loadedMbti
zodiac = loadedZodiac
// 保存原始值
originalMbti = loadedMbti
originalZodiac = loadedZodiac
} catch (_: Exception) { } } catch (_: Exception) { }
if (updateTrtcProfile) { if (updateTrtcProfile) {
TrtcHelper.updateTrtcProfile( TrtcHelper.updateTrtcProfile(
@@ -67,12 +84,15 @@ object AccountEditViewModel : ViewModel() {
} }
fun resetToOriginalData() { fun resetToOriginalData() {
profile?.let { // 恢复所有字段到原始值
name = it.nickName name = originalName
bio = it.bio bio = originalBio
// 清除之前裁剪的图片 mbti = originalMbti
croppedBitmap = null zodiac = originalZodiac
} // 清除之前裁剪的图片和壁纸
croppedBitmap = null
bannerImageUrl = null
bannerFile = null
} }
@@ -82,6 +102,30 @@ object AccountEditViewModel : ViewModel() {
it.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream()) it.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream())
UploadImage(file, "avatar.jpg", "", "jpg") UploadImage(file, "avatar.jpg", "", "jpg")
} }
// 处理背景图更新
val newBanner = bannerImageUrl?.let { uri ->
bannerFile?.let { file ->
val cursor = context.contentResolver.query(uri, null, null, null, null)
var uploadBanner: UploadImage? = null
cursor?.use { cur ->
val columnIndex = cur.getColumnIndex("_display_name")
if (cur.moveToFirst() && columnIndex != -1) {
val displayName = cur.getString(columnIndex)
val extension = displayName.substringAfterLast(".")
Log.d("AccountEditViewModel", "Banner file name: $displayName, extension: $extension")
uploadBanner = UploadImage(file, displayName, uri.toString(), extension)
} else {
// 如果无法获取文件名,使用默认值
val displayName = "banner.jpg"
val extension = "jpg"
uploadBanner = UploadImage(file, displayName, uri.toString(), extension)
}
}
uploadBanner
}
}
// 去除换行符,确保昵称和个人简介不包含换行 // 去除换行符,确保昵称和个人简介不包含换行
val cleanName = name.trim().replace("\n", "").replace("\r", "") val cleanName = name.trim().replace("\n", "").replace("\r", "")
val cleanBio = bio.trim().replace("\n", "").replace("\r", "") val cleanBio = bio.trim().replace("\n", "").replace("\r", "")
@@ -89,7 +133,7 @@ object AccountEditViewModel : ViewModel() {
val newName = if (cleanName == profile?.nickName) null else cleanName val newName = if (cleanName == profile?.nickName) null else cleanName
accountService.updateProfile( accountService.updateProfile(
avatar = newAvatar, avatar = newAvatar,
banner = null, banner = newBanner,
nickName = newName, nickName = newName,
bio = cleanBio bio = cleanBio
) )
@@ -100,8 +144,11 @@ object AccountEditViewModel : ViewModel() {
com.aiosman.ravenow.AppStore.setUserZodiac(uid, zodiac) com.aiosman.ravenow.AppStore.setUserZodiac(uid, zodiac)
} }
} catch (_: Exception) { } } catch (_: Exception) { }
// 刷新用户资料 // 清除背景图状态
reloadProfile() bannerImageUrl = null
bannerFile = null
// 刷新用户资料,保存成功后清除裁剪的图片
reloadProfile(clearCroppedBitmap = true)
// 刷新个人资料页面的用户资料 // 刷新个人资料页面的用户资料
MyProfileViewModel.loadUserProfile() MyProfileViewModel.loadUserProfile()
} }
@@ -116,6 +163,8 @@ object AccountEditViewModel : ViewModel() {
name = "" name = ""
bio = "" bio = ""
imageUrl = null imageUrl = null
bannerImageUrl = null
bannerFile = null
croppedBitmap = null croppedBitmap = null
isUpdating = false isUpdating = false
isLoading = false isLoading = false

View File

@@ -161,9 +161,7 @@ fun AccountSetting() {
SecurityOptionItem( SecurityOptionItem(
iconRes = R.mipmap.icons_block, iconRes = R.mipmap.icons_block,
label = stringResource(R.string.blocked_users), label = stringResource(R.string.blocked_users),
onClick = { onClick = { navController.navigate(NavigationRoute.BlockedUsersScreen.route) }
// TODO: 导航到屏蔽用户页面
}
) )
SecurityOptionItem( SecurityOptionItem(

View File

@@ -0,0 +1,127 @@
package com.aiosman.ravenow.ui.account
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
private object BlockedUsersConstants {
const val BACK_BUTTON_SIZE = 36
const val BACK_BUTTON_ICON_SIZE = 24
const val BACK_BUTTON_START_PADDING = 19
const val HEADER_VERTICAL_PADDING = 16
const val TITLE_OFFSET_X = 19
const val TITLE_TEXT_SIZE = 17
}
@Composable
private fun CircularBackButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val appColors = LocalAppTheme.current
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = "返回",
modifier = modifier
.size(BlockedUsersConstants.BACK_BUTTON_ICON_SIZE.dp)
.noRippleClickable { onClick() },
colorFilter = ColorFilter.tint(appColors.text)
)
}
/**
* 被屏蔽的用户界面
*/
@Composable
fun BlockedUsersScreen() {
val appColors = LocalAppTheme.current
val navController = LocalNavController.current
Column(
modifier = Modifier
.fillMaxSize()
.background(appColors.background),
) {
StatusBarSpacer()
// 顶部标题栏
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = BlockedUsersConstants.HEADER_VERTICAL_PADDING.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
CircularBackButton(
onClick = { navController.navigateUp() },
modifier = Modifier.padding(start = BlockedUsersConstants.BACK_BUTTON_START_PADDING.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(R.string.blocked_users),
fontWeight = FontWeight.W800,
fontSize = BlockedUsersConstants.TITLE_TEXT_SIZE.sp,
color = appColors.text
)
}
}
// 缺省状态
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = 149.dp),
contentAlignment = Alignment.TopCenter
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.mipmap.frame_23),
contentDescription = null,
modifier = Modifier.size(181.dp, 153.dp)
)
Spacer(modifier = Modifier.size(9.dp))
Text(
text = stringResource(R.string.no_users_isolated_yet),
color = appColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.LocalAppTheme import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R import com.aiosman.ravenow.R
@@ -80,6 +81,10 @@ fun MbtiSelectScreen() {
isSelected = mbti == currentMbti, isSelected = mbti == currentMbti,
onClick = { onClick = {
model.mbti = mbti model.mbti = mbti
// 立即保存到本地存储,确保选择后立即生效
AppState.UserId?.let { uid ->
com.aiosman.ravenow.AppStore.setUserMbti(uid, mbti)
}
navController.navigateUp() navController.navigateUp()
} }
) )

View File

@@ -20,12 +20,14 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight 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 androidx.compose.ui.unit.sp
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.ConstVars import com.aiosman.ravenow.ConstVars
import com.aiosman.ravenow.data.api.ErrorCode import com.aiosman.ravenow.data.api.ErrorCode
import com.aiosman.ravenow.LocalAppTheme import com.aiosman.ravenow.LocalAppTheme
@@ -99,10 +101,11 @@ fun ResetPasswordScreen() {
if (e.code == ErrorCode.USER_NOT_EXIST.code){ if (e.code == ErrorCode.USER_NOT_EXIST.code){
usernameError = context.getString(R.string.error_40002_user_not_exist) usernameError = context.getString(R.string.error_40002_user_not_exist)
} else { } else {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() // 其他错误不显示Toast
isSendSuccess = false
} }
} catch (e: Exception) { } catch (e: Exception) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() // 异常错误不显示Toast
isSendSuccess = false isSendSuccess = false
} finally { } finally {
isLoading = false isLoading = false
@@ -133,12 +136,21 @@ fun ResetPasswordScreen() {
modifier = Modifier.padding(horizontal = 24.dp), modifier = Modifier.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// 暗色模式下的 hint 文本颜色
val isDarkMode = AppState.darkMode
val hintColor = if (isDarkMode) {
Color(0xFFFFFFFF).copy(alpha = 0.7f)
} else {
null // 使用默认颜色
}
TextInputField( TextInputField(
text = username, text = username,
onValueChange = { username = it }, onValueChange = { username = it },
hint = stringResource(R.string.text_hint_email), hint = stringResource(R.string.text_hint_email),
enabled = !isLoading && countDown == null, enabled = !isLoading && countDown == null,
error = usernameError, error = usernameError,
customHintColor = hintColor
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Box( Box(
@@ -178,9 +190,11 @@ fun ResetPasswordScreen() {
} else { } else {
stringResource(R.string.recover) stringResource(R.string.recover)
}, },
backgroundColor = appColors.main, backgroundColor = Color(0xFF7C45ED), // 紫色背景
loadingBackgroundColor = Color(0xFF7C45ED), // loading 时保持紫色
disabledBackgroundColor = Color(0xFF7C45ED), // disabled 时保持紫色
color = appColors.mainText, color = appColors.mainText,
isLoading = isLoading, isLoading = isLoading && countDown == null, // 只在未发送成功时显示loading
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
enabled = countDown == null, enabled = countDown == null,
) { ) {
@@ -193,6 +207,8 @@ fun ResetPasswordScreen() {
.fillMaxWidth() .fillMaxWidth()
.height(48.dp), .height(48.dp),
text = stringResource(R.string.back_upper), text = stringResource(R.string.back_upper),
backgroundColor = Color(0xFF7C45ED), // 紫色背景
color = Color.White, // 白色文字
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
) { ) {
navController.navigateUp() navController.navigateUp()

View File

@@ -0,0 +1,15 @@
package com.aiosman.ravenow.ui.account
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@Composable
fun ZodiacBottomSheetHost() {
val show = ZodiacSheetManager.visible.collectAsState(false).value
if (show) {
ZodiacSelectBottomSheet(
onClose = { ZodiacSheetManager.close() }
)
}
}

View File

@@ -1,132 +1,384 @@
package com.aiosman.ravenow.ui.account package com.aiosman.ravenow.ui.account
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable 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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.material.icons.Icons import com.aiosman.ravenow.AppState
import androidx.compose.material.icons.filled.Check
import com.aiosman.ravenow.LocalAppTheme import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader import com.aiosman.ravenow.ui.modifiers.noRippleClickable
// 星座列表 // 星座资源ID列表
val ZODIAC_SIGNS = listOf( val ZODIAC_SIGN_RES_IDS = listOf(
"白羊座", "金牛座", "双子座", "巨蟹座", R.string.zodiac_aries,
"狮子座", "处女座", "天秤座", "天蝎座", R.string.zodiac_taurus,
"射手座", "摩羯座", "水瓶座", "双鱼座" R.string.zodiac_gemini,
R.string.zodiac_cancer,
R.string.zodiac_leo,
R.string.zodiac_virgo,
R.string.zodiac_libra,
R.string.zodiac_scorpio,
R.string.zodiac_sagittarius,
R.string.zodiac_capricorn,
R.string.zodiac_aquarius,
R.string.zodiac_pisces
) )
@Composable /**
fun ZodiacSelectScreen() { * 根据星座资源ID获取对应的图片资源ID
val navController = LocalNavController.current */
val appColors = LocalAppTheme.current fun getZodiacImageResId(zodiacResId: Int): Int {
val model = AccountEditViewModel return when (zodiacResId) {
val currentZodiac = model.zodiac R.string.zodiac_aries -> R.mipmap.baiyang
R.string.zodiac_taurus -> R.mipmap.jingniu
R.string.zodiac_gemini -> R.mipmap.shuangzi
R.string.zodiac_cancer -> R.mipmap.juxie
R.string.zodiac_leo -> R.mipmap.shizi
R.string.zodiac_virgo -> R.mipmap.chunv
R.string.zodiac_libra -> R.mipmap.tiancheng
R.string.zodiac_scorpio -> R.mipmap.tianxie
R.string.zodiac_sagittarius -> R.mipmap.sheshou
R.string.zodiac_capricorn -> R.mipmap.moxie
R.string.zodiac_aquarius -> R.mipmap.shuiping
R.string.zodiac_pisces -> R.mipmap.shuangyu
else -> R.mipmap.xingzuo // 默认使用占位图片
}
}
Column( /**
modifier = Modifier * 根据存储的星座字符串可能是任何语言找到对应的资源ID
.fillMaxSize() * 如果找不到返回null
.background(appColors.profileBackground) */
@Composable
fun findZodiacResId(storedZodiac: String?): Int? {
if (storedZodiac.isNullOrEmpty()) return null
// 尝试在所有语言的资源中查找匹配
ZODIAC_SIGN_RES_IDS.forEachIndexed { index, resId ->
val zodiacText = stringResource(resId)
if (zodiacText == storedZodiac) {
return resId
}
}
// 如果找不到精确匹配尝试通过资源ID索引查找兼容旧数据
// 这里可以根据需要添加更多兼容逻辑
return null
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ZodiacSelectBottomSheet(
onClose: () -> Unit
) {
val appColors = LocalAppTheme.current
val isDarkMode = AppState.darkMode
val model = AccountEditViewModel
val currentZodiacResId = findZodiacResId(model.zodiac)
val sheetBackgroundColor = if (isDarkMode) {
appColors.secondaryBackground
} else {
Color(0xFFFFFFFF)
}
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// 确保弹窗展开
LaunchedEffect(Unit) {
sheetState.expand()
}
// 监听状态变化,确保弹窗始终展开(防止拖拽关闭和滑动)
LaunchedEffect(sheetState.currentValue, sheetState.targetValue, sheetState.isVisible) {
// 如果弹窗被拖拽关闭或位置发生变化,立即重新展开
if (!sheetState.isVisible || sheetState.targetValue != androidx.compose.material3.SheetValue.Expanded) {
kotlinx.coroutines.delay(10) // 短暂延迟确保状态更新
sheetState.expand()
}
}
val statusBarPadding = WindowInsets.systemBars.asPaddingValues()
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
val offsetY = screenHeight * 0.07f - statusBarPadding.calculateTopPadding()
ModalBottomSheet(
onDismissRequest = onClose,
sheetState = sheetState,
containerColor = sheetBackgroundColor, // 根据主题自适应背景
dragHandle = null
) { ) {
// 头部
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 16.dp) .fillMaxHeight(0.95f)
) { .offset(y = offsetY)
NoticeScreenHeader( .padding(
title = stringResource(R.string.choose_zodiac), start = 16.dp,
moreIcon = false end = 16.dp,
) bottom = 8.dp
}
// 列表
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
items(ZODIAC_SIGNS) { zodiac ->
ZodiacItem(
zodiac = zodiac,
isSelected = zodiac == currentZodiac,
onClick = {
model.zodiac = zodiac
navController.navigateUp()
}
) )
Spacer(modifier = Modifier.height(8.dp)) ) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
// 头部 - 使用 Box 实现绝对居中布局
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
contentAlignment = Alignment.Center
) {
val cancelButtonGradientColors = if (isDarkMode) {
listOf(
Color(0xFF3A3A3C),
Color(0xFF2C2C2E)
)
} else {
listOf(
Color(0xFFFFFFFF),
Color(0xFFF8F8F8)
)
}
val cancelButtonContentColor = if (isDarkMode) Color(0xFFFFFFFF) else Color(0xFF404040)
// 左上角返回按钮 - 根据 Swift 代码样式,带淡灰色渐变背景
Row(
modifier = Modifier
.align(Alignment.CenterStart)
.height(36.dp)
.clip(RoundedCornerShape(18.dp)) // 圆角 100.0 在 36dp 高度下接近完全圆角
.background(
brush = Brush.linearGradient(
colors = cancelButtonGradientColors
// 不指定 start 和 end默认从左上到右下
)
)
.noRippleClickable { onClose() }
.padding(horizontal = 8.dp), // 内部 padding 确保内容不贴边
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
// 左箭头图标
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = null,
modifier = Modifier.size(17.dp),
colorFilter = ColorFilter.tint(cancelButtonContentColor)
)
// "取消" 文字
Text(
text = "取消",
fontSize = 17.sp,
fontWeight = FontWeight.Medium,
color = cancelButtonContentColor,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
// 中间标题 - 绝对居中
Text(
text = stringResource(R.string.choose_zodiac),
color = appColors.text,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
Spacer(Modifier.height(12.dp))
// 创建 NestedScrollConnection 来阻止滚动事件向上传播到 ModalBottomSheet
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// 不消费任何事件,让 LazyVerticalGrid 先处理
return Offset.Zero
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
// 消费 LazyVerticalGrid 处理后的剩余滚动事件,防止传递到 ModalBottomSheet
return available
}
override suspend fun onPreFling(available: Velocity): Velocity {
// 不消费惯性滚动,让 LazyVerticalGrid 先处理
return Velocity.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
// 消费 LazyVerticalGrid 处理后的剩余惯性滚动,防止传递到 ModalBottomSheet
return available
}
}
}
// 网格列表 - 2列
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.nestedScroll(nestedScrollConnection),
contentPadding = PaddingValues(
start = 8.dp,
top = 8.dp,
end = 8.dp,
bottom = 8.dp
),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
itemsIndexed(ZODIAC_SIGN_RES_IDS) { index, zodiacResId ->
val zodiacText = stringResource(zodiacResId)
ZodiacItem(
zodiac = zodiacText,
zodiacResId = zodiacResId,
isSelected = zodiacResId == currentZodiacResId,
onClick = {
// 保存当前语言的星座文本
model.zodiac = zodiacText
// 立即保存到本地存储,确保选择后立即生效
AppState.UserId?.let { uid ->
com.aiosman.ravenow.AppStore.setUserZodiac(uid, zodiacText)
}
onClose()
}
)
}
}
} }
} }
} }
} }
// 保留原有的 ZodiacSelectScreen 用于导航路由(如果需要)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ZodiacSelectScreen() {
val navController = com.aiosman.ravenow.LocalNavController.current
ZodiacSelectBottomSheet(
onClose = {
navController.navigateUp()
}
)
}
@Composable @Composable
fun ZodiacItem( fun ZodiacItem(
zodiac: String, zodiac: String,
zodiacResId: Int,
isSelected: Boolean, isSelected: Boolean,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val appColors = LocalAppTheme.current val appColors = LocalAppTheme.current
val isDarkMode = AppState.darkMode
Box( // 卡片背景色:浅灰色 (250, 249, 251)
// 暗色模式下使用比背景色更亮的颜色,以形成对比
val cardBackgroundColor = if (isDarkMode) {
Color(0xFF2A2A2A) // 比 secondaryBackground (0xFF1C1C1C) 更亮的灰色
} else {
Color(0xFFFAF9FB)
}
Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(16.dp)) .aspectRatio(1.1f) // 增加宽高比,使高度相对更低
.background(if (isSelected) appColors.main.copy(alpha = 0.1f) else Color.White) .shadow(
elevation = if (isDarkMode) 8.dp else 2.dp, // 深色模式下更强的阴影
shape = RoundedCornerShape(21.dp),
spotColor = if (isDarkMode) Color.Black.copy(alpha = 0.5f) else Color.Black.copy(alpha = 0.1f)
)
.clip(RoundedCornerShape(21.dp))
.background(cardBackgroundColor)
.clickable( .clickable(
indication = null, indication = null,
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
) { ) {
onClick() onClick()
} }
.padding(16.dp) .padding(horizontal = 24.dp, vertical = 12.dp), // 减小垂直padding确保文本不被遮挡
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) { ) {
Row( // 星座图标 - 使用对应星座的图片
modifier = Modifier.fillMaxWidth(), Box(
verticalAlignment = Alignment.CenterVertically modifier = Modifier.size(100.dp),
contentAlignment = Alignment.Center
) { ) {
Text( Image(
text = zodiac, painter = painterResource(id = getZodiacImageResId(zodiacResId)),
fontSize = 17.sp, contentDescription = zodiac,
fontWeight = FontWeight.Normal, modifier = Modifier.size(100.dp)
color = if (isSelected) appColors.main else appColors.text,
modifier = Modifier.weight(1f)
) )
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
modifier = Modifier.size(20.dp),
tint = appColors.main
)
}
} }
// 星座名称 - 使用负间距让文本向上移动,与图标更靠近
Text(
text = zodiac,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = appColors.text,
textAlign = TextAlign.Center,
modifier = Modifier.offset(y = (-20).dp) // 负间距,让文本进一步向上移动
)
} }
} }

View File

@@ -0,0 +1,19 @@
package com.aiosman.ravenow.ui.account
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object ZodiacSheetManager {
private val _visible = MutableStateFlow(false)
val visible: StateFlow<Boolean> = _visible.asStateFlow()
fun open() {
_visible.value = true
}
fun close() {
_visible.value = false
}
}

View File

@@ -72,7 +72,11 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import com.aiosman.ravenow.ConstVars import com.aiosman.ravenow.ConstVars
import com.aiosman.ravenow.ui.composables.pickupAndCompressLauncher import com.aiosman.ravenow.ui.composables.pickupAndCompressLauncher
import android.widget.Toast
import java.io.File import java.io.File
import androidx.activity.compose.BackHandler
import com.aiosman.ravenow.ui.account.ZodiacBottomSheetHost
import com.aiosman.ravenow.ui.account.ZodiacSheetManager
/** /**
* 编辑用户资料界面 * 编辑用户资料界面
@@ -97,6 +101,10 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
quality = 100 quality = 100
) { uri, file -> ) { uri, file ->
// 处理选中的图片 // 处理选中的图片
// 保存到 ViewModel 中,等待保存时一起上传
model.bannerImageUrl = uri
model.bannerFile = file
// 如果提供了回调,也调用它(用于个人主页直接更新)
onUpdateBanner?.invoke(uri, file, context) onUpdateBanner?.invoke(uri, file, context)
} }
@@ -104,10 +112,21 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
// 去除换行符,确保昵称不包含换行 // 去除换行符,确保昵称不包含换行
val cleanValue = value.replace("\n", "").replace("\r", "") val cleanValue = value.replace("\n", "").replace("\r", "")
model.name = cleanValue model.name = cleanValue
// 实时验证,但不显示错误(只在保存时显示)
usernameError = when { usernameError = when {
cleanValue.trim().isEmpty() -> "昵称不能为空" cleanValue.trim().isEmpty() -> context.getString(R.string.error_nickname_empty)
cleanValue.length < 3 -> "昵称长度不能小于3" cleanValue.length < 3 -> context.getString(R.string.error_nickname_too_short)
cleanValue.length > 20 -> "昵称长度不能大于20" cleanValue.length > 20 -> context.getString(R.string.error_nickname_too_long)
else -> null
}
}
fun validateNickname(): String? {
val cleanValue = model.name.replace("\n", "").replace("\r", "")
return when {
cleanValue.trim().isEmpty() -> context.getString(R.string.error_nickname_empty)
cleanValue.length < 3 -> context.getString(R.string.error_nickname_too_short)
cleanValue.length > 20 -> context.getString(R.string.error_nickname_too_long)
else -> null else -> null
} }
} }
@@ -118,8 +137,17 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
// 去除换行符,确保个人简介不包含换行 // 去除换行符,确保个人简介不包含换行
val cleanValue = value.replace("\n", "").replace("\r", "") val cleanValue = value.replace("\n", "").replace("\r", "")
model.bio = cleanValue model.bio = cleanValue
// 实时验证,但不显示错误(只在保存时显示)
bioError = when { bioError = when {
cleanValue.length > 100 -> "个人简介长度不能大于100" cleanValue.length > 100 -> context.getString(R.string.error_bio_too_long)
else -> null
}
}
fun validateBio(): String? {
val cleanValue = model.bio.replace("\n", "").replace("\r", "")
return when {
cleanValue.length > 100 -> context.getString(R.string.error_bio_too_long)
else -> null else -> null
} }
} }
@@ -146,21 +174,31 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
model.reloadProfile() model.reloadProfile()
} }
// 设置状态栏为透明,使用浅色图标(因为顶部背景是深色图片) // 处理系统返回键
BackHandler {
// 用户未保存直接返回,恢复所有字段到原始值
model.resetToOriginalData()
navController.navigateUp()
}
// 设置状态栏为透明,根据暗色模式决定图标颜色
val systemUiController = rememberSystemUiController() val systemUiController = rememberSystemUiController()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = false) systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode)
} }
StatusBarMaskLayout( StatusBarMaskLayout(
modifier = Modifier.background(Color(0xFFFAF9FB)), modifier = Modifier.background(appColors.background),
darkIcons = false, // 浅色图标(白色),因为顶部背景是深 darkIcons = !AppState.darkMode, // 根据暗色模式决定图标颜
maskBoxBackgroundColor = Color.Transparent maskBoxBackgroundColor = Color.Transparent
) { ) {
// 挂载星座选择弹窗
ZodiacBottomSheetHost()
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color(0xFFFAF9FB)) .background(appColors.background)
) { ) {
when { when {
model.isLoading -> { model.isLoading -> {
@@ -179,7 +217,8 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
// 顶部背景区域(圆角在底部,覆盖状态栏) // 顶部背景区域(圆角在底部,覆盖状态栏)
val banner = model.profile?.banner // 优先显示新选择的背景图,如果没有则显示原有的背景图
val banner = model.bannerImageUrl?.toString() ?: model.profile?.banner
val statusBarPadding = WindowInsets.systemBars.asPaddingValues().calculateTopPadding() val statusBarPadding = WindowInsets.systemBars.asPaddingValues().calculateTopPadding()
Box( Box(
modifier = Modifier modifier = Modifier
@@ -230,21 +269,13 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
) { ) {
// 更换封面图标 // 更换封面图标
Icon( Icon(
painter = painterResource( painter = painterResource(id = R.mipmap.fengm),
id = if (AppState.darkMode) {
// TODO: 添加更换封面暗色模式图标
R.mipmap.frame_4 // 临时占位,需替换为实际图标
} else {
// TODO: 添加更换封面亮色模式图标
R.mipmap.fengm // 临时占位,需替换为实际图标
}
),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = Color.White tint = Color.White
) )
Text( Text(
text = "更换封面", text = stringResource(R.string.change_cover),
fontSize = 12.sp, fontSize = 12.sp,
color = Color.White color = Color.White
) )
@@ -273,6 +304,8 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
.clip(CircleShape) .clip(CircleShape)
.background(Color.White.copy(alpha = 0.3f)) .background(Color.White.copy(alpha = 0.3f))
.noRippleClickable { .noRippleClickable {
// 用户未保存直接返回,恢复所有字段到原始值
model.resetToOriginalData()
navController.navigateUp() navController.navigateUp()
} }
.align(Alignment.CenterStart), .align(Alignment.CenterStart),
@@ -288,7 +321,7 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
// 标题 // 标题
Text( Text(
text = "编辑资料", text = stringResource(R.string.edit_profile_info),
fontSize = 17.sp, fontSize = 17.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = Color.White, color = Color.White,
@@ -318,7 +351,7 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
modifier = Modifier modifier = Modifier
.size(96.dp) .size(96.dp)
.clip(CircleShape) .clip(CircleShape)
.border(2.4.dp, Color(0xFFFAF9FB), CircleShape), .border(2.4.dp, appColors.background, CircleShape),
contentDescription = "", contentDescription = "",
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
@@ -338,15 +371,7 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
painter = painterResource( painter = painterResource(id = R.mipmap.bi),
id = if (AppState.darkMode) {
// TODO: 添加编辑头像暗色模式图标
R.mipmap.frame_4 // 临时占位,需替换为实际图标
} else {
// TODO: 添加编辑头像亮色模式图标
R.mipmap.bi // 临时占位,需替换为实际图标
}
),
contentDescription = "Edit Avatar", contentDescription = "Edit Avatar",
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = Color.White tint = Color.White
@@ -365,7 +390,7 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
) { ) {
// 昵称输入框 // 昵称输入框
ProfileInfoCard( ProfileInfoCard(
label = "昵称", label = stringResource(R.string.nickname),
value = model.name, value = model.name,
placeholder = "Value", placeholder = "Value",
onValueChange = { onNicknameChange(it) }, onValueChange = { onNicknameChange(it) },
@@ -376,7 +401,7 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
// 个人简介输入框 // 个人简介输入框
ProfileInfoCard( ProfileInfoCard(
label = "个人简介", label = stringResource(R.string.personal_intro),
value = model.bio, value = model.bio,
placeholder = "Welcome to my fantiac word i will show you something about magic", placeholder = "Welcome to my fantiac word i will show you something about magic",
onValueChange = { onBioChange(it) }, onValueChange = { onBioChange(it) },
@@ -390,11 +415,11 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(Color.White) .background(appColors.secondaryBackground)
) { ) {
// MBTI 类型 // MBTI 类型
ProfileSelectItem( ProfileSelectItem(
label = "MBTI 类型", label = stringResource(R.string.mbti_type),
value = model.mbti ?: "ENFP", value = model.mbti ?: "ENFP",
iconColor = Color(0xFF7C45ED), iconColor = Color(0xFF7C45ED),
iconResDark = null, // TODO: 添加MBTI暗色模式图标 iconResDark = null, // TODO: 添加MBTI暗色模式图标
@@ -411,21 +436,24 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(0.3.dp) .height(0.3.dp)
.background(Color(0x41413C43).copy(alpha = 0.2f)) .background(appColors.divider)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) )
// 星座(使用当前图标) // 星座(使用当前图标)
ProfileSelectItem( ProfileSelectItem(
label = "星座", label = stringResource(R.string.zodiac),
value = model.zodiac ?: "白羊座", value = model.zodiac?.let { storedZodiac ->
// 尝试找到对应的资源ID并显示当前语言的文本
findZodiacResId(storedZodiac)?.let { resId ->
stringResource(resId)
} ?: storedZodiac // 如果找不到,显示原始存储的值
} ?: stringResource(R.string.zodiac_aries),
iconColor = Color(0xFFFFCC00), iconColor = Color(0xFFFFCC00),
iconResDark = R.mipmap.frame_4, // 星座暗色模式图标 iconResDark = R.mipmap.frame_4, // 星座暗色模式图标
iconResLight = R.mipmap.xingzuo, // 星座亮色模式图标 iconResLight = R.mipmap.xingzuo, // 星座亮色模式图标
onClick = { onClick = {
debouncedNavigation { ZodiacSheetManager.open()
navController.navigate(NavigationRoute.ZodiacSelect.route)
}
} }
) )
} }
@@ -448,26 +476,43 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
) )
) )
.debouncedClickable( .debouncedClickable(
enabled = validate() && !model.isUpdating, enabled = !model.isUpdating,
debounceTime = 1000L debounceTime = 1000L
) { ) {
if (validate() && !model.isUpdating) { if (model.isUpdating) return@debouncedClickable
model.viewModelScope.launch {
model.isUpdating = true // 点击保存时重新验证
model.updateUserProfile(context) val nicknameErrorMsg = validateNickname()
model.viewModelScope.launch(Dispatchers.Main) { val bioErrorMsg = validateBio()
debouncedNavigation {
navController.navigateUp() // 如果有错误,显示对应的错误提示
} when {
model.isUpdating = false nicknameErrorMsg != null -> {
Toast.makeText(context, nicknameErrorMsg, Toast.LENGTH_SHORT).show()
return@debouncedClickable
}
bioErrorMsg != null -> {
Toast.makeText(context, bioErrorMsg, Toast.LENGTH_SHORT).show()
return@debouncedClickable
}
}
// 验证通过,执行保存
model.viewModelScope.launch {
model.isUpdating = true
model.updateUserProfile(context)
model.viewModelScope.launch(Dispatchers.Main) {
debouncedNavigation {
navController.navigateUp()
} }
model.isUpdating = false
} }
} }
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "保存", text = stringResource(R.string.save),
fontSize = 17.sp, fontSize = 17.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = Color.White color = Color.White
@@ -483,7 +528,7 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "加载用户资料失败,请重试", text = stringResource(R.string.error_load_profile_failed),
color = appColors.text color = appColors.text
) )
} }
@@ -505,13 +550,12 @@ fun ProfileInfoCard(
isMultiline: Boolean = false isMultiline: Boolean = false
) { ) {
val appColors = LocalAppTheme.current val appColors = LocalAppTheme.current
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(if (isMultiline) 66.dp else 56.dp) // 昵称框高度56dp个人简介66dp .height(if (isMultiline) 66.dp else 56.dp) // 昵称框高度56dp个人简介66dp
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(Color.White), .background(appColors.secondaryBackground),
contentAlignment = if (isMultiline) Alignment.TopStart else Alignment.CenterStart contentAlignment = if (isMultiline) Alignment.TopStart else Alignment.CenterStart
) { ) {
Row( Row(
@@ -526,7 +570,7 @@ fun ProfileInfoCard(
text = label, text = label,
fontSize = 17.sp, fontSize = 17.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = Color.Black, color = appColors.text,
modifier = Modifier.width(100.dp) modifier = Modifier.width(100.dp)
) )
@@ -541,7 +585,7 @@ fun ProfileInfoCard(
text = placeholder, text = placeholder,
fontSize = if (isMultiline) 15.sp else 17.sp, fontSize = if (isMultiline) 15.sp else 17.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = Color(0x993C3C43), color = appColors.secondaryText,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
@@ -553,9 +597,9 @@ fun ProfileInfoCard(
textStyle = androidx.compose.ui.text.TextStyle( textStyle = androidx.compose.ui.text.TextStyle(
fontSize = if (isMultiline) 15.sp else 17.sp, fontSize = if (isMultiline) 15.sp else 17.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = Color.Black color = appColors.text
), ),
cursorBrush = SolidColor(Color.Black), cursorBrush = SolidColor(appColors.text),
maxLines = if (isMultiline) Int.MAX_VALUE else 1, maxLines = if (isMultiline) Int.MAX_VALUE else 1,
singleLine = !isMultiline singleLine = !isMultiline
) )
@@ -576,6 +620,7 @@ fun ProfileSelectItem(
iconResDark: Int? = null, iconResDark: Int? = null,
iconResLight: Int? = null iconResLight: Int? = null
) { ) {
val appColors = LocalAppTheme.current
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -593,7 +638,8 @@ fun ProfileSelectItem(
Icon( Icon(
painter = painterResource( painter = painterResource(
id = if (AppState.darkMode) { id = if (AppState.darkMode) {
iconResDark ?: R.mipmap.frame_4 // 使用传入的暗色模式图标,或默认占位 // 暗色模式下使用和亮色模式一样的图标
iconResLight ?: iconResDark ?: R.mipmap.naoz
} else { } else {
iconResLight ?: R.mipmap.naoz // 使用传入的亮色模式图标,或默认占位 iconResLight ?: R.mipmap.naoz // 使用传入的亮色模式图标,或默认占位
} }
@@ -607,7 +653,7 @@ fun ProfileSelectItem(
text = label, text = label,
fontSize = 17.sp, fontSize = 17.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = Color.Black color = appColors.text
) )
} }
@@ -619,14 +665,14 @@ fun ProfileSelectItem(
text = value, text = value,
fontSize = 17.sp, fontSize = 17.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = Color(0x993C3C43) color = appColors.secondaryText
) )
Icon( Icon(
imageVector = Icons.Default.ArrowForward, imageVector = Icons.Default.ArrowForward,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(8.dp), modifier = Modifier.size(8.dp),
tint = Color(0x4D3C3C43) tint = appColors.secondaryText
) )
} }
} }

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -34,8 +35,11 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -565,10 +569,27 @@ fun AddAgentScreen() {
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.align(Alignment.Start) .align(Alignment.Start)
) { ) {
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
val autoLabel = stringResource(R.string.create_agent_auto)
val measuredTextWidth = remember(autoLabel, textMeasurer) {
textMeasurer.measure(
text = AnnotatedString(autoLabel),
style = TextStyle(
color = appColors.text,
fontWeight = FontWeight.W600,
fontSize = 14.sp
)
).size.width
}
val textWidthDp = with(density) { measuredTextWidth.toDp() }
val contentWidth = 24.dp + 18.dp + 8.dp + textWidthDp
val boxWidth = if (contentWidth > 140.dp) 250.dp else 140.dp
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.Start) .align(Alignment.Start)
.width(140.dp) .width(boxWidth)
.height(40.dp) .height(40.dp)
.shadow( .shadow(
elevation = 10.dp, elevation = 10.dp,
@@ -616,7 +637,7 @@ fun AddAgentScreen() {
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = stringResource(R.string.create_agent_auto), text = autoLabel,
color = appColors.text, color = appColors.text,
fontWeight = FontWeight.W600, fontWeight = FontWeight.W600,
fontSize = 14.sp fontSize = 14.sp

View File

@@ -59,6 +59,7 @@ import java.io.InputStream
/** /**
* 专门用于智能体头像裁剪的页面 * 专门用于智能体头像裁剪的页面
* 支持创建和编辑两种模式
*/ */
@Composable @Composable
fun AgentImageCropScreen() { fun AgentImageCropScreen() {
@@ -71,6 +72,14 @@ fun AgentImageCropScreen() {
val density = LocalDensity.current val density = LocalDensity.current
val navController = LocalNavController.current val navController = LocalNavController.current
// 检查是否在编辑模式通过检查是否有编辑ViewModel的实例
val isEditMode = remember {
// 通过检查导航栈或使用其他方式判断
// 暂时使用一个简单的方法检查AddAgentViewModel是否正在选择头像
// 如果不是,则可能是编辑模式
!AddAgentViewModel.isSelectingAvatar
}
val imagePickLauncher = rememberLauncherForActivityResult( val imagePickLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent() contract = ActivityResultContracts.GetContent()
) { uri: Uri? -> ) { uri: Uri? ->
@@ -84,7 +93,9 @@ fun AgentImageCropScreen() {
} }
if (uri == null) { if (uri == null) {
// 用户取消选择图片,重置标志 // 用户取消选择图片,重置标志
AddAgentViewModel.isSelectingAvatar = false if (!isEditMode) {
AddAgentViewModel.isSelectingAvatar = false
}
navController.popBackStack() navController.popBackStack()
} }
} }
@@ -122,7 +133,9 @@ fun AgentImageCropScreen() {
contentDescription = null, contentDescription = null,
modifier = Modifier.clickable { modifier = Modifier.clickable {
// 用户取消头像选择,重置标志 // 用户取消头像选择,重置标志
AddAgentViewModel.isSelectingAvatar = false if (!isEditMode) {
AddAgentViewModel.isSelectingAvatar = false
}
navController.popBackStack() navController.popBackStack()
}, },
colorFilter = ColorFilter.tint(Color.White) colorFilter = ColorFilter.tint(Color.White)
@@ -137,13 +150,21 @@ fun AgentImageCropScreen() {
modifier = Modifier.clickable { modifier = Modifier.clickable {
if (croppedBitmap != null) { if (croppedBitmap != null) {
// 如果已经有裁剪结果,直接返回 // 如果已经有裁剪结果,直接返回
AddAgentViewModel.croppedBitmap = croppedBitmap if (isEditMode) {
// 重置头像选择标志 // 编辑模式需要找到当前的编辑ViewModel实例
AddAgentViewModel.isSelectingAvatar = false // 由于无法直接访问,我们使用一个全局状态或者通过其他方式传递
AddAgentViewModel.viewModelScope.launch { // 暂时先保存到AddAgentViewModel,编辑页面会检查
AddAgentViewModel.updateAgentAvatar(context) AddAgentViewModel.croppedBitmap = croppedBitmap
navController.popBackStack() } else {
AddAgentViewModel.croppedBitmap = croppedBitmap
// 重置头像选择标志
AddAgentViewModel.isSelectingAvatar = false
AddAgentViewModel.viewModelScope.launch {
AddAgentViewModel.updateAgentAvatar(context)
navController.popBackStack()
}
} }
navController.popBackStack()
} else { } else {
// 进行裁剪 // 进行裁剪
imageCrop?.let { imageCrop?.let {

View File

@@ -0,0 +1,483 @@
package com.aiosman.ravenow.ui.agent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import com.aiosman.ravenow.LocalNavController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.composables.ActionButton
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.PointsPaymentDialog
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.form.FormTextInput
import com.aiosman.ravenow.ui.composables.form.FormTextInput2
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.data.PointService
import androidx.compose.runtime.collectAsState
import kotlinx.coroutines.launch
/**
* AI Prompt 编辑页面
*/
@Composable
fun AiPromptEditScreen(
chatAIId: String,
viewModel: AiPromptEditViewModel = viewModel()
) {
val context = LocalContext.current
val navController = LocalNavController.current
val appColors = LocalAppTheme.current
// 加载Prompt详情
LaunchedEffect(chatAIId) {
viewModel.loadPromptDetail(chatAIId)
}
// 监听头像裁剪结果从AgentImageCropScreen返回
LaunchedEffect(viewModel.isSelectingAvatar) {
if (!viewModel.isSelectingAvatar && AddAgentViewModel.croppedBitmap != null) {
// 从裁剪页面返回,检查是否有新的裁剪结果
viewModel.croppedBitmap = AddAgentViewModel.croppedBitmap
// 清空AddAgentViewModel的裁剪结果避免影响创建页面
AddAgentViewModel.croppedBitmap = null
}
}
// 状态
var showPrivacyConfirmDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
// 获取积分规则和余额
val pointsRules by PointService.pointsRules.collectAsState(initial = null)
val pointsBalance by PointService.pointsBalance.collectAsState(initial = null)
// 计算是否需要付费
val needsPayment = viewModel.needsPrivacyPayment()
val privacyCost = viewModel.getPrivacyCost()
val currentBalance = viewModel.getCurrentBalance()
val balanceAfterCost = viewModel.calculateBalanceAfterCost(privacyCost)
val isBalanceSufficient = viewModel.isBalanceSufficient(privacyCost)
Column(
modifier = Modifier
.fillMaxSize()
.background(color = Color(0xFFFAFAFB)),
horizontalAlignment = Alignment.CenterHorizontally
) {
StatusBarSpacer()
// 顶部导航栏
Box(
modifier = Modifier
.fillMaxWidth()
.background(color = Color(0xFFFAFAFB))
.padding(horizontal = 14.dp, vertical = 16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = "返回",
modifier = Modifier
.size(24.dp)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
navController.navigateUp()
},
colorFilter = ColorFilter.tint(appColors.text)
)
Spacer(modifier = Modifier.size(12.dp))
Text(
"编辑Ai",
fontWeight = FontWeight.W600,
modifier = Modifier.weight(1f),
fontSize = 17.sp,
color = appColors.text
)
}
}
Spacer(modifier = Modifier.height(1.dp))
// 内容区域
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xFFFAFAFB))
) {
// 头像选择
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 18.dp),
horizontalAlignment = Alignment.Start
) {
Text(
text = stringResource(R.string.avatar),
fontSize = 12.sp,
color = appColors.text,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(4.dp))
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(
brush = Brush.linearGradient(
colors = listOf(
Color(0x777c45ed),
Color(0x777c68ef),
Color(0x557bd8f8)
)
)
)
.noRippleClickable {
viewModel.isSelectingAvatar = true
// 标记为编辑模式
AddAgentViewModel.isSelectingAvatar = false
navController.navigate(NavigationRoute.AgentImageCrop.route)
},
contentAlignment = Alignment.Center
) {
when {
viewModel.croppedBitmap != null -> {
Image(
bitmap = viewModel.croppedBitmap!!.asImageBitmap(),
contentDescription = "Avatar",
modifier = Modifier
.size(72.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
}
viewModel.avatarUrl != null -> {
CustomAsyncImage(
context = context,
imageUrl = viewModel.avatarUrl!!,
contentDescription = "Avatar",
modifier = Modifier
.size(72.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
}
else -> {
Image(
painter = painterResource(id = R.mipmap.icons_infor_edit),
contentDescription = "Edit",
colorFilter = ColorFilter.tint(Color.White),
modifier = Modifier.size(20.dp),
)
}
}
}
}
Spacer(modifier = Modifier.height(18.dp))
// 名称输入
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.agent_name),
fontSize = 12.sp,
color = appColors.text,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(4.dp))
FormTextInput(
value = viewModel.title,
hint = stringResource(R.string.agent_name_hint_1),
background = Color.White,
modifier = Modifier.fillMaxWidth(),
) { value ->
viewModel.title = value
}
}
Spacer(modifier = Modifier.height(18.dp))
// 描述输入
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.agent_desc),
fontSize = 12.sp,
color = appColors.text,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(4.dp))
FormTextInput2(
value = viewModel.desc,
hint = stringResource(R.string.agent_desc_hint),
background = Color.White,
modifier = Modifier.fillMaxWidth(),
) { value ->
viewModel.desc = value
}
}
Spacer(modifier = Modifier.height(18.dp))
// 设定权限区域
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Text(
text = "设定权限",
fontSize = 12.sp,
color = appColors.text,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(8.dp))
// 公开/私有切换
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(25.dp))
.background(Color.White)
.border(
width = 1.dp,
color = Color(red = 124f / 255f, green = 116f / 255f, blue = 128f / 255f, alpha = 0.08f),
shape = RoundedCornerShape(25.dp)
)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = if (viewModel.isPublic) "公开" else "私有",
fontSize = 14.sp,
color = appColors.text,
fontWeight = FontWeight.W500
)
Switch(
checked = viewModel.isPublic,
onCheckedChange = { checked ->
if (!checked && needsPayment && !viewModel.paidForPrivacyEdit) {
// 需要付费,显示确认对话框
showPrivacyConfirmDialog = true
} else {
viewModel.isPublic = checked
}
},
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = appColors.brandColorsColor,
uncheckedThumbColor = Color.White,
uncheckedTrackColor = appColors.brandColorsColor.copy(alpha = 0.5f),
uncheckedBorderColor = Color.Transparent
),
modifier = Modifier.size(width = 64.dp, height = 28.dp)
)
}
// 首次解锁AI权限提示
if (needsPayment && !viewModel.paidForPrivacyEdit && privacyCost > 0) {
Spacer(modifier = Modifier.height(8.dp))
// 主要内容容器(去掉阴影)
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(
color = Color(red = 251f / 255f, green = 248f / 255f, blue = 239f / 255f)
)
.border(
width = 1.dp,
color = Color(red = 243f / 255f, green = 234f / 255f, blue = 206f / 255f),
shape = RoundedCornerShape(16.dp)
)
.padding(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// 锁图标容器
Box(
modifier = Modifier
.size(32.dp)
.background(
color = Color(red = 1f, green = 204f / 255f, blue = 0f, alpha = 0.12f),
shape = RoundedCornerShape(10.7.dp)
),
contentAlignment = Alignment.Center
) {
// 锁图标(使用文本代替,实际项目中可以使用图片资源)
Text(
text = "🔒",
fontSize = 18.sp
)
}
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "首次解锁Ai权限",
fontSize = 13.sp,
color = Color(red = 172f / 255f, green = 127f / 255f, blue = 94f / 255f),
fontWeight = FontWeight.W500
)
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "将消耗",
fontSize = 12.sp,
color = Color(red = 172f / 255f, green = 127f / 255f, blue = 94f / 255f)
)
Text(
text = "$privacyCost",
fontSize = 12.sp,
color = Color(red = 1f, green = 141f / 255f, blue = 40f / 255f)
)
// 小硬币图标
Box(
modifier = Modifier
.size(16.dp)
.background(
brush = Brush.linearGradient(
colors = listOf(
Color(0xFFFFD700),
Color(0xFFFFA500)
)
),
shape = CircleShape
)
)
Text(
text = "解锁后可随时切换",
fontSize = 12.sp,
color = Color(red = 172f / 255f, green = 127f / 255f, blue = 94f / 255f)
)
}
}
}
}
}
}
}
// 底部保存按钮
Box(
modifier = Modifier
.fillMaxWidth()
.background(color = Color(0xFFFAFAFB))
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
ActionButton(
text = "保存",
enabled = !viewModel.isUpdating && !viewModel.isLoading,
isLoading = viewModel.isUpdating,
modifier = Modifier.fillMaxWidth()
) {
// 验证输入
val validationError = viewModel.validate()
if (validationError != null) {
errorMessage = validationError
return@ActionButton
}
// 检查是否需要付费确认
if (needsPayment && !viewModel.paidForPrivacyEdit) {
showPrivacyConfirmDialog = true
return@ActionButton
}
// 执行保存
scope.launch {
try {
viewModel.updatePrompt(context)
navController.navigateUp()
} catch (e: Exception) {
errorMessage = e.message ?: "保存失败"
}
}
}
}
}
// 隐私权限付费确认对话框
if (showPrivacyConfirmDialog) {
PointsPaymentDialog(
cost = privacyCost,
currentBalance = currentBalance,
balanceAfterCost = balanceAfterCost,
isBalanceSufficient = isBalanceSufficient,
onConfirm = {
showPrivacyConfirmDialog = false
scope.launch {
try {
viewModel.isPublic = false
viewModel.updatePrompt(context)
viewModel.paidForPrivacyEdit = true
navController.navigateUp()
} catch (e: Exception) {
errorMessage = e.message ?: "保存失败"
}
}
},
onCancel = {
showPrivacyConfirmDialog = false
},
title = "首次解锁AI权限",
description = "将消耗 $privacyCost 派币解锁后可随时切换"
)
}
// 错误提示
errorMessage?.let { error ->
LaunchedEffect(error) {
kotlinx.coroutines.delay(3000)
errorMessage = null
}
// TODO: 显示Toast或Snackbar
}
}

View File

@@ -0,0 +1,208 @@
package com.aiosman.ravenow.ui.agent
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.data.UploadImage
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.data.PointService
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.entity.AgentEntity
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
class AiPromptEditViewModel : ViewModel() {
var chatAIId by mutableStateOf("")
var title by mutableStateOf("")
var desc by mutableStateOf("")
var isPublic by mutableStateOf(true)
var originalIsPublic by mutableStateOf(true)
var paidForPrivacyEdit by mutableStateOf(false)
var avatarUrl by mutableStateOf<String?>(null)
var croppedBitmap by mutableStateOf<Bitmap?>(null)
var isUpdating by mutableStateOf(false)
var isLoading by mutableStateOf(false)
var errorMessage by mutableStateOf<String?>(null)
var isSelectingAvatar by mutableStateOf(false)
/**
* 加载Prompt详情
*/
fun loadPromptDetail(chatAIId: String) {
viewModelScope.launch {
try {
isLoading = true
errorMessage = null
this@AiPromptEditViewModel.chatAIId = chatAIId
val response = ApiClient.api.getPromptDetail(chatAIId)
val body = response.body()?.data ?: throw ServiceException("Failed to get prompt detail")
// 填充数据
title = body.title
desc = body.desc
isPublic = body.isPublic
originalIsPublic = body.isPublic
avatarUrl = "${ApiClient.BASE_API_URL}/outside${body.avatar}?token=${com.aiosman.ravenow.AppStore.token}"
// 注意Agent数据模型可能没有paidForPrivacyEdit字段需要从其他地方获取
// 暂时设为false后续可以根据实际API响应调整
paidForPrivacyEdit = false
} catch (e: Exception) {
Log.e("AiPromptEditViewModel", "Error loading prompt detail", e)
errorMessage = "加载失败: ${e.message}"
} finally {
isLoading = false
}
}
}
/**
* 更新Prompt
*/
suspend fun updatePrompt(context: Context): AgentEntity? {
try {
isUpdating = true
errorMessage = null
// 准备头像文件
val avatarFile = if (croppedBitmap != null) {
val file = File(context.cacheDir, "agent_avatar_edit.jpg")
croppedBitmap!!.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream())
UploadImage(file, "agent_avatar_edit.jpg", "", "jpg")
} else {
null
}
// 准备请求参数
val textTitle = title.trim().toRequestBody("text/plain".toMediaTypeOrNull())
val textDesc = desc.trim().toRequestBody("text/plain".toMediaTypeOrNull())
val textValue = desc.trim().toRequestBody("text/plain".toMediaTypeOrNull()) // value通常和desc相同
val isPublicBody = isPublic.toString().toRequestBody("text/plain".toMediaTypeOrNull())
val avatarPart: MultipartBody.Part? = avatarFile?.let {
val requestFile = it.file.asRequestBody("image/*".toMediaTypeOrNull())
MultipartBody.Part.createFormData("avatar", it.filename, requestFile)
}
// 调用更新API
val response = ApiClient.api.updatePrompt(
promptId = chatAIId,
avatar = avatarPart,
title = textTitle,
desc = textDesc,
value = textValue,
isPublic = isPublicBody
)
val body = response.body()?.data ?: throw ServiceException("Failed to update prompt")
// 更新本地状态
originalIsPublic = isPublic
return body.toAgentEntity()
} catch (e: Exception) {
Log.e("AiPromptEditViewModel", "Error updating prompt", e)
errorMessage = "更新失败: ${e.message}"
throw e
} finally {
isUpdating = false
}
}
/**
* 验证输入
*/
fun validate(): String? {
return when {
title.trim().isEmpty() -> "智能体名称不能为空"
title.trim().length < 2 -> "智能体名称长度不能少于2个字符"
title.trim().length > 20 -> "智能体名称长度不能超过20个字符"
desc.trim().isEmpty() -> "智能体描述不能为空"
desc.trim().length > 512 -> "智能体描述长度不能超过512个字符"
else -> null
}
}
/**
* 判断是否需要付费解锁隐私切换
*/
fun needsPrivacyPayment(): Boolean {
// 如果已经解锁过,则不需要付费
if (paidForPrivacyEdit) {
return false
}
// 只有从公开true切换到私有false才需要付费
return originalIsPublic == true && isPublic == false
}
/**
* 获取解锁隐私权限的费用
* @return 费用金额,如果无法获取则返回 0
*/
fun getPrivacyCost(): Int {
val rules = PointService.pointsRules.value
val costRule = rules?.sub?.get(PointService.PointsRuleKey.SPEND_AGENT_PRIVATE)
return when (costRule) {
is PointService.RuleAmount.Fixed -> costRule.value
is PointService.RuleAmount.Range -> costRule.min // 使用最小值作为默认费用
null -> 0
}
}
/**
* 获取当前余额
* @return 当前余额,如果无法获取则返回 0
*/
fun getCurrentBalance(): Int {
return PointService.pointsBalance.value?.balance ?: 0
}
/**
* 计算消耗后余额
* @param cost 费用
* @return 消耗后余额
*/
fun calculateBalanceAfterCost(cost: Int): Int {
val currentBalance = getCurrentBalance()
return (currentBalance - cost).coerceAtLeast(0)
}
/**
* 检查余额是否充足
* @param cost 费用
* @return 是否充足
*/
fun isBalanceSufficient(cost: Int): Boolean {
return getCurrentBalance() >= cost
}
/**
* 清空数据
*/
fun clearData() {
chatAIId = ""
title = ""
desc = ""
isPublic = true
originalIsPublic = true
paidForPrivacyEdit = false
avatarUrl = null
croppedBitmap = null
isUpdating = false
isLoading = false
errorMessage = null
isSelectingAvatar = false
}
}

View File

@@ -16,6 +16,7 @@ import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.UserServiceImpl import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.entity.AccountProfileEntity import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.entity.ChatItem import com.aiosman.ravenow.entity.ChatItem
import com.aiosman.ravenow.im.OpenIMManager
import io.openim.android.sdk.OpenIMClient import io.openim.android.sdk.OpenIMClient
import io.openim.android.sdk.enums.ConversationType import io.openim.android.sdk.enums.ConversationType
import io.openim.android.sdk.enums.ViewType import io.openim.android.sdk.enums.ViewType
@@ -100,6 +101,10 @@ abstract class BaseChatViewModel : ViewModel() {
override fun onSuccess(data: ConversationInfo) { override fun onSuccess(data: ConversationInfo) {
conversationID = data.conversationID conversationID = data.conversationID
// 如果是群组的会话id应该加上s修正,不知道是不是openIm的bug
if (data.conversationType == 2) {
conversationID = "s${conversationID}"
}
Log.d(getLogTag(), "获取会话信息成功conversationID: $conversationID") Log.d(getLogTag(), "获取会话信息成功conversationID: $conversationID")
onSuccess?.invoke() onSuccess?.invoke()
} }
@@ -324,6 +329,7 @@ abstract class BaseChatViewModel : ViewModel() {
OpenIMClient.getInstance().messageManager.getAdvancedHistoryMessageList( OpenIMClient.getInstance().messageManager.getAdvancedHistoryMessageList(
object : OnBase<AdvancedMessage> { object : OnBase<AdvancedMessage> {
override fun onSuccess(data: AdvancedMessage?) { override fun onSuccess(data: AdvancedMessage?) {
val messages = data?.messageList ?: emptyList() val messages = data?.messageList ?: emptyList()
val newChatItems = messages.mapNotNull { val newChatItems = messages.mapNotNull {
ChatItem.convertToChatItem(it, context, avatar = getMessageAvatar(it)) ChatItem.convertToChatItem(it, context, avatar = getMessageAvatar(it))

View File

@@ -504,6 +504,12 @@ fun ChatAiOtherItem(item: ChatItem) {
@Composable @Composable
fun ChatAiItem(item: ChatItem, currentUserId: String) { fun ChatAiItem(item: ChatItem, currentUserId: String) {
// 通知消息显示特殊布局
if (item.isNotification) {
NotificationMessageItem(item)
return
}
val isCurrentUser = item.userId == currentUserId val isCurrentUser = item.userId == currentUserId
if (isCurrentUser) { if (isCurrentUser) {
ChatAiSelfItem(item) ChatAiSelfItem(item)

View File

@@ -516,6 +516,12 @@ fun ChatOtherItem(item: ChatItem) {
@Composable @Composable
fun ChatItem(item: ChatItem, currentUserId: String) { fun ChatItem(item: ChatItem, currentUserId: String) {
// 通知消息显示特殊布局
if (item.isNotification) {
NotificationMessageItem(item)
return
}
val isCurrentUser = item.userId == currentUserId val isCurrentUser = item.userId == currentUserId
if (isCurrentUser) { if (isCurrentUser) {
ChatSelfItem(item) ChatSelfItem(item)

View File

@@ -310,8 +310,13 @@ fun GroupChatScreen(groupId: String,name: String,avatar: String,) {
) )
} }
// 获取上一个item的userId用于判断是否显示头像和昵称 // 获取上一个item的userId用于判断是否显示头像和昵称
// 通知消息不参与判断逻辑
val previousItem = if (index < chatList.size - 1) chatList[index + 1] else null val previousItem = if (index < chatList.size - 1) chatList[index + 1] else null
val showAvatarAndNickname = previousItem?.userId != item.userId val showAvatarAndNickname = if (item.isNotification || previousItem?.isNotification == true) {
true // 通知消息前后都显示头像和昵称
} else {
previousItem?.userId != item.userId
}
GroupChatItem( GroupChatItem(
item = item, item = item,
currentUserId = viewModel.myProfile?.trtcUserId!!, currentUserId = viewModel.myProfile?.trtcUserId!!,
@@ -528,13 +533,13 @@ fun GroupChatOtherItem(item: ChatItem, showAvatarAndNickname: Boolean = true) {
@Composable @Composable
fun GroupChatItem(item: ChatItem, currentUserId: String, showAvatarAndNickname: Boolean = true) { fun GroupChatItem(item: ChatItem, currentUserId: String, showAvatarAndNickname: Boolean = true) {
val isCurrentUser = item.userId == currentUserId // 通知消息显示特殊布局(包括系统账户发送的消息)
if (item.isNotification) {
// 管理员消息显示特殊布局 NotificationMessageItem(item)
if (item.userId == "administrator") {
GroupChatAdminItem(item)
return return
} }
val isCurrentUser = item.userId == currentUserId
// 根据是否是当前用户显示不同样式 // 根据是否是当前用户显示不同样式
when (item.userId) { when (item.userId) {

View File

@@ -0,0 +1,58 @@
package com.aiosman.ravenow.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.entity.ChatItem
/**
* 通知消息显示组件
* 参考 iOS 的 tipsView 样式,用于显示通知类型的消息
*/
@Composable
fun NotificationMessageItem(item: ChatItem) {
val AppColors = LocalAppTheme.current
// 参考 iOS: HStack { Text(...) } .padding(.vertical, 8)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Center
) {
// 参考 iOS: Text(tips)
// .font(.caption2) - 12sp
// .foregroundColor(Color.textMain) - 主文本颜色
// .padding(.vertical, 8) .padding(.horizontal, 12)
// .background(Color.background.opacity(0.2))
// .background(.ultraThinMaterial.opacity(0.3))
// .cornerRadius(12)
Text(
text = item.message,
style = TextStyle(
color = AppColors.text, // 使用主文本颜色,不是次要文本颜色
fontSize = 12.sp, // .caption2 对应 12sp
textAlign = TextAlign.Center
),
modifier = Modifier
.clip(RoundedCornerShape(12.dp)) // 圆角 12不是 8
.background(
// 参考 iOS: Color.background.opacity(0.2) + .ultraThinMaterial.opacity(0.3)
// Android 使用半透明背景色模拟毛玻璃效果
AppColors.background.copy(alpha = 0.2f)
)
.padding(vertical = 8.dp, horizontal = 12.dp), // horizontal 12不是 16
maxLines = Int.MAX_VALUE
)
}
}

View File

@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.ime
@@ -40,6 +42,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.CommentEntity import com.aiosman.ravenow.entity.CommentEntity
import com.aiosman.ravenow.ui.composables.EditCommentBottomModal import com.aiosman.ravenow.ui.composables.EditCommentBottomModal
@@ -88,6 +91,7 @@ fun CommentModalContent(
} }
) )
val commentViewModel = model.commentsViewModel val commentViewModel = model.commentsViewModel
val AppColors = LocalAppTheme.current
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -99,10 +103,24 @@ fun CommentModalContent(
var bottomPadding by remember { mutableStateOf(0.dp) } var bottomPadding by remember { mutableStateOf(0.dp) }
var softwareKeyboardController = LocalSoftwareKeyboardController.current var softwareKeyboardController = LocalSoftwareKeyboardController.current
var replyComment by remember { mutableStateOf<CommentEntity?>(null) } var replyComment by remember { mutableStateOf<CommentEntity?>(null) }
var shouldAutoFocus by remember { mutableStateOf(false) }
LaunchedEffect(imePadding) { LaunchedEffect(imePadding) {
bottomPadding = imePadding.dp bottomPadding = imePadding.dp
} }
// 当设置回复评论时,自动聚焦到输入框
LaunchedEffect(replyComment) {
if (replyComment != null) {
// 延迟一下,确保输入框已经渲染
kotlinx.coroutines.delay(100)
shouldAutoFocus = true
// 请求显示键盘
softwareKeyboardController?.show()
} else {
shouldAutoFocus = false
}
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
onDismiss() onDismiss()
@@ -113,13 +131,12 @@ fun CommentModalContent(
onDismissRequest = { onDismissRequest = {
showCommentMenu = false showCommentMenu = false
}, },
containerColor = Color.White, containerColor = AppColors.background,
sheetState = rememberModalBottomSheetState( sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true skipPartiallyExpanded = true
), ),
dragHandle = {}, dragHandle = {},
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
windowInsets = WindowInsets(0)
) { ) {
CommentMenuModal( CommentMenuModal(
onDeleteClick = { onDeleteClick = {
@@ -142,6 +159,7 @@ fun CommentModalContent(
} }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize()
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -153,12 +171,13 @@ fun CommentModalContent(
stringResource(R.string.comment), stringResource(R.string.comment),
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = AppColors.text,
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
) )
} }
HorizontalDivider( HorizontalDivider(
color = Color(0xFFF7F7F7) color = AppColors.divider
) )
Row( Row(
modifier = Modifier modifier = Modifier
@@ -170,7 +189,7 @@ fun CommentModalContent(
Text( Text(
text = stringResource(id = R.string.comment_count, commentCount), text = stringResource(id = R.string.comment_count, commentCount),
fontSize = 14.sp, fontSize = 14.sp,
color = Color(0xff666666) color = AppColors.secondaryText
) )
OrderSelectionComponent { OrderSelectionComponent {
commentViewModel.order = it commentViewModel.order = it
@@ -180,12 +199,12 @@ fun CommentModalContent(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp)
.weight(1f) .weight(1f)
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.padding(horizontal = 16.dp)
) { ) {
item { item {
CommentContent( CommentContent(
@@ -194,7 +213,9 @@ fun CommentModalContent(
}, },
onReply = { parentComment, _, _, _ -> onReply = { parentComment, _, _, _ ->
// 设置回复的评论,这样 EditCommentBottomModal 会显示回复输入框
// CommentContent 内部已经处理了游客模式检查,所以这里直接设置即可
replyComment = parentComment
}, },
) )
Spacer(modifier = Modifier.height(72.dp)) Spacer(modifier = Modifier.height(72.dp))
@@ -205,9 +226,12 @@ fun CommentModalContent(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color(0xfff7f7f7)) .background(AppColors.secondaryBackground)
) { ) {
EditCommentBottomModal(replyComment) { EditCommentBottomModal(
replyComment = replyComment,
autoFocus = shouldAutoFocus
) {
commentViewModel.viewModelScope.launch { commentViewModel.viewModelScope.launch {
if (replyComment != null) { if (replyComment != null) {
if (replyComment?.parentCommentId != null) { if (replyComment?.parentCommentId != null) {
@@ -225,6 +249,13 @@ fun CommentModalContent(
// 顶级评论 // 顶级评论
commentViewModel.createComment(it) commentViewModel.createComment(it)
} }
// 评论创建成功后调用回调
onCommentAdded()
// 清空回复状态和自动聚焦状态
replyComment = null
shouldAutoFocus = false
// 隐藏键盘
softwareKeyboardController?.hide()
} }
} }

View File

@@ -124,27 +124,18 @@ fun CommentNoticeScreen() {
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
androidx.compose.foundation.Image( androidx.compose.foundation.Image(
painter = painterResource( painter = painterResource(id = R.mipmap.invalid_name_5),
id = if(AppState.darkMode) R.mipmap.tietie_dark
else R.mipmap.invalid_name_11),
contentDescription = "No Comment", contentDescription = "No Comment",
modifier = Modifier modifier = Modifier
.size(width = 181.dp, height = 153.dp) .size(width = 181.dp, height = 153.dp)
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(9.dp))
Text( Text(
text = "等一位旅人~", text = stringResource(R.string.no_one_pinged_yet),
color = AppColors.text, color = AppColors.text,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.W600 fontWeight = FontWeight.W600
) )
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "去发布动态,让更多人参与对话",
color = AppColors.text,
fontSize = 14.sp,
fontWeight = FontWeight.W400
)
} }
} }
} else { } else {

View File

@@ -73,7 +73,8 @@ fun AgentCard(
agentEntity.avatar, agentEntity.avatar,
contentDescription = agentEntity.openId, contentDescription = agentEntity.openId,
modifier = Modifier.size(40.dp), modifier = Modifier.size(40.dp),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop,
defaultRes = com.aiosman.ravenow.R.mipmap.group_copy
) )
} }
Column( Column(

View File

@@ -10,6 +10,7 @@ import androidx.compose.animation.togetherWith
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme import com.aiosman.ravenow.LocalAppTheme
@@ -36,6 +37,13 @@ fun AnimatedCounter(count: Int, modifier: Modifier = Modifier, fontSize: Int = 2
) )
} }
) { targetCount -> ) { targetCount ->
Text(text = "$targetCount", modifier = modifier, fontSize = fontSize.sp, color = AppColors.text) Text(
text = "$targetCount",
modifier = modifier,
fontSize = fontSize.sp,
color = AppColors.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
} }
} }

View File

@@ -64,6 +64,7 @@ fun AnimatedFavouriteIcon(
modifier = modifier.graphicsLayer { modifier = modifier.graphicsLayer {
rotationZ = animatableRotation.value rotationZ = animatableRotation.value
}, },
colorFilter = if (!isFavourite) ColorFilter.tint(AppColors.text) else null
) )
} }
} }

View File

@@ -8,9 +8,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import coil.annotation.ExperimentalCoilApi import coil3.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage import coil3.compose.AsyncImage
import coil.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.request.fallback
import coil3.request.placeholder
import com.aiosman.ravenow.utils.BlurHashDecoder import com.aiosman.ravenow.utils.BlurHashDecoder
import com.aiosman.ravenow.utils.Utils.getImageLoader import com.aiosman.ravenow.utils.Utils.getImageLoader

View File

@@ -2,6 +2,7 @@ package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed import androidx.compose.ui.composed
@@ -68,7 +69,6 @@ fun Modifier.debouncedClickableWithRipple(
clickable( clickable(
enabled = enabled && isClickable, enabled = enabled && isClickable,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = androidx.compose.material.ripple.rememberRipple()
) { ) {
if (isClickable) { if (isClickable) {
isClickable = false isClickable = false

View File

@@ -123,7 +123,7 @@ fun LazyGridItemScope.DraggableItem(
translationY = dragDropState.previousItemOffset.value.y translationY = dragDropState.previousItemOffset.value.y
} }
} else { } else {
Modifier.animateItemPlacement() Modifier
} }
Box(modifier = modifier.then(draggingModifier).clip(RoundedCornerShape(8.dp)), propagateMinConstraints = true) { Box(modifier = modifier.then(draggingModifier).clip(RoundedCornerShape(8.dp)), propagateMinConstraints = true) {
content(dragging) content(dragging)

View File

@@ -36,7 +36,9 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -59,10 +61,15 @@ fun EditCommentBottomModal(
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val context = LocalContext.current val context = LocalContext.current
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(autoFocus) { LaunchedEffect(autoFocus) {
if (autoFocus) { if (autoFocus) {
// 延迟一下,确保输入框已经渲染完成
kotlinx.coroutines.delay(150)
focusRequester.requestFocus() focusRequester.requestFocus()
// 显示键盘
keyboardController?.show()
} }
} }
@@ -82,7 +89,7 @@ fun EditCommentBottomModal(
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .weight(1f)
.clip(RoundedCornerShape(20.dp)) .clip(RoundedCornerShape(20.dp))
.background(Color.Gray.copy(alpha = 0.1f)) .background(AppColors.inputBackground)
.padding(horizontal = 16.dp, vertical = 16.dp) .padding(horizontal = 16.dp, vertical = 16.dp)
) { ) {
Row( Row(
@@ -99,7 +106,7 @@ fun EditCommentBottomModal(
.weight(1f) .weight(1f)
.focusRequester(focusRequester), .focusRequester(focusRequester),
textStyle = TextStyle( textStyle = TextStyle(
color = Color.Black, color = AppColors.text,
fontWeight = FontWeight.Normal fontWeight = FontWeight.Normal
), ),
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
@@ -110,7 +117,11 @@ fun EditCommentBottomModal(
innerTextField() innerTextField()
if (text.isEmpty()) { if (text.isEmpty()) {
Text( Text(
text = if (replyComment == null) "快来互动吧..." else "回复@${replyComment.name}", text = if (replyComment == null) {
stringResource(R.string.post_comment_hint)
} else {
stringResource(R.string.reply_to_user, replyComment.name ?: "")
},
color = AppColors.text.copy(alpha = 0.3f), // 30%透明度 color = AppColors.text.copy(alpha = 0.3f), // 30%透明度
) )
} }

View File

@@ -16,11 +16,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader import coil3.ImageLoader
import coil.compose.AsyncImage import coil3.asDrawable
import coil.request.ImageRequest import coil3.asImage
import coil.request.SuccessResult import coil3.compose.AsyncImage
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.crossfade
import com.aiosman.ravenow.utils.Utils.getImageLoader import com.aiosman.ravenow.utils.Utils.getImageLoader
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -59,7 +64,11 @@ fun rememberImageBitmap(imageUrl: String, imageLoader: ImageLoader): Bitmap? {
.build() .build()
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
(imageLoader.execute(request) as? SuccessResult)?.drawable?.toBitmap() val successResult = imageLoader.execute(request) as? SuccessResult
successResult?.let {
val drawable = it.image.asDrawable(context.resources)
drawable.toBitmap()
}
} }
bitmap = result bitmap = result
@@ -138,25 +147,33 @@ fun CustomAsyncImage(
} }
// 处理字符串URL // 处理字符串URL
val ctx = context ?: localContext
val placeholderImage = remember(placeholderRes, ctx) {
placeholderRes?.let { resId ->
ContextCompat.getDrawable(ctx, resId)?.asImage()
}
}
val errorImage = remember(errorRes, ctx) {
errorRes?.let { resId ->
ContextCompat.getDrawable(ctx, resId)?.asImage()
}
}
if (showShimmer) { if (showShimmer) {
var isLoading by remember { mutableStateOf(true) } var isLoading by remember { mutableStateOf(true) }
Box(modifier = modifier) { Box(modifier = modifier) {
AsyncImage( AsyncImage(
model = ImageRequest.Builder(context ?: localContext) model = ImageRequest.Builder(ctx)
.data(imageUrl) .data(imageUrl)
.crossfade(200) .crossfade(200)
.memoryCachePolicy(coil.request.CachePolicy.ENABLED) .memoryCachePolicy(coil3.request.CachePolicy.ENABLED)
.diskCachePolicy(coil.request.CachePolicy.ENABLED) .diskCachePolicy(coil3.request.CachePolicy.ENABLED)
.apply { .apply {
// 设置占位符图片 // 设置占位符图片
if (placeholderRes != null) { placeholderImage?.let { placeholder(it) }
placeholder(placeholderRes)
}
// 设置错误时显示的图片 // 设置错误时显示的图片
if (errorRes != null) { errorImage?.let { error(it) }
error(errorRes)
}
} }
.build(), .build(),
contentDescription = contentDescription, contentDescription = contentDescription,
@@ -177,20 +194,16 @@ fun CustomAsyncImage(
} }
} else { } else {
AsyncImage( AsyncImage(
model = ImageRequest.Builder(context ?: localContext) model = ImageRequest.Builder(ctx)
.data(imageUrl) .data(imageUrl)
.crossfade(200) .crossfade(200)
.memoryCachePolicy(coil.request.CachePolicy.ENABLED) .memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(coil.request.CachePolicy.ENABLED) .diskCachePolicy(CachePolicy.ENABLED)
.apply { .apply {
// 设置占位符图片 // 设置占位符图片
if (placeholderRes != null) { placeholderImage?.let { placeholder(it) }
placeholder(placeholderRes)
}
// 设置错误时显示的图片 // 设置错误时显示的图片
if (errorRes != null) { errorImage?.let { error(it) }
error(errorRes)
}
} }
.build(), .build(),
contentDescription = contentDescription, contentDescription = contentDescription,

View File

@@ -111,6 +111,7 @@ fun MomentCard(
) { ) {
MomentContentGroup( MomentContentGroup(
momentEntity = momentEntity, momentEntity = momentEntity,
imageIndex = imageIndex,
onPageChange = { index -> imageIndex = index } onPageChange = { index -> imageIndex = index }
) )
} }
@@ -120,7 +121,6 @@ fun MomentCard(
onLikeClick = onLikeClick, onLikeClick = onLikeClick,
onAddComment = onAddComment, onAddComment = onAddComment,
onFavoriteClick = onFavoriteClick, onFavoriteClick = onFavoriteClick,
imageIndex = imageIndex,
onCommentClick = { onCommentClick = {
navController.navigateToPost( navController.navigateToPost(
momentEntity.id, momentEntity.id,
@@ -327,6 +327,11 @@ fun PostImageView(
images: List<MomentImageEntity>, images: List<MomentImageEntity>,
onPageChange: (Int) -> Unit = {} onPageChange: (Int) -> Unit = {}
) { ) {
// 如果图片列表为空,不渲染任何内容
if (images.isEmpty()) {
return
}
val pagerState = rememberPagerState(pageCount = { images.size }) val pagerState = rememberPagerState(pageCount = { images.size })
LaunchedEffect(pagerState.currentPage) { LaunchedEffect(pagerState.currentPage) {
onPageChange(pagerState.currentPage) onPageChange(pagerState.currentPage)
@@ -361,27 +366,88 @@ fun PostImageView(
@Composable @Composable
fun MomentContentGroup( fun MomentContentGroup(
momentEntity: MomentEntity, momentEntity: MomentEntity,
imageIndex: Int = 0,
onPageChange: (Int) -> Unit = {} onPageChange: (Int) -> Unit = {}
) { ) {
val AppColors = LocalAppTheme.current val AppColors = LocalAppTheme.current
val context = LocalContext.current
if (momentEntity.relMoment != null) { if (momentEntity.relMoment != null) {
RelPostCard( RelPostCard(
momentEntity = momentEntity.relMoment!!, momentEntity = momentEntity.relMoment!!,
modifier = Modifier.background(Color(0xFFF8F8F8)) modifier = Modifier.background(Color(0xFFF8F8F8))
) )
} else { } else {
Box( Column(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
PostImageView( Box(
images = momentEntity.images, modifier = Modifier.fillMaxWidth()
onPageChange = onPageChange ) {
) // 优先显示图片,如果没有图片则显示视频缩略图
if (momentEntity.images.isNotEmpty()) {
PostImageView(
images = momentEntity.images,
onPageChange = onPageChange
)
} else if (momentEntity.videos != null && momentEntity.videos.isNotEmpty()) {
// 显示视频缩略图
val firstVideo = momentEntity.videos.first()
val thumbnailUrl = firstVideo.thumbnailUrl ?: firstVideo.thumbnailDirectUrl
if (thumbnailUrl != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(
if (firstVideo.width != null && firstVideo.height != null && firstVideo.height > 0) {
firstVideo.width.toFloat() / firstVideo.height.toFloat()
} else {
1f
}
)
) {
CustomAsyncImage(
context = context,
imageUrl = thumbnailUrl,
contentDescription = "Video thumbnail",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
}
}
}
// 图片指示器:显示在图片下方、文案上方
if (momentEntity.images.size > 1) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
momentEntity.images.forEachIndexed { index, _ ->
Box(
modifier = Modifier
.size(4.dp)
.clip(CircleShape)
.background(
if (imageIndex == index) Color.Red else Color.Gray.copy(
alpha = 0.5f
)
)
.padding(1.dp)
)
Spacer(modifier = Modifier.width(8.dp))
}
}
}
} }
} }
if (momentEntity.momentTextContent.isNotEmpty()) { if (!momentEntity.momentTextContent.isNullOrEmpty()) {
Text( Text(
text = momentEntity.momentTextContent, text = com.aiosman.ravenow.utils.Utils.unescapeHtml(momentEntity.momentTextContent),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 8.dp), .padding(start = 16.dp, end = 16.dp, top = 8.dp),
@@ -402,6 +468,7 @@ fun MomentOperateBtn(@DrawableRes icon: Int, count: String) {
.size(width = 24.dp, height = 24.dp), .size(width = 24.dp, height = 24.dp),
painter = painterResource(id = icon), painter = painterResource(id = icon),
contentDescription = "", contentDescription = "",
colorFilter = ColorFilter.tint(AppColors.text)
) )
if (count.isNotEmpty()) { if (count.isNotEmpty()) {
Text( Text(
@@ -426,7 +493,6 @@ fun MomentOperateBtn(count: String, content: @Composable () -> Unit) {
fontSize = 14, fontSize = 14,
modifier = Modifier modifier = Modifier
.padding(start = 7.dp) .padding(start = 7.dp)
.width(24.dp)
) )
} }
} }
@@ -438,8 +504,7 @@ fun MomentBottomOperateRowGroup(
onAddComment: () -> Unit = {}, onAddComment: () -> Unit = {},
onCommentClick: () -> Unit = {}, onCommentClick: () -> Unit = {},
onFavoriteClick: () -> Unit = {}, onFavoriteClick: () -> Unit = {},
momentEntity: MomentEntity, momentEntity: MomentEntity
imageIndex: Int = 0
) { ) {
val lastClickTime = remember { mutableStateOf(0L) } val lastClickTime = remember { mutableStateOf(0L) }
val clickDelay = 500L val clickDelay = 500L
@@ -451,7 +516,6 @@ fun MomentBottomOperateRowGroup(
sheetState = rememberModalBottomSheetState( sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true skipPartiallyExpanded = true
), ),
windowInsets = WindowInsets(0),
dragHandle = { dragHandle = {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -476,93 +540,65 @@ fun MomentBottomOperateRowGroup(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(56.dp) .height(56.dp)
.padding(start = 16.dp, end = 0.dp) .padding(start = 16.dp, end = 16.dp)
) { ) {
Column( Row(
modifier = Modifier.fillMaxSize() modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically
) { ) {
if (momentEntity.images.size > 1) { Row(
Row( modifier = Modifier.weight(1f).fillMaxHeight(),
modifier = Modifier verticalAlignment = Alignment.CenterVertically
.fillMaxWidth() ) {
.weight(1f), // 点赞按钮
horizontalArrangement = Arrangement.Center, MomentOperateBtn(count = momentEntity.likeCount.toString()) {
verticalAlignment = Alignment.CenterVertically AnimatedLikeIcon(
) { modifier = Modifier.size(24.dp),
momentEntity.images.forEachIndexed { index, _ -> liked = momentEntity.liked
Box( ) {
modifier = Modifier onLikeClick()
.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))
} }
} }
Spacer(modifier = Modifier.width(16.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(16.dp))
// 转发按钮
Box(
modifier = Modifier.noRippleClickable {
// TODO: 实现转发功能
}
) {
MomentOperateBtn(
icon = R.mipmap.icon_share,
count = ""
)
}
} }
Row( Spacer(modifier = Modifier.width(16.dp))
modifier = Modifier // 收藏按钮
.fillMaxWidth() MomentOperateBtn(count = momentEntity.favoriteCount.toString()) {
.weight(1f), AnimatedFavouriteIcon(
verticalAlignment = Alignment.CenterVertically modifier = Modifier.size(24.dp),
) { isFavourite = momentEntity.isFavorite
Row(
modifier = Modifier.weight(1f).fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically
) { ) {
// 点赞按钮 onFavoriteClick()
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()
}
} }
} }
} }

View File

@@ -0,0 +1,342 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
/**
* 全局付费确认对话框组件
* 参考 iOS 版本的 PointsConfirmDialog
*
* @param cost 需要支付的费用
* @param currentBalance 当前余额
* @param balanceAfterCost 支付后余额
* @param isBalanceSufficient 余额是否充足
* @param onConfirm 确认支付回调
* @param onCancel 取消回调
* @param title 对话框标题
* @param description 对话框描述
* @param isProcessing 是否正在处理中
*/
@Composable
fun PointsPaymentDialog(
cost: Int,
currentBalance: Int,
balanceAfterCost: Int,
isBalanceSufficient: Boolean,
onConfirm: () -> Unit,
onCancel: () -> Unit,
title: String,
description: String,
isProcessing: Boolean = false
) {
val appColors = LocalAppTheme.current
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp.dp
val dialogWidth = (screenWidth - 48.dp).coerceAtMost(360.dp)
Dialog(
onDismissRequest = {
if (!isProcessing) {
onCancel()
}
},
properties = DialogProperties(
dismissOnBackPress = !isProcessing,
dismissOnClickOutside = !isProcessing
)
) {
Card(
modifier = Modifier
.width(dialogWidth)
.shadow(
elevation = 20.dp,
shape = RoundedCornerShape(20.dp),
spotColor = Color.Black.copy(alpha = 0.2f)
),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = appColors.background
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 顶部图标 - 使用 paip_coin_img
Spacer(modifier = Modifier.height(32.dp))
Image(
painter = painterResource(id = R.mipmap.paip_coin_img),
contentDescription = null,
modifier = Modifier.size(80.dp),
contentScale = ContentScale.Fit
)
// 标题
Spacer(modifier = Modifier.height(16.dp))
Text(
text = title,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = appColors.text,
textAlign = TextAlign.Center
)
// 描述
Spacer(modifier = Modifier.height(8.dp))
Text(
text = description,
fontSize = 14.sp,
color = appColors.secondaryText,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 20.dp)
)
// 积分消耗信息区域
Spacer(modifier = Modifier.height(24.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
color = appColors.inputBackground.copy(alpha = 0.5f),
shape = RoundedCornerShape(12.dp)
)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 需要消耗
CostInfoRow(
label = stringResource(R.string.cost_required),
amount = cost,
appColors = appColors,
amountColor = Color(0xFFFF8C00) // 橙色
)
HorizontalDivider(color = appColors.divider)
// 当前余额
CostInfoRow(
label = stringResource(R.string.current_balance),
amount = currentBalance,
appColors = appColors,
amountColor = if (isBalanceSufficient) appColors.text else Color.Red
)
HorizontalDivider(color = appColors.divider)
// 支付后余额
CostInfoRow(
label = stringResource(R.string.balance_after),
amount = balanceAfterCost,
appColors = appColors,
amountColor = appColors.text
)
}
// 余额不足提示
if (!isBalanceSufficient) {
Spacer(modifier = Modifier.height(12.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = Color(0xFFFF8C00), // 橙色
modifier = Modifier.size(16.dp)
)
Text(
text = stringResource(R.string.insufficient_pai_coin_balance),
fontSize = 13.sp,
color = Color(0xFFFF8C00), // 橙色
textAlign = TextAlign.Center
)
}
}
// 按钮
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// 取消按钮
Button(
onClick = {
if (!isProcessing) {
onCancel()
}
},
modifier = Modifier
.weight(1f)
.height(50.dp),
enabled = !isProcessing,
colors = ButtonDefaults.buttonColors(
containerColor = appColors.inputBackground,
contentColor = appColors.text,
disabledContainerColor = appColors.inputBackground,
disabledContentColor = appColors.text.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = stringResource(R.string.cancel),
fontSize = 16.sp,
fontWeight = FontWeight.W500
)
}
// 确认按钮
Box(
modifier = Modifier
.weight(1f)
.height(50.dp)
.background(
brush = if (isBalanceSufficient) {
Brush.horizontalGradient(
colors = listOf(
appColors.main,
appColors.main
)
)
} else {
Brush.horizontalGradient(
colors = listOf(
Color(0xFFFF8C00), // 橙色
Color.Red
)
)
},
shape = RoundedCornerShape(12.dp)
)
.then(
if (!isProcessing) {
Modifier.noRippleClickable {
if (!isBalanceSufficient) {
// 积分不足,跳转充值页面
onCancel()
// 这里可以发送通知或回调来跳转充值页面
} else {
// 积分充足,确认消费
onConfirm()
}
}
} else {
Modifier
}
),
contentAlignment = Alignment.Center
) {
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
text = if (isBalanceSufficient) {
stringResource(R.string.confirm_consumption)
} else {
stringResource(R.string.go_recharge)
},
fontSize = 16.sp,
fontWeight = FontWeight.W600,
color = Color.White,
textAlign = TextAlign.Center
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
/**
* 费用信息行组件
*/
@Composable
private fun CostInfoRow(
label: String,
amount: Int,
appColors: com.aiosman.ravenow.AppThemeData,
amountColor: Color? = null
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
fontSize = 14.sp,
color = appColors.secondaryText
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
// 星形图标(参考 iOS 版本)
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = Color(0xFFFFD700) // 黄色
)
Text(
text = "${amount.formatNumber()}",
fontSize = 16.sp,
fontWeight = FontWeight.W600,
color = amountColor ?: appColors.text
)
Text(
text = stringResource(R.string.pai_coin),
fontSize = 14.sp,
color = appColors.secondaryText
)
}
}
}
/**
* 格式化数字,添加千位分隔符
*/
private fun Int.formatNumber(): String {
return this.toString().reversed().chunked(3).joinToString(",").reversed()
}

View File

@@ -71,7 +71,6 @@ fun PolicyCheckbox(
showModal = false showModal = false
}, },
sheetState = modalSheetState, sheetState = modalSheetState,
windowInsets = WindowInsets(0),
containerColor = Color.White, containerColor = Color.White,
) { ) {
WebViewDisplay( WebViewDisplay(
@@ -98,7 +97,7 @@ fun PolicyCheckbox(
addStyle( addStyle(
style = SpanStyle( style = SpanStyle(
color = appColor.main, color = Color(0xFF7C45ED), // 紫色
textDecoration = TextDecoration.Underline textDecoration = TextDecoration.Underline
), ),
start = template.length + 1, start = template.length + 1,

View File

@@ -6,18 +6,25 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme import com.aiosman.ravenow.LocalAppTheme
@@ -39,6 +46,7 @@ fun TabItem(
Column( Column(
modifier = modifier modifier = modifier
.wrapContentWidth()
.noRippleClickable { onClick() }, .noRippleClickable { onClick() },
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
@@ -72,31 +80,48 @@ fun UnderlineTabItem(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val AppColors = LocalAppTheme.current val AppColors = LocalAppTheme.current
// 动画化字体大小和padding
val animatedFontSize by animateFloatAsState(
targetValue = if (isSelected) 17f else 15f,
animationSpec = tween(durationMillis = 200),
label = "fontSize"
)
val animatedPadding by animateDpAsState(
targetValue = if (isSelected) 20.dp else 16.dp,
animationSpec = tween(durationMillis = 200),
label = "padding"
)
Column( Box(
modifier = modifier modifier = modifier
.noRippleClickable { onClick() }, .noRippleClickable { onClick() },
verticalArrangement = Arrangement.Center, contentAlignment = Alignment.Center
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// 文本层 - 始终居中,不受下划线影响
Text( Text(
text = text, text = text,
fontSize = 15.sp, fontSize = animatedFontSize.sp,
fontWeight = FontWeight.ExtraBold, fontWeight = FontWeight.ExtraBold,
color = if (isSelected) AppColors.text else AppColors.text.copy(alpha = 0.6f), color = if (isSelected) AppColors.text else AppColors.text.copy(alpha = 0.6f),
modifier = Modifier.padding(horizontal = 16.dp).padding(top = 13.dp) textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier
.padding(horizontal = animatedPadding)
) )
// 选中状态下显示图标 // 下划线层 - 固定在底部,不影响文本位置
Box( if (isSelected) {
modifier = Modifier.size(24.dp), Box(
contentAlignment = Alignment.Center modifier = Modifier
) { .align(Alignment.BottomCenter)
if (isSelected) { .size(24.dp)
.offset(y = (15).dp),
contentAlignment = Alignment.Center
) {
Image( Image(
painter = painterResource(id = R.mipmap.underline), painter = painterResource(id = R.mipmap.underline),
contentDescription = "selected indicator", contentDescription = "selected indicator",
modifier = Modifier.fillMaxSize()
) )
} }
} }

View File

@@ -39,6 +39,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.LocalAppTheme import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.modifiers.noRippleClickable import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@@ -59,17 +60,21 @@ fun TextInputField(
enabled: Boolean = true, enabled: Boolean = true,
leadingIcon: @Composable (() -> Unit)? = null, leadingIcon: @Composable (() -> Unit)? = null,
customBackgroundColor: Color? = null, customBackgroundColor: Color? = null,
customHintColor: Color? = null,
customLabelColor: Color? = null,
customCornerRadius: Float = 24f customCornerRadius: Float = 24f
) { ) {
val AppColors = LocalAppTheme.current val AppColors = LocalAppTheme.current
var showPassword by remember { mutableStateOf(!password) } var showPassword by remember { mutableStateOf(!password) }
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
val backgroundColor = customBackgroundColor ?: AppColors.inputBackground val backgroundColor = customBackgroundColor ?: AppColors.inputBackground
val hintColor = customHintColor ?: HintTextColor
val labelColor = customLabelColor ?: LabelTextColor
Column(modifier = modifier) { Column(modifier = modifier) {
label?.let { label?.let {
Text( Text(
text = it, text = it,
color = LabelTextColor, color = labelColor,
fontSize = 13.sp, fontSize = 13.sp,
modifier = Modifier.padding(start = 8.dp, top = 8.dp, bottom = 8.dp) modifier = Modifier.padding(start = 8.dp, top = 8.dp, bottom = 8.dp)
) )
@@ -121,13 +126,19 @@ fun TextInputField(
if (text.isEmpty() && hint != null) { if (text.isEmpty() && hint != null) {
Text( Text(
text = hint, text = hint,
color = HintTextColor, color = hintColor,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.W400 fontWeight = FontWeight.W400
) )
} }
} }
if (password) { if (password) {
// 暗色模式下图标为白色,否则使用默认颜色
val iconColor = if (AppState.darkMode) {
Color.White
} else {
PasswordIconColor
}
Image( Image(
painter = painterResource( painter = painterResource(
id = if (showPassword) { id = if (showPassword) {
@@ -142,7 +153,7 @@ fun TextInputField(
.noRippleClickable { .noRippleClickable {
showPassword = !showPassword showPassword = !showPassword
}, },
colorFilter = ColorFilter.tint(PasswordIconColor) colorFilter = ColorFilter.tint(iconColor)
) )
} }
} }

View File

@@ -67,6 +67,13 @@ fun FormTextInput(
.let { .let {
if (error != null) { if (error != null) {
it.border(1.dp, AppColors.error, RoundedCornerShape(24.dp)) it.border(1.dp, AppColors.error, RoundedCornerShape(24.dp))
} else if (background != null && background == Color.White) {
// 如果传入白色背景,添加灰色边框
it.border(
1.dp,
Color(red = 124f / 255f, green = 116f / 255f, blue = 128f / 255f, alpha = 0.08f),
RoundedCornerShape(25.dp)
)
} else { } else {
it it
} }

View File

@@ -68,6 +68,13 @@ fun FormTextInput2(
.let { .let {
if (error != null) { if (error != null) {
it.border(1.dp, AppColors.error, RoundedCornerShape(24.dp)) it.border(1.dp, AppColors.error, RoundedCornerShape(24.dp))
} else if (background != null && background == Color.White) {
// 如果传入白色背景,添加灰色边框
it.border(
1.dp,
Color(red = 124f / 255f, green = 116f / 255f, blue = 128f / 255f, alpha = 0.08f),
RoundedCornerShape(25.dp)
)
} else { } else {
it it
} }

View File

@@ -72,6 +72,8 @@ fun ImageCropScreen() {
} }
} }
if (uri == null) { if (uri == null) {
// 用户取消图片选择,清除已裁剪的图片
AccountEditViewModel.croppedBitmap = null
navController.popBackStack() navController.popBackStack()
} }
} }
@@ -103,6 +105,8 @@ fun ImageCropScreen() {
painter = painterResource(R.drawable.rider_pro_back_icon), painter = painterResource(R.drawable.rider_pro_back_icon),
contentDescription = null, contentDescription = null,
modifier = Modifier.clickable { modifier = Modifier.clickable {
// 用户取消头像选择,清除已裁剪的图片
AccountEditViewModel.croppedBitmap = null
navController.popBackStack() navController.popBackStack()
}, },
colorFilter = ColorFilter.tint(Color.White) colorFilter = ColorFilter.tint(Color.White)
@@ -119,11 +123,9 @@ fun ImageCropScreen() {
val bitmap = it.onCrop() val bitmap = it.onCrop()
// 专门处理个人资料头像 // 专门处理个人资料头像
// 只设置裁剪后的图片,不立即上传,等待用户在编辑资料界面点击保存
AccountEditViewModel.croppedBitmap = bitmap AccountEditViewModel.croppedBitmap = bitmap
AccountEditViewModel.viewModelScope.launch { navController.popBackStack()
AccountEditViewModel.updateUserProfile(context)
navController.popBackStack()
}
} }
} }
) )

View File

@@ -134,8 +134,8 @@ fun FavouriteListPage() {
) { ) {
Image( Image(
painter = painterResource( painter = painterResource(
id = if (com.aiosman.ravenow.AppState.darkMode) R.mipmap.invalid_dark id = if (com.aiosman.ravenow.AppState.darkMode) R.mipmap.empty_img
else R.mipmap.invalid_name_1), else R.mipmap.empty_img),
contentDescription = "No favourites", contentDescription = "No favourites",
modifier = Modifier.size(181.dp, 153.dp) modifier = Modifier.size(181.dp, 153.dp)
) )
@@ -155,6 +155,17 @@ fun FavouriteListPage() {
) { ) {
items(moments.itemCount) { idx -> items(moments.itemCount) { idx ->
val momentItem = moments[idx] ?: return@items val momentItem = moments[idx] ?: return@items
// 获取缩略图URL优先使用图片如果没有图片则使用视频缩略图
val thumbnailUrl = when {
momentItem.images.isNotEmpty() -> momentItem.images[0].thumbnail
momentItem.videos != null && momentItem.videos.isNotEmpty() -> {
momentItem.videos.first().thumbnailUrl ?: momentItem.videos.first().thumbnailDirectUrl
}
else -> null
}
if (thumbnailUrl == null) return@items
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -169,7 +180,7 @@ fun FavouriteListPage() {
} }
) { ) {
CustomAsyncImage( CustomAsyncImage(
imageUrl = momentItem.images[0].thumbnail, imageUrl = thumbnailUrl,
contentDescription = "", contentDescription = "",
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

View File

@@ -24,6 +24,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -123,25 +125,20 @@ fun FollowerListScreen(userId: Int) {
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Image( Image(
painter = painterResource( painter = painterResource(id = R.mipmap.frame_31),
id = if(AppState.darkMode) R.mipmap.frame_4
else R.mipmap.invalid_name_8),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(181.dp, 153.dp) modifier = Modifier.size(181.dp, 153.dp)
) )
Spacer(modifier = Modifier.size(9.dp)) // 调整间距为9dp Spacer(modifier = Modifier.size(9.dp)) // 调整间距为9dp
androidx.compose.material.Text( androidx.compose.material.Text(
text = "还没有人关注你呢", text = stringResource(R.string.awaiting_traveler),
color = appColors.text, color = appColors.text,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.W600 fontWeight = FontWeight.W600,
) textAlign = TextAlign.Center,
Spacer(modifier = Modifier.size(8.dp)) modifier = Modifier.padding(horizontal = 24.dp),
androidx.compose.material.Text( maxLines = 2,
text = "试着发信号出来,某人就会被吸引啦~", overflow = TextOverflow.Ellipsis
color = appColors.text,
fontSize = 14.sp,
fontWeight = FontWeight.W400
) )
} }
} }

View File

@@ -25,6 +25,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
@@ -113,26 +115,21 @@ fun FollowerNoticeScreen() {
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Image( Image(
painter = painterResource( painter = painterResource(id = R.mipmap.invalid_name_5),
id = if(AppState.darkMode) R.mipmap.frame_4
else R.mipmap.invalid_name_8),
contentDescription = "No Followers", contentDescription = "No Followers",
modifier = Modifier modifier = Modifier
.size(width = 181.dp, height = 153.dp) .size(width = 181.dp, height = 153.dp)
) )
Spacer(modifier = Modifier.height(if (AppState.darkMode) 9.dp else 24.dp)) Spacer(modifier = Modifier.height(9.dp))
androidx.compose.material.Text( androidx.compose.material.Text(
text = "还没有人关注你呢", text = stringResource(R.string.no_one_pinged_yet),
color = AppColors.text, color = AppColors.text,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.W600 fontWeight = FontWeight.W600,
) textAlign = TextAlign.Center,
Spacer(modifier = Modifier.size(8.dp)) modifier = Modifier.padding(horizontal = 24.dp),
androidx.compose.material.Text( maxLines = 2,
text = "试着发信号出来,某人就会被吸引啦~", overflow = TextOverflow.Ellipsis
color = AppColors.text,
fontSize = 14.sp,
fontWeight = FontWeight.W400
) )
} }
} }

View File

@@ -24,6 +24,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -125,25 +127,20 @@ fun FollowingListScreen(userId: Int) {
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Image( Image(
painter = painterResource( painter = painterResource(id = R.mipmap.frame_31),
id = if(AppState.darkMode) R.mipmap.frame_3
else R.mipmap.invalid_name_9),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(181.dp, 153.dp) modifier = Modifier.size(181.dp, 153.dp)
) )
Spacer(modifier = Modifier.size(9.dp)) // 调整间距为9dp Spacer(modifier = Modifier.size(9.dp)) // 调整间距为9dp
androidx.compose.material.Text( androidx.compose.material.Text(
text = "还没有关注任何灵魂", text = stringResource(R.string.awaiting_traveler),
color = appColors.text, color = appColors.text,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.W600 fontWeight = FontWeight.W600,
) textAlign = TextAlign.Center,
Spacer(modifier = Modifier.size(8.dp)) modifier = Modifier.padding(horizontal = 24.dp),
androidx.compose.material.Text( maxLines = 2,
text = "探索一下,总有一个你想靠近的光点 ✨", overflow = TextOverflow.Ellipsis
color = appColors.secondaryText,
fontSize = 14.sp,
fontWeight = FontWeight.W400
) )
} }
} }

View File

@@ -0,0 +1,592 @@
package com.aiosman.ravenow.ui.group
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.rememberLottieComposition
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.TabItem
import com.aiosman.ravenow.ui.composables.TabSpacer
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AddGroupMemberScreen(groupId: String, groupName: String?) {
val AppColors = LocalAppTheme.current
val navController = LocalNavController.current
val systemUiController = rememberSystemUiController()
val context = LocalContext.current
// 状态管理
var searchText by remember { mutableStateOf(TextFieldValue("")) }
var selectedMembers by remember { mutableStateOf(listOf<GroupMember>()) }
var selectedMemberIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var pagerState = rememberPagerState(pageCount = { 2 })
var scope = rememberCoroutineScope()
// LazyRow状态管理
val lazyRowState = rememberLazyListState()
// 清除错误信息
LaunchedEffect(searchText.text) {
if (AddGroupMemberViewModel.errorMessage != null) {
AddGroupMemberViewModel.clearError()
}
}
// 监听selectedMembers变化当有新成员添加时自动滚动到最后一个
LaunchedEffect(selectedMembers.size) {
if (selectedMembers.isNotEmpty()) {
kotlinx.coroutines.delay(100)
lazyRowState.animateScrollToItem(selectedMembers.size - 1)
}
}
val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
LaunchedEffect(Unit) {
systemUiController.setNavigationBarColor(Color.Transparent)
AddGroupMemberViewModel.groupName = groupName
AddGroupMemberViewModel.trtcId = groupId
AddGroupMemberViewModel.roomId = null
}
Box(
modifier = Modifier
.fillMaxSize()
.background(AppColors.background)
) {
Column(
modifier = Modifier
.fillMaxSize()
) {
StatusBarSpacer()
// 头部
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 返回按钮
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = "back",
modifier = Modifier
.size(24.dp)
.noRippleClickable {
navController.popBackStack()
},
colorFilter = ColorFilter.tint(AppColors.text)
)
// 标题
Text(
text = stringResource(R.string.group_chat_info_add_member),
fontSize = 17.sp,
fontWeight = FontWeight.W700,
color = AppColors.text,
modifier = Modifier
.weight(1f)
.padding(start = 16.dp)
)
}
// 搜索栏暂时不实现但保留UI
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.background(
color = AppColors.inputBackground,
shape = RoundedCornerShape(8.dp)
)
.padding(horizontal = 16.dp, vertical = 13.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_nav_search),
contentDescription = stringResource(R.string.search),
modifier = Modifier.size(16.dp),
colorFilter = ColorFilter.tint(AppColors.secondaryText)
)
Spacer(modifier = Modifier.width(8.dp))
BasicTextField(
value = searchText,
onValueChange = { searchText = it },
textStyle = androidx.compose.ui.text.TextStyle(
color = AppColors.text,
fontSize = 14.sp
),
modifier = Modifier.weight(1f),
singleLine = true,
cursorBrush = SolidColor(AppColors.text),
decorationBox = { innerTextField ->
Box {
if (searchText.text.isEmpty()) {
Text(
text = stringResource(R.string.search),
color = AppColors.secondaryText,
fontSize = 14.sp
)
}
innerTextField()
}
}
)
}
}
// 已选成员列表
if (selectedMembers.isNotEmpty()) {
LazyRow(
state = lazyRowState,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(selectedMembers) { member ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(48.dp)
) {
Box {
CustomAsyncImage(
context = context,
imageUrl = member.avatar,
contentDescription = member.name,
defaultRes = R.drawable.default_avatar,
placeholderRes = R.drawable.default_avatar,
errorRes = R.drawable.default_avatar,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
)
// 删除按钮
Box(
modifier = Modifier
.size(20.dp)
.background(AppColors.error, CircleShape)
.align(Alignment.TopEnd)
.noRippleClickable {
val (newSelectedMemberIds, newSelectedMembers) = AddGroupMemberViewModel.removeSelectedMember(
member, selectedMemberIds, selectedMembers
)
selectedMemberIds = newSelectedMemberIds
selectedMembers = newSelectedMembers
},
contentAlignment = Alignment.Center
) {
Text(
text = "×",
color = AppColors.mainText,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
}
}
// 名称显示
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (member.name.length > 5) {
member.name.substring(0, 5) + "..."
} else {
member.name
},
fontSize = 12.sp,
color = AppColors.text,
maxLines = 1,
modifier = Modifier.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
)
}
}
}
}
// Tab切换
Row(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
TabItem(
text = stringResource(R.string.chat_ai),
isSelected = pagerState.currentPage == 0,
onClick = {
scope.launch {
pagerState.animateScrollToPage(0)
}
}
)
TabSpacer()
TabItem(
text = stringResource(R.string.chat_friend),
isSelected = pagerState.currentPage == 1,
onClick = {
scope.launch {
pagerState.animateScrollToPage(1)
}
}
)
}
// 内容区域 - 自适应填满剩余高度
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
when (it) {
0 -> {
// AI智能体列表
AddMemberAiAgentListScreen(
searchText = searchText.text,
selectedMemberIds = selectedMemberIds,
excludeRoomId = null,
onMemberSelect = { member ->
val (newSelectedMemberIds, newSelectedMembers) = AddGroupMemberViewModel.toggleMemberSelection(
member, selectedMemberIds, selectedMembers
)
selectedMemberIds = newSelectedMemberIds
selectedMembers = newSelectedMembers
}
)
}
1 -> {
// 朋友列表
AddMemberFriendListScreen(
searchText = searchText.text,
selectedMemberIds = selectedMemberIds,
roomId = null,
onMemberSelect = { member ->
val (newSelectedMemberIds, newSelectedMembers) = AddGroupMemberViewModel.toggleMemberSelection(
member, selectedMemberIds, selectedMembers
)
selectedMemberIds = newSelectedMemberIds
selectedMembers = newSelectedMembers
}
)
}
}
}
// 确认按钮 - 固定在底部
Button(
onClick = {
if (selectedMembers.isNotEmpty()) {
scope.launch {
val success = AddGroupMemberViewModel.addMembersToGroup(selectedMembers)
if (success) {
navController.popBackStack()
}
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = navigationBarPadding + 16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = AppColors.main,
contentColor = AppColors.mainText,
disabledContainerColor = AppColors.disabledBackground,
disabledContentColor = AppColors.text
),
shape = RoundedCornerShape(24.dp),
enabled = selectedMembers.isNotEmpty() && !AddGroupMemberViewModel.isLoading
) {
Text(
text = stringResource(R.string.lets_ride_upper),
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
}
}
// 居中显示的错误提示弹窗
AddGroupMemberViewModel.errorMessage?.let { error ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
androidx.compose.material3.Card(
modifier = Modifier
.fillMaxWidth(0.8f),
shape = RoundedCornerShape(8.dp)
) {
Text(
text = error,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
color = Color.Red,
fontSize = 14.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
}
}
}
}
// 支持excludeRoomId的AI智能体列表Screen
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun AddMemberAiAgentListScreen(
searchText: String,
selectedMemberIds: Set<String> = emptySet(),
excludeRoomId: Int? = null,
onMemberSelect: (GroupMember) -> Unit
) {
val AppColors = LocalAppTheme.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val viewModel = remember(excludeRoomId) { AddMemberAiAgentListViewModel(excludeRoomId) }
val filteredAgents = viewModel.getFilteredAgents(searchText)
val pullRefreshState = rememberPullRefreshState(
refreshing = viewModel.isRefreshing,
onRefresh = {
viewModel.refresh(searchText)
}
)
LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo }
.collect { visibleItems ->
if (visibleItems.isNotEmpty()) {
val lastVisibleItem = visibleItems.last()
if (lastVisibleItem.index >= filteredAgents.size - 3 && viewModel.hasMoreData && !viewModel.isLoadingMore && !viewModel.isLoading) {
viewModel.loadMore(searchText)
}
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {
if (viewModel.isLoading && filteredAgents.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
LottieAnimation(
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
iterations = LottieConstants.IterateForever,
modifier = Modifier.size(80.dp)
)
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = filteredAgents,
key = { it.id }
) { agent ->
MemberItem(
member = agent,
isSelected = selectedMemberIds.contains(agent.id),
onSelect = { onMemberSelect(agent) }
)
}
if (viewModel.isLoadingMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
LottieAnimation(
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
iterations = LottieConstants.IterateForever,
modifier = Modifier.size(80.dp)
)
}
}
}
}
}
PullRefreshIndicator(
refreshing = viewModel.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter),
backgroundColor = AppColors.background,
contentColor = AppColors.main
)
}
}
// 支持roomId的朋友列表Screen
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun AddMemberFriendListScreen(
searchText: String,
selectedMemberIds: Set<String> = emptySet(),
roomId: Int? = null,
onMemberSelect: (GroupMember) -> Unit
) {
val AppColors = LocalAppTheme.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val viewModel = remember(roomId) { AddMemberFriendListViewModel(roomId) }
val filteredFriends = viewModel.getFilteredFriends(searchText)
val pullRefreshState = rememberPullRefreshState(
refreshing = viewModel.isRefreshing,
onRefresh = {
viewModel.refresh(searchText)
}
)
LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo }
.collect { visibleItems ->
if (visibleItems.isNotEmpty()) {
val lastVisibleItem = visibleItems.last()
if (lastVisibleItem.index >= filteredFriends.size - 3 && viewModel.hasMoreData && !viewModel.isLoadingMore && !viewModel.isLoading) {
viewModel.loadMore(searchText)
}
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {
if (viewModel.isLoading && filteredFriends.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
LottieAnimation(
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
iterations = LottieConstants.IterateForever,
modifier = Modifier.size(80.dp)
)
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = filteredFriends,
key = { it.id }
) { friend ->
MemberItem(
member = friend,
isSelected = selectedMemberIds.contains(friend.id),
onSelect = { onMemberSelect(friend) }
)
}
if (viewModel.isLoadingMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
LottieAnimation(
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
iterations = LottieConstants.IterateForever,
modifier = Modifier.size(80.dp)
)
}
}
}
}
}
PullRefreshIndicator(
refreshing = viewModel.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter),
backgroundColor = AppColors.background,
contentColor = AppColors.main
)
}
}

View File

@@ -0,0 +1,360 @@
package com.aiosman.ravenow.ui.group
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.RoomService
import com.aiosman.ravenow.data.RoomServiceImpl
import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.data.api.ApiClient
import kotlinx.coroutines.launch
object AddGroupMemberViewModel : ViewModel() {
val accountService: AccountService = AccountServiceImpl()
val userService: UserService = UserServiceImpl()
val roomService: RoomService = RoomServiceImpl()
// 状态管理
var isLoading by mutableStateOf(false)
var errorMessage by mutableStateOf<String?>(null)
var groupName: String? = null
var trtcId: String? = null
var roomId: Int? = null
// 添加成员到群聊
suspend fun addMembersToGroup(
selectedMembers: List<GroupMember>
): Boolean {
return try {
isLoading = true
// 验证房间标识
if (trtcId == null && roomId == null) {
isLoading = false
val errorMsg = "房间标识不能为空"
showToast(errorMsg)
return false
}
// 根据isAi属性分别获取用户和智能体的OpenID列表
val userOpenIds = selectedMembers.filter { !it.isAi }.map { it.id }
val agentOpenIds = selectedMembers.filter { it.isAi }.map { it.id }
var allSuccess = true
var errorMessages = mutableListOf<String>()
// 添加用户到房间
if (userOpenIds.isNotEmpty()) {
try {
val userResult = roomService.addUserToRoom(
roomId = roomId,
trtcId = trtcId,
openIds = userOpenIds
)
// 检查添加结果
if (userResult.failedCount > 0) {
val failedUsers = userResult.failedItems.joinToString(", ") { it.userId }
errorMessages.add("部分用户添加失败: $failedUsers")
}
if (userResult.successCount == 0 && userResult.failedCount > 0) {
allSuccess = false
}
} catch (e: Exception) {
allSuccess = false
errorMessages.add("添加用户失败: ${e.message}")
}
}
// 添加智能体到房间
if (agentOpenIds.isNotEmpty()) {
try {
val agentResult = roomService.addAgentToRoom(
roomId = roomId,
trtcId = trtcId,
agentOpenIds = agentOpenIds
)
// 检查添加结果
if (agentResult.failedCount > 0) {
val failedAgents = agentResult.failedItems.joinToString(", ") { it.agentOpenId }
errorMessages.add("部分智能体添加失败: $failedAgents")
}
if (agentResult.successCount == 0 && agentResult.failedCount > 0) {
allSuccess = false
}
} catch (e: Exception) {
allSuccess = false
errorMessages.add("添加智能体失败: ${e.message}")
}
}
isLoading = false
if (allSuccess) {
if (errorMessages.isNotEmpty()) {
// 有部分失败,但至少有一些成功
showToast(errorMessages.joinToString("\n"))
}
true
} else {
// 全部失败
val errorMsg = if (errorMessages.isNotEmpty()) {
errorMessages.joinToString("\n")
} else {
"添加成员失败"
}
showToast(errorMsg)
false
}
} catch (e: Exception) {
isLoading = false
val errorMsg = "添加成员失败: ${e.message}"
showToast(errorMsg)
false
}
}
private fun showToast(message: String) {
Log.w("AddGroupMemberViewModel", message)
}
// 清除错误信息
fun clearError() {
errorMessage = null
}
// 添加成员到选中列表
fun addSelectedMember(member: GroupMember, selectedMemberIds: Set<String>, selectedMembers: List<GroupMember>): Pair<Set<String>, List<GroupMember>> {
val newSelectedMemberIds = selectedMemberIds + member.id
val newSelectedMembers = if (selectedMembers.none { it.id == member.id }) {
selectedMembers + member
} else {
selectedMembers
}
return Pair(newSelectedMemberIds, newSelectedMembers)
}
// 从选中列表移除成员
fun removeSelectedMember(member: GroupMember, selectedMemberIds: Set<String>, selectedMembers: List<GroupMember>): Pair<Set<String>, List<GroupMember>> {
val newSelectedMemberIds = selectedMemberIds - member.id
val newSelectedMembers = selectedMembers.filter { it.id != member.id }
return Pair(newSelectedMemberIds, newSelectedMembers)
}
// 切换成员选中状态
fun toggleMemberSelection(member: GroupMember, selectedMemberIds: Set<String>, selectedMembers: List<GroupMember>): Pair<Set<String>, List<GroupMember>> {
return if (selectedMemberIds.contains(member.id)) {
removeSelectedMember(member, selectedMemberIds, selectedMembers)
} else {
addSelectedMember(member, selectedMemberIds, selectedMembers)
}
}
}
// 支持roomId的AI智能体列表ViewModel
class AddMemberAiAgentListViewModel(private val excludeRoomId: Int? = null) : ViewModel() {
private val accountService: AccountService = AccountServiceImpl()
var aiAgents by mutableStateOf<List<GroupMember>>(emptyList())
private set
var isLoading by mutableStateOf(false)
private set
var isRefreshing by mutableStateOf(false)
private set
var isLoadingMore by mutableStateOf(false)
private set
var currentPage by mutableStateOf(1)
private set
var hasMoreData by mutableStateOf(true)
private set
var errorMessage by mutableStateOf<String?>(null)
private set
private val pageSize = 20
init {
loadAgents(1)
}
fun loadAgents(page: Int, isRefresh: Boolean = false, searchText: String = "") {
viewModelScope.launch {
try {
if (isRefresh) {
isRefreshing = true
} else if (page == 1) {
isLoading = true
} else {
isLoadingMore = true
}
errorMessage = null
val response = accountService.getAgent(page, pageSize, excludeRoomId = excludeRoomId, title = if (searchText.isNotEmpty()) searchText else null, desc = if (searchText.isNotEmpty()) searchText else null)
if (response.isSuccessful && response.body() != null) {
val agentData = response.body()!!
val newAgents: List<GroupMember> = agentData.data.list.map { agent ->
GroupMember(
id = agent.openId,
name = agent.title,
avatar = "${ApiClient.BASE_API_URL+"/outside"}${agent.avatar}"+"?token="+"${AppStore.token}",
isAi = true
)
}
if (isRefresh || page == 1) {
aiAgents = newAgents
currentPage = 1
} else {
aiAgents = aiAgents + newAgents
currentPage = page
}
hasMoreData = newAgents.size >= pageSize
} else {
errorMessage = "获取AI智能体列表失败: ${response.message()}"
}
} catch (e: Exception) {
errorMessage = "获取AI智能体列表失败: ${e.message}"
} finally {
isLoading = false
isRefreshing = false
isLoadingMore = false
}
}
}
fun refresh(searchText: String = "") {
loadAgents(1, true, searchText)
}
fun loadMore(searchText: String = "") {
if (hasMoreData && !isLoadingMore && !isLoading) {
loadAgents(currentPage + 1, false, searchText)
}
}
fun clearError() {
errorMessage = null
}
fun getFilteredAgents(searchText: String): List<GroupMember> {
return if (searchText.isEmpty()) {
aiAgents
} else {
aiAgents.filter { it.name.contains(searchText, ignoreCase = true) }
}
}
}
// 支持roomId的朋友列表ViewModel
class AddMemberFriendListViewModel(private val roomId: Int? = null) : ViewModel() {
private val userService: UserService = UserServiceImpl()
var friends by mutableStateOf<List<GroupMember>>(emptyList())
private set
var isLoading by mutableStateOf(false)
private set
var isRefreshing by mutableStateOf(false)
private set
var isLoadingMore by mutableStateOf(false)
private set
var currentPage by mutableStateOf(1)
private set
var hasMoreData by mutableStateOf(true)
private set
var errorMessage by mutableStateOf<String?>(null)
private set
private val pageSize = 20
init {
loadFriends(1)
}
fun loadFriends(page: Int, isRefresh: Boolean = false, searchText: String = "") {
viewModelScope.launch {
try {
if (isRefresh) {
isRefreshing = true
} else if (page == 1) {
isLoading = true
} else {
isLoadingMore = true
}
errorMessage = null
val userData = userService.getUsers(pageSize, page, nickname = if (searchText.isNotEmpty()) searchText else null, roomId = roomId)
val newFriends: List<GroupMember> = userData.list.map { user ->
GroupMember(
id = user.chatAIId,
name = user.nickName,
avatar = user.avatar,
isAi = false
)
}
if (isRefresh || page == 1) {
friends = newFriends
currentPage = 1
} else {
friends = friends + newFriends
currentPage = page
}
hasMoreData = newFriends.size >= pageSize
} catch (e: Exception) {
errorMessage = "获取朋友列表失败: ${e.message}"
} finally {
isLoading = false
isRefreshing = false
isLoadingMore = false
}
}
}
fun refresh(searchText: String = "") {
loadFriends(1, true, searchText)
}
fun loadMore(searchText: String = "") {
if (hasMoreData && !isLoadingMore && !isLoading) {
loadFriends(currentPage + 1, false, searchText)
}
}
fun clearError() {
errorMessage = null
}
fun getFilteredFriends(searchText: String): List<GroupMember> {
return if (searchText.isEmpty()) {
friends
} else {
friends.filter { it.name.contains(searchText, ignoreCase = true) }
}
}
}

View File

@@ -11,6 +11,10 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.rememberLottieComposition
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -78,10 +82,10 @@ fun AiAgentListScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( LottieAnimation(
text = "加载中...", composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
color = AppColors.secondaryText, iterations = LottieConstants.IterateForever,
fontSize = 14.sp modifier = Modifier.size(80.dp)
) )
} }
} else { } else {
@@ -111,10 +115,10 @@ fun AiAgentListScreen(
.padding(16.dp), .padding(16.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( LottieAnimation(
text = "加载更多...", composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
color = AppColors.secondaryText, iterations = LottieConstants.IterateForever,
fontSize = 14.sp modifier = Modifier.size(80.dp)
) )
} }
} }

View File

@@ -3,6 +3,7 @@ package com.aiosman.ravenow.ui.group
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -14,11 +15,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
@@ -27,11 +32,15 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.aiosman.ravenow.LocalAppTheme import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.PointService
import com.aiosman.ravenow.ui.composables.CustomAsyncImage import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarSpacer import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.TabItem import com.aiosman.ravenow.ui.composables.TabItem
@@ -91,6 +100,19 @@ fun CreateGroupChatScreen() {
val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
// 获取费用和余额信息
val pointsRules by PointService.pointsRules.collectAsState(initial = null)
val pointsBalance by PointService.pointsBalance.collectAsState(initial = null)
val roomMaxMembers by PointService.roomMaxMembers.collectAsState(initial = null)
val cost = CreateGroupChatViewModel.getCreateRoomCost()
val currentBalance = CreateGroupChatViewModel.getCurrentBalance()
val balanceAfterCost = CreateGroupChatViewModel.calculateBalanceAfterCost(cost)
val isBalanceSufficient = CreateGroupChatViewModel.isBalanceSufficient(cost)
// 获取群聊初始上限
val maxMemberLimit = roomMaxMembers?.defaultMaxTotal ?: 5
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
systemUiController.setNavigationBarColor(Color.Transparent) systemUiController.setNavigationBarColor(Color.Transparent)
} }
@@ -367,33 +389,47 @@ fun CreateGroupChatScreen() {
} }
} }
// Tab切换 // Tab切换和成员数量显示
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight() .wrapContentHeight()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.CenterVertically
) { ) {
TabItem( // Tab左对齐
text = stringResource(R.string.chat_ai), Row(
isSelected = pagerState.currentPage == 0, horizontalArrangement = Arrangement.Start,
onClick = { verticalAlignment = Alignment.CenterVertically
scope.launch { ) {
pagerState.animateScrollToPage(0) TabItem(
text = stringResource(R.string.chat_ai),
isSelected = pagerState.currentPage == 0,
onClick = {
scope.launch {
pagerState.animateScrollToPage(0)
}
} }
} )
) TabSpacer()
TabSpacer() TabItem(
TabItem( text = stringResource(R.string.chat_friend),
text = stringResource(R.string.chat_friend), isSelected = pagerState.currentPage == 1,
isSelected = pagerState.currentPage == 1, onClick = {
onClick = { scope.launch {
scope.launch { pagerState.animateScrollToPage(1)
pagerState.animateScrollToPage(1) }
} }
} )
}
// 成员数量显示右对齐x/x格式
Text(
text = "${selectedMembers.size}/$maxMemberLimit",
fontSize = 14.sp,
color = if (selectedMembers.size > maxMemberLimit) AppColors.error else AppColors.secondaryText,
fontWeight = FontWeight.W500
) )
} }
@@ -436,26 +472,60 @@ fun CreateGroupChatScreen() {
} }
} }
// 余额和扣减积分显示(创建按钮上方)
val buttonTopPadding = if (cost > 0) 4.dp else 16.dp
if (cost > 0) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.Center
) {
Text(
text = "${stringResource(R.string.create_group_chat_current_balance)}: ${currentBalance.formatNumber()} ${stringResource(R.string.pai_coin)}",
fontSize = 12.sp,
color = AppColors.secondaryText
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = "${stringResource(R.string.create_group_chat_required_cost)} ${cost.formatNumber()} ${stringResource(R.string.pai_coin)}",
fontSize = 12.sp,
color = AppColors.secondaryText
)
}
}
// 创建群聊按钮 - 固定在底部 // 创建群聊按钮 - 固定在底部
Button( Button(
onClick = { onClick = {
// 创建群聊逻辑 // 创建群聊逻辑
if (selectedMembers.isNotEmpty()) { if (selectedMembers.isNotEmpty()) {
scope.launch { // 检查是否超过上限
val success = CreateGroupChatViewModel.createGroupChat( if (selectedMembers.size > maxMemberLimit) {
groupName = groupName.text, CreateGroupChatViewModel.showError(context.getString(R.string.create_group_chat_exceed_limit, maxMemberLimit))
selectedMembers = selectedMembers, return@Button
context = context }
) // 如果费用大于0显示确认弹窗
if (success) { if (cost > 0) {
navController.popBackStack() CreateGroupChatViewModel.showConfirmDialog()
} else {
// 费用为0直接创建
scope.launch {
val success = CreateGroupChatViewModel.createGroupChat(
groupName = groupName.text,
selectedMembers = selectedMembers,
context = context
)
if (success) {
navController.popBackStack()
}
} }
} }
} }
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = navigationBarPadding + 16.dp), .padding(start = 16.dp, end = 16.dp, top = buttonTopPadding, bottom = navigationBarPadding + 16.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = AppColors.main, containerColor = AppColors.main,
contentColor = AppColors.mainText, contentColor = AppColors.mainText,
@@ -467,7 +537,7 @@ fun CreateGroupChatScreen() {
) { ) {
if (CreateGroupChatViewModel.isLoading) { if (CreateGroupChatViewModel.isLoading) {
Text( Text(
text = "创建中...", text = stringResource(R.string.agent_createing),
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.W600 fontWeight = FontWeight.W600
) )
@@ -482,6 +552,38 @@ fun CreateGroupChatScreen() {
} }
// 消费确认弹窗
if (CreateGroupChatViewModel.showConfirmDialog) {
CreateGroupChatConfirmDialog(
cost = cost,
currentBalance = currentBalance,
balanceAfterCost = balanceAfterCost,
isBalanceSufficient = isBalanceSufficient,
onConfirm = {
// 检查是否超过上限
if (selectedMembers.size > maxMemberLimit) {
CreateGroupChatViewModel.hideConfirmDialog()
CreateGroupChatViewModel.showError(context.getString(R.string.create_group_chat_exceed_limit, maxMemberLimit))
} else {
CreateGroupChatViewModel.hideConfirmDialog()
scope.launch {
val success = CreateGroupChatViewModel.createGroupChat(
groupName = groupName.text,
selectedMembers = selectedMembers,
context = context
)
if (success) {
navController.popBackStack()
}
}
}
},
onCancel = {
CreateGroupChatViewModel.hideConfirmDialog()
}
)
}
// 居中显示的错误提示弹窗 // 居中显示的错误提示弹窗
CreateGroupChatViewModel.errorMessage?.let { error -> CreateGroupChatViewModel.errorMessage?.let { error ->
Box( Box(
@@ -496,7 +598,7 @@ fun CreateGroupChatScreen() {
horizontalAlignment = Alignment.CenterHorizontally, // 水平居中 horizontalAlignment = Alignment.CenterHorizontally, // 水平居中
verticalArrangement = Arrangement.Center // 垂直居中 verticalArrangement = Arrangement.Center // 垂直居中
) { ) {
androidx.compose.material3.Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.8f), .fillMaxWidth(0.8f),
shape = RoundedCornerShape(8.dp) shape = RoundedCornerShape(8.dp)
@@ -508,7 +610,7 @@ fun CreateGroupChatScreen() {
.fillMaxWidth(), .fillMaxWidth(),
color = Color.Red, color = Color.Red,
fontSize = 14.sp, fontSize = 14.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center textAlign = TextAlign.Center
) )
} }
} }
@@ -516,3 +618,219 @@ fun CreateGroupChatScreen() {
} }
} }
} }
/**
* 创建群聊消费确认弹窗
*/
@Composable
fun CreateGroupChatConfirmDialog(
cost: Int,
currentBalance: Int,
balanceAfterCost: Int,
isBalanceSufficient: Boolean,
onConfirm: () -> Unit,
onCancel: () -> Unit
) {
val AppColors = LocalAppTheme.current
Dialog(
onDismissRequest = onCancel,
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true
)
) {
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.padding(16.dp),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 硬币图标(使用文本代替,实际项目中可以使用图片资源)
Box(
modifier = Modifier
.size(64.dp)
.background(
brush = Brush.linearGradient(
colors = listOf(
Color(0xFFFFD700), // 金色
Color(0xFFFFA500) // 橙色
)
),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Text(
text = "pai",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(16.dp))
// 标题
Text(
text = stringResource(R.string.create_group_chat_confirm_title),
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = AppColors.text
)
Spacer(modifier = Modifier.height(24.dp))
// 需要消耗
CostInfoRow(
label = stringResource(R.string.create_group_chat_required_cost),
amount = cost,
AppColors = AppColors
)
Spacer(modifier = Modifier.height(12.dp))
// 当前余额
CostInfoRow(
label = stringResource(R.string.create_group_chat_current_balance),
amount = currentBalance,
AppColors = AppColors
)
Spacer(modifier = Modifier.height(12.dp))
// 消耗后余额
CostInfoRow(
label = stringResource(R.string.create_group_chat_balance_after),
amount = balanceAfterCost,
AppColors = AppColors
)
// 余额不足提示
if (!isBalanceSufficient) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.create_group_chat_insufficient_balance),
color = Color.Red,
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.height(24.dp))
// 按钮行
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// 取消按钮
OutlinedButton(
onClick = onCancel,
modifier = Modifier
.weight(1f)
.height(48.dp)
.border(
width = 1.dp,
color = AppColors.secondaryText.copy(alpha = 0.3f),
shape = RoundedCornerShape(24.dp)
),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = AppColors.text
),
shape = RoundedCornerShape(24.dp)
) {
Text(
text = stringResource(R.string.cancel),
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
}
// 确认消耗按钮
Button(
onClick = onConfirm,
modifier = Modifier
.weight(1f)
.height(48.dp),
enabled = isBalanceSufficient,
colors = ButtonDefaults.buttonColors(
containerColor = AppColors.main,
contentColor = AppColors.mainText,
disabledContainerColor = AppColors.disabledBackground,
disabledContentColor = AppColors.text
),
shape = RoundedCornerShape(24.dp)
) {
Text(
text = stringResource(R.string.create_group_chat_confirm_consume),
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
}
}
}
}
}
}
/**
* 费用信息行组件
*/
@Composable
fun CostInfoRow(
label: String,
amount: Int,
AppColors: com.aiosman.ravenow.AppThemeData
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
fontSize = 14.sp,
color = AppColors.text
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
// 小硬币图标(使用简单的圆形)
Box(
modifier = Modifier
.size(16.dp)
.background(
brush = Brush.linearGradient(
colors = listOf(
Color(0xFFFFD700),
Color(0xFFFFA500)
)
),
shape = CircleShape
)
)
Text(
text = "${amount.formatNumber()} ${stringResource(R.string.pai_coin)}",
fontSize = 14.sp,
fontWeight = FontWeight.W600,
color = AppColors.text
)
}
}
}
/**
* 格式化数字,添加千位分隔符
*/
fun Int.formatNumber(): String {
return this.toString().reversed().chunked(3).joinToString(",").reversed()
}

View File

@@ -16,8 +16,10 @@ import com.aiosman.ravenow.ConstVars
import com.aiosman.ravenow.data.AccountNotice import com.aiosman.ravenow.data.AccountNotice
import com.aiosman.ravenow.data.AccountService import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.PointService
import com.aiosman.ravenow.data.UserService import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.UserServiceImpl import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.CommentEntity import com.aiosman.ravenow.entity.CommentEntity
import com.aiosman.ravenow.exp.formatChatTime import com.aiosman.ravenow.exp.formatChatTime
import com.aiosman.ravenow.ui.NavigationRoute import com.aiosman.ravenow.ui.NavigationRoute
@@ -35,6 +37,7 @@ object CreateGroupChatViewModel : ViewModel() {
// 状态管理 // 状态管理
var isLoading by mutableStateOf(false) var isLoading by mutableStateOf(false)
var errorMessage by mutableStateOf<String?>(null) var errorMessage by mutableStateOf<String?>(null)
var showConfirmDialog by mutableStateOf(false)
// 创建群聊 // 创建群聊
suspend fun createGroupChat( suspend fun createGroupChat(
@@ -55,13 +58,13 @@ object CreateGroupChatViewModel : ViewModel() {
true true
} else { } else {
isLoading = false isLoading = false
val errorMsg = "创建群聊失败: ${response.message()}" val errorMsg = context.getString(R.string.create_group_chat_failed, response.message() ?: "")
showToast(errorMsg) showToast(errorMsg)
false false
} }
} catch (e: Exception) { } catch (e: Exception) {
isLoading = false isLoading = false
val errorMsg = "创建群聊失败: ${e.message}" val errorMsg = context.getString(R.string.create_group_chat_failed, e.message ?: "")
showToast(errorMsg) showToast(errorMsg)
false false
} }
@@ -75,6 +78,11 @@ object CreateGroupChatViewModel : ViewModel() {
} }
} }
// 显示错误信息(公开方法)
fun showError(message: String) {
showToast(message)
}
// 清除错误信息 // 清除错误信息
fun clearError() { fun clearError() {
errorMessage = null errorMessage = null
@@ -106,4 +114,59 @@ object CreateGroupChatViewModel : ViewModel() {
addSelectedMember(member, selectedMemberIds, selectedMembers) addSelectedMember(member, selectedMemberIds, selectedMembers)
} }
} }
/**
* 获取创建群聊的费用
* @return 费用金额,如果无法获取则返回 0
*/
fun getCreateRoomCost(): Int {
val rules = PointService.pointsRules.value
val costRule = rules?.sub?.get(PointService.PointsRuleKey.CREATE_ROOM)
return when (costRule) {
is PointService.RuleAmount.Fixed -> costRule.value
is PointService.RuleAmount.Range -> costRule.min // 使用最小值作为默认费用
null -> 0
}
}
/**
* 获取当前余额
* @return 当前余额,如果无法获取则返回 0
*/
fun getCurrentBalance(): Int {
return PointService.pointsBalance.value?.balance ?: 0
}
/**
* 计算消耗后余额
* @param cost 费用
* @return 消耗后余额
*/
fun calculateBalanceAfterCost(cost: Int): Int {
val currentBalance = getCurrentBalance()
return (currentBalance - cost).coerceAtLeast(0)
}
/**
* 检查余额是否充足
* @param cost 费用
* @return 是否充足
*/
fun isBalanceSufficient(cost: Int): Boolean {
return getCurrentBalance() >= cost
}
/**
* 显示确认弹窗
*/
fun showConfirmDialog() {
showConfirmDialog = true
}
/**
* 隐藏确认弹窗
*/
fun hideConfirmDialog() {
showConfirmDialog = false
}
} }

View File

@@ -11,6 +11,10 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.rememberLottieComposition
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -78,10 +82,10 @@ fun FriendListScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( LottieAnimation(
text = "加载中...", composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
color = AppColors.secondaryText, iterations = LottieConstants.IterateForever,
fontSize = 14.sp modifier = Modifier.size(80.dp)
) )
} }
} else { } else {
@@ -111,10 +115,10 @@ fun FriendListScreen(
.padding(16.dp), .padding(16.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( LottieAnimation(
text = "加载更多...", composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
color = AppColors.secondaryText, iterations = LottieConstants.IterateForever,
fontSize = 14.sp modifier = Modifier.size(80.dp)
) )
} }
} }

View File

@@ -40,9 +40,12 @@ import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.composables.CustomAsyncImage import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.PointsPaymentDialog
import com.aiosman.ravenow.ui.composables.StatusBarSpacer import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.index.NavItem import com.aiosman.ravenow.ui.index.NavItem
import com.aiosman.ravenow.ui.modifiers.noRippleClickable import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToGroupMembers
import com.aiosman.ravenow.ui.navigateToGroupProfileSettings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -64,6 +67,8 @@ fun GroupChatInfoScreen(groupId: String) {
var showAddMemoryDialog by remember { mutableStateOf(false) } var showAddMemoryDialog by remember { mutableStateOf(false) }
var showMemoryManageDialog by remember { mutableStateOf(false) } var showMemoryManageDialog by remember { mutableStateOf(false) }
var showVisibilityDialog by remember { mutableStateOf(false) } var showVisibilityDialog by remember { mutableStateOf(false) }
var showVisibilityPaymentDialog by remember { mutableStateOf(false) }
var pendingIsPrivate by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val memoryManageSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val memoryManageSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -285,27 +290,6 @@ fun GroupChatInfoScreen(groupId: String) {
} }
} }
// 解锁群扩展 横幅
item {
Spacer(modifier = Modifier.height(12.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(AppColors.decentBackground.copy(alpha = 0.35f))
.padding(horizontal = 10.dp, vertical = 10.dp),
contentAlignment = Alignment.CenterStart
) {
Text(
text = stringResource(R.string.group_chat_info_unlock_extension),
style = androidx.compose.ui.text.TextStyle(
color = AppColors.main,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
)
}
}
// 群记忆 卡片 // 群记忆 卡片
item { item {
@@ -395,14 +379,14 @@ fun GroupChatInfoScreen(groupId: String) {
item { item {
Spacer(modifier = Modifier.height(13.dp)) Spacer(modifier = Modifier.height(13.dp))
// 设置聊天主题 // 群资料设置
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.padding(12.dp) .padding(12.dp)
.noRippleClickable { .noRippleClickable {
// TODO: 实现设置聊天主题功能 navController.navigateToGroupProfileSettings(groupId)
}, },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -455,6 +439,8 @@ fun GroupChatInfoScreen(groupId: String) {
), ),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
// 未解锁时才显示"待解锁"
if (viewModel.groupInfo?.privateFeePaid != true) {
Text( Text(
text = stringResource(R.string.group_chat_info_locked), text = stringResource(R.string.group_chat_info_locked),
style = androidx.compose.ui.text.TextStyle( style = androidx.compose.ui.text.TextStyle(
@@ -462,6 +448,7 @@ fun GroupChatInfoScreen(groupId: String) {
fontSize = 11.sp fontSize = 11.sp
) )
) )
}
Image( Image(
painter = painterResource(R.drawable.rave_now_nav_right), painter = painterResource(R.drawable.rave_now_nav_right),
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
@@ -476,7 +463,7 @@ fun GroupChatInfoScreen(groupId: String) {
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.padding(12.dp) .padding(12.dp)
.noRippleClickable { .noRippleClickable {
// 静态占位 navController.navigateToGroupMembers(groupId)
}, },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -590,11 +577,50 @@ fun GroupChatInfoScreen(groupId: String) {
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp) shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
) { ) {
GroupVisibilityDialog( GroupVisibilityDialog(
onDismiss = { showVisibilityDialog = false } viewModel = viewModel,
onDismiss = { showVisibilityDialog = false },
onConfirmPrivate = { isPrivate ->
// 如果选择私密群组且未解锁,显示付费确认弹框
if (isPrivate && (viewModel.groupInfo?.privateFeePaid != true)) {
pendingIsPrivate = true
showVisibilityDialog = false
showVisibilityPaymentDialog = true
} else {
// 直接更新可见性
viewModel.updateVisibility(isPrivate)
showVisibilityDialog = false
}
}
) )
} }
} }
// 付费确认弹框
if (showVisibilityPaymentDialog) {
val cost = viewModel.privateGroupCost ?: 0
val currentBalance = viewModel.pointsBalance ?: 0
val balanceAfterCost = (currentBalance - cost).coerceAtLeast(0)
val isBalanceSufficient = currentBalance >= cost
PointsPaymentDialog(
cost = cost,
currentBalance = currentBalance,
balanceAfterCost = balanceAfterCost,
isBalanceSufficient = isBalanceSufficient,
onConfirm = {
// 确认支付,更新可见性
viewModel.updateVisibility(pendingIsPrivate)
showVisibilityPaymentDialog = false
},
onCancel = {
showVisibilityPaymentDialog = false
},
title = stringResource(R.string.group_chat_info_private_group),
description = stringResource(R.string.group_chat_info_private_group_desc),
isProcessing = viewModel.isUpdatingVisibility
)
}
// 添加群记忆弹窗 // 添加群记忆弹窗
if (showAddMemoryDialog) { if (showAddMemoryDialog) {
ModalBottomSheet( ModalBottomSheet(
@@ -646,6 +672,10 @@ fun GroupChatInfoScreen(groupId: String) {
}, },
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp) shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
) { ) {
// 立即展开到全屏,避免逐渐变高的动画
LaunchedEffect(Unit) {
memoryManageSheetState.expand()
}
GroupMemoryManageContent( GroupMemoryManageContent(
groupId = groupId, groupId = groupId,
viewModel = viewModel, viewModel = viewModel,
@@ -878,8 +908,13 @@ fun AddGroupMemoryDialog(
text = "", text = "",
fontSize = 13.sp fontSize = 13.sp
) )
val memoryCost = viewModel.addMemoryCost
Text( Text(
text = stringResource(R.string.group_chat_info_memory_cost), text = if (memoryCost != null && memoryCost > 0) {
"添加记忆需消耗 ${memoryCost}派币"
} else {
stringResource(R.string.group_chat_info_memory_cost)
},
style = TextStyle( style = TextStyle(
fontSize = 13.sp, fontSize = 13.sp,
color = Color.Black color = Color.Black
@@ -978,12 +1013,16 @@ fun AddGroupMemoryDialog(
@Composable @Composable
fun GroupVisibilityDialog( fun GroupVisibilityDialog(
onDismiss: () -> Unit viewModel: GroupChatInfoViewModel,
onDismiss: () -> Unit,
onConfirmPrivate: (Boolean) -> Unit
) { ) {
val AppColors = LocalAppTheme.current val AppColors = LocalAppTheme.current
var isPrivate by remember { mutableStateOf(false) } val currentTrtcType = viewModel.groupInfo?.trtcType ?: "Public"
val balance = 482 val isPrivateFeePaid = viewModel.groupInfo?.privateFeePaid == true
val unlockCost = 500 var isPrivate by remember { mutableStateOf(currentTrtcType == "Private") }
val balance = viewModel.pointsBalance ?: 0
val unlockCost = viewModel.privateGroupCost ?: 0
Column( Column(
modifier = Modifier modifier = Modifier
@@ -1038,14 +1077,15 @@ fun GroupVisibilityDialog(
VisibilityOptionItem( VisibilityOptionItem(
title = stringResource(R.string.group_chat_info_private_group), title = stringResource(R.string.group_chat_info_private_group),
desc = stringResource(R.string.group_chat_info_private_group_desc), desc = stringResource(R.string.group_chat_info_private_group_desc),
badge = stringResource(R.string.group_chat_info_private_group_cost), badge = if (!isPrivateFeePaid && unlockCost > 0) "${unlockCost}派币" else null,
selected = isPrivate, selected = isPrivate,
onClick = { isPrivate = true } onClick = { isPrivate = true }
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// 余额与费用 // 余额与费用(仅未解锁时显示)
if (!isPrivateFeePaid) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -1068,6 +1108,7 @@ fun GroupVisibilityDialog(
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
}
// 完成按钮 // 完成按钮
Box( Box(
@@ -1084,7 +1125,9 @@ fun GroupVisibilityDialog(
) )
) )
) )
.noRippleClickable { onDismiss() }, .noRippleClickable {
onConfirmPrivate(isPrivate)
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
@@ -1093,12 +1136,15 @@ fun GroupVisibilityDialog(
) )
} }
// 仅未解锁时显示充值提示
if (!isPrivateFeePaid) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.group_chat_info_recharge_hint), text = stringResource(R.string.group_chat_info_recharge_hint),
style = TextStyle(fontSize = 12.sp, color = Color(0x4D3C3C43)), style = TextStyle(fontSize = 12.sp, color = Color(0x4D3C3C43)),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
}
} }
} }

View File

@@ -9,18 +9,17 @@ import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.AppStore import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.ChatState import com.aiosman.ravenow.ChatState
import com.aiosman.ravenow.data.api.ApiClient import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.AgentRule import com.aiosman.ravenow.data.RoomService
import com.aiosman.ravenow.data.api.AgentRuleQuota import com.aiosman.ravenow.data.RoomServiceImpl
import com.aiosman.ravenow.data.api.CreateAgentRuleRequestBody
import com.aiosman.ravenow.data.api.UpdateAgentRuleRequestBody
import com.aiosman.ravenow.data.api.CreateAgentRuleRequestBody
import com.aiosman.ravenow.data.api.AgentRule
import com.aiosman.ravenow.data.api.AgentRuleQuota
import com.aiosman.ravenow.data.parseErrorResponse import com.aiosman.ravenow.data.parseErrorResponse
import com.aiosman.ravenow.data.PointService
import com.aiosman.ravenow.entity.RoomRuleEntity
import com.aiosman.ravenow.entity.RoomRuleQuotaEntity
import com.aiosman.ravenow.entity.ChatNotification import com.aiosman.ravenow.entity.ChatNotification
import com.aiosman.ravenow.entity.GroupInfo import com.aiosman.ravenow.entity.GroupInfo
import com.aiosman.ravenow.entity.GroupMember import com.aiosman.ravenow.entity.GroupMember
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class GroupChatInfoViewModel( class GroupChatInfoViewModel(
@@ -37,30 +36,26 @@ class GroupChatInfoViewModel(
val notificationStrategy get() = chatNotification?.strategy ?: "default" val notificationStrategy get() = chatNotification?.strategy ?: "default"
// 记忆管理相关状态 // 记忆管理相关状态
var memoryQuota by mutableStateOf<AgentRuleQuota?>(null) var memoryQuota by mutableStateOf<RoomRuleQuotaEntity?>(null)
var memoryList by mutableStateOf<List<AgentRule>>(emptyList()) var memoryList by mutableStateOf<List<RoomRuleEntity>>(emptyList())
var isLoadingMemory by mutableStateOf(false) var isLoadingMemory by mutableStateOf(false)
var memoryError by mutableStateOf<String?>(null) var memoryError by mutableStateOf<String?>(null)
var promptOpenId by mutableStateOf<String?>(null)
// 房间规则服务
private val roomService: RoomService = RoomServiceImpl()
// 群可见性相关状态
var privateGroupCost by mutableStateOf<Int?>(null)
var pointsBalance by mutableStateOf<Int?>(null)
var isLoadingVisibility by mutableStateOf(false)
var isUpdatingVisibility by mutableStateOf(false)
// 群记忆相关状态
var addMemoryCost by mutableStateOf<Int?>(null)
init { init {
loadGroupInfo() loadGroupInfo()
loadPromptOpenId() loadVisibilityInfo()
} loadMemoryCost()
/**
* 获取群聊中智能体的 OpenID
*/
private fun loadPromptOpenId() {
viewModelScope.launch {
try {
val response = ApiClient.api.createGroupChatAi(trtcGroupId = groupId)
val groupChatResponse = response.body()?.data
val prompts = groupChatResponse?.prompts
promptOpenId = prompts?.firstOrNull()?.openId
} catch (e: Exception) {
Log.e("GroupChatInfoViewModel", "获取智能体OpenID失败: ${e.message}", e)
}
}
} }
suspend fun updateNotificationStrategy(strategy: String) { suspend fun updateNotificationStrategy(strategy: String) {
val result = ChatState.updateChatNotification(groupId.hashCode(), strategy) val result = ChatState.updateChatNotification(groupId.hashCode(), strategy)
@@ -96,7 +91,9 @@ class GroupChatInfoViewModel(
"${ApiClient.BASE_API_URL+"/outside"}${it.avatar}"+"?token="+"${AppStore.token}" "${ApiClient.BASE_API_URL+"/outside"}${it.avatar}"+"?token="+"${AppStore.token}"
}, },
memberCount = room.userCount, memberCount = room.userCount,
isCreator = room.creator.userId == MyProfileViewModel.profile?.id.toString() isCreator = room.creator.userId == MyProfileViewModel.profile?.id.toString(),
trtcType = it.trtcType ?: "Public",
privateFeePaid = it.privateFeePaid ?: false
) )
} }
@@ -109,57 +106,27 @@ class GroupChatInfoViewModel(
} }
/** /**
* 添加群记忆 * 添加群记忆(房间规则)
* @param memoryText 记忆内容 * @param memoryText 记忆内容
* @param promptOpenId 智能体的 OpenID可选如果不提供则从群聊信息中获取
*/ */
fun addGroupMemory(memoryText: String, promptOpenId: String? = null) { fun addGroupMemory(memoryText: String) {
viewModelScope.launch { viewModelScope.launch {
try { try {
isAddingMemory = true isAddingMemory = true
addMemoryError = null addMemoryError = null
addMemorySuccess = false addMemorySuccess = false
// 如果没有提供 promptOpenId需要先获取群聊的智能体信息 // 使用房间规则接口创建群记忆
val openId = promptOpenId ?: run { roomService.createRoomRule(
// 通过 createGroupChatAi 接口获取群聊详细信息(包含 prompts
val response = ApiClient.api.createGroupChatAi(trtcGroupId = groupId)
val groupChatResponse = response.body()?.data
val prompts = groupChatResponse?.prompts
if (prompts.isNullOrEmpty()) {
throw Exception("群聊中没有找到智能体,无法添加记忆")
}
// 使用第一个智能体的 openId
prompts.firstOrNull()?.openId
?: throw Exception("无法获取智能体信息")
}
if (openId.isBlank()) {
throw Exception("智能体ID不能为空")
}
// 创建智能体规则(群记忆)
val requestBody = CreateAgentRuleRequestBody(
rule = memoryText, rule = memoryText,
openId = openId trtcId = groupId
) )
val response = ApiClient.api.createAgentRule(requestBody) addMemorySuccess = true
Log.d("GroupChatInfoViewModel", "群记忆添加成功")
if (response.isSuccessful) { // 刷新记忆列表和配额
addMemorySuccess = true loadMemoryQuota()
Log.d("GroupChatInfoViewModel", "群记忆添加成功") loadMemoryList()
// 刷新记忆列表和配额
loadMemoryQuota(openId)
loadMemoryList(openId)
} else {
val errorResponse = parseErrorResponse(response.errorBody())
val errorMessage = errorResponse?.toServiceException()?.message
?: "添加群记忆失败: ${response.code()}"
throw Exception(errorMessage)
}
} catch (e: Exception) { } catch (e: Exception) {
addMemoryError = e.message ?: "添加群记忆失败" addMemoryError = e.message ?: "添加群记忆失败"
Log.e("GroupChatInfoViewModel", "添加群记忆失败: ${e.message}", e) Log.e("GroupChatInfoViewModel", "添加群记忆失败: ${e.message}", e)
@@ -170,38 +137,16 @@ class GroupChatInfoViewModel(
} }
/** /**
* 获取记忆配额信息 * 获取记忆配额信息(房间规则配额)
*/ */
fun loadMemoryQuota(openId: String? = null) { fun loadMemoryQuota() {
viewModelScope.launch { viewModelScope.launch {
try { try {
isLoadingMemory = true isLoadingMemory = true
memoryError = null memoryError = null
val targetOpenId = openId ?: promptOpenId // 使用房间规则接口获取配额
if (targetOpenId.isNullOrBlank()) { memoryQuota = roomService.getRoomRuleQuota(trtcId = groupId)
// 如果还没有获取到 openId先获取
val response = ApiClient.api.createGroupChatAi(trtcGroupId = groupId)
val groupChatResponse = response.body()?.data
val prompts = groupChatResponse?.prompts
val fetchedOpenId = prompts?.firstOrNull()?.openId
?: throw Exception("无法获取智能体信息")
promptOpenId = fetchedOpenId
val quotaResponse = ApiClient.api.getAgentRuleQuota(fetchedOpenId)
if (quotaResponse.isSuccessful) {
memoryQuota = quotaResponse.body()?.data
} else {
throw Exception("获取配额信息失败: ${quotaResponse.code()}")
}
} else {
val quotaResponse = ApiClient.api.getAgentRuleQuota(targetOpenId)
if (quotaResponse.isSuccessful) {
memoryQuota = quotaResponse.body()?.data
} else {
throw Exception("获取配额信息失败: ${quotaResponse.code()}")
}
}
} catch (e: Exception) { } catch (e: Exception) {
memoryError = e.message ?: "获取配额信息失败" memoryError = e.message ?: "获取配额信息失败"
Log.e("GroupChatInfoViewModel", "获取配额信息失败: ${e.message}", e) Log.e("GroupChatInfoViewModel", "获取配额信息失败: ${e.message}", e)
@@ -212,38 +157,21 @@ class GroupChatInfoViewModel(
} }
/** /**
* 获取记忆列表 * 获取记忆列表(房间规则列表)
*/ */
fun loadMemoryList(openId: String? = null, page: Int = 1, pageSize: Int = 20) { fun loadMemoryList(page: Int = 1, pageSize: Int = 20) {
viewModelScope.launch { viewModelScope.launch {
try { try {
isLoadingMemory = true isLoadingMemory = true
memoryError = null memoryError = null
val targetOpenId = openId ?: promptOpenId // 使用房间规则接口获取列表
if (targetOpenId.isNullOrBlank()) { val result = roomService.getRoomRuleList(
// 如果还没有获取到 openId先获取 trtcId = groupId,
val response = ApiClient.api.createGroupChatAi(trtcGroupId = groupId) page = page,
val groupChatResponse = response.body()?.data pageSize = pageSize
val prompts = groupChatResponse?.prompts )
val fetchedOpenId = prompts?.firstOrNull()?.openId memoryList = result.list
?: throw Exception("无法获取智能体信息")
promptOpenId = fetchedOpenId
val listResponse = ApiClient.api.getAgentRuleList(fetchedOpenId, page = page, pageSize = pageSize)
if (listResponse.isSuccessful) {
memoryList = listResponse.body()?.data?.list ?: emptyList()
} else {
throw Exception("获取记忆列表失败: ${listResponse.code()}")
}
} else {
val listResponse = ApiClient.api.getAgentRuleList(targetOpenId, page = page, pageSize = pageSize)
if (listResponse.isSuccessful) {
memoryList = listResponse.body()?.data?.list ?: emptyList()
} else {
throw Exception("获取记忆列表失败: ${listResponse.code()}")
}
}
} catch (e: Exception) { } catch (e: Exception) {
memoryError = e.message ?: "获取记忆列表失败" memoryError = e.message ?: "获取记忆列表失败"
Log.e("GroupChatInfoViewModel", "获取记忆列表失败: ${e.message}", e) Log.e("GroupChatInfoViewModel", "获取记忆列表失败: ${e.message}", e)
@@ -254,7 +182,7 @@ class GroupChatInfoViewModel(
} }
/** /**
* 删除记忆 * 删除记忆(房间规则)
*/ */
fun deleteMemory(ruleId: Int) { fun deleteMemory(ruleId: Int) {
viewModelScope.launch { viewModelScope.launch {
@@ -262,19 +190,12 @@ class GroupChatInfoViewModel(
isLoadingMemory = true isLoadingMemory = true
memoryError = null memoryError = null
val response = ApiClient.api.deleteAgentRule(ruleId) // 使用房间规则接口删除
if (response.isSuccessful) { roomService.deleteRoomRule(ruleId)
// 刷新记忆列表和配额
promptOpenId?.let { openId -> // 刷新记忆列表和配额
loadMemoryQuota(openId) loadMemoryQuota()
loadMemoryList(openId) loadMemoryList()
}
} else {
val errorResponse = parseErrorResponse(response.errorBody())
val errorMessage = errorResponse?.toServiceException()?.message
?: "删除记忆失败: ${response.code()}"
throw Exception(errorMessage)
}
} catch (e: Exception) { } catch (e: Exception) {
memoryError = e.message ?: "删除记忆失败" memoryError = e.message ?: "删除记忆失败"
Log.e("GroupChatInfoViewModel", "删除记忆失败: ${e.message}", e) Log.e("GroupChatInfoViewModel", "删除记忆失败: ${e.message}", e)
@@ -285,34 +206,23 @@ class GroupChatInfoViewModel(
} }
/** /**
* 更新记忆 * 更新记忆(房间规则)
*/ */
fun updateMemory(ruleId: Int, newRuleText: String, targetOpenId: String? = null) { fun updateMemory(ruleId: Int, newRuleText: String) {
viewModelScope.launch { viewModelScope.launch {
try { try {
isLoadingMemory = true isLoadingMemory = true
memoryError = null memoryError = null
val openId = targetOpenId ?: promptOpenId // 使用房间规则接口更新
?: throw Exception("无法获取智能体ID") roomService.updateRoomRule(
val requestBody = UpdateAgentRuleRequestBody(
id = ruleId, id = ruleId,
rule = newRuleText, rule = newRuleText
openId = openId
) )
val response = ApiClient.api.updateAgentRule(requestBody) // 刷新记忆列表和配额
if (response.isSuccessful) { loadMemoryQuota()
// 刷新记忆列表和配额 loadMemoryList()
loadMemoryQuota(openId)
loadMemoryList(openId)
} else {
val errorResponse = parseErrorResponse(response.errorBody())
val errorMessage = errorResponse?.toServiceException()?.message
?: "更新记忆失败: ${response.code()}"
throw Exception(errorMessage)
}
} catch (e: Exception) { } catch (e: Exception) {
memoryError = e.message ?: "更新记忆失败" memoryError = e.message ?: "更新记忆失败"
Log.e("GroupChatInfoViewModel", "更新记忆失败: ${e.message}", e) Log.e("GroupChatInfoViewModel", "更新记忆失败: ${e.message}", e)
@@ -321,4 +231,77 @@ class GroupChatInfoViewModel(
} }
} }
} }
/**
* 加载群可见性相关信息(价格和积分余额)
*/
fun loadVisibilityInfo() {
viewModelScope.launch {
try {
isLoadingVisibility = true
// 获取积分规则中的私密群组价格
PointService.refreshPointsRules()
val rules = PointService.pointsRules.first()
val roomPrivateRule = rules?.sub?.get(PointService.PointsRuleKey.ROOM_PRIVATE)
privateGroupCost = when (roomPrivateRule) {
is PointService.RuleAmount.Fixed -> roomPrivateRule.value
is PointService.RuleAmount.Range -> roomPrivateRule.min
null -> null
}
// 获取积分余额
PointService.refreshMyPointsBalance(includeStatistics = false)
val balance = PointService.pointsBalance.first()
pointsBalance = balance?.balance
} catch (e: Exception) {
Log.e("GroupChatInfoViewModel", "加载可见性信息失败: ${e.message}", e)
} finally {
isLoadingVisibility = false
}
}
}
/**
* 更新群可见性
* @param isPrivate 是否设置为私密群组
*/
fun updateVisibility(isPrivate: Boolean) {
viewModelScope.launch {
try {
isUpdatingVisibility = true
// TODO: 实现更新房间可见性的接口调用
// 暂时留空
// 更新成功后刷新群信息和积分余额
loadGroupInfo()
loadVisibilityInfo()
} catch (e: Exception) {
Log.e("GroupChatInfoViewModel", "更新可见性失败: ${e.message}", e)
} finally {
isUpdatingVisibility = false
}
}
}
/**
* 加载添加群记忆的价格
*/
fun loadMemoryCost() {
viewModelScope.launch {
try {
// 获取积分规则中的房间记忆价格(群聊记忆,区别于 Agent 记忆)
PointService.refreshPointsRules()
val rules = PointService.pointsRules.first()
val roomMemoryRule = rules?.sub?.get(PointService.PointsRuleKey.SPEND_ROOM_MEMORY)
addMemoryCost = when (roomMemoryRule) {
is PointService.RuleAmount.Fixed -> roomMemoryRule.value
is PointService.RuleAmount.Range -> roomMemoryRule.min
else -> null
}
} catch (e: Exception) {
Log.e("GroupChatInfoViewModel", "加载记忆价格失败: ${e.message}", e)
}
}
}
} }

View File

@@ -0,0 +1,431 @@
package com.aiosman.ravenow.ui.group
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.zIndex
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.GroupMember
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToAddGroupMember
@Composable
fun GroupMembersScreen(groupId: String) {
val navController = LocalNavController.current
val context = LocalContext.current
val AppColors = LocalAppTheme.current
val viewModel = viewModel<GroupMembersViewModel>(
key = "GroupMembersViewModel_$groupId",
factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return GroupMembersViewModel(groupId) as T
}
}
)
var selectedMemberPosition by remember { mutableStateOf<Pair<Offset, Float>?>(null) }
var selectedMemberId by remember { mutableStateOf<String?>(null) }
Box(
modifier = Modifier
.fillMaxSize()
.background(AppColors.background)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// 顶部导航栏
Column(
modifier = Modifier
.fillMaxWidth()
.background(AppColors.background)
) {
StatusBarSpacer()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp, horizontal = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
androidx.compose.foundation.Image(
painter = painterResource(R.drawable.rider_pro_back_icon),
contentDescription = null,
colorFilter = ColorFilter.tint(AppColors.text),
modifier = Modifier
.size(24.dp)
.noRippleClickable {
navController.navigateUp()
}
)
Text(
text = stringResource(R.string.group_members_title),
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold
),
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center
)
androidx.compose.foundation.Image(
painter = painterResource(R.drawable.rider_pro_add_other),
contentDescription = stringResource(R.string.group_chat_info_add_member),
colorFilter = ColorFilter.tint(AppColors.text),
modifier = Modifier
.size(24.dp)
.noRippleClickable {
navController.navigateToAddGroupMember(groupId, viewModel.groupInfo?.groupName)
}
)
}
}
// 内容区域
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
// 当前用户信息
item {
if (viewModel.currentUserMember != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.group_members_you),
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text.copy(alpha = 0.6f),
fontSize = 13.sp
),
modifier = Modifier.padding(start = 12.dp, bottom = 8.dp)
)
CurrentUserItem(
member = viewModel.currentUserMember!!,
isAdmin = viewModel.groupInfo?.isCreator == true
)
Spacer(modifier = Modifier.height(16.dp))
}
}
// 群成员列表
item {
Text(
text = stringResource(
R.string.group_members_list,
viewModel.members.size + (if (viewModel.currentUserMember != null) 1 else 0)
),
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text.copy(alpha = 0.6f),
fontSize = 13.sp
),
modifier = Modifier.padding()
)
}
items(viewModel.members) { member ->
MemberItem(
member = member,
isAdmin = viewModel.groupInfo?.isCreator == true,
onSendMessage = {
// TODO: 实现发消息功能
},
onMenuClick = { position, height ->
if (selectedMemberId == member.userId) {
selectedMemberId = null
selectedMemberPosition = null
} else {
selectedMemberId = member.userId
selectedMemberPosition = Pair(position, height)
}
},
onDeleteMember = {
viewModel.deleteMember(member.userId)
selectedMemberId = null
selectedMemberPosition = null
},
isMenuVisible = selectedMemberId == member.userId
)
}
}
}
// 弹窗 - 显示在最上层
selectedMemberPosition?.let { (position, height) ->
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val screenWidth = with(density) { configuration.screenWidthDp.dp }
val horizontalOffset = (screenWidth - 238.dp) / 2
Box(
modifier = Modifier
.width(238.dp)
.height(60.dp)
.zIndex(1000f)
.offset {
val xOffset = with(density) { horizontalOffset.toPx().toInt() }
val yOffset = (position.y + height + with(density) { 4.dp.toPx() }).toInt()
IntOffset(x = xOffset, y = yOffset)
}
.shadow(
elevation = 8.dp,
shape = RoundedCornerShape(24.dp),
spotColor = Color.Black.copy(alpha = 0.15f),
ambientColor = Color.Black.copy(alpha = 0.08f)
)
.clip(RoundedCornerShape(24.dp))
.background(
brush = androidx.compose.ui.graphics.Brush.verticalGradient(
colors = listOf(
Color(0xFF262626).copy(alpha = 0.4f),
Color(0xFFF5F5F5).copy(alpha = 0.6f)
)
)
)
.clickable {
selectedMemberId?.let { memberId ->
viewModel.deleteMember(memberId)
selectedMemberId = null
selectedMemberPosition = null
}
},
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.group_members_delete_member),
color = Color(0xFFFF3B30),
fontSize = 15.sp
)
}
}
}
}
@Composable
private fun CurrentUserItem(
member: GroupMember,
isAdmin: Boolean
) {
val AppColors = LocalAppTheme.current
val context = LocalContext.current
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 头像
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (member.avatar.isNotEmpty()) Color.Transparent
else AppColors.decentBackground
)
) {
if (member.avatar.isNotEmpty()) {
CustomAsyncImage(
imageUrl = member.avatar,
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
contentDescription = member.nickname
)
} else {
// 默认头像占位
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF0EEF1)),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(R.drawable.default_avatar),
contentDescription = null,
tint = AppColors.text.copy(alpha = 0.5f),
modifier = Modifier.size(20.dp)
)
}
}
}
Spacer(modifier = Modifier.width(12.dp))
// 名称和身份
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = member.nickname,
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text,
fontSize = 13.sp,
fontWeight = FontWeight.Medium
)
)
if (isAdmin) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.group_members_admin),
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text,
fontSize = 12.sp
)
)
}
}
}
}
@Composable
private fun MemberItem(
member: GroupMember,
isAdmin: Boolean,
onSendMessage: () -> Unit,
onMenuClick: (Offset, Float) -> Unit,
onDeleteMember: () -> Unit,
isMenuVisible: Boolean
) {
val AppColors = LocalAppTheme.current
val context = LocalContext.current
val density = LocalDensity.current
var itemPosition by remember { mutableStateOf(Offset.Zero) }
var itemHeight by remember { mutableStateOf(0f) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.onGloballyPositioned { coordinates ->
itemPosition = coordinates.positionInRoot()
itemHeight = coordinates.size.height.toFloat()
},
verticalAlignment = Alignment.CenterVertically
) {
// 头像
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (member.avatar.isNotEmpty()) Color.Transparent
else AppColors.decentBackground
)
) {
if (member.avatar.isNotEmpty()) {
CustomAsyncImage(
imageUrl = member.avatar,
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
contentDescription = member.nickname
)
} else {
// 默认头像占位
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF0EEF1)),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(R.mipmap.group_copy),
contentDescription = "默认头像",
modifier = Modifier.size(40.dp),
)
}
}
}
Spacer(modifier = Modifier.width(12.dp))
// 名称
Text(
text = member.nickname,
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text,
fontSize = 13.sp,
fontWeight = FontWeight.Medium
)
)
Spacer(modifier = Modifier.weight(1f))
// 菜单按钮
IconButton(
onClick = { onMenuClick(itemPosition, itemHeight) },
modifier = Modifier.size(24.dp)
) {
androidx.compose.foundation.Image(
painter = painterResource(R.drawable.rider_pro_more_horizon),
contentDescription = null,
colorFilter = ColorFilter.tint(AppColors.text),
modifier = Modifier
.size(24.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
// 发消息按钮 - 右对齐到与Switch相同的位置
Box(
modifier = Modifier
.clip(RoundedCornerShape(14.dp))
.background(AppColors.decentBackground)
.clickable { onSendMessage() }
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Text(
text = stringResource(R.string.group_members_send_message),
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold
)
)
}
}
}

View File

@@ -0,0 +1,117 @@
package com.aiosman.ravenow.ui.group
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.entity.GroupInfo
import com.aiosman.ravenow.entity.GroupMember
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
import kotlinx.coroutines.launch
class GroupMembersViewModel(
private val groupId: String
) : ViewModel() {
var groupInfo by mutableStateOf<GroupInfo?>(null)
var currentUserMember by mutableStateOf<GroupMember?>(null)
var members by mutableStateOf<List<GroupMember>>(emptyList())
var isLoading by mutableStateOf(false)
var error by mutableStateOf<String?>(null)
var requireApproval by mutableStateOf(true) // 默认开启
var roomId by mutableStateOf<Int?>(null)
init {
loadGroupMembers()
}
private fun loadGroupMembers() {
viewModelScope.launch {
try {
isLoading = true
error = null
// 调用接口获取群聊详情
val response = ApiClient.api.getRoomDetail(trtcId = groupId)
if (response.isSuccessful && response.body() != null) {
val room = response.body()!!.data
// 保存roomId
roomId = room.id
// 设置群信息
groupInfo = GroupInfo(
groupId = groupId,
groupName = room.name,
groupAvatar = if (room.avatar.isNullOrEmpty()) {
val groupIdBase64 = android.util.Base64.encodeToString(
groupId.toByteArray(),
android.util.Base64.NO_WRAP
)
"${ApiClient.RETROFIT_URL}group/avatar?groupIdBase64=$groupIdBase64&token=${AppStore.token}"
} else {
"${ApiClient.BASE_API_URL}/outside${room.avatar}?token=${AppStore.token}"
},
memberCount = room.userCount,
isCreator = room.creator.userId == MyProfileViewModel.profile?.id.toString()
)
// 获取当前用户ID
val currentUserId = MyProfileViewModel.profile?.id.toString()
// 转换成员列表
val allMembers = room.users.map { user ->
val avatarUrl = if (user.profile.avatar.isNullOrEmpty()) {
""
} else {
// 与动态列表和关注列表一致,使用 BASE_SERVER 构建头像URL不需要token
"${ApiClient.BASE_SERVER}${user.profile.avatar}"
}
GroupMember(
userId = user.userId,
nickname = user.profile.nickname.ifEmpty { user.profile.username },
avatar = avatarUrl,
isOwner = user.userId == room.creator.userId
)
}
// 分离当前用户和其他成员
currentUserMember = allMembers.find { it.userId == currentUserId }
members = allMembers.filter { it.userId != currentUserId }
} else {
error = "获取群成员列表失败"
}
} catch (e: Exception) {
error = e.message ?: "加载失败"
Log.e("GroupMembersViewModel", "加载群成员失败", e)
} finally {
isLoading = false
}
}
}
fun refresh() {
loadGroupMembers()
}
fun deleteMember(memberId: String) {
viewModelScope.launch {
try {
// TODO: 实现删除成员的API调用
// 删除成功后刷新列表
loadGroupMembers()
} catch (e: Exception) {
error = e.message ?: "删除成员失败"
Log.e("GroupMembersViewModel", "删除成员失败", e)
}
}
}
}

View File

@@ -39,6 +39,9 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import android.widget.Toast import android.widget.Toast
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
@Composable @Composable
fun GroupMemoryManageContent( fun GroupMemoryManageContent(
@@ -48,9 +51,6 @@ fun GroupMemoryManageContent(
onDismiss: () -> Unit = {} onDismiss: () -> Unit = {}
) { ) {
val AppColors = LocalAppTheme.current val AppColors = LocalAppTheme.current
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
val sheetHeight = screenHeight * 0.95f
val context = LocalContext.current val context = LocalContext.current
// 编辑记忆的状态 - 存储正在编辑的记忆ID // 编辑记忆的状态 - 存储正在编辑的记忆ID
@@ -68,8 +68,7 @@ fun GroupMemoryManageContent(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.height(sheetHeight)
.background(Color(0xFFFAF9FB)) .background(Color(0xFFFAF9FB))
) { ) {
// 顶部栏:返回按钮 + 标题 + 加号按钮 // 顶部栏:返回按钮 + 标题 + 加号按钮
@@ -81,7 +80,7 @@ fun GroupMemoryManageContent(
) { ) {
// 中间标题 - 绝对居中,不受其他组件影响 // 中间标题 - 绝对居中,不受其他组件影响
Text( Text(
text = "记忆管理", text = stringResource(R.string.group_chat_info_memory_manage2),
style = TextStyle( style = TextStyle(
color = Color.Black, color = Color.Black,
fontSize = 17.sp, fontSize = 17.sp,
@@ -109,7 +108,7 @@ fun GroupMemoryManageContent(
colorFilter = ColorFilter.tint(Color.Black) colorFilter = ColorFilter.tint(Color.Black)
) )
Text( Text(
text = "返回", text = stringResource(R.string.back_upper),
style = TextStyle( style = TextStyle(
color = Color.Black, color = Color.Black,
fontSize = 15.sp, fontSize = 15.sp,
@@ -150,7 +149,7 @@ fun GroupMemoryManageContent(
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("已付费:", style = TextStyle(color = Color(0x993C3C43), fontSize = 13.sp)) Text(stringResource(R.string.memory_paid), style = TextStyle(color = Color(0x993C3C43), fontSize = 13.sp))
Spacer(Modifier.width(3.dp)) Spacer(Modifier.width(3.dp))
Text( Text(
"${quota?.purchasedCount ?: 0}", "${quota?.purchasedCount ?: 0}",
@@ -158,7 +157,7 @@ fun GroupMemoryManageContent(
) )
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("已使用:", style = TextStyle(color = Color(0x993C3C43), fontSize = 13.sp)) Text(stringResource(R.string.memory_used), style = TextStyle(color = Color(0x993C3C43), fontSize = 13.sp))
Spacer(Modifier.width(3.dp)) Spacer(Modifier.width(3.dp))
Text( Text(
"${quota?.currentCount ?: 0}", "${quota?.currentCount ?: 0}",
@@ -167,7 +166,7 @@ fun GroupMemoryManageContent(
} }
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("可用上限:", style = TextStyle(color = Color(0x993C3C43), fontSize = 13.sp)) Text(stringResource(R.string.upper_limit), style = TextStyle(color = Color(0x993C3C43), fontSize = 13.sp))
Spacer(Modifier.width(3.dp)) Spacer(Modifier.width(3.dp))
Text( Text(
"50", "50",
@@ -237,12 +236,12 @@ fun GroupMemoryManageContent(
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Text( Text(
text = "暂无记忆", text = stringResource(R.string.no_memory),
style = TextStyle(color = Color.Black, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) style = TextStyle(color = Color.Black, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
) )
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text( Text(
text = "点击上方按钮添加群记忆", text = stringResource(R.string.add_memory),
style = TextStyle(color = Color.Black, fontSize = 14.sp, fontWeight = FontWeight.Normal) style = TextStyle(color = Color.Black, fontSize = 14.sp, fontWeight = FontWeight.Normal)
) )
} }
@@ -257,7 +256,7 @@ fun GroupMemoryManageContent(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun EditGroupMemoryDialog( fun EditGroupMemoryDialog(
memory: com.aiosman.ravenow.data.api.AgentRule, memory: com.aiosman.ravenow.entity.RoomRuleEntity,
viewModel: GroupChatInfoViewModel, viewModel: GroupChatInfoViewModel,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onUpdateMemory: (String) -> Unit onUpdateMemory: (String) -> Unit
@@ -403,7 +402,7 @@ fun EditGroupMemoryDialog(
*/ */
@Composable @Composable
fun MemoryItem( fun MemoryItem(
memory: com.aiosman.ravenow.data.api.AgentRule, memory: com.aiosman.ravenow.entity.RoomRuleEntity,
isEditing: Boolean = false, isEditing: Boolean = false,
onEdit: () -> Unit = {}, onEdit: () -> Unit = {},
onCancel: () -> Unit = {}, onCancel: () -> Unit = {},
@@ -579,20 +578,44 @@ fun MemoryItem(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// 底部行:日期 + 编辑删除按钮 // 底部行:创建者信息 + 编辑删除按钮
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.CenterVertically
) { ) {
// 日期文本 - 左侧 // 创建者信息 - 左侧:头像 + 用户名 · 时间
Text( Row(
text = formattedDate, horizontalArrangement = Arrangement.spacedBy(8.dp),
style = TextStyle( verticalAlignment = Alignment.CenterVertically
color = Color(0x993C3C43), ) {
fontSize = 11.sp // 圆形头像
val avatarUrl = memory.creator?.avatarDirectUrl?.takeIf { it.isNotBlank() }
CustomAsyncImage(
imageUrl = avatarUrl,
contentDescription = "创建者头像",
modifier = Modifier
.size(20.dp)
.clip(CircleShape),
defaultRes = R.drawable.default_avatar,
placeholderRes = R.drawable.default_avatar,
errorRes = R.drawable.default_avatar,
contentScale = ContentScale.Crop
) )
)
// 用户名 · 时间
Text(
text = buildString {
append(memory.creator?.nickname ?: "未知用户")
append(" · ")
append(formattedDate)
},
style = TextStyle(
color = Color(0x993C3C43),
fontSize = 11.sp
)
)
}
// 编辑和删除图标 - 右侧 // 编辑和删除图标 - 右侧
Row( Row(

View File

@@ -0,0 +1,327 @@
package com.aiosman.ravenow.ui.group
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.InputStream
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.ui.zIndex
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable
fun GroupProfileSettingsScreen(groupId: String) {
val navController = LocalNavController.current
val context = LocalContext.current
val AppColors = LocalAppTheme.current
val viewModel = viewModel<GroupProfileSettingsViewModel>(
key = "GroupProfileSettingsViewModel_$groupId",
factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return GroupProfileSettingsViewModel(groupId) as T
}
}
)
// 群图标选择器
val groupIconPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
uri?.let {
// 直接转换为 Bitmap 并显示,不进行裁剪
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
val bitmap = uriToBitmap(context, it)
bitmap?.let { bmp ->
withContext(Dispatchers.Main) {
viewModel.setGroupIcon(bmp)
}
}
}
}
}
// 群头像选择器
val groupAvatarPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
uri?.let {
// 直接转换为 Bitmap 并显示,不进行裁剪
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
val bitmap = uriToBitmap(context, it)
bitmap?.let { bmp ->
withContext(Dispatchers.Main) {
viewModel.setGroupAvatar(bmp)
}
}
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(AppColors.background)
) {
// 群默认图标图片区域(渐变背景)
Box(
modifier = Modifier
.fillMaxWidth()
.height(249.dp)
.background(
brush = Brush.horizontalGradient(
colors = listOf(
Color(0xFF7C45ED),
Color(0xFFE91E63)
)
),
shape = RoundedCornerShape(bottomStart = 20.dp, bottomEnd = 20.dp)
)
.noRippleClickable {
groupIconPicker.launch("image/*")
}
) {
// 显示选中的图标或默认渐变
viewModel.groupIconBitmap?.let { bitmap ->
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
// 顶部导航栏(显示在渐变背景上层)
Column(
modifier = Modifier
.fillMaxWidth()
.zIndex(1f)
) {
StatusBarSpacer()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp, horizontal = 12.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.drawable.rider_pro_back_icon),
modifier = Modifier
.size(24.dp)
.noRippleClickable {
navController.navigateUp()
},
contentDescription = null,
colorFilter = ColorFilter.tint(Color.White)
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.group_info_edit),
style = TextStyle(
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.weight(1f))
}
}
// 群头像区域
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 270.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(70.dp)
) {
// 群头像显示
if (viewModel.groupInfo?.groupAvatar?.isNotEmpty() == true) {
CustomAsyncImage(
imageUrl = viewModel.groupInfo!!.groupAvatar,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(12.dp)),
contentDescription = "群聊头像"
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(16.dp))
.background(AppColors.decentBackground),
contentAlignment = Alignment.Center
) {
Text(
text = viewModel.groupInfo?.groupName?.firstOrNull()?.toString() ?: "",
style = TextStyle(
color = AppColors.text,
fontSize = 32.sp,
fontWeight = FontWeight.Bold
)
)
}
}
// 右下角加号按钮
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(22.dp)
.clip(CircleShape)
.background(
brush = Brush.horizontalGradient(
colors = listOf(
Color(0xFF7C45ED),
Color(0xFF7BD8F8)
)
)
)
.noRippleClickable {
groupAvatarPicker.launch("image/*")
},
contentAlignment = Alignment.Center
) {
Text(
text = "+",
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// 群聊名称编辑框
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(AppColors.decentBackground)
.padding(horizontal = 12.dp, vertical = 12.dp)
) {
Text(
text = stringResource(R.string.group_name)+":",
style = TextStyle(
color = AppColors.text.copy(alpha = 0.7f),
fontSize = 14.sp
)
)
BasicTextField(
value = viewModel.groupName,
onValueChange = { viewModel.updateGroupName(it) },
textStyle = TextStyle(
color = AppColors.text,
fontSize = 15.sp
),
cursorBrush = SolidColor(AppColors.text),
modifier = Modifier.padding(start = 70.dp),
singleLine = true
)
}
}
Spacer(modifier = Modifier.weight(1f))
// 保存按钮
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 24.dp)
.height(50.dp)
.clip(RoundedCornerShape(25.dp))
.background(
brush = Brush.horizontalGradient(
colors = listOf(
Color(0xFF7C45ED),
Color(0xFF7BD8F8)
)
)
)
.noRippleClickable {
// TODO: 实现保存功能
},
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.save),
style = TextStyle(
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Medium
)
)
}
}
}
}
// 将 Uri 转换为 Bitmap 的辅助函数
fun uriToBitmap(context: android.content.Context, uri: Uri): Bitmap? {
return try {
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(inputStream)
} catch (e: Exception) {
e.printStackTrace()
null
}
}

View File

@@ -0,0 +1,75 @@
package com.aiosman.ravenow.ui.group
import android.graphics.Bitmap
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.entity.GroupInfo
import kotlinx.coroutines.launch
class GroupProfileSettingsViewModel(
private val groupId: String
) : ViewModel() {
var groupInfo by mutableStateOf<GroupInfo?>(null)
var groupName by mutableStateOf("")
var groupIconBitmap by mutableStateOf<Bitmap?>(null)
var groupAvatarBitmap by mutableStateOf<Bitmap?>(null)
var isLoading by mutableStateOf(false)
var error by mutableStateOf<String?>(null)
init {
loadGroupInfo()
}
fun loadGroupInfo() {
viewModelScope.launch {
try {
isLoading = true
error = null
// 直接调用 API 加载群信息
val response = com.aiosman.ravenow.data.api.ApiClient.api.getRoomDetail(trtcId = groupId)
val room = response.body()?.data
groupInfo = room?.let {
com.aiosman.ravenow.entity.GroupInfo(
groupId = groupId,
groupName = it.name,
groupAvatar = if (it.avatar.isNullOrEmpty()) {
val groupIdBase64 = android.util.Base64.encodeToString(
groupId.toByteArray(),
android.util.Base64.NO_WRAP
)
"${com.aiosman.ravenow.data.api.ApiClient.RETROFIT_URL}group/avatar?groupIdBase64=$groupIdBase64&token=${com.aiosman.ravenow.AppStore.token}"
} else {
"${com.aiosman.ravenow.data.api.ApiClient.BASE_API_URL}/outside${it.avatar}?token=${com.aiosman.ravenow.AppStore.token}"
},
memberCount = room.userCount,
isCreator = room.creator.userId == com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel.profile?.id.toString()
)
}
groupInfo?.let {
groupName = it.groupName
}
} catch (e: Exception) {
error = e.message ?: "加载失败"
} finally {
isLoading = false
}
}
}
fun updateGroupName(newName: String) {
groupName = newName
}
fun setGroupIcon(bitmap: Bitmap) {
groupIconBitmap = bitmap
}
fun setGroupAvatar(bitmap: Bitmap) {
groupAvatarBitmap = bitmap
}
}

View File

@@ -47,7 +47,6 @@ fun CreateBottomSheet(
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState, sheetState = sheetState,
windowInsets = BottomSheetDefaults.windowInsets,
containerColor = appColors.background, containerColor = appColors.background,
dragHandle = null, dragHandle = null,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp) shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)

View File

@@ -70,6 +70,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -265,12 +266,11 @@ fun IndexScreen() {
modifier = Modifier modifier = Modifier
.background(AppColors.background) .background(AppColors.background)
.padding(0.dp), .padding(0.dp),
beyondBoundsPageCount = 4,
userScrollEnabled = false userScrollEnabled = false
) { page -> ) { page ->
when (page) { when (page) {
0 -> Agent() 0 -> Agent()
1 -> Home() 1 -> Home(isPageVisible = pagerState.currentPage == 1)
2 -> Add() 2 -> Add()
3 -> Notifications() 3 -> Notifications()
4 -> Profile() 4 -> Profile()
@@ -333,7 +333,9 @@ fun IndexScreen() {
} }
@Composable @Composable
fun Home() { fun Home(
isPageVisible: Boolean = true
) {
val systemUiController = rememberSystemUiController() val systemUiController = rememberSystemUiController()
val context = LocalContext.current val context = LocalContext.current
@@ -349,7 +351,7 @@ fun Home() {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
MomentsList() MomentsList(isPageVisible = isPageVisible)
} }
} }
@@ -423,13 +425,6 @@ fun Profile() {
systemUiController.setStatusBarColor(Color.Transparent, !AppState.darkMode) systemUiController.setStatusBarColor(Color.Transparent, !AppState.darkMode)
} }
// 页面退出时清理个人资料相关资源
DisposableEffect(Unit) {
onDispose {
ResourceCleanupManager.cleanupPageResources("profile")
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize(),
@@ -519,14 +514,31 @@ fun SideMenuContent(
var messageNotificationEnabled by remember { mutableStateOf(true) } var messageNotificationEnabled by remember { mutableStateOf(true) }
var darkModeEnabled by remember { mutableStateOf(AppState.darkMode) } var darkModeEnabled by remember { mutableStateOf(AppState.darkMode) }
// 菜单背景色 #FAF9FB // 同步暗色模式状态
val menuBackgroundColor = Color(0xFFFAF9FB) LaunchedEffect(AppState.darkMode) {
darkModeEnabled = AppState.darkMode
}
// 菜单背景色 - 根据暗色模式适配
val menuBackgroundColor = if (darkModeEnabled) {
appColors.secondaryBackground // 暗色模式:深灰色
} else {
Color(0xFFFAF9FB) // 亮色模式:浅灰色
}
// 遮罩颜色 黑色透明度0.6 // 遮罩颜色 黑色透明度0.6
val overlayColor = Color.Black.copy(alpha = 0.6f) val overlayColor = Color.Black.copy(alpha = 0.6f)
// 卡片背景色 白色 // 卡片背景色 - 根据暗色模式适配
val cardBackgroundColor = Color.White val cardBackgroundColor = if (darkModeEnabled) {
// 跟随系统文字颜色 #979499 appColors.background // 暗色模式:深色背景
val followSystemTextColor = Color(0xFF979499) } else {
Color.White // 亮色模式:白色
}
// 文字颜色 - 根据暗色模式适配
val textColor = appColors.text
// 图标颜色 - 根据暗色模式适配
val iconColor = appColors.text
// 跟随系统文字颜色 - 根据暗色模式适配
val followSystemTextColor = appColors.secondaryText
// 开关开启颜色 #7C45ED // 开关开启颜色 #7C45ED
val switchActiveColor = Color(0xFF7C45ED) val switchActiveColor = Color(0xFF7C45ED)
@@ -563,34 +575,33 @@ fun SideMenuContent(
// 顶部状态栏间距 // 顶部状态栏间距
val statusBarHeight = WindowInsets.systemBars.asPaddingValues().calculateTopPadding() val statusBarHeight = WindowInsets.systemBars.asPaddingValues().calculateTopPadding()
// 扫一扫功能入口 - 右边距离右边66pt // 扫一扫功能入口
Row( Row(
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.offset(x = (-112).dp, y = 88.dp) .offset(x = (-60).dp, y = 88.dp)
.noRippleClickable { .noRippleClickable {
// TODO: 实现扫一扫功能 coroutineScope.launch {
}, onClose()
horizontalArrangement = Arrangement.spacedBy(16.dp), navController.navigate(NavigationRoute.ScanQr.route)
verticalAlignment = Alignment.CenterVertically }
) { },
// 扫一扫图标(使用现有图标或占位) horizontalArrangement = Arrangement.spacedBy(8.dp),
Image( verticalAlignment = Alignment.CenterVertically
painter = painterResource(id = R.mipmap.sao), ) {
contentDescription = null, Image(
modifier = Modifier.size(24.dp), painter = painterResource(id = R.mipmap.sao),
colorFilter = ColorFilter.tint(Color.Black) contentDescription = null,
) modifier = Modifier.size(24.dp),
} colorFilter = ColorFilter.tint(iconColor)
// 绝对定位的"扫一扫"文字上方71.5dp右侧66dp )
Text( Text(
text = stringResource(R.string.scan_qr), text = stringResource(R.string.scan_qr),
fontSize = 14.sp, fontSize = 14.sp,
color = Color.Black, color = textColor,
modifier = Modifier textAlign = TextAlign.Center
.align(Alignment.TopEnd)
.offset(x = (-66).dp, y = 91.5.dp)
) )
}
// QR码图标 - 右边距离右边112dp上边距离上边68pt // QR码图标 - 右边距离右边112dp上边距离上边68pt
Image( Image(
painter = painterResource(id = R.mipmap.qr_code_icon), painter = painterResource(id = R.mipmap.qr_code_icon),
@@ -602,7 +613,7 @@ fun SideMenuContent(
.noRippleClickable { .noRippleClickable {
// TODO: 实现QR码功能 // TODO: 实现QR码功能
}, },
colorFilter = ColorFilter.tint(Color.Black) colorFilter = ColorFilter.tint(iconColor)
) )
// 菜单选项卡片组 - 第一组卡片上方距离上方108pt绝对定位 // 菜单选项卡片组 - 第一组卡片上方距离上方108pt绝对定位
@@ -616,6 +627,8 @@ fun SideMenuContent(
// 第一组卡片:编辑资料、账号安全、收藏 // 第一组卡片:编辑资料、账号安全、收藏
MenuCard( MenuCard(
backgroundColor = cardBackgroundColor, backgroundColor = cardBackgroundColor,
textColor = textColor,
iconColor = iconColor,
width = 270.dp, width = 270.dp,
height = 164.dp, height = 164.dp,
items = listOf( items = listOf(
@@ -655,6 +668,8 @@ fun SideMenuContent(
// 第二组卡片:暗色模式、消息通知 // 第二组卡片:暗色模式、消息通知
MenuCard( MenuCard(
backgroundColor = cardBackgroundColor, backgroundColor = cardBackgroundColor,
textColor = textColor,
iconColor = iconColor,
width = 270.dp, width = 270.dp,
height = 112.dp, // 根据设计图第二组卡片高度为112dp height = 112.dp, // 根据设计图第二组卡片高度为112dp
items = listOf( items = listOf(
@@ -709,6 +724,8 @@ fun SideMenuContent(
// 第三组卡片:关于派派、反馈、退出登录 // 第三组卡片:关于派派、反馈、退出登录
MenuCard( MenuCard(
backgroundColor = cardBackgroundColor, backgroundColor = cardBackgroundColor,
textColor = textColor,
iconColor = iconColor,
width = 270.dp, width = 270.dp,
height = 164.dp, height = 164.dp,
items = listOf( items = listOf(
@@ -776,6 +793,8 @@ data class MenuItem(
@Composable @Composable
fun MenuCard( fun MenuCard(
backgroundColor: Color, backgroundColor: Color,
textColor: Color,
iconColor: Color,
items: List<MenuItem>, items: List<MenuItem>,
width: androidx.compose.ui.unit.Dp? = null, width: androidx.compose.ui.unit.Dp? = null,
height: androidx.compose.ui.unit.Dp? = null height: androidx.compose.ui.unit.Dp? = null
@@ -794,14 +813,15 @@ fun MenuCard(
.then(if (height != null) Modifier.weight(1f) else Modifier), .then(if (height != null) Modifier.weight(1f) else Modifier),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
MenuItemRow(item = item, compact = height != null) // 传递compact参数 MenuItemRow(item = item, compact = height != null, textColor = textColor, iconColor = iconColor) // 传递颜色参数
} }
} }
} }
} }
@Composable @Composable
fun MenuItemRow(item: MenuItem, compact: Boolean = false) { fun MenuItemRow(item: MenuItem, compact: Boolean = false, textColor: Color, iconColor: Color) {
val appColors = LocalAppTheme.current
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -825,12 +845,12 @@ fun MenuItemRow(item: MenuItem, compact: Boolean = false) {
painter = painterResource(id = item.icon), painter = painterResource(id = item.icon),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(Color.Black) colorFilter = ColorFilter.tint(iconColor)
) )
Text( Text(
text = item.label, text = item.label,
fontSize = 14.sp, fontSize = 14.sp,
color = Color.Black color = textColor
) )
} }
@@ -841,7 +861,7 @@ fun MenuItemRow(item: MenuItem, compact: Boolean = false) {
painter = painterResource(id = R.drawable.rave_now_nav_right), painter = painterResource(id = R.drawable.rave_now_nav_right),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(Color(0xFF111213)) colorFilter = ColorFilter.tint(appColors.text)
) )
} }
} }

View File

@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
@@ -24,22 +23,18 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -50,9 +45,10 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.aiosman.ravenow.AppStore import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.GuestLoginCheckOut import com.aiosman.ravenow.GuestLoginCheckOut
@@ -62,30 +58,29 @@ import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.NavigationRoute import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.composables.CustomAsyncImage 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.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.composables.TabItem
import com.aiosman.ravenow.ui.composables.TabSpacer import com.aiosman.ravenow.ui.composables.TabSpacer
import com.aiosman.ravenow.ui.index.tabs.moment.CustomTabItem 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.AgentItem
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.ExploreViewModel
import com.aiosman.ravenow.utils.DebounceUtils 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.LazyRow
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.platform.LocalContext
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.ui.platform.LocalConfiguration
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.rememberLottieComposition
// 检测是否接近列表底部的扩展函数 // 检测是否接近列表底部的扩展函数
fun LazyListState.isScrolledToEnd(buffer: Int = 3): Boolean { fun LazyListState.isScrolledToEnd(buffer: Int = 3): Boolean {
@@ -104,28 +99,14 @@ fun Agent() {
val navigationBarPaddings = val navigationBarPaddings =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 48.dp WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 48.dp
val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues() val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues()
// 游客模式下只显示热门Agent正常用户显示我的Agent和热门Agent
val tabCount = if (AppStore.isGuest) 1 else 2
var pagerState = rememberPagerState { tabCount }
var scope = rememberCoroutineScope()
val viewModel: AgentViewModel = viewModel() val viewModel: AgentViewModel = AgentViewModel
// 确保推荐Agent数据已加载 // 确保推荐Agent数据已加载
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.ensureDataLoaded() viewModel.ensureDataLoaded()
} }
// 防抖状态
var lastClickTime by remember { mutableStateOf(0L) }
// 页面退出时只清理必要的资源不清理推荐Agent数据
DisposableEffect(Unit) {
onDispose {
// 只清理子页面的资源保留推荐Agent数据
// ResourceCleanupManager.cleanupPageResources("ai")
}
}
val agentItems = viewModel.agentItems val agentItems = viewModel.agentItems
var selectedTabIndex by remember { mutableStateOf(0) } var selectedTabIndex by remember { mutableStateOf(0) }
@@ -156,7 +137,7 @@ fun Agent() {
contentDescription = "Rave AI Logo", contentDescription = "Rave AI Logo",
modifier = Modifier modifier = Modifier
.height(44.dp) .height(44.dp)
.padding(top =9.dp,bottom=9.dp) .padding(top = 9.dp, bottom = 9.dp)
.wrapContentSize(), .wrapContentSize(),
// colorFilter = ColorFilter.tint(AppColors.text) // colorFilter = ColorFilter.tint(AppColors.text)
) )
@@ -167,7 +148,7 @@ fun Agent() {
contentDescription = "search", contentDescription = "search",
modifier = Modifier modifier = Modifier
.size(44.dp) .size(44.dp)
.padding(top = 9.dp,bottom=9.dp) .padding(top = 9.dp, bottom = 9.dp)
.noRippleClickable { .noRippleClickable {
navController.navigate(NavigationRoute.Search.route) navController.navigate(NavigationRoute.Search.route)
}, },
@@ -258,11 +239,19 @@ fun Agent() {
) { ) {
when { when {
selectedTabIndex == 0 -> { selectedTabIndex == 0 -> {
AgentViewPagerSection(agentItems = viewModel.agentItems.take(15), viewModel) AgentViewPagerSection(
agentItems = viewModel.agentItems.take(15),
viewModel
)
} }
selectedTabIndex in 1..viewModel.categories.size -> { selectedTabIndex in 1..viewModel.categories.size -> {
AgentViewPagerSection(agentItems = viewModel.agentItems.take(15), viewModel) AgentViewPagerSection(
agentItems = viewModel.agentItems.take(15),
viewModel
)
} }
else -> { else -> {
val shuffledAgents = viewModel.agentItems.shuffled().take(15) val shuffledAgents = viewModel.agentItems.shuffled().take(15)
AgentViewPagerSection(agentItems = shuffledAgents, viewModel) AgentViewPagerSection(agentItems = shuffledAgents, viewModel)
@@ -271,52 +260,55 @@ fun Agent() {
} }
} }
// 热门聊天室 if (viewModel.chatRooms.isNotEmpty()) {
stickyHeader(key = "hot_rooms_header") { // 热门聊天室
Row( stickyHeader(key = "hot_rooms_header") {
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier
.background(AppColors.background) .fillMaxWidth()
.padding(top = 8.dp, bottom = 12.dp), .background(AppColors.background)
horizontalArrangement = Arrangement.Start, .padding(top = 8.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically horizontalArrangement = Arrangement.Start,
) { verticalAlignment = Alignment.CenterVertically
Image( ) {
painter = painterResource(R.mipmap.rider_pro_hot_room), Image(
contentDescription = "chat room", painter = painterResource(R.mipmap.rider_pro_hot_room),
modifier = Modifier.size(28.dp) contentDescription = "chat room",
) modifier = Modifier.size(28.dp)
Spacer(modifier = Modifier.width(4.dp)) )
androidx.compose.material3.Text( Spacer(modifier = Modifier.width(4.dp))
text = stringResource(R.string.hot_rooms), androidx.compose.material3.Text(
fontSize = 16.sp, text = stringResource(R.string.hot_rooms),
fontWeight = androidx.compose.ui.text.font.FontWeight.W900, fontSize = 16.sp,
color = AppColors.text 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))
// 热门聊天室网格
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)) } item { Spacer(modifier = Modifier.height(20.dp)) }
// "发现更多" 标题 - 吸顶 // "发现更多" 标题 - 吸顶
@@ -373,81 +365,25 @@ fun Agent() {
} }
} }
// 加载更多指示器 // 加载更多指示器(仅在展示"发现更多"时显示)
if (viewModel.isLoadingMore) { if (viewModel.isLoadingMore) {
item { item {
Row( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 24.dp), .padding(vertical = 24.dp),
horizontalArrangement = Arrangement.Center contentAlignment = Alignment.Center
) { ) {
androidx.compose.material3.CircularProgressIndicator( LottieAnimation(
modifier = Modifier.size(24.dp), composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
color = AppColors.text, iterations = LottieConstants.IterateForever,
strokeWidth = 2.dp modifier = Modifier.size(80.dp)
)
Spacer(modifier = Modifier.width(12.dp))
androidx.compose.material3.Text(
text = "加载中...",
color = AppColors.secondaryText,
fontSize = 14.sp
) )
} }
} }
} }
} }
}
}
@Composable
fun AgentGridLayout(
agentItems: List<AgentItem>,
viewModel: AgentViewModel,
navController: NavHostController
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
// 将agentItems按两列分组
agentItems.chunked(2).forEachIndexed { rowIndex, rowItems ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
top = if (rowIndex == 0) 30.dp else 20.dp, // 第一行添加更多顶部间距
bottom = 20.dp
),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// 第一列
Box(
modifier = Modifier.weight(1f)
) {
AgentCardSquare(
agentItem = rowItems[0],
viewModel = viewModel,
navController = navController
)
}
// 第二列(如果存在)
if (rowItems.size > 1) {
Box(
modifier = Modifier.weight(1f)
) {
AgentCardSquare(
agentItem = rowItems[1],
viewModel = viewModel,
navController = navController
)
}
} else {
// 如果只有一列,添加空白占位
Spacer(modifier = Modifier.weight(1f))
}
}
}
} }
} }
@@ -459,8 +395,7 @@ fun AgentCardSquare(
navController: NavHostController navController: NavHostController
) { ) {
val AppColors = LocalAppTheme.current val AppColors = LocalAppTheme.current
val cardHeight = 180.dp val cardHeight = 210.dp
val avatarSize = cardHeight / 3 // 头像大小为方块高度的三分之一
// 防抖状态 // 防抖状态
var lastClickTime by remember { mutableStateOf(0L) } var lastClickTime by remember { mutableStateOf(0L) }
@@ -469,96 +404,76 @@ fun AgentCardSquare(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(cardHeight) .height(cardHeight)
.background(AppColors.secondaryBackground, RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.clickable { .noRippleClickable {
if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) { if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) {
viewModel.goToProfile(agentItem.openId, navController) viewModel.goToProfile(agentItem.openId, navController)
}) { }) {
lastClickTime = System.currentTimeMillis() lastClickTime = System.currentTimeMillis()
} }
}, }
contentAlignment = Alignment.TopCenter
) { ) {
// 背景大图
CustomAsyncImage(
imageUrl = agentItem.avatar,
contentDescription = agentItem.title,
modifier = Modifier.fillMaxSize(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
defaultRes = R.mipmap.rider_pro_agent
)
// 底部渐变与文字
Box( Box(
modifier = Modifier modifier = Modifier
.offset(y = 4.dp) .align(Alignment.BottomStart)
.size(avatarSize) .fillMaxWidth()
.background(AppColors.background, RoundedCornerShape(avatarSize / 2)) .background(
.clip(RoundedCornerShape(avatarSize / 2)), Brush.verticalGradient(
contentAlignment = Alignment.Center 0f to Color.Transparent,
1f to Color(0xB2000000)
)
)
.padding(12.dp)
) { ) {
Image( Column(
painter = painterResource(R.mipmap.group_copy), modifier = Modifier
contentDescription = "默认头像", .fillMaxWidth()
modifier = Modifier.size(avatarSize), .padding(bottom = 40.dp) // 为底部聊天按钮预留空间
) ) {
if (agentItem.avatar.isNotEmpty()) { androidx.compose.material3.Text(
CustomAsyncImage( text = agentItem.title,
imageUrl = agentItem.avatar, color = Color.White,
contentDescription = "Agent头像", fontSize = 14.sp,
modifier = Modifier fontWeight = androidx.compose.ui.text.font.FontWeight.W700,
.size(avatarSize) maxLines = 1,
.clip(RoundedCornerShape(avatarSize / 2)), overflow = TextOverflow.Ellipsis
contentScale = androidx.compose.ui.layout.ContentScale.Crop )
Spacer(modifier = Modifier.height(4.dp))
androidx.compose.material3.Text(
text = agentItem.desc,
color = Color.White.copy(alpha = 0.92f),
fontSize = 11.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
} }
} }
// 内容区域(名称和描述) // 底部居中 Chat 按钮
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( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = 12.dp) .padding(bottom = 12.dp)
.width(60.dp) .width(70.dp)
.height(32.dp) .height(32.dp)
.background( .background(AppColors.text, RoundedCornerShape(16.dp))
color = AppColors.text, .noRippleClickable {
shape = RoundedCornerShape(
topStart = 14.dp,
topEnd = 14.dp,
bottomStart = 0.dp,
bottomEnd = 14.dp
)
)
.clickable {
if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) { if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) {
// 检查游客模式,如果是游客则跳转登录
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) { if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) {
navController.navigate(NavigationRoute.Login.route) navController.navigate(NavigationRoute.Login.route)
} else { } else {
viewModel.createSingleChat(agentItem.openId) viewModel.createSingleChat(agentItem.openId)
viewModel.goToChatAi( viewModel.goToChatAi(agentItem.openId, navController)
agentItem.openId,
navController = navController
)
} }
}) { }) {
lastClickTime = System.currentTimeMillis() lastClickTime = System.currentTimeMillis()
@@ -568,99 +483,55 @@ fun AgentCardSquare(
) { ) {
androidx.compose.material3.Text( androidx.compose.material3.Text(
text = stringResource(R.string.chat), text = stringResource(R.string.chat),
fontSize = 15.sp,
color = AppColors.background, color = AppColors.background,
fontWeight = androidx.compose.ui.text.font.FontWeight.W500 fontSize = 13.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.W600
) )
} }
} }
} }
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun AgentViewPagerSection(agentItems: List<AgentItem>,viewModel: AgentViewModel) { fun AgentViewPagerSection(agentItems: List<AgentItem>, viewModel: AgentViewModel) {
val AppColors = LocalAppTheme.current val AppColors = LocalAppTheme.current
if (agentItems.isEmpty()) return
// 每页显示5个agent val pagerState = rememberPagerState(pageCount = { agentItems.size })
val itemsPerPage = 5 val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val totalPages = (agentItems.size + itemsPerPage - 1) / itemsPerPage val cardAspect = 1133.5f / 846.4f
// 外层 LazyColumn 左右各 8dp + Pager contentPadding 左右各 20dp
val horizontalPaddings = 56.dp
val pagerHeight = (screenWidth - horizontalPaddings) * cardAspect
if (totalPages > 0) { Column {
val pagerState = rememberPagerState(pageCount = { totalPages }) Box(
modifier = Modifier
.height(pagerHeight)
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 20.dp),
pageSpacing = 12.dp
) { page ->
// 缩放效果
val pageOffset = (
(pagerState.currentPage - page) + pagerState
.currentPageOffsetFraction
).coerceIn(-1f, 1f)
val scale = 1f - (0.06f * kotlin.math.abs(pageOffset))
Column { AgentLargeCard(
// Agent内容 agentItem = agentItems[page],
Box( viewModel = viewModel,
modifier = Modifier navController = LocalNavController.current,
.height(310.dp) modifier = Modifier
) { .graphicsLayer {
HorizontalPager( scaleX = scale
state = pagerState, scaleY = scale
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<AgentItem>, page: Int, modifier: Modifier = Modifier,navController: NavHostController) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 0.dp)
) {
// 显示3个agent
agentItems.forEachIndexed { index, agentItem ->
AgentCard2(agentItem = agentItem, viewModel = viewModel, navController = LocalNavController.current)
if (index < agentItems.size - 1) {
Spacer(modifier = Modifier.height(8.dp))
} }
} }
} }
@@ -668,104 +539,90 @@ fun AgentPage(viewModel: AgentViewModel,agentItems: List<AgentItem>, page: Int,
@SuppressLint("SuspiciousIndentation") @SuppressLint("SuspiciousIndentation")
@Composable @Composable
fun AgentCard2(viewModel: AgentViewModel,agentItem: AgentItem,navController: NavHostController) { fun AgentLargeCard(
agentItem: AgentItem,
viewModel: AgentViewModel,
navController: NavHostController,
modifier: Modifier = Modifier
) {
val AppColors = LocalAppTheme.current val AppColors = LocalAppTheme.current
// 防抖状态
var lastClickTime by remember { mutableStateOf(0L) } var lastClickTime by remember { mutableStateOf(0L) }
Row( Box(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 3.dp), .aspectRatio(846.4f / 1133.5f)
verticalAlignment = Alignment.CenterVertically .clip(RoundedCornerShape(24.dp))
.noRippleClickable {
if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) {
viewModel.goToProfile(agentItem.openId, navController)
}) {
lastClickTime = System.currentTimeMillis()
}
}
) { ) {
// 左侧头像 // 背景大图
CustomAsyncImage(
imageUrl = agentItem.avatar,
contentDescription = agentItem.title,
modifier = Modifier.fillMaxSize(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
defaultRes = R.mipmap.rider_pro_agent
)
// 底部渐变与文字
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .align(Alignment.BottomStart)
.background(Color(0x00F5F5F5), RoundedCornerShape(24.dp)) .fillMaxWidth()
.clickable { .background(
if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) { Brush.verticalGradient(
viewModel.goToProfile(agentItem.openId, navController) 0f to Color.Transparent,
}) { 1f to Color(0xB2000000)
lastClickTime = System.currentTimeMillis() )
} )
}, .padding(16.dp)
contentAlignment = Alignment.Center
) { ) {
Image( Column(
painter = painterResource(R.mipmap.group_copy), modifier = Modifier
contentDescription = "默认头像", .fillMaxWidth()
modifier = Modifier.size(48.dp), .padding(bottom = 56.dp) // 为底部聊天按钮预留空间
) ) {
androidx.compose.material3.Text(
if (agentItem.avatar.isNotEmpty()) { text = agentItem.title,
CustomAsyncImage( color = Color.White,
imageUrl = agentItem.avatar, fontSize = 20.sp,
contentDescription = "Agent头像", fontWeight = androidx.compose.ui.text.font.FontWeight.W700,
modifier = Modifier maxLines = 1,
.size(48.dp) overflow = TextOverflow.Ellipsis
.clip(RoundedCornerShape(24.dp)), )
contentScale = androidx.compose.ui.layout.ContentScale.Crop Spacer(modifier = Modifier.height(8.dp))
androidx.compose.material3.Text(
text = agentItem.desc,
color = Color.White.copy(alpha = 0.92f),
fontSize = 14.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
} }
} }
Spacer(modifier = Modifier.width(12.dp)) // 底部居中 Chat 按钮
// 中间文字内容
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( Box(
modifier = Modifier modifier = Modifier
.size(width = 60.dp, height = 32.dp) .align(Alignment.BottomCenter)
.background( .padding(bottom = 16.dp)
color = Color(0X147c7480), .widthIn(min = 180.dp)
shape = RoundedCornerShape( .fillMaxWidth(0.65f)
topStart = 14.dp, .height(44.dp)
topEnd = 14.dp, .background(AppColors.text, RoundedCornerShape(22.dp))
bottomStart = 0.dp, .noRippleClickable {
bottomEnd = 14.dp
)
)
.clickable {
if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) { if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) {
// 检查游客模式,如果是游客则跳转登录
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) { if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) {
navController.navigate(NavigationRoute.Login.route) navController.navigate(NavigationRoute.Login.route)
} else { } else {
viewModel.createSingleChat(agentItem.openId) viewModel.createSingleChat(agentItem.openId)
viewModel.goToChatAi( viewModel.goToChatAi(agentItem.openId, navController)
agentItem.openId,
navController = navController
)
} }
}) { }) {
lastClickTime = System.currentTimeMillis() lastClickTime = System.currentTimeMillis()
@@ -775,64 +632,11 @@ fun AgentCard2(viewModel: AgentViewModel,agentItem: AgentItem,navController: Nav
) { ) {
androidx.compose.material3.Text( androidx.compose.material3.Text(
text = stringResource(R.string.chat), text = stringResource(R.string.chat),
fontSize = 12.sp, color = AppColors.background,
color = AppColors.text,
fontWeight = androidx.compose.ui.text.font.FontWeight.W500
)
}
}
}
@Composable
fun ChatRoomsSection(
chatRooms: List<ChatRoom>,
navController: NavHostController
) {
val AppColors = LocalAppTheme.current
Column(
modifier = Modifier.fillMaxWidth()
) {
// 标题
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.mipmap.rider_pro_hot_room),
contentDescription = "chat room",
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(4.dp))
androidx.compose.material3.Text(
text = stringResource(R.string.hot_rooms),
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.W900, fontWeight = androidx.compose.ui.text.font.FontWeight.W600
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)
)
}
}
}
}
} }
} }
@@ -844,7 +648,7 @@ fun ChatRoomCard(
) { ) {
val AppColors = LocalAppTheme.current val AppColors = LocalAppTheme.current
val cardSize = 180.dp val cardSize = 180.dp
val viewModel: AgentViewModel = viewModel() val viewModel: AgentViewModel = AgentViewModel
val context = LocalContext.current val context = LocalContext.current
// 防抖状态 // 防抖状态
@@ -863,26 +667,16 @@ fun ChatRoomCard(
modifier = Modifier modifier = Modifier
.size(120.dp) .size(120.dp)
.background( .background(
color = AppColors.background, color = Color.Transparent,
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column( LottieAnimation(
horizontalAlignment = Alignment.CenterHorizontally, composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
verticalArrangement = Arrangement.Center iterations = LottieConstants.IterateForever,
) { modifier = Modifier.size(96.dp)
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
)
}
} }
} }
} }
@@ -893,7 +687,10 @@ fun ChatRoomCard(
.size(cardSize) .size(cardSize)
.background(AppColors.tabUnselectedBackground, RoundedCornerShape(12.dp)) .background(AppColors.tabUnselectedBackground, RoundedCornerShape(12.dp))
.clickable(enabled = !viewModel.isJoiningRoom) { .clickable(enabled = !viewModel.isJoiningRoom) {
if (!viewModel.isJoiningRoom && DebounceUtils.simpleDebounceClick(lastClickTime, 500L) { if (!viewModel.isJoiningRoom && DebounceUtils.simpleDebounceClick(
lastClickTime,
500L
) {
// 加入群聊房间 // 加入群聊房间
viewModel.joinRoom( viewModel.joinRoom(
id = chatRoom.id, id = chatRoom.id,
@@ -908,7 +705,8 @@ fun ChatRoomCard(
// 处理错误可以显示Toast或其他提示 // 处理错误可以显示Toast或其他提示
} }
) )
}) { }
) {
lastClickTime = System.currentTimeMillis() lastClickTime = System.currentTimeMillis()
} }
} }
@@ -916,29 +714,23 @@ fun ChatRoomCard(
// 优先显示banner如果没有banner则显示头像 // 优先显示banner如果没有banner则显示头像
val imageUrl = if (chatRoom.banner.isNotEmpty()) chatRoom.banner else chatRoom.avatar val imageUrl = if (chatRoom.banner.isNotEmpty()) chatRoom.banner else chatRoom.avatar
if (imageUrl.isNotEmpty()) { CustomAsyncImage(
CustomAsyncImage( imageUrl = imageUrl,
imageUrl = imageUrl, contentDescription = if (chatRoom.banner.isNotEmpty()) "房间banner" else "房间头像",
contentDescription = if (chatRoom.banner.isNotEmpty()) "房间banner" else "房间头像", modifier = Modifier
modifier = Modifier .width(cardSize)
.width(cardSize) .height(120.dp)
.height(120.dp) .clip(
.clip(RoundedCornerShape( RoundedCornerShape(
topStart = 12.dp, topStart = 12.dp,
topEnd = 12.dp, topEnd = 12.dp,
bottomStart = 0.dp, bottomStart = 0.dp,
bottomEnd = 0.dp)), bottomEnd = 0.dp
contentScale = androidx.compose.ui.layout.ContentScale.Crop )
) ),
} else { contentScale = androidx.compose.ui.layout.ContentScale.Crop,
// 默认房间图标 defaultRes = R.mipmap.rider_pro_agent
Image( )
painter = painterResource(R.mipmap.rider_pro_agent),
contentDescription = "默认房间图标",
modifier = Modifier.size(cardSize * 0.4f),
colorFilter = ColorFilter.tint(AppColors.secondaryText)
)
}
// 房间名称,重叠在底部 // 房间名称,重叠在底部
Box( Box(
@@ -981,9 +773,13 @@ fun ChatRoomCard(
Text( Text(
text = "${chatRoom.memberCount} ${stringResource(R.string.chatting_now)}", text = "${chatRoom.memberCount} ${stringResource(R.string.chatting_now)}",
fontSize = 12.sp, fontSize = 12.sp,
modifier = Modifier.alpha(0.6f), modifier = Modifier
.alpha(0.6f)
.weight(1f),
color = AppColors.text, color = AppColors.text,
fontWeight = androidx.compose.ui.text.font.FontWeight.W500 fontWeight = androidx.compose.ui.text.font.FontWeight.W500,
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
} }
} }

View File

@@ -134,7 +134,7 @@ object AgentViewModel: ViewModel() {
pageSize = pageSize, pageSize = pageSize,
withWorkflow = 1, withWorkflow = 1,
categoryIds = listOf(categoryId), categoryIds = listOf(categoryId),
random = 1 // random = 1
) )
} else { } else {
// 获取推荐智能体使用random=1 // 获取推荐智能体使用random=1
@@ -143,7 +143,7 @@ object AgentViewModel: ViewModel() {
pageSize = pageSize, pageSize = pageSize,
withWorkflow = 1, withWorkflow = 1,
categoryIds = null, categoryIds = null,
random = 1 // random = 1
) )
} }
@@ -197,7 +197,7 @@ object AgentViewModel: ViewModel() {
page = 1, page = 1,
pageSize = 20, pageSize = 20,
isRecommended = 1, isRecommended = 1,
random = 1 // random = "1"
) )
if (response.isSuccessful) { if (response.isSuccessful) {
val allRooms = response.body()?.list ?: emptyList() val allRooms = response.body()?.list ?: emptyList()
@@ -332,18 +332,17 @@ object AgentViewModel: ViewModel() {
openId: String, openId: String,
navController: NavHostController navController: NavHostController
) { ) {
viewModelScope.launch { // 直接使用openId导航页面内的AiProfileViewModel会处理数据加载
try { // 避免重复请求因为AiProfileViewModel.loadProfile已经支持通过openId加载
val profile = userService.getUserProfileByOpenId(openId) try {
// 从Agent列表点击进去的一定是智能体直接传递isAiAccount = true navController.navigate(
navController.navigate( NavigationRoute.AccountProfile.route
NavigationRoute.AccountProfile.route .replace("{id}", openId)
.replace("{id}", profile.id.toString()) .replace("{isAiAccount}", "true")
.replace("{isAiAccount}", "true") )
) } catch (e: Exception) {
} catch (e: Exception) { Log.e("AgentViewModel", "Navigation failed", e)
// swallow error to avoid crash on navigation attempt failures e.printStackTrace()
}
} }
} }

View File

@@ -156,10 +156,10 @@ object HotAgentViewModel : ViewModel() {
try { try {
// 预加载头像图片到缓存 // 预加载头像图片到缓存
com.aiosman.ravenow.utils.Utils.getImageLoader(context).enqueue( com.aiosman.ravenow.utils.Utils.getImageLoader(context).enqueue(
coil.request.ImageRequest.Builder(context) coil3.request.ImageRequest.Builder(context)
.data(agent.avatar) .data(agent.avatar)
.memoryCachePolicy(coil.request.CachePolicy.ENABLED) .memoryCachePolicy(coil3.request.CachePolicy.ENABLED)
.diskCachePolicy(coil.request.CachePolicy.ENABLED) .diskCachePolicy(coil3.request.CachePolicy.ENABLED)
.build() .build()
) )
preloadedImageIds.add(agent.id) preloadedImageIds.add(agent.id)

View File

@@ -163,7 +163,7 @@ fun NotificationsScreen() {
modifier = Modifier modifier = Modifier
.size(24.dp) .size(24.dp)
.noRippleClickable { .noRippleClickable {
// TODO: 实现搜索功能 navController.navigate(NavigationRoute.Search.route)
}, },
colorFilter = ColorFilter.tint(AppColors.text) colorFilter = ColorFilter.tint(AppColors.text)
) )

View File

@@ -30,6 +30,7 @@ import io.openim.android.sdk.models.ConversationInfo
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import com.aiosman.ravenow.data.repo.TrtcUserTypeRepository
data class Conversation( data class Conversation(
val id: String, val id: String,
@@ -76,6 +77,13 @@ object MessageListViewModel : ViewModel() {
// noticeInfo = info // noticeInfo = info
// //
// isLoading = false // isLoading = false
if (loadChat && com.aiosman.ravenow.AppState.enableChat) {
// 预热 trtcId -> isAI 缓存,避免首次进入 Agent/Friends 列表时阻塞
try {
prewarmChatTypes(context)
} catch (_: Exception) {
}
}
} }
@@ -163,5 +171,23 @@ object MessageListViewModel : ViewModel() {
} }
} }
private suspend fun prewarmChatTypes(context: Context) {
val result = suspendCoroutine { continuation ->
OpenIMClient.getInstance().conversationManager.getAllConversationList(
object : OnBase<List<ConversationInfo>> {
override fun onSuccess(data: List<ConversationInfo>?) {
continuation.resumeWith(Result.success(data ?: emptyList()))
}
override fun onError(code: Int, error: String?) {
continuation.resumeWith(Result.failure(Exception("Error $code: $error")))
}
}
)
}
val trtcIds = result.filter { it.conversationType == 1 }
.mapNotNull { it.userID }
.distinct()
TrtcUserTypeRepository.ensureTypes(context, trtcIds)
}
} }

View File

@@ -34,6 +34,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight 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.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -47,6 +48,11 @@ import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.rememberDebouncer import com.aiosman.ravenow.ui.composables.rememberDebouncer
import com.aiosman.ravenow.ui.modifiers.noRippleClickable import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.utils.NetworkUtils import com.aiosman.ravenow.utils.NetworkUtils
import com.aiosman.ravenow.ui.network.ReloadButton
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.rememberLottieComposition
/** /**
* 智能体聊天列表页面 * 智能体聊天列表页面
@@ -96,26 +102,23 @@ fun AgentChatListScreen() {
if (isNetworkAvailable) { if (isNetworkAvailable) {
Spacer(modifier = Modifier.height(39.dp)) Spacer(modifier = Modifier.height(39.dp))
Image( Image(
painter = painterResource( painter = painterResource(id = R.mipmap.invalid_name_3),
id = if(AppState.darkMode) R.mipmap.juhao_dark
else R.mipmap.invalid_name_5),
contentDescription = "null data", contentDescription = "null data",
modifier = Modifier modifier = Modifier
.size(width = 181.dp, height = 153.dp) .width(181.dp)
.height(153.dp)
) )
Spacer(modifier = Modifier.height(if (AppState.darkMode) 9.dp else 24.dp)) Spacer(modifier = Modifier.height(9.dp))
Text( Text(
text = stringResource(R.string.agent_chat_empty_title), text = stringResource(R.string.no_one_knocked_yet),
color = AppColors.text, color = AppColors.text,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.W600 fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.agent_chat_empty_subtitle),
color = AppColors.secondaryText,
fontSize = 14.sp
)
} }
else { else {
Spacer(modifier = Modifier.height(39.dp)) Spacer(modifier = Modifier.height(39.dp))
@@ -130,13 +133,21 @@ fun AgentChatListScreen() {
text = stringResource(R.string.friend_chat_no_network_title), text = stringResource(R.string.friend_chat_no_network_title),
color = AppColors.text, color = AppColors.text,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.W600 fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.friend_chat_no_network_subtitle), text = stringResource(R.string.friend_chat_no_network_subtitle),
color = AppColors.secondaryText, color = AppColors.secondaryText,
fontSize = 14.sp fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
maxLines = 3,
overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
ReloadButton( ReloadButton(
@@ -180,9 +191,10 @@ fun AgentChatListScreen() {
.padding(16.dp), .padding(16.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
CircularProgressIndicator( LottieAnimation(
modifier = Modifier.size(24.dp), composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
color = AppColors.main iterations = LottieConstants.IterateForever,
modifier = Modifier.size(80.dp)
) )
} }
} }

View File

@@ -2,6 +2,7 @@ package com.aiosman.ravenow.ui.index.tabs.message.tab
import android.content.Context import android.content.Context
import android.icu.util.Calendar import android.icu.util.Calendar
import android.util.Log
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -28,6 +29,7 @@ import io.openim.android.sdk.models.Message
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import com.aiosman.ravenow.utils.MessageParser import com.aiosman.ravenow.utils.MessageParser
import com.aiosman.ravenow.data.repo.TrtcUserTypeRepository
data class AgentConversation( data class AgentConversation(
val id: String, val id: String,
@@ -53,7 +55,7 @@ data class AgentConversation(
nickname = conversation.showName ?: "", nickname = conversation.showName ?: "",
lastMessage = displayText, // 使用解析后的显示文本 lastMessage = displayText, // 使用解析后的显示文本
lastMessageTime = lastMessage.time.formatChatTime(context), lastMessageTime = lastMessage.time.formatChatTime(context),
avatar = "${ApiClient.BASE_API_URL+"/"}${conversation.faceURL}"+"?token="+"${AppStore.token}".replace("storage/avatars/", "/avatar/"), avatar = "${ApiClient.BASE_SERVER}${conversation.faceURL}",
unreadCount = conversation.unreadCount, unreadCount = conversation.unreadCount,
trtcUserId = conversation.userID ?: "", trtcUserId = conversation.userID ?: "",
displayText = displayText, displayText = displayText,
@@ -127,15 +129,7 @@ object AgentChatListViewModel : ViewModel() {
OpenIMClient.getInstance().conversationManager.getAllConversationList( OpenIMClient.getInstance().conversationManager.getAllConversationList(
object : OnBase<List<ConversationInfo>> { object : OnBase<List<ConversationInfo>> {
override fun onSuccess(data: List<ConversationInfo>?) { override fun onSuccess(data: List<ConversationInfo>?) {
// 过滤出智能体会话(单聊类型,且可能有特定标识) continuation.resumeWith(Result.success(data ?: emptyList()))
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?) { override fun onError(code: Int, error: String?) {
@@ -145,7 +139,22 @@ object AgentChatListViewModel : ViewModel() {
) )
} }
agentChatList = result.map { conversation -> // 仅单聊
val singleChats = result.filter { it.conversationType == 1 }
val trtcIds = singleChats.mapNotNull { it.userID }.distinct()
// 预热缓存包含AI
try {
TrtcUserTypeRepository.ensureTypes(context, trtcIds)
} catch (e: Exception) {
Log.w("AgentChatListViewModel", "ensureTypes failed: ${e.message}")
}
// 过滤出 AI 会话
val filtered = singleChats.filter { conv ->
val id = conv.userID ?: return@filter false
TrtcUserTypeRepository.getCachedType(id) == true
}
agentChatList = filtered.map { conversation ->
AgentConversation.convertToAgentConversation(conversation, context) AgentConversation.convertToAgentConversation(conversation, context)
} }
} }

View File

@@ -42,6 +42,7 @@ import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.rememberDebouncer import com.aiosman.ravenow.ui.composables.rememberDebouncer
import com.aiosman.ravenow.ui.modifiers.noRippleClickable import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.utils.NetworkUtils import com.aiosman.ravenow.utils.NetworkUtils
import com.aiosman.ravenow.ui.network.ReloadButton
import androidx.compose.material.Button import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults import androidx.compose.material.ButtonDefaults
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -121,35 +122,88 @@ fun AllChatListScreen() {
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
// 监听各个 ViewModel 的列表变化
val agentChatList = AgentChatListViewModel.agentChatList
val groupChatList = GroupChatListViewModel.groupChatList
val friendChatList = FriendChatListViewModel.friendChatList
// 当列表变化时,自动更新合并列表
LaunchedEffect(agentChatList, groupChatList, friendChatList) {
val combinedList = mutableListOf<CombinedConversation>()
agentChatList.forEach { agent ->
combinedList.add(CombinedConversation(type = "agent", agentConversation = agent))
}
groupChatList.forEach { group ->
combinedList.add(CombinedConversation(type = "group", groupConversation = group))
}
friendChatList.forEach { friend ->
val isDuplicate = combinedList.any {
it.type == "agent" && it.agentConversation?.trtcUserId == friend.trtcUserId
}
if (!isDuplicate) {
combinedList.add(CombinedConversation(type = "friend", friendConversation = friend))
}
}
// 按最后消息时间排序
val sortedList = combinedList.sortedByDescending {
it.lastMessageTime
}
allConversations = sortedList
}
// 监听加载状态
val isAnyLoading = AgentChatListViewModel.isLoading ||
GroupChatListViewModel.isLoading ||
FriendChatListViewModel.isLoading
// 当加载状态变化时,更新 isLoading
LaunchedEffect(isAnyLoading) {
if (isAnyLoading) {
// 如果有任何数据正在加载,确保显示加载状态
if (!isLoading) {
isLoading = true
}
} else {
// 所有数据加载完成
if (isLoading) {
isLoading = false
}
}
}
val state = rememberPullRefreshState( val state = rememberPullRefreshState(
refreshing = refreshing, refreshing = refreshing,
onRefresh = { onRefresh = {
refreshing = true refreshing = true
refreshAllData(context, // 刷新所有类型的数据
onSuccess = { conversations -> AgentChatListViewModel.refreshPager(pullRefresh = true, context = context)
allConversations = conversations GroupChatListViewModel.refreshPager(pullRefresh = true, context = context)
refreshing = false FriendChatListViewModel.refreshPager(pullRefresh = true, context = context)
},
onError = { errorMsg ->
error = errorMsg
refreshing = false
}
)
} }
) )
// 监听刷新状态
LaunchedEffect(AgentChatListViewModel.refreshing, GroupChatListViewModel.refreshing, FriendChatListViewModel.refreshing) {
val isAnyRefreshing = AgentChatListViewModel.refreshing ||
GroupChatListViewModel.refreshing ||
FriendChatListViewModel.refreshing
if (!isAnyRefreshing && refreshing) {
refreshing = false
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
isLoading = true isLoading = true
refreshAllData(context, // 初始化加载所有类型的数据
onSuccess = { conversations -> AgentChatListViewModel.refreshPager(context = context)
allConversations = conversations GroupChatListViewModel.refreshPager(context = context)
isLoading = false FriendChatListViewModel.refreshPager(context = context)
},
onError = { errorMsg ->
error = errorMsg
isLoading = false
}
)
} }
Column( Column(
@@ -174,25 +228,22 @@ fun AllChatListScreen() {
if (isNetworkAvailable) { if (isNetworkAvailable) {
Spacer(modifier = Modifier.height(39.dp)) Spacer(modifier = Modifier.height(39.dp))
Image( Image(
painter = painterResource( painter = painterResource(id = R.mipmap.invalid_name_3),
id = if(AppState.darkMode) R.mipmap.piao_dark
else R.mipmap.invalid_name_2),
contentDescription = "null data", contentDescription = "null data",
modifier = Modifier modifier = Modifier
.size(width = 181.dp, height = 153.dp) .width(181.dp)
.height(153.dp)
) )
Spacer(modifier = Modifier.height(if (AppState.darkMode) 9.dp else 24.dp)) Spacer(modifier = Modifier.height(9.dp))
Text( Text(
text = stringResource(R.string.friend_chat_empty_title), text = stringResource(R.string.no_one_knocked_yet),
color = AppColors.text, color = AppColors.text,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.W600 fontWeight = FontWeight.W600,
) textAlign = TextAlign.Center,
Spacer(modifier = Modifier.height(8.dp)) modifier = Modifier.padding(horizontal = 24.dp),
Text( maxLines = 2,
text = stringResource(R.string.friend_chat_empty_subtitle), overflow = TextOverflow.Ellipsis
color = AppColors.secondaryText,
fontSize = 14.sp
) )
} else { } else {
Spacer(modifier = Modifier.height(39.dp)) Spacer(modifier = Modifier.height(39.dp))
@@ -207,28 +258,30 @@ fun AllChatListScreen() {
text = stringResource(R.string.friend_chat_no_network_title), text = stringResource(R.string.friend_chat_no_network_title),
color = AppColors.text, color = AppColors.text,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.W600 fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.friend_chat_no_network_subtitle), text = stringResource(R.string.friend_chat_no_network_subtitle),
color = AppColors.secondaryText, color = AppColors.secondaryText,
fontSize = 14.sp fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
maxLines = 3,
overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
ReloadButton( ReloadButton(
onClick = { onClick = {
isLoading = true isLoading = true
refreshAllData(context, // 重新加载所有类型的数据
onSuccess = { conversations -> AgentChatListViewModel.refreshPager(context = context)
allConversations = conversations GroupChatListViewModel.refreshPager(context = context)
isLoading = false FriendChatListViewModel.refreshPager(context = context)
},
onError = { errorMsg ->
error = errorMsg
isLoading = false
}
)
} }
) )
} }
@@ -347,44 +400,3 @@ fun AllChatListScreen() {
} }
} }
fun refreshAllData(
context: android.content.Context,
onSuccess: (List<CombinedConversation>) -> Unit,
onError: (String) -> Unit
) {
try {
// 同时刷新所有类型的数据
AgentChatListViewModel.refreshPager(context = context)
GroupChatListViewModel.refreshPager(context = context)
FriendChatListViewModel.refreshPager(context = context)
val combinedList = mutableListOf<CombinedConversation>()
AgentChatListViewModel.agentChatList.forEach { agent ->
combinedList.add(CombinedConversation(type = "agent", agentConversation = agent))
}
GroupChatListViewModel.groupChatList.forEach { group ->
combinedList.add(CombinedConversation(type = "group", groupConversation = group))
}
FriendChatListViewModel.friendChatList.forEach { friend ->
val isDuplicate = combinedList.any {//判断重复
it.type == "agent" && it.agentConversation?.trtcUserId == friend.trtcUserId
}
if (!isDuplicate) {
combinedList.add(CombinedConversation(type = "friend", friendConversation = friend))
}
}
// 按最后消息时间排序
val sortedList = combinedList.sortedByDescending {
it.lastMessageTime
}
onSuccess(sortedList)
} catch (e: Exception) {
onError("刷新数据失败: ${e.message}")
}
}

View File

@@ -84,26 +84,23 @@ fun FriendChatListScreen() {
if (isNetworkAvailable) { if (isNetworkAvailable) {
Spacer(modifier = Modifier.height(39.dp)) Spacer(modifier = Modifier.height(39.dp))
Image( Image(
painter = painterResource( painter = painterResource(id = R.mipmap.invalid_name_3),
id = if(AppState.darkMode) R.mipmap.piao_dark
else R.mipmap.invalid_name_2),
contentDescription = "null data", contentDescription = "null data",
modifier = Modifier modifier = Modifier
.size(width = 181.dp, height = 153.dp) .width(181.dp)
.height(153.dp)
) )
Spacer(modifier = Modifier.height(if (AppState.darkMode) 9.dp else 24.dp)) Spacer(modifier = Modifier.height(9.dp))
Text( Text(
text = stringResource(R.string.friend_chat_empty_title), text = stringResource(R.string.no_one_knocked_yet),
color = AppColors.text, color = AppColors.text,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.W600 fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.friend_chat_empty_subtitle),
color = AppColors.secondaryText,
fontSize = 14.sp
)
}else { }else {
Spacer(modifier = Modifier.height(39.dp)) Spacer(modifier = Modifier.height(39.dp))
Image( Image(
@@ -117,13 +114,21 @@ fun FriendChatListScreen() {
text = stringResource(R.string.friend_chat_no_network_title), text = stringResource(R.string.friend_chat_no_network_title),
color = AppColors.text, color = AppColors.text,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.W600 fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.friend_chat_no_network_subtitle), text = stringResource(R.string.friend_chat_no_network_subtitle),
color = AppColors.secondaryText, color = AppColors.secondaryText,
fontSize = 14.sp fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
maxLines = 3,
overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
ReloadButton( ReloadButton(

View File

@@ -2,6 +2,7 @@ package com.aiosman.ravenow.ui.index.tabs.message.tab
import android.content.Context import android.content.Context
import android.icu.util.Calendar import android.icu.util.Calendar
import android.util.Log
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -25,6 +26,7 @@ import io.openim.android.sdk.models.Message
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import com.aiosman.ravenow.utils.MessageParser import com.aiosman.ravenow.utils.MessageParser
import com.aiosman.ravenow.data.repo.TrtcUserTypeRepository
data class FriendConversation( data class FriendConversation(
val id: String, val id: String,
@@ -135,13 +137,19 @@ object FriendChatListViewModel : ViewModel() {
) )
} }
// 过滤出朋友会话(单聊类型,且排除 AI 智能体) // 仅单聊
val filteredConversations = result.filter { conversation -> val singleChats = result.filter { it.conversationType == 1 }
// 1 表示单聊,排除 AI 智能体会话 val trtcIds = singleChats.mapNotNull { it.userID }.distinct()
conversation.conversationType == 1 && // 预热缓存包含AI
// 可以根据实际业务逻辑添加更多过滤条件 try {
// 比如排除 AI 智能体的 userID 前缀或标识 TrtcUserTypeRepository.ensureTypes(context, trtcIds)
!(conversation.userID?.startsWith("ai_") == true) } catch (e: Exception) {
Log.w("FriendChatListViewModel", "ensureTypes failed: ${e.message}")
}
// 过滤出普通人会话(未知也归入普通人以回退)
val filteredConversations = singleChats.filter { conversation ->
val id = conversation.userID ?: return@filter true
TrtcUserTypeRepository.getCachedType(id) != true
} }
friendChatList = filteredConversations.map { conversation -> friendChatList = filteredConversations.map { conversation ->

View File

@@ -12,7 +12,6 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -23,9 +22,11 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.graphics.Color
import com.aiosman.ravenow.AppState import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.LocalAppTheme import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController import com.aiosman.ravenow.LocalNavController
@@ -34,6 +35,7 @@ import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.rememberDebouncer import com.aiosman.ravenow.ui.composables.rememberDebouncer
import com.aiosman.ravenow.ui.modifiers.noRippleClickable import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.utils.NetworkUtils import com.aiosman.ravenow.utils.NetworkUtils
import com.aiosman.ravenow.ui.network.ReloadButton
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
@@ -76,26 +78,23 @@ fun GroupChatListScreen() {
if (isNetworkAvailable) { if (isNetworkAvailable) {
Spacer(modifier = Modifier.height(39.dp)) Spacer(modifier = Modifier.height(39.dp))
Image( Image(
painter = painterResource( painter = painterResource(id = R.mipmap.invalid_name_3),
id = if(AppState.darkMode) R.mipmap.fei_dark
else R.mipmap.invalid_name_12),
contentDescription = "null data", contentDescription = "null data",
modifier = Modifier modifier = Modifier
.size(width = 181.dp, height = 153.dp) .width(181.dp)
.height(153.dp)
) )
Spacer(modifier = Modifier.height(if (AppState.darkMode) 9.dp else 24.dp)) Spacer(modifier = Modifier.height(9.dp))
Text( Text(
text = stringResource(R.string.group_chat_empty), text = stringResource(R.string.no_one_knocked_yet),
color = AppColors.text, color = AppColors.text,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.W600 fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.group_chat_empty_join),
color = AppColors.secondaryText,
fontSize = 14.sp
)
}else { }else {
Spacer(modifier = Modifier.height(39.dp)) Spacer(modifier = Modifier.height(39.dp))
Image( Image(
@@ -109,13 +108,21 @@ fun GroupChatListScreen() {
text = stringResource(R.string.friend_chat_no_network_title), text = stringResource(R.string.friend_chat_no_network_title),
color = AppColors.text, color = AppColors.text,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.W600 fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.friend_chat_no_network_subtitle), text = stringResource(R.string.friend_chat_no_network_subtitle),
color = AppColors.secondaryText, color = AppColors.secondaryText,
fontSize = 14.sp fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
maxLines = 3,
overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
ReloadButton( ReloadButton(
@@ -146,13 +153,6 @@ fun GroupChatListScreen() {
} }
} }
) )
if (index < GroupChatListViewModel.groupChatList.size - 1) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 24.dp),
color = AppColors.divider
)
}
} }
if (GroupChatListViewModel.isLoading && GroupChatListViewModel.groupChatList.isNotEmpty()) { if (GroupChatListViewModel.isLoading && GroupChatListViewModel.groupChatList.isNotEmpty()) {
@@ -206,16 +206,16 @@ fun GroupChatItem(
val AppColors = LocalAppTheme.current val AppColors = LocalAppTheme.current
val chatDebouncer = rememberDebouncer() val chatDebouncer = rememberDebouncer()
val avatarDebouncer = rememberDebouncer() val avatarDebouncer = rememberDebouncer()
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp)
.noRippleClickable { .noRippleClickable {
chatDebouncer { chatDebouncer {
onChatClick(conversation) onChatClick(conversation)
} }
} },
verticalAlignment = Alignment.CenterVertically
) { ) {
Box { Box {
CustomAsyncImage( CustomAsyncImage(
@@ -235,9 +235,9 @@ fun GroupChatItem(
Column( Column(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(start = 12.dp) .padding(start = 12.dp, top = 2.dp),
verticalArrangement = Arrangement.Center
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -245,22 +245,22 @@ fun GroupChatItem(
) { ) {
Text( Text(
text = conversation.groupName, text = conversation.groupName,
fontSize = 16.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = AppColors.text, color = AppColors.text,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = conversation.lastMessageTime, text = conversation.lastMessageTime,
fontSize = 12.sp, fontSize = 11.sp,
color = AppColors.secondaryText color = AppColors.secondaryText
) )
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(6.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -268,29 +268,29 @@ fun GroupChatItem(
) { ) {
Text( Text(
text = "${if (conversation.isSelf) stringResource(R.string.friend_chat_me_prefix) else ""}${conversation.displayText}", text = "${if (conversation.isSelf) stringResource(R.string.friend_chat_me_prefix) else ""}${conversation.displayText}",
fontSize = 14.sp, fontSize = 12.sp,
color = AppColors.secondaryText, color = AppColors.secondaryText,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(10.dp))
if (conversation.unreadCount > 0) { if (conversation.unreadCount > 0) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(if (conversation.unreadCount > 99) 24.dp else 20.dp) .size(if (conversation.unreadCount > 99) 24.dp else 20.dp)
.background( .background(
color = AppColors.main, color = Color(0xFFFF3B30),
shape = CircleShape shape = CircleShape
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = if (conversation.unreadCount > 99) "99+" else conversation.unreadCount.toString(), text = if (conversation.unreadCount > 99) "99+" else conversation.unreadCount.toString(),
color = AppColors.mainText, color = Color.White,
fontSize = if (conversation.unreadCount > 99) 9.sp else 10.sp, fontSize = if (conversation.unreadCount > 99) 11.sp else 12.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
} }

Some files were not shown because too many files have changed in this diff Show More