139 Commits
atm ... zhong_1

Author SHA1 Message Date
742b8b25e8 创建群聊页面UI调整:修改创建群聊按钮正常状态下的颜色;新增禁用状态下点击时提示语 2025-12-01 18:45:08 +08:00
af3b7e7bb9 Merge pull request #95 from Kevinlinpr/zhong_1
修复BUG-我的pai coin积分、草稿箱和举报界面,上滑弹框,弹框会持续抖动
2025-12-01 10:44:58 +08:00
b11910d8a9 Merge pull request #96 from Kevinlinpr/nagisa
调整个人资料编辑简介框、修改短视频暂停图标、修复短视频界面点暂停锁屏后再解锁会自动播放视频
2025-12-01 10:44:20 +08:00
80ac0090bd 调整个人资料编辑简介框、修改短视频暂停图标、修复短视频界面点暂停锁屏后再解锁会自动播放视频 2025-11-28 18:48:43 +08:00
1e3e417035 Merge branch 'main' of github.com:Kevinlinpr/rider-pro-android-app into zhong_1 2025-11-28 18:45:40 +08:00
ca8b7b3c53 修复BUG-我的pai coin积分、草稿箱和举报界面,上滑弹框,弹框会持续抖动 2025-11-28 18:44:49 +08:00
e33c1e8aef Merge pull request #94 from Kevinlinpr/nagisa
文本
2025-11-28 17:47:20 +08:00
fcce1f863b 文本 2025-11-28 17:41:08 +08:00
04974b0a01 Merge pull request #92 from Kevinlinpr/nagisa
调整界面以及修复多个bug
2025-11-28 15:01:41 +08:00
672dc1859f Merge branch 'main' into nagisa 2025-11-28 14:59:47 +08:00
90e47fc3ce Merge pull request #93 from Kevinlinpr/zhong_1
通知界面UI调整
2025-11-28 14:51:30 +08:00
084eb7bb52 替换聊天界面发送按钮图标;
修复群聊天界面顶部不显示群头像以及组件异常显示;
修改群聊信息界面群记忆UI
2025-11-27 18:57:54 +08:00
8937ccbf56 修复多个bug、更改评论筛选
-修复搜索/动态/关注界面可以同时点赞/收藏同一个动态,使总点赞/收藏数增加
-修复个人主页上滑时动态/智能体/群聊图标会被遮挡
-评论显示不全,只显示50条内容(现在滑动到第50条评论后会出现加载更多按键)
-评论筛选改为全部和最新和热门
-修复评论筛选图标自动发生改变
2025-11-27 18:38:18 +08:00
fe78b5192b Merge branch 'main' of github.com:Kevinlinpr/rider-pro-android-app into zhong_1 2025-11-26 18:54:27 +08:00
cfe5ce8102 通知界面UI调整
修复BUG:通知界面系统栏空白区域调整合适长度,统一点赞/粉丝/评论3个页面的消息长宽和大小
2025-11-26 18:45:17 +08:00
05615ce5dc 调整界面以及修复多个bug
-调整mbti类型界面
-修复断网时,用户资料编辑保存出现闪退
-修复暂停短视频后进入后台并返回,视频会继续播放,暂停图标不会消失
-修复输入框为空时显示为英文,且个人简介布局靠上
-修复其他用户/智能体个人主页群聊列表显示我加入的群聊
-修复系统切换暗黑模式,应用会刷新,但模式不变
2025-11-26 18:44:07 +08:00
3d64cc5929 Merge pull request #90 from Kevinlinpr/zhong_1
优化代码
2025-11-26 10:18:12 +08:00
e59d4283ca Merge pull request #91 from Kevinlinpr/nagisa
界面调整、以及修复bug等
2025-11-26 10:17:51 +08:00
5b9487b60d ui调整、bug修复
-修复星座以及mbti类型选择后不需要保存就能更改成功
-mbti类型选择界面缺省图调整
-修复我的-侧边栏在滑动时会出现黑色遮罩
2025-11-25 18:12:30 +08:00
0a1601c16c 新增空状态组件文件
GalleryItem.kt、GroupChatEmptyContent.kt 、UserAgentsList.kt 3个文件都使用统一的 EmptyStateView 组件来显示空状态
消息页统一使用ChatEmptyStateView组件来显示空状态
2025-11-25 17:55:18 +08:00
c94fcd493e 界面调整、以及修复bug等
-收藏界面和动态界面添加了多图角标和视频角标
-短视频新增双击点赞和双击取消点赞功能
-修复帖子详情页的多图内容不能左右滑动图片,去掉帖子详情页多图下通过Next和Previous按钮来切换图片
-评论框界面调整
2025-11-24 18:35:51 +08:00
357790d794 优化代码
修改我的-群聊UI将群聊列表样改为网格样式
2025-11-24 18:28:42 +08:00
6d18e13826 Merge pull request #89 from Kevinlinpr/zhong_1
优化网络错误缺省图
2025-11-24 14:59:51 +08:00
9c592ee62b 优化网络错误缺省图
将缺省内容单独写成函数,替换对应标签页中的缺省内容,避免大量重复代码
2025-11-21 18:56:52 +08:00
f238a2e83f Merge pull request #87 from Kevinlinpr/nagisa
修复bug、暗色模式适配
2025-11-20 23:06:53 +08:00
882de043ee Merge pull request #88 from Kevinlinpr/zhong_1
优化通知页面、关于派派页面UI
2025-11-20 23:06:27 +08:00
1a0ed2da19 优化页面卡顿
将缺省内容单独写成函数,替换4个标签页中的缺省内容避免重复代码
2025-11-20 18:52:10 +08:00
958d1c16be 修复bug、暗色模式适配
-密码界面和删除账户界面暗色模式适配
-调整消息:所有Tab 分类下的缺省图和文案大小和位置
-修复给搜索到的帖子点赞或者收藏再退出搜索界面 去动态界面或者关注界面找到这个帖子点赞或者收藏后 点进这个帖子点赞或者收藏会变为双倍
2025-11-20 18:50:55 +08:00
e686bc3b52 优化通知页面UI
关闭列表中的顶部占位,用户名Text增加maxLines = 1和overflow = TextOverflow.Ellipsis,超长昵称会自动截断显示…。
关于派派UI调整
更换图标和文本
2025-11-20 18:47:30 +08:00
bd5079806b Merge pull request #85 from Kevinlinpr/nagisa
修复多个界面和权限相关问题
2025-11-19 23:50:17 +08:00
787c5dc574 Merge pull request #86 from Kevinlinpr/zhong_1
群聊消息页面、群聊成员页面UI调整
2025-11-19 23:49:38 +08:00
f89564e60a 群聊消息页面、群聊成员页面UI调整
进入群聊后判断当前用户是否为群主,若为群主则显示“添加成员、群资料设置、群可见性、删除成员”组件;替换组件图标
2025-11-19 18:37:43 +08:00
778c06342a 修复多个界面和权限相关问题
- 修复其他用户个人主页群聊列表显示问题
- 移除不必要的全部、公开、私有标签栏
- 修复搜索框切换类型闪退
- 修复摄像机权限被拒绝时的闪退问题
- 修复视频动态显示不全问题
- 移除标签栏触摸反馈
2025-11-19 18:30:07 +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
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
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
6590b09300 Merge pull request #55 from Kevinlinpr/zhong_1
修改BUG:登录账号邮箱格式错误无提示
2025-11-10 14:06:17 +08:00
30563a75f3 Merge branch 'main' into zhong_1 2025-11-10 11:20:08 +08:00
aac3220a69 Merge pull request #56 from Kevinlinpr/feat/pr-20251104-154907
我的(侧边栏)ui调整
2025-11-10 11:05:12 +08:00
e2de134180 Merge branch 'main' into zhong_1
Merge main branch to keep zhong_1 up to date# Please enter a commit message to explain why this merge is necessary,
2025-11-10 10:19:44 +08:00
7f1be94896 Merge pull request #54 from Kevinlinpr/atm2
优化分类数据加载的语言参数
2025-11-10 10:07:35 +08:00
1c048fd9c0 发布动态页面UI调整 2025-11-07 21:30:56 +08:00
2613d2e801 修复bug:关注用户或者AI后关注列表能正常显示 2025-11-07 21:29:03 +08:00
c100a8ceef 注册账号界面ui调整 2025-11-07 21:25:02 +08:00
f86b5e1d39 账号安全界面ui设置 2025-11-07 21:22:10 +08:00
75eb38b188 我的界面ui设置,新加群聊标签以及缺省图 2025-11-07 21:19:17 +08:00
4f588483c0 Merge branch 'feat/pr-20251104-154907' of https://github.com/Kevinlinpr/rider-pro-android-app into feat/pr-20251104-154907 2025-11-07 14:42:17 +08:00
0bc442762d 我的-页面顶部导航栏ui修改,增加下滑时顶部导航栏的变化效果以及壁纸头像大小修正 2025-11-07 14:36:25 +08:00
397ac6a9ee Merge branch 'main' into feat/pr-20251104-154907 2025-11-07 13:56:53 +08:00
784f87dc39 recover 2025-11-07 10:46:08 +08:00
e714f567b9 我的-编辑-ui调整 2025-11-06 21:34:08 +08:00
703beb8d43 我的(侧边栏)ui调整 2025-11-06 21:23:51 +08:00
2b30beb367 资源文件替换 2025-11-06 20:56:20 +08:00
6fffa0447e 修复文件将所有 PromptRule 引用改为 AgentRule,并更新相关字段访问 2025-11-06 20:49:10 +08:00
2a9d6a2f6b 抓包配置文件 2025-11-06 18:15:00 +08:00
cc12a08472 修改BUG:登录账号邮箱格式错误无提示 2025-11-06 10:53:34 +08:00
513897499d 优化分类数据加载的语言参数
根据系统语言标签(如 "zh-CN")将其转换为后端支持的语言代码(zh, cn, ja),用于请求分类数据。默认回退到 "zh"。
2025-11-06 10:20:28 +08:00
baa6f284bd Merge pull request #53 from Kevinlinpr/atm
新增智能体和房间规则管理功能
2025-11-06 10:09:16 +08:00
523 changed files with 21506 additions and 4039 deletions

View File

@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<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">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=c328a150" />
<DeviceId pluginId="PhysicalDevice" identifier="serial=f800b364" />
</handle>
</Target>
</DropdownSelection>

31
.idea/gradle.xml generated
View File

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

2
.idea/kotlinc.xml generated
View File

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

View File

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

View File

@@ -7,6 +7,8 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<application
android:name=".RaveNowApplication"
@@ -19,6 +21,7 @@
android:supportsRtl="true"
android:theme="@style/Theme.RaveNow"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="31">
<meta-data
android:name="com.google.android.geo.API_KEY"
@@ -50,7 +53,8 @@
android:label="@string/app_name"
android:theme="@style/Theme.App.Starting"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustResize"
android:configChanges="fontScale|orientation|screenSize|keyboardHidden|uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<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.DictService
import com.aiosman.ravenow.data.DictServiceImpl
import com.aiosman.ravenow.data.PointService
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.ui.favourite.FavouriteListViewModel
import com.aiosman.ravenow.ui.favourite.FavouriteNoticeViewModel
@@ -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.utils.Utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.coroutines.suspendCoroutine
import com.aiosman.ravenow.im.OpenIMManager
import io.openim.android.sdk.OpenIMClient
@@ -50,7 +52,7 @@ object AppState {
suspend fun initWithAccount(scope: CoroutineScope, context: Context) {
// 如果是游客模式,使用简化的初始化流程
if (AppStore.isGuest) {
initWithGuestAccount()
initWithGuestAccount(scope)
return
}
@@ -81,18 +83,58 @@ object AppState {
// 注册 JPush
Messaging.registerDevice(scope, context)
initChat(context)
// 设置当前用户并刷新积分信息(完成登录态初始化后)
PointService.setCurrentUser(UserId)
try {
PointService.refreshMyPointsBalance()
} catch (e: Exception) {
Log.e("AppState", "刷新积分失败: ${e.message}")
}
// 并行加载积分规则和房间规则配置(不阻塞主流程)
scope.launch {
try {
PointService.refreshPointsRules()
} catch (e: Exception) {
Log.e("AppState", "加载积分规则失败: ${e.message}")
}
}
scope.launch {
try {
PointService.refreshRoomMaxMembers()
} catch (e: Exception) {
Log.e("AppState", "加载房间规则失败: ${e.message}")
}
}
}
/**
* 游客模式的简化初始化
*/
private fun initWithGuestAccount() {
private fun initWithGuestAccount(scope: CoroutineScope) {
// 游客模式下不初始化推送和TRTC
// 设置默认的用户信息
UserId = 0
profile = null
enableChat = false
Log.d("AppState", "Guest mode initialized without push notifications and TRTC")
// 游客模式下也加载规则配置(用于查看费用信息)
scope.launch {
try {
PointService.refreshPointsRules()
} catch (e: Exception) {
Log.e("AppState", "加载积分规则失败: ${e.message}")
}
}
scope.launch {
try {
PointService.refreshRoomMaxMembers()
} catch (e: Exception) {
Log.e("AppState", "加载房间规则失败: ${e.message}")
}
}
}
private suspend fun initChat(context: Context){
@@ -228,6 +270,8 @@ object AppState {
AgentViewModel.ResetModel()
MineAgentViewModel.ResetModel()
UserId = null
// 清空积分全局状态,避免用户切换串号
PointService.clear()
// 清除游客状态
AppStore.isGuest = false

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -22,6 +23,8 @@ import androidx.compose.animation.SharedTransitionScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.lifecycle.ProcessLifecycleOwner
@@ -37,6 +40,7 @@ import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.dialogs.CheckUpdateDialog
import com.aiosman.ravenow.ui.navigateToPost
import com.aiosman.ravenow.ui.post.NewPostViewModel
import com.aiosman.ravenow.ui.points.PointsBottomSheetHost
import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.analytics
@@ -56,6 +60,25 @@ class MainActivity : ComponentActivity() {
private val scope = CoroutineScope(Dispatchers.Main)
val context = this
override fun attachBaseContext(newBase: Context) {
// 禁用字体缩放,固定字体大小为系统默认大小
val configuration = Configuration(newBase.resources.configuration)
configuration.fontScale = 1.0f
val context = newBase.createConfigurationContext(configuration)
super.attachBaseContext(context)
}
override fun onConfigurationChanged(newConfig: Configuration) {
// 确保配置变化时字体缩放保持为 1.0
val config = Configuration(newConfig)
config.fontScale = 1.0f
super.onConfigurationChanged(config)
val isNightMode = (newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
if (AppState.darkMode != isNightMode) {
syncDarkModeWithSystem(isNightMode)
}
}
// 请求通知权限
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
@@ -110,9 +133,7 @@ class MainActivity : ComponentActivity() {
JPushInterface.init(this)
if (AppState.darkMode) {
window.decorView.setBackgroundColor(android.graphics.Color.BLACK)
}
updateWindowBackground(AppState.darkMode)
enableEdgeToEdge()
scope.launch {
@@ -127,6 +148,15 @@ class MainActivity : ComponentActivity() {
}
setContent {
// 强制字体缩放为 1.0 - 通过覆盖 Density 来实现
val density = LocalDensity.current
val fixedDensity = remember {
androidx.compose.ui.unit.Density(
density = density.density,
fontScale = 1.0f
)
}
var showSplash by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
@@ -138,9 +168,12 @@ class MainActivity : ComponentActivity() {
SplashScreen()
} else {
CompositionLocalProvider(
LocalAppTheme provides AppState.appTheme
LocalAppTheme provides AppState.appTheme,
LocalDensity provides fixedDensity
) {
CheckUpdateDialog()
// 全局挂载积分底部弹窗 Host
PointsBottomSheetHost()
Navigation(startDestination) { navController ->
// 处理带有 postId 的通知点击
@@ -238,8 +271,22 @@ class MainActivity : ComponentActivity() {
notificationManager.createNotificationChannel(channel)
}
}
private fun syncDarkModeWithSystem(isNightMode: Boolean) {
AppState.darkMode = isNightMode
AppState.appTheme = if (isNightMode) DarkThemeColors() else LightThemeColors()
AppStore.saveDarkMode(isNightMode)
updateWindowBackground(isNightMode)
}
private fun updateWindowBackground(isDarkMode: Boolean) {
window.decorView.setBackgroundColor(
if (isDarkMode) android.graphics.Color.BLACK else android.graphics.Color.WHITE
)
}
}
val LocalNavController = compositionLocalOf<NavHostController> {
error("NavController not provided")
}

View File

@@ -2,6 +2,7 @@ package com.aiosman.ravenow
import android.app.Application
import android.content.Context
import android.content.res.Configuration
import android.util.Log
import com.google.firebase.FirebaseApp
import com.google.firebase.perf.FirebasePerformance
@@ -11,6 +12,14 @@ import com.google.firebase.perf.FirebasePerformance
*/
class RaveNowApplication : Application() {
override fun attachBaseContext(base: Context) {
// 禁用字体缩放,固定字体大小为系统默认大小
val configuration = Configuration(base.resources.configuration)
configuration.fontScale = 1.0f
val context = base.createConfigurationContext(configuration)
super.attachBaseContext(context)
}
override fun onCreate() {
super.onCreate()

View File

@@ -64,6 +64,15 @@ data class AccountProfile(
val aiAccount: Boolean,
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
@@ -89,7 +98,17 @@ data class AccountProfile(
chatToken = openImToken,
aiAccount = aiAccount,
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 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 userIds 用户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 {
@@ -525,7 +545,13 @@ class AccountServiceImpl : AccountService {
val bannerField: MultipartBody.Part? = banner?.let {
createMultipartBody(it.file, it.filename, "banner")
}
ApiClient.api.updateProfile(avatarField, bannerField, nicknameField, bioField)
val resp = ApiClient.api.updateProfile(avatarField, bannerField, nicknameField, bioField)
if (!resp.isSuccessful) {
parseErrorResponse(resp.errorBody())?.let {
throw it.toServiceException()
}
throw ServiceException("Failed to update profile")
}
}
override suspend fun registerUserWithPassword(loginName: String, password: String) {
@@ -630,15 +656,15 @@ class AccountServiceImpl : AccountService {
}
}
override suspend fun getAgent(page: Int, pageSize: Int): retrofit2.Response<DataContainer<ListContainer<Agent>>> {
return ApiClient.api.getAgent(page, pageSize)
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, 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(
name = name,
userIds = userIds,
promptIds = promptIds
promptIds = promptIds,
)
return ApiClient.api.createGroupChat(requestBody)
}

View File

@@ -73,7 +73,7 @@ data class Profile(
@SerializedName("nickname")
val nickname: String,
@SerializedName("trtcUserId")
val trtcUserId: String,
val trtcUserId: String? = null,
@SerializedName("username")
val username: String
){
@@ -85,7 +85,7 @@ data class Profile(
avatar = "${ApiClient.BASE_SERVER}$avatar",
bio = bio,
banner = "${ApiClient.BASE_SERVER}$banner",
trtcUserId = trtcUserId,
trtcUserId = trtcUserId ?: "",
chatAIId = chatAIId,
aiAccount = aiAccount
)
@@ -108,6 +108,15 @@ interface AgentService {
authorId: Int? = null
): 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 {
return CommentEntity(
id = id,
name = user.nickName,
name = user.nickName ?: "未知用户",
comment = content,
date = ApiClient.dateFromApiString(createdAt),
likes = likeCount,
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,
liked = isLiked,
unread = isUnread,

View File

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

View File

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

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.RoomRule
import com.aiosman.ravenow.data.api.RoomRuleCreator
import com.aiosman.ravenow.entity.AddAgentToRoomFailedItemEntity
import com.aiosman.ravenow.entity.AddAgentToRoomItemEntity
import com.aiosman.ravenow.entity.AddAgentToRoomResultEntity
import com.aiosman.ravenow.entity.AddUserToRoomFailedItemEntity
import com.aiosman.ravenow.entity.AddUserToRoomItemEntity
import com.aiosman.ravenow.entity.AddUserToRoomResultEntity
import com.aiosman.ravenow.entity.CreatorEntity
import com.aiosman.ravenow.entity.RemoveAgentFromRoomFailedItemEntity
import com.aiosman.ravenow.entity.RemoveAgentFromRoomItemEntity
import com.aiosman.ravenow.entity.RemoveAgentFromRoomResultEntity
import com.aiosman.ravenow.entity.RemoveUserFromRoomFailedItemEntity
import com.aiosman.ravenow.entity.RemoveUserFromRoomItemEntity
import com.aiosman.ravenow.entity.RemoveUserFromRoomResultEntity
import com.aiosman.ravenow.entity.RoomEntity
import com.aiosman.ravenow.entity.RoomRuleEntity
import com.aiosman.ravenow.entity.RoomRuleCreatorEntity
@@ -14,6 +26,22 @@ import com.aiosman.ravenow.entity.RoomRuleQuotaEntity
import com.aiosman.ravenow.entity.UsersEntity
import com.google.gson.annotations.SerializedName
/**
* 房间内的智能体信息PromptTemplate
*/
data class PromptTemplate(
@SerializedName("id")
val id: Int,
@SerializedName("openId")
val openId: String,
@SerializedName("title")
val title: String,
@SerializedName("desc")
val desc: String,
@SerializedName("avatar")
val avatar: String
)
data class Room(
@SerializedName("id")
val id: Int,
@@ -39,12 +67,26 @@ data class Room(
val creator: Creator,
@SerializedName("userCount")
val userCount: Int,
@SerializedName("totalMemberCount")
val totalMemberCount: Int? = null,
@SerializedName("maxMemberLimit")
val maxMemberLimit: Int,
@SerializedName("maxTotal")
val maxTotal: Int? = null,
@SerializedName("systemMaxTotal")
val systemMaxTotal: Int? = null,
@SerializedName("canJoin")
val canJoin: Boolean,
@SerializedName("canJoinCode")
val canJoinCode: Int,
@SerializedName("privateFeePaid")
val privateFeePaid: Boolean? = null,
@SerializedName("prompts")
val prompts: List<PromptTemplate>? = null,
@SerializedName("createdAt")
val createdAt: String? = null,
@SerializedName("updatedAt")
val updatedAt: String? = null,
@SerializedName("users")
val users: List<Users>
@@ -63,9 +105,24 @@ data class Room(
allowInHot = allowInHot,
creator = creator.toCreatorEntity(),
userCount = userCount,
totalMemberCount = totalMemberCount,
maxMemberLimit = maxMemberLimit,
maxTotal = maxTotal,
systemMaxTotal = systemMaxTotal,
canJoin = canJoin,
canJoinCode = canJoinCode,
privateFeePaid = privateFeePaid ?: false,
prompts = prompts?.map {
com.aiosman.ravenow.entity.PromptTemplateEntity(
id = it.id,
openId = it.openId,
title = it.title,
desc = it.desc,
avatar = it.avatar
)
} ?: emptyList(),
createdAt = createdAt,
updatedAt = updatedAt,
users = users.map { it.toUsersEntity() }
)
}
@@ -78,7 +135,7 @@ data class Creator(
@SerializedName("userId")
val userId: String,
@SerializedName("trtcUserId")
val trtcUserId: String,
val trtcUserId: String? = null,
@SerializedName("profile")
val profile: Profile
){
@@ -86,7 +143,7 @@ data class Creator(
return CreatorEntity(
id = id,
userId = userId,
trtcUserId = trtcUserId,
trtcUserId = trtcUserId ?: "",
profile = profile.toProfileEntity()
)
}
@@ -98,7 +155,7 @@ data class Users(
@SerializedName("userId")
val userId: String,
@SerializedName("trtcUserId")
val trtcUserId: String,
val trtcUserId: String? = null,
@SerializedName("profile")
val profile: Profile
){
@@ -173,6 +230,68 @@ interface RoomService {
roomId: Int? = null,
trtcId: String? = null
): RoomRuleQuotaEntity
// ========== Room Member Management ==========
/**
* 添加用户到房间
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param openIds 要添加的用户OpenID列表
* @return 添加结果实体
* @throws ServiceException 添加失败时抛出异常
*/
suspend fun addUserToRoom(
roomId: Int? = null,
trtcId: String? = null,
openIds: List<String>
): AddUserToRoomResultEntity
/**
* 添加智能体到房间
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param agentOpenIds 要添加的智能体OpenID列表
* @return 添加结果实体
* @throws ServiceException 添加失败时抛出异常
*/
suspend fun addAgentToRoom(
roomId: Int? = null,
trtcId: String? = null,
agentOpenIds: List<String>
): AddAgentToRoomResultEntity
/**
* 从房间移除智能体
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param agentOpenIds 要移除的智能体OpenID列表
* @return 移除结果实体
* @throws ServiceException 移除失败时抛出异常
*/
suspend fun removeAgentFromRoom(
roomId: Int? = null,
trtcId: String? = null,
agentOpenIds: List<String>
): RemoveAgentFromRoomResultEntity
/**
* 从房间移除用户
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param userIds 要移除的用户ID列表OpenID
* @return 移除结果实体
* @throws ServiceException 移除失败时抛出异常
*/
suspend fun removeUserFromRoom(
roomId: Int? = null,
trtcId: String? = null,
userIds: List<String>
): RemoveUserFromRoomResultEntity
}
/**
@@ -253,6 +372,78 @@ class RoomServiceImpl : RoomService {
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(
id = id,
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
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.BatchTrtcUserIdRequestBody
import com.aiosman.ravenow.entity.AccountProfileEntity
data class UserAuth(
@@ -46,7 +47,8 @@ interface UserService {
page: Int = 1,
nickname: String? = null,
followerId: Int? = null,
followingId: Int? = null
followingId: Int? = null,
roomId: Int? = null
): ListContainer<AccountProfileEntity>
@@ -66,6 +68,16 @@ interface UserService {
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 {
@@ -90,14 +102,16 @@ class UserServiceImpl : UserService {
page: Int,
nickname: String?,
followerId: Int?,
followingId: Int?
followingId: Int?,
roomId: Int?
): ListContainer<AccountProfileEntity> {
val resp = ApiClient.api.getUsers(
page = page,
pageSize = pageSize,
search = nickname,
followerId = followerId,
followingId = followingId
followingId = followingId,
includeAI = true,
)
val body = resp.body() ?: throw ServiceException("Failed to get account")
return ListContainer<AccountProfileEntity>(
@@ -119,4 +133,18 @@ class UserServiceImpl : UserService {
val body = resp.body() ?: throw ServiceException("Failed to get account")
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.ListContainer
import com.aiosman.ravenow.data.Moment
import com.aiosman.ravenow.data.RecommendationsResponse
import com.aiosman.ravenow.data.Room
import com.aiosman.ravenow.entity.ChatNotification
import com.aiosman.ravenow.data.membership.MembershipConfigData
@@ -86,6 +87,13 @@ data class JoinGroupChatRequestBody(
val roomId: Int? = null,
)
data class BatchTrtcUserIdRequestBody(
@SerializedName("trtcUserIds")
val trtcUserIds: List<String>,
@SerializedName("includeAI")
val includeAI: Boolean? = null,
)
data class LoginUserRequestBody(
@SerializedName("username")
val username: String? = null,
@@ -273,6 +281,260 @@ data class RemoveAccountRequestBody(
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 错误响应(用于加入房间等接口的错误处理)
data class ApiErrorResponse(
@SerializedName("err")
@@ -651,6 +913,11 @@ data class UpdateRoomRuleRequestBody(
* @param id 创建者ID
* @param nickname 创建者昵称
* @param avatar 创建者头像文件名
* @param avatarMedium 中等头像文件名
* @param avatarLarge 大头像文件名
* @param avatarDirectUrl 小头像直接访问URL
* @param avatarMediumDirectUrl 中等头像直接访问URL
* @param avatarLargeDirectUrl 大头像直接访问URL
*/
data class RoomRuleCreator(
@SerializedName("id")
@@ -658,7 +925,17 @@ data class RoomRuleCreator(
@SerializedName("nickname")
val nickname: String,
@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?
)
// ========== 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 {
@GET("membership/config")
@retrofit2.http.Headers("X-Requires-Auth: true")
@@ -800,6 +1142,7 @@ interface RaveNowAPI {
@Query("favouriteUserId") favouriteUserId: Int? = null,
@Query("explore") explore: String? = null,
@Query("newsFilter") newsFilter: String? = null,
@Query("videoFilter") videoFilter: String? = null,
): Response<ListContainer<Moment>>
@Multipart
@@ -937,6 +1280,11 @@ interface RaveNowAPI {
@Path("id") id: String
): Response<DataContainer<AccountProfile>>
@POST("profile/trtc/batch")
suspend fun getAccountProfilesByTrtcBatch(
@Body body: BatchTrtcUserIdRequestBody
): Response<DataContainer<List<AccountProfile>>>
@POST("user/{id}/follow")
suspend fun followUser(
@Path("id") id: Int
@@ -954,7 +1302,7 @@ interface RaveNowAPI {
@Query("nickname") search: String? = null,
@Query("followerId") followerId: Int? = null,
@Query("followingId") followingId: Int? = null,
@Query("includeAI") includeAI: Boolean? = false,
@Query("includeAI") includeAI: Boolean? = true,
@Query("chatSessionIdNotNull") chatSessionIdNotNull: Boolean? = true,
): Response<ListContainer<AccountProfile>>
@@ -1002,6 +1350,16 @@ interface RaveNowAPI {
@Query("keys") keys: String
): 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")
suspend fun generateCaptcha(
@Body body: CaptchaRequestBody
@@ -1045,6 +1403,9 @@ interface RaveNowAPI {
@Query("authorId") authorId: Int? = null,
@Query("categoryIds") categoryIds: List<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>>>
@GET("outside/my/prompts")
@@ -1087,11 +1448,39 @@ interface RaveNowAPI {
@POST("outside/rooms")
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")
suspend fun getRooms(@Query("page") page: Int = 1,
@Query("pageSize") pageSize: Int = 20,
@Query("isRecommended") isRecommended: Int = 1,
@Query("random") random: Int? = null,
suspend fun getRooms(
@Query("page") page: Int = 1,
@Query("pageSize") pageSize: Int = 20,
@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>>
@GET("outside/rooms/detail")
@@ -1136,6 +1525,35 @@ interface RaveNowAPI {
@Query("pageSize") pageSize: Int? = null
): 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 ==========
/**
@@ -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 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,
)
/**
* 创建者信息
*/
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(
private val agentService: AgentService,
@@ -103,6 +132,16 @@ class AgentRemoteDataSource(
authorId = authorId
)
}
suspend fun searchAgentByTitle(
pageNumber: Int,
title: String
): ListContainer<AgentEntity>? {
return agentService.searchAgentByTitle(
pageNumber = pageNumber,
title = title
)
}
}
class AgentServiceImpl() : AgentService {
@@ -118,6 +157,17 @@ class AgentServiceImpl() : AgentService {
authorId = authorId
)
}
override suspend fun searchAgentByTitle(
pageNumber: Int,
pageSize: Int,
title: String
): ListContainer<AgentEntity>? {
return agentBackend.searchAgentByTitle(
pageNumber = pageNumber,
title = title
)
}
}
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(

View File

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

View File

@@ -250,8 +250,26 @@ data class MomentImageEntity(
val id: Long,
// 图片URL
val url: String,
// 原始图片URL
val originalUrl: String? = null,
// 直接访问URL
val directUrl: String? = null,
// 缩略图URL
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
val blurHash: String? = null,
// 宽度
@@ -260,6 +278,38 @@ data class MomentImageEntity(
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 momentTextContent: String,
val momentTextContent: String?,
// 动态图片
@DrawableRes val momentPicture: Int,
// 点赞数
@@ -300,6 +350,10 @@ data class MomentEntity(
var relMoment: MomentEntity? = null,
// 是否收藏
var isFavorite: Boolean = false,
// 外部链接
val url: String? = null,
// 动态视频列表
val videos: List<MomentVideoEntity>? = null,
// 新闻相关字段
val isNews: Boolean = false,
val newsTitle: String = "",
@@ -307,13 +361,22 @@ data class MomentEntity(
val newsSource: String = "",
val newsCategory: 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(
val explore: Boolean? = false,
val timelineId: Int? = null,
val authorId : Int? = null,
val newsOnly: Boolean? = null
val newsOnly: Boolean? = null,
val videoOnly: Boolean? = null
)
class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
override suspend fun fetchData(
@@ -327,7 +390,8 @@ class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
explore = if (extra.explore == true) "true" else "",
timelineId = extra.timelineId,
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 {
ListContainer(
@@ -346,7 +410,16 @@ class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
fun updateMomentLike(id: Int,isLike:Boolean) {
this.list = this.list.map { momentItem ->
if (momentItem.id == id) {
momentItem.copy(likeCount = momentItem.likeCount + if (isLike) 1 else -1, liked = isLike)
// 只有当状态发生变化时才更新计数,避免重复更新
val countDelta = if (momentItem.liked != isLike) {
if (isLike) 1 else -1
} else {
0
}
momentItem.copy(
likeCount = (momentItem.likeCount + countDelta).coerceAtLeast(0),
liked = isLike
)
} else {
momentItem
}
@@ -357,7 +430,16 @@ class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
fun updateFavoriteCount(id: Int,isFavorite:Boolean) {
this.list = this.list.map { momentItem ->
if (momentItem.id == id) {
momentItem.copy(favoriteCount = momentItem.favoriteCount + if (isFavorite) 1 else -1, isFavorite = isFavorite)
// 只有当状态发生变化时才更新计数,避免重复更新
val countDelta = if (momentItem.isFavorite != isFavorite) {
if (isFavorite) 1 else -1
} else {
0
}
momentItem.copy(
favoriteCount = (momentItem.favoriteCount + countDelta).coerceAtLeast(0),
isFavorite = isFavorite
)
} else {
momentItem
}

View File

@@ -1,13 +1,29 @@
package com.aiosman.ravenow.entity
import android.util.Log
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.aiosman.ravenow.data.ListContainer
import com.aiosman.ravenow.data.Room
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.data.api.ApiClient
import java.io.IOException
/**
* 群聊房间
*/
/**
* 房间内的智能体信息实体
*/
data class PromptTemplateEntity(
val id: Int,
val openId: String,
val title: String,
val desc: String,
val avatar: String
)
data class RoomEntity(
val id: Int,
val name: String,
@@ -21,9 +37,16 @@ data class RoomEntity(
val allowInHot: Boolean,
val creator: CreatorEntity,
val userCount: Int,
val totalMemberCount: Int? = null,
val maxMemberLimit: Int,
val maxTotal: Int? = null,
val systemMaxTotal: Int? = null,
val canJoin: Boolean,
val canJoinCode: Int,
val privateFeePaid: Boolean = false,
val prompts: List<PromptTemplateEntity> = emptyList(),
val createdAt: String? = null,
val updatedAt: String? = null,
val users: List<UsersEntity>,
)
@@ -58,7 +81,12 @@ data class ProfileEntity(
data class RoomRuleCreatorEntity(
val id: Int,
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 +114,128 @@ data class RoomRuleQuotaEntity(
val usagePercent: Double
)
// ========== Room Member Management 实体类 ==========
/**
* 添加用户成功项目
*/
data class AddUserToRoomItemEntity(
val userId: String,
val type: String
)
/**
* 添加用户失败项目
*/
data class AddUserToRoomFailedItemEntity(
val userId: String,
val type: String,
val error: String
)
/**
* 添加用户到房间的结果
*/
data class AddUserToRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<AddUserToRoomItemEntity>,
val failedItems: List<AddUserToRoomFailedItemEntity>,
val skippedItems: List<AddUserToRoomItemEntity>
)
/**
* 添加智能体成功项目
*/
data class AddAgentToRoomItemEntity(
val agentOpenId: String,
val type: String
)
/**
* 添加智能体失败项目
*/
data class AddAgentToRoomFailedItemEntity(
val agentOpenId: String,
val type: String,
val error: String
)
/**
* 添加智能体到房间的结果
*/
data class AddAgentToRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<AddAgentToRoomItemEntity>,
val failedItems: List<AddAgentToRoomFailedItemEntity>,
val skippedItems: List<AddAgentToRoomItemEntity>
)
/**
* 移除智能体成功项目
*/
data class RemoveAgentFromRoomItemEntity(
val agentOpenId: String,
val type: String
)
/**
* 移除智能体失败项目
*/
data class RemoveAgentFromRoomFailedItemEntity(
val agentOpenId: String,
val type: String,
val error: String
)
/**
* 从房间移除智能体的结果
*/
data class RemoveAgentFromRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<RemoveAgentFromRoomItemEntity>,
val failedItems: List<RemoveAgentFromRoomFailedItemEntity>,
val skippedItems: List<RemoveAgentFromRoomItemEntity>
)
/**
* 移除用户成功项目
*/
data class RemoveUserFromRoomItemEntity(
val userId: String,
val type: String
)
/**
* 移除用户失败项目
*/
data class RemoveUserFromRoomFailedItemEntity(
val userId: String,
val type: String,
val error: String
)
/**
* 从房间移除用户的结果
*/
data class RemoveUserFromRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<RemoveUserFromRoomItemEntity>,
val failedItems: List<RemoveUserFromRoomFailedItemEntity>,
val skippedItems: List<RemoveUserFromRoomItemEntity>
)
class RoomLoader : DataLoader<AgentEntity,AgentLoaderExtraArgs>() {
override suspend fun fetchData(
page: Int,
@@ -112,4 +262,95 @@ class RoomLoader : DataLoader<AgentEntity,AgentLoaderExtraArgs>() {
}
}
/**
* 房间远程数据源
*/
class RoomRemoteDataSource {
suspend fun searchRooms(
pageNumber: Int,
pageSize: Int = 20,
search: String
): ListContainer<RoomEntity>? {
return try {
val resp = ApiClient.api.getRooms(
page = pageNumber,
pageSize = pageSize,
search = search,
roomType = "public" // 搜索时只显示公有房间
)
if (!resp.isSuccessful) {
// API 调用失败,返回 null
return null
}
val body = resp.body() ?: return null
// 安全地转换数据,过滤掉转换失败的项目
val roomList = body.list.mapNotNull { room ->
try {
room.toRoomtEntity()
} catch (e: Exception) {
// 如果某个房间数据转换失败,记录错误但继续处理其他房间
Log.e("RoomRemoteDataSource", "Failed to convert room: ${room.id}", e)
null
}
}
ListContainer(
total = body.total,
page = pageNumber,
pageSize = pageSize,
list = roomList
)
} catch (e: Exception) {
// 捕获所有异常,返回 null 让 PagingSource 处理
Log.e("RoomRemoteDataSource", "searchRooms error", e)
null
}
}
}
/**
* 房间搜索分页加载器
*/
class RoomSearchPagingSource(
private val roomRemoteDataSource: RoomRemoteDataSource,
private val keyword: String,
) : PagingSource<Int, RoomEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, RoomEntity> {
return try {
val currentPage = params.key ?: 1
val rooms = roomRemoteDataSource.searchRooms(
pageNumber = currentPage,
pageSize = params.loadSize,
search = keyword
)
if (rooms == null) {
// API 调用失败,返回空列表
LoadResult.Page(
data = emptyList(),
prevKey = if (currentPage == 1) null else currentPage - 1,
nextKey = null
)
} else {
LoadResult.Page(
data = rooms.list,
prevKey = if (currentPage == 1) null else currentPage - 1,
nextKey = if (rooms.list.isNotEmpty() && rooms.list.size >= params.loadSize) currentPage + 1 else null
)
}
} catch (exception: Exception) {
// 捕获所有异常,包括 IOException、ServiceException 等
LoadResult.Error(exception)
}
}
override fun getRefreshKey(state: PagingState<Int, RoomEntity>): Int? {
// 更健壮的实现:根据 anchorPosition 计算刷新键
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
}

View File

@@ -48,7 +48,7 @@ fun Date.formatPostTime(): String {
}
/**
* YYYY.DD.MM HH:MM
* yyyy-MM-dd HH:mm
*/
fun Date.formatPostTime2(): String {
val calendar = Calendar.getInstance()
@@ -58,7 +58,14 @@ fun Date.formatPostTime2(): String {
val day = calendar.get(Calendar.DAY_OF_MONTH)
val hour = calendar.get(Calendar.HOUR_OF_DAY)
val minute = calendar.get(Calendar.MINUTE)
return "$year.$month.$day $hour:$minute"
// 确保两位数
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 {

View File

@@ -2,6 +2,7 @@ package com.aiosman.ravenow
import android.content.Context
import android.content.SharedPreferences
import android.content.res.Configuration
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
/**
@@ -24,11 +25,16 @@ object AppStore {
.requestEmail()
.build()
googleSignInOptions = gso
// apply dark mode
if (sharedPreferences.getBoolean("darkMode", false)) {
AppState.darkMode = true
AppState.appTheme = DarkThemeColors()
// apply dark mode - 如果用户未手动设置,优先跟随系统
val hasUserPreference = sharedPreferences.contains("darkMode")
val resolvedDarkMode = if (hasUserPreference) {
sharedPreferences.getBoolean("darkMode", false)
} else {
val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
currentNightMode == Configuration.UI_MODE_NIGHT_YES
}
AppState.darkMode = resolvedDarkMode
AppState.appTheme = if (resolvedDarkMode) DarkThemeColors() else LightThemeColors()
// load chat background
val savedBgUrl = sharedPreferences.getString("chatBackgroundUrl", null)

View File

@@ -35,12 +35,14 @@ import com.aiosman.ravenow.LocalSharedTransitionScope
import com.aiosman.ravenow.ui.about.AboutScreen
import com.aiosman.ravenow.ui.account.AccountEditScreen2
import com.aiosman.ravenow.ui.account.AccountSetting
import com.aiosman.ravenow.ui.account.BlockedUsersScreen
import com.aiosman.ravenow.ui.account.MbtiSelectScreen
import com.aiosman.ravenow.ui.account.RemoveAccountScreen
import com.aiosman.ravenow.ui.account.ResetPasswordScreen
import com.aiosman.ravenow.ui.account.ZodiacSelectScreen
import com.aiosman.ravenow.ui.agent.AddAgentScreen
import com.aiosman.ravenow.ui.agent.AgentImageCropScreen
import com.aiosman.ravenow.ui.agent.AiPromptEditScreen
import com.aiosman.ravenow.ui.group.CreateGroupChatScreen
import com.aiosman.ravenow.ui.chat.ChatAiScreen
import com.aiosman.ravenow.ui.chat.ChatSettingScreen
@@ -59,6 +61,9 @@ import com.aiosman.ravenow.ui.gallery.OfficialGalleryScreen
import com.aiosman.ravenow.ui.gallery.OfficialPhotographerScreen
import com.aiosman.ravenow.ui.gallery.ProfileTimelineScreen
import com.aiosman.ravenow.ui.group.GroupChatInfoScreen
import com.aiosman.ravenow.ui.group.GroupMembersScreen
import com.aiosman.ravenow.ui.group.AddGroupMemberScreen
import com.aiosman.ravenow.ui.group.GroupProfileSettingsScreen
import com.aiosman.ravenow.ui.index.IndexScreen
import com.aiosman.ravenow.ui.index.tabs.message.NotificationsScreen
import com.aiosman.ravenow.ui.index.tabs.search.SearchScreen
@@ -73,8 +78,10 @@ import com.aiosman.ravenow.ui.post.NewPostImageGridScreen
import com.aiosman.ravenow.ui.post.NewPostScreen
import com.aiosman.ravenow.ui.post.PostScreen
import com.aiosman.ravenow.ui.profile.AccountProfileV2
import com.aiosman.ravenow.ui.profile.AiProfileWrap
import com.aiosman.ravenow.ui.index.tabs.profile.vip.VipSelPage
import com.aiosman.ravenow.ui.notification.NotificationScreen
import com.aiosman.ravenow.ui.scan.ScanQrScreen
sealed class NavigationRoute(
val route: String,
@@ -119,11 +126,17 @@ sealed class NavigationRoute(
data object AddAgent : NavigationRoute("AddAgent")
data object CreateGroupChat : NavigationRoute("CreateGroupChat")
data object GroupInfo : NavigationRoute("GroupInfo/{id}")
data object GroupMembers : NavigationRoute("GroupMembers/{id}")
data object AddGroupMember : NavigationRoute("AddGroupMember/{groupId}/{groupName}")
data object GroupProfileSettings : NavigationRoute("GroupProfileSettings/{id}")
data object VipSelPage : NavigationRoute("VipSelPage")
data object RemoveAccountScreen: NavigationRoute("RemoveAccount")
data object NotificationScreen : NavigationRoute("NotificationScreen")
data object MbtiSelect : NavigationRoute("MbtiSelect")
data object ZodiacSelect : NavigationRoute("ZodiacSelect")
data object ScanQr : NavigationRoute("ScanQr")
data object AiPromptEdit : NavigationRoute("AiPromptEdit/{chatAIId}")
data object BlockedUsersScreen : NavigationRoute("BlockedUsersScreen")
}
@@ -337,7 +350,13 @@ fun NavigationController(
) {
val id = it.arguments?.getString("id")!!
val isAiAccount = it.arguments?.getBoolean("isAiAccount") ?: false
AccountProfileV2(id, isAiAccount)
// 根据isAiAccount参数分发到不同的Profile页面
if (isAiAccount) {
AiProfileWrap(id)
} else {
AccountProfileV2(id, isAiAccount)
}
}
}
composable(
@@ -422,6 +441,9 @@ fun NavigationController(
composable(route = NavigationRoute.ChangePasswordScreen.route) {
ChangePasswordScreen()
}
composable(route = NavigationRoute.BlockedUsersScreen.route) {
BlockedUsersScreen()
}
composable(route = NavigationRoute.RemoveAccountScreen.route) {
RemoveAccountScreen()
}
@@ -447,6 +469,9 @@ fun NavigationController(
SearchScreen()
}
}
composable(route = NavigationRoute.ScanQr.route) {
ScanQrScreen()
}
composable(
route = NavigationRoute.FollowerList.route,
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) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
@@ -620,6 +690,18 @@ fun NavigationController(
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

@@ -2,6 +2,7 @@ package com.aiosman.ravenow.ui.about
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -40,22 +41,21 @@ fun AboutScreen() {
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
) {
NoticeScreenHeader(
title = stringResource(R.string.about_rave_now),
title = stringResource(R.string.about_paipai),
moreIcon = false
)
}
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(start = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Spacer(modifier = Modifier.height(48.dp))
// app icon
// app icondww
Box {
Image(
painter = painterResource(id = R.mipmap.rider_pro_color_logo_next),
painter = painterResource(id = R.mipmap.invalid_name),
contentDescription = "app icon",
modifier = Modifier.size(80.dp)
)
@@ -63,7 +63,7 @@ fun AboutScreen() {
Spacer(modifier = Modifier.height(24.dp))
// app name
Text(
text = "Rave Now".uppercase(),
text = stringResource(R.string.paipai),
fontSize = 24.sp,
color = appColors.text,
fontWeight = FontWeight.ExtraBold
@@ -71,7 +71,7 @@ fun AboutScreen() {
Spacer(modifier = Modifier.height(16.dp))
// app version
Text(
text = stringResource(R.string.version_text, versionText),
text = stringResource(R.string.version_text, versionText ?: ""),
fontSize = 16.sp,
color = appColors.secondaryText,
fontWeight = FontWeight.Normal

View File

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

View File

@@ -1,117 +1,175 @@
package com.aiosman.ravenow.ui.account
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.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.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.Messaging
import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.ActionButton
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.index.NavItem
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import kotlinx.coroutines.launch
private object AccountSettingConstants {
const val BACK_BUTTON_SIZE = 36
const val BACK_BUTTON_ICON_SIZE = 24
const val BACK_BUTTON_START_PADDING = 19
const val OPTION_ITEM_HEIGHT = 56
const val OPTION_ITEM_ICON_SIZE = 24
const val OPTION_ITEM_HORIZONTAL_PADDING = 16
const val OPTION_ITEM_ICON_TEXT_SPACING = 12
const val OPTION_ITEM_TEXT_SIZE = 17
const val HEADER_VERTICAL_PADDING = 16
const val TITLE_OFFSET_X = 19
const val CARD_CORNER_RADIUS = 16
}
@Composable
private fun CircularBackButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val appColors = LocalAppTheme.current
Box(
modifier = modifier
.size(AccountSettingConstants.BACK_BUTTON_SIZE.dp)
.background(
color = appColors.secondaryBackground,
shape = CircleShape
)
.noRippleClickable { onClick() },
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = "返回",
modifier = Modifier.size(AccountSettingConstants.BACK_BUTTON_ICON_SIZE.dp),
colorFilter = ColorFilter.tint(appColors.text)
)
}
}
@Composable
private fun SecurityOptionItem(
iconRes: Int,
label: String,
onClick: () -> Unit,
applyColorFilter: Boolean = true
) {
val appColors = LocalAppTheme.current
Row(
modifier = Modifier
.fillMaxWidth()
.height(AccountSettingConstants.OPTION_ITEM_HEIGHT.dp)
.padding(horizontal = AccountSettingConstants.OPTION_ITEM_HORIZONTAL_PADDING.dp)
.noRippleClickable { onClick() },
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = iconRes),
contentDescription = null,
modifier = Modifier.size(AccountSettingConstants.OPTION_ITEM_ICON_SIZE.dp),
colorFilter = if (applyColorFilter) ColorFilter.tint(appColors.text) else null
)
Text(
text = label,
modifier = Modifier
.padding(start = AccountSettingConstants.OPTION_ITEM_ICON_TEXT_SPACING.dp)
.weight(1f),
color = appColors.text,
fontSize = AccountSettingConstants.OPTION_ITEM_TEXT_SIZE.sp,
fontWeight = FontWeight.Medium
)
Image(
painter = painterResource(id = R.drawable.rave_now_nav_right),
contentDescription = null,
modifier = Modifier.size(AccountSettingConstants.OPTION_ITEM_ICON_SIZE.dp),
colorFilter = ColorFilter.tint(appColors.secondaryText)
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountSetting() {
val appColors = LocalAppTheme.current
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.background(appColors.background),
) {
StatusBarSpacer()
// 顶部标题栏
Box(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
modifier = Modifier
.fillMaxWidth()
.padding(vertical = AccountSettingConstants.HEADER_VERTICAL_PADDING.dp)
) {
NoticeScreenHeader(
title = stringResource(R.string.account_and_security),
moreIcon = false
CircularBackButton(
onClick = { navController.navigateUp() },
modifier = Modifier.padding(start = AccountSettingConstants.BACK_BUTTON_START_PADDING.dp)
)
Text(
text = stringResource(R.string.account_and_security),
fontWeight = FontWeight.W800,
fontSize = AccountSettingConstants.OPTION_ITEM_TEXT_SIZE.sp,
color = appColors.text,
modifier = Modifier
.align(Alignment.Center)
.offset(x = AccountSettingConstants.TITLE_OFFSET_X.dp)
)
}
// 安全选项卡片
Column(
modifier = Modifier.padding(start = 24.dp)
modifier = Modifier
.fillMaxWidth()
.background(
color = appColors.background,
shape = RoundedCornerShape(AccountSettingConstants.CARD_CORNER_RADIUS.dp)
)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
NavItem(
iconRes = R.mipmap.rider_pro_change_password,
label = stringResource(R.string.change_password),
modifier = Modifier.noRippleClickable {
navController.navigate(NavigationRoute.ChangePasswordScreen.route)
}
)
}
// 分割线
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(appColors.divider)
SecurityOptionItem(
iconRes = R.mipmap.icons_padlock,
label = stringResource(R.string.change_password),
onClick = { navController.navigate(NavigationRoute.ChangePasswordScreen.route) }
)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
NavItem(
iconRes = R.drawable.rider_pro_moment_delete,
label = stringResource(R.string.remove_account),
modifier = Modifier.noRippleClickable {
navController.navigate(NavigationRoute.RemoveAccountScreen.route)
}
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(appColors.divider)
SecurityOptionItem(
iconRes = R.mipmap.icons_block,
label = stringResource(R.string.blocked_users),
onClick = { navController.navigate(NavigationRoute.BlockedUsersScreen.route) }
)
SecurityOptionItem(
iconRes = R.mipmap.icons_remove,
label = stringResource(R.string.remove_account),
onClick = { navController.navigate(NavigationRoute.RemoveAccountScreen.route) },
applyColorFilter = false
)
}
}
}

View File

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

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

View File

@@ -1,41 +1,59 @@
package com.aiosman.ravenow.ui.account
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.itemsIndexed
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.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
// MBTI类型列表
val MBTI_TYPES = listOf(
@@ -45,92 +63,326 @@ val MBTI_TYPES = listOf(
"ISTP", "ISFP", "ESTP", "ESFP"
)
fun getMbtiImageResId(mbti: String, isDarkMode: Boolean): Int {
return when {
isDarkMode && mbti == "ENTP" -> R.mipmap.anmbti_entp
isDarkMode && mbti == "ESTP" -> R.mipmap.anmbti_estp
isDarkMode && mbti == "ENTJ" -> R.mipmap.anmbti_entj
else -> when (mbti) {
"INTJ" -> R.mipmap.mbti_intj
"INTP" -> R.mipmap.mbti_intp
"ENTJ" -> R.mipmap.mbti_entj
"ENTP" -> R.mipmap.mbti_entp
"INFJ" -> R.mipmap.mbti_infj
"INFP" -> R.mipmap.mbti_infp
"ENFJ" -> R.mipmap.mbti_enfj
"ENFP" -> R.mipmap.mbti_enfp
"ISTJ" -> R.mipmap.mbti_istj
"ISFJ" -> R.mipmap.mbti_isfj
"ESTJ" -> R.mipmap.mbti_estj
"ESFJ" -> R.mipmap.mbti_esfj
"ISTP" -> R.mipmap.mbti_istp
"ISFP" -> R.mipmap.mbti_isfp
"ESTP" -> R.mipmap.mbti_estp
"ESFP" -> R.mipmap.mbti_esfp
else -> R.mipmap.xingzuo
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MbtiSelectScreen() {
val navController = LocalNavController.current
fun MbtiSelectBottomSheet(
onClose: () -> Unit
) {
val appColors = LocalAppTheme.current
val isDarkMode = AppState.darkMode
val model = AccountEditViewModel
val currentMbti = model.mbti
val sheetBackgroundColor = if (isDarkMode) {
appColors.secondaryBackground
} else {
Color(0xFFFFFFFF)
}
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// 确保弹窗展开
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()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(appColors.profileBackground)
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(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
NoticeScreenHeader(
title = stringResource(R.string.choose_mbti),
moreIcon = false
)
}
// 列表
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
items(MBTI_TYPES) { mbti ->
MBTIItem(
mbti = mbti,
isSelected = mbti == currentMbti,
onClick = {
model.mbti = mbti
navController.navigateUp()
}
.fillMaxHeight(0.95f)
.offset(y = offsetY)
.padding(
start = 16.dp,
end = 16.dp,
bottom = 8.dp
)
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)
// 左上角返回按钮
Row(
modifier = Modifier
.align(Alignment.CenterStart)
.height(36.dp)
.clip(RoundedCornerShape(18.dp))
.background(
brush = Brush.linearGradient(
colors = cancelButtonGradientColors
)
)
.noRippleClickable { onClose() }
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
// 左箭头图标
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = null,
modifier = Modifier.size(17.dp),
colorFilter = ColorFilter.tint(cancelButtonContentColor)
)
// "取消" 文字
Text(
text = "取消",
fontSize = 17.sp,
fontWeight = FontWeight.Medium,
color = cancelButtonContentColor,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
// 中间标题 - 绝对居中
Text(
text = stringResource(R.string.choose_mbti),
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 {
// 不消费任何事件,让 LazyColumn 先处理
return Offset.Zero
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
// 消费 LazyColumn 处理后的剩余滚动事件,防止传递到 ModalBottomSheet
return available
}
override suspend fun onPreFling(available: Velocity): Velocity {
// 不消费惯性滚动,让 LazyColumn 先处理
return Velocity.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
// 消费 LazyColumn 处理后的剩余惯性滚动,防止传递到 ModalBottomSheet
return available
}
}
}
// MBTI解释文字背景色
val descriptionBackgroundColor = if (isDarkMode) {
Color(0xFF2A2A2A) // 比 secondaryBackground (0xFF1C1C1C) 更亮的灰色
} else {
Color(0xFFFAF9FB)
}
// 使用LazyColumn包裹解释文字和MBTI类型网格使它们一起滚动
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.nestedScroll(nestedScrollConnection),
contentPadding = PaddingValues(
start = 8.dp,
top = 0.dp,
end = 8.dp,
bottom = 8.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// MBTI解释文字 - 作为第一个item
item {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(descriptionBackgroundColor)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Text(
text = stringResource(R.string.mbti_description),
color = appColors.text,
fontSize = 14.sp,
lineHeight = 20.sp
)
}
}
// MBTI类型网格 - 手动创建2列网格布局
itemsIndexed(MBTI_TYPES.chunked(2)) { rowIndex, rowItems ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = if (rowIndex < MBTI_TYPES.chunked(2).size - 1) 10.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
rowItems.forEachIndexed { colIndex, mbti ->
Box(
modifier = Modifier.weight(1f)
) {
MbtiItem(
mbti = mbti,
isSelected = mbti == currentMbti,
onClick = {
// 保存MBTI类型
model.mbti = mbti
onClose()
}
)
}
}
// 如果这一行只有1个item添加一个空的Spacer来保持布局
if (rowItems.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
}
}
}
// 保留原有的 MbtiSelectScreen 用于导航路由(如果需要)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MBTIItem(
fun MbtiSelectScreen() {
val navController = LocalNavController.current
MbtiSelectBottomSheet(
onClose = {
navController.navigateUp()
}
)
}
@Composable
fun MbtiItem(
mbti: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val appColors = LocalAppTheme.current
val isDarkMode = AppState.darkMode
Box(
// 卡片背景色
val cardBackgroundColor = if (isDarkMode) {
Color(0xFF2A2A2A) // 比 secondaryBackground (0xFF1C1C1C) 更亮的灰色
} else {
Color(0xFFFAF9FB)
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(if (isSelected) appColors.main.copy(alpha = 0.1f) else Color.White)
.aspectRatio(1.1f)
.shadow(
elevation = if (isDarkMode) 8.dp else 2.dp,
shape = RoundedCornerShape(21.dp),
spotColor = if (isDarkMode) Color.Black.copy(alpha = 0.5f) else Color.Black.copy(alpha = 0.1f)
)
.clip(RoundedCornerShape(21.dp))
.background(cardBackgroundColor)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onClick()
}
.padding(16.dp)
.padding(horizontal = 24.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
// MBTI图标 - 使用占位图片
Box(
modifier = Modifier.size(100.dp),
contentAlignment = Alignment.Center
) {
Text(
text = mbti,
fontSize = 17.sp,
fontWeight = FontWeight.Normal,
color = if (isSelected) appColors.main else appColors.text,
modifier = Modifier.weight(1f)
Image(
painter = painterResource(id = getMbtiImageResId(mbti, isDarkMode)),
contentDescription = mbti,
modifier = Modifier.size(100.dp)
)
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
modifier = Modifier.size(20.dp),
tint = appColors.main
)
}
}
// MBTI名称 - 使用负间距让文本向上移动,与图标更靠近
Text(
text = mbti,
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 MbtiSheetManager {
private val _visible = MutableStateFlow(false)
val visible: StateFlow<Boolean> = _visible.asStateFlow()
fun open() {
_visible.value = true
}
fun close() {
_visible.value = false
}
}

View File

@@ -20,12 +20,14 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.ConstVars
import com.aiosman.ravenow.data.api.ErrorCode
import com.aiosman.ravenow.LocalAppTheme
@@ -99,10 +101,11 @@ fun ResetPasswordScreen() {
if (e.code == ErrorCode.USER_NOT_EXIST.code){
usernameError = context.getString(R.string.error_40002_user_not_exist)
} else {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
// 其他错误不显示Toast
isSendSuccess = false
}
} catch (e: Exception) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
// 异常错误不显示Toast
isSendSuccess = false
} finally {
isLoading = false
@@ -133,12 +136,21 @@ fun ResetPasswordScreen() {
modifier = Modifier.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 暗色模式下的 hint 文本颜色
val isDarkMode = AppState.darkMode
val hintColor = if (isDarkMode) {
Color(0xFFFFFFFF).copy(alpha = 0.7f)
} else {
null // 使用默认颜色
}
TextInputField(
text = username,
onValueChange = { username = it },
hint = stringResource(R.string.text_hint_email),
enabled = !isLoading && countDown == null,
error = usernameError,
customHintColor = hintColor
)
Spacer(modifier = Modifier.height(16.dp))
Box(
@@ -178,9 +190,11 @@ fun ResetPasswordScreen() {
} else {
stringResource(R.string.recover)
},
backgroundColor = appColors.main,
backgroundColor = Color(0xFF7C45ED), // 紫色背景
loadingBackgroundColor = Color(0xFF7C45ED), // loading 时保持紫色
disabledBackgroundColor = Color(0xFF7C45ED), // disabled 时保持紫色
color = appColors.mainText,
isLoading = isLoading,
isLoading = isLoading && countDown == null, // 只在未发送成功时显示loading
contentPadding = PaddingValues(0.dp),
enabled = countDown == null,
) {
@@ -193,6 +207,8 @@ fun ResetPasswordScreen() {
.fillMaxWidth()
.height(48.dp),
text = stringResource(R.string.back_upper),
backgroundColor = Color(0xFF7C45ED), // 紫色背景
color = Color.White, // 白色文字
contentPadding = PaddingValues(0.dp),
) {
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,380 @@
package com.aiosman.ravenow.ui.account
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
// 星座列表
val ZODIAC_SIGNS = listOf(
"白羊座", "金牛座", "双子座", "巨蟹座",
"狮子座", "处女座", "天秤座", "天蝎座",
"射手座", "摩羯座", "水瓶座", "双鱼座"
// 星座资源ID列表
val ZODIAC_SIGN_RES_IDS = listOf(
R.string.zodiac_aries,
R.string.zodiac_taurus,
R.string.zodiac_gemini,
R.string.zodiac_cancer,
R.string.zodiac_leo,
R.string.zodiac_virgo,
R.string.zodiac_libra,
R.string.zodiac_scorpio,
R.string.zodiac_sagittarius,
R.string.zodiac_capricorn,
R.string.zodiac_aquarius,
R.string.zodiac_pisces
)
@Composable
fun ZodiacSelectScreen() {
val navController = LocalNavController.current
val appColors = LocalAppTheme.current
val model = AccountEditViewModel
val currentZodiac = model.zodiac
/**
* 根据星座资源ID获取对应的图片资源ID
*/
fun getZodiacImageResId(zodiacResId: Int): Int {
return when (zodiacResId) {
R.string.zodiac_aries -> R.mipmap.baiyang
R.string.zodiac_taurus -> R.mipmap.jingniu
R.string.zodiac_gemini -> R.mipmap.shuangzi
R.string.zodiac_cancer -> R.mipmap.juxie
R.string.zodiac_leo -> R.mipmap.shizi
R.string.zodiac_virgo -> R.mipmap.chunv
R.string.zodiac_libra -> R.mipmap.tiancheng
R.string.zodiac_scorpio -> R.mipmap.tianxie
R.string.zodiac_sagittarius -> R.mipmap.sheshou
R.string.zodiac_capricorn -> R.mipmap.moxie
R.string.zodiac_aquarius -> R.mipmap.shuiping
R.string.zodiac_pisces -> R.mipmap.shuangyu
else -> R.mipmap.xingzuo // 默认使用占位图片
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(appColors.profileBackground)
/**
* 根据存储的星座字符串可能是任何语言找到对应的资源ID
* 如果找不到返回null
*/
@Composable
fun findZodiacResId(storedZodiac: String?): Int? {
if (storedZodiac.isNullOrEmpty()) return null
// 尝试在所有语言的资源中查找匹配
ZODIAC_SIGN_RES_IDS.forEachIndexed { index, resId ->
val zodiacText = stringResource(resId)
if (zodiacText == storedZodiac) {
return resId
}
}
// 如果找不到精确匹配尝试通过资源ID索引查找兼容旧数据
// 这里可以根据需要添加更多兼容逻辑
return null
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ZodiacSelectBottomSheet(
onClose: () -> Unit
) {
val appColors = LocalAppTheme.current
val isDarkMode = AppState.darkMode
val model = AccountEditViewModel
val currentZodiacResId = findZodiacResId(model.zodiac)
val sheetBackgroundColor = if (isDarkMode) {
appColors.secondaryBackground
} else {
Color(0xFFFFFFFF)
}
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// 确保弹窗展开
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(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
NoticeScreenHeader(
title = stringResource(R.string.choose_zodiac),
moreIcon = false
)
}
// 列表
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()
}
.fillMaxHeight(0.95f)
.offset(y = offsetY)
.padding(
start = 16.dp,
end = 16.dp,
bottom = 8.dp
)
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
onClose()
}
)
}
}
}
}
}
}
// 保留原有的 ZodiacSelectScreen 用于导航路由(如果需要)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ZodiacSelectScreen() {
val navController = com.aiosman.ravenow.LocalNavController.current
ZodiacSelectBottomSheet(
onClose = {
navController.navigateUp()
}
)
}
@Composable
fun ZodiacItem(
zodiac: String,
zodiacResId: Int,
isSelected: Boolean,
onClick: () -> Unit
) {
val appColors = LocalAppTheme.current
val isDarkMode = AppState.darkMode
Box(
// 卡片背景色:浅灰色 (250, 249, 251)
// 暗色模式下使用比背景色更亮的颜色,以形成对比
val cardBackgroundColor = if (isDarkMode) {
Color(0xFF2A2A2A) // 比 secondaryBackground (0xFF1C1C1C) 更亮的灰色
} else {
Color(0xFFFAF9FB)
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(if (isSelected) appColors.main.copy(alpha = 0.1f) else Color.White)
.aspectRatio(1.1f) // 增加宽高比,使高度相对更低
.shadow(
elevation = if (isDarkMode) 8.dp else 2.dp, // 深色模式下更强的阴影
shape = RoundedCornerShape(21.dp),
spotColor = if (isDarkMode) Color.Black.copy(alpha = 0.5f) else Color.Black.copy(alpha = 0.1f)
)
.clip(RoundedCornerShape(21.dp))
.background(cardBackgroundColor)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onClick()
}
.padding(16.dp)
.padding(horizontal = 24.dp, vertical = 12.dp), // 减小垂直padding确保文本不被遮挡
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
// 星座图标 - 使用对应星座的图片
Box(
modifier = Modifier.size(100.dp),
contentAlignment = Alignment.Center
) {
Text(
text = zodiac,
fontSize = 17.sp,
fontWeight = FontWeight.Normal,
color = if (isSelected) appColors.main else appColors.text,
modifier = Modifier.weight(1f)
Image(
painter = painterResource(id = getZodiacImageResId(zodiacResId)),
contentDescription = zodiac,
modifier = Modifier.size(100.dp)
)
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

@@ -16,9 +16,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
@@ -68,6 +70,19 @@ fun ChangePasswordScreen() {
var confirmPasswordError by remember { mutableStateOf<String?>(null) }
var passwordError by remember { mutableStateOf<String?>(null) }
val AppColors = LocalAppTheme.current
// 暗色模式下的 hint 文本颜色
val isDarkMode = AppState.darkMode
val hintColor = if (isDarkMode) {
Color(0xFFFFFFFF).copy(alpha = 0.7f)
} else {
null // 使用默认颜色
}
val labelColor = if (isDarkMode) {
Color(0xFFFFFFFF).copy(alpha = 0.7f)
} else {
null // 使用默认颜色
}
fun validate(): Boolean {
// 使用通用密码校验器校验当前密码
val currentPasswordValidation = PasswordValidator.validateCurrentPassword(currentPassword, context)
@@ -112,7 +127,9 @@ fun ChangePasswordScreen() {
password = true,
label = stringResource(R.string.current_password),
hint = stringResource(R.string.current_password_tip5),
error = oldPasswordError
error = oldPasswordError,
customHintColor = hintColor,
customLabelColor = labelColor
)
Spacer(modifier = Modifier.height(4.dp))
TextInputField(
@@ -121,7 +138,9 @@ fun ChangePasswordScreen() {
password = true,
label = stringResource(R.string.new_password),
hint = stringResource(R.string.new_password),
error = passwordError
error = passwordError,
customHintColor = hintColor,
customLabelColor = labelColor
)
Spacer(modifier = Modifier.height(4.dp))
TextInputField(
@@ -130,7 +149,9 @@ fun ChangePasswordScreen() {
password = true,
label = stringResource(R.string.confirm_new_password_tip1),
hint = stringResource(R.string.new_password_tip1),
error = confirmPasswordError
error = confirmPasswordError,
customHintColor = hintColor,
customLabelColor = labelColor
)
Spacer(modifier = Modifier.height(50.dp))
ActionButton(

View File

@@ -4,19 +4,26 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit as EditIcon
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -28,10 +35,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.AppStore
@@ -39,29 +50,37 @@ import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.form.FormTextInput
import com.aiosman.ravenow.ui.composables.debouncedClickable
import com.aiosman.ravenow.ui.composables.rememberDebouncedNavigation
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.text.TextLayoutResult
import com.aiosman.ravenow.ConstVars
import com.aiosman.ravenow.ui.composables.pickupAndCompressLauncher
import android.widget.Toast
import java.io.File
import androidx.activity.compose.BackHandler
import com.aiosman.ravenow.ui.account.ZodiacBottomSheetHost
import com.aiosman.ravenow.ui.account.ZodiacSheetManager
import com.aiosman.ravenow.ui.account.MbtiBottomSheetHost
import com.aiosman.ravenow.ui.account.MbtiSheetManager
/**
* 编辑用户资料界面
@@ -86,6 +105,10 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
quality = 100
) { uri, file ->
// 处理选中的图片
// 保存到 ViewModel 中,等待保存时一起上传
model.bannerImageUrl = uri
model.bannerFile = file
// 如果提供了回调,也调用它(用于个人主页直接更新)
onUpdateBanner?.invoke(uri, file, context)
}
@@ -93,10 +116,21 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
// 去除换行符,确保昵称不包含换行
val cleanValue = value.replace("\n", "").replace("\r", "")
model.name = cleanValue
// 实时验证,但不显示错误(只在保存时显示)
usernameError = when {
cleanValue.trim().isEmpty() -> "昵称不能为空"
cleanValue.length < 3 -> "昵称长度不能小于3"
cleanValue.length > 20 -> "昵称长度不能大于20"
cleanValue.trim().isEmpty() -> context.getString(R.string.error_nickname_empty)
cleanValue.length < 3 -> context.getString(R.string.error_nickname_too_short)
cleanValue.length > 20 -> context.getString(R.string.error_nickname_too_long)
else -> null
}
}
fun validateNickname(): String? {
val cleanValue = model.name.replace("\n", "").replace("\r", "")
return when {
cleanValue.trim().isEmpty() -> context.getString(R.string.error_nickname_empty)
cleanValue.length < 3 -> context.getString(R.string.error_nickname_too_short)
cleanValue.length > 20 -> context.getString(R.string.error_nickname_too_long)
else -> null
}
}
@@ -107,8 +141,17 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
// 去除换行符,确保个人简介不包含换行
val cleanValue = value.replace("\n", "").replace("\r", "")
model.bio = cleanValue
// 实时验证,但不显示错误(只在保存时显示)
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
}
}
@@ -134,192 +177,40 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
// 确保显示的是当前登录用户的信息,而不是之前用户的缓存数据
model.reloadProfile()
}
// 处理系统返回键
BackHandler {
// 用户未保存直接返回,恢复所有字段到原始值
model.resetToOriginalData()
navController.navigateUp()
}
// 设置状态栏为透明,根据暗色模式决定图标颜色
val systemUiController = rememberSystemUiController()
LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode)
}
StatusBarMaskLayout(
modifier = Modifier.background(color = appColors.background).padding(horizontal = 16.dp),
darkIcons = !AppState.darkMode,
maskBoxBackgroundColor = appColors.background
modifier = Modifier.background(appColors.background),
darkIcons = !AppState.darkMode, // 根据暗色模式决定图标颜色
maskBoxBackgroundColor = Color.Transparent
) {
Column(
// 挂载星座选择弹窗
ZodiacBottomSheetHost()
// 挂载MBTI选择弹窗
MbtiBottomSheetHost()
Box(
modifier = Modifier
.fillMaxSize()
.background(color = appColors.background),
horizontalAlignment = Alignment.CenterHorizontally
.background(appColors.background)
) {
//StatusBarSpacer()
Box(
modifier = Modifier.padding(horizontal = 0.dp, vertical = 16.dp)
) {
NoticeScreenHeader(
title = stringResource(R.string.edit_profile),
moreIcon = false
) {
Icon(
modifier = Modifier
.size(24.dp)
.debouncedClickable(
enabled = validate() && !model.isUpdating,
debounceTime = 1000L
) {
if (validate() && !model.isUpdating) {
model.viewModelScope.launch {
model.isUpdating = true
model.updateUserProfile(context)
model.viewModelScope.launch(Dispatchers.Main) {
debouncedNavigation {
navController.navigateUp()
}
model.isUpdating = false
}
}
}
},
imageVector = Icons.Default.Check,
contentDescription = "保存",
tint = if (validate() && !model.isUpdating) appColors.text else appColors.nonActiveText
)
}
}
// 添加横幅图片区域
val banner = model.profile?.banner
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.clip(RoundedCornerShape(12.dp))
) {
if (banner != null) {
CustomAsyncImage(
context = LocalContext.current,
imageUrl = banner,
modifier = Modifier.fillMaxSize(),
contentDescription = "Banner",
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Gray.copy(alpha = 0.1f))
)
}
Box(
modifier = Modifier
.width(120.dp)
.height(42.dp)
.align(Alignment.BottomEnd)
.padding(end = 12.dp, bottom = 12.dp)
.background(
color = Color.Black.copy(alpha = 0.4f),
shape = RoundedCornerShape(9.dp)
)
.noRippleClickable {
Intent(Intent.ACTION_PICK).apply {
type = "image/*"
pickBannerImageLauncher.launch(this)
}
}
){
Text(
text = "change",
fontSize = 14.sp,
fontWeight = FontWeight.W600,
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
}
Spacer(modifier = Modifier.height(20.dp))
// 显示内容或加载状态
Log.d("AccountEditScreen2", "UI状态 - profile: ${model.profile?.nickName}, isLoading: ${model.isLoading}")
when {
model.profile != null -> {
Log.d("AccountEditScreen2", "显示用户资料内容")
// 有数据时显示内容
val it = model.profile!!
Box(
modifier = Modifier.size(88.dp),
contentAlignment = Alignment.Center
) {
CustomAsyncImage(
context,
model.croppedBitmap ?: it.avatar,
modifier = Modifier
.size(88.dp)
.clip(
RoundedCornerShape(88.dp)
),
contentDescription = "",
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(
brush = Brush.linearGradient(
colors = listOf(
Color(0xFF7c45ed),
Color(0x997c68ef),
Color(0xFF7bd8f8)
)
),
)
.align(Alignment.BottomEnd)
.debouncedClickable(
debounceTime = 800L
) {
debouncedNavigation {
navController.navigate(NavigationRoute.ImageCrop.route)
}
},
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Add,
contentDescription = "Add",
tint = Color.White,
)
}
}
Spacer(modifier = Modifier.height(18.dp))
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp)
) {
FormTextInput(
value = model.name,
label = stringResource(R.string.nickname),
hint = "Input nickname",
modifier = Modifier.fillMaxWidth(),
error = usernameError
) { value ->
onNicknameChange(value)
}
FormTextInput(
value = model.bio,
label = stringResource(R.string.bio),
hint = "Input bio",
modifier = Modifier.fillMaxWidth(),
error = bioError
) { value ->
onBioChange(value)
}
}
}
model.isLoading -> {
Log.d("AccountEditScreen2", "显示加载指示器")
// 加载中状态
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
androidx.compose.material3.CircularProgressIndicator(
@@ -327,24 +218,519 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
)
}
}
model.profile != null -> {
Column(
modifier = Modifier.fillMaxSize()
) {
// 顶部背景区域(圆角在底部,覆盖状态栏)
// 优先显示新选择的背景图,如果没有则显示原有的背景图
val banner = model.bannerImageUrl?.toString() ?: model.profile?.banner
val statusBarPadding = WindowInsets.systemBars.asPaddingValues().calculateTopPadding()
Box(
modifier = Modifier
.fillMaxWidth()
.offset(y = -statusBarPadding)
) {
Box(
modifier = Modifier
.width(402.dp)
.height(206.dp)
.align(Alignment.TopCenter)
.clip(RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp))
) {
if (banner != null) {
CustomAsyncImage(
context = context,
imageUrl = banner,
modifier = Modifier.fillMaxSize(),
contentDescription = "Banner",
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Gray.copy(alpha = 0.2f))
)
}
// 更换封面按钮(位于壁纸右下方)
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 20.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0x5C7D7C80)) // RGB(125, 120, 128, 0.36)
.padding(horizontal = 8.dp, vertical = 4.dp)
.noRippleClickable {
Intent(Intent.ACTION_PICK).apply {
type = "image/*"
pickBannerImageLauncher.launch(this)
}
}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
// 更换封面图标
Icon(
painter = painterResource(id = R.mipmap.fengm),
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color.White
)
Text(
text = stringResource(R.string.change_cover),
fontSize = 12.sp,
color = Color.White
)
}
}
// 状态栏区域(时间、信号、电池)
// 这里使用系统状态栏,不单独实现
// 导航栏区域
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(statusBarPadding + 12.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
) {
// 返回按钮
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.3f))
.noRippleClickable {
// 用户未保存直接返回,恢复所有字段到原始值
model.resetToOriginalData()
navController.navigateUp()
}
.align(Alignment.CenterStart),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = "Back",
modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(Color.White)
)
}
// 标题
Text(
text = stringResource(R.string.edit_profile_info),
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}
// 用户头像区域(距离顶部-50dp包含状态栏高度左右居中
Box(
modifier = Modifier
.fillMaxWidth()
.offset(y = (-50).dp - statusBarPadding),
contentAlignment = Alignment.Center
) {
val it = model.profile!!
Box(
modifier = Modifier.size(96.dp),
contentAlignment = Alignment.BottomEnd
) {
// 头像
CustomAsyncImage(
context,
model.croppedBitmap ?: it.avatar,
modifier = Modifier
.size(96.dp)
.clip(CircleShape)
.border(2.4.dp, appColors.background, CircleShape),
contentDescription = "",
contentScale = ContentScale.Crop
)
// 编辑头像按钮
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(Color(0xFF110C13))
.border(2.dp, Color.White, CircleShape)
.debouncedClickable(debounceTime = 800L) {
debouncedNavigation {
navController.navigate(NavigationRoute.ImageCrop.route)
}
},
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = R.mipmap.bi),
contentDescription = "Edit Avatar",
modifier = Modifier.size(16.dp),
tint = Color.White
)
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 16.dp)
.offset(y = (-74).dp)//
) {
// 昵称输入框
ProfileInfoCard(
label = stringResource(R.string.nickname),
value = model.name,
placeholder = stringResource(R.string.nickname_placeholder),
onValueChange = { onNicknameChange(it) },
isMultiline = false
)
Spacer(modifier = Modifier.height(16.dp))
// 个人简介输入框
ProfileInfoCard(
label = stringResource(R.string.personal_intro),
value = model.bio,
placeholder = "",
onValueChange = { onBioChange(it) },
isMultiline = true
)
Spacer(modifier = Modifier.height(16.dp))
// MBTI 类型和星座
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(appColors.secondaryBackground)
) {
// MBTI 类型
ProfileSelectItem(
label = stringResource(R.string.mbti_type),
value = model.mbti ?: "ENFP",
iconColor = Color(0xFF7C45ED),
iconResDark = null, // TODO: 添加MBTI暗色模式图标
iconResLight = null, // TODO: 添加MBTI亮色模式图标
onClick = {
MbtiSheetManager.open()
}
)
// 分隔线
Box(
modifier = Modifier
.fillMaxWidth()
.height(0.3.dp)
.background(appColors.divider)
.padding(horizontal = 16.dp)
)
// 星座(使用当前图标)
ProfileSelectItem(
label = stringResource(R.string.zodiac),
value = model.zodiac?.let { storedZodiac ->
// 尝试找到对应的资源ID并显示当前语言的文本
findZodiacResId(storedZodiac)?.let { resId ->
stringResource(resId)
} ?: storedZodiac // 如果找不到,显示原始存储的值
} ?: stringResource(R.string.zodiac_aries),
iconColor = Color(0xFFFFCC00),
iconResDark = R.mipmap.frame_4, // 星座暗色模式图标
iconResLight = R.mipmap.xingzuo, // 星座亮色模式图标
onClick = {
ZodiacSheetManager.open()
}
)
}
Spacer(modifier = Modifier.weight(1f))
// 保存按钮
Box(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.clip(RoundedCornerShape(1000.dp))
.background(
brush = Brush.horizontalGradient(
colors = listOf(
Color(0xFF7C45ED), // RGB(124, 69, 237) - 左侧
Color(0xFF7C57EE), // RGB(124, 87, 238) - 中间
Color(0xFF7BD8F8) // RGB(123, 216, 248) - 右侧
)
)
)
.debouncedClickable(
enabled = !model.isUpdating,
debounceTime = 1000L
) {
if (model.isUpdating) return@debouncedClickable
// 点击保存时重新验证
val nicknameErrorMsg = validateNickname()
val bioErrorMsg = validateBio()
// 如果有错误,显示对应的错误提示
when {
nicknameErrorMsg != null -> {
Toast.makeText(context, nicknameErrorMsg, Toast.LENGTH_SHORT).show()
return@debouncedClickable
}
bioErrorMsg != null -> {
Toast.makeText(context, bioErrorMsg, Toast.LENGTH_SHORT).show()
return@debouncedClickable
}
}
// 验证通过,执行保存
model.viewModelScope.launch {
model.isUpdating = true
try {
model.updateUserProfile(context)
model.viewModelScope.launch(Dispatchers.Main) {
debouncedNavigation {
navController.navigateUp()
}
model.isUpdating = false
}
} catch (e: Exception) {
// 捕获所有异常,包括网络异常
model.viewModelScope.launch(Dispatchers.Main) {
Toast.makeText(
context,
context.getString(R.string.network_error_check_network),
Toast.LENGTH_SHORT
).show()
model.isUpdating = false
}
}
}
},
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.save),
fontSize = 17.sp,
fontWeight = FontWeight.Normal,
color = Color.White
)
}
}
}
}
else -> {
Log.d("AccountEditScreen2", "显示错误信息 - 没有数据且不在加载中")
// 没有数据且不在加载中,显示错误信息
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Text(
text = "加载用户资料失败,请重试",
Text(
text = stringResource(R.string.error_load_profile_failed),
color = appColors.text
)
}
}
}
}
}
}
}}
/**
* 信息输入卡片组件
*/
@Composable
fun ProfileInfoCard(
label: String,
value: String,
placeholder: String,
onValueChange: (String) -> Unit,
isMultiline: Boolean = false
) {
val appColors = LocalAppTheme.current
var isFocused by remember { mutableStateOf(false) }
var lineCount by remember { mutableStateOf(1) }
// 根据行数决定对齐方式:单行时居中,多行时顶部对齐
val verticalAlignment = if (isMultiline) {
if (lineCount <= 1) Alignment.CenterVertically else Alignment.Top
} else {
Alignment.CenterVertically
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(if (isMultiline) 66.dp else 56.dp) // 昵称框高度56dp个人简介66dp
.clip(RoundedCornerShape(16.dp))
.background(appColors.secondaryBackground),
contentAlignment = if (isMultiline && lineCount > 1) Alignment.TopStart else Alignment.CenterStart
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(vertical = if (isMultiline && lineCount > 1) 11.dp else 0.dp),
verticalAlignment = verticalAlignment
) {
// 标签
Box(
modifier = Modifier
.width(100.dp)
.height(if (isMultiline) 44.dp else 56.dp),
contentAlignment = Alignment.CenterStart
) {
Text(
text = label,
fontSize = 17.sp,
fontWeight = FontWeight.Normal,
color = appColors.text
)
}
Spacer(modifier = Modifier.width(16.dp))
// 输入框
Box(
modifier = Modifier.weight(1f)
) {
// 对于个人简介isMultiline = true当值为空且没有焦点时显示图标
if (value.isEmpty() && isMultiline && !isFocused) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(44.dp),
contentAlignment = Alignment.CenterStart
) {
Icon(
painter = painterResource(id = R.mipmap.icons_infor_edit),
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = appColors.secondaryText
)
}
} else if (value.isEmpty() && !isMultiline && placeholder.isNotEmpty()) {
// 对于非多行输入框,仍然显示 placeholder 文字
Text(
text = placeholder,
fontSize = 17.sp,
fontWeight = FontWeight.Normal,
color = appColors.secondaryText,
modifier = Modifier.fillMaxWidth()
)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focusState ->
isFocused = focusState.isFocused
},
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = if (isMultiline) 15.sp else 17.sp,
fontWeight = FontWeight.Normal,
color = appColors.text
),
cursorBrush = SolidColor(appColors.text),
maxLines = if (isMultiline) Int.MAX_VALUE else 1,
singleLine = !isMultiline,
onTextLayout = { textLayoutResult: TextLayoutResult ->
if (isMultiline) {
lineCount = textLayoutResult.lineCount
}
}
)
}
}
}
}
/**
* 选择项组件MBTI、星座
*/
@Composable
fun ProfileSelectItem(
label: String,
value: String,
iconColor: Color,
onClick: () -> Unit,
iconResDark: Int? = null,
iconResLight: Int? = null
) {
val appColors = LocalAppTheme.current
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// 自定义图标
Icon(
painter = painterResource(
id = if (AppState.darkMode) {
// 暗色模式下使用和亮色模式一样的图标
iconResLight ?: iconResDark ?: R.mipmap.naoz
} else {
iconResLight ?: R.mipmap.naoz // 使用传入的亮色模式图标,或默认占位
}
),
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = iconColor
)
Text(
text = label,
fontSize = 17.sp,
fontWeight = FontWeight.Normal,
color = appColors.text
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = value,
fontSize = 17.sp,
fontWeight = FontWeight.Normal,
color = appColors.secondaryText
)
Icon(
imageVector = Icons.Default.ArrowForward,
contentDescription = null,
modifier = Modifier.size(8.dp),
tint = appColors.secondaryText
)
}
}
}

View File

@@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@@ -49,6 +50,14 @@ fun RemoveAccountScreen() {
var passwordError by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
// 暗色模式下的 hint 文本颜色
val isDarkMode = AppState.darkMode
val hintColor = if (isDarkMode) {
Color(0xFFFFFFFF).copy(alpha = 0.7f)
} else {
null // 使用默认颜色
}
fun removeAccount(password: String) {
// 使用通用密码校验器
@@ -132,7 +141,8 @@ fun RemoveAccountScreen() {
},
password = true,
hint = stringResource(R.string.remove_account_password_hint),
error = passwordError
error = passwordError,
customHintColor = hintColor
)
Spacer(modifier = Modifier.weight(1f))

View File

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

View File

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

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

@@ -617,7 +617,7 @@ fun ChatAiInput(
animationSpec = tween(300)
)
Image(
painter = painterResource(R.mipmap.rider_pro_im_send),
painter = painterResource(R.mipmap.btn),
modifier = Modifier
.size(24.dp)
.alpha(alpha)

View File

@@ -650,7 +650,7 @@ fun ChatInput(
animationSpec = tween(300)
)
Image(
painter = painterResource(R.mipmap.rider_pro_im_send),
painter = painterResource(R.mipmap.btn),
modifier = Modifier
.size(24.dp)
.alpha(alpha)

View File

@@ -182,46 +182,22 @@ fun GroupChatScreen(groupId: String,name: String,avatar: String,) {
colorFilter = ColorFilter.tint(AppColors.text)
)
Spacer(modifier = Modifier.width(16.dp))
if (viewModel.groupAvatar.isNotEmpty()) {
if (viewModel.groupInfo?.groupAvatar?.isNotEmpty() == true) {
CustomAsyncImage(
imageUrl = viewModel.groupAvatar,
imageUrl = viewModel.groupInfo!!.groupAvatar,
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(8.dp))
.noRippleClickable {
navController.navigateToGroupInfo(groupId)
},
.size(35.dp)
.clip(RoundedCornerShape(15.dp)),
contentDescription = "群聊头像"
)
} else {
Box(
modifier = Modifier
.size(24.dp)
.clip(RoundedCornerShape(8.dp))
.background(AppColors.decentBackground)
.noRippleClickable {
navController.navigateToGroupInfo(groupId)
},
contentAlignment = Alignment.Center
) {
Text(
text = viewModel.groupName,
style = TextStyle(
color = AppColors.text,
fontSize = 18.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.W700
),
maxLines = 1,
overflow =TextOverflow.Ellipsis,
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Column {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.Start
) {
Text(
text = viewModel.groupName,
style = TextStyle(
@@ -229,24 +205,21 @@ fun GroupChatScreen(groupId: String,name: String,avatar: String,) {
fontSize = 18.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
),
maxLines = 1,
overflow =TextOverflow.Ellipsis,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.weight(1f))
Box {
Image(
painter = painterResource(R.drawable.rider_pro_more_horizon),
modifier = Modifier
.size(28.dp)
.noRippleClickable {
navController.navigateToGroupInfo(groupId)
},
contentDescription = null,
colorFilter = ColorFilter.tint(AppColors.text)
)
}
Image(
painter = painterResource(R.drawable.rider_pro_more_horizon),
modifier = Modifier
.size(28.dp)
.noRippleClickable {
navController.navigateToGroupInfo(groupId)
},
contentDescription = "更多",
colorFilter = ColorFilter.tint(AppColors.text)
)
}
}
},
@@ -677,7 +650,7 @@ fun GroupChatInput(
animationSpec = tween(300)
)
Image(
painter = painterResource(R.mipmap.rider_pro_im_send),
painter = painterResource(R.mipmap.btn),
modifier = Modifier
.size(24.dp)
.alpha(alpha)

View File

@@ -1,11 +1,13 @@
package com.aiosman.ravenow.ui.chat
import android.content.Context
import android.util.Base64
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.SendChatAiRequestBody
import io.openim.android.sdk.enums.ConversationType
@@ -50,17 +52,46 @@ class GroupChatViewModel(
}
private suspend fun getGroupInfo() {
// 简化群组信息获取,使用默认信息
groupInfo = GroupInfo(
groupId = groupId,
groupName = name,
groupAvatar = avatar,
memberCount = 0,
ownerId = ""
)
groupName = groupInfo?.groupName ?: ""
groupAvatar = groupInfo?.groupAvatar ?: ""
memberCount = groupInfo?.memberCount ?: 0
try {
val response = ApiClient.api.getRoomDetail(trtcId = groupId)
val room = response.body()?.data
groupInfo = room?.let {
GroupInfo(
groupId = groupId,
groupName = it.name,
groupAvatar = if (it.avatar.isNullOrEmpty()) {
val groupIdBase64 = Base64.encodeToString(
groupId.toByteArray(),
Base64.NO_WRAP
)
"${ApiClient.RETROFIT_URL}group/avatar?groupIdBase64=$groupIdBase64&token=${AppStore.token}"
} else {
"${ApiClient.BASE_API_URL}/outside${it.avatar}?token=${AppStore.token}"
},
memberCount = it.userCount,
ownerId = it.creator.userId
)
} ?: GroupInfo(
groupId = groupId,
groupName = name,
groupAvatar = avatar,
memberCount = 0,
ownerId = ""
)
} catch (e: Exception) {
Log.e("GroupChatViewModel", "加载群信息失败: ${e.message}", e)
groupInfo = GroupInfo(
groupId = groupId,
groupName = name,
groupAvatar = avatar,
memberCount = 0,
ownerId = ""
)
} finally {
groupName = groupInfo?.groupName ?: ""
groupAvatar = groupInfo?.groupAvatar ?: ""
memberCount = groupInfo?.memberCount ?: 0
}
}
override fun getConversationParams(): Triple<String, Int, Boolean> {

View File

@@ -8,11 +8,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -29,6 +32,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -40,6 +44,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.CommentEntity
import com.aiosman.ravenow.ui.composables.EditCommentBottomModal
@@ -76,8 +81,9 @@ class CommentModalViewModel(
fun CommentModalContent(
postId: Int? = null,
commentCount: Int = 0,
onCommentAdded: () -> Unit = {},
onDismiss: () -> Unit = {}
onDismiss: () -> Unit = {},
showTitle: Boolean = true,
onCommentAdded: () -> Unit = {}
) {
val model = viewModel<CommentModalViewModel>(
key = "CommentModalViewModel_$postId",
@@ -88,6 +94,7 @@ fun CommentModalContent(
}
)
val commentViewModel = model.commentsViewModel
val AppColors = LocalAppTheme.current
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
LaunchedEffect(Unit) {
@@ -99,10 +106,24 @@ fun CommentModalContent(
var bottomPadding by remember { mutableStateOf(0.dp) }
var softwareKeyboardController = LocalSoftwareKeyboardController.current
var replyComment by remember { mutableStateOf<CommentEntity?>(null) }
var shouldAutoFocus by remember { mutableStateOf(false) }
LaunchedEffect(imePadding) {
bottomPadding = imePadding.dp
}
// 当设置回复评论时,自动聚焦到输入框
LaunchedEffect(replyComment) {
if (replyComment != null) {
// 延迟一下,确保输入框已经渲染
kotlinx.coroutines.delay(100)
shouldAutoFocus = true
// 请求显示键盘
softwareKeyboardController?.show()
} else {
shouldAutoFocus = false
}
}
DisposableEffect(Unit) {
onDispose {
onDismiss()
@@ -113,13 +134,12 @@ fun CommentModalContent(
onDismissRequest = {
showCommentMenu = false
},
containerColor = Color.White,
containerColor = AppColors.background,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
),
dragHandle = {},
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
windowInsets = WindowInsets(0)
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
) {
CommentMenuModal(
onDeleteClick = {
@@ -142,37 +162,55 @@ fun CommentModalContent(
}
Column(
modifier = Modifier
.fillMaxSize()
) {
// 拖动手柄
Box(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, bottom = 16.dp, end = 16.dp)
.padding(top = 8.dp, bottom = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.comment),
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.Center)
Box(
modifier = Modifier
.width(40.dp)
.height(4.dp)
.clip(RoundedCornerShape(50))
.background(AppColors.divider)
)
}
HorizontalDivider(
color = Color(0xFFF7F7F7)
)
if (showTitle) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, bottom = 16.dp, end = 16.dp)
) {
Text(
stringResource(R.string.comment),
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = AppColors.text,
modifier = Modifier.align(Alignment.Center)
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(id = R.string.comment_count, commentCount),
fontSize = 14.sp,
color = Color(0xff666666)
color = AppColors.secondaryText
)
OrderSelectionComponent {
OrderSelectionComponent(
selectedOrder = commentViewModel.order
) {
commentViewModel.order = it
commentViewModel.reloadComment()
}
@@ -180,12 +218,12 @@ fun CommentModalContent(
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.weight(1f)
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
item {
CommentContent(
@@ -194,7 +232,9 @@ fun CommentModalContent(
},
onReply = { parentComment, _, _, _ ->
// 设置回复的评论,这样 EditCommentBottomModal 会显示回复输入框
// CommentContent 内部已经处理了游客模式检查,所以这里直接设置即可
replyComment = parentComment
},
)
Spacer(modifier = Modifier.height(72.dp))
@@ -205,9 +245,12 @@ fun CommentModalContent(
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xfff7f7f7))
.background(AppColors.secondaryBackground)
) {
EditCommentBottomModal(replyComment) {
EditCommentBottomModal(
replyComment = replyComment,
autoFocus = shouldAutoFocus
) {
commentViewModel.viewModelScope.launch {
if (replyComment != null) {
if (replyComment?.parentCommentId != null) {
@@ -225,6 +268,13 @@ fun CommentModalContent(
// 顶级评论
commentViewModel.createComment(it)
}
// 评论创建成功后调用回调
onCommentAdded()
// 清空回复状态和自动聚焦状态
replyComment = null
shouldAutoFocus = false
// 隐藏键盘
softwareKeyboardController?.hide()
}
}

View File

@@ -41,16 +41,15 @@ import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.CommentEntity
import com.aiosman.ravenow.exp.timeAgo
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToPost
import kotlinx.coroutines.launch
import com.aiosman.ravenow.utils.NetworkUtils
import com.aiosman.ravenow.ui.network.ReloadButton
import com.aiosman.ravenow.ui.network.NetworkErrorContent
@Composable
fun CommentNoticeScreen() {
fun CommentNoticeScreen(includeStatusBarPadding: Boolean = true){
val viewModel = viewModel<CommentNoticeListViewModel>(
key = "CommentNotice",
factory = object : ViewModelProvider.Factory {
@@ -68,50 +67,22 @@ fun CommentNoticeScreen() {
val navController = LocalNavController.current
val AppColors = LocalAppTheme.current
Column(
modifier = Modifier.fillMaxSize().background(color = AppColors.background)
StatusBarMaskLayout(
modifier = Modifier
.background(color = AppColors.background)
.padding(horizontal = 16.dp),
darkIcons = !AppState.darkMode,
maskBoxBackgroundColor = AppColors.background,
includeStatusBarPadding = includeStatusBarPadding
) {
StatusBarSpacer()
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current)
if (!isNetworkAvailable) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = 149.dp),
contentAlignment = Alignment.TopCenter
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
androidx.compose.foundation.Image(
painter = painterResource(id = R.mipmap.invalid_name_10),
contentDescription = "network error",
modifier = Modifier.size(181.dp)
)
Spacer(modifier = Modifier.size(24.dp))
Text(
text = stringResource(R.string.friend_chat_no_network_title),
color = AppColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(R.string.friend_chat_no_network_subtitle),
color = AppColors.text,
fontSize = 14.sp,
fontWeight = FontWeight.W400
)
Spacer(modifier = Modifier.height(16.dp))
ReloadButton(
onClick = {
viewModel.initData(context, force = true)
}
)
NetworkErrorContent(
onReload = {
viewModel.initData(context, force = true)
}
}
)
} else if (comments.itemCount == 0 && comments.loadState.refresh is LoadState.NotLoading) {
Box(
modifier = Modifier
@@ -124,33 +95,25 @@ fun CommentNoticeScreen() {
modifier = Modifier.fillMaxWidth()
) {
androidx.compose.foundation.Image(
painter = painterResource(
id = if(AppState.darkMode) R.mipmap.tietie_dark
else R.mipmap.invalid_name_11),
painter = painterResource(id = R.mipmap.invalid_name_5),
contentDescription = "No Comment",
modifier = Modifier
.size(width = 181.dp, height = 153.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(9.dp))
Text(
text = "等一位旅人~",
text = stringResource(R.string.no_one_pinged_yet),
color = AppColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "去发布动态,让更多人参与对话",
color = AppColors.text,
fontSize = 14.sp,
fontWeight = FontWeight.W400
)
}
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize().padding(horizontal = 16.dp)
.weight(1f)
.background(color = AppColors.background)
) {
items(comments.itemCount) { index ->
comments[index]?.let { comment ->
@@ -224,53 +187,58 @@ fun CommentNoticeItem(
val navController = LocalNavController.current
val context = LocalContext.current
val AppColors = LocalAppTheme.current
val commentPrefix = stringResource(R.string.comment_notice)
Row(
modifier = Modifier.padding(vertical = 20.dp, horizontal = 16.dp)
modifier = Modifier.padding(vertical = 12.dp)
) {
Box {
CustomAsyncImage(
context = context,
imageUrl = commentItem.avatar,
contentDescription = commentItem.name,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.noRippleClickable {
navController.navigate(
NavigationRoute.AccountProfile.route.replace(
"{id}",
commentItem.author.toString()
)
// 左侧头像区域
CustomAsyncImage(
context = context,
imageUrl = commentItem.avatar,
contentDescription = commentItem.name,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.noRippleClickable {
navController.navigate(
NavigationRoute.AccountProfile.route.replace(
"{id}",
commentItem.author.toString()
)
}
)
}
)
}
)
// 右侧内容区域
Row(
modifier = Modifier
.weight(1f)
.padding(start = 12.dp)
.padding(start = 8.dp)
.noRippleClickable {
onPostClick()
}
) {
// 主要信息列
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = commentItem.name,
fontSize = 18.sp,
modifier = Modifier,
color = AppColors.text
fontSize = 14.sp,
color = AppColors.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
// 评论内容行
Row {
var text = commentItem.comment
if (commentItem.parentCommentId != null) {
text = "Reply you: $text"
}
Text(
text = text,
text = "$commentPrefix $text",
fontSize = 14.sp,
maxLines = 1,
color = AppColors.secondaryText,
@@ -284,25 +252,20 @@ fun CommentNoticeItem(
color = AppColors.secondaryText,
)
}
}
Spacer(modifier = Modifier.width(24.dp))
Spacer(modifier = Modifier.width(4.dp))
// 右侧帖子图片
commentItem.post?.let {
Box {
Box(
modifier = Modifier.padding(4.dp)
) {
CustomAsyncImage(
context = context,
imageUrl = it.images[0].thumbnail,
contentDescription = "Post Image",
modifier = Modifier
.size(48.dp).clip(RoundedCornerShape(8.dp))
)
// unread indicator
}
CustomAsyncImage(
context = context,
imageUrl = it.images[0].thumbnail,
contentDescription = "Post Image",
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
)
// 未读指示器
if (commentItem.unread) {
Box(
modifier = Modifier
@@ -312,11 +275,7 @@ fun CommentNoticeItem(
)
}
}
}
}
}
}

View File

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

View File

@@ -10,6 +10,7 @@ import androidx.compose.animation.togetherWith
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
@@ -36,6 +37,13 @@ fun AnimatedCounter(count: Int, modifier: Modifier = Modifier, fontSize: Int = 2
)
}
) { 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 {
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.platform.LocalContext
import androidx.core.graphics.drawable.toDrawable
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.request.ImageRequest
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage
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.Utils.getImageLoader

View File

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

View File

@@ -123,7 +123,7 @@ fun LazyGridItemScope.DraggableItem(
translationY = dragDropState.previousItemOffset.value.y
}
} else {
Modifier.animateItemPlacement()
Modifier
}
Box(modifier = modifier.then(draggingModifier).clip(RoundedCornerShape(8.dp)), propagateMinConstraints = true) {
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.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
@@ -59,10 +61,15 @@ fun EditCommentBottomModal(
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val focusRequester = remember { FocusRequester() }
val context = LocalContext.current
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(autoFocus) {
if (autoFocus) {
// 延迟一下,确保输入框已经渲染完成
kotlinx.coroutines.delay(150)
focusRequester.requestFocus()
// 显示键盘
keyboardController?.show()
}
}
@@ -82,7 +89,7 @@ fun EditCommentBottomModal(
.fillMaxWidth()
.weight(1f)
.clip(RoundedCornerShape(20.dp))
.background(Color.Gray.copy(alpha = 0.1f))
.background(AppColors.inputBackground)
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
Row(
@@ -99,7 +106,7 @@ fun EditCommentBottomModal(
.weight(1f)
.focusRequester(focusRequester),
textStyle = TextStyle(
color = Color.Black,
color = AppColors.text,
fontWeight = FontWeight.Normal
),
decorationBox = { innerTextField ->
@@ -110,7 +117,11 @@ fun EditCommentBottomModal(
innerTextField()
if (text.isEmpty()) {
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%透明度
)
}

View File

@@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@@ -32,7 +33,7 @@ fun FollowButton(
.wrapContentWidth()
.clip(RoundedCornerShape(8.dp))
.background(
color = if (isFollowing) AppColors.main else AppColors.nonActive
color = if (isFollowing) AppColors.nonActive else AppColors.nonActive
)
.padding(horizontal = 16.dp, vertical = 8.dp)
.noRippleClickable {
@@ -41,11 +42,9 @@ fun FollowButton(
contentAlignment = Alignment.Center
) {
Text(
text = if (isFollowing) stringResource(R.string.following_upper) else stringResource(
R.string.follow_upper
),
text = if (isFollowing) stringResource(R.string.follow_upper_had) else stringResource(R.string.follow_upper),
fontSize = fontSize,
color = if (isFollowing) AppColors.mainText else AppColors.text,
color = if (isFollowing) AppColors.text else AppColors.text,
style = TextStyle(fontWeight = FontWeight.Bold)
)
}

View File

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

View File

@@ -111,6 +111,7 @@ fun MomentCard(
) {
MomentContentGroup(
momentEntity = momentEntity,
imageIndex = imageIndex,
onPageChange = { index -> imageIndex = index }
)
}
@@ -120,7 +121,6 @@ fun MomentCard(
onLikeClick = onLikeClick,
onAddComment = onAddComment,
onFavoriteClick = onFavoriteClick,
imageIndex = imageIndex,
onCommentClick = {
navController.navigateToPost(
momentEntity.id,
@@ -327,6 +327,11 @@ fun PostImageView(
images: List<MomentImageEntity>,
onPageChange: (Int) -> Unit = {}
) {
// 如果图片列表为空,不渲染任何内容
if (images.isEmpty()) {
return
}
val pagerState = rememberPagerState(pageCount = { images.size })
LaunchedEffect(pagerState.currentPage) {
onPageChange(pagerState.currentPage)
@@ -361,27 +366,88 @@ fun PostImageView(
@Composable
fun MomentContentGroup(
momentEntity: MomentEntity,
imageIndex: Int = 0,
onPageChange: (Int) -> Unit = {}
) {
val AppColors = LocalAppTheme.current
val context = LocalContext.current
if (momentEntity.relMoment != null) {
RelPostCard(
momentEntity = momentEntity.relMoment!!,
modifier = Modifier.background(Color(0xFFF8F8F8))
)
} else {
Box(
Column(
modifier = Modifier.fillMaxWidth()
) {
PostImageView(
images = momentEntity.images,
onPageChange = onPageChange
)
Box(
modifier = Modifier.fillMaxWidth()
) {
// 优先显示图片,如果没有图片则显示视频缩略图
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 = momentEntity.momentTextContent,
text = com.aiosman.ravenow.utils.Utils.unescapeHtml(momentEntity.momentTextContent),
modifier = Modifier
.fillMaxWidth()
.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),
painter = painterResource(id = icon),
contentDescription = "",
colorFilter = ColorFilter.tint(AppColors.text)
)
if (count.isNotEmpty()) {
Text(
@@ -426,7 +493,6 @@ fun MomentOperateBtn(count: String, content: @Composable () -> Unit) {
fontSize = 14,
modifier = Modifier
.padding(start = 7.dp)
.width(24.dp)
)
}
}
@@ -438,8 +504,7 @@ fun MomentBottomOperateRowGroup(
onAddComment: () -> Unit = {},
onCommentClick: () -> Unit = {},
onFavoriteClick: () -> Unit = {},
momentEntity: MomentEntity,
imageIndex: Int = 0
momentEntity: MomentEntity
) {
val lastClickTime = remember { mutableStateOf(0L) }
val clickDelay = 500L
@@ -451,7 +516,6 @@ fun MomentBottomOperateRowGroup(
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
),
windowInsets = WindowInsets(0),
dragHandle = {
Box(
modifier = Modifier
@@ -476,93 +540,65 @@ fun MomentBottomOperateRowGroup(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.padding(start = 16.dp, end = 0.dp)
.padding(start = 16.dp, end = 16.dp)
) {
Column(
modifier = Modifier.fillMaxSize()
Row(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically
) {
if (momentEntity.images.size > 1) {
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
momentEntity.images.forEachIndexed { index, _ ->
Box(
modifier = Modifier
.size(4.dp)
.clip(CircleShape)
.background(
if (imageIndex == index) Color.Red else Color.Gray.copy(
alpha = 0.5f
)
)
.padding(1.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Row(
modifier = Modifier.weight(1f).fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically
) {
// 点赞按钮
MomentOperateBtn(count = momentEntity.likeCount.toString()) {
AnimatedLikeIcon(
modifier = Modifier.size(24.dp),
liked = momentEntity.liked
) {
onLikeClick()
}
}
Spacer(modifier = Modifier.width(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(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f).fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically
Spacer(modifier = Modifier.width(16.dp))
// 收藏按钮
MomentOperateBtn(count = momentEntity.favoriteCount.toString()) {
AnimatedFavouriteIcon(
modifier = Modifier.size(24.dp),
isFavourite = momentEntity.isFavorite
) {
// 点赞按钮
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()
}
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
},
sheetState = modalSheetState,
windowInsets = WindowInsets(0),
containerColor = Color.White,
) {
WebViewDisplay(
@@ -98,7 +97,7 @@ fun PolicyCheckbox(
addStyle(
style = SpanStyle(
color = appColor.main,
color = Color(0xFF7C45ED), // 紫色
textDecoration = TextDecoration.Underline
),
start = template.length + 1,

View File

@@ -36,6 +36,7 @@ fun StatusBarMaskLayout(
modifier: Modifier = Modifier,
darkIcons: Boolean = true,
useNavigationBarMask: Boolean = true,
includeStatusBarPadding: Boolean = true,
maskBoxBackgroundColor: Color = Color.Transparent,
content: @Composable ColumnScope.() -> Unit
) {
@@ -50,13 +51,13 @@ fun StatusBarMaskLayout(
Column(
modifier = modifier.fillMaxSize()
) {
Box(
modifier = Modifier
.height(paddingValues.calculateTopPadding())
.fillMaxWidth()
.background(maskBoxBackgroundColor)
) {
if (includeStatusBarPadding) {
Box(
modifier = Modifier
.height(paddingValues.calculateTopPadding())
.fillMaxWidth()
.background(maskBoxBackgroundColor)
)
}
content()
if (navigationBarPaddings > 24.dp && useNavigationBarMask) {

View File

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

View File

@@ -39,10 +39,15 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
private val LabelTextColor = Color(red = 60f / 255f, green = 60f / 255f, blue = 67f / 255f, alpha = 0.6f)
private val HintTextColor = Color(red = 60f / 255f, green = 60f / 255f, blue = 67f / 255f, alpha = 0.3f)
private val PasswordIconColor = Color(red = 17f / 255f, green = 12f / 255f, blue = 19f / 255f)
@Composable
fun TextInputField(
modifier: Modifier = Modifier,
@@ -52,69 +57,106 @@ fun TextInputField(
label: String? = null,
hint: String? = null,
error: String? = null,
enabled: Boolean = true
enabled: Boolean = true,
leadingIcon: @Composable (() -> Unit)? = null,
customBackgroundColor: Color? = null,
customHintColor: Color? = null,
customLabelColor: Color? = null,
customCornerRadius: Float = 24f
) {
val AppColors = LocalAppTheme.current
var showPassword by remember { mutableStateOf(!password) }
var isFocused by remember { mutableStateOf(false) }
val backgroundColor = customBackgroundColor ?: AppColors.inputBackground
val hintColor = customHintColor ?: HintTextColor
val labelColor = customLabelColor ?: LabelTextColor
Column(modifier = modifier) {
label?.let {
Text(it, color = AppColors.secondaryText)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = it,
color = labelColor,
fontSize = 13.sp,
modifier = Modifier.padding(start = 8.dp, top = 8.dp, bottom = 8.dp)
)
}
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier
.clip(RoundedCornerShape(24.dp))
.background(AppColors.inputBackground)
.clip(RoundedCornerShape(customCornerRadius.dp))
.background(backgroundColor)
.border(
width = 2.dp,
color = if (error == null) Color.Transparent else AppColors.error,
shape = RoundedCornerShape(24.dp)
shape = RoundedCornerShape(customCornerRadius.dp)
)
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically){
BasicTextField(
value = text,
onValueChange = onValueChange,
modifier = Modifier
.weight(1f)
.onFocusChanged { focusState ->
isFocused = focusState.isFocused
},
textStyle = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.W500,
color = AppColors.text
),
keyboardOptions = KeyboardOptions(
keyboardType = if (password) KeyboardType.Password else KeyboardType.Text
),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
singleLine = true,
enabled = enabled,
cursorBrush = SolidColor(AppColors.text),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
){
leadingIcon?.let {
Box(modifier = Modifier.size(24.dp)) {
it()
}
Spacer(modifier = Modifier.size(12.dp))
}
Box(modifier = Modifier.weight(1f)) {
BasicTextField(
value = text,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focusState ->
isFocused = focusState.isFocused
},
textStyle = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.W400,
color = AppColors.text
),
keyboardOptions = KeyboardOptions(
keyboardType = if (password) KeyboardType.Password else KeyboardType.Email
),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
singleLine = true,
enabled = enabled,
cursorBrush = SolidColor(AppColors.text),
)
if (text.isEmpty() && hint != null) {
Text(
text = hint,
color = hintColor,
fontSize = 16.sp,
fontWeight = FontWeight.W400
)
}
}
if (password) {
// 暗色模式下图标为白色,否则使用默认颜色
val iconColor = if (AppState.darkMode) {
Color.White
} else {
PasswordIconColor
}
Image(
painter = painterResource(id = R.drawable.rider_pro_eye),
painter = painterResource(
id = if (showPassword) {
R.drawable.rider_pro_eye
} else {
R.mipmap.icon_eyes_closed_light
}
),
contentDescription = "Password",
modifier = Modifier
.size(18.dp)
.size(24.dp)
.noRippleClickable {
showPassword = !showPassword
},
colorFilter = ColorFilter.tint(AppColors.text)
colorFilter = ColorFilter.tint(iconColor)
)
}
}
if (text.isEmpty()) {
hint?.let {
Text(it, modifier = Modifier.padding(start = 5.dp), color = AppColors.inputHint, fontWeight = FontWeight.W600)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(

View File

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

View File

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

View File

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

View File

@@ -17,15 +17,19 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -42,7 +46,7 @@ import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.favourite.FavouriteListViewModel.refreshPager
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToPost
import com.aiosman.ravenow.ui.network.ReloadButton
import com.aiosman.ravenow.ui.network.NetworkErrorContent
import com.aiosman.ravenow.utils.NetworkUtils
@OptIn(ExperimentalMaterialApi::class)
@@ -84,43 +88,11 @@ fun FavouriteListPage() {
var moments = dataFlow.collectAsLazyPagingItems()
if (!isNetworkAvailable) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top=149.dp),
contentAlignment = Alignment.TopCenter
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.mipmap.invalid_name_10),
contentDescription = "network error",
modifier = Modifier.size(181.dp)
)
Spacer(modifier = Modifier.size(24.dp))
Text(
text = stringResource(R.string.friend_chat_no_network_title),
color = AppColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(R.string.friend_chat_no_network_subtitle),
color = AppColors.secondaryText,
fontSize = 14.sp,
fontWeight = FontWeight.W400
)
Spacer(modifier = Modifier.height(16.dp))
ReloadButton(
onClick = {
model.refreshPager(force = true)
}
)
NetworkErrorContent(
onReload = {
model.refreshPager(force = true)
}
}
)
} else if(moments.itemCount == 0) {
Box(
modifier = Modifier
@@ -134,8 +106,8 @@ fun FavouriteListPage() {
) {
Image(
painter = painterResource(
id = if (com.aiosman.ravenow.AppState.darkMode) R.mipmap.invalid_dark
else R.mipmap.invalid_name_1),
id = if (com.aiosman.ravenow.AppState.darkMode) R.mipmap.empty_img
else R.mipmap.empty_img),
contentDescription = "No favourites",
modifier = Modifier.size(181.dp, 153.dp)
)
@@ -155,6 +127,17 @@ fun FavouriteListPage() {
) {
items(moments.itemCount) { idx ->
val momentItem = moments[idx] ?: return@items
// 获取缩略图URL优先使用图片如果没有图片则使用视频缩略图
val thumbnailUrl = when {
momentItem.images.isNotEmpty() -> momentItem.images[0].thumbnail
momentItem.videos != null && momentItem.videos.isNotEmpty() -> {
momentItem.videos.first().thumbnailUrl ?: momentItem.videos.first().thumbnailDirectUrl
}
else -> null
}
if (thumbnailUrl == null) return@items
Box(
modifier = Modifier
.fillMaxWidth()
@@ -169,14 +152,17 @@ fun FavouriteListPage() {
}
) {
CustomAsyncImage(
imageUrl = momentItem.images[0].thumbnail,
imageUrl = thumbnailUrl,
contentDescription = "",
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
context = context
)
if (momentItem.images.size > 1) {
val isVideoMoment = momentItem.images.isEmpty() && !momentItem.videos.isNullOrEmpty()
if (momentItem.images.size > 1 || (momentItem.videos?.size ?: 0) > 1) {
Box(
modifier = Modifier
.padding(top = 8.dp, end = 8.dp)
@@ -189,6 +175,31 @@ fun FavouriteListPage() {
)
}
}
if (isVideoMoment) {
Box(
modifier = Modifier
.padding(top = 8.dp, end = 8.dp)
.align(Alignment.TopEnd)
) {
Box(
modifier = Modifier
.size(24.dp)
.background(
color = Color.Black.copy(alpha = 0.4f),
shape = RoundedCornerShape(12.dp)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "",
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
}

View File

@@ -24,6 +24,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -40,7 +42,7 @@ import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
import kotlinx.coroutines.launch
import com.aiosman.ravenow.utils.NetworkUtils
import com.aiosman.ravenow.ui.network.ReloadButton
import com.aiosman.ravenow.ui.network.NetworkErrorContent
@OptIn(ExperimentalMaterialApi::class)
@Composable
@@ -74,43 +76,11 @@ fun FollowerListScreen(userId: Int) {
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current)
if (!isNetworkAvailable) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = 149.dp),
contentAlignment = Alignment.TopCenter
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.mipmap.invalid_name_10),
contentDescription = "network error",
modifier = Modifier.size(181.dp)
)
Spacer(modifier = Modifier.size(24.dp))
androidx.compose.material.Text(
text = stringResource(R.string.friend_chat_no_network_title),
color = appColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.size(8.dp))
androidx.compose.material.Text(
text = stringResource(R.string.friend_chat_no_network_subtitle),
color = appColors.text,
fontSize = 14.sp,
fontWeight = FontWeight.W400
)
Spacer(modifier = Modifier.height(16.dp))
ReloadButton(
onClick = {
model.loadData(userId, true)
}
)
NetworkErrorContent(
onReload = {
model.loadData(userId, true)
}
}
)
} else if (users.itemCount == 0) {
Box(
modifier = Modifier
@@ -123,25 +93,20 @@ fun FollowerListScreen(userId: Int) {
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(
id = if(AppState.darkMode) R.mipmap.frame_4
else R.mipmap.invalid_name_8),
painter = painterResource(id = R.mipmap.frame_31),
contentDescription = null,
modifier = Modifier.size(181.dp, 153.dp)
)
Spacer(modifier = Modifier.size(9.dp)) // 调整间距为9dp
androidx.compose.material.Text(
text = "还没有人关注你呢",
text = stringResource(R.string.awaiting_traveler),
color = appColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.size(8.dp))
androidx.compose.material.Text(
text = "试着发信号出来,某人就会被吸引啦~",
color = appColors.text,
fontSize = 14.sp,
fontWeight = FontWeight.W400
fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}

View File

@@ -21,10 +21,13 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.paging.compose.collectAsLazyPagingItems
@@ -38,7 +41,7 @@ import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.FollowButton
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.network.ReloadButton
import com.aiosman.ravenow.ui.network.NetworkErrorContent
import kotlinx.coroutines.launch
import com.aiosman.ravenow.utils.NetworkUtils
@@ -46,13 +49,14 @@ import com.aiosman.ravenow.utils.NetworkUtils
* 关注消息列表
*/
@Composable
fun FollowerNoticeScreen() {
fun FollowerNoticeScreen(includeStatusBarPadding: Boolean = true) {
val scope = rememberCoroutineScope()
val AppColors = LocalAppTheme.current
StatusBarMaskLayout(
modifier = Modifier.background(color = AppColors.background).padding(horizontal = 16.dp),
darkIcons = !AppState.darkMode,
maskBoxBackgroundColor = AppColors.background
maskBoxBackgroundColor = AppColors.background,
includeStatusBarPadding = includeStatusBarPadding
) {
val model = FollowerNoticeViewModel
var dataFlow = model.followerItemsFlow
@@ -64,43 +68,11 @@ fun FollowerNoticeScreen() {
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current)
if (!isNetworkAvailable) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top=149.dp),
contentAlignment = Alignment.TopCenter
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.mipmap.invalid_name_10),
contentDescription = "network error",
modifier = Modifier.size(181.dp)
)
Spacer(modifier = Modifier.size(24.dp))
androidx.compose.material.Text(
text = stringResource(R.string.friend_chat_no_network_title),
color = AppColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.size(8.dp))
androidx.compose.material.Text(
text = stringResource(R.string.friend_chat_no_network_subtitle),
color = AppColors.text,
fontSize = 14.sp,
fontWeight = FontWeight.W400
)
Spacer(modifier = Modifier.height(16.dp))
ReloadButton(
onClick = {
model.reload(force = true)
}
)
NetworkErrorContent(
onReload = {
model.reload(force = true)
}
}
)
} else if (followers.itemCount == 0) {
Box(
modifier = Modifier
@@ -113,26 +85,21 @@ fun FollowerNoticeScreen() {
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(
id = if(AppState.darkMode) R.mipmap.frame_4
else R.mipmap.invalid_name_8),
painter = painterResource(id = R.mipmap.invalid_name_5),
contentDescription = "No Followers",
modifier = Modifier
.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(
text = "还没有人关注你呢",
text = stringResource(R.string.no_one_pinged_yet),
color = AppColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.size(8.dp))
androidx.compose.material.Text(
text = "试着发信号出来,某人就会被吸引啦~",
color = AppColors.text,
fontSize = 14.sp,
fontWeight = FontWeight.W400
fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
@@ -174,35 +141,57 @@ fun FollowItem(
val AppColors = LocalAppTheme.current
val context = LocalContext.current
val navController = LocalNavController.current
Box(
val followText = stringResource(R.string.followed_you)
Row(
modifier = Modifier
.padding(vertical = 16.dp)
.noRippleClickable {
navController.navigate(
NavigationRoute.AccountProfile.route.replace(
"{id}",
userId.toString()
)
)
}
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 左侧头像区域
CustomAsyncImage(
context = context,
imageUrl = avatar,
contentDescription = nickname,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.noRippleClickable {
navController.navigate(
NavigationRoute.AccountProfile.route.replace(
"{id}",
userId.toString()
)
)
}
)
// 右侧内容区域
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(1f)
.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
CustomAsyncImage(
context = context,
imageUrl = avatar,
contentDescription = nickname,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
)
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(nickname, fontWeight = FontWeight.Bold, fontSize = 16.sp, color = AppColors.text)
Text(
text = nickname,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = AppColors.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = followText,
fontSize = 14.sp,
color = AppColors.secondaryText,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (!isFollowing && userId != AppState.UserId) {
FollowButton(

View File

@@ -24,6 +24,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -38,7 +40,7 @@ import com.aiosman.ravenow.exp.viewModelFactory
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
import com.aiosman.ravenow.ui.network.ReloadButton
import com.aiosman.ravenow.ui.network.NetworkErrorContent
import kotlinx.coroutines.launch
import com.aiosman.ravenow.utils.NetworkUtils
@@ -76,43 +78,11 @@ fun FollowingListScreen(userId: Int) {
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current)
if (!isNetworkAvailable) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top=149.dp),
contentAlignment = Alignment.TopCenter
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.mipmap.invalid_name_10),
contentDescription = "network error",
modifier = Modifier.size(181.dp)
)
Spacer(modifier = Modifier.size(24.dp))
androidx.compose.material.Text(
text = stringResource(R.string.friend_chat_no_network_title),
color = appColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.size(8.dp))
androidx.compose.material.Text(
text = stringResource(R.string.friend_chat_no_network_subtitle),
color = appColors.secondaryText,
fontSize = 14.sp,
fontWeight = FontWeight.W400
)
Spacer(modifier = Modifier.height(16.dp))
ReloadButton(
onClick = {
model.loadData(userId, true)
}
)
NetworkErrorContent(
onReload = {
model.loadData(userId, true)
}
}
)
} else if(users.itemCount == 0) {
Box(
modifier = Modifier
@@ -125,25 +95,20 @@ fun FollowingListScreen(userId: Int) {
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(
id = if(AppState.darkMode) R.mipmap.frame_3
else R.mipmap.invalid_name_9),
painter = painterResource(id = R.mipmap.frame_31),
contentDescription = null,
modifier = Modifier.size(181.dp, 153.dp)
)
Spacer(modifier = Modifier.size(9.dp)) // 调整间距为9dp
androidx.compose.material.Text(
text = "还没有关注任何灵魂",
text = stringResource(R.string.awaiting_traveler),
color = appColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.size(8.dp))
androidx.compose.material.Text(
text = "探索一下,总有一个你想靠近的光点 ✨",
color = appColors.secondaryText,
fontSize = 14.sp,
fontWeight = FontWeight.W400
fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}

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.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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -78,10 +82,10 @@ fun AiAgentListScreen(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "加载中...",
color = AppColors.secondaryText,
fontSize = 14.sp
LottieAnimation(
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
iterations = LottieConstants.IterateForever,
modifier = Modifier.size(80.dp)
)
}
} else {
@@ -111,10 +115,10 @@ fun AiAgentListScreen(
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "加载更多...",
color = AppColors.secondaryText,
fontSize = 14.sp
LottieAnimation(
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
iterations = LottieConstants.IterateForever,
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.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
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.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.collectAsState
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
@@ -27,11 +32,15 @@ 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.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.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.PointService
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.TabItem
@@ -90,6 +99,28 @@ fun CreateGroupChatScreen() {
}
val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
var showSelectTipsDialog by remember { mutableStateOf(false) }
// 自动隐藏“请选择群成员并输入群名称”提示弹窗
LaunchedEffect(showSelectTipsDialog) {
if (showSelectTipsDialog) {
kotlinx.coroutines.delay(2000)
showSelectTipsDialog = false
}
}
// 获取费用和余额信息
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) {
systemUiController.setNavigationBarColor(Color.Transparent)
@@ -367,33 +398,47 @@ fun CreateGroupChatScreen() {
}
}
// Tab切换
// Tab切换和成员数量显示
Row(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.Bottom
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TabItem(
text = stringResource(R.string.chat_ai),
isSelected = pagerState.currentPage == 0,
onClick = {
scope.launch {
pagerState.animateScrollToPage(0)
// Tab左对齐
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
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)
)
TabSpacer()
TabItem(
text = stringResource(R.string.chat_friend),
isSelected = pagerState.currentPage == 1,
onClick = {
scope.launch {
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,11 +481,171 @@ fun CreateGroupChatScreen() {
}
}
// 创建群聊按钮 - 固定在底部
Button(
onClick = {
// 创建群聊逻辑
if (selectedMembers.isNotEmpty()) {
// 余额和扣减积分显示(创建按钮上方)
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
)
}
}
// 创建群聊按钮 - 固定在底部(启用时使用渐变背景)
val isCreateEnabled =
groupName.text.isNotEmpty() && selectedMembers.isNotEmpty() && !CreateGroupChatViewModel.isLoading
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
start = 16.dp,
end = 16.dp,
top = buttonTopPadding,
bottom = navigationBarPadding + 16.dp
)
.let { baseModifier ->
if (isCreateEnabled) {
baseModifier.background(
brush = Brush.linearGradient(
colors = listOf(
Color(0x997c45ed),
Color(0x997c68ef),
Color(0x997bd8f8)
)
),
shape = RoundedCornerShape(24.dp)
)
} else {
baseModifier
}
}
) {
Button(
onClick = {
// 创建群聊逻辑
if (selectedMembers.isNotEmpty()) {
// 检查是否超过上限
if (selectedMembers.size > maxMemberLimit) {
CreateGroupChatViewModel.showError(
context.getString(
R.string.create_group_chat_exceed_limit,
maxMemberLimit
)
)
return@Button
}
// 如果费用大于0显示确认弹窗
if (cost > 0) {
CreateGroupChatViewModel.showConfirmDialog()
} else {
// 费用为0直接创建
scope.launch {
val success = CreateGroupChatViewModel.createGroupChat(
groupName = groupName.text,
selectedMembers = selectedMembers,
context = context
)
if (success) {
navController.popBackStack()
}
}
}
}
},
modifier = Modifier
.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = if (isCreateEnabled) Color.Transparent else AppColors.main,
contentColor = AppColors.mainText,
disabledContainerColor = AppColors.disabledBackground,
disabledContentColor = AppColors.text
),
shape = RoundedCornerShape(24.dp),
enabled = isCreateEnabled
) {
if (CreateGroupChatViewModel.isLoading) {
Text(
text = stringResource(R.string.agent_createing),
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
} else {
Text(
text = stringResource(R.string.create_group_chat),
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
}
}
// 禁用状态下拦截点击并弹出提示
if (!isCreateEnabled) {
Box(
modifier = Modifier
.matchParentSize()
.noRippleClickable {
showSelectTipsDialog = true
}
)
}
}
}
// 请选择群成员并输入群名称 提示弹窗
if (showSelectTipsDialog) {
Dialog(
onDismissRequest = { showSelectTipsDialog = false },
properties = DialogProperties(dismissOnClickOutside = true, dismissOnBackPress = true)
) {
Box(
modifier = Modifier
.background(color = AppColors.background, shape = RoundedCornerShape(16.dp))
.padding(horizontal = 20.dp, vertical = 16.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.create_group_chat_select_members_and_name),
fontSize = 16.sp,
fontWeight = FontWeight.W600,
color = Color(0xFF7C45ED),
textAlign = TextAlign.Center
)
}
}
}
}
// 消费确认弹窗
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,
@@ -453,33 +658,10 @@ fun CreateGroupChatScreen() {
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = navigationBarPadding + 16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = AppColors.main,
contentColor = AppColors.mainText,
disabledContainerColor = AppColors.disabledBackground,
disabledContentColor = AppColors.text
),
shape = RoundedCornerShape(24.dp),
enabled = groupName.text.isNotEmpty() && selectedMembers.isNotEmpty() && !CreateGroupChatViewModel.isLoading
) {
if (CreateGroupChatViewModel.isLoading) {
Text(
text = "创建中...",
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
} else {
Text(
text = stringResource(R.string.create_group_chat),
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
onCancel = {
CreateGroupChatViewModel.hideConfirmDialog()
}
}
)
}
// 居中显示的错误提示弹窗
@@ -496,7 +678,7 @@ fun CreateGroupChatScreen() {
horizontalAlignment = Alignment.CenterHorizontally, // 水平居中
verticalArrangement = Arrangement.Center // 垂直居中
) {
androidx.compose.material3.Card(
Card(
modifier = Modifier
.fillMaxWidth(0.8f),
shape = RoundedCornerShape(8.dp)
@@ -508,7 +690,7 @@ fun CreateGroupChatScreen() {
.fillMaxWidth(),
color = Color.Red,
fontSize = 14.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
textAlign = TextAlign.Center
)
}
}
@@ -516,3 +698,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.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.PointService
import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.CommentEntity
import com.aiosman.ravenow.exp.formatChatTime
import com.aiosman.ravenow.ui.NavigationRoute
@@ -35,6 +37,7 @@ object CreateGroupChatViewModel : ViewModel() {
// 状态管理
var isLoading by mutableStateOf(false)
var errorMessage by mutableStateOf<String?>(null)
var showConfirmDialog by mutableStateOf(false)
// 创建群聊
suspend fun createGroupChat(
@@ -55,13 +58,13 @@ object CreateGroupChatViewModel : ViewModel() {
true
} else {
isLoading = false
val errorMsg = "创建群聊失败: ${response.message()}"
val errorMsg = context.getString(R.string.create_group_chat_failed, response.message() ?: "")
showToast(errorMsg)
false
}
} catch (e: Exception) {
isLoading = false
val errorMsg = "创建群聊失败: ${e.message}"
val errorMsg = context.getString(R.string.create_group_chat_failed, e.message ?: "")
showToast(errorMsg)
false
}
@@ -75,6 +78,11 @@ object CreateGroupChatViewModel : ViewModel() {
}
}
// 显示错误信息(公开方法)
fun showError(message: String) {
showToast(message)
}
// 清除错误信息
fun clearError() {
errorMessage = null
@@ -106,4 +114,59 @@ object CreateGroupChatViewModel : ViewModel() {
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.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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -78,10 +82,10 @@ fun FriendListScreen(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "加载中...",
color = AppColors.secondaryText,
fontSize = 14.sp
LottieAnimation(
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
iterations = LottieConstants.IterateForever,
modifier = Modifier.size(80.dp)
)
}
} else {
@@ -111,10 +115,10 @@ fun FriendListScreen(
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "加载更多...",
color = AppColors.secondaryText,
fontSize = 14.sp
LottieAnimation(
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
iterations = LottieConstants.IterateForever,
modifier = Modifier.size(80.dp)
)
}
}

View File

@@ -2,6 +2,7 @@ package com.aiosman.ravenow.ui.group
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -40,9 +41,13 @@ 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.PointsPaymentDialog
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.index.NavItem
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToAddGroupMember
import com.aiosman.ravenow.ui.navigateToGroupMembers
import com.aiosman.ravenow.ui.navigateToGroupProfileSettings
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@@ -64,6 +69,8 @@ fun GroupChatInfoScreen(groupId: String) {
var showAddMemoryDialog by remember { mutableStateOf(false) }
var showMemoryManageDialog 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 memoryManageSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -168,7 +175,7 @@ fun GroupChatInfoScreen(groupId: String) {
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${viewModel.groupInfo?.memberCount ?: 0}",
text = "${viewModel.groupInfo?.memberCount ?: 0}${stringResource(R.string.people)}",
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text.copy(alpha = 0.7f),
fontSize = 11.sp
@@ -182,38 +189,42 @@ fun GroupChatInfoScreen(groupId: String) {
item {
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.padding(horizontal = 24.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
// 添加其他人
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.noRippleClickable {
// TODO: 实现添加其他人功能
}
) {
Box(
modifier = Modifier
.size(30.dp)
.clip(CircleShape),
contentAlignment = Alignment.Center
if (viewModel.groupInfo?.isCreator == true) {
// 添加其他人
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.noRippleClickable {
navController.navigateToAddGroupMember(groupId, viewModel.groupInfo?.groupName)
}
) {
Image(
painter = painterResource(R.drawable.rider_pro_add_other),
modifier = Modifier.size(20.dp),
contentDescription = null,
colorFilter = ColorFilter.tint(
AppColors.text)
Box(
modifier = Modifier
.size(30.dp)
.clip(CircleShape),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(R.drawable.rider_pro_add_other),
modifier = Modifier.size(20.dp),
contentDescription = null,
colorFilter = ColorFilter.tint(
AppColors.text)
)
}
Spacer(modifier = Modifier.height(5.dp))
Text(
text = stringResource(R.string.group_chat_info_add_member),
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text,
fontSize = 11.sp
)
)
}
Spacer(modifier = Modifier.height(5.dp))
Text(
text = stringResource(R.string.group_chat_info_add_member),
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text,
fontSize = 11.sp
)
)
}
// 通知设置
@@ -253,11 +264,11 @@ fun GroupChatInfoScreen(groupId: String) {
)
}
// 退出群聊
// 分享群聊
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.noRippleClickable {
// TODO: 实现退出群聊功能
// TODO: 实现分享功能
}
) {
Box(
@@ -267,7 +278,7 @@ fun GroupChatInfoScreen(groupId: String) {
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(R.drawable.group_info_edit),
painter = painterResource(R.mipmap.icon_share),
modifier = Modifier.size(20.dp),
contentDescription = null,
colorFilter = ColorFilter.tint(AppColors.text)
@@ -285,27 +296,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 {
@@ -313,16 +303,16 @@ fun GroupChatInfoScreen(groupId: String) {
Column(
modifier = Modifier
.fillMaxWidth()
.border(1.dp, AppColors.decentBackground, RoundedCornerShape(12.dp))
.clip(RoundedCornerShape(12.dp))
.background(AppColors.decentBackground.copy(alpha = 0.28f))
.background(AppColors.background)
.padding(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(R.drawable.group_info_edit),
modifier = Modifier.size(16.dp),
painter = painterResource(R.mipmap.icons_brain),
modifier = Modifier.size(20.dp),
contentDescription = null,
colorFilter = ColorFilter.tint(AppColors.text)
)
Spacer(modifier = Modifier.width(6.dp))
Column(modifier = Modifier.weight(1f)) {
@@ -331,7 +321,6 @@ fun GroupChatInfoScreen(groupId: String) {
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text,
fontSize = 15.sp,
fontWeight = FontWeight.Bold
)
)
Spacer(modifier = Modifier.height(2.dp))
@@ -353,7 +342,7 @@ fun GroupChatInfoScreen(groupId: String) {
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(20.dp))
.background(AppColors.background)
.background(AppColors.decentBackground)
.padding(vertical = 8.dp)
.noRippleClickable {
showAddMemoryDialog = true
@@ -372,7 +361,7 @@ fun GroupChatInfoScreen(groupId: String) {
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(20.dp))
.background(AppColors.background)
.background(AppColors.decentBackground)
.padding(vertical = 8.dp)
.noRippleClickable {
showMemoryManageDialog = true
@@ -395,78 +384,84 @@ fun GroupChatInfoScreen(groupId: String) {
item {
Spacer(modifier = Modifier.height(13.dp))
// 设置聊天主题
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.padding(12.dp)
.noRippleClickable {
// TODO: 实现设置聊天主题功能
},
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.drawable.group_info_edit),
modifier = Modifier.size(20.dp),
contentDescription = null,
colorFilter = ColorFilter.tint(
AppColors.text)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = stringResource(R.string.group_chat_info_group_settings),
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text,
fontSize = 15.sp
),
modifier = Modifier.weight(1f)
)
Image(
painter = painterResource(R.drawable.rave_now_nav_right),
modifier = Modifier.size(16.dp),
contentDescription = null,
)
}
// 群可见性
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.padding(12.dp)
.noRippleClickable {
showVisibilityDialog = true
},
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.mipmap.rider_pro_change_password),
modifier = Modifier.size(20.dp),
contentDescription = null,
colorFilter = ColorFilter.tint(AppColors.text)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = stringResource(R.string.group_chat_info_group_visibility),
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text,
fontSize = 15.sp
),
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(R.string.group_chat_info_locked),
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text.copy(alpha = 0.5f),
fontSize = 11.sp
// 仅当当前用户是群聊创建者时显示以下组件
if (viewModel.groupInfo?.isCreator == true) {
// 群资料设置
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.padding(12.dp)
.noRippleClickable {
navController.navigateToGroupProfileSettings(groupId)
},
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.mipmap.fengm),
modifier = Modifier.size(20.dp),
contentDescription = null,
colorFilter = ColorFilter.tint(
AppColors.text)
)
)
Image(
painter = painterResource(R.drawable.rave_now_nav_right),
modifier = Modifier.size(16.dp),
contentDescription = null,
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = stringResource(R.string.group_chat_info_group_settings),
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text,
fontSize = 15.sp
),
modifier = Modifier.weight(1f)
)
Image(
painter = painterResource(R.drawable.rave_now_nav_right),
modifier = Modifier.size(16.dp),
contentDescription = null,
)
}
// 群可见性
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.padding(12.dp)
.noRippleClickable {
showVisibilityDialog = true
},
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.mipmap.rider_pro_change_password),
modifier = Modifier.size(20.dp),
contentDescription = null,
colorFilter = ColorFilter.tint(AppColors.text)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = stringResource(R.string.group_chat_info_group_visibility),
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text,
fontSize = 15.sp
),
modifier = Modifier.weight(1f)
)
// 未解锁时才显示"待解锁"
if (viewModel.groupInfo?.privateFeePaid != true) {
Text(
text = stringResource(R.string.group_chat_info_locked),
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text.copy(alpha = 0.5f),
fontSize = 11.sp
)
)
}
Image(
painter = painterResource(R.drawable.rave_now_nav_right),
modifier = Modifier.size(16.dp),
contentDescription = null,
)
}
}
// 成员管理
@@ -476,20 +471,26 @@ fun GroupChatInfoScreen(groupId: String) {
.clip(RoundedCornerShape(8.dp))
.padding(12.dp)
.noRippleClickable {
// 静态占位
navController.navigateToGroupMembers(groupId)
},
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.drawable.group_info_users),
modifier = Modifier.size(20.dp),
painter = painterResource(R.mipmap.icons_users),
modifier = Modifier.size(25.dp),
contentDescription = null,
colorFilter = ColorFilter.tint(
AppColors.text)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = stringResource(R.string.group_chat_info_member_manage),
text = stringResource(
if (viewModel.groupInfo?.isCreator == true) {
R.string.group_chat_info_member_manage
} else {
R.string.group_members_title
}
),
style = androidx.compose.ui.text.TextStyle(
color = AppColors.text,
fontSize = 15.sp
@@ -512,8 +513,8 @@ fun GroupChatInfoScreen(groupId: String) {
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.drawable.group_info_edit),
modifier = Modifier.size(20.dp),
painter = painterResource(R.mipmap.iconsgallery),
modifier = Modifier.size(25.dp),
contentDescription = null,
colorFilter = ColorFilter.tint(
AppColors.text)
@@ -533,36 +534,70 @@ fun GroupChatInfoScreen(groupId: String) {
contentDescription = null,
)
}
// 解散群聊
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.padding(12.dp)
.noRippleClickable { },
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.drawable.group_info_exit),
modifier = Modifier.size(20.dp),
contentDescription = null,
colorFilter = ColorFilter.tint(Color(0xFFFF3B30))
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = stringResource(R.string.group_chat_info_dissolve),
style = androidx.compose.ui.text.TextStyle(
color = Color(0xFFFF3B30),
fontSize = 15.sp
),
modifier = Modifier.weight(1f)
)
Image(
painter = painterResource(R.drawable.rave_now_nav_right),
modifier = Modifier.size(18.dp),
contentDescription = null,
colorFilter = ColorFilter.tint(Color(0xFFFF3B30))
)
if (viewModel.groupInfo?.isCreator == true) {
// 解散群聊(仅群主)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.padding(12.dp)
.noRippleClickable { },
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(3.dp))
Image(
painter = painterResource(R.mipmap.iconslogout),
modifier = Modifier.size(20.dp),
contentDescription = null,
colorFilter = ColorFilter.tint(Color(0xFFFF3B30))
)
Spacer(modifier = Modifier.width(11.dp))
Text(
text = stringResource(R.string.group_chat_info_dissolve),
style = androidx.compose.ui.text.TextStyle(
color = Color(0xFFFF3B30),
fontSize = 15.sp
),
modifier = Modifier.weight(1f)
)
Image(
painter = painterResource(R.drawable.rave_now_nav_right),
modifier = Modifier.size(18.dp),
contentDescription = null
)
}
} else {
// 退出群聊(非群主)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.padding(12.dp)
.noRippleClickable { },
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(3.dp))
Image(
painter = painterResource(R.drawable.group_info_exit),
modifier = Modifier.size(20.dp),
contentDescription = null,
colorFilter = ColorFilter.tint(Color(0xFFFF3B30))
)
Spacer(modifier = Modifier.width(11.dp))
Text(
text = stringResource(R.string.group_chat_info_quit),
style = androidx.compose.ui.text.TextStyle(
color = Color(0xFFFF3B30),
fontSize = 15.sp
),
modifier = Modifier.weight(1f)
)
Image(
painter = painterResource(R.drawable.rave_now_nav_right),
modifier = Modifier.size(18.dp),
contentDescription = null
)
}
}
}
}
@@ -590,11 +625,50 @@ fun GroupChatInfoScreen(groupId: String) {
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
) {
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) {
ModalBottomSheet(
@@ -646,6 +720,10 @@ fun GroupChatInfoScreen(groupId: String) {
},
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
) {
// 立即展开到全屏,避免逐渐变高的动画
LaunchedEffect(Unit) {
memoryManageSheetState.expand()
}
GroupMemoryManageContent(
groupId = groupId,
viewModel = viewModel,
@@ -878,8 +956,13 @@ fun AddGroupMemoryDialog(
text = "",
fontSize = 13.sp
)
val memoryCost = viewModel.addMemoryCost
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(
fontSize = 13.sp,
color = Color.Black
@@ -978,12 +1061,16 @@ fun AddGroupMemoryDialog(
@Composable
fun GroupVisibilityDialog(
onDismiss: () -> Unit
viewModel: GroupChatInfoViewModel,
onDismiss: () -> Unit,
onConfirmPrivate: (Boolean) -> Unit
) {
val AppColors = LocalAppTheme.current
var isPrivate by remember { mutableStateOf(false) }
val balance = 482
val unlockCost = 500
val currentTrtcType = viewModel.groupInfo?.trtcType ?: "Public"
val isPrivateFeePaid = viewModel.groupInfo?.privateFeePaid == true
var isPrivate by remember { mutableStateOf(currentTrtcType == "Private") }
val balance = viewModel.pointsBalance ?: 0
val unlockCost = viewModel.privateGroupCost ?: 0
Column(
modifier = Modifier
@@ -1038,14 +1125,15 @@ fun GroupVisibilityDialog(
VisibilityOptionItem(
title = stringResource(R.string.group_chat_info_private_group),
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,
onClick = { isPrivate = true }
)
Spacer(modifier = Modifier.height(16.dp))
// 余额与费用
// 余额与费用(仅未解锁时显示)
if (!isPrivateFeePaid) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -1068,6 +1156,7 @@ fun GroupVisibilityDialog(
}
Spacer(modifier = Modifier.height(8.dp))
}
// 完成按钮
Box(
@@ -1084,7 +1173,9 @@ fun GroupVisibilityDialog(
)
)
)
.noRippleClickable { onDismiss() },
.noRippleClickable {
onConfirmPrivate(isPrivate)
},
contentAlignment = Alignment.Center
) {
Text(
@@ -1093,12 +1184,15 @@ fun GroupVisibilityDialog(
)
}
// 仅未解锁时显示充值提示
if (!isPrivateFeePaid) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.group_chat_info_recharge_hint),
style = TextStyle(fontSize = 12.sp, color = Color(0x4D3C3C43)),
textAlign = TextAlign.Center
)
}
}
}

View File

@@ -9,14 +9,17 @@ import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.ChatState
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.CreatePromptRuleRequestBody
import com.aiosman.ravenow.data.api.PromptRule
import com.aiosman.ravenow.data.api.PromptRuleQuota
import com.aiosman.ravenow.data.RoomService
import com.aiosman.ravenow.data.RoomServiceImpl
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.GroupInfo
import com.aiosman.ravenow.entity.GroupMember
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class GroupChatInfoViewModel(
@@ -33,30 +36,26 @@ class GroupChatInfoViewModel(
val notificationStrategy get() = chatNotification?.strategy ?: "default"
// 记忆管理相关状态
var memoryQuota by mutableStateOf<PromptRuleQuota?>(null)
var memoryList by mutableStateOf<List<PromptRule>>(emptyList())
var memoryQuota by mutableStateOf<RoomRuleQuotaEntity?>(null)
var memoryList by mutableStateOf<List<RoomRuleEntity>>(emptyList())
var isLoadingMemory by mutableStateOf(false)
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 {
loadGroupInfo()
loadPromptOpenId()
}
/**
* 获取群聊中智能体的 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)
}
}
loadVisibilityInfo()
loadMemoryCost()
}
suspend fun updateNotificationStrategy(strategy: String) {
val result = ChatState.updateChatNotification(groupId.hashCode(), strategy)
@@ -92,7 +91,9 @@ class GroupChatInfoViewModel(
"${ApiClient.BASE_API_URL+"/outside"}${it.avatar}"+"?token="+"${AppStore.token}"
},
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
)
}
@@ -105,57 +106,27 @@ class GroupChatInfoViewModel(
}
/**
* 添加群记忆
* 添加群记忆(房间规则)
* @param memoryText 记忆内容
* @param promptOpenId 智能体的 OpenID可选如果不提供则从群聊信息中获取
*/
fun addGroupMemory(memoryText: String, promptOpenId: String? = null) {
fun addGroupMemory(memoryText: String) {
viewModelScope.launch {
try {
isAddingMemory = true
addMemoryError = null
addMemorySuccess = false
// 如果没有提供 promptOpenId需要先获取群聊的智能体信息
val openId = promptOpenId ?: run {
// 通过 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 = CreatePromptRuleRequestBody(
// 使用房间规则接口创建群记忆
roomService.createRoomRule(
rule = memoryText,
openId = openId
trtcId = groupId
)
val response = ApiClient.api.createPromptRule(requestBody)
if (response.isSuccessful) {
addMemorySuccess = true
Log.d("GroupChatInfoViewModel", "群记忆添加成功")
// 刷新记忆列表和配额
loadMemoryQuota(openId)
loadMemoryList(openId)
} else {
val errorResponse = parseErrorResponse(response.errorBody())
val errorMessage = errorResponse?.toServiceException()?.message
?: "添加群记忆失败: ${response.code()}"
throw Exception(errorMessage)
}
addMemorySuccess = true
Log.d("GroupChatInfoViewModel", "群记忆添加成功")
// 刷新记忆列表和配额
loadMemoryQuota()
loadMemoryList()
} catch (e: Exception) {
addMemoryError = e.message ?: "添加群记忆失败"
Log.e("GroupChatInfoViewModel", "添加群记忆失败: ${e.message}", e)
@@ -166,38 +137,16 @@ class GroupChatInfoViewModel(
}
/**
* 获取记忆配额信息
* 获取记忆配额信息(房间规则配额)
*/
fun loadMemoryQuota(openId: String? = null) {
fun loadMemoryQuota() {
viewModelScope.launch {
try {
isLoadingMemory = true
memoryError = null
val targetOpenId = openId ?: promptOpenId
if (targetOpenId.isNullOrBlank()) {
// 如果还没有获取到 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.getPromptRuleQuota(fetchedOpenId)
if (quotaResponse.isSuccessful) {
memoryQuota = quotaResponse.body()?.data
} else {
throw Exception("获取配额信息失败: ${quotaResponse.code()}")
}
} else {
val quotaResponse = ApiClient.api.getPromptRuleQuota(targetOpenId)
if (quotaResponse.isSuccessful) {
memoryQuota = quotaResponse.body()?.data
} else {
throw Exception("获取配额信息失败: ${quotaResponse.code()}")
}
}
// 使用房间规则接口获取配额
memoryQuota = roomService.getRoomRuleQuota(trtcId = groupId)
} catch (e: Exception) {
memoryError = e.message ?: "获取配额信息失败"
Log.e("GroupChatInfoViewModel", "获取配额信息失败: ${e.message}", e)
@@ -208,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 {
try {
isLoadingMemory = true
memoryError = null
val targetOpenId = openId ?: promptOpenId
if (targetOpenId.isNullOrBlank()) {
// 如果还没有获取到 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 listResponse = ApiClient.api.getPromptRuleList(fetchedOpenId, page = page, pageSize = pageSize)
if (listResponse.isSuccessful) {
memoryList = listResponse.body()?.data?.list ?: emptyList()
} else {
throw Exception("获取记忆列表失败: ${listResponse.code()}")
}
} else {
val listResponse = ApiClient.api.getPromptRuleList(targetOpenId, page = page, pageSize = pageSize)
if (listResponse.isSuccessful) {
memoryList = listResponse.body()?.data?.list ?: emptyList()
} else {
throw Exception("获取记忆列表失败: ${listResponse.code()}")
}
}
// 使用房间规则接口获取列表
val result = roomService.getRoomRuleList(
trtcId = groupId,
page = page,
pageSize = pageSize
)
memoryList = result.list
} catch (e: Exception) {
memoryError = e.message ?: "获取记忆列表失败"
Log.e("GroupChatInfoViewModel", "获取记忆列表失败: ${e.message}", e)
@@ -250,7 +182,7 @@ class GroupChatInfoViewModel(
}
/**
* 删除记忆
* 删除记忆(房间规则)
*/
fun deleteMemory(ruleId: Int) {
viewModelScope.launch {
@@ -258,19 +190,12 @@ class GroupChatInfoViewModel(
isLoadingMemory = true
memoryError = null
val response = ApiClient.api.deletePromptRule(ruleId)
if (response.isSuccessful) {
// 刷新记忆列表和配额
promptOpenId?.let { openId ->
loadMemoryQuota(openId)
loadMemoryList(openId)
}
} else {
val errorResponse = parseErrorResponse(response.errorBody())
val errorMessage = errorResponse?.toServiceException()?.message
?: "删除记忆失败: ${response.code()}"
throw Exception(errorMessage)
}
// 使用房间规则接口删除
roomService.deleteRoomRule(ruleId)
// 刷新记忆列表和配额
loadMemoryQuota()
loadMemoryList()
} catch (e: Exception) {
memoryError = e.message ?: "删除记忆失败"
Log.e("GroupChatInfoViewModel", "删除记忆失败: ${e.message}", e)
@@ -281,34 +206,23 @@ class GroupChatInfoViewModel(
}
/**
* 更新记忆
* 更新记忆(房间规则)
*/
fun updateMemory(ruleId: Int, newRuleText: String, targetOpenId: String? = null) {
fun updateMemory(ruleId: Int, newRuleText: String) {
viewModelScope.launch {
try {
isLoadingMemory = true
memoryError = null
val openId = targetOpenId ?: promptOpenId
?: throw Exception("无法获取智能体ID")
val requestBody = com.aiosman.ravenow.data.api.UpdatePromptRuleRequestBody(
// 使用房间规则接口更新
roomService.updateRoomRule(
id = ruleId,
rule = newRuleText,
openId = openId
rule = newRuleText
)
val response = ApiClient.api.updatePromptRule(requestBody)
if (response.isSuccessful) {
// 刷新记忆列表和配额
loadMemoryQuota(openId)
loadMemoryList(openId)
} else {
val errorResponse = parseErrorResponse(response.errorBody())
val errorMessage = errorResponse?.toServiceException()?.message
?: "更新记忆失败: ${response.code()}"
throw Exception(errorMessage)
}
// 刷新记忆列表和配额
loadMemoryQuota()
loadMemoryList()
} catch (e: Exception) {
memoryError = e.message ?: "更新记忆失败"
Log.e("GroupChatInfoViewModel", "更新记忆失败: ${e.message}", e)
@@ -317,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,432 @@
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))
// 菜单按钮
if (isAdmin) {
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 android.widget.Toast
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
fun GroupMemoryManageContent(
@@ -48,9 +51,6 @@ fun GroupMemoryManageContent(
onDismiss: () -> Unit = {}
) {
val AppColors = LocalAppTheme.current
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
val sheetHeight = screenHeight * 0.95f
val context = LocalContext.current
// 编辑记忆的状态 - 存储正在编辑的记忆ID
@@ -68,8 +68,7 @@ fun GroupMemoryManageContent(
Column(
modifier = Modifier
.fillMaxWidth()
.height(sheetHeight)
.fillMaxSize()
.background(Color(0xFFFAF9FB))
) {
// 顶部栏:返回按钮 + 标题 + 加号按钮
@@ -81,7 +80,7 @@ fun GroupMemoryManageContent(
) {
// 中间标题 - 绝对居中,不受其他组件影响
Text(
text = "记忆管理",
text = stringResource(R.string.group_chat_info_memory_manage2),
style = TextStyle(
color = Color.Black,
fontSize = 17.sp,
@@ -109,7 +108,7 @@ fun GroupMemoryManageContent(
colorFilter = ColorFilter.tint(Color.Black)
)
Text(
text = "返回",
text = stringResource(R.string.back_upper),
style = TextStyle(
color = Color.Black,
fontSize = 15.sp,
@@ -150,7 +149,7 @@ fun GroupMemoryManageContent(
) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), 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))
Text(
"${quota?.purchasedCount ?: 0}",
@@ -158,7 +157,7 @@ fun GroupMemoryManageContent(
)
}
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))
Text(
"${quota?.currentCount ?: 0}",
@@ -167,7 +166,7 @@ fun GroupMemoryManageContent(
}
}
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))
Text(
"50",
@@ -237,12 +236,12 @@ fun GroupMemoryManageContent(
Spacer(Modifier.height(10.dp))
Text(
text = "暂无记忆",
text = stringResource(R.string.no_memory),
style = TextStyle(color = Color.Black, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
)
Spacer(Modifier.height(6.dp))
Text(
text = "点击上方按钮添加群记忆",
text = stringResource(R.string.add_memory),
style = TextStyle(color = Color.Black, fontSize = 14.sp, fontWeight = FontWeight.Normal)
)
}
@@ -257,7 +256,7 @@ fun GroupMemoryManageContent(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditGroupMemoryDialog(
memory: com.aiosman.ravenow.data.api.PromptRule,
memory: com.aiosman.ravenow.entity.RoomRuleEntity,
viewModel: GroupChatInfoViewModel,
onDismiss: () -> Unit,
onUpdateMemory: (String) -> Unit
@@ -403,7 +402,7 @@ fun EditGroupMemoryDialog(
*/
@Composable
fun MemoryItem(
memory: com.aiosman.ravenow.data.api.PromptRule,
memory: com.aiosman.ravenow.entity.RoomRuleEntity,
isEditing: Boolean = false,
onEdit: () -> Unit = {},
onCancel: () -> Unit = {},
@@ -579,20 +578,44 @@ fun MemoryItem(
Spacer(modifier = Modifier.height(16.dp))
// 底部行:日期 + 编辑删除按钮
// 底部行:创建者信息 + 编辑删除按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
verticalAlignment = Alignment.CenterVertically
) {
// 日期文本 - 左侧
Text(
text = formattedDate,
style = TextStyle(
color = Color(0x993C3C43),
fontSize = 11.sp
// 创建者信息 - 左侧:头像 + 用户名 · 时间
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 圆形头像
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(

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(
onDismissRequest = onDismiss,
sheetState = sheetState,
windowInsets = BottomSheetDefaults.windowInsets,
containerColor = appColors.background,
dragHandle = null,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)

View File

@@ -1,6 +1,8 @@
package com.aiosman.ravenow.ui.index
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
@@ -11,15 +13,21 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
@@ -42,8 +50,10 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -56,10 +66,15 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import com.aiosman.ravenow.AppState
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.GuestLoginCheckOut
import com.aiosman.ravenow.GuestLoginCheckOutScene
@@ -121,153 +136,19 @@ fun IndexScreen() {
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = drawerState.isOpen,
scrimColor = Color.Black.copy(alpha = 0.6f),
drawerContent = {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Column(
modifier = Modifier
.requiredWidth(250.dp)
.fillMaxHeight()
.background(
AppColors.background
)
) {
Spacer(modifier = Modifier.height(88.dp))
NavItem(
iconRes = R.drawable.rave_now_nav_account,
label = stringResource(R.string.account_and_security),
modifier = Modifier.noRippleClickable {
coroutineScope.launch {
drawerState.close()
navController.navigate(NavigationRoute.AccountSetting.route)
}
SideMenuContent(
onClose = {
coroutineScope.launch {
drawerState.close()
}
)
Spacer(modifier = Modifier.height(16.dp))
NavItem(
iconRes = R.drawable.rider_pro_favourited,
label = stringResource(R.string.favourites),
modifier = Modifier.noRippleClickable {
coroutineScope.launch {
drawerState.close()
navController.navigate(NavigationRoute.FavouriteList.route)
}
}
)
NavItem(
iconRes = R.drawable.rave_now_nav_night,
label = stringResource(R.string.dark_mode),
rightContent = {
Switch(
checked = AppState.darkMode,
onCheckedChange = {
AppState.switchTheme()
},
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = AppColors.main,
uncheckedThumbColor = Color.White,
uncheckedTrackColor = AppColors.main.copy(alpha = 0.5f),
uncheckedBorderColor = Color.Transparent
),
modifier = Modifier.scale(0.8f)
)
}
)
// divider
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 16.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(AppColors.divider)
)
}
NavItem(
iconRes = R.drawable.rave_now_nav_about,
label = stringResource(R.string.blocked),
modifier = Modifier.noRippleClickable {
coroutineScope.launch {
drawerState.close()
navController.navigate(NavigationRoute.AboutScreen.route)
}
}
)
NavItem(
iconRes = R.drawable.rave_now_nav_about,
label = stringResource(R.string.feedback),
modifier = Modifier.noRippleClickable {
coroutineScope.launch {
drawerState.close()
navController.navigate(NavigationRoute.AboutScreen.route)
}
}
)
NavItem(
iconRes = R.drawable.rave_now_nav_about,
label = stringResource(R.string.about_rave_now),
modifier = Modifier.noRippleClickable {
coroutineScope.launch {
drawerState.close()
navController.navigate(NavigationRoute.AboutScreen.route)
}
}
)
// divider
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 16.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(AppColors.divider)
)
}
// NavItem(
// iconRes = R.drawable.rave_now_nav_switch,
// label = "Switch Account"
// )
// Spacer(modifier = Modifier.height(16.dp))
NavItem(
iconRes = R.drawable.rave_now_nav_logout,
label = stringResource(R.string.logout),
modifier = Modifier.noRippleClickable {
coroutineScope.launch {
drawerState.close()
// 只有非游客用户才需要取消注册推送设备
if (!AppStore.isGuest) {
Messaging.unregisterDevice(context)
}
AppStore.apply {
token = null
rememberMe = false
isGuest = false // 清除游客状态
saveData()
}
// 删除推送渠道
navController.navigate(NavigationRoute.Login.route) {
popUpTo(NavigationRoute.Login.route) {
inclusive = true
}
}
AppState.ReloadAppState(context)
}
}
)
}
},
navController = navController,
context = context,
isDrawerOpen = drawerState.isOpen
)
}
}
) {
@@ -382,12 +263,11 @@ fun IndexScreen() {
modifier = Modifier
.background(AppColors.background)
.padding(0.dp),
beyondBoundsPageCount = 4,
userScrollEnabled = false
) { page ->
when (page) {
0 -> Agent()
1 -> Home()
1 -> Home(isPageVisible = pagerState.currentPage == 1)
2 -> Add()
3 -> Notifications()
4 -> Profile()
@@ -447,11 +327,12 @@ fun IndexScreen() {
)
}
}
}
@Composable
fun Home() {
fun Home(
isPageVisible: Boolean = true
) {
val systemUiController = rememberSystemUiController()
val context = LocalContext.current
@@ -467,7 +348,7 @@ fun Home() {
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
MomentsList()
MomentsList(isPageVisible = isPageVisible)
}
}
@@ -541,13 +422,6 @@ fun Profile() {
systemUiController.setStatusBarColor(Color.Transparent, !AppState.darkMode)
}
// 页面退出时清理个人资料相关资源
DisposableEffect(Unit) {
onDispose {
ResourceCleanupManager.cleanupPageResources("profile")
}
}
Column(
modifier = Modifier
.fillMaxSize(),
@@ -623,4 +497,349 @@ fun NavItem(
}
}
}
@Composable
fun SideMenuContent(
onClose: () -> Unit,
navController: androidx.navigation.NavController,
context: android.content.Context,
isDrawerOpen: Boolean
) {
val appColors = LocalAppTheme.current
val coroutineScope = rememberCoroutineScope()
var messageNotificationEnabled by remember { mutableStateOf(true) }
var darkModeEnabled by remember { mutableStateOf(AppState.darkMode) }
// 同步暗色模式状态
LaunchedEffect(AppState.darkMode) {
darkModeEnabled = AppState.darkMode
}
// 菜单背景色 - 根据暗色模式适配
val menuBackgroundColor = if (darkModeEnabled) {
appColors.secondaryBackground // 暗色模式:深灰色
} else {
Color(0xFFFAF9FB) // 亮色模式:浅灰色
}
// 卡片背景色 - 根据暗色模式适配
val cardBackgroundColor = if (darkModeEnabled) {
appColors.background // 暗色模式:深色背景
} else {
Color.White // 亮色模式:白色
}
// 文字颜色 - 根据暗色模式适配
val textColor = appColors.text
// 图标颜色 - 根据暗色模式适配
val iconColor = appColors.text
// 跟随系统文字颜色 - 根据暗色模式适配
val followSystemTextColor = appColors.secondaryText
// 开关开启颜色 #7C45ED
val switchActiveColor = Color(0xFF7C45ED)
Box(
modifier = Modifier
.fillMaxSize()
) {
// 右侧菜单面板
Box(
modifier = Modifier
.requiredWidth(302.dp)
.requiredHeight(874.dp)
.align(Alignment.CenterEnd)
.background(menuBackgroundColor)
) {
// 顶部状态栏间距
val statusBarHeight = WindowInsets.systemBars.asPaddingValues().calculateTopPadding()
// 扫一扫功能入口
Row(
modifier = Modifier
.align(Alignment.TopEnd)
.offset(x = (-60).dp, y = 88.dp)
.noRippleClickable {
coroutineScope.launch {
onClose()
navController.navigate(NavigationRoute.ScanQr.route)
}
},
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.mipmap.sao),
contentDescription = null,
modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(iconColor)
)
Text(
text = stringResource(R.string.scan_qr),
fontSize = 14.sp,
color = textColor,
textAlign = TextAlign.Center
)
}
// QR码图标 - 右边距离右边112dp上边距离上边68pt
Image(
painter = painterResource(id = R.mipmap.qr_code_icon),
contentDescription = null,
modifier = Modifier
.size(24.dp)
.align(Alignment.TopEnd)
.offset(x = (-26).dp, y = 88.dp)
.noRippleClickable {
// TODO: 实现QR码功能
},
colorFilter = ColorFilter.tint(iconColor)
)
// 菜单选项卡片组 - 第一组卡片上方距离上方108pt绝对定位
Column(
modifier = Modifier
.fillMaxWidth()
.offset(y = 128.dp) // 直接距离顶部128dp整体下移20dp
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// 第一组卡片:编辑资料、账号安全、收藏
MenuCard(
backgroundColor = cardBackgroundColor,
textColor = textColor,
iconColor = iconColor,
width = 270.dp,
height = 164.dp,
items = listOf(
MenuItem(
icon = R.mipmap.icons_edited_data,
label = stringResource(R.string.edit_profile_info),
onClick = {
coroutineScope.launch {
onClose()
navController.navigate(NavigationRoute.AccountEdit.route)
}
}
),
MenuItem(
icon = R.mipmap.icons_account_and_security,
label = stringResource(R.string.account_and_security),
onClick = {
coroutineScope.launch {
onClose()
navController.navigate(NavigationRoute.AccountSetting.route)
}
}
),
MenuItem(
icon = R.mipmap.collect,
label = stringResource(R.string.favourites),
onClick = {
coroutineScope.launch {
onClose()
navController.navigate(NavigationRoute.FavouriteList.route)
}
}
)
)
)
// 第二组卡片:暗色模式、消息通知
MenuCard(
backgroundColor = cardBackgroundColor,
textColor = textColor,
iconColor = iconColor,
width = 270.dp,
height = 112.dp, // 根据设计图第二组卡片高度为112dp
items = listOf(
MenuItem(
icon = R.mipmap.icons_dark_mode,
label = stringResource(R.string.dark_mode),
rightContent = {
Switch(
checked = darkModeEnabled,
onCheckedChange = {
darkModeEnabled = it
AppState.darkMode = it
AppState.appTheme = if (it) {
com.aiosman.ravenow.DarkThemeColors()
} else {
com.aiosman.ravenow.LightThemeColors()
}
AppStore.saveDarkMode(it)
},
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = switchActiveColor,
uncheckedThumbColor = Color.White,
uncheckedTrackColor = switchActiveColor.copy(alpha = 0.5f),
uncheckedBorderColor = Color.Transparent
),
modifier = Modifier.size(width = 64.dp, height = 28.dp)
)
}
),
MenuItem(
icon = R.mipmap.icons_bell,
label = stringResource(R.string.message_notification),
rightContent = {
Switch(
checked = messageNotificationEnabled,
onCheckedChange = { messageNotificationEnabled = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = switchActiveColor,
uncheckedThumbColor = Color.White,
uncheckedTrackColor = switchActiveColor.copy(alpha = 0.5f),
uncheckedBorderColor = Color.Transparent
),
modifier = Modifier.size(width = 64.dp, height = 28.dp)
)
}
)
)
)
// 第三组卡片:关于派派、反馈、退出登录
MenuCard(
backgroundColor = cardBackgroundColor,
textColor = textColor,
iconColor = iconColor,
width = 270.dp,
height = 164.dp,
items = listOf(
MenuItem(
icon = R.mipmap.icons_about,
label = stringResource(R.string.about_paipai),
onClick = {
coroutineScope.launch {
onClose()
navController.navigate(NavigationRoute.AboutScreen.route)
}
}
),
MenuItem(
icon = R.mipmap.feedback_icon,
label = stringResource(R.string.feedback),
onClick = {
coroutineScope.launch {
onClose()
navController.navigate(NavigationRoute.AboutScreen.route)
}
}
),
MenuItem(
icon = R.mipmap.log_out_icon,
label = stringResource(R.string.logout_confirm),
onClick = {
coroutineScope.launch {
onClose()
// 只有非游客用户才需要取消注册推送设备
if (!AppStore.isGuest) {
Messaging.unregisterDevice(context)
}
AppStore.apply {
token = null
rememberMe = false
isGuest = false
saveData()
}
navController.navigate(NavigationRoute.Login.route) {
popUpTo(NavigationRoute.Login.route) {
inclusive = true
}
}
AppState.ReloadAppState(context)
}
},
showRightArrow = false
)
)
)
}
}
}
}
data class MenuItem(
val icon: Int,
val label: String,
val onClick: (() -> Unit)? = null,
val rightContent: @Composable (() -> Unit)? = null,
val showRightArrow: Boolean = true
)
@Composable
fun MenuCard(
backgroundColor: Color,
textColor: Color,
iconColor: Color,
items: List<MenuItem>,
width: androidx.compose.ui.unit.Dp? = null,
height: androidx.compose.ui.unit.Dp? = null
) {
Column(
modifier = Modifier
.then(if (width != null) Modifier.requiredWidth(width) else Modifier.fillMaxWidth())
.then(if (height != null) Modifier.requiredHeight(height) else Modifier)
.background(backgroundColor, RoundedCornerShape(16.dp))
.padding(horizontal = 16.dp),
verticalArrangement = if (height != null) Arrangement.SpaceEvenly else Arrangement.spacedBy(8.dp) // 固定高度时均匀分布
) {
items.forEachIndexed { index, item ->
Box(
modifier = Modifier
.then(if (height != null) Modifier.weight(1f) else Modifier),
contentAlignment = Alignment.Center
) {
MenuItemRow(item = item, compact = height != null, textColor = textColor, iconColor = iconColor) // 传递颜色参数
}
}
}
}
@Composable
fun MenuItemRow(item: MenuItem, compact: Boolean = false, textColor: Color, iconColor: Color) {
val appColors = LocalAppTheme.current
Row(
modifier = Modifier
.fillMaxWidth()
.then(
if (item.onClick != null) {
Modifier.noRippleClickable { item.onClick?.invoke() }
} else {
Modifier
}
)
.padding(vertical = if (compact) 4.dp else 8.dp), // 紧凑模式下减少垂直padding
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Image(
painter = painterResource(id = item.icon),
contentDescription = null,
modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(iconColor)
)
Text(
text = item.label,
fontSize = 14.sp,
color = textColor
)
}
if (item.rightContent != null) {
item.rightContent?.invoke()
} else if (item.showRightArrow) {
Image(
painter = painterResource(id = R.drawable.rave_now_nav_right),
contentDescription = null,
modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(appColors.text)
)
}
}
}

View File

@@ -50,9 +50,10 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.GuestLoginCheckOut
@@ -86,6 +87,14 @@ 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.graphics.Brush
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.aspectRatio
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 {
@@ -109,7 +118,7 @@ fun Agent() {
var pagerState = rememberPagerState { tabCount }
var scope = rememberCoroutineScope()
val viewModel: AgentViewModel = viewModel()
val viewModel: AgentViewModel = AgentViewModel
// 确保推荐Agent数据已加载
LaunchedEffect(Unit) {
@@ -271,130 +280,129 @@ fun Agent() {
}
}
// 热门聊天室
stickyHeader(key = "hot_rooms_header") {
Row(
modifier = Modifier
.fillMaxWidth()
.background(AppColors.background)
.padding(top = 8.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.mipmap.rider_pro_hot_room),
contentDescription = "chat room",
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(4.dp))
androidx.compose.material3.Text(
text = stringResource(R.string.hot_rooms),
fontSize = 16.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.W900,
color = AppColors.text
)
}
}
// 热门聊天室网格
items(viewModel.chatRooms.chunked(2)) { rowRooms ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
rowRooms.forEach { chatRoom ->
ChatRoomCard(
chatRoom = chatRoom,
navController = LocalNavController.current,
modifier = Modifier.weight(1f)
)
}
if (rowRooms.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
item { Spacer(modifier = Modifier.height(20.dp)) }
// "发现更多" 标题 - 吸顶
stickyHeader(key = "discover_more") {
Row(
modifier = Modifier
.fillMaxWidth()
.background(AppColors.background)
.padding(top = 8.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
Image(
painter = painterResource(R.mipmap.bars_x_buttons_home_n_copy_2),
contentDescription = "agent",
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(4.dp))
androidx.compose.material3.Text(
text = stringResource(R.string.agent_find),
fontSize = 16.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.W900,
color = AppColors.text
)
}
}
// Agent网格 - 使用行式布局
items(
items = agentItems.chunked(2),
key = { row -> row.firstOrNull()?.openId ?: "" }
) { rowItems ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
rowItems.forEach { agentItem ->
Box(
modifier = Modifier.weight(1f)
) {
AgentCardSquare(
agentItem = agentItem,
viewModel = viewModel,
navController = LocalNavController.current
)
}
}
// 如果这一行只有一个item添加一个空的占位符
if (rowItems.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
// 加载更多指示器
if (viewModel.isLoadingMore) {
item {
if (viewModel.chatRooms.isNotEmpty()) {
// 热门聊天室
stickyHeader(key = "hot_rooms_header") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
horizontalArrangement = Arrangement.Center
.background(AppColors.background)
.padding(top = 8.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = AppColors.text,
strokeWidth = 2.dp
Image(
painter = painterResource(R.mipmap.rider_pro_hot_room),
contentDescription = "chat room",
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(4.dp))
androidx.compose.material3.Text(
text = "加载中...",
color = AppColors.secondaryText,
fontSize = 14.sp
text = stringResource(R.string.hot_rooms),
fontSize = 16.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.W900,
color = AppColors.text
)
}
}
// 热门聊天室网格
items(viewModel.chatRooms.chunked(2)) { rowRooms ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
rowRooms.forEach { chatRoom ->
ChatRoomCard(
chatRoom = chatRoom,
navController = LocalNavController.current,
modifier = Modifier.weight(1f)
)
}
if (rowRooms.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
// 只有当热门聊天室有数据时,才展示“发现更多”区域
if (viewModel.chatRooms.isNotEmpty()) {
item { Spacer(modifier = Modifier.height(20.dp)) }
// "发现更多" 标题 - 吸顶
stickyHeader(key = "discover_more") {
Row(
modifier = Modifier
.fillMaxWidth()
.background(AppColors.background)
.padding(top = 8.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
Image(
painter = painterResource(R.mipmap.bars_x_buttons_home_n_copy_2),
contentDescription = "agent",
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(4.dp))
androidx.compose.material3.Text(
text = stringResource(R.string.agent_find),
fontSize = 16.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.W900,
color = AppColors.text
)
}
}
// Agent网格 - 使用行式布局
items(
items = agentItems.chunked(2),
key = { row -> row.firstOrNull()?.openId ?: "" }
) { rowItems ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
rowItems.forEach { agentItem ->
Box(
modifier = Modifier.weight(1f)
) {
AgentCardSquare(
agentItem = agentItem,
viewModel = viewModel,
navController = LocalNavController.current
)
}
}
// 如果这一行只有一个item添加一个空的占位符
if (rowItems.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
// 加载更多指示器(仅在展示"发现更多"时显示)
if (viewModel.isLoadingMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
contentAlignment = Alignment.Center
) {
LottieAnimation(
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
iterations = LottieConstants.IterateForever,
modifier = Modifier.size(80.dp)
)
}
}
}
}
}
}
@@ -459,8 +467,7 @@ fun AgentCardSquare(
navController: NavHostController
) {
val AppColors = LocalAppTheme.current
val cardHeight = 180.dp
val avatarSize = cardHeight / 3 // 头像大小为方块高度的三分之一
val cardHeight = 210.dp
// 防抖状态
var lastClickTime by remember { mutableStateOf(0L) }
@@ -469,96 +476,76 @@ fun AgentCardSquare(
modifier = Modifier
.fillMaxWidth()
.height(cardHeight)
.background(AppColors.secondaryBackground, RoundedCornerShape(12.dp))
.clickable {
.clip(RoundedCornerShape(12.dp))
.noRippleClickable {
if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) {
viewModel.goToProfile(agentItem.openId, navController)
}) {
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(
modifier = Modifier
.offset(y = 4.dp)
.size(avatarSize)
.background(AppColors.background, RoundedCornerShape(avatarSize / 2))
.clip(RoundedCornerShape(avatarSize / 2)),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(R.mipmap.group_copy),
contentDescription = "默认头像",
modifier = Modifier.size(avatarSize),
)
if (agentItem.avatar.isNotEmpty()) {
CustomAsyncImage(
imageUrl = agentItem.avatar,
contentDescription = "Agent头像",
modifier = Modifier
.size(avatarSize)
.clip(RoundedCornerShape(avatarSize / 2)),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(
Brush.verticalGradient(
0f to Color.Transparent,
1f to Color(0xB2000000)
)
)
}
}
// 内容区域(名称和描述)
.padding(12.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp + avatarSize + 8.dp, start = 8.dp, end = 8.dp, bottom = 48.dp), // 为底部聊天按钮留空间
horizontalAlignment = Alignment.CenterHorizontally
.padding(bottom = 40.dp) // 为底部聊天按钮留空间
) {
androidx.compose.material3.Text(
text = agentItem.title,
color = Color.White,
fontSize = 14.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.W600,
color = AppColors.text,
fontWeight = androidx.compose.ui.text.font.FontWeight.W700,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(4.dp))
androidx.compose.material3.Text(
text = agentItem.desc,
fontSize = 12.sp,
color = AppColors.secondaryText,
color = Color.White.copy(alpha = 0.92f),
fontSize = 11.sp,
maxLines = 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
overflow = TextOverflow.Ellipsis
)
}
}
// 聊天按钮
// 底部居中 Chat 按钮
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 12.dp)
.width(60.dp)
.width(70.dp)
.height(32.dp)
.background(
color = AppColors.text,
shape = RoundedCornerShape(
topStart = 14.dp,
topEnd = 14.dp,
bottomStart = 0.dp,
bottomEnd = 14.dp
)
)
.clickable {
.background(AppColors.text, RoundedCornerShape(16.dp))
.noRippleClickable {
if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) {
// 检查游客模式,如果是游客则跳转登录
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
viewModel.createSingleChat(agentItem.openId)
viewModel.goToChatAi(
agentItem.openId,
navController = navController
)
viewModel.goToChatAi(agentItem.openId, navController)
}
}) {
lastClickTime = System.currentTimeMillis()
@@ -568,9 +555,9 @@ fun AgentCardSquare(
) {
androidx.compose.material3.Text(
text = stringResource(R.string.chat),
fontSize = 15.sp,
color = AppColors.background,
fontWeight = androidx.compose.ui.text.font.FontWeight.W500
fontSize = 13.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.W600
)
}
}
@@ -579,73 +566,148 @@ fun AgentCardSquare(
@Composable
fun AgentViewPagerSection(agentItems: List<AgentItem>,viewModel: AgentViewModel) {
val AppColors = LocalAppTheme.current
if (agentItems.isEmpty()) return
// 每页显示5个agent
val itemsPerPage = 5
val totalPages = (agentItems.size + itemsPerPage - 1) / itemsPerPage
val pagerState = rememberPagerState(pageCount = { agentItems.size })
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val cardAspect = 1133.5f / 846.4f
// 外层 LazyColumn 左右各 8dp + Pager contentPadding 左右各 20dp
val horizontalPaddings = 56.dp
val pagerHeight = (screenWidth - horizontalPaddings) * cardAspect
if (totalPages > 0) {
val pagerState = rememberPagerState(pageCount = { totalPages })
Column {
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 {
// Agent内容
Box(
modifier = Modifier
.height(310.dp)
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp),
pageSpacing = 0.dp
) { page ->
// 计算当前页面的偏移量
val pageOffset = (
(pagerState.currentPage - page) + pagerState
.currentPageOffsetFraction
).coerceIn(-1f, 1f)
AgentLargeCard(
agentItem = agentItems[page],
viewModel = viewModel,
navController = LocalNavController.current,
modifier = Modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
}
)
}
}
}
}
// 根据偏移量计算缩放比例
val scale = 1f - (0.1f * kotlin.math.abs(pageOffset))
@SuppressLint("SuspiciousIndentation")
@Composable
fun AgentLargeCard(
agentItem: AgentItem,
viewModel: AgentViewModel,
navController: NavHostController,
modifier: Modifier = Modifier
) {
val AppColors = LocalAppTheme.current
var lastClickTime by remember { mutableStateOf(0L) }
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,
)
Box(
modifier = modifier
.fillMaxWidth()
.aspectRatio(846.4f / 1133.5f)
.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
)
// 指示器
Row(
// 底部渐变与文字
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(
Brush.verticalGradient(
0f to Color.Transparent,
1f to Color(0xB2000000)
)
)
.padding(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(30.dp)
.padding(top = 12.dp),
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center
.padding(bottom = 56.dp) // 为底部聊天按钮预留空间
) {
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
)
)
}
androidx.compose.material3.Text(
text = agentItem.title,
color = Color.White,
fontSize = 20.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.W700,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
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
)
}
}
// 底部居中 Chat 按钮
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp)
.widthIn(min = 180.dp)
.fillMaxWidth(0.65f)
.height(44.dp)
.background(AppColors.text, RoundedCornerShape(22.dp))
.noRippleClickable {
if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) {
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
viewModel.createSingleChat(agentItem.openId)
viewModel.goToChatAi(agentItem.openId, navController)
}
}) {
lastClickTime = System.currentTimeMillis()
}
},
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Text(
text = stringResource(R.string.chat),
color = AppColors.background,
fontSize = 16.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.W600
)
}
}
}
@@ -694,22 +756,15 @@ fun AgentCard2(viewModel: AgentViewModel,agentItem: AgentItem,navController: Nav
},
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(R.mipmap.group_copy),
contentDescription = "默认头像",
modifier = Modifier.size(48.dp),
CustomAsyncImage(
imageUrl = agentItem.avatar,
contentDescription = "Agent头像",
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(24.dp)),
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
defaultRes = R.mipmap.group_copy
)
if (agentItem.avatar.isNotEmpty()) {
CustomAsyncImage(
imageUrl = agentItem.avatar,
contentDescription = "Agent头像",
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(24.dp)),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
}
}
Spacer(modifier = Modifier.width(12.dp))
@@ -844,7 +899,7 @@ fun ChatRoomCard(
) {
val AppColors = LocalAppTheme.current
val cardSize = 180.dp
val viewModel: AgentViewModel = viewModel()
val viewModel: AgentViewModel = AgentViewModel
val context = LocalContext.current
// 防抖状态
@@ -863,26 +918,16 @@ fun ChatRoomCard(
modifier = Modifier
.size(120.dp)
.background(
color = AppColors.background,
color = Color.Transparent,
shape = RoundedCornerShape(12.dp)
),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
color = AppColors.main
)
Spacer(modifier = Modifier.height(12.dp))
androidx.compose.material3.Text(
text = "加入中...",
fontSize = 14.sp,
color = AppColors.text
)
}
LottieAnimation(
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
iterations = LottieConstants.IterateForever,
modifier = Modifier.size(96.dp)
)
}
}
}
@@ -916,29 +961,20 @@ fun ChatRoomCard(
// 优先显示banner如果没有banner则显示头像
val imageUrl = if (chatRoom.banner.isNotEmpty()) chatRoom.banner else chatRoom.avatar
if (imageUrl.isNotEmpty()) {
CustomAsyncImage(
imageUrl = imageUrl,
contentDescription = if (chatRoom.banner.isNotEmpty()) "房间banner" else "房间头像",
modifier = Modifier
.width(cardSize)
.height(120.dp)
.clip(RoundedCornerShape(
topStart = 12.dp,
topEnd = 12.dp,
bottomStart = 0.dp,
bottomEnd = 0.dp)),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
} else {
// 默认房间图标
Image(
painter = painterResource(R.mipmap.rider_pro_agent),
contentDescription = "默认房间图标",
modifier = Modifier.size(cardSize * 0.4f),
colorFilter = ColorFilter.tint(AppColors.secondaryText)
)
}
CustomAsyncImage(
imageUrl = imageUrl,
contentDescription = if (chatRoom.banner.isNotEmpty()) "房间banner" else "房间头像",
modifier = Modifier
.width(cardSize)
.height(120.dp)
.clip(RoundedCornerShape(
topStart = 12.dp,
topEnd = 12.dp,
bottomStart = 0.dp,
bottomEnd = 0.dp)),
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
defaultRes = R.mipmap.rider_pro_agent
)
// 房间名称,重叠在底部
Box(
@@ -981,9 +1017,13 @@ fun ChatRoomCard(
Text(
text = "${chatRoom.memberCount} ${stringResource(R.string.chatting_now)}",
fontSize = 12.sp,
modifier = Modifier.alpha(0.6f),
modifier = Modifier
.alpha(0.6f)
.weight(1f),
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

@@ -197,7 +197,7 @@ object AgentViewModel: ViewModel() {
page = 1,
pageSize = 20,
isRecommended = 1,
random = 1
random = "1"
)
if (response.isSuccessful) {
val allRooms = response.body()?.list ?: emptyList()
@@ -234,22 +234,24 @@ object AgentViewModel: ViewModel() {
try {
// 获取完整的语言标记(如 "zh-CN"
val sysLang = com.aiosman.ravenow.utils.Utils.getPreferredLanguageTag()
val fullLangTag = com.aiosman.ravenow.utils.Utils.getPreferredLanguageTag()
// 转换为后端支持的语言代码(仅支持 zh、cn、ja
val sysLang = convertToSupportedLangCode(fullLangTag)
val response = apiClient.getCategories(
page = 1,
pageSize = 100,
isActive = true,
withChildren = false,
withParent = false,
withCount = true,
hideEmpty = true,
// withChildren = false,
// withParent = false,
// withCount = true,
// hideEmpty = true,
lang = sysLang
)
println("分类数据请求完成,响应成功: ${response.isSuccessful}, 语言标记: $sysLang")
println("分类数据请求完成,响应成功: ${response.isSuccessful}, 原始语言标记: $fullLangTag, 转换后: $sysLang")
if (response.isSuccessful) {
val categoryList = response.body()?.list ?: emptyList()
println("获取到 ${categoryList.size} 个分类")
// 使用当前语言获取翻译后的分类名称
// 使用转换后的语言代码获取翻译后的分类名称
categories = categoryList.map { category ->
CategoryItem.fromCategoryTemplate(category, sysLang)
}
@@ -266,6 +268,24 @@ object AgentViewModel: ViewModel() {
}
}
/**
* 将完整的语言标记转换为后端支持的语言代码
* 后端仅支持: zh, cn, ja
*
* @param langTag 完整的语言标记,如 "zh-CN", "zh-TW", "ja-JP", "en-US" 等
* @return 后端支持的语言代码,默认返回 "zh"
*/
private fun convertToSupportedLangCode(langTag: String): String {
return when {
langTag.startsWith("zh", ignoreCase = true) -> "zh"
langTag.startsWith("ja", ignoreCase = true) -> "ja"
// 如果是中文相关的其他标记,也返回 zh
langTag.equals("cn", ignoreCase = true) -> "cn"
// 默认返回中文
else -> "zh"
}
}
fun loadAgentsByCategory(categoryId: Int) {
loadAgentData(categoryId)
}

View File

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

View File

@@ -163,7 +163,7 @@ fun NotificationsScreen() {
modifier = Modifier
.size(24.dp)
.noRippleClickable {
// TODO: 实现搜索功能
navController.navigate(NavigationRoute.Search.route)
},
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.launch
import kotlin.coroutines.suspendCoroutine
import com.aiosman.ravenow.data.repo.TrtcUserTypeRepository
data class Conversation(
val id: String,
@@ -76,6 +77,13 @@ object MessageListViewModel : ViewModel() {
// noticeInfo = info
//
// 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

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -34,6 +35,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -47,6 +49,12 @@ import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.rememberDebouncer
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.utils.NetworkUtils
import com.aiosman.ravenow.ui.network.NetworkErrorContentCompact
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 com.aiosman.ravenow.ui.index.tabs.profile.composable.ChatEmptyStateView
/**
* 智能体聊天列表页面
@@ -83,67 +91,28 @@ fun AgentChatListScreen() {
.pullRefresh(state)
) {
if (AgentChatListViewModel.agentChatList.isEmpty() && !AgentChatListViewModel.isLoading) {
// 空状态
Column(
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center
) {
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(context)
// 空状态
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.offset(y = (-40).dp)
) {
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(context)
if (isNetworkAvailable) {
Spacer(modifier = Modifier.height(39.dp))
Image(
painter = painterResource(
id = if(AppState.darkMode) R.mipmap.juhao_dark
else R.mipmap.invalid_name_5),
contentDescription = "null data",
modifier = Modifier
.size(width = 181.dp, height = 153.dp)
)
Spacer(modifier = Modifier.height(if (AppState.darkMode) 9.dp else 24.dp))
Text(
text = stringResource(R.string.agent_chat_empty_title),
color = AppColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.agent_chat_empty_subtitle),
color = AppColors.secondaryText,
fontSize = 14.sp
)
}
else {
Spacer(modifier = Modifier.height(39.dp))
Image(
painter = painterResource(id = R.mipmap.invalid_name_10),
contentDescription = "network error",
modifier = Modifier
.size(181.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.friend_chat_no_network_title),
color = AppColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.friend_chat_no_network_subtitle),
color = AppColors.secondaryText,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(16.dp))
ReloadButton(
onClick = {
AgentChatListViewModel.refreshPager(context = context)
}
)
if (isNetworkAvailable) {
ChatEmptyStateView()
} else {
NetworkErrorContentCompact(
onReload = {
AgentChatListViewModel.refreshPager(context = context)
}
)
}
}
}
} else {
@@ -180,9 +149,10 @@ fun AgentChatListScreen() {
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = AppColors.main
LottieAnimation(
composition = rememberLottieComposition(LottieCompositionSpec.Asset("star_Loader.lottie")).value,
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.icu.util.Calendar
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -28,6 +29,7 @@ import io.openim.android.sdk.models.Message
import kotlinx.coroutines.launch
import kotlin.coroutines.suspendCoroutine
import com.aiosman.ravenow.utils.MessageParser
import com.aiosman.ravenow.data.repo.TrtcUserTypeRepository
data class AgentConversation(
val id: String,
@@ -53,7 +55,7 @@ data class AgentConversation(
nickname = conversation.showName ?: "",
lastMessage = displayText, // 使用解析后的显示文本
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,
trtcUserId = conversation.userID ?: "",
displayText = displayText,
@@ -127,15 +129,7 @@ object AgentChatListViewModel : ViewModel() {
OpenIMClient.getInstance().conversationManager.getAllConversationList(
object : OnBase<List<ConversationInfo>> {
override fun onSuccess(data: List<ConversationInfo>?) {
// 过滤出智能体会话(单聊类型,且可能有特定标识)
val agentConversations = data?.filter { conversation ->
// 这里需要根据实际业务逻辑来过滤智能体会话
// 可能通过会话类型、用户ID前缀、或其他标识来判断
conversation.conversationType == 1 // 1 表示单聊
// 可以添加更多过滤条件,比如:
// && conversation.userID?.startsWith("ai_") == true
} ?: emptyList()
continuation.resumeWith(Result.success(agentConversations))
continuation.resumeWith(Result.success(data ?: emptyList()))
}
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)
}
}

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