392 Commits

Author SHA1 Message Date
76ce9685ac 修复星座和mbti选择界面上滑后会一直抖动、调整星座和mbti选择界面、、修复短视频在播放时退出后再进入会暂停但是不会显示暂停图标 2025-12-01 18:49:47 +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
2ba4c2ec02 Merge branch 'main' into atm 2025-11-05 23:15:50 +08:00
28061617da Merge pull request #49 from Kevinlinpr/zhong_1
记忆管理功能实现
2025-11-05 22:30:50 +08:00
c9e411c4ad Merge branch 'main' into zhong_1 2025-11-05 22:30:29 +08:00
995e061b6f Merge pull request #52 from Kevinlinpr/feat/pr-20251104-154907
添加深色模式缺省图
2025-11-05 22:25:09 +08:00
9c9eb66b71 新增智能体和房间规则管理功能
- 新增智能体(Agent)规则的增删改查(CRUD)和服务实现。
- 新增房间(Room)规则的增删改查(CRUD)和服务实现。
- 将 `PromptRule` 相关命名重构为 `AgentRule`,以提高代码清晰度。
- 将相关数据实体中表示总数的字段类型从 `Int` 修改为 `Long`。
2025-11-05 22:24:03 +08:00
f90bfbfa0f 添加深色模式缺省图 2025-11-05 21:31:11 +08:00
9ea03cee34 群记忆项编辑功能;群权限设置UI;群记忆管理缺省图 2025-11-05 21:29:18 +08:00
1de8bb825c Merge pull request #51 from Kevinlinpr/revert-48-feat/pr-20251104-154907
Revert "Feat/pr 20251104 154907"
2025-11-05 16:57:59 +08:00
721b7aa9ab Revert "Feat/pr 20251104 154907" 2025-11-05 16:49:17 +08:00
f16be90cc3 Merge pull request #50 from Kevinlinpr/feat/pr-20251104-154907
修改底部导航栏颜色,暗夜模式缺省图
2025-11-05 16:44:23 +08:00
4a1c15747c 文本资源文件替换 2025-11-04 19:01:11 +08:00
0bbfd9b739 修改底部导航栏颜色,暗夜模式缺省图 2025-11-04 18:59:18 +08:00
cff6b78c30 记忆管理功能实现 2025-11-04 18:28:24 +08:00
13593212df Merge pull request #48 from Kevinlinpr/feat/pr-20251104-154907
Feat/pr 20251104 154907
2025-11-04 16:01:05 +08:00
56f225702e Merge origin/main into feat/pr-20251104-154907 (prefer main); resolve gradlew via theirs 2025-11-04 15:58:50 +08:00
0a5b81cc5c test 2025-11-04 14:55:09 +08:00
10fc40373d test 2025-11-04 14:50:20 +08:00
f18ee9e360 test 2025-11-04 14:40:01 +08:00
9262288bc9 Merge pull request #47 from Kevinlinpr/atm
新增智能体规则相关数据类及API接口,包括创建、修改、删除规则功能和查询规则列表及配额信息
2025-11-04 11:01:25 +08:00
3273b17d15 新增智能体规则相关数据类及API接口,包括创建、修改、删除规则功能和查询规则列表及配额信息 2025-11-04 10:59:59 +08:00
4ef5a94d46 Merge pull request #46 from Kevinlinpr/zhong_1
实现新闻界面查看全文
2025-11-03 10:10:28 +08:00
4a684886fa 聊天自定义背景实现 2025-10-31 16:41:39 +08:00
d7f87c7c55 新增聊天设置页面;自定义背景UI 2025-10-31 11:57:49 +08:00
00933dadb8 实现新闻界面查看全文 2025-10-29 18:08:24 +08:00
d5cc186e27 Merge pull request #45 from Kevinlinpr/zhong_1
登录界面UI调整;新增新闻评论
2025-10-28 18:45:22 +08:00
90156745ad 添加新闻接口,UI调整 2025-10-28 18:42:05 +08:00
7095832722 登录界面UI调整;新增新闻评论 2025-10-27 18:51:42 +08:00
658b337d22 Merge pull request #44 from Kevinlinpr/zhong_1
动态、热门界面UI调整
2025-10-27 11:16:47 +08:00
f6a760371a 新增新闻标签页;修改底部导航栏背景颜色 2025-10-24 18:27:58 +08:00
13ed16078b 新增苹果账户登录;新增拉黑功能;UI调整 2025-10-23 17:56:24 +08:00
2a5174cbb6 动态、热门界面UI调整 2025-10-22 18:52:46 +08:00
ea26cb40a0 Merge pull request #43 from Kevinlinpr/zhong_1
热门聊天室实现;首页UI调整
2025-10-21 21:39:20 +08:00
eb8119b775 动态页面顶部标签 2025-10-21 18:35:22 +08:00
66da741eda 登录页面UI调整 2025-10-21 17:19:32 +08:00
18dd52e193 聊天室显示个数 2025-10-20 17:05:48 +08:00
f839a793a3 热门聊天室实现;首页UI调整 2025-10-20 16:54:23 +08:00
54df6c088b Merge pull request #41 from Kevinlinpr/home_page
主页推荐agent实现
2025-10-17 17:23:28 +08:00
28fb94a824 顶部 推荐 agent 列表实现 2025-10-17 17:21:52 +08:00
4bdbbb0231 主页分类动态获取 2025-10-17 16:56:25 +08:00
58a2013a8f Merge pull request #40 from Kevinlinpr/lottie_deps
lottie 依赖
2025-10-17 16:11:15 +08:00
2f2da0a159 lottie 依赖 2025-10-17 15:27:12 +08:00
4ffaf3c3a8 Merge pull request #38 from Kevinlinpr/zhong
编辑资料页面UI调整:添加横幅图片区域
2025-10-16 18:10:11 +08:00
29490d288b “我的”页面UI调整 2025-10-16 18:06:24 +08:00
a99ab30c4e 编辑资料页面UI调整:添加横幅图片区域 2025-10-15 18:54:04 +08:00
0442925ae9 Merge pull request #37 from Kevinlinpr/zhong
解决问题:首页智能体头像为默认头像时显示为空
2025-10-14 18:20:22 +08:00
9d3d13a22d 新增消息界面“全部”标签页 2025-10-14 17:40:25 +08:00
f0a9704e2d 个人信息页和用户信息页UI调整 2025-10-13 18:49:47 +08:00
7f2c103ada 修改首页智能体头像显示逻辑,先显示默认头像(所有情况都显示)如果有网络头像则覆盖显示。 2025-10-11 18:55:39 +08:00
bd01ae39d0 Merge pull request #36 from Kevinlinpr/zhong
UI调整
2025-10-10 21:42:24 +08:00
d94e3b5c20 消息界面通知按钮点击事件;新增通知界面 2025-10-10 18:41:57 +08:00
44cc76d2e3 日文资源文件
实现重新加载功能
收藏界面UI调整
2025-10-09 17:36:06 +08:00
fac6f23356 新建文件夹 app/src/main/assets 将.lottie文件放入
UI调整
2025-09-30 18:54:35 +08:00
39928abc46 Merge pull request #35 from Zhong202501/main
缺省图
2025-09-30 15:53:37 +08:00
4d0d7004b0 缺省图 2025-09-29 18:29:59 +08:00
28a6e3fef3 Merge pull request #34 from Zhong202501/main
添加启动界面
2025-09-28 18:50:51 +08:00
b275d88ef7 添加启动界面 2025-09-28 18:24:54 +08:00
595ef7f942 Merge pull request #33 from Zhong202501/main
Agent创建成功全局显示; 适配暗黑模式
2025-09-27 20:11:44 +08:00
1202b55c74 text颜色 2025-09-26 18:50:51 +08:00
074009d256 添加界面状态恢复逻辑 2025-09-26 18:31:27 +08:00
ad1de9e3f7 Agent创建成功全局显示;
适配暗黑模式
2025-09-26 17:01:46 +08:00
359bcfdfd7 Agent创建成功全局显示;
适配暗黑模式
2025-09-26 17:00:12 +08:00
1901fddb2e Merge pull request #32 from Zhong202501/main
创建弹窗UI调整
2025-09-26 11:29:03 +08:00
b96ae94bdb 界面逻辑优化 2025-09-25 18:32:34 +08:00
dedd356896 创建弹窗UI调整 2025-09-24 18:51:20 +08:00
bb9fda75ae Merge pull request #31 from Zhong202501/main
AI美化功能; 输入框逻辑优化; 文本资源文件;
2025-09-24 14:35:08 +08:00
ea911f113b AI美化功能 2025-09-23 18:32:01 +08:00
88f379fe5b Merge pull request #30 from Kevinlinpr/agent_scroll
优化AI界面,添加分页加载功能,支持动态加载更多智能体数据;重构UI布局
2025-09-23 13:43:55 +08:00
1a24136c35 优化AI界面,添加分页加载功能,支持动态加载更多智能体数据;重构UI布局 2025-09-23 11:57:11 +08:00
742410223c Merge pull request #29 from Zhong202501/main
手动创建AI界面调整
2025-09-23 10:37:15 +08:00
bd5aff7564 手动创建AI界面调整 2025-09-22 17:57:39 +08:00
b43c1585c4 Merge pull request #28 from Zhong202501/main
创建AI界面UI兼容;动态页面调整
2025-09-22 10:48:57 +08:00
cb582393f1 手动创造AI界面;调整输入框点击区域;创建AI时的三点彩色动画 2025-09-19 18:45:10 +08:00
a200d00587 UI调整 2025-09-18 18:19:19 +08:00
6d2133545f 动态详情页面评论调整 2025-09-18 18:16:54 +08:00
2aad126010 创建AI界面UI兼容;动态页面调整 2025-09-17 18:41:15 +08:00
b7b777d2d0 Merge pull request #26 from Zhong202501/new
首页底部导航栏图标;创建AI界面
2025-09-17 11:03:17 +08:00
e804c8be0c Merge pull request #20 from Kevinlinpr/new-bottom-create-button
Feat: Add Create Bottom Sheet and icons
2025-09-17 10:44:34 +08:00
228a74695e Merge pull request #22 from Zhong202501/main
添加Category接口
2025-09-17 10:39:22 +08:00
41a51b85da 首页底部导航栏图标;创建AI界面 2025-09-16 18:18:36 +08:00
e74e8615a5 Agent卡片组件UI;Agent聊天界面输入框显示问题 2025-09-15 14:06:05 +08:00
349d39daf2 修复BUG:我的界面右上角图标会跟随背景图一起向上滑走 2025-09-12 18:24:49 +08:00
eca85c8377 Feat: Add Create Bottom Sheet and icons
- Implemented a new `CreateBottomSheet` Composable to provide users with options to create AI, Group Chat, or Moment.
- Added new drawable resources for the create options: `ic_create_ai.xml`, `ic_create_group_chat.xml`, `ic_create_monent.xml`, and `ic_create_close.xml`.
- Integrated the `CreateBottomSheet` into the `IndexScreen`. Clicking the "+" button now opens this bottom sheet instead of directly navigating to new post creation.
- Updated `IndexViewModel` to manage the visibility state of the `CreateBottomSheet`.
- Added string resources for the Create Bottom Sheet in English, Chinese, and Japanese.
- Ensured proper navigation and tourist mode checks for each create option.
- Implemented graceful dismissal of the bottom sheet with animations.
2025-09-12 17:21:29 +08:00
8154a0ddc4 Category接口;Agent卡片组件背景颜色 2025-09-11 18:14:54 +08:00
f8be622ba6 Merge pull request #17 from Zhong202501/main
首页Agent卡片组件
2025-09-10 19:22:52 +08:00
f3c841779b Merge pull request #18 from Kevinlinpr/ll
修复一些未处理异常,切换到测试服务器
2025-09-10 19:19:38 +08:00
57e4614ce8 修复一些未处理异常,切换到测试服务器 2025-09-10 18:34:36 +08:00
922d6e72d6 首页Agent卡片组件 2025-09-10 18:02:58 +08:00
c41c097d41 处理最新消息显示 2025-09-10 14:03:27 +08:00
5218ca7046 重构IM viewmodel代码 2025-09-10 11:57:05 +08:00
ce6ee7bf82 imsdk 调通 2025-09-09 19:05:07 +08:00
e00deb5661 merge conflict 2025-09-09 17:57:28 +08:00
95d6522a54 fix im connect error 2025-09-09 17:53:52 +08:00
d231f3678c 首页UI 2025-09-09 16:18:35 +08:00
cd35562244 标签页调整 2025-09-09 14:41:37 +08:00
21cb512237 启动图标;动态界面调整 2025-09-08 18:06:39 +08:00
0aa3069efe 调整im 登陆 2025-09-08 16:02:46 +08:00
b79073b295 优化 firebase 报错 2025-09-08 15:42:55 +08:00
1a41cb7aef 初步替换IM接口 2025-09-08 15:13:17 +08:00
1d632fb757 openim sdk 依赖及初始化 2025-09-08 12:10:38 +08:00
ef04450696 移除二进制文件 2025-09-05 16:54:24 +08:00
ef6fb6348f Merge branch 'main' of github.com-qq:Kevinlinpr/rider-pro-android-app 2025-09-05 16:52:10 +08:00
e61fb2ad79 修复无网崩溃 2025-09-05 16:51:27 +08:00
b4ed311978 增加加载异常处理 2025-09-05 16:42:34 +08:00
20ff6df3bf 暗黑模式下输入框为全白问题 2025-09-05 16:10:40 +08:00
18a0bb8494 动态详情页交互功能增加游客登录判断 2025-09-05 15:21:09 +08:00
9dceb99a98 群聊创建失败提示弹窗问题 2025-09-05 14:17:09 +08:00
9f14b35847 防抖调整 2025-09-04 18:28:11 +08:00
9b7349a761 暗黑模式下创建群聊界面不显示置灰状态的创建群聊按钮问题;输入框光标颜色 2025-09-04 14:23:15 +08:00
23f3baf238 Merge remote-tracking branch 'upstream/main' 2025-09-03 18:39:42 +08:00
1f1101e260 动态输入评论UI调整 2025-09-03 18:38:11 +08:00
ca9f4a372b Merge remote-tracking branch 'origin/main' 2025-09-03 18:07:59 +08:00
d93373d8fa Refactor: Add debounce for navigation and optimize comments loading
- Implemented debounced navigation to prevent multiple rapid navigations.
- Replaced Pager-based comment loading with a simpler list-based approach for improved performance and reduced complexity.
- Added loading and error states for comment fetching.
- Introduced `debouncedClickable` modifier for handling click events with debounce.
- Updated image viewer to use simple navigation arrows instead of HorizontalPager for better user experience.
- Added a new string resource for password length error.
2025-09-03 18:07:44 +08:00
ae7254163a Refactor: Add debounce for navigation and optimize comments loading
- Implemented debounced navigation to prevent multiple rapid navigations.
- Replaced Pager-based comment loading with a simpler list-based approach for improved performance and reduced complexity.
- Added loading and error states for comment fetching.
- Introduced `debouncedClickable` modifier for handling click events with debounce.
- Updated image viewer to use simple navigation arrows instead of HorizontalPager for better user experience.
- Added a new string resource for password length error.
2025-09-03 18:02:39 +08:00
e49e509c38 暗黑模式缺省图;UI调整 2025-09-03 17:37:52 +08:00
d703b5ae05 Refactor: Add debounce for navigation and optimize comments loading
- Implemented debounced navigation to prevent multiple rapid navigations.
- Replaced Pager-based comment loading with a simpler list-based approach for improved performance and reduced complexity.
- Added loading and error states for comment fetching.
- Introduced `debouncedClickable` modifier for handling click events with debounce.
- Updated image viewer to use simple navigation arrows instead of HorizontalPager for better user experience.
- Added a new string resource for password length error.
2025-09-03 16:03:57 +08:00
824be5fad8 图片添加加载效果
- 为AsyncImage添加了Shimmer加载效果
- 优化了热门动态的加载逻辑
- 统一了ViewModel的重置方法名
2025-09-03 14:59:47 +08:00
79547de2db 账户编辑、评论点赞功能优化和UI调整
**功能优化:**

*   **账户编辑:**
    *   昵称和个人简介输入时自动去除换行符。
    *   修复了进入编辑页面时可能未正确加载或重置用户资料的问题。
    *   保存资料时,确保昵称和个人简介中的换行符被移除。
    *   清除裁剪的头像图片,避免重复使用。
*   **评论点赞/取消点赞:**
    *   引入乐观更新策略,提升用户体验,点赞/取消点赞操作会立即反映在UI上,然后进行后台API调用。
    *   增加防抖机制,防止用户快速重复点击点赞/取消点赞按钮导致多次API请求。

**UI调整:**

*   **账户编辑页面:**
    *   页面切换动画调整为iOS风格的底部滑入滑出效果。
2025-09-03 14:21:13 +08:00
16f95782f8 优化AI页面资源管理和数据加载
- AgentViewModel:
    - 新增 `refreshAgentData` 方法用于刷新推荐Agent数据。
    - 新增 `ensureDataLoaded` 方法用于检查推荐Agent数据是否为空,如果为空则重新加载。
- ResourceCleanupManager:
    - `cleanupPageResources` 方法新增 `preserveRecommendedData` 参数,用于控制是否保留推荐数据。
    - 新增 `cleanupAiPageCompletely` 方法,用于完全清理AI页面资源(包括推荐数据),主要用于登出等场景。
- Agent.kt:
    - 在页面进入时调用 `viewModel.ensureDataLoaded()` 确保推荐Agent数据已加载。
    - 页面退出时不再清理推荐Agent数据,只在完全登出或需要彻底清理时才清理。

此更改旨在:
- 避免不必要的推荐Agent数据重复加载。
- 优化AI页面退出时的资源清理逻辑,仅在需要时才清理推荐数据。
2025-09-03 11:49:53 +08:00
3aa0172580 Api地址调整 2025-09-03 11:42:59 +08:00
a950504d6f 修改头像昵称获取方式,修改接口地址 2025-09-03 11:41:28 +08:00
288728f142 修复添加Agent时取消选择头像后数据被清空的问题
- 引入`isSelectingAvatar`状态来标记是否正在选择头像。
- 当用户从图片裁剪页面返回或取消选择时,不再清空`AddAgentViewModel`中的数据。
- 仅当用户从`AddAgent`页面返回(并且不是在选择头像的过程中)时,才清空已填写的数据。
- 调整了`AddAgent`页面的返回逻辑,以确保在选择头像过程中返回时数据得以保留。
2025-09-03 11:05:12 +08:00
db85deea96 群聊仅自己可见开关调整 2025-09-02 18:28:37 +08:00
7882ab8f19 评论区UI调整 2025-09-02 17:38:56 +08:00
2b0d33fdbe 上架图-缺省图调整 2025-09-02 16:32:59 +08:00
0ac3312fea 群聊信息UI调整 2025-09-01 18:52:50 +08:00
2f61850c33 群聊信息;更改密码UI调整 2025-09-01 18:17:50 +08:00
6c743a6cac VIP页面UI调整 2025-09-01 18:01:43 +08:00
c202f0d280 UI调整 2025-09-01 17:54:38 +08:00
c20b6ba682 动态tab栏UI调整
- 移除不安全的HttpClient,使用安全的HttpClient
- 动态tab栏非激活状态颜色调整
2025-09-01 17:46:00 +08:00
ab43f154f5 Refactor: 优化智能体操作交互
- 个人主页智能体列表项增加长按操作,用于弹出操作菜单。
- 实现智能体操作菜单弹窗,提供删除等操作选项。
- 增加删除智能体确认对话框,防止误操作。
- 移除账号页面密码输入框重构为通用组件。
2025-09-01 17:14:22 +08:00
9c7c87722b 切换评论排列方式时屏幕下方会出现红线调整 2025-09-01 16:51:12 +08:00
337bda46a2 Ai列表的游客模式优化 2025-09-01 16:14:53 +08:00
2e176605f7 Merge remote-tracking branch 'origin/main' 2025-09-01 16:03:58 +08:00
6721d715e7 关注、粉丝列表点击关注按钮增加游客登录判断 2025-09-01 16:03:12 +08:00
b9fac2c1ee 暗黑模式下更换头像后上方白边调整 2025-09-01 16:02:05 +08:00
118a6cf986 优化DropdownMenu UI
- 修复聊天页静音的显示文案问题
- 优化DropdownMenu的UI
2025-09-01 15:50:10 +08:00
36d322ef8c Reset ViewModels on logout/account switch
This commit introduces a `ResetModel()` function to `AgentViewModel` and `MineAgentViewModel` to clear their state.

This function is now called in `ResourceCleanupManager` and `AppState` during logout or when switching accounts to ensure that data from the previous session is not retained.

Additionally, the search and group name input fields in `CreateGroupChatScreen` are now single-line.
2025-09-01 15:36:22 +08:00
5c12982908 Refactor: Use common password validator
Refactors password validation logic to use a common `PasswordValidator` utility. This ensures consistent password validation rules across different parts of the application, including:

- User login
- Email sign-up
- Password change
- Account removal

Also adds a string resource for password too long error message.
2025-09-01 15:10:14 +08:00
4d319351b7 朋友圈刷新方式调整 2025-09-01 15:02:39 +08:00
76bcb1e7fc Limit user bio display to one line
Ensures that user bios in the user item component are truncated to a single line with an ellipsis if they exceed the available width. This applies to both users with existing bios and those with the "No bio here." placeholder.
2025-09-01 14:34:03 +08:00
83cff9d56c Enhance AI Agent Profile Interaction
This commit introduces several enhancements to how AI agent profiles are displayed and interacted with:

**Profile Display:**
- **AI Account Distinction:** Profile pages now differentiate between regular user accounts and AI agent accounts.
    - AI agent profiles will not display the "Agents" tab in their profile.
    - The profile header height is adjusted for AI accounts.
- **Navigation Parameter:** An `isAiAccount` boolean parameter is added to the `AccountProfile` navigation route to indicate if the profile being viewed belongs to an AI.

**Interaction & Navigation:**
- **Avatar Click Navigation:**
    - Clicking an AI agent's avatar in various lists (Hot Agents, My Agents, User Agents Row, User Agents List) now navigates to the agent's dedicated profile page.
    - When navigating to an agent's profile from an agent list, `isAiAccount` is set to `true`.
- **Chat Initiation:** Clicking the chat button on AI agent cards in the "Agent" tab (both Hot and My Agents) now correctly initiates a chat with the respective AI.
- **ViewModel Updates:**
    - `AgentViewModel`, `MineAgentViewModel`, and `HotAgentViewModel` now include a `goToProfile` function to handle navigation to agent profiles, correctly passing the `isAiAccount` flag.

**Code Refinements:**
- Click handlers for agent avatars and chat buttons are now wrapped with `DebounceUtils.simpleDebounceClick` to prevent multiple rapid clicks.
- The `UserContentPageIndicator` now conditionally hides the "Agent" tab based on the `isAiAccount` status.
- `UserAgentsRow` and `UserAgentsList` now accept an `onAvatarClick` callback for navigating to agent profiles.
- `AgentItem` (used in `UserAgentsRow`) and `UserAgentCard` (used in `UserAgentsList`) now handle avatar clicks.
- The general `Agent` composable (used in `AiPostComposable`) now also supports an `onAvatarClick` callback.
2025-09-01 14:17:44 +08:00
484d641554 Enhance AI Agent Profile Interaction
This commit introduces several enhancements to how AI agent profiles are displayed and interacted with:

**Profile Display:**
- **AI Account Distinction:** Profile pages now differentiate between regular user accounts and AI agent accounts.
    - AI agent profiles will not display the "Agents" tab in their profile.
    - The profile header height is adjusted for AI accounts.
- **Navigation Parameter:** An `isAiAccount` boolean parameter is added to the `AccountProfile` navigation route to indicate if the profile being viewed belongs to an AI.

**Interaction & Navigation:**
- **Avatar Click Navigation:**
    - Clicking an AI agent's avatar in various lists (Hot Agents, My Agents, User Agents Row, User Agents List) now navigates to the agent's dedicated profile page.
    - When navigating to an agent's profile from an agent list, `isAiAccount` is set to `true`.
- **Chat Initiation:** Clicking the chat button on AI agent cards in the "Agent" tab (both Hot and My Agents) now correctly initiates a chat with the respective AI.
- **ViewModel Updates:**
    - `AgentViewModel`, `MineAgentViewModel`, and `HotAgentViewModel` now include a `goToProfile` function to handle navigation to agent profiles, correctly passing the `isAiAccount` flag.

**Code Refinements:**
- Click handlers for agent avatars and chat buttons are now wrapped with `DebounceUtils.simpleDebounceClick` to prevent multiple rapid clicks.
- The `UserContentPageIndicator` now conditionally hides the "Agent" tab based on the `isAiAccount` status.
- `UserAgentsRow` and `UserAgentsList` now accept an `onAvatarClick` callback for navigating to agent profiles.
- `AgentItem` (used in `UserAgentsRow`) and `UserAgentCard` (used in `UserAgentsList`) now handle avatar clicks.
- The general `Agent` composable (used in `AiPostComposable`) now also supports an `onAvatarClick` callback.
2025-09-01 01:10:35 +08:00
12b3c15083 允许在群聊列表中跳转到群聊信息页面 2025-09-01 00:02:20 +08:00
cb253d3276 完善个人信息编辑
- 修复了从相册选择图片时,如果文件名不包含扩展名导致的崩溃问题。
- 在编辑个人信息页面增加了“修改密码”的入口。
- 修复了新建Agent时,保存按钮状态更新不及时的问题。
- 优化了新建Agent时,对异常的处理。
2025-08-31 23:59:05 +08:00
2907e7f9a6 Refactor: 优化关注列表和代码逻辑
本次提交主要包含以下更改:

- **关注/粉丝列表逻辑修正**:修复了在 `FollowerListViewModel` 和 `FollowingListViewModel` 中 `followerId` 和 `followingId` 属性赋值错误的问题,确保正确加载关注和粉丝列表。
- **分页大小调整**:将 `BaseFollowModel` 中分页加载的 `pageSize` 从 5 调整为 20,以提高用户体验。
- **个人资料页智能体展示**:
    - 在 `AccountProfileV2` 和 `AccountProfileViewModel` 中增加了对用户智能体列表的加载和展示逻辑。
    - 修复了 `MyProfileViewModel` 中加载当前用户智能体时 `authorId` 传递错误的问题。
    - 优化了 `UserAgentsRow` 和 `UserAgentsViewModel` 的逻辑,确保正确加载和显示用户智能体,并在用户切换时清理旧数据。
- **代码清理**:移除了部分冗余的 `println` 调试信息。
- **Agent创建流程优化**:在 `AddAgentViewModel` 和 `AgentImageCropScreen` 中,确保在已有裁剪图片时直接使用,避免重复裁剪。
2025-08-31 23:50:52 +08:00
281169cfcb UI优化 2025-08-31 23:26:13 +08:00
ba7eeeca90 增加智能体头像裁剪页面
- 新增 `AgentImageCropScreen.kt` 用于智能体头像的裁剪和预览。
- 调整导航逻辑,添加智能体时跳转到新的 `AgentImageCrop` 路由。
- 原有的 `ImageCropScreen` 专门用于处理个人资料头像的裁剪。
- `AddAgentViewModel` 中移除 `isFromAddAgent` 标志,通过不同的导航路由区分头像裁剪来源。
2025-08-31 23:18:33 +08:00
00824ff7b4 Refactor: 优化个人主页和账户主页帖子加载逻辑
- 统一MyProfileViewModel和AccountProfileViewModel中的帖子加载逻辑,使用一致的pageSize。
- 在ProfileWrap和AccountProfileV2中传递正确的postCount。
- 在ProfileV3中改进了加载更多帖子的触发条件,确保在有更多数据时才触发加载。
- 修复了注册页面勾选协议和促销选项后,错误状态未清除的问题。
- 为DataLoader和ProfileV3中的滚动加载逻辑添加了详细日志,方便调试。
2025-08-31 23:04:52 +08:00
3777a76c44 群聊头像支持点击跳转到群信息页面 2025-08-31 22:24:53 +08:00
40ccd70e80 Add VIP selection page and related data models
This commit introduces a new VIP selection page (`VipSelPage.kt`) that allows users to choose between Premium and Standard membership plans.

Key changes include:

*   **New VIP Selection UI:**
    *   `VipSelPage.kt`: Implements the UI for selecting VIP plans, displaying prices, and benefits.
    *   `SelfProfileAction.kt`: Updated to include a "Rave Premium" button alongside "Edit Profile".
*   **Data Models for Membership:**
    *   `MembershipModels.kt`: Defines data classes for membership configuration (`MembershipConfigData`, `ConfigData`, `Member`, `Benefit`, `Good`), price models (`VipPriceModel`), page data models (`VipPageDataModel`), and request bodies (`ValidateProductRequestBody`, `ValidateData`).
    *   `VipModelMapper`: Provides functions to transform backend data into UI-friendly models for price and benefit display.
*   **API Integration:**
    *   `RiderProAPI.kt`: Added new endpoints `getMembershipConfig` to fetch membership details and `validateAndroidProduct` for product validation.
*   **Navigation:**
    *   `Navi.kt`: Added `VipSelPage` to the navigation routes.
    *   `ProfileV3.kt`: The "Rave Premium" button in the self profile action now navigates to the `VipSelPage`.
*   **Theming:**
    *   `Colors.kt`: Added new color definitions for premium buttons, VIP benefit highlighting, and price card states (selected/unselected).
*   **Assets:**
    *   `ic_member.webp`: New icon for the "Rave Premium" button.
2025-08-31 22:17:20 +08:00
5759d4ec95 导航切换动画调整
将默认的淡入淡出动画效果替换为更接近iOS风格的侧滑动画,提升页面切换的流畅度和视觉体验。

**具体变更:**

*   **页面进入:** 新页面从右侧滑入。
*   **页面退出:** 当前页面向右侧滑出,前一页面从左侧轻微偏移处滑回。
*   **动画时长:** 统一设置为280毫秒。

**影响范围:**

*   图片详情页 (`ImagePagerScreen`)
*   创建群聊页 (`CreateGroupChatScreen`)

**其他优化:**

*   **创建群聊页UI调整:**
    *   群聊名称输入框样式统一,采用圆角灰色背景。
    *   底部创建按钮适配导航栏高度。
    *   列表区域自适应填满剩余空间,防止内容被遮挡。
    *   选择成员列表项固定高度,避免选中状态变化时布局跳动。
    *   为头像和选择框添加默认图和占位图。
*   **ImageLoader优化:**
    *   实现全局共享的 `ImageLoader` 实例,避免重复创建,提高内存缓存利用率。
*   **列表性能优化:**
    *   为好友列表和AI助手列表的 `items` 添加 `key`,提升列表项更新效率。
*   **资源清理调整:**
    *   移除了在离开首页和动态页时全量清理资源的操作,以避免返回时列表重置或不必要的重新加载。
*   **ProfileV3页代码清理:**
    *   移除未使用的导入。
2025-08-31 21:13:06 +08:00
21200910c1 Refactor: 个人主页UI及智能体列表功能调整
- **个人主页UI调整:**
    - "编辑个人资料"按钮样式调整为与"私信"按钮一致。
    - "关注"按钮样式调整为渐变色,"已关注"状态下显示灰色边框。
    - "私信"按钮样式调整为灰色背景。
    - "帖子"、"粉丝"、"关注"文案调整。
    - 个人主页背景色适配深色模式。
    - 个人主页内容切换Tab图标化,并添加下划线指示器。
- **智能体列表功能:**
    - 新增`UserAgentsList.kt`用于展示用户的智能体列表。
    - 在个人主页中集成智能体列表展示(新的Tab页)。
    - 调整`UserAgentsRow`组件,使其能够加载并展示当前用户或其他用户的智能体,并添加"更多"按钮。
    - `MyProfileViewModel`中增加对智能体数据的加载和管理。
    - `UserAgentsViewModel`调整为可以加载指定用户或当前用户的智能体数据。
- **其他:**
    - `Colors.kt`中新增`profileBackground`颜色定义。
    - `Agent.kt`中调整了`getAgent`方法返回类型。
2025-08-31 20:23:28 +08:00
a79628026c 首页UI调整 2025-08-29 18:39:59 +08:00
7c0d35ec8c 首页搜索调整 2025-08-28 18:58:22 +08:00
da5fdcbd57 删除账户UI调整 2025-08-28 14:30:47 +08:00
weber
fdf8c1fa5a 资源清理管理 2025-08-27 18:32:51 +08:00
weber
2a7d310be5 新增网络工具,修改bug 2025-08-27 16:37:53 +08:00
weber
52e571da01 用户信息调整 2025-08-27 11:39:50 +08:00
5d4a95bf07 UI调整 2025-08-26 18:42:33 +08:00
f2ab55d545 Merge upstream/main and resolve conflicts 2025-08-25 18:48:56 +08:00
weber
df75c710e5 UI调整 2025-08-25 18:35:06 +08:00
weber
77033854f0 UI调整 2025-08-25 10:49:00 +08:00
Weber
4be63f3428 Merge pull request #16 from Zhong202501/main
粉丝 关注 评论UI调整
2025-08-25 10:09:57 +08:00
52d32a8510 粉丝 关注 评论UI调整 2025-08-22 18:52:41 +08:00
bd2d291164 Merge upstream/main and resolve conflicts 2025-08-22 10:41:30 +08:00
weber
9fb79b3881 聊天室和群聊信息调整 2025-08-21 19:04:59 +08:00
weber
5ee1897739 消息列表和聊天时调整 2025-08-21 17:08:18 +08:00
Weber
edcab76fdb Merge pull request #15 from Zhong202501/zhong
UI调整
2025-08-21 16:51:49 +08:00
ed9dd9ab3e first push 2025-08-21 16:49:24 +08:00
a9066bd473 test push 2025-08-21 16:43:30 +08:00
3e9353f07b UI调整 2025-08-21 16:36:54 +08:00
weber
8f8c2ff2e9 UI调整,群聊开发 2025-08-20 19:19:14 +08:00
weber
791b24b2fb UI调整,群聊天室开发 2025-08-18 19:02:11 +08:00
weber
2de8127882 首页UI调整 2025-08-15 18:56:38 +08:00
weber
112efa74e7 首页UI调整 2025-08-14 19:04:20 +08:00
weber
d8c091b19b 页面样式调整 2025-08-14 15:39:21 +08:00
weber
cd3fc03524 修正会话分组列表数据 2025-08-13 19:04:50 +08:00
weber
bc7a897cec 预留首页-探索,完善群组功能 2025-08-13 18:57:03 +08:00
weber
2d518cbd68 自定义NavigationItem,新增群组创建页面 2025-08-12 19:06:56 +08:00
weber
697af504b7 会话分组及聊天室实现 2025-08-11 18:21:22 +08:00
weber
54ca1d3f1c Ai和用户类型分组验证 2025-08-08 18:53:10 +08:00
1bb0adeb90 添加标签组件并更新导航栏Add按钮样式
- 新增可复用的`TabItem`和`TabSpacer`组件,用于实现标签页切换效果。
- 在消息列表和AI Agent页面中,使用新的`TabItem`和`TabSpacer`组件替换原有的标签页实现,简化代码并统一风格。
- 更新底部导航栏Add按钮的图标和交互行为:
    - 使用新的`ic_nav_add.xml`图标。
    - Add按钮只显示图标,不显示文字标签。
    - Add按钮图标放大。
- 在`Colors.kt`中为`AppThemeData`添加新的颜色属性,以支持新标签组件的自定义主题。
2025-08-08 09:57:42 +08:00
weber
f6a796e2bc 智能体会话开发 2025-08-07 19:03:05 +08:00
weber
38759eb3e4 处理下标越界和防抖 2025-08-06 18:47:24 +08:00
weber
b837c704e5 我的智能体 2025-08-06 18:14:36 +08:00
weber
a944bd0fa3 智能体列表,全部 2025-08-06 15:58:17 +08:00
weber
993604bfc1 我的智能体 2025-08-05 17:01:23 +08:00
dc9c013383 更新消息和AI代理页面的文本颜色,注释掉部分消息列表初始化逻辑 2025-08-05 16:51:17 +08:00
91c34a0acf Merge remote-tracking branch 'origin/main' 2025-08-05 16:31:46 +08:00
a2254e503e 适配夜间模式 2025-08-05 16:31:28 +08:00
weber
daea7824af create agent 2025-08-05 16:25:06 +08:00
0f5d3d7960 限制帖子图片数量为9张 2025-08-05 16:20:30 +08:00
6433d4a23c 修改颜色配置 2025-08-05 16:00:36 +08:00
873001ce28 在 FavouriteListViewModel 中增加 EventBus 监听,以便在动态取消收藏时刷新列表。 2025-08-05 15:47:30 +08:00
7ea75a4755 区分动态发布者和其他用户在动态菜单中的操作权限 2025-08-05 15:12:29 +08:00
a80711a475 统一重置密码界面背景色和错误文本颜色 2025-08-05 15:03:05 +08:00
weber
3e544844bb 添加新的表单文本输入组件 FormTextInput2,包含错误提示和动态显示功能;新增图标和图片资源。 2025-08-05 14:24:15 +08:00
weber
29d2bb753f 实现了 Agent 和 Profile 数据类,添加了 AddAgent 界面 2025-07-31 15:37:17 +08:00
weber
6ec732b996 20250723-Test Push 2025-07-23 19:07:29 +08:00
9f93e6dc14 更新代码 2024-12-07 17:14:45 +08:00
50fb1874e7 增加动态举报 2024-12-06 22:08:57 +08:00
76e7bbb84a 添加Chat处理逻辑 2024-12-06 10:57:31 +08:00
cc7a65e016 更新Google登录 2024-12-01 21:27:39 +08:00
784427cfba 更新密码校验规则 2024-12-01 15:29:03 +08:00
c54d5c914a 更新关注逻辑 2024-12-01 15:13:47 +08:00
79fccda1aa 更新动态加载逻辑 2024-12-01 09:40:13 +08:00
6c19f83cfb 更新个人资料同时更新聊天个人信息 2024-11-30 22:24:28 +08:00
3a68a51f3c 更新聊天头像逻辑 2024-11-30 08:50:50 +08:00
324e04881e 修改推送配置 2024-11-28 18:03:24 +08:00
5cacfbdd95 Rave Now 版本 VERSION 1.0.000.19 2024-11-24 21:14:41 +08:00
9fac012d11 移除https错误忽略 2024-11-24 19:58:53 +08:00
ca5629043b Rave Now 版本 VERSION 1.0.000.18 2024-11-21 06:18:16 +08:00
b697082732 Rave Now 版本 新签名 2024-11-17 20:54:32 +08:00
a243abb8a2 Rave Now 版本1.0.000.17 2024-11-17 20:41:11 +08:00
e75b181818 Rave Now 版本1.0.000.17 2024-11-17 20:35:42 +08:00
4cdb7f28b3 完全替换为Rave Now,只保留了Google Play的密钥还有一些图片映射还有旧字样。 2024-11-17 20:31:18 +08:00
074244c0f8 改包名com.aiosman.ravenow 2024-11-17 20:07:42 +08:00
914cfca6be 更新软件Logo 2024-11-15 20:04:15 +08:00
e62e83c566 Merge remote-tracking branch 'refs/remotes/origin/main' 2024-11-14 22:41:23 +08:00
5b2613cacc 修正firebase analytics 2024-11-14 17:49:26 +08:00
ee88708860 1.0.000.16 2024-11-05 04:37:03 +08:00
34ce648102 fix #12 修正删除动态后,移除我的页面中的item 2024-11-03 16:51:23 +08:00
7152bfd9c1 fix #9 修正多次点击的白屏,添加修改用户资料防抖 2024-11-03 12:48:37 +08:00
d5e210b080 更新代码 2024-11-03 09:18:28 +08:00
3cef90c887 更新代码 2024-11-03 09:17:46 +08:00
d49e037f51 fix #7 新增了修改密码时错误的旧密码提示 2024-11-03 09:15:19 +08:00
7ed3c51118 fix #6 修正了创建新的评论后,评论区重复显示评论的问题 2024-11-03 08:20:48 +08:00
560f4655df fix #5
修正了按钮高度问题
2024-11-03 00:32:58 +08:00
720bafe787 添加com.google.ar.core 2024-10-30 01:11:50 +08:00
068ca693ea 1.0.000.15 2024-10-29 05:30:54 +08:00
d6e2db7edb 删除post中的following按钮 2024-10-29 05:30:01 +08:00
5b3d663a73 修正动态页面错误 2024-10-27 11:31:27 +08:00
be18a60b2b 修正个人主页阴影错误 2024-10-26 23:05:31 +08:00
e1ba8709de 禁止在主页进行unfollow操作 2024-10-26 21:59:37 +08:00
e8ddd67219 屏蔽关注动态页面的关注按钮 2024-10-26 19:18:50 +08:00
2012668361 动态主题切换 2024-10-26 19:05:52 +08:00
01fb092e83 feat: Implement moment follow/unfollow feature
This commit implements the follow/unfollow feature for moments in the Search, Timeline, and Explore tabs.

It includes:

- Adding a follow button to moment cards and implementing follow/unfollow logic in the respective ViewModels.
- Updating the UI to reflect the follow status changes in the moment list.
- Handling follow/unfollow API requests.
2024-10-26 17:44:51 +08:00
fe2bd2f382 feat: 实现探索动态功能
在动态列表中新增探索动态功能,允许用户浏览和发现新的动态内容。

具体修改包括:

- 在API接口中添加`explore`参数,用于请求探索动态数据。
- 修改MomentService,添加`explore`参数,用于获取探索动态数据。
- 更新MomentViewModel,使用`explore`参数请求探索动态数据。
- 修改MomentPagingSource,使用`explore`参数请求探索动态数据。
- 修改MomentCard,使用`explore`参数显示探索动态数据。
2024-10-26 17:17:29 +08:00
5c1cca6a69 修改更新提示的颜色 2024-10-26 16:48:45 +08:00
9838439ff1 补充提交 2024-10-26 16:14:47 +08:00
994e493035 版本更新提示 2024-10-26 16:06:59 +08:00
fd2f1cfc1d 版本1.0.000.15 2024-10-26 06:54:56 +08:00
8dca3e8de1 暗黑模式按钮、更新弹框文案 2024-10-26 06:53:47 +08:00
e38c36aa2c 软件内版本检测更新 2024-10-26 06:02:30 +08:00
ed66f15be7 修复小细节,home图标和消息通知 2024-10-26 02:51:23 +08:00
d3518b1a82 首页新增推荐 2024-10-25 22:01:58 +08:00
5e65f217bb 底部导航Mask颜色使用AppColor 2024-10-25 03:33:21 +08:00
c183c2fb9d TextField图标文字对齐(password) 2024-10-25 02:49:04 +08:00
684078515f 提取应用颜色 2024-10-24 23:53:51 +08:00
9cf6a338ea 提取App颜色 2024-10-24 17:22:05 +08:00
9e55043a17 新消息时自动刷新 2024-10-24 15:00:11 +08:00
fde7a0ad95 问题修正 2024-10-23 21:09:59 +08:00
dad032e233 问题修正 2024-10-23 20:32:32 +08:00
1242 changed files with 56865 additions and 11307 deletions

2
.idea/.name generated
View File

@@ -1 +1 @@
RiderPro RaveNow

2
.idea/compiler.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" /> <bytecodeTargetLevel target="21" />
</component> </component>
</project> </project>

View File

@@ -4,10 +4,10 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2024-08-11T15:57:44.196893Z"> <DropdownSelection timestamp="2025-11-11T06:03:31.167121900Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/kevinlinpr/.android/avd/Pixel_8_Pro_API_34.avd" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=f800b364" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

2
.idea/gradle.xml generated
View File

@@ -4,6 +4,7 @@
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" /> <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules"> <option name="modules">
@@ -12,7 +13,6 @@
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
</set> </set>
</option> </option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings> </GradleProjectSettings>
</option> </option>
</component> </component>

View File

@@ -65,6 +65,10 @@
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true"> <inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />

2
.idea/kotlinc.xml generated
View File

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

3
.idea/misc.xml generated
View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

View File

@@ -5,8 +5,12 @@
<set> <set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" /> <option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" /> <option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" /> <option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" /> <option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set> </set>
</option> </option>
</component> </component>

176
MIGRATION_GUIDE.md Normal file
View File

@@ -0,0 +1,176 @@
# 腾讯云 IM SDK 到 OpenIM 迁移指南
## 迁移状态
### ✅ 已完成的工作
1. **依赖项迁移**
-`build.gradle.kts` 中移除了腾讯云 IM SDK 依赖 (`libs.imsdk.plus`)
-`gradle/libs.versions.toml` 中移除了相关版本定义
- 保留了 OpenIM SDK 依赖 (`io.openim:android-sdk`, `io.openim:core-sdk`)
2. **核心组件迁移**
-`TrtcHelper.kt` - 完全迁移到 OpenIM API
-`Chat.kt` 实体类 - 更新为 OpenIM 消息模型
-`AgentChatListViewModel.kt` - 部分迁移到 OpenIM API
-`OpenIMManager.kt` - 完整的 OpenIM 管理器
-`OpenIMService.kt` - OpenIM 后台服务
-`AppState.kt` - 已使用 OpenIM 进行初始化和登录
3. **兼容层创建**
- 创建了 `TencentIMCompat.kt` 兼容层,避免编译错误
- 所有使用腾讯云 IM 的文件都已添加兼容层导入
4. **配置清理**
- AndroidManifest.xml 已经是干净的,无需额外清理
### 🔄 需要进一步完成的工作
#### 1. 完整的 ViewModel 迁移
以下文件仍在使用兼容层,需要完全迁移到 OpenIM
- `ChatViewModel.kt` - 聊天功能核心
- `ChatAiViewModel.kt` - AI 聊天功能
- `GroupChatViewModel.kt` - 群聊功能
- `FriendChatListViewModel.kt` - 好友聊天列表
- `GroupChatListViewModel.kt` - 群聊列表
- `MessageListViewModel.kt` - 消息列表
- `MineAgentViewModel.kt` - 我的智能体
- `CreateGroupChatViewModel.kt` - 创建群聊
#### 2. UI 组件迁移
以下 Screen 文件需要更新以使用新的数据模型:
- `ChatScreen.kt`
- `ChatAiScreen.kt`
- `GroupChatScreen.kt`
#### 3. 消息类型映射
需要完善 OpenIM 消息类型到应用内部类型的映射:
```kotlin
// OpenIM 消息类型
101 -> TEXT
102 -> IMAGE
103 -> AUDIO
104 -> VIDEO
105 -> FILE
```
## OpenIM 集成状态
### ✅ 已集成的功能
1. **SDK 初始化** - `OpenIMManager.initSDK()`
2. **用户登录** - `AppState.loginToOpenIM()`
3. **连接监听** - 连接状态、踢下线、token 过期
4. **消息监听** - 新消息、消息撤回、已读回执等
5. **会话管理** - 会话变化、未读数统计
6. **用户信息管理** - 用户资料更新
7. **好友关系管理** - 好友申请、添加、删除等
8. **群组管理** - 群信息变更、成员管理等
### 🔧 需要实现的功能
1. **消息发送**
```kotlin
// 需要实现
OpenIMClient.getInstance().messageManager.sendMessage(...)
```
2. **历史消息获取**
```kotlin
// 需要实现
OpenIMClient.getInstance().messageManager.getAdvancedHistoryMessageList(...)
```
3. **会话列表获取**
```kotlin
// 已在 AgentChatListViewModel 中部分实现
OpenIMClient.getInstance().conversationManager.getAllConversationList(...)
```
4. **图片消息处理**
- 需要适配 OpenIM 的 `PictureElem` 结构
- 更新图片显示逻辑
## 迁移步骤建议
### 第一阶段:核心聊天功能
1. 完成 `ChatViewModel.kt` 的完整迁移
2. 实现消息发送和接收
3. 实现历史消息加载
4. 测试基本聊天功能
### 第二阶段:会话管理
1. 完成各种 ChatListViewModel 的迁移
2. 实现会话列表的正确显示
3. 实现未读消息统计
### 第三阶段:高级功能
1. 群聊功能迁移
2. 文件、图片等多媒体消息
3. 消息状态和已读回执
### 第四阶段:清理和优化
1. 删除兼容层 `TencentIMCompat.kt`
2. 清理所有临时代码
3. 性能优化和测试
## 关键 API 对比
### 消息发送
```kotlin
// 腾讯云 IM
V2TIMManager.getMessageManager().sendMessage(message, receiver, null, ...)
// OpenIM
OpenIMClient.getInstance().messageManager.sendMessage(message, receiver, ...)
```
### 获取会话列表
```kotlin
// 腾讯云 IM
V2TIMManager.getConversationManager().getConversationList(...)
// OpenIM
OpenIMClient.getInstance().conversationManager.getAllConversationList(...)
```
### 消息监听
```kotlin
// 腾讯云 IM
V2TIMManager.getMessageManager().addAdvancedMsgListener(listener)
// OpenIM
OpenIMClient.getInstance().messageManager.setAdvancedMsgListener(listener)
```
## 注意事项
1. **数据结构差异**OpenIM 和腾讯云 IM 的数据结构有所不同,需要仔细映射
2. **回调机制**OpenIM 使用不同的回调接口
3. **消息 ID**OpenIM 使用 `clientMsgID`,腾讯云使用 `msgID`
4. **时间戳**:注意时间戳的单位和格式差异
5. **用户 ID**:确保用户 ID 在两个系统中的一致性
## 测试建议
1. **单元测试**:为每个迁移的组件编写测试
2. **集成测试**:测试完整的聊天流程
3. **兼容性测试**:确保与现有数据的兼容性
4. **性能测试**:对比迁移前后的性能表现
## 删除兼容层的时机
当以下条件都满足时,可以安全删除 `TencentIMCompat.kt`
1. 所有 ViewModel 都已完全迁移到 OpenIM
2. 所有功能都已测试通过
3. 没有编译错误
4. 应用运行正常
删除步骤:
1. 删除 `app/src/main/java/com/aiosman/ravenow/compat/TencentIMCompat.kt`
2. 从所有文件中移除 `import com.aiosman.ravenow.compat.*`
3. 清理所有相关的临时注释

View File

@@ -1,21 +1,24 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.compose.compiler)
id("com.google.gms.google-services") id("com.google.gms.google-services")
id("com.google.firebase.crashlytics") id("com.google.firebase.crashlytics")
id("com.google.firebase.firebase-perf") id("com.google.firebase.firebase-perf")
id("org.jetbrains.kotlin.kapt")
alias(libs.plugins.ksp)
} }
android { android {
namespace = "com.aiosman.riderpro" namespace = "com.aiosman.ravenow"
compileSdk = 34 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "com.aiosman.riderpro" applicationId = "com.aiosman.ravenow"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 35
versionCode = 1000014 versionCode = 1000019
versionName = "1.0.000.14" versionName = "1.0.000.19"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -24,7 +27,7 @@ android {
addManifestPlaceholders( addManifestPlaceholders(
mapOf( mapOf(
"JPUSH_PKGNAME " to applicationId!!, "JPUSH_PKGNAME " to applicationId!!,
"JPUSH_APPKEY" to "ad805ee9f2760376f4f47178", "JPUSH_APPKEY" to "cbd968cae60346065e03f9d7",
"JPUSH_CHANNEL" to "developer-default", "JPUSH_CHANNEL" to "developer-default",
) )
@@ -32,6 +35,9 @@ android {
} }
buildTypes { buildTypes {
debug {
isDebuggable = true
}
release { release {
isMinifyEnabled = false isMinifyEnabled = false
proguardFiles( proguardFiles(
@@ -41,17 +47,15 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = "1.8" jvmTarget = "17"
} }
buildFeatures { buildFeatures {
compose = true compose = true
} buildConfig = true
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
} }
packaging { packaging {
resources { resources {
@@ -93,27 +97,48 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.androidx.animation) implementation(libs.androidx.animation)
implementation("io.coil-kt:coil-compose:2.7.0") implementation(libs.coil)
implementation("io.coil-kt:coil:2.7.0") implementation(libs.coil.compose)
implementation("com.google.android.gms:play-services-auth:21.2.0") implementation(libs.coil.network.okhttp)
implementation("io.github.serpro69:kotlin-faker:2.0.0-rc.5") implementation(libs.play.services.auth)
implementation("androidx.compose.material:material:1.6.8") implementation(libs.kotlin.faker)
implementation("net.engawapg.lib:zoomable:1.6.1") implementation(libs.androidx.material)
implementation("com.squareup.retrofit2:retrofit:2.11.0") implementation(libs.androidx.material.icons.extended)
implementation("com.squareup.retrofit2:converter-gson:2.11.0") implementation(libs.zoomable)
implementation("androidx.credentials:credentials:1.2.2") implementation(libs.retrofit)
implementation("androidx.credentials:credentials-play-services-auth:1.2.2") implementation(libs.converter.gson)
implementation("com.auth0.android:jwtdecode:2.0.2") implementation(libs.androidx.credentials)
implementation(libs.androidx.credentials.play.services.auth)
implementation(libs.jwtdecode)
implementation(platform("com.google.firebase:firebase-bom:33.2.0")) implementation(platform(libs.firebase.bom))
implementation("com.google.firebase:firebase-crashlytics") implementation(libs.firebase.crashlytics)
implementation("com.google.firebase:firebase-analytics") implementation(libs.firebase.analytics)
implementation("com.google.firebase:firebase-perf") implementation(libs.firebase.perf)
implementation("com.google.firebase:firebase-messaging-ktx")
implementation ("cn.jiguang.sdk:jpush-google:5.4.0") implementation(libs.firebase.messaging.ktx)
api ("com.tencent.imsdk:imsdk-plus:8.1.6116")
implementation("io.github.rroohit:ImageCropView:3.0.1") implementation (libs.jpush.google)
implementation("androidx.core:core-splashscreen:1.0.1") // 添加 SplashScreen 依赖 implementation (libs.im.sdk)
implementation (libs.im.core.sdk)
implementation (libs.gson)
implementation(libs.imagecropview)
implementation(libs.androidx.core.splashscreen) // 添加 SplashScreen 依赖
// 添加 lifecycle-runtime-ktx 依赖
implementation(libs.androidx.lifecycle.runtime.ktx.v262)
implementation (libs.eventbus)
implementation(libs.lottie)
// CameraX + ML Kit版本在 libs.versions.toml
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.mlkit.barcode.scanning)
// Room 持久化
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
} }

View File

@@ -9,7 +9,7 @@
"client_info": { "client_info": {
"mobilesdk_app_id": "1:987156664714:android:2c29c11b9cd8be78b9f873", "mobilesdk_app_id": "1:987156664714:android:2c29c11b9cd8be78b9f873",
"android_client_info": { "android_client_info": {
"package_name": "com.aiosman.riderpro" "package_name": "com.aiosman.ravenow"
} }
}, },
"oauth_client": [], "oauth_client": [],

View File

@@ -20,4 +20,23 @@
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-keep class com.tencent.imsdk.** { *; } # OpenIM SDK ProGuard rules
-keep class io.openim.android.sdk.** { *; }
-keep class io.openim.core.** { *; }
-keepclassmembers class io.openim.android.sdk.** { *; }
-keepclassmembers class io.openim.core.** { *; }
# Keep OpenIM models and listeners
-keep class io.openim.android.sdk.models.** { *; }
-keep class io.openim.android.sdk.listener.** { *; }
-keep class io.openim.android.sdk.enums.** { *; }
# Keep OpenIM Client and managers
-keep class io.openim.android.sdk.OpenIMClient { *; }
-keep class io.openim.android.sdk.manager.** { *; }
# Prevent obfuscation of callback methods
-keepclassmembers class * implements io.openim.android.sdk.listener.** {
public *;
}

View File

@@ -1,4 +1,4 @@
package com.aiosman.riderpro package com.aiosman.ravenow
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() { fun useAppContext() {
// Context of the app under test. // Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.aiosman.riderpro", appContext.packageName) assertEquals("com.aiosman.ravenow", appContext.packageName)
} }
} }

View File

@@ -4,19 +4,24 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" /> <uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<application <application
android:name=".RaveNowApplication"
android:allowBackup="false" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/rider_pro_logo_red" android:icon="@mipmap/invalid_name"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/rider_pro_logo_red_round" android:roundIcon="@mipmap/rider_pro_logo_next_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.RiderPro" android:theme="@style/Theme.RaveNow"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="31"> tools:targetApi="31">
<meta-data <meta-data
android:name="com.google.android.geo.API_KEY" android:name="com.google.android.geo.API_KEY"
@@ -34,12 +39,22 @@
<meta-data <meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id" android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="Default Message" /> android:value="Default Message" />
<meta-data android:name="com.google.ar.core" android:value="optional" />
<!-- Firebase Performance 配置:禁用自动网络请求监控 -->
<meta-data
android:name="firebase_performance_collection_enabled"
android:value="true" />
<meta-data
android:name="firebase_performance_logcat_enabled"
android:value="false" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.App.Starting" android:theme="@style/Theme.App.Starting"
android:windowSoftInputMode="adjustResize"> android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:configChanges="fontScale|orientation|screenSize|keyboardHidden|uiMode">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -52,6 +67,13 @@
<data android:mimeType="image/*" /> <data android:mimeType="image/*" />
</intent-filter> </intent-filter>
</activity> </activity>
<receiver
android:name=".model.ApkInstallReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
</intent-filter>
</receiver>
<service <service
android:name=".MyFirebaseMessagingService" android:name=".MyFirebaseMessagingService"
@@ -70,7 +92,7 @@
</intent-filter> </intent-filter>
</service> </service>
<service <service
android:name=".TrtcService" android:name=".OpenIMService"
android:exported="false" /> android:exported="false" />
<receiver <receiver
@@ -79,14 +101,14 @@
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="cn.jpush.android.intent.RECEIVER_MESSAGE" /> <action android:name="cn.jpush.android.intent.RECEIVER_MESSAGE" />
<category android:name="com.aiosman.riderpro" /> <category android:name="com.aiosman.ravenow" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="com.aiosman.riderpro.fileprovider" android:authorities="com.aiosman.ravenow.fileprovider"
android:exported="false" android:exported="false"
android:grantUriPermissions="true"> android:grantUriPermissions="true">
<meta-data <meta-data

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,282 @@
package com.aiosman.ravenow
import android.content.Context
import android.content.Intent
import android.icu.util.Calendar
import android.icu.util.TimeZone
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.DictService
import com.aiosman.ravenow.data.DictServiceImpl
import com.aiosman.ravenow.data.PointService
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.ui.favourite.FavouriteListViewModel
import com.aiosman.ravenow.ui.favourite.FavouriteNoticeViewModel
import com.aiosman.ravenow.ui.follower.FollowerNoticeViewModel
import com.aiosman.ravenow.ui.index.IndexViewModel
import com.aiosman.ravenow.ui.index.tabs.message.MessageListViewModel
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.dynamic.DynamicViewModel
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.Explore
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.hot.HotMomentViewModel
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline.TimelineMomentViewModel
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
import com.aiosman.ravenow.ui.account.AccountEditViewModel
import com.aiosman.ravenow.ui.index.tabs.search.DiscoverViewModel
import com.aiosman.ravenow.ui.index.tabs.search.SearchViewModel
import com.aiosman.ravenow.ui.index.tabs.ai.AgentViewModel
import com.aiosman.ravenow.ui.index.tabs.ai.tabs.mine.MineAgentViewModel
import com.aiosman.ravenow.ui.like.LikeNoticeViewModel
import com.aiosman.ravenow.utils.Utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.coroutines.suspendCoroutine
import com.aiosman.ravenow.im.OpenIMManager
import io.openim.android.sdk.OpenIMClient
import io.openim.android.sdk.models.InitConfig
object AppState {
var UserId: Int? = null
var profile: AccountProfileEntity? = null
var darkMode by mutableStateOf(false)
var appTheme by mutableStateOf<AppThemeData>(LightThemeColors())
var googleClientId: String? = null
var enableGoogleLogin: Boolean = false
var enableChat = false
var agentCreatedSuccess by mutableStateOf(false)
var chatBackgroundUrl by mutableStateOf<String?>(null)
suspend fun initWithAccount(scope: CoroutineScope, context: Context) {
// 如果是游客模式,使用简化的初始化流程
if (AppStore.isGuest) {
initWithGuestAccount(scope)
return
}
val accountService: AccountService = AccountServiceImpl()
// 获取用户认证信息
val resp = accountService.getMyAccount()
// 更新必要的用户信息
val calendar: Calendar = Calendar.getInstance()
val tz: TimeZone = calendar.timeZone
val offsetInMillis: Int = tz.rawOffset
accountService.updateUserExtra(
Utils.getCurrentLanguage(),
// 时区偏移量单位是秒
offsetInMillis / 1000,
tz.displayName
)
// 设置当前登录用户 ID
UserId = resp.id
try {
var profileResult = accountService.getMyAccountProfile()
profile = profileResult
} catch (e:Exception) {
Log.e("AppState", "getMyAccountProfile Error:"+ e.message )
}
// 获取当前用户资料
// 注册 JPush
Messaging.registerDevice(scope, context)
initChat(context)
// 设置当前用户并刷新积分信息(完成登录态初始化后)
PointService.setCurrentUser(UserId)
try {
PointService.refreshMyPointsBalance()
} catch (e: Exception) {
Log.e("AppState", "刷新积分失败: ${e.message}")
}
// 并行加载积分规则和房间规则配置(不阻塞主流程)
scope.launch {
try {
PointService.refreshPointsRules()
} catch (e: Exception) {
Log.e("AppState", "加载积分规则失败: ${e.message}")
}
}
scope.launch {
try {
PointService.refreshRoomMaxMembers()
} catch (e: Exception) {
Log.e("AppState", "加载房间规则失败: ${e.message}")
}
}
}
/**
* 游客模式的简化初始化
*/
private fun initWithGuestAccount(scope: CoroutineScope) {
// 游客模式下不初始化推送和TRTC
// 设置默认的用户信息
UserId = 0
profile = null
enableChat = false
Log.d("AppState", "Guest mode initialized without push notifications and TRTC")
// 游客模式下也加载规则配置(用于查看费用信息)
scope.launch {
try {
PointService.refreshPointsRules()
} catch (e: Exception) {
Log.e("AppState", "加载积分规则失败: ${e.message}")
}
}
scope.launch {
try {
PointService.refreshRoomMaxMembers()
} catch (e: Exception) {
Log.e("AppState", "加载房间规则失败: ${e.message}")
}
}
}
private suspend fun initChat(context: Context){
val dictService :DictService = DictServiceImpl()
val enableItem = dictService.getDictByKey(ConstVars.DICT_KEY_ENABLE_TRTC)
val isEnableTrtc = enableItem.value as? Boolean
if (isEnableTrtc != true) {
enableChat = false
return
}
val accountService: AccountService = AccountServiceImpl()
val initConfig = InitConfig(
"https://im.ravenow.ai/api",//SDK api地址
"wss://im.ravenow.ai/msg_gateway",//SDK WebSocket地址
OpenIMManager.getStorageDir(context),//SDK数据库存储目录
)
// initConfig.isLogStandardOutput = true;
// initConfig.logLevel = 6
// 使用 OpenIMManager 初始化 SDK
OpenIMManager.initSDK(context, initConfig)
try {
if (profile?.chatToken != null && profile?.trtcUserId != null) {
loginToOpenIM(profile!!.trtcUserId, profile!!.chatToken!!)
}
context.startService(Intent(context, OpenIMService::class.java))
enableChat = true
} catch (e: Exception) {
e.printStackTrace()
enableChat = false
}
}
suspend fun loginToOpenIM(userId: String, imToken: String): Boolean {
return suspendCoroutine { continuation ->
OpenIMClient.getInstance().login(object : io.openim.android.sdk.listener.OnBase<String> {
override fun onError(code: Int, error: String?) {
Log.e("AppState", "OpenIM 登录失败: code=$code, error=$error")
continuation.resumeWith(Result.failure(Exception("OpenIM Login failed: $code, $error")))
}
override fun onSuccess(data: String?) {
Log.d("AppState", "OpenIM 登录成功: $data")
//其他api调用必须保证登录回调成功后操作
continuation.resumeWith(Result.success(true))
}
}, userId, imToken)
}
}
// suspend fun updateTrtcUserProfile() {
// val accountService: AccountService = AccountServiceImpl()
// val profile = accountService.getMyAccountProfile()
// val info = V2TIMUserFullInfo()
// info.setNickname(profile.nickName)
// info.faceUrl = profile.rawAvatar
// info.selfSignature = profile.bio
// return suspendCoroutine { continuation ->
// V2TIMManager.getInstance().setSelfInfo(info, object : V2TIMCallback {
// override fun onError(code: Int, desc: String?) {
// continuation.resumeWith(Result.failure(Exception("Update user profile failed: $code, $desc")))
// }
//
// override fun onSuccess() {
// continuation.resumeWith(Result.success(Unit))
// }
// })
// }
// }
fun switchTheme() {
darkMode = !darkMode
appTheme = if (darkMode) {
DarkThemeColors()
} else {
LightThemeColors()
}
AppStore.saveDarkMode(darkMode)
}
/**
* 检查是否是游客模式,并且是否需要登录
* @return true 如果是游客模式
*/
fun isGuestMode(): Boolean {
return AppStore.isGuest
}
/**
* 检查游客模式并提示登录
* @param onGuestMode 当是游客模式时的回调
* @return true 如果是游客模式
*/
fun checkGuestModeAndPromptLogin(onGuestMode: (() -> Unit)? = null): Boolean {
if (AppStore.isGuest) {
onGuestMode?.invoke()
return true
}
return false
}
fun ReloadAppState(context: Context) {
// 重置动态列表页面
TimelineMomentViewModel.ResetModel()
DynamicViewModel.ResetModel()
HotMomentViewModel.resetModel()
// 重置我的页面
MyProfileViewModel.ResetModel()
// 重置编辑资料页面 - 暂时注释掉看是否是这里导致的问题
// AccountEditViewModel.ResetModel()
// 重置发现页面
DiscoverViewModel.ResetModel()
// 重置搜索页面
SearchViewModel.ResetModel()
// 重置消息页面
MessageListViewModel.ResetModel()
// 重置点赞通知页面
LikeNoticeViewModel.ResetModel()
// 重置收藏页面
FavouriteListViewModel.ResetModel()
// 重置收藏通知页面
FavouriteNoticeViewModel.ResetModel()
// 重置粉丝通知页面
FollowerNoticeViewModel.ResetModel()
// 重置关注通知页面
IndexViewModel.ResetModel()
// 重置AI Agent相关页面
AgentViewModel.ResetModel()
MineAgentViewModel.ResetModel()
UserId = null
// 清空积分全局状态,避免用户切换串号
PointService.clear()
// 清除游客状态
AppStore.isGuest = false
context.stopService(Intent(context, OpenIMService::class.java))
}
}

View File

@@ -1,8 +1,8 @@
package com.aiosman.riderpro package com.aiosman.ravenow
import com.aiosman.riderpro.data.ChatService import com.aiosman.ravenow.data.ChatService
import com.aiosman.riderpro.data.ChatServiceImpl import com.aiosman.ravenow.data.ChatServiceImpl
import com.aiosman.riderpro.entity.ChatNotification import com.aiosman.ravenow.entity.ChatNotification
/** /**
* 保存一些关于聊天的状态 * 保存一些关于聊天的状态

View File

@@ -0,0 +1,130 @@
package com.aiosman.ravenow
import androidx.compose.ui.graphics.Color
//var AppColors = LightThemeColors()
//var AppColors = if (AppState.darkMode) DarkThemeColors() else LightThemeColors()
open class AppThemeData(
var main: Color,
var mainText: Color,
var basicMain: Color,
var nonActive: Color,
var text: Color,
var nonActiveText: Color,
var secondaryText: Color,
var loadingMain: Color,
var loadingText: Color,
var disabledBackground: Color,
var background: Color,
var secondaryBackground: Color,
var decentBackground: Color,
var divider: Color,
var inputBackground: Color,
var inputBackground2: Color,
var inputHint: Color,
var error: Color,
var checkedBackground: Color,
var unCheckedBackground: Color,
var checkedText: Color,
var chatActionColor: Color,
var brandColorsColor: Color,
var tabSelectedBackground: Color,
var tabUnselectedBackground: Color,
var tabSelectedText: Color,
var tabUnselectedText: Color,
var bubbleBackground: Color,
var profileBackground:Color,
// Premium 按钮相关颜色
var premiumText: Color,
var premiumBackground: Color,
// VIP 权益强调色(用于 2X / 勾选高亮)
var vipHave: Color,
// 价格卡片颜色
var priceCardSelectedBorder: Color,
var priceCardSelectedBackground: Color,
var priceCardUnselectedBorder: Color,
var priceCardUnselectedBackground: Color,
)
class LightThemeColors : AppThemeData(
main = Color(0xffD80264),
mainText = Color(0xffffffff),
basicMain = Color(0xfff0f0f0),
nonActive = Color(0xfff5f5f5),
text = Color(0xff333333),
nonActiveText = Color(0xff3C3C43),
secondaryText = Color(0x99000000),
loadingMain = Color(0xFFD95757),
loadingText = Color(0xffffffff),
disabledBackground = Color(0xFFD0D0D0),
background = Color(0xFFFFFFFF),
secondaryBackground = Color(0xFFF7f7f7),
divider = Color(0xFFEbEbEb),
inputBackground = Color(0xFFF7f7f7),
inputBackground2 = Color(0xFFFFFFFF),
inputHint = Color(0xffdadada),
error = Color(0xffFF0000),
checkedBackground = Color(0xff000000),
unCheckedBackground = Color(0xFFECEAEC),
checkedText = Color(0xffFFFFFF),
decentBackground = Color(0xfff5f5f5),
chatActionColor = Color(0xffe0e0e0),
brandColorsColor = Color(0xffD80264),
tabSelectedBackground = Color(0xff110C13),
tabUnselectedBackground = Color(0xfffaf9fb),
tabSelectedText = Color(0xffffffff),
tabUnselectedText = Color(0xff000000),
bubbleBackground = Color(0xfff5f5f5),
profileBackground = Color(0xffffffff),
premiumText = Color(0xFFCD7B00),
premiumBackground = Color(0xFFFFF5D4),
vipHave = Color(0xFFFAAD14),
priceCardSelectedBorder = Color(0xFF000000),
priceCardSelectedBackground = Color(0xFFFFF5D4),
priceCardUnselectedBorder = Color(0xFFF0EEF1),
priceCardUnselectedBackground = Color(0xFFFAF9FB),
)
class DarkThemeColors : AppThemeData(
main = Color(0xffda3832),
mainText = Color(0xffffffff),
basicMain = Color(0xFF1C1C1C),
nonActive = Color(0xff1f1f1f),
text = Color(0xffffffff),
nonActiveText = Color(0xff888888),
secondaryText = Color(0x99ffffff),
loadingMain = Color(0xFFD95757),
loadingText = Color(0xff000000),
disabledBackground = Color(0xFF3A3A3A),
background = Color(0xFF121212),
secondaryBackground = Color(0xFF1C1C1C),
divider = Color(0xFF282828),
inputBackground = Color(0xFF1C1C1C),
inputBackground2 = Color(0xFF1C1C1C),
inputHint = Color(0xff888888),
error = Color(0xffFF0000),
checkedBackground = Color(0xffffffff),
unCheckedBackground = Color(0xFF7C7480),
checkedText = Color(0xff000000),
decentBackground = Color(0xFF171717),
chatActionColor = Color(0xFF3D3D3D),
brandColorsColor = Color(0xffD80264),
tabSelectedBackground = Color(0xffffffff),
tabUnselectedBackground = Color(0xFF1C1C1C),
tabSelectedText = Color(0xff000000),
tabUnselectedText = Color(0xffffffff),
bubbleBackground = Color(0xff2d2c2e),
profileBackground = Color(0xff100c12),
// 暗色模式下的Premium按钮颜色 - 使用更暗的黄色调
premiumText = Color(0xFF000000),
premiumBackground = Color(0xFFFAAD14),
// VIP权益强调色 - 保持金黄色但调整透明度
vipHave = Color(0xFFFAAD14),
// 暗色模式下的价格卡片颜色
priceCardSelectedBorder = Color(0xFFFAAD14),
priceCardSelectedBackground = Color(0xFF2A2A2A),
priceCardUnselectedBorder = Color(0xFF3A3A3A),
priceCardUnselectedBackground = Color(0xFF1C1C1C),
)

View File

@@ -0,0 +1,77 @@
package com.aiosman.ravenow
object ConstVars {
// api 地址 - 根据构建类型自动选择
// Debug: http://192.168.0.201:8088
// Release: https://rider-pro.aiosman.com/beta_api
val BASE_SERVER = if (BuildConfig.DEBUG) {
// "http://47.109.137.67:6363" // Debug环境
"https://rider-pro.aiosman.com/beta_api" // Release环境
} else {
"https://rider-pro.aiosman.com/beta_api" // Release环境
}
const val MOMENT_LIKE_CHANNEL_ID = "moment_like"
const val MOMENT_LIKE_CHANNEL_NAME = "Moment Like"
/**
* 上传头像图片大小限制
* 10M
*/
const val AVATAR_FILE_SIZE_LIMIT = 1024 * 1024 * 10
/**
* 上传头像图片压缩时最大的尺寸
* 512
*/
const val AVATAR_IMAGE_MAX_SIZE = 512
/**
* 上传 banner 图片大小限制
*/
const val BANNER_IMAGE_MAX_SIZE = 2048
// 用户协议地址
const val DICT_KEY_PRIVATE_POLICY_URL = "private_policy"
// 重置邮箱间隔
const val DIC_KEY_RESET_EMAIL_INTERVAL = "send_reset_password_timeout"
// 开启google登录
const val DICT_KEY_ENABLE_GOOGLE_LOGIN = "enable_google_login"
// google登录clientid
const val DICT_KEY_GOOGLE_LOGIN_CLIENT_ID = "google_login_client_id"
// trtc功能开启
const val DICT_KEY_ENABLE_TRTC = "enable_chat"
// 举报选项
const val DICT_KEY_REPORT_OPTIONS = "report_reasons"
}
enum class GuestLoginCheckOutScene {
CREATE_POST,
CREATE_AGENT,
VIEW_MESSAGES,
VIEW_PROFILE,
JOIN_GROUP_CHAT,
CHAT_WITH_AGENT,
LIKE_MOMENT,
COMMENT_MOMENT,
FOLLOW_USER,
REPORT_CONTENT
}
object GuestLoginCheckOut {
var NeedLoginScene = listOf<GuestLoginCheckOutScene>(
GuestLoginCheckOutScene.CREATE_POST,
GuestLoginCheckOutScene.CREATE_AGENT,
GuestLoginCheckOutScene.VIEW_MESSAGES,
GuestLoginCheckOutScene.VIEW_PROFILE,
GuestLoginCheckOutScene.JOIN_GROUP_CHAT,
GuestLoginCheckOutScene.CHAT_WITH_AGENT,
GuestLoginCheckOutScene.LIKE_MOMENT,
GuestLoginCheckOutScene.COMMENT_MOMENT,
GuestLoginCheckOutScene.FOLLOW_USER,
GuestLoginCheckOutScene.REPORT_CONTENT
)
fun needLogin(scene: GuestLoginCheckOutScene): Boolean {
return AppStore.isGuest && NeedLoginScene.contains(scene)
}
}

View File

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

View File

@@ -1,4 +1,4 @@
package com.aiosman.riderpro package com.aiosman.ravenow
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent

View File

@@ -1,6 +1,5 @@
package com.aiosman.riderpro package com.aiosman.ravenow
import android.content.Context
import cn.jpush.android.service.JCommonService import cn.jpush.android.service.JCommonService
class JpushService : JCommonService() { class JpushService : JCommonService() {

View File

@@ -0,0 +1,306 @@
package com.aiosman.ravenow
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.navigation.NavHostController
import cn.jiguang.api.utils.JCollectionAuth
import cn.jpush.android.api.JPushInterface
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.ui.Navigation
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.dialogs.CheckUpdateDialog
import com.aiosman.ravenow.ui.navigateToPost
import com.aiosman.ravenow.ui.post.NewPostViewModel
import com.aiosman.ravenow.ui.points.PointsBottomSheetHost
import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.analytics
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import com.aiosman.ravenow.ui.splash.SplashScreen
class MainActivity : ComponentActivity() {
// Firebase Analytics
private lateinit var analytics: FirebaseAnalytics
private val scope = CoroutineScope(Dispatchers.Main)
val context = this
override fun attachBaseContext(newBase: Context) {
// 禁用字体缩放,固定字体大小为系统默认大小
val configuration = Configuration(newBase.resources.configuration)
configuration.fontScale = 1.0f
val context = newBase.createConfigurationContext(configuration)
super.attachBaseContext(context)
}
override fun onConfigurationChanged(newConfig: Configuration) {
// 确保配置变化时字体缩放保持为 1.0
val config = Configuration(newConfig)
config.fontScale = 1.0f
super.onConfigurationChanged(config)
val isNightMode = (newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
if (AppState.darkMode != isNightMode) {
syncDarkModeWithSystem(isNightMode)
}
}
// 请求通知权限
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { isGranted: Boolean ->
if (isGranted) {
// FCM SDK (and your app) can post notifications.
} else {
}
}
/**
* 获取账号信息
*/
private suspend fun getAccount(): Boolean {
val accountService: AccountService = AccountServiceImpl()
try {
val resp = accountService.getMyAccount()
return true
} catch (e: Exception) {
return false
}
}
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.P)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 设置屏幕方向为竖屏
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
// 监听应用生命周期
ProcessLifecycleOwner.get().lifecycle.addObserver(MainActivityLifecycleObserver())
// 创建通知渠道
createNotificationChannel()
// 沉浸式状态栏
WindowCompat.setDecorFitsSystemWindows(window, false)
// 初始化 Places SDK
// 初始化 Firebase Analytics
analytics = Firebase.analytics
// 请求通知权限
askNotificationPermission()
// 加载一些本地化的配置
AppStore.init(this)
JPushInterface.setDebugMode(true);
// 调整点一初始化代码前增加setAuth调用
JCollectionAuth.setAuth(this, true)
JPushInterface.init(this)
updateWindowBackground(AppState.darkMode)
enableEdgeToEdge()
scope.launch {
// 检查是否有登录态
val isAccountValidate = getAccount()
var startDestination = NavigationRoute.Login.route
// 如果有登录态,且记住登录状态,且账号有效,则初始化应用状态,下一步进入首页
if (AppStore.token != null && AppStore.rememberMe && (isAccountValidate || AppStore.isGuest)) {
// 根据用户类型进行相应的初始化游客模式会跳过推送和TRTC初始化
AppState.initWithAccount(scope, this@MainActivity)
startDestination = NavigationRoute.Index.route
}
setContent {
// 强制字体缩放为 1.0 - 通过覆盖 Density 来实现
val density = LocalDensity.current
val fixedDensity = remember {
androidx.compose.ui.unit.Density(
density = density.density,
fontScale = 1.0f
)
}
var showSplash by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(2000)
showSplash = false
}
if (showSplash) {
SplashScreen()
} else {
CompositionLocalProvider(
LocalAppTheme provides AppState.appTheme,
LocalDensity provides fixedDensity
) {
CheckUpdateDialog()
// 全局挂载积分底部弹窗 Host
PointsBottomSheetHost()
Navigation(startDestination) { navController ->
// 处理带有 postId 的通知点击
val postId = intent.getStringExtra("POST_ID")
var commentId = intent.getStringExtra("COMMENT_ID")
val action = intent.getStringExtra("ACTION")
if (action == "newFollow") {
navController.navigate(NavigationRoute.Followers.route)
return@Navigation
}
if (action == "followCount") {
navController.navigate(NavigationRoute.Followers.route)
return@Navigation
}
if (action == "TRTC_NEW_MESSAGE") {
val userService:UserService = UserServiceImpl()
val sender = intent.getStringExtra("SENDER")
sender?.let {
scope.launch {
try {
val profile = userService.getUserProfileByTrtcUserId(it,0)
navController.navigate(NavigationRoute.Chat.route.replace(
"{id}",
profile.id.toString()
))
}catch (e:Exception){
e.printStackTrace()
}
}
}
return@Navigation
}
if (commentId == null) {
commentId = "0"
}
if (postId != null) {
Log.d("MainActivity", "Navigation to Post$postId")
navController.navigateToPost(
id = postId.toInt(),
highlightCommentId = commentId.toInt(),
initImagePagerIndex = 0
)
}
// 处理分享过来的图片
if (intent?.action == Intent.ACTION_SEND || intent?.action == Intent.ACTION_SEND_MULTIPLE) {
val imageUris: List<Uri>? = if (intent.action == Intent.ACTION_SEND) {
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM)!!)
} else {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
NewPostViewModel.asNewPostWithImageUris(imageUris!!.map { it.toString() })
navController.navigate(NavigationRoute.NewPost.route)
}
}
}
}
}
}
}
/**
* 请求通知权限
*/
private fun askNotificationPermission() {
// This is only necessary for API level >= 33 (TIRAMISU)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
// FCM SDK (and your app) can post notifications.
} else if (shouldShowRequestPermissionRationale(android.Manifest.permission.POST_NOTIFICATIONS)) {
} else {
// Directly ask for the permission
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
/**
* 创建通知渠道
*/
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = ConstVars.MOMENT_LIKE_CHANNEL_ID
val channelName = ConstVars.MOMENT_LIKE_CHANNEL_NAME
val channel =
NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT)
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
private fun syncDarkModeWithSystem(isNightMode: Boolean) {
AppState.darkMode = isNightMode
AppState.appTheme = if (isNightMode) DarkThemeColors() else LightThemeColors()
AppStore.saveDarkMode(isNightMode)
updateWindowBackground(isNightMode)
}
private fun updateWindowBackground(isDarkMode: Boolean) {
window.decorView.setBackgroundColor(
if (isDarkMode) android.graphics.Color.BLACK else android.graphics.Color.WHITE
)
}
}
val LocalNavController = compositionLocalOf<NavHostController> {
error("NavController not provided")
}
@OptIn(ExperimentalSharedTransitionApi::class)
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> {
error("SharedTransitionScope not provided")
}
val LocalAnimatedContentScope = compositionLocalOf<AnimatedContentScope> {
error("AnimatedContentScope not provided")
}
val LocalAppTheme = compositionLocalOf<AppThemeData> {
error("AppThemeData not provided")
}

View File

@@ -1,4 +1,4 @@
package com.aiosman.riderpro package com.aiosman.ravenow
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner

View File

@@ -1,10 +1,10 @@
package com.aiosman.riderpro package com.aiosman.ravenow
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import cn.jpush.android.api.JPushInterface import cn.jpush.android.api.JPushInterface
import com.aiosman.riderpro.data.AccountService import com.aiosman.ravenow.data.AccountService
import com.aiosman.riderpro.data.AccountServiceImpl import com.aiosman.ravenow.data.AccountServiceImpl
import com.google.android.gms.tasks.OnCompleteListener import com.google.android.gms.tasks.OnCompleteListener
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope

View File

@@ -1,4 +1,4 @@
package com.aiosman.riderpro package com.aiosman.ravenow
import android.Manifest import android.Manifest
import android.app.PendingIntent import android.app.PendingIntent
@@ -6,7 +6,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.util.Log import android.util.Log
import androidx.compose.material.Icon
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@@ -31,7 +30,7 @@ fun showLikeNotification(context: Context, title: String, message: String, postI
) )
val notificationBuilder = NotificationCompat.Builder(context, channelId) val notificationBuilder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.rider_pro_favoriate) .setSmallIcon(R.drawable.rider_pro_favourite)
.setContentTitle(title) .setContentTitle(title)
.setContentText(message) .setContentText(message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)

View File

@@ -0,0 +1,302 @@
package com.aiosman.ravenow
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.aiosman.ravenow.entity.ChatItem
import io.openim.android.sdk.OpenIMClient
import io.openim.android.sdk.listener.OnAdvanceMsgListener
import io.openim.android.sdk.models.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* OpenIM 服务
* 负责处理 OpenIM 的后台消息监听和通知
*/
class OpenIMService : Service() {
companion object {
private const val TAG = "OpenIMService"
private const val CHANNEL_ID = "openim_notification"
private const val NOTIFICATION_ID = 1001
}
private var openIMMessageListener: OnAdvanceMsgListener? = null
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "OpenIMService onStartCommand")
createNotificationChannel()
CoroutineScope(Dispatchers.IO).launch {
registerMessageListener(applicationContext)
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "OpenIMService onDestroy")
CoroutineScope(Dispatchers.IO).launch {
unRegisterMessageListener()
}
}
/**
* 注册 OpenIM 消息监听器
*/
private fun registerMessageListener(context: Context) {
val scope = CoroutineScope(Dispatchers.IO)
openIMMessageListener = object : OnAdvanceMsgListener {
override fun onRecvNewMessage(msg: Message?) {
Log.d(TAG, "收到新消息: ${msg?.toString()}")
msg?.let {
// 如果应用在前台,不显示通知
if (MainActivityLifecycle.isForeground) {
return
}
scope.launch {
// 检查通知策略
val shouldNotify = shouldShowNotification(it)
if (shouldNotify) {
sendNotification(context, it)
}
}
}
}
override fun onRecvC2CReadReceipt(list: List<C2CReadReceiptInfo>?) {
Log.d(TAG, "收到C2C已读回执数量: ${list?.size}")
// 处理已读回执,可以更新消息状态
}
override fun onRecvGroupMessageReadReceipt(groupMessageReceipt: GroupMessageReceipt?) {
Log.d(TAG, "收到群组消息已读回执")
// 处理群组消息已读回执
}
override fun onRecvMessageRevokedV2(info: RevokedInfo?) {
Log.d(TAG, "消息被撤回: ${info?.clientMsgID}")
// 处理消息撤回可以更新UI
}
override fun onRecvMessageExtensionsChanged(msgID: String?, list: List<KeyValue>?) {
Log.d(TAG, "消息扩展信息变更: $msgID")
// 处理消息扩展信息变更
}
override fun onRecvMessageExtensionsDeleted(msgID: String?, list: List<String>?) {
Log.d(TAG, "消息扩展信息删除: $msgID")
// 处理消息扩展信息删除
}
override fun onRecvMessageExtensionsAdded(msgID: String?, list: List<KeyValue>?) {
Log.d(TAG, "消息扩展信息添加: $msgID")
// 处理消息扩展信息添加
}
override fun onMsgDeleted(message: Message?) {
Log.d(TAG, "消息被删除: ${message?.clientMsgID}")
// 处理消息删除
}
override fun onRecvOfflineNewMessage(msg: List<Message>?) {
Log.d(TAG, "收到离线新消息,数量: ${msg?.size}")
// 处理离线新消息
msg?.forEach { message ->
// 为离线消息也可以发送通知
if (!MainActivityLifecycle.isForeground) {
scope.launch {
val shouldNotify = shouldShowNotification(message)
if (shouldNotify) {
sendNotification(context, message)
}
}
}
}
}
override fun onRecvOnlineOnlyMessage(s: String?) {
Log.d(TAG, "收到仅在线消息: $s")
// 处理仅在线消息
}
}
// 添加消息监听器
OpenIMClient.getInstance().messageManager.setAdvancedMsgListener(openIMMessageListener)
}
/**
* 取消注册消息监听器
*/
private fun unRegisterMessageListener() {
openIMMessageListener?.let {
// OpenIM SDK 可能需要不同的方法来移除监听器
// 这里假设有类似的方法具体需要根据SDK文档调整
Log.d(TAG, "取消注册消息监听器")
}
openIMMessageListener = null
}
/**
* 判断是否应该显示通知
* @param message 消息对象
* @return 是否显示通知
*/
private suspend fun shouldShowNotification(message: Message): Boolean {
// 这里可以根据用户设置、消息类型等判断是否显示通知
// 类似于 TrtcService 中的策略检查
// 示例:检查是否是系统消息或者用户设置了免打扰
return try {
// 可以根据发送者ID或会话ID检查通知策略
val senderId = message.sendID
// 这里可以调用类似 ChatState.getStrategyByTargetTrtcId 的方法
// 暂时返回 true表示默认显示通知
true
} catch (e: Exception) {
Log.e(TAG, "检查通知策略失败", e)
true // 默认显示通知
}
}
/**
* 创建通知渠道
*/
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "OpenIM 消息通知"
val descriptionText = "OpenIM 即时消息通知"
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
description = descriptionText
enableVibration(true)
enableLights(true)
}
val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
/**
* 发送通知
* @param context 上下文
* @param message OpenIM 消息对象
*/
private fun sendNotification(context: Context, message: Message) {
try {
// 创建点击通知后的意图
val intent = Intent(context, MainActivity::class.java).apply {
putExtra("ACTION", "OPENIM_NEW_MESSAGE")
putExtra("SENDER_ID", message.sendID)
putExtra("CONVERSATION_ID", message.sessionType)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// 构建通知内容
val senderName = getSenderDisplayName(message)
val messageContent = getMessageDisplayContent(message)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.rider_pro_log_round)
.setContentTitle(senderName)
.setContentText(messageContent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setVibrate(longArrayOf(0, 300, 300, 300))
.setLights(0xFF0000FF.toInt(), 300, 300)
// 发送通知
with(NotificationManagerCompat.from(context)) {
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
Log.w(TAG, "没有通知权限,无法发送通知")
return
}
// 使用消息ID的哈希值作为通知ID确保每条消息都有唯一的通知
val notificationId = message.clientMsgID?.hashCode() ?: NOTIFICATION_ID
notify(notificationId, builder.build())
}
Log.d(TAG, "发送通知成功: $senderName - $messageContent")
} catch (e: Exception) {
Log.e(TAG, "发送通知失败", e)
}
}
/**
* 获取发送者显示名称
* @param message 消息对象
* @return 发送者名称
*/
private fun getSenderDisplayName(message: Message): String {
return try {
// 尝试获取发送者的昵称或显示名
message.senderNickname?.takeIf { it.isNotEmpty() }
?: message.sendID
?: "未知用户"
} catch (e: Exception) {
Log.e(TAG, "获取发送者名称失败", e)
"未知用户"
}
}
/**
* 获取消息显示内容
* @param message 消息对象
* @return 消息内容
*/
private fun getMessageDisplayContent(message: Message): String {
return try {
when (message.contentType) {
101 -> message.textElem.content ?: "[文本消息]" // 文本消息
102 -> "[图片]"
103 -> "[语音]"
104 -> "[视频]"
105 -> "[文件]"
106 -> "[位置]"
107 -> "[自定义消息]"
108 -> "[合并消息]"
109 -> "[名片]"
110 -> "[引用消息]"
else -> "[消息]"
}
} catch (e: Exception) {
Log.e(TAG, "获取消息内容失败", e)
"[消息]"
}
}
}

View File

@@ -0,0 +1,66 @@
package com.aiosman.ravenow
import android.app.Application
import android.content.Context
import android.content.res.Configuration
import android.util.Log
import com.google.firebase.FirebaseApp
import com.google.firebase.perf.FirebasePerformance
/**
* 自定义Application类用于处理多进程中的Firebase初始化
*/
class RaveNowApplication : Application() {
override fun attachBaseContext(base: Context) {
// 禁用字体缩放,固定字体大小为系统默认大小
val configuration = Configuration(base.resources.configuration)
configuration.fontScale = 1.0f
val context = base.createConfigurationContext(configuration)
super.attachBaseContext(context)
}
override fun onCreate() {
super.onCreate()
// 获取当前进程名
val processName = getCurrentProcessName()
Log.d("RaveNowApplication", "当前进程: $processName")
// 在所有进程中初始化Firebase
try {
if (FirebaseApp.getApps(this).isEmpty()) {
FirebaseApp.initializeApp(this)
Log.d("RaveNowApplication", "Firebase已在进程 $processName 中初始化")
// 如果是pushcore进程禁用Firebase Performance监控
if (processName.contains(":pushcore")) {
try {
FirebasePerformance.getInstance().isPerformanceCollectionEnabled = false
Log.d("RaveNowApplication", "已在pushcore进程中禁用Firebase Performance监控")
} catch (e: Exception) {
Log.w("RaveNowApplication", "禁用Firebase Performance监控失败", e)
}
}
} else {
Log.d("RaveNowApplication", "Firebase已在进程 $processName 中存在")
}
} catch (e: Exception) {
Log.e("RaveNowApplication", "Firebase初始化失败在进程 $processName", e)
}
}
/**
* 获取当前进程名
*/
private fun getCurrentProcessName(): String {
return try {
val pid = android.os.Process.myPid()
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager
val processes = activityManager.runningAppProcesses
processes?.find { it.pid == pid }?.processName ?: "unknown"
} catch (e: Exception) {
"unknown"
}
}
}

View File

@@ -1,25 +1,30 @@
package com.aiosman.riderpro.data package com.aiosman.ravenow.data
import com.aiosman.riderpro.AppState import com.aiosman.ravenow.AppState
import com.aiosman.riderpro.data.api.ApiClient import com.aiosman.ravenow.AppStore
import com.aiosman.riderpro.data.api.AppConfig import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.riderpro.data.api.CaptchaInfo import com.aiosman.ravenow.data.api.AppConfig
import com.aiosman.riderpro.data.api.ChangePasswordRequestBody import com.aiosman.ravenow.data.api.CaptchaInfo
import com.aiosman.riderpro.data.api.GoogleRegisterRequestBody import com.aiosman.ravenow.data.api.ChangePasswordRequestBody
import com.aiosman.riderpro.data.api.LoginUserRequestBody import com.aiosman.ravenow.data.api.GoogleRegisterRequestBody
import com.aiosman.riderpro.data.api.RegisterMessageChannelRequestBody import com.aiosman.ravenow.data.api.GuestLoginRequestBody
import com.aiosman.riderpro.data.api.RegisterRequestBody import com.aiosman.ravenow.data.api.LoginUserRequestBody
import com.aiosman.riderpro.data.api.ResetPasswordRequestBody import com.aiosman.ravenow.data.api.RegisterMessageChannelRequestBody
import com.aiosman.riderpro.data.api.TrtcSignResponseBody import com.aiosman.ravenow.data.api.RegisterRequestBody
import com.aiosman.riderpro.data.api.UnRegisterMessageChannelRequestBody import com.aiosman.ravenow.data.api.RemoveAccountRequestBody
import com.aiosman.riderpro.data.api.UpdateNoticeRequestBody import com.aiosman.ravenow.data.api.ResetPasswordRequestBody
import com.aiosman.riderpro.data.api.UpdateUserLangRequestBody import com.aiosman.ravenow.data.api.TrtcSignResponseBody
import com.aiosman.riderpro.entity.AccountFavouriteEntity import com.aiosman.ravenow.data.api.UnRegisterMessageChannelRequestBody
import com.aiosman.riderpro.entity.AccountLikeEntity import com.aiosman.ravenow.data.api.UpdateNoticeRequestBody
import com.aiosman.riderpro.entity.AccountProfileEntity import com.aiosman.ravenow.data.DataContainer
import com.aiosman.riderpro.entity.NoticeCommentEntity import com.aiosman.ravenow.data.ListContainer
import com.aiosman.riderpro.entity.NoticePostEntity import com.aiosman.ravenow.data.api.UpdateUserLangRequestBody
import com.aiosman.riderpro.entity.NoticeUserEntity import com.aiosman.ravenow.entity.AccountFavouriteEntity
import com.aiosman.ravenow.entity.AccountLikeEntity
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.entity.NoticeCommentEntity
import com.aiosman.ravenow.entity.NoticePostEntity
import com.aiosman.ravenow.entity.NoticeUserEntity
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
@@ -52,6 +57,22 @@ data class AccountProfile(
val banner: String?, val banner: String?,
// trtcUserId // trtcUserId
val trtcUserId: String, val trtcUserId: String,
val openImToken: String?,
// aiAccount true:ai false:普通用户
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 * 转换为Entity
@@ -62,7 +83,8 @@ data class AccountProfile(
followerCount = followerCount, followerCount = followerCount,
followingCount = followingCount, followingCount = followingCount,
nickName = nickname, nickName = nickname,
avatar = "${ApiClient.BASE_SERVER}$avatar", avatar =
"${ApiClient.BASE_SERVER}$avatar",
bio = bio, bio = bio,
country = "Worldwide", country = "Worldwide",
isFollowing = isFollowing, isFollowing = isFollowing,
@@ -72,7 +94,21 @@ data class AccountProfile(
} }
null null
}, },
trtcUserId = trtcUserId trtcUserId = trtcUserId,
chatToken = openImToken,
aiAccount = aiAccount,
rawAvatar = avatar,
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()
) )
} }
} }
@@ -288,6 +324,13 @@ interface AccountService {
*/ */
suspend fun loginUserWithGoogle(googleId: String): UserAuth suspend fun loginUserWithGoogle(googleId: String): UserAuth
/**
* 游客登录
* @param deviceId 设备ID
* @param deviceInfo 设备信息
*/
suspend fun guestLogin(deviceId: String, deviceInfo: String? = null): UserAuth
/** /**
* 退出登录 * 退出登录
*/ */
@@ -385,13 +428,39 @@ interface AccountService {
suspend fun getMyTrtcSign(): TrtcSignResponseBody suspend fun getMyTrtcSign(): TrtcSignResponseBody
suspend fun getAppConfig(): AppConfig suspend fun getAppConfig(): AppConfig
suspend fun removeAccount(password: String)
/**
* 获取AI智能体列表
* @param page 页码
* @param pageSize 每页数量
*/
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>, roomId: Int? = null): retrofit2.Response<DataContainer<Unit>>
} }
class AccountServiceImpl : AccountService { class AccountServiceImpl : AccountService {
override suspend fun getMyAccountProfile(): AccountProfileEntity { override suspend fun getMyAccountProfile(): AccountProfileEntity {
// 如果已有缓存,直接返回缓存结果
AppState.profile?.let { return it }
// 第一次调用,获取数据并缓存
val resp = ApiClient.api.getMyAccount() val resp = ApiClient.api.getMyAccount()
val body = resp.body() ?: throw ServiceException("Failed to get account") val body = resp.body() ?: throw ServiceException("Failed to get account")
return body.data.toAccountProfileEntity() val profile = body.data.toAccountProfileEntity()
// 缓存结果到共享状态
AppState.profile = profile
return profile
} }
override suspend fun getMyAccount(): UserAuth { override suspend fun getMyAccount(): UserAuth {
@@ -423,12 +492,31 @@ class AccountServiceImpl : AccountService {
override suspend fun loginUserWithGoogle(googleId: String): UserAuth { override suspend fun loginUserWithGoogle(googleId: String): UserAuth {
val resp = ApiClient.api.login(LoginUserRequestBody(googleId = googleId)) val resp = ApiClient.api.login(LoginUserRequestBody(googleId = googleId))
val body = resp.body() ?: throw ServiceException("Failed to login") val body = resp.body() ?: throw ServiceException("Failed to login")
return UserAuth(0, body.token) return UserAuth(0, body.token)
} }
override suspend fun guestLogin(deviceId: String, deviceInfo: String?): UserAuth {
val resp = ApiClient.api.guestLogin(GuestLoginRequestBody(
deviceId = deviceId,
deviceInfo = deviceInfo
))
if (!resp.isSuccessful) {
parseErrorResponse(resp.errorBody())?.let {
throw it.toServiceException()
}
throw ServiceException("Failed to guest login")
}
val body = resp.body() ?: throw ServiceException("Failed to guest login")
return UserAuth(0, body.token, isGuest = true)
}
override suspend fun regiterUserWithGoogleAccount(idToken: String) { override suspend fun regiterUserWithGoogleAccount(idToken: String) {
val resp = ApiClient.api.registerWithGoogle(GoogleRegisterRequestBody(idToken)) val resp = ApiClient.api.registerWithGoogle(GoogleRegisterRequestBody(idToken))
if (resp.code() != 200) { if (!resp.isSuccessful) {
parseErrorResponse(resp.errorBody())?.let {
throw it.toServiceException()
}
throw ServiceException("Failed to register") throw ServiceException("Failed to register")
} }
} }
@@ -457,7 +545,13 @@ class AccountServiceImpl : AccountService {
val bannerField: MultipartBody.Part? = banner?.let { val bannerField: MultipartBody.Part? = banner?.let {
createMultipartBody(it.file, it.filename, "banner") 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) { override suspend fun registerUserWithPassword(loginName: String, password: String) {
@@ -472,7 +566,13 @@ class AccountServiceImpl : AccountService {
} }
override suspend fun changeAccountPassword(oldPassword: String, newPassword: String) { override suspend fun changeAccountPassword(oldPassword: String, newPassword: String) {
ApiClient.api.changePassword(ChangePasswordRequestBody(oldPassword, newPassword)) val resp = ApiClient.api.changePassword(ChangePasswordRequestBody(oldPassword, newPassword))
if (!resp.isSuccessful) {
parseErrorResponse(resp.errorBody())?.let {
throw it.toServiceException()
}
throw ServiceException("Failed to change password")
}
} }
override suspend fun getMyLikeNotice(page: Int, pageSize: Int): ListContainer<AccountLike> { override suspend fun getMyLikeNotice(page: Int, pageSize: Int): ListContainer<AccountLike> {
@@ -543,4 +643,29 @@ class AccountServiceImpl : AccountService {
val body = resp.body() ?: throw ServiceException("Failed to get app config") val body = resp.body() ?: throw ServiceException("Failed to get app config")
return body.data return body.data
} }
override suspend fun removeAccount(password: String) {
val resp = ApiClient.api.deleteAccount(
RemoveAccountRequestBody(password)
)
if (!resp.isSuccessful) {
parseErrorResponse(resp.errorBody())?.let {
throw it.toServiceException()
}
throw ServiceException("Failed to remove account")
}
}
override suspend fun getAgent(page: Int, pageSize: Int, 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>, roomId: Int?): retrofit2.Response<DataContainer<Unit>> {
val requestBody = com.aiosman.ravenow.data.api.CreateGroupChatRequestBody(
name = name,
userIds = userIds,
promptIds = promptIds,
)
return ApiClient.api.createGroupChat(requestBody)
}
} }

View File

@@ -0,0 +1,325 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.AgentRule
import com.aiosman.ravenow.data.api.AgentRuleListResponse
import com.aiosman.ravenow.data.api.AgentRuleQuota
import com.aiosman.ravenow.data.api.AgentRuleAgent
import com.aiosman.ravenow.data.api.CreateAgentRuleRequestBody
import com.aiosman.ravenow.data.api.InsufficientBalanceError
import com.aiosman.ravenow.data.api.UpdateAgentRuleRequestBody
import com.aiosman.ravenow.entity.AgentEntity
import com.aiosman.ravenow.entity.ProfileEntity
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
data class Agent(
/*@SerializedName("author")
val author: String,*/
@SerializedName("avatar")
val avatar: String,
@SerializedName("breakMode")
val breakMode: Boolean,
@SerializedName("createdAt")
val createdAt: String,
@SerializedName("desc")
val desc: String,
@SerializedName("id")
val id: Int,
@SerializedName("isPublic")
val isPublic: Boolean,
@SerializedName("openId")
val openId: String,
@SerializedName("title")
val title: String,
@SerializedName("updatedAt")
val updatedAt: String,
@SerializedName("useCount")
val useCount: Int
) {
fun toAgentEntity(): AgentEntity {
return AgentEntity(
id = id,
title = title,
desc = desc,
createdAt = createdAt,
updatedAt = updatedAt,
avatar = "${ApiClient.BASE_API_URL+"/outside"}$avatar"+"?token="+"${AppStore.token}",
//author = author,
isPublic = isPublic,
openId = openId,
breakMode = breakMode,
useCount = useCount,
)
}
}
data class Profile(
@SerializedName("aiAccount")
val aiAccount: Boolean,
@SerializedName("avatar")
val avatar: String,
@SerializedName("banner")
val banner: String,
@SerializedName("bio")
val bio: String,
@SerializedName("chatAIId")
val chatAIId: String,
@SerializedName("id")
val id: Int,
@SerializedName("nickname")
val nickname: String,
@SerializedName("trtcUserId")
val trtcUserId: String? = null,
@SerializedName("username")
val username: String
){
fun toProfileEntity(): ProfileEntity {
return ProfileEntity(
id = id,
username = username,
nickname = nickname,
avatar = "${ApiClient.BASE_SERVER}$avatar",
bio = bio,
banner = "${ApiClient.BASE_SERVER}$banner",
trtcUserId = trtcUserId ?: "",
chatAIId = chatAIId,
aiAccount = aiAccount
)
}
}
/**
* 智能体服务
*/
interface AgentService {
/**
* 获取智能体列表
* @param pageNumber 页码
* @param pageSize 每页数量,默认 20
* @param authorId 作者ID可选参数用于筛选特定作者的智能体
* @return 智能体列表容器,包含分页信息和智能体列表,失败时返回 null
*/
suspend fun getAgent(
pageNumber: Int,
pageSize: Int = 20,
authorId: Int? = null
): ListContainer<AgentEntity>?
/**
* 根据标题关键字搜索智能体
*/
suspend fun searchAgentByTitle(
pageNumber: Int,
pageSize: Int = 20,
title: String
): ListContainer<AgentEntity>?
}
// ========== Agent 规则 - 领域实体 ==========
data class AgentRuleAgentInfo(
val id: Int,
val title: String,
val avatar: String,
)
data class AgentRuleEntity(
val id: Int,
val rule: String,
val creator: String,
val creatorType: String,
val scope: String,
val agent: AgentRuleAgentInfo,
val createdAt: String,
val updatedAt: String,
)
data class AgentRuleListResult(
val page: Int,
val pageSize: Int,
val total: Int,
val list: List<AgentRuleEntity>,
)
data class AgentRuleQuotaEntity(
val agentId: Int,
val agentTitle: String,
val baseMaxCount: Int,
val purchasedCount: Int,
val totalMaxCount: Int,
val currentCount: Int,
val remainingCount: Int,
val usagePercent: Double,
)
// ========== Agent 规则 - Service 接口 ==========
/**
* Agent 规则服务
*/
interface AgentRuleService {
/**
* 根据 OpenId 创建 Agent 规则
* @param openId Agent 的 OpenId
* @param rule 规则内容,不能为空
* @throws ServiceException 创建失败时抛出异常(包括余额不足等情况)
*/
suspend fun createAgentRuleByOpenId(openId: String, rule: String)
/**
* 修改 Agent 规则
* @param id 规则ID
* @param rule 新的规则内容,不能为空
* @param openId Agent 的 OpenId可选参数
* @throws ServiceException 修改失败时抛出异常(包括余额不足等情况)
*/
suspend fun updateAgentRule(id: Int, rule: String, openId: String? = null)
/**
* 删除 Agent 规则
* @param id 规则ID
* @throws ServiceException 删除失败时抛出异常
*/
suspend fun deleteAgentRule(id: Int)
/**
* 查询 Agent 规则列表
* @param openId Agent 的 OpenId
* @param keyword 关键词搜索,可选参数
* @param page 页码,默认 1
* @param pageSize 每页数量,默认 10
* @return 规则列表响应,包含分页信息和规则列表
* @throws ServiceException 查询失败时抛出异常
*/
suspend fun getAgentRuleList(
openId: String,
keyword: String? = null,
page: Int = 1,
pageSize: Int = 10
): AgentRuleListResult
/**
* 查询 Agent 规则配额使用情况
* @param openId Agent 的 OpenId
* @return 规则配额信息,包含基础配额、已购买配额、当前使用量等
* @throws ServiceException 查询失败时抛出异常
*/
suspend fun getAgentRuleQuota(openId: String): AgentRuleQuotaEntity
}
class AgentRuleServiceImpl : AgentRuleService {
private val gson = Gson()
override suspend fun createAgentRuleByOpenId(openId: String, rule: String) {
val body = CreateAgentRuleRequestBody(
rule = rule,
promptId = null,
openId = openId
)
val resp = ApiClient.api.createAgentRule(body)
if (!resp.isSuccessful) {
val errorText = resp.errorBody()?.string()
try {
val err = gson.fromJson(errorText, InsufficientBalanceError::class.java)
if (err != null && err.code == 35600) {
throw ServiceException(err.message)
}
} catch (_: Exception) {
// ignore parse error
}
throw ServiceException("创建 Agent 规则失败: HTTP ${resp.code()}")
}
}
override suspend fun updateAgentRule(id: Int, rule: String, openId: String?) {
val body = UpdateAgentRuleRequestBody(
id = id,
rule = rule,
promptId = null,
openId = openId
)
val resp = ApiClient.api.updateAgentRule(body)
if (!resp.isSuccessful) {
val errorText = resp.errorBody()?.string()
try {
val err = gson.fromJson(errorText, InsufficientBalanceError::class.java)
if (err != null && err.code == 35600) {
throw ServiceException(err.message)
}
} catch (_: Exception) {
// ignore parse error
}
throw ServiceException("更新 Agent 规则失败: HTTP ${resp.code()}")
}
}
override suspend fun deleteAgentRule(id: Int) {
val resp = ApiClient.api.deleteAgentRule(id)
if (!resp.isSuccessful) {
throw ServiceException("删除 Agent 规则失败: HTTP ${resp.code()}")
}
}
override suspend fun getAgentRuleList(
openId: String,
keyword: String?,
page: Int,
pageSize: Int
): AgentRuleListResult {
val resp = ApiClient.api.getAgentRuleList(
promptId = openId,
rule = keyword,
page = page,
pageSize = pageSize
)
val data = resp.body()?.data ?: throw ServiceException("获取 Agent 规则列表失败")
return data.toEntity()
}
override suspend fun getAgentRuleQuota(openId: String): AgentRuleQuotaEntity {
val resp = ApiClient.api.getAgentRuleQuota(openId)
val data = resp.body()?.data ?: throw ServiceException("获取 Agent 规则配额失败")
return data.toEntity()
}
private fun AgentRuleListResponse.toEntity(): AgentRuleListResult = AgentRuleListResult(
page = page,
pageSize = pageSize,
total = total,
list = list.map { it.toEntity() }
)
private fun AgentRule.toEntity(): AgentRuleEntity = AgentRuleEntity(
id = id,
rule = rule,
creator = creator,
creatorType = creatorType,
scope = scope,
agent = prompt.toEntity(),
createdAt = createdAt,
updatedAt = updatedAt,
)
private fun AgentRuleAgent.toEntity(): AgentRuleAgentInfo = AgentRuleAgentInfo(
id = id,
title = title,
avatar = avatar,
)
private fun AgentRuleQuota.toEntity(): AgentRuleQuotaEntity = AgentRuleQuotaEntity(
agentId = promptId,
agentTitle = promptTitle,
baseMaxCount = baseMaxCount,
purchasedCount = purchasedCount,
totalMaxCount = totalMaxCount,
currentCount = currentCount,
remainingCount = remainingCount,
usagePercent = usagePercent,
)
}

View File

@@ -1,10 +1,10 @@
package com.aiosman.riderpro.data package com.aiosman.ravenow.data
import com.aiosman.riderpro.data.api.ApiClient import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.riderpro.data.api.CaptchaRequestBody import com.aiosman.ravenow.data.api.CaptchaRequestBody
import com.aiosman.riderpro.data.api.CaptchaResponseBody import com.aiosman.ravenow.data.api.CaptchaResponseBody
import com.aiosman.riderpro.data.api.CheckLoginCaptchaRequestBody import com.aiosman.ravenow.data.api.CheckLoginCaptchaRequestBody
import com.aiosman.riderpro.data.api.GenerateLoginCaptchaRequestBody import com.aiosman.ravenow.data.api.GenerateLoginCaptchaRequestBody
interface CaptchaService { interface CaptchaService {

View File

@@ -1,8 +1,8 @@
package com.aiosman.riderpro.data package com.aiosman.ravenow.data
import com.aiosman.riderpro.data.api.ApiClient import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.riderpro.data.api.UpdateChatNotificationRequestBody import com.aiosman.ravenow.data.api.UpdateChatNotificationRequestBody
import com.aiosman.riderpro.entity.ChatNotification import com.aiosman.ravenow.entity.ChatNotification
interface ChatService { interface ChatService {
suspend fun getChatNotifications( suspend fun getChatNotifications(

View File

@@ -1,8 +1,8 @@
package com.aiosman.riderpro.data package com.aiosman.ravenow.data
import com.aiosman.riderpro.data.api.ApiClient import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.riderpro.data.api.CommentRequestBody import com.aiosman.ravenow.data.api.CommentRequestBody
import com.aiosman.riderpro.entity.CommentEntity import com.aiosman.ravenow.entity.CommentEntity
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
/** /**
@@ -123,12 +123,16 @@ data class Comment(
fun toCommentEntity(): CommentEntity { fun toCommentEntity(): CommentEntity {
return CommentEntity( return CommentEntity(
id = id, id = id,
name = user.nickName, name = user.nickName ?: "未知用户",
comment = content, comment = content,
date = ApiClient.dateFromApiString(createdAt), date = ApiClient.dateFromApiString(createdAt),
likes = likeCount, likes = likeCount,
postId = postId, postId = postId,
avatar = "${ApiClient.BASE_SERVER}${user.avatar}", avatar = if (user.avatar != null && user.avatar.isNotEmpty()) {
"${ApiClient.BASE_SERVER}${user.avatar}"
} else {
""
},
author = user.id, author = user.id,
liked = isLiked, liked = isLiked,
unread = isUnread, unread = isUnread,

View File

@@ -0,0 +1,50 @@
package com.aiosman.ravenow.data
import android.util.Log
import com.aiosman.ravenow.ConstVars
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.CreateReportRequestBody
import com.aiosman.ravenow.entity.ReportReasons
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
data class ReportReasonList(
@SerializedName("reasons") var reasons: ArrayList<ReportReasons>
)
interface CommonService {
suspend fun getReportReasons(): ReportReasonList
suspend fun createReport(
reportReasonId: Int,
reportType: String,
reportId: Int,
)
}
class CommonServiceImpl : CommonService {
private val dictService: DictService = DictServiceImpl()
override suspend fun getReportReasons(): ReportReasonList {
val dictItem = dictService.getDictByKey(ConstVars.DICT_KEY_REPORT_OPTIONS)
val rawJson: String = dictItem.value as? String ?: throw Exception("parse report reasons error")
val gson = Gson()
val list = gson.fromJson(rawJson, ReportReasonList::class.java)
return list
}
override suspend fun createReport(
reportReasonId: Int,
reportType: String,
reportId: Int,
) {
ApiClient.api.createReport(
CreateReportRequestBody(
reportType = reportType,
reportId = reportId,
reason = reportReasonId,
extra = "",
base64Images = emptyList()
)
)
}
}

View File

@@ -1,4 +1,4 @@
package com.aiosman.riderpro.data package com.aiosman.ravenow.data
/** /**
* 通用接口返回数据 * 通用接口返回数据

View File

@@ -0,0 +1,48 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.DictItem
interface DictService {
/**
* 获取字典项
*/
suspend fun getDictByKey(key: String): DictItem
/**
* 获取字典列表
*/
suspend fun getDistList(keys: List<String>): List<DictItem>
/**
* 获取外部字典项
*/
suspend fun getOutsideDictByKey(key: String): DictItem
/**
* 获取外部字典列表
*/
suspend fun getOutsideDistList(keys: List<String>): List<DictItem>
}
class DictServiceImpl : DictService {
override suspend fun getDictByKey(key: String): DictItem {
val resp = ApiClient.api.getDict(key)
return resp.body()?.data ?: throw Exception("failed to get dict")
}
override suspend fun getDistList(keys: List<String>): List<DictItem> {
val resp = ApiClient.api.getDicts(keys.joinToString(","))
return resp.body()?.list ?: throw Exception("failed to get dict list")
}
override suspend fun getOutsideDictByKey(key: String): DictItem {
val resp = ApiClient.api.getOutsideDict(key)
return resp.body()?.data ?: throw Exception("failed to get outside dict")
}
override suspend fun getOutsideDistList(keys: List<String>): List<DictItem> {
val resp = ApiClient.api.getOutsideDicts(keys.joinToString(","))
return resp.body()?.list ?: throw Exception("failed to get outside dict list")
}
}

View File

@@ -1,5 +1,7 @@
package com.aiosman.riderpro.data package com.aiosman.ravenow.data
import com.aiosman.ravenow.data.api.ErrorCode
import com.aiosman.ravenow.data.api.toErrorCode
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import okhttp3.ResponseBody import okhttp3.ResponseBody
@@ -13,6 +15,7 @@ class ServiceException(
val data: Any? = null, val data: Any? = null,
val error: String? = null, val error: String? = null,
val name: String? = null, val name: String? = null,
val errorType: ErrorCode = ErrorCode.UNKNOWN
) : Exception( ) : Exception(
message message
) )
@@ -30,7 +33,8 @@ data class ApiErrorResponse(
message = error ?: name ?: "", message = error ?: name ?: "",
code = code, code = code,
error = error, error = error,
name = name name = name,
errorType = (code ?: 0).toErrorCode()
) )
} }
} }

View File

@@ -1,4 +1,4 @@
package com.aiosman.riderpro.data package com.aiosman.ravenow.data
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
@@ -9,7 +9,7 @@ import com.google.gson.annotations.SerializedName
data class ListContainer<T>( data class ListContainer<T>(
// 总数 // 总数
@SerializedName("total") @SerializedName("total")
val total: Int, val total: Long,
// 当前页 // 当前页
@SerializedName("page") @SerializedName("page")
val page: Int, val page: Int,

View File

@@ -0,0 +1,321 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.entity.MomentImageEntity
import com.aiosman.ravenow.entity.MomentVideoEntity
import com.google.gson.annotations.SerializedName
import java.io.File
data class Moment(
@SerializedName("id")
val id: Long,
@SerializedName("textContent")
val textContent: String,
@SerializedName("url")
val url: String? = null,
@SerializedName("images")
val images: List<Image>? = null,
@SerializedName("videos")
val videos: List<Video>? = null,
@SerializedName("user")
val user: User,
@SerializedName("likeCount")
val likeCount: Long,
@SerializedName("isLiked")
val isLiked: Boolean,
@SerializedName("favoriteCount")
val favoriteCount: Long,
@SerializedName("isFavorite")
val isFavorite: Boolean,
@SerializedName("isCommented")
val isCommented: Boolean,
@SerializedName("commentCount")
val commentCount: Long,
@SerializedName("time")
val time: String?,
@SerializedName("isFollowed")
val isFollowed: Boolean,
// 新闻相关字段
@SerializedName("isNews")
val isNews: Boolean = false,
@SerializedName("newsTitle")
val newsTitle: String? = null,
@SerializedName("newsUrl")
val newsUrl: String? = null,
@SerializedName("newsSource")
val newsSource: String? = null,
@SerializedName("newsCategory")
val newsCategory: String? = null,
@SerializedName("newsLanguage")
val newsLanguage: String? = null,
@SerializedName("newsContent")
val newsContent: String? = null,
@SerializedName("hasFullText")
val hasFullText: Boolean = false,
@SerializedName("summary")
val summary: String? = null,
@SerializedName("publishedAt")
val publishedAt: String? = null,
@SerializedName("imageCached")
val imageCached: Boolean = false
) {
fun toMomentItem(): MomentEntity {
return MomentEntity(
id = id.toInt(),
avatar = if (user.avatar != null && user.avatar.isNotEmpty()) {
"${ApiClient.BASE_SERVER}${user.avatar}"
} else {
"" // 如果头像为空,使用空字符串
},
nickname = user.nickName ?: "未知用户", // 如果昵称为空,使用默认值
location = "Worldwide",
time = if (time != null && time.isNotEmpty()) {
ApiClient.dateFromApiString(time)
} else {
java.util.Date() // 如果时间为空,使用当前时间作为默认值
},
followStatus = isFollowed,
momentTextContent = textContent,
momentPicture = R.drawable.default_moment_img,
likeCount = likeCount.toInt(),
commentCount = commentCount.toInt(),
shareCount = 0,
favoriteCount = favoriteCount.toInt(),
images = images?.map {
MomentImageEntity(
id = it.id,
url = "${ApiClient.BASE_SERVER}${it.url}",
originalUrl = it.originalUrl,
directUrl = it.directUrl,
thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}",
thumbnailDirectUrl = it.thumbnailDirectUrl,
small = it.small,
smallDirectUrl = it.smallDirectUrl,
medium = it.medium,
mediumDirectUrl = it.mediumDirectUrl,
large = it.large,
largeDirectUrl = it.largeDirectUrl,
blurHash = it.blurHash,
width = it.width,
height = it.height
)
} ?: emptyList(),
authorId = user.id.toInt(),
liked = isLiked,
isFavorite = isFavorite,
url = url,
videos = videos?.map {
MomentVideoEntity(
id = it.id,
url = "${ApiClient.BASE_SERVER}${it.url}",
originalUrl = it.originalUrl,
directUrl = it.directUrl,
thumbnailUrl = it.thumbnailUrl?.let { thumb -> "${ApiClient.BASE_SERVER}$thumb" },
thumbnailDirectUrl = it.thumbnailDirectUrl,
duration = it.duration,
width = it.width,
height = it.height,
size = it.size,
format = it.format,
bitrate = it.bitrate,
frameRate = it.frameRate
)
},
// 新闻相关字段
isNews = isNews,
newsTitle = newsTitle ?: "",
newsUrl = newsUrl ?: "",
newsSource = newsSource ?: "",
newsCategory = newsCategory ?: "",
newsLanguage = newsLanguage ?: "",
newsContent = newsContent ?: "",
hasFullText = hasFullText,
summary = summary,
publishedAt = publishedAt,
imageCached = imageCached
)
}
}
data class Image(
@SerializedName("id")
val id: Long,
@SerializedName("url")
val url: String,
@SerializedName("original_url")
val originalUrl: String? = null,
@SerializedName("directUrl")
val directUrl: String? = null,
@SerializedName("thumbnail")
val thumbnail: String,
@SerializedName("thumbnailDirectUrl")
val thumbnailDirectUrl: String? = null,
@SerializedName("small")
val small: String? = null,
@SerializedName("smallDirectUrl")
val smallDirectUrl: String? = null,
@SerializedName("medium")
val medium: String? = null,
@SerializedName("mediumDirectUrl")
val mediumDirectUrl: String? = null,
@SerializedName("large")
val large: String? = null,
@SerializedName("largeDirectUrl")
val largeDirectUrl: String? = null,
@SerializedName("blurHash")
val blurHash: String?,
@SerializedName("width")
val width: Int?,
@SerializedName("height")
val height: Int?
)
data class Video(
@SerializedName("id")
val id: Long,
@SerializedName("url")
val url: String,
@SerializedName("original_url")
val originalUrl: String? = null,
@SerializedName("directUrl")
val directUrl: String? = null,
@SerializedName("thumbnailUrl")
val thumbnailUrl: String? = null,
@SerializedName("thumbnailDirectUrl")
val thumbnailDirectUrl: String? = null,
@SerializedName("duration")
val duration: Int? = null,
@SerializedName("width")
val width: Int? = null,
@SerializedName("height")
val height: Int? = null,
@SerializedName("size")
val size: Long? = null,
@SerializedName("format")
val format: String? = null,
@SerializedName("bitrate")
val bitrate: Int? = null,
@SerializedName("frameRate")
val frameRate: String? = null
)
data class User(
@SerializedName("id")
val id: Long,
@SerializedName("nickName")
val nickName: String?,
@SerializedName("avatar")
val avatar: String?,
@SerializedName("avatarMedium")
val avatarMedium: String? = null,
@SerializedName("avatarLarge")
val avatarLarge: String? = null,
@SerializedName("originAvatar")
val originAvatar: String? = null,
@SerializedName("avatarDirectUrl")
val avatarDirectUrl: String? = null,
@SerializedName("avatarMediumDirectUrl")
val avatarMediumDirectUrl: String? = null,
@SerializedName("avatarLargeDirectUrl")
val avatarLargeDirectUrl: String? = null,
@SerializedName("aiAccount")
val aiAccount: Boolean = false,
@SerializedName("aiRoleAvatar")
val aiRoleAvatar: String? = null,
@SerializedName("aiRoleAvatarMedium")
val aiRoleAvatarMedium: String? = null,
@SerializedName("aiRoleAvatarLarge")
val aiRoleAvatarLarge: String? = null,
@SerializedName("aiRoleAvatarDirectUrl")
val aiRoleAvatarDirectUrl: String? = null,
@SerializedName("aiRoleAvatarMediumDirectUrl")
val aiRoleAvatarMediumDirectUrl: String? = null,
@SerializedName("aiRoleAvatarLargeDirectUrl")
val aiRoleAvatarLargeDirectUrl: String? = null
)
data class UploadImage(
val file: File,
val filename: String,
val url: String,
val ext: String
)
interface MomentService {
/**
* 获取动态详情
* @param id 动态ID
*/
suspend fun getMomentById(id: Int): MomentEntity
/**
* 点赞动态
* @param id 动态ID
*/
suspend fun likeMoment(id: Int)
/**
* 取消点赞动态
* @param id 动态ID
*/
suspend fun dislikeMoment(id: Int)
/**
* 获取动态列表
* @param pageNumber 页码
* @param author 作者ID,过滤条件
* @param timelineId 用户时间线ID,指定用户 ID 的时间线
* @param contentSearch 内容搜索,过滤条件
* @param trend 是否趋势动态
* @param explore 是否探索动态
* @return 动态列表
*/
suspend fun getMoments(
pageNumber: Int,
author: Int? = null,
timelineId: Int? = null,
contentSearch: String? = null,
trend: Boolean? = false,
explore: Boolean? = false,
favoriteUserId: Int? = null
): ListContainer<MomentEntity>
/**
* 创建动态
* @param content 动态内容
* @param authorId 作者ID
* @param images 图片列表
* @param relPostId 关联动态ID
*/
suspend fun createMoment(
content: String,
authorId: Int,
images: List<UploadImage>,
relPostId: Int? = null
): MomentEntity
suspend fun agentMoment(
content: String,
): String
/**
* 收藏动态
* @param id 动态ID
*/
suspend fun favoriteMoment(id: Int)
/**
* 取消收藏动态
* @param id 动态ID
*/
suspend fun unfavoriteMoment(id: Int)
/**
* 删除动态
*/
suspend fun deleteMoment(id: Int)
}

View File

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

@@ -0,0 +1,618 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.CreateRoomRuleRequestBody
import com.aiosman.ravenow.data.api.UpdateRoomRuleRequestBody
import com.aiosman.ravenow.data.api.RoomRuleQuota
import com.aiosman.ravenow.data.api.RoomRule
import com.aiosman.ravenow.data.api.RoomRuleCreator
import com.aiosman.ravenow.entity.AddAgentToRoomFailedItemEntity
import com.aiosman.ravenow.entity.AddAgentToRoomItemEntity
import com.aiosman.ravenow.entity.AddAgentToRoomResultEntity
import com.aiosman.ravenow.entity.AddUserToRoomFailedItemEntity
import com.aiosman.ravenow.entity.AddUserToRoomItemEntity
import com.aiosman.ravenow.entity.AddUserToRoomResultEntity
import com.aiosman.ravenow.entity.CreatorEntity
import com.aiosman.ravenow.entity.RemoveAgentFromRoomFailedItemEntity
import com.aiosman.ravenow.entity.RemoveAgentFromRoomItemEntity
import com.aiosman.ravenow.entity.RemoveAgentFromRoomResultEntity
import com.aiosman.ravenow.entity.RemoveUserFromRoomFailedItemEntity
import com.aiosman.ravenow.entity.RemoveUserFromRoomItemEntity
import com.aiosman.ravenow.entity.RemoveUserFromRoomResultEntity
import com.aiosman.ravenow.entity.RoomEntity
import com.aiosman.ravenow.entity.RoomRuleEntity
import com.aiosman.ravenow.entity.RoomRuleCreatorEntity
import com.aiosman.ravenow.entity.RoomRuleQuotaEntity
import com.aiosman.ravenow.entity.UsersEntity
import com.google.gson.annotations.SerializedName
/**
* 房间内的智能体信息PromptTemplate
*/
data class PromptTemplate(
@SerializedName("id")
val id: Int,
@SerializedName("openId")
val openId: String,
@SerializedName("title")
val title: String,
@SerializedName("desc")
val desc: String,
@SerializedName("avatar")
val avatar: String
)
data class Room(
@SerializedName("id")
val id: Int,
@SerializedName("name")
val name: String,
@SerializedName("description")
val description: String,
@SerializedName("trtcRoomId")
val trtcRoomId: String,
@SerializedName("trtcType")
val trtcType: String,
@SerializedName("cover")
val cover: String,
@SerializedName("avatar")
val avatar: String,
@SerializedName("recommendBanner")
val recommendBanner: String,
@SerializedName("isRecommended")
val isRecommended: Boolean,
@SerializedName("allowInHot")
val allowInHot: Boolean,
@SerializedName("creator")
val creator: Creator,
@SerializedName("userCount")
val userCount: Int,
@SerializedName("totalMemberCount")
val totalMemberCount: Int? = null,
@SerializedName("maxMemberLimit")
val maxMemberLimit: Int,
@SerializedName("maxTotal")
val maxTotal: Int? = null,
@SerializedName("systemMaxTotal")
val systemMaxTotal: Int? = null,
@SerializedName("canJoin")
val canJoin: Boolean,
@SerializedName("canJoinCode")
val canJoinCode: Int,
@SerializedName("privateFeePaid")
val privateFeePaid: Boolean? = null,
@SerializedName("prompts")
val prompts: List<PromptTemplate>? = null,
@SerializedName("createdAt")
val createdAt: String? = null,
@SerializedName("updatedAt")
val updatedAt: String? = null,
@SerializedName("users")
val users: List<Users>
) {
fun toRoomtEntity(): RoomEntity {
return RoomEntity(
id= id,
name = name,
description = description ,
trtcRoomId = trtcRoomId,
trtcType = trtcType,
cover = cover,
avatar = avatar,
recommendBanner = recommendBanner,
isRecommended = isRecommended,
allowInHot = allowInHot,
creator = creator.toCreatorEntity(),
userCount = userCount,
totalMemberCount = totalMemberCount,
maxMemberLimit = maxMemberLimit,
maxTotal = maxTotal,
systemMaxTotal = systemMaxTotal,
canJoin = canJoin,
canJoinCode = canJoinCode,
privateFeePaid = privateFeePaid ?: false,
prompts = prompts?.map {
com.aiosman.ravenow.entity.PromptTemplateEntity(
id = it.id,
openId = it.openId,
title = it.title,
desc = it.desc,
avatar = it.avatar
)
} ?: emptyList(),
createdAt = createdAt,
updatedAt = updatedAt,
users = users.map { it.toUsersEntity() }
)
}
}
data class Creator(
@SerializedName("id")
val id: Int,
@SerializedName("userId")
val userId: String,
@SerializedName("trtcUserId")
val trtcUserId: String? = null,
@SerializedName("profile")
val profile: Profile
){
fun toCreatorEntity(): CreatorEntity {
return CreatorEntity(
id = id,
userId = userId,
trtcUserId = trtcUserId ?: "",
profile = profile.toProfileEntity()
)
}
}
data class Users(
@SerializedName("id")
val id: Int,
@SerializedName("userId")
val userId: String,
@SerializedName("trtcUserId")
val trtcUserId: String? = null,
@SerializedName("profile")
val profile: Profile
){
fun toUsersEntity(): UsersEntity {
return UsersEntity(
id = id,
userId = userId,
profile = profile.toProfileEntity()
)
}
}
/**
* 房间规则相关服务
*/
interface RoomService {
/**
* 创建房间规则
* @param rule 规则内容,不能为空
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC房间ID与 roomId 二选一
* @throws ServiceException 创建失败时抛出异常
*/
suspend fun createRoomRule(
rule: String,
roomId: Int? = null,
trtcId: String? = null
)
/**
* 修改房间规则
* @param id 规则ID
* @param rule 新的规则内容,不能为空
* @throws ServiceException 修改失败时抛出异常
*/
suspend fun updateRoomRule(
id: Int,
rule: String
)
/**
* 删除房间规则
* @param id 规则ID
* @throws ServiceException 删除失败时抛出异常
*/
suspend fun deleteRoomRule(id: Int)
/**
* 查询房间规则列表
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC房间ID与 roomId 二选一
* @param page 页码,默认 1
* @param pageSize 每页数量,默认 10
* @return 规则列表响应,包含分页信息和规则列表
* @throws ServiceException 查询失败时抛出异常
*/
suspend fun getRoomRuleList(
roomId: Int? = null,
trtcId: String? = null,
page: Int = 1,
pageSize: Int = 10
): ListContainer<RoomRuleEntity>
/**
* 查询规则配额使用情况
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC房间ID与 roomId 二选一
* @return 规则配额信息
* @throws ServiceException 查询失败时抛出异常
*/
suspend fun getRoomRuleQuota(
roomId: Int? = null,
trtcId: String? = null
): RoomRuleQuotaEntity
// ========== Room Member Management ==========
/**
* 添加用户到房间
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param openIds 要添加的用户OpenID列表
* @return 添加结果实体
* @throws ServiceException 添加失败时抛出异常
*/
suspend fun addUserToRoom(
roomId: Int? = null,
trtcId: String? = null,
openIds: List<String>
): AddUserToRoomResultEntity
/**
* 添加智能体到房间
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param agentOpenIds 要添加的智能体OpenID列表
* @return 添加结果实体
* @throws ServiceException 添加失败时抛出异常
*/
suspend fun addAgentToRoom(
roomId: Int? = null,
trtcId: String? = null,
agentOpenIds: List<String>
): AddAgentToRoomResultEntity
/**
* 从房间移除智能体
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param agentOpenIds 要移除的智能体OpenID列表
* @return 移除结果实体
* @throws ServiceException 移除失败时抛出异常
*/
suspend fun removeAgentFromRoom(
roomId: Int? = null,
trtcId: String? = null,
agentOpenIds: List<String>
): RemoveAgentFromRoomResultEntity
/**
* 从房间移除用户
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param userIds 要移除的用户ID列表OpenID
* @return 移除结果实体
* @throws ServiceException 移除失败时抛出异常
*/
suspend fun removeUserFromRoom(
roomId: Int? = null,
trtcId: String? = null,
userIds: List<String>
): RemoveUserFromRoomResultEntity
}
/**
* 房间规则服务实现类
*/
class RoomServiceImpl : RoomService {
override suspend fun createRoomRule(
rule: String,
roomId: Int?,
trtcId: String?
) {
val resp = ApiClient.api.createRoomRule(
CreateRoomRuleRequestBody(
rule = rule,
roomId = roomId,
trtcId = trtcId
)
)
if (!resp.isSuccessful) {
throw ServiceException("创建房间规则失败")
}
}
override suspend fun updateRoomRule(
id: Int,
rule: String
) {
val resp = ApiClient.api.updateRoomRule(
UpdateRoomRuleRequestBody(
id = id,
rule = rule
)
)
if (!resp.isSuccessful) {
throw ServiceException("修改房间规则失败")
}
}
override suspend fun deleteRoomRule(id: Int) {
val resp = ApiClient.api.deleteRoomRule(id)
if (!resp.isSuccessful) {
throw ServiceException("删除房间规则失败")
}
}
override suspend fun getRoomRuleList(
roomId: Int?,
trtcId: String?,
page: Int,
pageSize: Int
): ListContainer<RoomRuleEntity> {
val resp = ApiClient.api.getRoomRuleList(
roomId = roomId,
trtcId = trtcId,
page = page,
pageSize = pageSize
)
val body = resp.body() ?: throw ServiceException("获取房间规则列表失败")
return ListContainer(
list = body.list.map { it.toRoomRuleEntity() },
page = body.page,
total = body.total,
pageSize = body.pageSize
)
}
override suspend fun getRoomRuleQuota(
roomId: Int?,
trtcId: String?
): RoomRuleQuotaEntity {
val resp = ApiClient.api.getRoomRuleQuota(
roomId = roomId,
trtcId = trtcId
)
val body = resp.body() ?: throw ServiceException("获取规则配额信息失败")
val data = body.data ?: throw ServiceException("规则配额数据为空")
return data.toRoomRuleQuotaEntity()
}
override suspend fun addUserToRoom(
roomId: Int?,
trtcId: String?,
openIds: List<String>
): AddUserToRoomResultEntity {
val resp = ApiClient.api.addUserToRoom(
com.aiosman.ravenow.data.api.AddUserToRoomRequestBody(
roomId = roomId,
trtcId = trtcId,
openIds = openIds
)
)
val body = resp.body() ?: throw ServiceException("添加用户到房间失败")
val data = body.data ?: throw ServiceException("添加用户响应数据为空")
return data.result.toAddUserToRoomResultEntity()
}
override suspend fun addAgentToRoom(
roomId: Int?,
trtcId: String?,
agentOpenIds: List<String>
): AddAgentToRoomResultEntity {
val resp = ApiClient.api.addAgentToRoom(
com.aiosman.ravenow.data.api.AddAgentToRoomRequestBody(
roomId = roomId,
trtcId = trtcId,
agentOpenIds = agentOpenIds
)
)
val body = resp.body() ?: throw ServiceException("添加智能体到房间失败")
val data = body.data ?: throw ServiceException("添加智能体响应数据为空")
return data.result.toAddAgentToRoomResultEntity()
}
override suspend fun removeAgentFromRoom(
roomId: Int?,
trtcId: String?,
agentOpenIds: List<String>
): RemoveAgentFromRoomResultEntity {
val resp = ApiClient.api.removeAgentFromRoom(
com.aiosman.ravenow.data.api.RemoveAgentFromRoomRequestBody(
roomId = roomId,
trtcId = trtcId,
agentOpenIds = agentOpenIds
)
)
val body = resp.body() ?: throw ServiceException("从房间移除智能体失败")
val data = body.data ?: throw ServiceException("移除智能体响应数据为空")
return data.toRemoveAgentFromRoomResultEntity()
}
override suspend fun removeUserFromRoom(
roomId: Int?,
trtcId: String?,
userIds: List<String>
): RemoveUserFromRoomResultEntity {
val resp = ApiClient.api.removeUserFromRoom(
com.aiosman.ravenow.data.api.RemoveUserFromRoomRequestBody(
roomId = roomId,
trtcId = trtcId,
userIds = userIds
)
)
val body = resp.body() ?: throw ServiceException("从房间移除用户失败")
val data = body.data ?: throw ServiceException("移除用户响应数据为空")
return data.toRemoveUserFromRoomResultEntity()
}
}
/**
* RoomRule 扩展函数,转换为 RoomRuleEntity
*/
fun RoomRule.toRoomRuleEntity(): RoomRuleEntity {
return RoomRuleEntity(
id = id,
rule = rule,
creator = creator?.toRoomRuleCreatorEntity(),
creatorType = creatorType,
roomId = roomId,
createdAt = createdAt,
updatedAt = updatedAt
)
}
/**
* RoomRuleCreator 扩展函数,转换为 RoomRuleCreatorEntity
*/
fun RoomRuleCreator.toRoomRuleCreatorEntity(): RoomRuleCreatorEntity {
return RoomRuleCreatorEntity(
id = id,
nickname = nickname,
avatar = avatar,
avatarMedium = avatarMedium,
avatarLarge = avatarLarge,
avatarDirectUrl = avatarDirectUrl,
avatarMediumDirectUrl = avatarMediumDirectUrl,
avatarLargeDirectUrl = avatarLargeDirectUrl
)
}
/**
* RoomRuleQuota 扩展函数,转换为 RoomRuleQuotaEntity
*/
fun RoomRuleQuota.toRoomRuleQuotaEntity(): RoomRuleQuotaEntity {
return RoomRuleQuotaEntity(
baseMaxCount = baseMaxCount,
purchasedCount = purchasedCount,
totalMaxCount = totalMaxCount,
currentCount = currentCount,
remainingCount = remainingCount,
usagePercent = usagePercent
)
}
// ========== Room Member Management 扩展函数 ==========
/**
* AddUserToRoomResult 扩展函数,转换为 AddUserToRoomResultEntity
*/
fun com.aiosman.ravenow.data.api.AddUserToRoomResult.toAddUserToRoomResultEntity(): AddUserToRoomResultEntity {
return AddUserToRoomResultEntity(
totalCount = totalCount,
successCount = successCount,
failedCount = failedCount,
skippedCount = skippedCount,
successItems = successItems.map { it.toAddUserToRoomItemEntity() },
failedItems = failedItems.map { it.toAddUserToRoomFailedItemEntity() },
skippedItems = skippedItems.map { it.toAddUserToRoomItemEntity() }
)
}
fun com.aiosman.ravenow.data.api.AddUserToRoomItem.toAddUserToRoomItemEntity(): AddUserToRoomItemEntity {
return AddUserToRoomItemEntity(
userId = userId,
type = type
)
}
fun com.aiosman.ravenow.data.api.AddUserToRoomFailedItem.toAddUserToRoomFailedItemEntity(): AddUserToRoomFailedItemEntity {
return AddUserToRoomFailedItemEntity(
userId = userId,
type = type,
error = error
)
}
/**
* AddAgentToRoomResult 扩展函数,转换为 AddAgentToRoomResultEntity
*/
fun com.aiosman.ravenow.data.api.AddAgentToRoomResult.toAddAgentToRoomResultEntity(): AddAgentToRoomResultEntity {
return AddAgentToRoomResultEntity(
totalCount = totalCount,
successCount = successCount,
failedCount = failedCount,
skippedCount = skippedCount,
successItems = successItems.map { it.toAddAgentToRoomItemEntity() },
failedItems = failedItems.map { it.toAddAgentToRoomFailedItemEntity() },
skippedItems = skippedItems.map { it.toAddAgentToRoomItemEntity() }
)
}
fun com.aiosman.ravenow.data.api.AddAgentToRoomItem.toAddAgentToRoomItemEntity(): AddAgentToRoomItemEntity {
return AddAgentToRoomItemEntity(
agentOpenId = agentOpenId,
type = type
)
}
fun com.aiosman.ravenow.data.api.AddAgentToRoomFailedItem.toAddAgentToRoomFailedItemEntity(): AddAgentToRoomFailedItemEntity {
return AddAgentToRoomFailedItemEntity(
agentOpenId = agentOpenId,
type = type,
error = error
)
}
/**
* RemoveAgentFromRoomResult 扩展函数,转换为 RemoveAgentFromRoomResultEntity
*/
fun com.aiosman.ravenow.data.api.RemoveAgentFromRoomResult.toRemoveAgentFromRoomResultEntity(): RemoveAgentFromRoomResultEntity {
return RemoveAgentFromRoomResultEntity(
totalCount = totalCount,
successCount = successCount,
failedCount = failedCount,
skippedCount = skippedCount,
successItems = successItems.map { it.toRemoveAgentFromRoomItemEntity() },
failedItems = failedItems.map { it.toRemoveAgentFromRoomFailedItemEntity() },
skippedItems = skippedItems.map { it.toRemoveAgentFromRoomItemEntity() }
)
}
fun com.aiosman.ravenow.data.api.RemoveAgentFromRoomItem.toRemoveAgentFromRoomItemEntity(): RemoveAgentFromRoomItemEntity {
return RemoveAgentFromRoomItemEntity(
agentOpenId = agentOpenId,
type = type
)
}
fun com.aiosman.ravenow.data.api.RemoveAgentFromRoomFailedItem.toRemoveAgentFromRoomFailedItemEntity(): RemoveAgentFromRoomFailedItemEntity {
return RemoveAgentFromRoomFailedItemEntity(
agentOpenId = agentOpenId,
type = type,
error = error
)
}
/**
* RemoveUserFromRoomResult 扩展函数,转换为 RemoveUserFromRoomResultEntity
*/
fun com.aiosman.ravenow.data.api.RemoveUserFromRoomResult.toRemoveUserFromRoomResultEntity(): RemoveUserFromRoomResultEntity {
return RemoveUserFromRoomResultEntity(
totalCount = totalCount,
successCount = successCount,
failedCount = failedCount,
skippedCount = skippedCount,
successItems = successItems.map { it.toRemoveUserFromRoomItemEntity() },
failedItems = failedItems.map { it.toRemoveUserFromRoomFailedItemEntity() },
skippedItems = skippedItems.map { it.toRemoveUserFromRoomItemEntity() }
)
}
fun com.aiosman.ravenow.data.api.RemoveUserFromRoomItem.toRemoveUserFromRoomItemEntity(): RemoveUserFromRoomItemEntity {
return RemoveUserFromRoomItemEntity(
userId = userId,
type = type
)
}
fun com.aiosman.ravenow.data.api.RemoveUserFromRoomFailedItem.toRemoveUserFromRoomFailedItemEntity(): RemoveUserFromRoomFailedItemEntity {
return RemoveUserFromRoomFailedItemEntity(
userId = userId,
type = type,
error = error
)
}

View File

@@ -1,11 +1,13 @@
package com.aiosman.riderpro.data package com.aiosman.ravenow.data
import com.aiosman.riderpro.data.api.ApiClient import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.riderpro.entity.AccountProfileEntity import com.aiosman.ravenow.data.api.BatchTrtcUserIdRequestBody
import com.aiosman.ravenow.entity.AccountProfileEntity
data class UserAuth( data class UserAuth(
val id: Int, val id: Int,
val token: String? = null val token: String? = null,
val isGuest: Boolean = false
) )
/** /**
@@ -45,11 +47,37 @@ interface UserService {
page: Int = 1, page: Int = 1,
nickname: String? = null, nickname: String? = null,
followerId: Int? = null, followerId: Int? = null,
followingId: Int? = null followingId: Int? = null,
roomId: Int? = null
): ListContainer<AccountProfileEntity> ): ListContainer<AccountProfileEntity>
suspend fun getUserProfileByTrtcUserId(id: String):AccountProfileEntity
/**
* 获取用户信息
* @param id 用户ID
* @return 用户信息
*/
suspend fun getUserProfileByTrtcUserId(id: String,includeAI: Int):AccountProfileEntity
/**
* 获取用户信息
* @param id 用户ID
* @return 用户信息
*/
suspend fun getUserProfileByOpenId(id: String):AccountProfileEntity
/**
* 批量通过 TRTC 用户ID 获取用户信息列表
* @param ids TRTC 用户ID列表最多100个
* @param includeAI 是否包含AI账号默认 false
* @return 用户信息实体列表
*/
suspend fun getUserProfilesByTrtcUserIds(
ids: List<String>,
includeAI: Boolean = false
): List<AccountProfileEntity>
} }
class UserServiceImpl : UserService { class UserServiceImpl : UserService {
@@ -74,14 +102,16 @@ class UserServiceImpl : UserService {
page: Int, page: Int,
nickname: String?, nickname: String?,
followerId: Int?, followerId: Int?,
followingId: Int? followingId: Int?,
roomId: Int?
): ListContainer<AccountProfileEntity> { ): ListContainer<AccountProfileEntity> {
val resp = ApiClient.api.getUsers( val resp = ApiClient.api.getUsers(
page = page, page = page,
pageSize = pageSize, pageSize = pageSize,
search = nickname, search = nickname,
followerId = followerId, followerId = followerId,
followingId = followingId followingId = followingId,
includeAI = true,
) )
val body = resp.body() ?: throw ServiceException("Failed to get account") val body = resp.body() ?: throw ServiceException("Failed to get account")
return ListContainer<AccountProfileEntity>( return ListContainer<AccountProfileEntity>(
@@ -92,9 +122,29 @@ class UserServiceImpl : UserService {
) )
} }
override suspend fun getUserProfileByTrtcUserId(id: String): AccountProfileEntity { override suspend fun getUserProfileByTrtcUserId(id: String,includeAI: Int): AccountProfileEntity {
val resp = ApiClient.api.getAccountProfileByTrtcUserId(id) val resp = ApiClient.api.getAccountProfileByTrtcUserId(id,includeAI)
val body = resp.body() ?: throw ServiceException("Failed to get account") val body = resp.body() ?: throw ServiceException("Failed to get account")
return body.data.toAccountProfileEntity() return body.data.toAccountProfileEntity()
} }
override suspend fun getUserProfileByOpenId(id: String): AccountProfileEntity {
val resp = ApiClient.api.getAccountProfileByOpenId(id)
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

@@ -1,9 +1,9 @@
package com.aiosman.riderpro.data.api package com.aiosman.ravenow.data.api
import android.icu.text.SimpleDateFormat import android.icu.text.SimpleDateFormat
import android.icu.util.TimeZone import android.icu.util.TimeZone
import com.aiosman.riderpro.AppStore import com.aiosman.ravenow.AppStore
import com.aiosman.riderpro.ConstVars import com.aiosman.ravenow.ConstVars
import com.auth0.android.jwt.JWT import com.auth0.android.jwt.JWT
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor import okhttp3.Interceptor
@@ -11,54 +11,19 @@ import okhttp3.OkHttpClient
import okhttp3.Response import okhttp3.Response
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.security.cert.CertificateException
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
fun getUnsafeOkHttpClient( fun getSafeOkHttpClient(
authInterceptor: AuthInterceptor? = null authInterceptor: AuthInterceptor? = null
): OkHttpClient { ): OkHttpClient {
return try { return OkHttpClient.Builder()
// Create a trust manager that does not validate certificate chains
val trustAllCerts = arrayOf<TrustManager>(object : X509TrustManager {
@Throws(CertificateException::class)
override fun checkClientTrusted(
chain: Array<java.security.cert.X509Certificate>,
authType: String
) {
}
@Throws(CertificateException::class)
override fun checkServerTrusted(
chain: Array<java.security.cert.X509Certificate>,
authType: String
) {
}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
})
// Install the all-trusting trust manager
val sslContext = SSLContext.getInstance("SSL")
sslContext.init(null, trustAllCerts, java.security.SecureRandom())
// Create an ssl socket factory with our all-trusting manager
val sslSocketFactory = sslContext.socketFactory
OkHttpClient.Builder()
.sslSocketFactory(sslSocketFactory, trustAllCerts[0] as X509TrustManager)
.hostnameVerifier { _, _ -> true }
.apply { .apply {
authInterceptor?.let { authInterceptor?.let {
addInterceptor(it) addInterceptor(it)
} }
} }
.build() .build()
} catch (e: Exception) {
throw RuntimeException(e)
}
} }
class AuthInterceptor() : Interceptor { class AuthInterceptor() : Interceptor {
@@ -81,6 +46,7 @@ class AuthInterceptor() : Interceptor {
} }
requestBuilder.addHeader("Authorization", "Bearer ${AppStore.token}") requestBuilder.addHeader("Authorization", "Bearer ${AppStore.token}")
requestBuilder.addHeader("DEVICE-OS", "Android")
val response = chain.proceed(requestBuilder.build()) val response = chain.proceed(requestBuilder.build())
return response return response
@@ -90,9 +56,9 @@ class AuthInterceptor() : Interceptor {
val client = Retrofit.Builder() val client = Retrofit.Builder()
.baseUrl(ApiClient.RETROFIT_URL) .baseUrl(ApiClient.RETROFIT_URL)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.client(getUnsafeOkHttpClient()) .client(getSafeOkHttpClient())
.build() .build()
.create(RiderProAPI::class.java) .create(RaveNowAPI::class.java)
val resp = client.refreshToken(AppStore.token ?: "") val resp = client.refreshToken(AppStore.token ?: "")
val newToken = resp.body()?.token val newToken = resp.body()?.token
@@ -104,12 +70,12 @@ class AuthInterceptor() : Interceptor {
} }
object ApiClient { object ApiClient {
const val BASE_SERVER = ConstVars.BASE_SERVER val BASE_SERVER = ConstVars.BASE_SERVER
const val BASE_API_URL = "${BASE_SERVER}/api/v1" val BASE_API_URL = "${BASE_SERVER}/api/v1"
const val RETROFIT_URL = "${BASE_API_URL}/" val RETROFIT_URL = "${BASE_API_URL}/"
const val TIME_FORMAT = "yyyy-MM-dd HH:mm:ss" const val TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"
private val okHttpClient: OkHttpClient by lazy { private val okHttpClient: OkHttpClient by lazy {
getUnsafeOkHttpClient(authInterceptor = AuthInterceptor()) getSafeOkHttpClient(authInterceptor = AuthInterceptor())
} }
private val retrofit: Retrofit by lazy { private val retrofit: Retrofit by lazy {
Retrofit.Builder() Retrofit.Builder()
@@ -118,8 +84,8 @@ object ApiClient {
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
} }
val api: RiderProAPI by lazy { val api: RaveNowAPI by lazy {
retrofit.create(RiderProAPI::class.java) retrofit.create(RaveNowAPI::class.java)
} }
fun formatTime(date: Date): String { fun formatTime(date: Date): String {

View File

@@ -0,0 +1,42 @@
package com.aiosman.ravenow.data.api
import android.content.Context
import android.widget.Toast
import com.aiosman.ravenow.R
//
enum class ErrorCode(val code: Int) {
USER_EXIST(40001),
USER_NOT_EXIST(40002),
InvalidateCaptcha(40004),
IncorrectOldPassword(40005),
// 未知错误
UNKNOWN(99999)
}
fun ErrorCode.toErrorMessage(context: Context): String {
return context.getErrorMessageCode(code)
}
fun ErrorCode.showToast(context: Context) {
Toast.makeText(context, toErrorMessage(context), Toast.LENGTH_SHORT).show()
}
// code to ErrorCode
fun Int.toErrorCode(): ErrorCode {
return when (this) {
40001 -> ErrorCode.USER_EXIST
40002 -> ErrorCode.USER_NOT_EXIST
40004 -> ErrorCode.InvalidateCaptcha
40005 -> ErrorCode.IncorrectOldPassword
else -> ErrorCode.UNKNOWN
}
}
fun Context.getErrorMessageCode(code: Int?): String {
return when (code) {
40001 -> getString(R.string.error_10001_user_exist)
ErrorCode.IncorrectOldPassword.code -> getString(R.string.error_incorrect_old_password)
else -> getString(R.string.error_unknown)
}
}

File diff suppressed because it is too large Load Diff

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,192 @@
package com.aiosman.ravenow.data.membership
import com.google.gson.JsonElement
import com.google.gson.annotations.SerializedName
data class MembershipConfigData(
@SerializedName("id") val id: Int,
@SerializedName("name") val name: String,
@SerializedName("description") val description: String,
@SerializedName("version") val version: String,
@SerializedName("config_data") val configData: ConfigData,
@SerializedName("createdAt") val createdAt: String,
@SerializedName("updatedAt") val updatedAt: String,
)
data class ConfigData(
@SerializedName("members") val members: List<Member>
)
data class Member(
@SerializedName("name") val name: String,
@SerializedName("benefits") val benefits: List<Benefit>,
@SerializedName("goods") val goods: List<Good>,
)
data class Benefit(
@SerializedName("level") val level: Int,
@SerializedName("name") val name: String,
@SerializedName("value") val value: JsonElement,
@SerializedName("order") val order: Int,
)
data class Good(
@SerializedName("description") val description: String,
@SerializedName("discount") val discount: Double,
@SerializedName("goods_id") val goodsId: String,
@SerializedName("originalPrice") val originalPrice: Double,
@SerializedName("period") val period: String,
@SerializedName("price") val price: Double,
)
data class ValidateProductRequestBody(
@SerializedName("plan_id") val planId: String,
@SerializedName("product_id") val productId: String,
)
data class ValidateData(
@SerializedName("isValid") val isValid: Boolean,
@SerializedName("mapped") val mapped: Boolean,
@SerializedName("hasUnfinishedOrder") val hasUnfinishedOrder: Boolean,
)
data class VipPriceModel(
val title: String,
val proPrice: String,
val standardPrice: String,
val proDesc: String,
val standardDesc: String,
val id: String,
val proGoodsId: String?,
val standardGoodsId: String?,
) {
companion object {
const val MONTH_ID = "monthly"
const val YEAR_ID = "yearly"
}
}
data class VipPageDataModel(
val title: String,
val proHave: Boolean?,
val proDesc: String,
val standardHave: Boolean?,
val standardDesc: String,
val freeHave: Boolean?,
val freeDesc: String,
val order: Int,
)
object VipModelMapper {
fun generatePageDataList(members: List<Member>): List<VipPageDataModel> {
if (members.size < 3) return emptyList()
val free = members[0]
val standard = members[1]
val pro = members[2]
val names = (members.flatMap { it.benefits.map { b -> b.name } }).toSet().sorted()
val list = names.map { name ->
val freeB = free.benefits.firstOrNull { it.name == name }
val stdB = standard.benefits.firstOrNull { it.name == name }
val proB = pro.benefits.firstOrNull { it.name == name }
val order = proB?.order ?: stdB?.order ?: freeB?.order ?: 0
VipPageDataModel(
title = name,
proHave = proB?.value?.asBooleanOrNull(),
proDesc = proB?.value?.asStringOrEmpty() ?: "",
standardHave = stdB?.value?.asBooleanOrNull(),
standardDesc = stdB?.value?.asStringOrEmpty() ?: "",
freeHave = freeB?.value?.asBooleanOrNull(),
freeDesc = freeB?.value?.asStringOrEmpty() ?: "",
order = order,
)
}
return list.sortedBy { it.order }
}
fun generatePriceDataList(members: List<Member>): List<VipPriceModel> {
if (members.size < 3) return emptyList()
val standard = members[1]
val pro = members[2]
val list = mutableListOf<VipPriceModel>()
// 首月(示例:如果后端 period = "first_month"
val stdFirst = standard.goods.firstOrNull { it.period == "first_month" }
val proFirst = pro.goods.firstOrNull { it.period == "first_month" }
if (stdFirst != null && proFirst != null) {
list.add(
VipPriceModel(
title = "首月",
proPrice = proFirst.price.toInt().toString(),
standardPrice = stdFirst.price.toInt().toString(),
proDesc = proFirst.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
standardDesc = stdFirst.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
id = "first_month",
proGoodsId = proFirst.goodsId,
standardGoodsId = stdFirst.goodsId,
)
)
}
val stdMonth = standard.goods.firstOrNull { it.period == "month" }
val proMonth = pro.goods.firstOrNull { it.period == "month" }
if (stdMonth != null && proMonth != null) {
list.add(
VipPriceModel(
title = "月付",
proPrice = proMonth.price.toInt().toString(),
standardPrice = stdMonth.price.toInt().toString(),
proDesc = proMonth.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
standardDesc = stdMonth.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
id = VipPriceModel.MONTH_ID,
proGoodsId = proMonth.goodsId,
standardGoodsId = stdMonth.goodsId,
)
)
}
// 半年
val stdHalf = standard.goods.firstOrNull { it.period == "half_year" }
val proHalf = pro.goods.firstOrNull { it.period == "half_year" }
if (stdHalf != null && proHalf != null) {
list.add(
VipPriceModel(
title = "半年",
proPrice = proHalf.price.toInt().toString(),
standardPrice = stdHalf.price.toInt().toString(),
proDesc = proHalf.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
standardDesc = stdHalf.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
id = "half_year",
proGoodsId = proHalf.goodsId,
standardGoodsId = stdHalf.goodsId,
)
)
}
val stdYear = standard.goods.firstOrNull { it.period == "year" }
val proYear = pro.goods.firstOrNull { it.period == "year" }
if (stdYear != null && proYear != null) {
list.add(
VipPriceModel(
title = "每年",
proPrice = proYear.price.toInt().toString(),
standardPrice = stdYear.price.toInt().toString(),
proDesc = proYear.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
standardDesc = stdYear.originalPrice.takeIf { it > 0 }?.toInt()?.toString() ?: "",
id = VipPriceModel.YEAR_ID,
proGoodsId = proYear.goodsId,
standardGoodsId = stdYear.goodsId,
)
)
}
return list
}
}
private fun JsonElement.asStringOrEmpty(): String {
return try { if (isJsonPrimitive && asJsonPrimitive.isString) asString else "" } catch (_: Exception) { "" }
}
private fun JsonElement.asBooleanOrNull(): Boolean? {
return try { if (isJsonPrimitive && asJsonPrimitive.isBoolean) asBoolean else null } catch (_: Exception) { null }
}

View File

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

@@ -1,11 +1,11 @@
package com.aiosman.riderpro.entity package com.aiosman.ravenow.entity
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.paging.PagingState import androidx.paging.PagingState
import com.aiosman.riderpro.data.AccountFollow import com.aiosman.ravenow.data.AccountFollow
import com.aiosman.riderpro.data.AccountService import com.aiosman.ravenow.data.AccountService
import com.aiosman.riderpro.data.Image import com.aiosman.ravenow.data.Image
import com.aiosman.riderpro.data.api.ApiClient import com.aiosman.ravenow.data.api.ApiClient
import java.io.IOException import java.io.IOException
import java.util.Date import java.util.Date
@@ -61,6 +61,20 @@ data class AccountProfileEntity(
val banner: String?, val banner: String?,
// trtcUserId // trtcUserId
val trtcUserId: String, val trtcUserId: String,
val chatToken: String?,
val aiAccount: Boolean,
val rawAvatar: String,
val chatAIId: String,
// AI角色背景图
val aiRoleAvatar: String? = null,
val aiRoleAvatarMedium: String? = null,
val aiRoleAvatarLarge: String? = null,
// 创建者信息仅AI账号有
val creatorProfile: CreatorProfileEntity? = null,
) )
/** /**
@@ -104,6 +118,28 @@ data class NoticeUserEntity(
val avatar: String, val avatar: String,
) )
/**
* 创建者信息
*/
data class CreatorProfileEntity(
// 用户ID
val id: Long,
// 用户名
val username: String? = null,
// 昵称
val nickname: String,
// 头像
val avatar: String? = null,
// 个人简介
val bio: String? = null,
// trtcUserId
val trtcUserId: String? = null,
// chatAIId
val chatAIId: String? = null,
// 是否为AI账号
val aiAccount: Boolean = false,
)
/** /**
* 用户点赞消息分页数据加载器 * 用户点赞消息分页数据加载器
*/ */

View File

@@ -0,0 +1,329 @@
package com.aiosman.ravenow.entity
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.data.Agent
import com.aiosman.ravenow.data.ListContainer
import com.aiosman.ravenow.data.AgentService
import com.aiosman.ravenow.data.DataContainer
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.data.UploadImage
import com.aiosman.ravenow.data.api.ApiClient
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.io.IOException
/**
* 智能体
*/
suspend fun createAgent(
title: String,
desc: String,
avatar: UploadImage? = null,
workflowId: Int = 1,
isPublic: Boolean = true,
breakMode: Boolean = false,
useWorkflow: Boolean = true,
): AgentEntity {
val textTitle = title.toRequestBody("text/plain".toMediaTypeOrNull())
val textDesc = desc.toRequestBody("text/plain".toMediaTypeOrNull())
val workflowIdRequestBody =
workflowId.toString().toRequestBody("text/plain".toMediaTypeOrNull())
val isPublicRequestBody = isPublic.toString().toRequestBody("text/plain".toMediaTypeOrNull())
val breakModeRequestBody = breakMode.toString().toRequestBody("text/plain".toMediaTypeOrNull())
val useWorkflowRequestBody =
useWorkflow.toString().toRequestBody("text/plain".toMediaTypeOrNull())
val workflowInputsValue = "{\"si\":\"$desc\"}"
val workflowInputsRequestBody =
workflowInputsValue.toRequestBody("text/plain".toMediaTypeOrNull())
val avatarField: MultipartBody.Part? = avatar?.let {
createMultipartBody(it.file, it.filename, "avatar")
}
val response = ApiClient.api.createAgent(
avatarField,
textTitle,
textDesc,
textDesc,
workflowIdRequestBody,
isPublicRequestBody,
breakModeRequestBody,
useWorkflowRequestBody,
workflowInputsRequestBody
)
val body = response.body()?.data ?: throw ServiceException("Failed to create agent")
return body.toAgentEntity()
}
/**
* 智能体信息分页加载器
*/
class AgentPagingSource(
private val agentRemoteDataSource: AgentRemoteDataSource,
private val authorId: Int? = null
) : PagingSource<Int, AgentEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, AgentEntity> {
return try {
val currentPage = params.key ?: 1
val users = agentRemoteDataSource.getAgent(
pageNumber = currentPage,
authorId = authorId
)
LoadResult.Page(
data = users?.list ?: listOf(),
prevKey = if (currentPage == 1) null else currentPage - 1,
nextKey = if (users?.list?.isNotEmpty() == true) users.page + 1 else null
)
} catch (exception: IOException) {
return LoadResult.Error(exception)
}
}
override fun getRefreshKey(state: PagingState<Int, AgentEntity>): Int? {
return state.anchorPosition
}
}
/**
* 智能体搜索分页加载器(按标题关键字)
*/
class AgentSearchPagingSource(
private val agentRemoteDataSource: AgentRemoteDataSource,
private val keyword: String,
) : PagingSource<Int, AgentEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, AgentEntity> {
return try {
val currentPage = params.key ?: 1
val agents = agentRemoteDataSource.searchAgentByTitle(
pageNumber = currentPage,
title = keyword
)
LoadResult.Page(
data = agents?.list ?: listOf(),
prevKey = if (currentPage == 1) null else currentPage - 1,
nextKey = if (agents?.list?.isNotEmpty() == true) currentPage + 1 else null
)
} catch (exception: IOException) {
LoadResult.Error(exception)
}
}
override fun getRefreshKey(state: PagingState<Int, AgentEntity>): Int? {
return state.anchorPosition
}
}
class AgentRemoteDataSource(
private val agentService: AgentService,
) {
suspend fun getAgent(
pageNumber: Int,
authorId: Int? = null
): ListContainer<AgentEntity>? {
return agentService.getAgent(
pageNumber = pageNumber,
authorId = authorId
)
}
suspend fun searchAgentByTitle(
pageNumber: Int,
title: String
): ListContainer<AgentEntity>? {
return agentService.searchAgentByTitle(
pageNumber = pageNumber,
title = title
)
}
}
class AgentServiceImpl() : AgentService {
val agentBackend = AgentBackend()
override suspend fun getAgent(
pageNumber: Int,
pageSize: Int,
authorId: Int?
): ListContainer<AgentEntity>? {
return agentBackend.getAgent(
pageNumber = pageNumber,
authorId = authorId
)
}
override suspend fun searchAgentByTitle(
pageNumber: Int,
pageSize: Int,
title: String
): ListContainer<AgentEntity>? {
return agentBackend.searchAgentByTitle(
pageNumber = pageNumber,
title = title
)
}
}
class AgentBackend {
val DataBatchSize = 20
suspend fun getAgent(
pageNumber: Int,
authorId: Int? = null
): ListContainer<AgentEntity>? {
// 如果是游客模式且获取我的AgentauthorId为null返回空列表
if (authorId == null && AppStore.isGuest) {
return ListContainer(
total = 0,
page = pageNumber,
pageSize = DataBatchSize,
list = emptyList()
)
}
val resp = if (authorId != null) {
ApiClient.api.getAgent(
page = pageNumber,
pageSize = DataBatchSize,
authorId = authorId
)
} else {
ApiClient.api.getMyAgent(
page = pageNumber,
pageSize = DataBatchSize
)
}
val body = resp.body() ?: return null
// 处理不同的返回类型
return if (authorId != null) {
// getAgent 返回 DataContainer<ListContainer<Agent>>
val dataContainer =
body as DataContainer<ListContainer<Agent>>
val listContainer = dataContainer.data
ListContainer(
total = listContainer.total,
page = pageNumber,
pageSize = DataBatchSize,
list = listContainer.list.map { it.toAgentEntity() }
)
} else {
// getMyAgent 返回 ListContainer<Agent>
val listContainer =
body as ListContainer<Agent>
ListContainer(
total = listContainer.total,
page = pageNumber,
pageSize = DataBatchSize,
list = listContainer.list.map { it.toAgentEntity() }
)
}
}
suspend fun searchAgentByTitle(
pageNumber: Int,
title: String
): ListContainer<AgentEntity>? {
val resp = ApiClient.api.getAgent(
page = pageNumber,
pageSize = DataBatchSize,
withWorkflow = 1,
title = title
)
val body = resp.body() ?: return null
val dataContainer = body as DataContainer<ListContainer<Agent>>
val listContainer = dataContainer.data
return ListContainer(
total = listContainer.total,
page = pageNumber,
pageSize = DataBatchSize,
list = listContainer.list.map { it.toAgentEntity() }
)
}
}
data class AgentEntity(
//val author: String,
val avatar: String,
val breakMode: Boolean,
val createdAt: String,
val desc: String,
val id: Int,
val isPublic: Boolean,
val openId: String,
//val profile: ProfileEntity,
val title: String,
val updatedAt: String,
val useCount: Int,
)
fun createMultipartBody(file: File, filename: String, name: String): MultipartBody.Part {
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
return MultipartBody.Part.createFormData(name, filename, requestFile)
}
class AgentLoaderExtraArgs(
val authorId: Int? = null
)
class AgentLoader : DataLoader<AgentEntity, AgentLoaderExtraArgs>() {
override suspend fun fetchData(
page: Int,
pageSize: Int,
extra: AgentLoaderExtraArgs
): ListContainer<AgentEntity> {
// 如果是游客模式且获取我的AgentauthorId为null返回空列表
if (extra.authorId == null && AppStore.isGuest) {
return ListContainer(
total = 0,
page = page,
pageSize = pageSize,
list = emptyList()
)
}
val result = if (extra.authorId != null) {
ApiClient.api.getAgent(
page = page,
pageSize = pageSize,
authorId = extra.authorId
)
} else {
ApiClient.api.getMyAgent(
page = page,
pageSize = pageSize
)
}
val body = result.body() ?: throw ServiceException("Failed to get agent")
return if (extra.authorId != null) {
// getAgent 返回 DataContainer<ListContainer<Agent>>
val dataContainer = body as DataContainer<ListContainer<Agent>>
val listContainer = dataContainer.data
ListContainer(
list = listContainer.list.map { it.toAgentEntity() },
total = listContainer.total,
page = page,
pageSize = pageSize
)
} else {
// getMyAgent 返回 ListContainer<Agent>
val listContainer =
body as ListContainer<Agent>
ListContainer(
list = listContainer.list.map { it.toAgentEntity() },
total = listContainer.total,
page = page,
pageSize = pageSize
)
}
}
}

View File

@@ -0,0 +1,114 @@
package com.aiosman.ravenow.entity
import android.content.Context
import android.icu.util.Calendar
import com.aiosman.ravenow.ConstVars
import com.aiosman.ravenow.exp.formatChatTime
import com.google.gson.annotations.SerializedName
import io.openim.android.sdk.models.Message
import io.openim.android.sdk.models.PictureElem
data class ChatItem(
val message: String,
val avatar: String,
val time: String,
val userId: String,
val nickname: String,
val timeCategory: String = "",
val timestamp: Long = 0,
val imageList: MutableList<PictureInfo> = emptyList<PictureInfo>().toMutableList(),
val messageType: Int = 0,
val textDisplay: String = "",
val msgId: String, // Add this property
var showTimestamp: Boolean = false,
var showTimeDivider: Boolean = false
) {
companion object {
// OpenIM 消息类型常量
const val MESSAGE_TYPE_TEXT = 101
const val MESSAGE_TYPE_IMAGE = 102
const val MESSAGE_TYPE_AUDIO = 103
const val MESSAGE_TYPE_VIDEO = 104
const val MESSAGE_TYPE_FILE = 105
fun convertToChatItem(message: Message, context: Context, avatar: String? = null): ChatItem? {
val timestamp = message.createTime
val calendar = Calendar.getInstance()
calendar.timeInMillis = timestamp
var faceAvatar = avatar
if (faceAvatar == null) {
faceAvatar = "${ConstVars.BASE_SERVER}${message.senderFaceUrl}"
}
when (message.contentType) {
MESSAGE_TYPE_IMAGE -> {
val pictureElem = message.pictureElem
if (pictureElem != null && !pictureElem.sourcePicture.url.isNullOrEmpty()) {
return ChatItem(
message = "Image",
avatar = faceAvatar,
time = calendar.time.formatChatTime(context),
userId = message.sendID,
nickname = message.senderNickname ?: "",
timestamp = timestamp,
imageList = listOfNotNull(
PictureInfo(
url = pictureElem.sourcePicture.url ?: "",
width = pictureElem.sourcePicture.width,
height = pictureElem.sourcePicture.height,
size = pictureElem.sourcePicture.size
)
).toMutableList(),
messageType = MESSAGE_TYPE_IMAGE,
textDisplay = "Image",
msgId = message.clientMsgID
)
}
return null
}
MESSAGE_TYPE_TEXT -> {
return ChatItem(
message = message.textElem?.content ?: "Unsupported message type",
avatar = faceAvatar,
time = calendar.time.formatChatTime(context),
userId = message.sendID,
nickname = message.senderNickname ?: "",
timestamp = timestamp,
imageList = emptyList<PictureInfo>().toMutableList(),
messageType = MESSAGE_TYPE_TEXT,
textDisplay = message.textElem?.content ?: "Unsupported message type",
msgId = message.clientMsgID
)
}
else -> {
return null
}
}
}
}
}
// OpenIM 图片信息数据类
data class PictureInfo(
val url: String,
val width: Int,
val height: Int,
val size: Long
)
data class ChatNotification(
@SerializedName("userId")
val userId: Int,
@SerializedName("userTrtcId")
val userTrtcId: String,
@SerializedName("targetUserId")
val targetUserId: Int,
@SerializedName("targetTrtcId")
val targetTrtcId: String,
@SerializedName("strategy")
val strategy: String
)

View File

@@ -1,10 +1,9 @@
package com.aiosman.riderpro.entity package com.aiosman.ravenow.entity
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.paging.PagingState import androidx.paging.PagingState
import com.aiosman.riderpro.data.CommentRemoteDataSource import com.aiosman.ravenow.data.CommentRemoteDataSource
import com.aiosman.riderpro.data.NoticePost import com.aiosman.ravenow.data.NoticePost
import java.io.IOException
import java.util.Date import java.util.Date
data class CommentEntity( data class CommentEntity(

View File

@@ -0,0 +1,19 @@
package com.aiosman.ravenow.entity
import android.content.Context
import com.google.gson.annotations.SerializedName
data class ReportReasons(
@SerializedName("id") var id: Int,
@SerializedName("text") var text: Map<String, String>
) {
fun getReasonText(context:Context): String? {
val language = context.resources.configuration.locale.language
val langMapping = mapOf(
"zh" to "zh",
"en" to "en"
)
val useLang = langMapping[language] ?: "en"
return text[useLang] ?: text["en"]
}
}

View File

@@ -0,0 +1,18 @@
package com.aiosman.ravenow.entity
data class GroupMember(
val userId: String,
val nickname: String,
val avatar: String,
val isOwner: Boolean = false
)
data class GroupInfo(
val groupId: String,
val groupName: String,
val groupAvatar: String,
val memberCount: Int,
val isCreator: Boolean = false,
val trtcType: String = "Public",
val privateFeePaid: Boolean = false,
)

View File

@@ -0,0 +1,109 @@
package com.aiosman.ravenow.entity
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.aiosman.ravenow.data.ListContainer
abstract class DataLoader<T,ET> {
var list: MutableList<T> = mutableListOf()
var page by mutableStateOf(1)
var total by mutableStateOf(0L)
var pageSize by mutableStateOf(10)
var hasNext by mutableStateOf(true)
var isLoading by mutableStateOf(false)
var error by mutableStateOf<String?>(null)
var onListChanged: ((List<T>) -> Unit)? = null
var onError: ((String) -> Unit)? = null
private var firstLoad = true
abstract suspend fun fetchData(
page: Int, pageSize: Int, extra: ET
): ListContainer<T>
suspend fun loadData(
extra: ET
) {
Log.d("DataLoader", "loadData开始 - firstLoad: $firstLoad")
if (!firstLoad) {
Log.d("DataLoader", "loadData跳过 - 非首次加载")
return
}
if (isLoading) {
Log.d("DataLoader", "loadData跳过 - 正在加载中")
return
}
firstLoad = false
isLoading = true
error = null
try {
Log.d("DataLoader", "调用fetchData - page: $page, pageSize: $pageSize")
val result = fetchData(page, pageSize, extra)
list = result.list.toMutableList()
this.page = page
this.total = result.total
this.pageSize = pageSize
this.hasNext = result.list.size == pageSize
Log.d("DataLoader", "loadData完成 - 数据量: ${list.size}, total: $total, hasNext: $hasNext")
onListChanged?.invoke(list)
} catch (e: Exception) {
Log.e("DataLoader", "loadData失败", e)
error = e.message ?: "加载数据时发生未知错误"
firstLoad = true // 重置firstLoad状态允许重试
onError?.invoke(error!!)
} finally {
isLoading = false
}
}
suspend fun loadMore(extra: ET) {
if (firstLoad) {
Log.d("DataLoader", "loadMore跳过 - firstLoad为true")
return
}
if (!hasNext) {
Log.d("DataLoader", "loadMore跳过 - hasNext为false")
return
}
if (isLoading) {
Log.d("DataLoader", "loadMore跳过 - 正在加载中")
return
}
isLoading = true
error = null
try {
Log.d("DataLoader", "开始loadMore - 当前页: $page, 当前数据量: ${list.size}")
val result = fetchData(page + 1, pageSize, extra)
list.addAll(result.list)
page += 1
hasNext = result.list.size == pageSize
Log.d("DataLoader", "loadMore完成 - 新页: $page, 新数据量: ${list.size}, 本次获取: ${result.list.size}, hasNext: $hasNext")
onListChanged?.invoke(list)
} catch (e: Exception) {
Log.e("DataLoader", "loadMore失败", e)
error = e.message ?: "加载更多数据时发生未知错误"
onError?.invoke(error!!)
} finally {
isLoading = false
}
}
fun clear() {
list.clear()
page = 1
total = 0
pageSize = 10
hasNext = true
firstLoad = true
isLoading = false
error = null
}
}

View File

@@ -1,15 +1,15 @@
package com.aiosman.riderpro.entity package com.aiosman.ravenow.entity
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.paging.PagingState import androidx.paging.PagingState
import com.aiosman.riderpro.data.ListContainer import com.aiosman.ravenow.data.ListContainer
import com.aiosman.riderpro.data.MomentService import com.aiosman.ravenow.data.MomentService
import com.aiosman.riderpro.data.ServiceException import com.aiosman.ravenow.data.ServiceException
import com.aiosman.riderpro.data.UploadImage import com.aiosman.ravenow.data.UploadImage
import com.aiosman.riderpro.data.api.ApiClient import com.aiosman.ravenow.data.api.AgentMomentRequestBody
import com.aiosman.riderpro.data.parseErrorResponse import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.riderpro.entity.MomentEntity import com.aiosman.ravenow.data.parseErrorResponse
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
@@ -27,6 +27,7 @@ class MomentPagingSource(
private val timelineId: Int? = null, private val timelineId: Int? = null,
private val contentSearch: String? = null, private val contentSearch: String? = null,
private val trend: Boolean? = false, private val trend: Boolean? = false,
private val explore: Boolean? = false,
private val favoriteUserId: Int? = null private val favoriteUserId: Int? = null
) : PagingSource<Int, MomentEntity>() { ) : PagingSource<Int, MomentEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MomentEntity> { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MomentEntity> {
@@ -38,6 +39,7 @@ class MomentPagingSource(
timelineId = timelineId, timelineId = timelineId,
contentSearch = contentSearch, contentSearch = contentSearch,
trend = trend, trend = trend,
explore = explore,
favoriteUserId = favoriteUserId favoriteUserId = favoriteUserId
) )
@@ -66,6 +68,7 @@ class MomentRemoteDataSource(
timelineId: Int?, timelineId: Int?,
contentSearch: String?, contentSearch: String?,
trend: Boolean?, trend: Boolean?,
explore: Boolean?,
favoriteUserId: Int? favoriteUserId: Int?
): ListContainer<MomentEntity> { ): ListContainer<MomentEntity> {
return momentService.getMoments( return momentService.getMoments(
@@ -74,6 +77,7 @@ class MomentRemoteDataSource(
timelineId = timelineId, timelineId = timelineId,
contentSearch = contentSearch, contentSearch = contentSearch,
trend = trend, trend = trend,
explore = explore,
favoriteUserId = favoriteUserId favoriteUserId = favoriteUserId
) )
} }
@@ -88,7 +92,8 @@ class MomentServiceImpl() : MomentService {
timelineId: Int?, timelineId: Int?,
contentSearch: String?, contentSearch: String?,
trend: Boolean?, trend: Boolean?,
favoriteUserId: Int? explore: Boolean?,
favoriteUserId: Int?,
): ListContainer<MomentEntity> { ): ListContainer<MomentEntity> {
return momentBackend.fetchMomentItems( return momentBackend.fetchMomentItems(
pageNumber = pageNumber, pageNumber = pageNumber,
@@ -96,7 +101,8 @@ class MomentServiceImpl() : MomentService {
timelineId = timelineId, timelineId = timelineId,
contentSearch = contentSearch, contentSearch = contentSearch,
trend = trend, trend = trend,
favoriteUserId = favoriteUserId favoriteUserId = favoriteUserId,
explore = explore
) )
} }
@@ -122,6 +128,10 @@ class MomentServiceImpl() : MomentService {
return momentBackend.createMoment(content, authorId, images, relPostId) return momentBackend.createMoment(content, authorId, images, relPostId)
} }
override suspend fun agentMoment(content: String): String {
return momentBackend.agentMoment(content)
}
override suspend fun favoriteMoment(id: Int) { override suspend fun favoriteMoment(id: Int) {
momentBackend.favoriteMoment(id) momentBackend.favoriteMoment(id)
} }
@@ -144,6 +154,7 @@ class MomentBackend {
timelineId: Int?, timelineId: Int?,
contentSearch: String?, contentSearch: String?,
trend: Boolean?, trend: Boolean?,
explore: Boolean?,
favoriteUserId: Int? = null favoriteUserId: Int? = null
): ListContainer<MomentEntity> { ): ListContainer<MomentEntity> {
val resp = ApiClient.api.getPosts( val resp = ApiClient.api.getPosts(
@@ -153,7 +164,8 @@ class MomentBackend {
authorId = author, authorId = author,
contentSearch = contentSearch, contentSearch = contentSearch,
trend = if (trend == true) "true" else "", trend = if (trend == true) "true" else "",
favouriteUserId = favoriteUserId favouriteUserId = favoriteUserId,
explore = if (explore == true) "true" else ""
) )
val body = resp.body() ?: throw ServiceException("Failed to get moments") val body = resp.body() ?: throw ServiceException("Failed to get moments")
return ListContainer( return ListContainer(
@@ -205,6 +217,17 @@ class MomentBackend {
} }
suspend fun agentMoment(
content: String,
): String {
val textContent = content.toRequestBody("text/plain".toMediaTypeOrNull())
val sessionId = ""
val response = ApiClient.api.agentMoment(AgentMomentRequestBody(generateText = content, sessionId =sessionId ))
val body = response.body()?.data ?: throw ServiceException("Failed to agent moment")
return body.toString()
}
suspend fun favoriteMoment(id: Int) { suspend fun favoriteMoment(id: Int) {
ApiClient.api.favoritePost(id) ApiClient.api.favoritePost(id)
} }
@@ -227,8 +250,26 @@ data class MomentImageEntity(
val id: Long, val id: Long,
// 图片URL // 图片URL
val url: String, val url: String,
// 原始图片URL
val originalUrl: String? = null,
// 直接访问URL
val directUrl: String? = null,
// 缩略图URL // 缩略图URL
val thumbnail: String, val thumbnail: String,
// 缩略图直接访问URL
val thumbnailDirectUrl: String? = null,
// 小尺寸图片URL
val small: String? = null,
// 小尺寸图片直接访问URL
val smallDirectUrl: String? = null,
// 中尺寸图片URL
val medium: String? = null,
// 中尺寸图片直接访问URL
val mediumDirectUrl: String? = null,
// 大尺寸图片URL
val large: String? = null,
// 大尺寸图片直接访问URL
val largeDirectUrl: String? = null,
// 图片BlurHash // 图片BlurHash
val blurHash: String? = null, val blurHash: String? = null,
// 宽度 // 宽度
@@ -237,6 +278,38 @@ data class MomentImageEntity(
var height: Int? = null var height: Int? = null
) )
/**
* 动态视频
*/
data class MomentVideoEntity(
// 视频ID
val id: Long,
// 视频URL
val url: String,
// 原始文件名
val originalUrl: String? = null,
// 直接访问URL
val directUrl: String? = null,
// 视频缩略图URL
val thumbnailUrl: String? = null,
// 视频缩略图直接访问URL
val thumbnailDirectUrl: String? = null,
// 视频时长(秒)
val duration: Int? = null,
// 宽度
val width: Int? = null,
// 高度
val height: Int? = null,
// 文件大小(字节)
val size: Long? = null,
// 视频格式
val format: String? = null,
// 视频比特率kbps
val bitrate: Int? = null,
// 帧率
val frameRate: String? = null
)
/** /**
* 动态 * 动态
*/ */
@@ -254,7 +327,7 @@ data class MomentEntity(
// 是否关注 // 是否关注
val followStatus: Boolean, val followStatus: Boolean,
// 动态内容 // 动态内容
val momentTextContent: String, val momentTextContent: String?,
// 动态图片 // 动态图片
@DrawableRes val momentPicture: Int, @DrawableRes val momentPicture: Int,
// 点赞数 // 点赞数
@@ -276,5 +349,135 @@ data class MomentEntity(
// 关联动态 // 关联动态
var relMoment: MomentEntity? = null, var relMoment: MomentEntity? = null,
// 是否收藏 // 是否收藏
var isFavorite: Boolean = false var isFavorite: Boolean = false,
// 外部链接
val url: String? = null,
// 动态视频列表
val videos: List<MomentVideoEntity>? = null,
// 新闻相关字段
val isNews: Boolean = false,
val newsTitle: String = "",
val newsUrl: String = "",
val newsSource: String = "",
val newsCategory: String = "",
val newsLanguage: 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 videoOnly: Boolean? = null
)
class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
override suspend fun fetchData(
page: Int,
pageSize: Int,
extra: MomentLoaderExtraArgs
): ListContainer<MomentEntity> {
val result = ApiClient.api.getPosts(
page = page,
pageSize = pageSize,
explore = if (extra.explore == true) "true" else "",
timelineId = extra.timelineId,
authorId = extra.authorId,
newsFilter = if (extra.newsOnly == true) "news_only" else "",
videoFilter = if (extra.videoOnly == true) "video_only" else ""
)
val data = result.body()?.let {
ListContainer(
list = it.list.map { it.toMomentItem() },
total = it.total,
page = page,
pageSize = pageSize
)
}
if (data == null) {
throw ServiceException("Failed to get moments")
}
return data
}
fun updateMomentLike(id: Int,isLike:Boolean) {
this.list = this.list.map { momentItem ->
if (momentItem.id == id) {
// 只有当状态发生变化时才更新计数,避免重复更新
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
}
}.toMutableList()
onListChanged?.invoke(this.list)
}
fun updateFavoriteCount(id: Int,isFavorite:Boolean) {
this.list = this.list.map { momentItem ->
if (momentItem.id == id) {
// 只有当状态发生变化时才更新计数,避免重复更新
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
}
}.toMutableList()
onListChanged?.invoke(this.list)
}
fun updateCommentCount(id: Int, delta: Int) {
this.list = this.list.map { momentItem ->
if (momentItem.id == id) {
val newCount = (momentItem.commentCount + delta).coerceAtLeast(0)
momentItem.copy(commentCount = newCount)
} else {
momentItem
}
}.toMutableList()
onListChanged?.invoke(this.list)
}
fun removeMoment(id: Int) {
this.list = this.list.filter { it.id != id }.toMutableList()
onListChanged?.invoke(this.list)
}
fun addMoment(moment: MomentEntity) {
this.list.add(0, moment)
onListChanged?.invoke(this.list)
}
fun updateFollowStatus(authorId:Int,isFollow:Boolean) {
this.list = this.list.map { momentItem ->
if (momentItem.authorId == authorId) {
momentItem.copy(followStatus = isFollow)
} else {
momentItem
}
}.toMutableList()
onListChanged?.invoke(this.list)
}
}

View File

@@ -0,0 +1,356 @@
package com.aiosman.ravenow.entity
import android.util.Log
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.aiosman.ravenow.data.ListContainer
import com.aiosman.ravenow.data.Room
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.data.api.ApiClient
import java.io.IOException
/**
* 群聊房间
*/
/**
* 房间内的智能体信息实体
*/
data class PromptTemplateEntity(
val id: Int,
val openId: String,
val title: String,
val desc: String,
val avatar: String
)
data class RoomEntity(
val id: Int,
val name: String,
val description: String,
val trtcRoomId: String,
val trtcType: String,
val cover: String,
val avatar: String,
val recommendBanner: String,
val isRecommended: Boolean,
val allowInHot: Boolean,
val creator: CreatorEntity,
val userCount: Int,
val totalMemberCount: Int? = null,
val maxMemberLimit: Int,
val maxTotal: Int? = null,
val systemMaxTotal: Int? = null,
val canJoin: Boolean,
val canJoinCode: Int,
val privateFeePaid: Boolean = false,
val prompts: List<PromptTemplateEntity> = emptyList(),
val createdAt: String? = null,
val updatedAt: String? = null,
val users: List<UsersEntity>,
)
data class CreatorEntity(
val id: Int,
val userId: String,
val trtcUserId: String,
val profile: ProfileEntity
)
data class UsersEntity(
val id: Int,
val userId: String,
val profile: ProfileEntity
)
data class ProfileEntity(
val id: Int,
val username: String,
val nickname: String,
val avatar: String,
val banner: String,
val bio: String,
val trtcUserId: String,
val chatAIId: String,
val aiAccount: Boolean,
)
/**
* 房间规则创建者信息
*/
data class RoomRuleCreatorEntity(
val id: Int,
val nickname: String,
val avatar: String,
val avatarMedium: String? = null,
val avatarLarge: String? = null,
val avatarDirectUrl: String? = null,
val avatarMediumDirectUrl: String? = null,
val avatarLargeDirectUrl: String? = null
)
/**
* 房间规则详情
*/
data class RoomRuleEntity(
val id: Int,
val rule: String,
val creator: RoomRuleCreatorEntity?,
val creatorType: String,
val roomId: Int,
val createdAt: String,
val updatedAt: String
)
/**
* 房间规则配额信息
*/
data class RoomRuleQuotaEntity(
val baseMaxCount: Int,
val purchasedCount: Int,
val totalMaxCount: Int,
val currentCount: Int,
val remainingCount: Int,
val usagePercent: Double
)
// ========== Room Member Management 实体类 ==========
/**
* 添加用户成功项目
*/
data class AddUserToRoomItemEntity(
val userId: String,
val type: String
)
/**
* 添加用户失败项目
*/
data class AddUserToRoomFailedItemEntity(
val userId: String,
val type: String,
val error: String
)
/**
* 添加用户到房间的结果
*/
data class AddUserToRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<AddUserToRoomItemEntity>,
val failedItems: List<AddUserToRoomFailedItemEntity>,
val skippedItems: List<AddUserToRoomItemEntity>
)
/**
* 添加智能体成功项目
*/
data class AddAgentToRoomItemEntity(
val agentOpenId: String,
val type: String
)
/**
* 添加智能体失败项目
*/
data class AddAgentToRoomFailedItemEntity(
val agentOpenId: String,
val type: String,
val error: String
)
/**
* 添加智能体到房间的结果
*/
data class AddAgentToRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<AddAgentToRoomItemEntity>,
val failedItems: List<AddAgentToRoomFailedItemEntity>,
val skippedItems: List<AddAgentToRoomItemEntity>
)
/**
* 移除智能体成功项目
*/
data class RemoveAgentFromRoomItemEntity(
val agentOpenId: String,
val type: String
)
/**
* 移除智能体失败项目
*/
data class RemoveAgentFromRoomFailedItemEntity(
val agentOpenId: String,
val type: String,
val error: String
)
/**
* 从房间移除智能体的结果
*/
data class RemoveAgentFromRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<RemoveAgentFromRoomItemEntity>,
val failedItems: List<RemoveAgentFromRoomFailedItemEntity>,
val skippedItems: List<RemoveAgentFromRoomItemEntity>
)
/**
* 移除用户成功项目
*/
data class RemoveUserFromRoomItemEntity(
val userId: String,
val type: String
)
/**
* 移除用户失败项目
*/
data class RemoveUserFromRoomFailedItemEntity(
val userId: String,
val type: String,
val error: String
)
/**
* 从房间移除用户的结果
*/
data class RemoveUserFromRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<RemoveUserFromRoomItemEntity>,
val failedItems: List<RemoveUserFromRoomFailedItemEntity>,
val skippedItems: List<RemoveUserFromRoomItemEntity>
)
class RoomLoader : DataLoader<AgentEntity,AgentLoaderExtraArgs>() {
override suspend fun fetchData(
page: Int,
pageSize: Int,
extra: AgentLoaderExtraArgs
): ListContainer<AgentEntity> {
val result = ApiClient.api.getAgent(
page = page,
pageSize = pageSize,
)
val data = result.body()?.let {
ListContainer(
list = it.data.list.map { it.toAgentEntity()},
total = it.data.total,
page = page,
pageSize = pageSize
)
}
if (data == null) {
throw ServiceException("Failed to get agent")
}
return data
}
}
/**
* 房间远程数据源
*/
class RoomRemoteDataSource {
suspend fun searchRooms(
pageNumber: Int,
pageSize: Int = 20,
search: String
): ListContainer<RoomEntity>? {
return try {
val resp = ApiClient.api.getRooms(
page = pageNumber,
pageSize = pageSize,
search = search,
roomType = "public" // 搜索时只显示公有房间
)
if (!resp.isSuccessful) {
// API 调用失败,返回 null
return null
}
val body = resp.body() ?: return null
// 安全地转换数据,过滤掉转换失败的项目
val roomList = body.list.mapNotNull { room ->
try {
room.toRoomtEntity()
} catch (e: Exception) {
// 如果某个房间数据转换失败,记录错误但继续处理其他房间
Log.e("RoomRemoteDataSource", "Failed to convert room: ${room.id}", e)
null
}
}
ListContainer(
total = body.total,
page = pageNumber,
pageSize = pageSize,
list = roomList
)
} catch (e: Exception) {
// 捕获所有异常,返回 null 让 PagingSource 处理
Log.e("RoomRemoteDataSource", "searchRooms error", e)
null
}
}
}
/**
* 房间搜索分页加载器
*/
class RoomSearchPagingSource(
private val roomRemoteDataSource: RoomRemoteDataSource,
private val keyword: String,
) : PagingSource<Int, RoomEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, RoomEntity> {
return try {
val currentPage = params.key ?: 1
val rooms = roomRemoteDataSource.searchRooms(
pageNumber = currentPage,
pageSize = params.loadSize,
search = keyword
)
if (rooms == null) {
// API 调用失败,返回空列表
LoadResult.Page(
data = emptyList(),
prevKey = if (currentPage == 1) null else currentPage - 1,
nextKey = null
)
} else {
LoadResult.Page(
data = rooms.list,
prevKey = if (currentPage == 1) null else currentPage - 1,
nextKey = if (rooms.list.isNotEmpty() && rooms.list.size >= params.loadSize) currentPage + 1 else null
)
}
} catch (exception: Exception) {
// 捕获所有异常,包括 IOException、ServiceException 等
LoadResult.Error(exception)
}
}
override fun getRefreshKey(state: PagingState<Int, RoomEntity>): Int? {
// 更健壮的实现:根据 anchorPosition 计算刷新键
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
}

View File

@@ -1,8 +1,8 @@
package com.aiosman.riderpro.entity package com.aiosman.ravenow.entity
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.paging.PagingState import androidx.paging.PagingState
import com.aiosman.riderpro.data.UserService import com.aiosman.ravenow.data.UserService
import java.io.IOException import java.io.IOException
/** /**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
package com.aiosman.ravenow.exp
import android.graphics.Bitmap
fun Bitmap.rotate(degree: Int): Bitmap {
val matrix = android.graphics.Matrix()
matrix.postRotate(degree.toFloat())
return Bitmap.createBitmap(this, 0, 0, this.width, this.height, matrix, true)
}

View File

@@ -1,11 +1,9 @@
package com.aiosman.riderpro.exp package com.aiosman.ravenow.exp
import android.content.Context import android.content.Context
import android.icu.text.SimpleDateFormat import android.icu.text.SimpleDateFormat
import android.icu.util.Calendar import android.icu.util.Calendar
import androidx.compose.ui.res.stringResource import com.aiosman.ravenow.R
import com.aiosman.riderpro.R
import com.aiosman.riderpro.data.api.ApiClient
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -50,7 +48,7 @@ fun Date.formatPostTime(): String {
} }
/** /**
* YYYY.DD.MM HH:MM * yyyy-MM-dd HH:mm
*/ */
fun Date.formatPostTime2(): String { fun Date.formatPostTime2(): String {
val calendar = Calendar.getInstance() val calendar = Calendar.getInstance()
@@ -60,7 +58,14 @@ fun Date.formatPostTime2(): String {
val day = calendar.get(Calendar.DAY_OF_MONTH) val day = calendar.get(Calendar.DAY_OF_MONTH)
val hour = calendar.get(Calendar.HOUR_OF_DAY) val hour = calendar.get(Calendar.HOUR_OF_DAY)
val minute = calendar.get(Calendar.MINUTE) val minute = calendar.get(Calendar.MINUTE)
return "$year.$month.$day $hour:$minute"
// 确保两位数
val monthStr = String.format("%02d", month)
val dayStr = String.format("%02d", day)
val hourStr = String.format("%02d", hour)
val minuteStr = String.format("%02d", minute)
return "$year-$monthStr-$dayStr $hourStr:$minuteStr"
} }
fun Date.formatChatTime(context: Context): String { fun Date.formatChatTime(context: Context): String {

View File

@@ -1,4 +1,4 @@
package com.aiosman.riderpro.exp package com.aiosman.ravenow.exp
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity

View File

@@ -1,4 +1,4 @@
package com.aiosman.riderpro.exp package com.aiosman.ravenow.exp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider

View File

@@ -0,0 +1,342 @@
package com.aiosman.ravenow.im
import android.app.Application
import android.content.Context
import android.util.Log
import io.openim.android.sdk.OpenIMClient
import io.openim.android.sdk.listener.*
import io.openim.android.sdk.models.*
/**
* OpenIM SDK 管理器
* 负责 OpenIM SDK 的初始化和各种监听器的设置
*/
object OpenIMManager {
private const val TAG = "OpenIMManager"
/**
* 初始化 OpenIM SDK
* @param context Android上下文
* @param initConfig SDK初始化配置
*/
fun initSDK(context: Context, initConfig: InitConfig) {
try {
OpenIMClient.getInstance().initSDK(
context.applicationContext as Application,
initConfig,
object : OnConnListener {
override fun onConnectFailed(code: Int, error: String?) {
Log.e(TAG, "连接服务器失败: code=$code, error=$error")
}
override fun onConnectSuccess() {
//连接服务器成功
Log.d(TAG, "连接服务器成功")
}
override fun onConnecting() {
//连接服务器中...
Log.d(TAG, "连接服务器中...")
}
override fun onKickedOffline() {
//当前用户被踢下线
Log.w(TAG, "当前用户被踢下线")
// 可以在这里处理用户被踢下线的逻辑,比如跳转到登录页面
}
override fun onUserTokenExpired() {
//登录票据已经过期
Log.w(TAG, "登录票据已经过期")
// 可以在这里处理token过期的逻辑比如重新登录
}
}
)
// 初始化完成后设置各种监听器
initListeners()
Log.d(TAG, "OpenIM SDK 初始化成功")
} catch (e: Exception) {
Log.e(TAG, "OpenIM SDK 初始化失败", e)
}
}
/**
* 初始化所有监听器
*/
private fun initListeners() {
setUserListener()
setMessageListener()
setFriendshipListener()
setConversationListener()
setGroupListener()
setSignalingListener()
}
/**
* 设置用户信息监听器
*/
private fun setUserListener() {
OpenIMClient.getInstance().userInfoManager.setOnUserListener(object : OnUserListener {
override fun onSelfInfoUpdated(userInfo: UserInfo?) {
Log.d(TAG, "用户信息更新: ${userInfo?.nickname}")
// 处理用户信息更新
}
})
}
/**
* 设置消息监听器
*/
private fun setMessageListener() {
OpenIMClient.getInstance().messageManager.setAdvancedMsgListener(object : OnAdvanceMsgListener {
override fun onRecvNewMessage(msg: Message?) {
Log.d(TAG, "收到新消息: ${msg?.toString()}")
// 处理新消息
}
override fun onRecvC2CReadReceipt(list: List<C2CReadReceiptInfo>?) {
Log.d(TAG, "收到C2C已读回执数量: ${list?.size}")
// 处理C2C已读回执
}
override fun onRecvGroupMessageReadReceipt(groupMessageReceipt: GroupMessageReceipt?) {
Log.d(TAG, "收到群组消息已读回执")
// 处理群组消息已读回执
}
override fun onRecvMessageRevokedV2(info: RevokedInfo?) {
Log.d(TAG, "消息被撤回: ${info?.clientMsgID}")
// 处理消息撤回
}
override fun onRecvMessageExtensionsChanged(msgID: String?, list: List<KeyValue>?) {
Log.d(TAG, "消息扩展信息变更: $msgID")
// 处理消息扩展信息变更
}
override fun onRecvMessageExtensionsDeleted(msgID: String?, list: List<String>?) {
Log.d(TAG, "消息扩展信息删除: $msgID")
// 处理消息扩展信息删除
}
override fun onRecvMessageExtensionsAdded(msgID: String?, list: List<KeyValue>?) {
Log.d(TAG, "消息扩展信息添加: $msgID")
// 处理消息扩展信息添加
}
override fun onMsgDeleted(message: Message?) {
Log.d(TAG, "消息被删除: ${message?.clientMsgID}")
// 处理消息删除
}
override fun onRecvOfflineNewMessage(msg: List<Message>?) {
Log.d(TAG, "收到离线新消息,数量: ${msg?.size}")
// 处理离线新消息
}
override fun onRecvOnlineOnlyMessage(s: String?) {
Log.d(TAG, "收到仅在线消息: $s")
// 处理仅在线消息
}
})
}
/**
* 设置好友关系监听器
*/
private fun setFriendshipListener() {
OpenIMClient.getInstance().friendshipManager.setOnFriendshipListener(object : OnFriendshipListener {
override fun onFriendApplicationAdded(friendApplication: FriendApplicationInfo?) {
Log.d(TAG, "收到好友申请")
// 处理好友申请
}
override fun onFriendApplicationDeleted(friendApplication: FriendApplicationInfo?) {
Log.d(TAG, "好友申请被删除")
// 处理好友申请删除
}
override fun onFriendApplicationAccepted(friendApplication: FriendApplicationInfo?) {
Log.d(TAG, "好友申请被接受")
// 处理好友申请接受
}
override fun onFriendApplicationRejected(friendApplication: FriendApplicationInfo?) {
Log.d(TAG, "好友申请被拒绝")
// 处理好友申请拒绝
}
override fun onFriendAdded(friendInfo: FriendInfo?) {
Log.d(TAG, "添加好友: ${friendInfo?.nickname}")
// 处理添加好友
}
override fun onFriendDeleted(friendInfo: FriendInfo?) {
Log.d(TAG, "删除好友: ${friendInfo?.nickname}")
// 处理删除好友
}
override fun onFriendInfoChanged(friendInfo: FriendInfo?) {
Log.d(TAG, "好友信息变更: ${friendInfo?.nickname}")
// 处理好友信息变更
}
override fun onBlacklistAdded(blackInfo: BlacklistInfo) {
Log.d(TAG, "添加黑名单")
// 处理添加黑名单
}
override fun onBlacklistDeleted(blackInfo: BlacklistInfo) {
Log.d(TAG, "移除黑名单")
// 处理移除黑名单
}
})
}
/**
* 设置会话监听器
*/
private fun setConversationListener() {
OpenIMClient.getInstance().conversationManager.setOnConversationListener(object : OnConversationListener {
override fun onConversationChanged(conversationList: MutableList<ConversationInfo>?) {
Log.d(TAG, "会话发生变化,数量: ${conversationList?.size}")
// 处理会话变化
}
override fun onNewConversation(conversationList: MutableList<ConversationInfo>?) {
Log.d(TAG, "新增会话,数量: ${conversationList?.size}")
// 处理新增会话
}
override fun onSyncServerFailed(reinstalled:Boolean) {
Log.e(TAG, "同步服务器失败")
// 处理同步失败
}
override fun onSyncServerFinish(reinstalled:Boolean) {
Log.d(TAG, "同步服务器完成")
// 处理同步完成
}
override fun onSyncServerStart(reinstalled:Boolean) {
Log.d(TAG, "开始同步服务器")
// 处理开始同步
}
override fun onTotalUnreadMessageCountChanged(totalUnreadCount: Int) {
Log.d(TAG, "总未读消息数变化: $totalUnreadCount")
// 处理总未读数变化
}
})
}
/**
* 设置群组监听器
*/
private fun setGroupListener() {
OpenIMClient.getInstance().groupManager.setOnGroupListener(object : OnGroupListener {
override fun onGroupApplicationAdded(groupApplication: GroupApplicationInfo?) {
Log.d(TAG, "收到入群申请")
// 处理入群申请
}
override fun onGroupApplicationDeleted(groupApplication: GroupApplicationInfo?) {
Log.d(TAG, "入群申请被删除")
// 处理入群申请删除
}
override fun onGroupApplicationAccepted(groupApplication: GroupApplicationInfo?) {
Log.d(TAG, "入群申请被接受")
// 处理入群申请接受
}
override fun onGroupApplicationRejected(groupApplication: GroupApplicationInfo?) {
Log.d(TAG, "入群申请被拒绝")
// 处理入群申请拒绝
}
override fun onGroupInfoChanged(groupInfo: GroupInfo?) {
Log.d(TAG, "群信息变更: ${groupInfo?.groupName}")
// 处理群信息变更
}
override fun onGroupMemberAdded(groupMemberInfo: GroupMembersInfo?) {
Log.d(TAG, "群成员加入")
// 处理群成员加入
}
override fun onGroupMemberDeleted(groupMemberInfo: GroupMembersInfo?) {
Log.d(TAG, "群成员退出")
// 处理群成员退出
}
override fun onGroupMemberInfoChanged(groupMemberInfo: GroupMembersInfo?) {
Log.d(TAG, "群成员信息变更")
// 处理群成员信息变更
}
override fun onJoinedGroupAdded(groupInfo: GroupInfo?) {
Log.d(TAG, "加入新群: ${groupInfo?.groupName}")
// 处理加入新群
}
override fun onJoinedGroupDeleted(groupInfo: GroupInfo?) {
Log.d(TAG, "退出群聊: ${groupInfo?.groupName}")
// 处理退出群聊
}
})
}
/**
* 设置信令监听器
*/
private fun setSignalingListener() {
// OpenIMClient.getInstance(). .setSignalingListener(object : OnSignalingListener {
// override fun onInvitationReceived(signalInvitationInfo: SignalInvitationInfo?) {
// Log.d(TAG, "收到信令邀请")
// // 处理信令邀请
// }
//
// override fun onInvitationCancelled(signalInvitationInfo: SignalInvitationInfo?) {
// Log.d(TAG, "信令邀请被取消")
// // 处理信令邀请取消
// }
//
// override fun onInvitationTimeout(signalInvitationInfo: SignalInvitationInfo?) {
// Log.d(TAG, "信令邀请超时")
// // 处理信令邀请超时
// }
//
// override fun onInviteeAccepted(signalInvitationInfo: SignalInvitationInfo?) {
// Log.d(TAG, "信令邀请被接受")
// // 处理信令邀请接受
// }
//
// override fun onInviteeRejected(signalInvitationInfo: SignalInvitationInfo?) {
// Log.d(TAG, "信令邀请被拒绝")
// // 处理信令邀请拒绝
// }
// })
}
/**
* 获取SDK数据库存储目录
* @param context Android上下文
* @return 存储目录路径
*/
fun getStorageDir(context: Context): String {
// 使用应用的内部存储目录下的im_sdk文件夹
val storageDir = context.filesDir.resolve("im_sdk")
// 如果目录不存在,创建它
if (!storageDir.exists()) {
storageDir.mkdirs()
}
return storageDir.absolutePath
}
}

View File

@@ -1,4 +1,4 @@
package com.aiosman.riderpro.model package com.aiosman.ravenow.model
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes

View File

@@ -1,4 +1,4 @@
package com.aiosman.riderpro.model package com.aiosman.ravenow.model
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.paging.PagingState import androidx.paging.PagingState

View File

@@ -0,0 +1,41 @@
package com.aiosman.ravenow.model
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
data class UpdateInfo(
val versionCode: Int,
val versionName: String,
val updateContent: String,
val downloadUrl: String,
val forceUpdate: Boolean
)
class ApkInstallReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.d("ApkInstallReceiver", "onReceive() called") // 添加日志输出
if (DownloadManager.ACTION_DOWNLOAD_COMPLETE == intent.action) {
Log.d("ApkInstallReceiver", "Download complete") // 添加日志输出
val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
// 方案二:通过 DownloadManager 的 API 获取 Uri
val uri = downloadManager.getUriForDownloadedFile(downloadId)
if (uri != null) {
installApk(context, uri)
}
}
}
private fun installApk(context: Context, uri: Uri) {
Log.d("ApkInstallReceiver", "installApk() called with: context = $context, uri = $uri") // 添加日志输出
val installIntent = Intent(Intent.ACTION_VIEW)
installIntent.setDataAndType(uri, "application/vnd.android.package-archive")
installIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
context.startActivity(installIntent)
}
}

View File

@@ -0,0 +1,105 @@
package com.aiosman.ravenow
import android.content.Context
import android.content.SharedPreferences
import android.content.res.Configuration
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
/**
* 持久化本地数据
*/
object AppStore {
private const val STORE_VERSION = 1
private const val PREFS_NAME = "app_prefs_$STORE_VERSION"
var token: String? = null
var rememberMe: Boolean = false
var isGuest: Boolean = false
private lateinit var sharedPreferences: SharedPreferences
lateinit var googleSignInOptions: GoogleSignInOptions
fun init(context: Context) {
sharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
this.loadData()
val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken("754277015802-uarf8br8k8gkpbj0t9g65bvkvit630q5.apps.googleusercontent.com") // Replace with your server's client ID
.requestEmail()
.build()
googleSignInOptions = gso
// apply dark mode - 如果用户未手动设置,优先跟随系统
val hasUserPreference = sharedPreferences.contains("darkMode")
val resolvedDarkMode = if (hasUserPreference) {
sharedPreferences.getBoolean("darkMode", false)
} else {
val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
currentNightMode == Configuration.UI_MODE_NIGHT_YES
}
AppState.darkMode = resolvedDarkMode
AppState.appTheme = if (resolvedDarkMode) DarkThemeColors() else LightThemeColors()
// load chat background
val savedBgUrl = sharedPreferences.getString("chatBackgroundUrl", null)
if (savedBgUrl != null) {
AppState.chatBackgroundUrl = savedBgUrl
}
}
suspend fun saveData() {
// shared preferences
sharedPreferences.edit().apply {
putString("token", token)
putBoolean("rememberMe", rememberMe)
putBoolean("isGuest", isGuest)
}.apply()
}
fun loadData() {
// shared preferences
token = sharedPreferences.getString("token", null)
rememberMe = sharedPreferences.getBoolean("rememberMe", false)
isGuest = sharedPreferences.getBoolean("isGuest", false)
}
fun saveDarkMode(darkMode: Boolean) {
sharedPreferences.edit().apply {
putBoolean("darkMode", darkMode)
}.apply()
}
fun saveChatBackgroundUrl(url: String?) {
sharedPreferences.edit().apply {
if (url != null) {
putString("chatBackgroundUrl", url)
} else {
remove("chatBackgroundUrl")
}
}.apply()
AppState.chatBackgroundUrl = url
}
// ===================== 用户本地扩展信息 =====================
// 后端暂未提供 MBTI 与星座字段,使用本地持久化按用户维度进行存储
private fun mbtiKey(userId: Int) = "mbti_user_$userId"
private fun zodiacKey(userId: Int) = "zodiac_user_$userId"
fun getUserMbti(userId: Int): String? {
return sharedPreferences.getString(mbtiKey(userId), null)
}
fun setUserMbti(userId: Int, mbti: String?) {
sharedPreferences.edit().apply {
if (mbti.isNullOrEmpty()) remove(mbtiKey(userId)) else putString(mbtiKey(userId), mbti)
}.apply()
}
fun getUserZodiac(userId: Int): String? {
return sharedPreferences.getString(zodiacKey(userId), null)
}
fun setUserZodiac(userId: Int, zodiac: String?) {
sharedPreferences.edit().apply {
if (zodiac.isNullOrEmpty()) remove(zodiacKey(userId)) else putString(zodiacKey(userId), zodiac)
}.apply()
}
}

View File

@@ -1,4 +1,4 @@
package com.aiosman.riderpro.test package com.aiosman.ravenow.test
data class StreetPosition( data class StreetPosition(
val name:String, val name:String,

View File

@@ -0,0 +1,821 @@
package com.aiosman.ravenow.ui
import ChangePasswordScreen
import ImageViewer
import ModificationListScreen
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.aiosman.ravenow.LocalAnimatedContentScope
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.LocalSharedTransitionScope
import com.aiosman.ravenow.ui.about.AboutScreen
import com.aiosman.ravenow.ui.account.AccountEditScreen2
import com.aiosman.ravenow.ui.account.AccountSetting
import com.aiosman.ravenow.ui.account.BlockedUsersScreen
import com.aiosman.ravenow.ui.account.MbtiSelectScreen
import com.aiosman.ravenow.ui.account.RemoveAccountScreen
import com.aiosman.ravenow.ui.account.ResetPasswordScreen
import com.aiosman.ravenow.ui.account.ZodiacSelectScreen
import com.aiosman.ravenow.ui.agent.AddAgentScreen
import com.aiosman.ravenow.ui.agent.AgentImageCropScreen
import com.aiosman.ravenow.ui.agent.AiPromptEditScreen
import com.aiosman.ravenow.ui.group.CreateGroupChatScreen
import com.aiosman.ravenow.ui.chat.ChatAiScreen
import com.aiosman.ravenow.ui.chat.ChatSettingScreen
import com.aiosman.ravenow.ui.chat.ChatScreen
import com.aiosman.ravenow.ui.chat.GroupChatScreen
import com.aiosman.ravenow.ui.comment.CommentsScreen
import com.aiosman.ravenow.ui.comment.notice.CommentNoticeScreen
import com.aiosman.ravenow.ui.composables.AgentCreatedSuccessIndicator
import com.aiosman.ravenow.ui.crop.ImageCropScreen
import com.aiosman.ravenow.ui.favourite.FavouriteListPage
import com.aiosman.ravenow.ui.favourite.FavouriteNoticeScreen
import com.aiosman.ravenow.ui.follower.FollowerListScreen
import com.aiosman.ravenow.ui.follower.FollowerNoticeScreen
import com.aiosman.ravenow.ui.follower.FollowingListScreen
import com.aiosman.ravenow.ui.gallery.OfficialGalleryScreen
import com.aiosman.ravenow.ui.gallery.OfficialPhotographerScreen
import com.aiosman.ravenow.ui.gallery.ProfileTimelineScreen
import com.aiosman.ravenow.ui.group.GroupChatInfoScreen
import com.aiosman.ravenow.ui.group.GroupMembersScreen
import com.aiosman.ravenow.ui.group.AddGroupMemberScreen
import com.aiosman.ravenow.ui.group.GroupProfileSettingsScreen
import com.aiosman.ravenow.ui.index.IndexScreen
import com.aiosman.ravenow.ui.index.tabs.message.NotificationsScreen
import com.aiosman.ravenow.ui.index.tabs.search.SearchScreen
import com.aiosman.ravenow.ui.like.LikeNoticeScreen
import com.aiosman.ravenow.ui.location.LocationDetailScreen
import com.aiosman.ravenow.ui.login.EmailSignupScreen
import com.aiosman.ravenow.ui.login.LoginPage
import com.aiosman.ravenow.ui.login.SignupScreen
import com.aiosman.ravenow.ui.login.UserAuthScreen
import com.aiosman.ravenow.ui.modification.EditModificationScreen
import com.aiosman.ravenow.ui.post.NewPostImageGridScreen
import com.aiosman.ravenow.ui.post.NewPostScreen
import com.aiosman.ravenow.ui.post.PostScreen
import com.aiosman.ravenow.ui.profile.AccountProfileV2
import com.aiosman.ravenow.ui.profile.AiProfileWrap
import com.aiosman.ravenow.ui.index.tabs.profile.vip.VipSelPage
import com.aiosman.ravenow.ui.notification.NotificationScreen
import com.aiosman.ravenow.ui.scan.ScanQrScreen
sealed class NavigationRoute(
val route: String,
) {
data object Index : NavigationRoute("Index")
data object ProfileTimeline : NavigationRoute("ProfileTimeline")
data object LocationDetail : NavigationRoute("LocationDetail/{x}/{y}")
data object OfficialPhoto : NavigationRoute("OfficialPhoto")
data object OfficialPhotographer : NavigationRoute("OfficialPhotographer")
data object Post : NavigationRoute("Post/{id}/{highlightCommentId}/{initImagePagerIndex}")
data object ModificationList : NavigationRoute("ModificationList")
data object MyMessage : NavigationRoute("MyMessage")
data object Comments : NavigationRoute("Comments")
data object Likes : NavigationRoute("Likes")
data object Followers : NavigationRoute("Followers")
data object NewPost : NavigationRoute("NewPost")
data object EditModification : NavigationRoute("EditModification")
data object Login : NavigationRoute("Login")
data object AccountProfile : NavigationRoute("AccountProfile/{id}?isAiAccount={isAiAccount}")
data object SignUp : NavigationRoute("SignUp")
data object UserAuth : NavigationRoute("UserAuth")
data object EmailSignUp : NavigationRoute("EmailSignUp")
data object AccountEdit : NavigationRoute("AccountEditScreen")
data object ImageViewer : NavigationRoute("ImageViewer")
data object ChangePasswordScreen : NavigationRoute("ChangePasswordScreen")
data object FavouritesScreen : NavigationRoute("FavouritesScreen")
data object NewPostImageGrid : NavigationRoute("NewPostImageGrid")
data object Search : NavigationRoute("Search")
data object FollowerList : NavigationRoute("FollowerList/{id}")
data object FollowingList : NavigationRoute("FollowingList/{id}")
data object ResetPassword : NavigationRoute("ResetPassword")
data object FavouriteList : NavigationRoute("FavouriteList")
data object Chat : NavigationRoute("Chat/{id}")
data object ChatAi : NavigationRoute("ChatAi/{id}")
data object ChatSetting : NavigationRoute("ChatSetting")
data object ChatGroup : NavigationRoute("ChatGroup/{id}/{name}/{avatar}")
data object CommentNoticeScreen : NavigationRoute("CommentNoticeScreen")
data object ImageCrop : NavigationRoute("ImageCrop")
data object AgentImageCrop : NavigationRoute("AgentImageCrop")
data object AccountSetting : NavigationRoute("AccountSetting")
data object AboutScreen : NavigationRoute("AboutScreen")
data object AddAgent : NavigationRoute("AddAgent")
data object CreateGroupChat : NavigationRoute("CreateGroupChat")
data object GroupInfo : NavigationRoute("GroupInfo/{id}")
data object GroupMembers : NavigationRoute("GroupMembers/{id}")
data object AddGroupMember : NavigationRoute("AddGroupMember/{groupId}/{groupName}")
data object GroupProfileSettings : NavigationRoute("GroupProfileSettings/{id}")
data object VipSelPage : NavigationRoute("VipSelPage")
data object RemoveAccountScreen: NavigationRoute("RemoveAccount")
data object NotificationScreen : NavigationRoute("NotificationScreen")
data object MbtiSelect : NavigationRoute("MbtiSelect")
data object ZodiacSelect : NavigationRoute("ZodiacSelect")
data object ScanQr : NavigationRoute("ScanQr")
data object AiPromptEdit : NavigationRoute("AiPromptEdit/{chatAIId}")
data object BlockedUsersScreen : NavigationRoute("BlockedUsersScreen")
}
@Composable
fun NavigationController(
navController: NavHostController,
startDestination: String = NavigationRoute.Login.route
) {
val navigationBarHeight = with(LocalDensity.current) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
NavHost(
navController = navController,
startDestination = startDestination,
) {
composable(route = NavigationRoute.Index.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
IndexScreen()
}
}
composable(route = NavigationRoute.ProfileTimeline.route) {
ProfileTimelineScreen()
}
composable(
route = NavigationRoute.LocationDetail.route,
arguments = listOf(
navArgument("x") { type = NavType.FloatType },
navArgument("y") { type = NavType.FloatType }
)
) {
Box(
modifier = Modifier.padding(bottom = navigationBarHeight)
) {
val x = it.arguments?.getFloat("x") ?: 0f
val y = it.arguments?.getFloat("y") ?: 0f
LocationDetailScreen(
x, y
)
}
}
composable(route = NavigationRoute.OfficialPhoto.route) {
OfficialGalleryScreen()
}
composable(route = NavigationRoute.OfficialPhotographer.route) {
OfficialPhotographerScreen()
}
composable(
route = NavigationRoute.Post.route,
arguments = listOf(
navArgument("id") { type = NavType.StringType },
navArgument("highlightCommentId") { type = NavType.IntType },
navArgument("initImagePagerIndex") { type = NavType.IntType }
),
enterTransition = {
// iOS push: new screen slides in from the right
slideInHorizontally(
initialOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(durationMillis = 280)
)
},
exitTransition = {
// iOS push: previous screen shifts slightly left (parallax)
slideOutHorizontally(
targetOffsetX = { fullWidth -> -fullWidth / 3 },
animationSpec = tween(durationMillis = 280)
)
},
popEnterTransition = {
// iOS pop: previous screen slides back from slight left offset
slideInHorizontally(
initialOffsetX = { fullWidth -> -fullWidth / 3 },
animationSpec = tween(durationMillis = 280)
)
},
popExitTransition = {
// iOS pop: current screen slides out to the right
slideOutHorizontally(
targetOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(durationMillis = 280)
)
}
) { backStackEntry ->
val id = backStackEntry.arguments?.getString("id")
val highlightCommentId =
backStackEntry.arguments?.getInt("highlightCommentId")?.let {
if (it == 0) null else it
}
val initIndex = backStackEntry.arguments?.getInt("initImagePagerIndex")
PostScreen(
id!!,
highlightCommentId,
initImagePagerIndex = initIndex
)
}
composable(route = NavigationRoute.ModificationList.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
ModificationListScreen()
}
composable(route = NavigationRoute.MyMessage.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
NotificationsScreen()
}
composable(route = NavigationRoute.Comments.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
CommentsScreen()
}
composable(route = NavigationRoute.Likes.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
LikeNoticeScreen()
}
composable(route = NavigationRoute.Followers.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
FollowerNoticeScreen()
}
composable(
route = NavigationRoute.NewPost.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
NewPostScreen()
}
composable(route = NavigationRoute.EditModification.route) {
Box(
modifier = Modifier.padding(top = 64.dp)
) {
EditModificationScreen()
}
}
composable(route = NavigationRoute.Login.route) {
LoginPage()
}
composable(
route = NavigationRoute.AccountProfile.route,
arguments = listOf(
navArgument("id") { type = NavType.StringType },
navArgument("isAiAccount") {
type = NavType.BoolType
defaultValue = false
}
),
enterTransition = {
// iOS push: new screen slides in from the right
slideInHorizontally(
initialOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(durationMillis = 280)
)
},
exitTransition = {
// iOS push: previous screen shifts slightly left (parallax)
slideOutHorizontally(
targetOffsetX = { fullWidth -> -fullWidth / 3 },
animationSpec = tween(durationMillis = 280)
)
},
popEnterTransition = {
// iOS pop: previous screen slides back from slight left offset
slideInHorizontally(
initialOffsetX = { fullWidth -> -fullWidth / 3 },
animationSpec = tween(durationMillis = 280)
)
},
popExitTransition = {
// iOS pop: current screen slides out to the right
slideOutHorizontally(
targetOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(durationMillis = 280)
)
}
) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
val id = it.arguments?.getString("id")!!
val isAiAccount = it.arguments?.getBoolean("isAiAccount") ?: false
// 根据isAiAccount参数分发到不同的Profile页面
if (isAiAccount) {
AiProfileWrap(id)
} else {
AccountProfileV2(id, isAiAccount)
}
}
}
composable(
route = NavigationRoute.SignUp.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
SignupScreen()
}
composable(
route = NavigationRoute.UserAuth.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
UserAuthScreen()
}
composable(
route = NavigationRoute.EmailSignUp.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
EmailSignupScreen()
}
composable(
route = NavigationRoute.AccountEdit.route,
enterTransition = {
// iOS风格从底部向上滑入
slideInVertically(
initialOffsetY = { fullHeight -> fullHeight },
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
) + fadeIn(
animationSpec = tween(durationMillis = 300)
)
},
exitTransition = {
// iOS风格向底部滑出
slideOutVertically(
targetOffsetY = { fullHeight -> fullHeight },
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
) + fadeOut(
animationSpec = tween(durationMillis = 300)
)
},
popEnterTransition = {
// 返回时从底部滑入
slideInVertically(
initialOffsetY = { fullHeight -> fullHeight },
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
) + fadeIn(
animationSpec = tween(durationMillis = 300)
)
},
popExitTransition = {
// 返回时向底部滑出
slideOutVertically(
targetOffsetY = { fullHeight -> fullHeight },
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
) + fadeOut(
animationSpec = tween(durationMillis = 300)
)
}
) {
AccountEditScreen2()
}
composable(route = NavigationRoute.ImageViewer.route) {
ImageViewer()
}
composable(route = NavigationRoute.ChangePasswordScreen.route) {
ChangePasswordScreen()
}
composable(route = NavigationRoute.BlockedUsersScreen.route) {
BlockedUsersScreen()
}
composable(route = NavigationRoute.RemoveAccountScreen.route) {
RemoveAccountScreen()
}
composable(route = NavigationRoute.MbtiSelect.route) {
MbtiSelectScreen()
}
composable(route = NavigationRoute.ZodiacSelect.route) {
ZodiacSelectScreen()
}
composable(route = NavigationRoute.VipSelPage.route) {
VipSelPage()
}
composable(route = NavigationRoute.FavouritesScreen.route) {
FavouriteNoticeScreen()
}
composable(route = NavigationRoute.NewPostImageGrid.route) {
NewPostImageGridScreen()
}
composable(route = NavigationRoute.Search.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
SearchScreen()
}
}
composable(route = NavigationRoute.ScanQr.route) {
ScanQrScreen()
}
composable(
route = NavigationRoute.FollowerList.route,
arguments = listOf(navArgument("id") { type = NavType.IntType })
) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
FollowerListScreen(it.arguments?.getInt("id")!!)
}
}
composable(
route = NavigationRoute.FollowingList.route,
arguments = listOf(navArgument("id") { type = NavType.IntType })
) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
FollowingListScreen(it.arguments?.getInt("id")!!)
}
}
composable(route = NavigationRoute.ResetPassword.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
ResetPasswordScreen()
}
}
composable(route = NavigationRoute.FavouriteList.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
FavouriteListPage()
}
}
composable(
route = NavigationRoute.Chat.route,
arguments = listOf(navArgument("id") { type = NavType.StringType })
) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
ChatScreen(it.arguments?.getString("id")!!)
}
}
composable(
route = NavigationRoute.ChatAi.route,
arguments = listOf(navArgument("id") { type = NavType.StringType })
) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
ChatAiScreen(it.arguments?.getString("id")!!)
}
}
composable(route = NavigationRoute.ChatSetting.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
ChatSettingScreen()
}
}
composable(
route = NavigationRoute.ChatGroup.route,
arguments = listOf(navArgument("id") { type = NavType.StringType },
navArgument("name") { type = NavType.StringType },
navArgument("avatar") { type = NavType.StringType })
) {
val encodedId = it.arguments?.getString("id")
val decodedId = encodedId?.let { java.net.URLDecoder.decode(it, "UTF-8") }
val name = it.arguments?.getString("name")
val avatar = it.arguments?.getString("avatar")
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
GroupChatScreen(decodedId?:"",name?:"",avatar?:"")
}
}
composable(route = NavigationRoute.CommentNoticeScreen.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
CommentNoticeScreen()
}
}
composable(route = NavigationRoute.ImageCrop.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
ImageCropScreen()
}
}
composable(route = NavigationRoute.AgentImageCrop.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
AgentImageCropScreen()
}
}
composable(route = NavigationRoute.AccountSetting.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
AccountSetting()
}
}
composable(route = NavigationRoute.AboutScreen.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
AboutScreen()
}
}
composable(
route = NavigationRoute.AddAgent.route,
) {
AddAgentScreen()
}
composable(
route = NavigationRoute.CreateGroupChat.route,
enterTransition = {
slideInHorizontally(
initialOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(durationMillis = 280)
)
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { fullWidth -> -fullWidth / 3 },
animationSpec = tween(durationMillis = 280)
)
},
popEnterTransition = {
slideInHorizontally(
initialOffsetX = { fullWidth -> -fullWidth / 3 },
animationSpec = tween(durationMillis = 280)
)
},
popExitTransition = {
slideOutHorizontally(
targetOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(durationMillis = 280)
)
}
) {
CreateGroupChatScreen()
}
composable(
route = NavigationRoute.GroupInfo.route,
arguments = listOf(navArgument("id") { type = NavType.StringType })
) {
val encodedId = it.arguments?.getString("id")
val decodedId = encodedId?.let { java.net.URLDecoder.decode(it, "UTF-8") }
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
GroupChatInfoScreen(decodedId?:"")
}
}
composable(
route = NavigationRoute.GroupMembers.route,
arguments = listOf(navArgument("id") { type = NavType.StringType })
) {
val encodedId = it.arguments?.getString("id")
val decodedId = encodedId?.let { java.net.URLDecoder.decode(it, "UTF-8") }
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
GroupMembersScreen(decodedId ?: "")
}
}
composable(
route = NavigationRoute.AddGroupMember.route,
arguments = listOf(
navArgument("groupId") { type = NavType.StringType },
navArgument("groupName") { type = NavType.StringType }
)
) {
val encodedGroupId = it.arguments?.getString("groupId")
val decodedGroupId = encodedGroupId?.let { java.net.URLDecoder.decode(it, "UTF-8") }
val encodedGroupName = it.arguments?.getString("groupName")
val decodedGroupName = encodedGroupName?.let { java.net.URLDecoder.decode(it, "UTF-8") }
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
AddGroupMemberScreen(decodedGroupId ?: "", decodedGroupName)
}
}
composable(
route = NavigationRoute.GroupProfileSettings.route,
arguments = listOf(navArgument("id") { type = NavType.StringType })
) {
val encodedId = it.arguments?.getString("id")
val decodedId = encodedId?.let { java.net.URLDecoder.decode(it, "UTF-8") }
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
GroupProfileSettingsScreen(decodedId ?: "")
}
}
composable(route = NavigationRoute.NotificationScreen.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
NotificationScreen()
}
}
composable(
route = NavigationRoute.AiPromptEdit.route,
arguments = listOf(navArgument("chatAIId") { type = NavType.StringType })
) {
val chatAIId = it.arguments?.getString("chatAIId") ?: ""
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
AiPromptEditScreen(chatAIId = chatAIId)
}
}
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Navigation(
startDestination: String = NavigationRoute.Login.route,
onLaunch: (navController: NavHostController) -> Unit
) {
val navController = rememberNavController()
LaunchedEffect(Unit) {
onLaunch(navController)
}
SharedTransitionLayout {
CompositionLocalProvider(
LocalNavController provides navController,
LocalSharedTransitionScope provides this@SharedTransitionLayout,
) {
Box {
NavigationController(
navController = navController,
startDestination = startDestination
)
AgentCreatedSuccessIndicator()
}
}
}
}
fun NavHostController.navigateToPost(
id: Int,
highlightCommentId: Int? = 0,
initImagePagerIndex: Int? = 0
) {
navigate(
route = NavigationRoute.Post.route
.replace("{id}", id.toString())
.replace("{highlightCommentId}", highlightCommentId.toString())
.replace("{initImagePagerIndex}", initImagePagerIndex.toString())
)
}
fun NavHostController.navigateToChat(id: String) {
navigate(
route = NavigationRoute.Chat.route
.replace("{id}", id)
)
}
fun NavHostController.navigateToChatAi(id: String) {
navigate(
route = NavigationRoute.ChatAi.route
.replace("{id}", id)
)
}
fun NavHostController.navigateToGroupChat(id: String,name:String,avatar:String) {
val encodedId = java.net.URLEncoder.encode(id, "UTF-8")
val encodedName = java.net.URLEncoder.encode(name, "UTF-8")
val encodedAvator = java.net.URLEncoder.encode(avatar, "UTF-8")
navigate(
route = NavigationRoute.ChatGroup.route
.replace("{id}", encodedId)
.replace("{name}", encodedName)
.replace("{avatar}", encodedAvator)
)
}
fun NavHostController.navigateToGroupInfo(id: String) {
val encodedId = java.net.URLEncoder.encode(id, "UTF-8")
navigate(
route = NavigationRoute.GroupInfo.route
.replace("{id}", encodedId)
)
}
fun NavHostController.navigateToGroupMembers(id: String) {
val encodedId = java.net.URLEncoder.encode(id, "UTF-8")
navigate(
route = NavigationRoute.GroupMembers.route
.replace("{id}", encodedId)
)
}
fun NavHostController.navigateToAddGroupMember(groupId: String, groupName: String?) {
val encodedGroupId = java.net.URLEncoder.encode(groupId, "UTF-8")
val encodedGroupName = java.net.URLEncoder.encode(groupName ?: "", "UTF-8")
navigate(
route = NavigationRoute.AddGroupMember.route
.replace("{groupId}", encodedGroupId)
.replace("{groupName}", encodedGroupName)
)
}
fun NavHostController.navigateToGroupProfileSettings(id: String) {
val encodedId = java.net.URLEncoder.encode(id, "UTF-8")
navigate(
route = NavigationRoute.GroupProfileSettings.route
.replace("{id}", encodedId)
)
}
fun NavHostController.goTo(
route: NavigationRoute
) {
navigate(route.route)
}

View File

@@ -0,0 +1,82 @@
package com.aiosman.ravenow.ui.about
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
@Composable
fun AboutScreen() {
val appColors = LocalAppTheme.current
val context = LocalContext.current
val versionText = context.packageManager.getPackageInfo(context.packageName, 0).versionName
Column(
modifier = Modifier
.fillMaxSize()
.background(appColors.background),
) {
StatusBarSpacer()
Box(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
) {
NoticeScreenHeader(
title = stringResource(R.string.about_paipai),
moreIcon = false
)
}
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// app icondww
Box {
Image(
painter = painterResource(id = R.mipmap.invalid_name),
contentDescription = "app icon",
modifier = Modifier.size(80.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
// app name
Text(
text = stringResource(R.string.paipai),
fontSize = 24.sp,
color = appColors.text,
fontWeight = FontWeight.ExtraBold
)
Spacer(modifier = Modifier.height(16.dp))
// app version
Text(
text = stringResource(R.string.version_text, versionText ?: ""),
fontSize = 16.sp,
color = appColors.secondaryText,
fontWeight = FontWeight.Normal
)
}
}
}

View File

@@ -0,0 +1,172 @@
package com.aiosman.ravenow.ui.account
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.UploadImage
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
import com.aiosman.ravenow.utils.TrtcHelper
import com.aiosman.ravenow.AppStore
import android.util.Log
import java.io.File
object AccountEditViewModel : ViewModel() {
var name by mutableStateOf("")
var bio by mutableStateOf("")
var imageUrl by mutableStateOf<Uri?>(null)
var bannerImageUrl by mutableStateOf<Uri?>(null)
var bannerFile by mutableStateOf<File?>(null)
val accountService: AccountService = AccountServiceImpl()
var profile by mutableStateOf<AccountProfileEntity?>(null)
var croppedBitmap by mutableStateOf<Bitmap?>(null)
var isUpdating by mutableStateOf(false)
var isLoading by mutableStateOf(false)
// 本地扩展字段
var mbti by mutableStateOf<String?>(null)
var zodiac by mutableStateOf<String?>(null)
// 保存原始值,用于取消时恢复
private var originalName: String = ""
private var originalBio: String = ""
private var originalMbti: String? = null
private var originalZodiac: String? = null
suspend fun reloadProfile(updateTrtcProfile:Boolean = false, clearCroppedBitmap: Boolean = false) {
Log.d("AccountEditViewModel", "reloadProfile: 开始加载用户资料")
isLoading = true
try {
Log.d("AccountEditViewModel", "reloadProfile: 调用API获取用户资料")
accountService.getMyAccountProfile().let {
Log.d("AccountEditViewModel", "reloadProfile: 成功获取用户资料 - nickName: ${it.nickName}")
profile = it
name = it.nickName
bio = it.bio
// 保存原始值,用于取消时恢复
originalName = it.nickName
originalBio = it.bio
// 只在明确要求时清除之前裁剪的图片(例如保存成功后)
if (clearCroppedBitmap) {
croppedBitmap = null
}
// 读取本地扩展字段
try {
val uid = it.id // 使用 profile 的 id确保非空
val loadedMbti = com.aiosman.ravenow.AppStore.getUserMbti(uid)
val loadedZodiac = com.aiosman.ravenow.AppStore.getUserZodiac(uid)
mbti = loadedMbti
zodiac = loadedZodiac
// 保存原始值
originalMbti = loadedMbti
originalZodiac = loadedZodiac
} catch (_: Exception) { }
if (updateTrtcProfile) {
TrtcHelper.updateTrtcProfile(
it.nickName,
it.rawAvatar
)
}
}
} catch (e: Exception) {
// 处理异常避免UI消失
Log.e("AccountEditViewModel", "reloadProfile: 加载用户资料失败", e)
e.printStackTrace()
// 如果是首次加载失败至少保持之前的profile不变
// 这样UI不会突然消失
} finally {
Log.d("AccountEditViewModel", "reloadProfile: 加载完成isLoading设为false")
isLoading = false
}
}
fun resetToOriginalData() {
// 恢复所有字段到原始值
name = originalName
bio = originalBio
mbti = originalMbti
zodiac = originalZodiac
// 清除之前裁剪的图片和壁纸
croppedBitmap = null
bannerImageUrl = null
bannerFile = null
}
suspend fun updateUserProfile(context: Context) {
val newAvatar = croppedBitmap?.let {
val file = File(context.cacheDir, "avatar.jpg")
it.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream())
UploadImage(file, "avatar.jpg", "", "jpg")
}
// 处理背景图更新
val newBanner = bannerImageUrl?.let { uri ->
bannerFile?.let { file ->
val cursor = context.contentResolver.query(uri, null, null, null, null)
var uploadBanner: UploadImage? = null
cursor?.use { cur ->
val columnIndex = cur.getColumnIndex("_display_name")
if (cur.moveToFirst() && columnIndex != -1) {
val displayName = cur.getString(columnIndex)
val extension = displayName.substringAfterLast(".")
Log.d("AccountEditViewModel", "Banner file name: $displayName, extension: $extension")
uploadBanner = UploadImage(file, displayName, uri.toString(), extension)
} else {
// 如果无法获取文件名,使用默认值
val displayName = "banner.jpg"
val extension = "jpg"
uploadBanner = UploadImage(file, displayName, uri.toString(), extension)
}
}
uploadBanner
}
}
// 去除换行符,确保昵称和个人简介不包含换行
val cleanName = name.trim().replace("\n", "").replace("\r", "")
val cleanBio = bio.trim().replace("\n", "").replace("\r", "")
val newName = if (cleanName == profile?.nickName) null else cleanName
accountService.updateProfile(
avatar = newAvatar,
banner = newBanner,
nickName = newName,
bio = cleanBio
)
// 保存本地扩展字段
try {
profile?.id?.let { uid ->
com.aiosman.ravenow.AppStore.setUserMbti(uid, mbti)
com.aiosman.ravenow.AppStore.setUserZodiac(uid, zodiac)
}
} catch (_: Exception) { }
// 清除背景图状态
bannerImageUrl = null
bannerFile = null
// 刷新用户资料,保存成功后清除裁剪的图片
reloadProfile(clearCroppedBitmap = true)
// 刷新个人资料页面的用户资料
MyProfileViewModel.loadUserProfile()
}
/**
* 重置ViewModel状态
* 用于用户登出或切换账号时清理数据
*/
fun ResetModel() {
Log.d("AccountEditViewModel", "ResetModel: 重置ViewModel状态")
profile = null
name = ""
bio = ""
imageUrl = null
bannerImageUrl = null
bannerFile = null
croppedBitmap = null
isUpdating = false
isLoading = false
}
}

View File

@@ -0,0 +1,175 @@
package com.aiosman.ravenow.ui.account
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
private object AccountSettingConstants {
const val BACK_BUTTON_SIZE = 36
const val BACK_BUTTON_ICON_SIZE = 24
const val BACK_BUTTON_START_PADDING = 19
const val OPTION_ITEM_HEIGHT = 56
const val OPTION_ITEM_ICON_SIZE = 24
const val OPTION_ITEM_HORIZONTAL_PADDING = 16
const val OPTION_ITEM_ICON_TEXT_SPACING = 12
const val OPTION_ITEM_TEXT_SIZE = 17
const val HEADER_VERTICAL_PADDING = 16
const val TITLE_OFFSET_X = 19
const val CARD_CORNER_RADIUS = 16
}
@Composable
private fun CircularBackButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val appColors = LocalAppTheme.current
Box(
modifier = modifier
.size(AccountSettingConstants.BACK_BUTTON_SIZE.dp)
.background(
color = appColors.secondaryBackground,
shape = CircleShape
)
.noRippleClickable { onClick() },
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = "返回",
modifier = Modifier.size(AccountSettingConstants.BACK_BUTTON_ICON_SIZE.dp),
colorFilter = ColorFilter.tint(appColors.text)
)
}
}
@Composable
private fun SecurityOptionItem(
iconRes: Int,
label: String,
onClick: () -> Unit,
applyColorFilter: Boolean = true
) {
val appColors = LocalAppTheme.current
Row(
modifier = Modifier
.fillMaxWidth()
.height(AccountSettingConstants.OPTION_ITEM_HEIGHT.dp)
.padding(horizontal = AccountSettingConstants.OPTION_ITEM_HORIZONTAL_PADDING.dp)
.noRippleClickable { onClick() },
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = iconRes),
contentDescription = null,
modifier = Modifier.size(AccountSettingConstants.OPTION_ITEM_ICON_SIZE.dp),
colorFilter = if (applyColorFilter) ColorFilter.tint(appColors.text) else null
)
Text(
text = label,
modifier = Modifier
.padding(start = AccountSettingConstants.OPTION_ITEM_ICON_TEXT_SPACING.dp)
.weight(1f),
color = appColors.text,
fontSize = AccountSettingConstants.OPTION_ITEM_TEXT_SIZE.sp,
fontWeight = FontWeight.Medium
)
Image(
painter = painterResource(id = R.drawable.rave_now_nav_right),
contentDescription = null,
modifier = Modifier.size(AccountSettingConstants.OPTION_ITEM_ICON_SIZE.dp),
colorFilter = ColorFilter.tint(appColors.secondaryText)
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountSetting() {
val appColors = LocalAppTheme.current
val navController = LocalNavController.current
Column(
modifier = Modifier
.fillMaxSize()
.background(appColors.background),
) {
StatusBarSpacer()
// 顶部标题栏
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = AccountSettingConstants.HEADER_VERTICAL_PADDING.dp)
) {
CircularBackButton(
onClick = { navController.navigateUp() },
modifier = Modifier.padding(start = AccountSettingConstants.BACK_BUTTON_START_PADDING.dp)
)
Text(
text = stringResource(R.string.account_and_security),
fontWeight = FontWeight.W800,
fontSize = AccountSettingConstants.OPTION_ITEM_TEXT_SIZE.sp,
color = appColors.text,
modifier = Modifier
.align(Alignment.Center)
.offset(x = AccountSettingConstants.TITLE_OFFSET_X.dp)
)
}
// 安全选项卡片
Column(
modifier = Modifier
.fillMaxWidth()
.background(
color = appColors.background,
shape = RoundedCornerShape(AccountSettingConstants.CARD_CORNER_RADIUS.dp)
)
) {
SecurityOptionItem(
iconRes = R.mipmap.icons_padlock,
label = stringResource(R.string.change_password),
onClick = { navController.navigate(NavigationRoute.ChangePasswordScreen.route) }
)
SecurityOptionItem(
iconRes = R.mipmap.icons_block,
label = stringResource(R.string.blocked_users),
onClick = { navController.navigate(NavigationRoute.BlockedUsersScreen.route) }
)
SecurityOptionItem(
iconRes = R.mipmap.icons_remove,
label = stringResource(R.string.remove_account),
onClick = { navController.navigate(NavigationRoute.RemoveAccountScreen.route) },
applyColorFilter = false
)
}
}
}

View File

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

@@ -0,0 +1,383 @@
package com.aiosman.ravenow.ui.account
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
// MBTI类型列表
val MBTI_TYPES = listOf(
"INTJ", "INTP", "ENTJ", "ENTP",
"INFJ", "INFP", "ENFJ", "ENFP",
"ISTJ", "ISFJ", "ESTJ", "ESFJ",
"ISTP", "ISFP", "ESTP", "ESFP"
)
fun getMbtiImageResId(mbti: String, isDarkMode: Boolean): Int {
return when {
isDarkMode && mbti == "ENTP" -> R.mipmap.anmbti_entp
isDarkMode && mbti == "ESTP" -> R.mipmap.anmbti_estp
isDarkMode && mbti == "ENTJ" -> R.mipmap.anmbti_entj
else -> when (mbti) {
"INTJ" -> R.mipmap.mbti_intj
"INTP" -> R.mipmap.mbti_intp
"ENTJ" -> R.mipmap.mbti_entj
"ENTP" -> R.mipmap.mbti_entp
"INFJ" -> R.mipmap.mbti_infj
"INFP" -> R.mipmap.mbti_infp
"ENFJ" -> R.mipmap.mbti_enfj
"ENFP" -> R.mipmap.mbti_enfp
"ISTJ" -> R.mipmap.mbti_istj
"ISFJ" -> R.mipmap.mbti_isfj
"ESTJ" -> R.mipmap.mbti_estj
"ESFJ" -> R.mipmap.mbti_esfj
"ISTP" -> R.mipmap.mbti_istp
"ISFP" -> R.mipmap.mbti_isfp
"ESTP" -> R.mipmap.mbti_estp
"ESFP" -> R.mipmap.mbti_esfp
else -> R.mipmap.xingzuo
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MbtiSelectBottomSheet(
onClose: () -> Unit
) {
val appColors = LocalAppTheme.current
val isDarkMode = AppState.darkMode
val model = AccountEditViewModel
val currentMbti = model.mbti
val sheetBackgroundColor = if (isDarkMode) {
appColors.secondaryBackground
} else {
Color(0xFFFFFFFF)
}
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onClose,
sheetState = sheetState,
containerColor = Color.Transparent,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
dragHandle = {}
) {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(top = 8.dp)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)),
color = sheetBackgroundColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
// 头部
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
contentAlignment = Alignment.Center
) {
val cancelButtonGradientColors = if (isDarkMode) {
listOf(
Color(0xFF3A3A3C),
Color(0xFF2C2C2E)
)
} else {
listOf(
Color(0xFFFFFFFF),
Color(0xFFF8F8F8)
)
}
val cancelButtonContentColor = if (isDarkMode) Color(0xFFFFFFFF) else Color(0xFF404040)
// 左上角返回按钮:整体「箭头 + 取消」在按钮内居中
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.width(91.dp)
.height(44.dp)
.clip(RoundedCornerShape(1000.dp))
.background(
brush = Brush.linearGradient(
colors = cancelButtonGradientColors
)
)
.noRippleClickable { onClose() },
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = null,
modifier = Modifier.size(17.dp),
colorFilter = ColorFilter.tint(cancelButtonContentColor)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "取消",
fontSize = 17.sp,
fontWeight = FontWeight.Medium,
color = cancelButtonContentColor,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Clip
)
}
}
// 中间标题
Text(
text = stringResource(R.string.choose_mbti),
color = appColors.text,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
}
Spacer(Modifier.height(12.dp))
// NestedScroll阻止滚动事件向上传到 BottomSheet
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return available
}
override suspend fun onPreFling(available: Velocity): Velocity {
return Velocity.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return available
}
}
}
val descriptionBackgroundColor = if (isDarkMode) {
Color(0xFF2A2A2A)
} else {
Color(0xFFFAF9FB)
}
// 列表:上面是说明文字,下面是 MBTI 网格
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.nestedScroll(nestedScrollConnection),
contentPadding = PaddingValues(
start = 8.dp,
top = 0.dp,
end = 8.dp,
bottom = 8.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 说明文字
item {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(descriptionBackgroundColor)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Text(
text = stringResource(R.string.mbti_description),
color = appColors.text,
fontSize = 14.sp,
lineHeight = 20.sp
)
}
}
// MBTI 类型网格2 列)
itemsIndexed(MBTI_TYPES.chunked(2)) { rowIndex, rowItems ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
bottom = if (rowIndex < MBTI_TYPES.chunked(2).size - 1) 10.dp else 0.dp
),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
rowItems.forEach { mbti ->
Box(
modifier = Modifier.weight(1f)
) {
MbtiItem(
mbti = mbti,
isSelected = mbti == currentMbti,
onClick = {
model.mbti = mbti
onClose()
}
)
}
}
if (rowItems.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
}
}
}
}
// 保留原有的 MbtiSelectScreen 用于导航路由(如果需要)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MbtiSelectScreen() {
val navController = LocalNavController.current
MbtiSelectBottomSheet(
onClose = {
navController.navigateUp()
}
)
}
@Composable
fun MbtiItem(
mbti: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val appColors = LocalAppTheme.current
val isDarkMode = AppState.darkMode
// 卡片背景色
val cardBackgroundColor = if (isDarkMode) {
Color(0xFF2A2A2A) // 比 secondaryBackground (0xFF1C1C1C) 更亮的灰色
} else {
Color(0xFFFAF9FB)
}
Column(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.1f)
.shadow(
elevation = if (isDarkMode) 8.dp else 2.dp,
shape = RoundedCornerShape(21.dp),
spotColor = if (isDarkMode) Color.Black.copy(alpha = 0.5f) else Color.Black.copy(alpha = 0.1f)
)
.clip(RoundedCornerShape(21.dp))
.background(cardBackgroundColor)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onClick()
}
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// 直接把 MBTI 图标和文字放在灰色卡片内部,布局与星座保持一致
Image(
painter = painterResource(id = getMbtiImageResId(mbti, isDarkMode)),
contentDescription = mbti,
modifier = Modifier.size(96.dp)
)
Spacer(modifier = Modifier.height(0.dp))
Text(
text = mbti,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = appColors.text,
textAlign = TextAlign.Center,
modifier = Modifier.offset(y = (-10).dp)
)
}
}

View File

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

@@ -1,6 +1,7 @@
package com.aiosman.riderpro.ui.account package com.aiosman.ravenow.ui.account
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -26,20 +27,21 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.aiosman.riderpro.ConstVars import com.aiosman.ravenow.AppState
import com.aiosman.riderpro.ErrorCode import com.aiosman.ravenow.ConstVars
import com.aiosman.riderpro.LocalNavController import com.aiosman.ravenow.data.api.ErrorCode
import com.aiosman.riderpro.R import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.riderpro.data.AccountService import com.aiosman.ravenow.LocalNavController
import com.aiosman.riderpro.data.AccountServiceImpl import com.aiosman.ravenow.R
import com.aiosman.riderpro.data.DictService import com.aiosman.ravenow.data.AccountService
import com.aiosman.riderpro.data.DictServiceImpl import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.riderpro.data.ServiceException import com.aiosman.ravenow.data.DictService
import com.aiosman.riderpro.getErrorMessageCode import com.aiosman.ravenow.data.DictServiceImpl
import com.aiosman.riderpro.ui.comment.NoticeScreenHeader import com.aiosman.ravenow.data.ServiceException
import com.aiosman.riderpro.ui.composables.ActionButton import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.riderpro.ui.composables.StatusBarSpacer import com.aiosman.ravenow.ui.composables.ActionButton
import com.aiosman.riderpro.ui.composables.TextInputField import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.TextInputField
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -56,6 +58,7 @@ fun ResetPasswordScreen() {
var usernameError by remember { mutableStateOf<String?>(null) } var usernameError by remember { mutableStateOf<String?>(null) }
var countDown by remember { mutableStateOf<Int?>(null) } var countDown by remember { mutableStateOf<Int?>(null) }
var countDownMax by remember { mutableStateOf(60) } var countDownMax by remember { mutableStateOf(60) }
val appColors = LocalAppTheme.current
fun validate(): Boolean { fun validate(): Boolean {
if (username.isEmpty()) { if (username.isEmpty()) {
usernameError = context.getString(R.string.text_error_email_required) usernameError = context.getString(R.string.text_error_email_required)
@@ -68,7 +71,7 @@ fun ResetPasswordScreen() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { try {
dictService.getDictByKey(ConstVars.DIC_KEY_RESET_EMAIL_INTERVAL).let { dictService.getDictByKey(ConstVars.DIC_KEY_RESET_EMAIL_INTERVAL).let {
countDownMax = it.value.toInt() countDownMax = it.value as? Int ?: 60
} }
} catch (e: Exception) { } catch (e: Exception) {
countDownMax = 60 countDownMax = 60
@@ -98,10 +101,11 @@ fun ResetPasswordScreen() {
if (e.code == ErrorCode.USER_NOT_EXIST.code){ if (e.code == ErrorCode.USER_NOT_EXIST.code){
usernameError = context.getString(R.string.error_40002_user_not_exist) usernameError = context.getString(R.string.error_40002_user_not_exist)
} else { } else {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() // 其他错误不显示Toast
isSendSuccess = false
} }
} catch (e: Exception) { } catch (e: Exception) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() // 异常错误不显示Toast
isSendSuccess = false isSendSuccess = false
} finally { } finally {
isLoading = false isLoading = false
@@ -111,7 +115,7 @@ fun ResetPasswordScreen() {
Column( Column(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize().background(color = appColors.background)
) { ) {
StatusBarSpacer() StatusBarSpacer()
Box( Box(
@@ -132,12 +136,21 @@ fun ResetPasswordScreen() {
modifier = Modifier.padding(horizontal = 24.dp), modifier = Modifier.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// 暗色模式下的 hint 文本颜色
val isDarkMode = AppState.darkMode
val hintColor = if (isDarkMode) {
Color(0xFFFFFFFF).copy(alpha = 0.7f)
} else {
null // 使用默认颜色
}
TextInputField( TextInputField(
text = username, text = username,
onValueChange = { username = it }, onValueChange = { username = it },
hint = stringResource(R.string.text_hint_email), hint = stringResource(R.string.text_hint_email),
enabled = !isLoading && countDown == null, enabled = !isLoading && countDown == null,
error = usernameError, error = usernameError,
customHintColor = hintColor
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Box( Box(
@@ -148,7 +161,7 @@ fun ResetPasswordScreen() {
Text( Text(
text = stringResource(R.string.reset_mail_send_success), text = stringResource(R.string.reset_mail_send_success),
style = TextStyle( style = TextStyle(
color = Color(0xFF333333), color = appColors.text,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
), ),
@@ -158,7 +171,7 @@ fun ResetPasswordScreen() {
Text( Text(
text = stringResource(R.string.reset_mail_send_failed), text = stringResource(R.string.reset_mail_send_failed),
style = TextStyle( style = TextStyle(
color = Color(0xFF333333), color = appColors.text,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
), ),
@@ -177,9 +190,11 @@ fun ResetPasswordScreen() {
} else { } else {
stringResource(R.string.recover) stringResource(R.string.recover)
}, },
backgroundColor = Color(0xffda3832), backgroundColor = Color(0xFF7C45ED), // 紫色背景
color = Color.White, loadingBackgroundColor = Color(0xFF7C45ED), // loading 时保持紫色
isLoading = isLoading, disabledBackgroundColor = Color(0xFF7C45ED), // disabled 时保持紫色
color = appColors.mainText,
isLoading = isLoading && countDown == null, // 只在未发送成功时显示loading
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
enabled = countDown == null, enabled = countDown == null,
) { ) {
@@ -192,9 +207,11 @@ fun ResetPasswordScreen() {
.fillMaxWidth() .fillMaxWidth()
.height(48.dp), .height(48.dp),
text = stringResource(R.string.back_upper), text = stringResource(R.string.back_upper),
backgroundColor = Color(0xFF7C45ED), // 紫色背景
color = Color.White, // 白色文字
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
) { ) {
navController.popBackStack() 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

@@ -0,0 +1,376 @@
package com.aiosman.ravenow.ui.account
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
// 星座资源ID列表
val ZODIAC_SIGN_RES_IDS = listOf(
R.string.zodiac_aries,
R.string.zodiac_taurus,
R.string.zodiac_gemini,
R.string.zodiac_cancer,
R.string.zodiac_leo,
R.string.zodiac_virgo,
R.string.zodiac_libra,
R.string.zodiac_scorpio,
R.string.zodiac_sagittarius,
R.string.zodiac_capricorn,
R.string.zodiac_aquarius,
R.string.zodiac_pisces
)
/**
* 根据星座资源ID获取对应的图片资源ID
*/
fun getZodiacImageResId(zodiacResId: Int): Int {
return when (zodiacResId) {
R.string.zodiac_aries -> R.mipmap.baiyang
R.string.zodiac_taurus -> R.mipmap.jingniu
R.string.zodiac_gemini -> R.mipmap.shuangzi
R.string.zodiac_cancer -> R.mipmap.juxie
R.string.zodiac_leo -> R.mipmap.shizi
R.string.zodiac_virgo -> R.mipmap.chunv
R.string.zodiac_libra -> R.mipmap.tiancheng
R.string.zodiac_scorpio -> R.mipmap.tianxie
R.string.zodiac_sagittarius -> R.mipmap.sheshou
R.string.zodiac_capricorn -> R.mipmap.moxie
R.string.zodiac_aquarius -> R.mipmap.shuiping
R.string.zodiac_pisces -> R.mipmap.shuangyu
else -> R.mipmap.xingzuo // 默认使用占位图片
}
}
/**
* 根据存储的星座字符串可能是任何语言找到对应的资源ID
* 如果找不到返回null
*/
@Composable
fun findZodiacResId(storedZodiac: String?): Int? {
if (storedZodiac.isNullOrEmpty()) return null
// 尝试在所有语言的资源中查找匹配
ZODIAC_SIGN_RES_IDS.forEachIndexed { index, resId ->
val zodiacText = stringResource(resId)
if (zodiacText == storedZodiac) {
return resId
}
}
// 如果找不到精确匹配尝试通过资源ID索引查找兼容旧数据
// 这里可以根据需要添加更多兼容逻辑
return null
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ZodiacSelectBottomSheet(
onClose: () -> Unit
) {
val appColors = LocalAppTheme.current
val isDarkMode = AppState.darkMode
val model = AccountEditViewModel
val currentZodiacResId = findZodiacResId(model.zodiac)
val sheetBackgroundColor = if (isDarkMode) {
appColors.secondaryBackground
} else {
Color(0xFFFFFFFF)
}
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onClose,
sheetState = sheetState,
// 对齐发布动态草稿箱样式:底层透明,内容区域自己绘制圆角和背景
containerColor = Color.Transparent,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
dragHandle = {}
) {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(top = 8.dp)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)),
color = sheetBackgroundColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
// 头部 - 使用 Box 实现绝对居中布局(对齐草稿箱样式)
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
contentAlignment = Alignment.Center
) {
val cancelButtonGradientColors = if (isDarkMode) {
listOf(
Color(0xFF3A3A3C),
Color(0xFF2C2C2E)
)
} else {
listOf(
Color(0xFFFFFFFF),
Color(0xFFF8F8F8)
)
}
val cancelButtonContentColor = if (isDarkMode) Color(0xFFFFFFFF) else Color(0xFF404040)
// 左上角返回按钮:参考 iOS 设计,整体「箭头 + 取消」在 91x44 的按钮内居中
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.width(91.dp)
.height(44.dp)
.clip(RoundedCornerShape(1000.dp))
.background(
brush = Brush.linearGradient(
colors = cancelButtonGradientColors
)
)
.noRippleClickable { onClose() },
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier
// 不再固定宽度,让内容自然占位,避免裁剪掉“消”字
.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = null,
modifier = Modifier.size(17.dp),
colorFilter = ColorFilter.tint(cancelButtonContentColor)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "取消",
fontSize = 17.sp,
fontWeight = FontWeight.Medium,
color = cancelButtonContentColor,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Clip
)
}
}
Text(
text = stringResource(R.string.choose_zodiac),
color = appColors.text,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
Spacer(Modifier.height(12.dp))
// 创建 NestedScrollConnection
// 1. 不抢在列表前面消费事件,让 LazyVerticalGrid 正常滚动
// 2. 在列表滚动之后把剩余滚动吃掉,避免继续传递到 BottomSheet 去触发下拉关闭
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// 不在这里消费,先让 LazyVerticalGrid 自己处理滚动
return Offset.Zero
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
// 列表滚动完之后,把剩余滚动(尤其是向下拖拽)全部吃掉,防止再传给 BottomSheet
return available
}
override suspend fun onPreFling(available: Velocity): Velocity {
// 不抢在列表前面处理 fling让 LazyVerticalGrid 先做惯性滚动
return Velocity.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
// 列表惯性滚动之后,把剩余的 fling 速度吃掉,避免带动 BottomSheet 下滑关闭
return available
}
}
}
// 网格列表 - 2列与草稿箱一样放在内容区域内部滚动
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.nestedScroll(nestedScrollConnection),
contentPadding = PaddingValues(
start = 8.dp,
top = 8.dp,
end = 8.dp,
bottom = 8.dp
),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
itemsIndexed(ZODIAC_SIGN_RES_IDS) { index, zodiacResId ->
val zodiacText = stringResource(zodiacResId)
ZodiacItem(
zodiac = zodiacText,
zodiacResId = zodiacResId,
isSelected = zodiacResId == currentZodiacResId,
onClick = {
model.zodiac = zodiacText
onClose()
}
)
}
}
}
}
}
}
}
// 保留原有的 ZodiacSelectScreen 用于导航路由(如果需要)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ZodiacSelectScreen() {
val navController = com.aiosman.ravenow.LocalNavController.current
ZodiacSelectBottomSheet(
onClose = {
navController.navigateUp()
}
)
}
@Composable
fun ZodiacItem(
zodiac: String,
zodiacResId: Int,
isSelected: Boolean,
onClick: () -> Unit
) {
val appColors = LocalAppTheme.current
val isDarkMode = AppState.darkMode
// 卡片背景色:浅灰色 (250, 249, 251)
// 暗色模式下使用比背景色更亮的颜色,以形成对比
val cardBackgroundColor = if (isDarkMode) {
Color(0xFF2A2A2A) // 比 secondaryBackground (0xFF1C1C1C) 更亮的灰色
} else {
Color(0xFFFAF9FB)
}
Column(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.1f)
.shadow(
elevation = if (isDarkMode) 8.dp else 2.dp,
shape = RoundedCornerShape(21.dp),
spotColor = if (isDarkMode) Color.Black.copy(alpha = 0.5f) else Color.Black.copy(alpha = 0.1f)
)
.clip(RoundedCornerShape(21.dp))
.background(cardBackgroundColor)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onClick()
}
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// 直接把图标和文字放在灰色卡片内部,不再额外嵌套一层 Box
Image(
painter = painterResource(id = getZodiacImageResId(zodiacResId)),
contentDescription = zodiac,
// 图标稍微放大一些,让视觉更聚焦在星座图标上
modifier = Modifier.size(96.dp)
)
Spacer(modifier = Modifier.height(0.dp))
Text(
text = zodiac,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = appColors.text,
textAlign = TextAlign.Center,
modifier = Modifier.offset(y = (-10).dp) // 再整体向上偏移 5dp共 10dp
)
}
}

View File

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

@@ -1,3 +1,4 @@
import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -16,15 +17,24 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.aiosman.riderpro.LocalNavController import com.aiosman.ravenow.AppState
import com.aiosman.riderpro.R import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.riderpro.data.AccountService import com.aiosman.ravenow.LocalNavController
import com.aiosman.riderpro.data.AccountServiceImpl import com.aiosman.ravenow.R
import com.aiosman.riderpro.ui.comment.NoticeScreenHeader import com.aiosman.ravenow.data.AccountService
import com.aiosman.riderpro.ui.composables.ActionButton import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.riderpro.ui.composables.StatusBarSpacer import com.aiosman.ravenow.data.ServiceException
import com.aiosman.riderpro.ui.composables.TextInputField import com.aiosman.ravenow.data.api.ErrorCode
import com.aiosman.ravenow.data.api.showToast
import com.aiosman.ravenow.data.api.toErrorMessage
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.ActionButton
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.TextInputField
import com.aiosman.ravenow.utils.PasswordValidator
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -48,34 +58,50 @@ class ChangePasswordViewModel {
*/ */
@Composable @Composable
fun ChangePasswordScreen() { fun ChangePasswordScreen() {
val context = LocalContext.current
val viewModel = remember { ChangePasswordViewModel() } val viewModel = remember { ChangePasswordViewModel() }
var currentPassword by remember { mutableStateOf("") } var currentPassword by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") } var newPassword by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") } var confirmPassword by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val navController = LocalNavController.current val navController = LocalNavController.current
var oldPasswordError by remember { mutableStateOf<String?>(null) } var oldPasswordError by remember { mutableStateOf<String?>(null) }
var confirmPasswordError by remember { mutableStateOf<String?>(null) } var confirmPasswordError by remember { mutableStateOf<String?>(null) }
var passwordError by remember { mutableStateOf<String?>(null) } var passwordError by remember { mutableStateOf<String?>(null) }
fun validate(): Boolean { val AppColors = LocalAppTheme.current
oldPasswordError = // 暗色模式下的 hint 文本颜色
if (currentPassword.isEmpty()) "Please enter your current password" else null val isDarkMode = AppState.darkMode
passwordError = when { val hintColor = if (isDarkMode) {
newPassword.length < 8 -> "Password must be at least 8 characters long" Color(0xFFFFFFFF).copy(alpha = 0.7f)
!newPassword.any { it.isDigit() } -> "Password must contain at least one digit" } else {
!newPassword.any { it.isUpperCase() } -> "Password must contain at least one uppercase letter" null // 使用默认颜色
!newPassword.any { it.isLowerCase() } -> "Password must contain at least one lowercase letter"
else -> null
} }
confirmPasswordError = val labelColor = if (isDarkMode) {
if (newPassword != confirmPassword) "Passwords do not match" else null Color(0xFFFFFFFF).copy(alpha = 0.7f)
} else {
null // 使用默认颜色
}
fun validate(): Boolean {
// 使用通用密码校验器校验当前密码
val currentPasswordValidation = PasswordValidator.validateCurrentPassword(currentPassword, context)
oldPasswordError = if (!currentPasswordValidation.isValid) currentPasswordValidation.errorMessage else null
// 使用通用密码校验器校验新密码
val newPasswordValidation = PasswordValidator.validatePassword(newPassword, context)
passwordError = if (!newPasswordValidation.isValid) newPasswordValidation.errorMessage else null
// 使用通用密码确认校验器
val confirmPasswordValidation = PasswordValidator.validatePasswordConfirmation(newPassword, confirmPassword, context)
confirmPasswordError = if (!confirmPasswordValidation.isValid) confirmPasswordValidation.errorMessage else null
return passwordError == null && confirmPasswordError == null && oldPasswordError == null return passwordError == null && confirmPasswordError == null && oldPasswordError == null
} }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.White), .background(AppColors.background),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
StatusBarSpacer() StatusBarSpacer()
@@ -83,7 +109,7 @@ fun ChangePasswordScreen() {
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
) { ) {
NoticeScreenHeader( NoticeScreenHeader(
title = "Change password", title = stringResource(R.string.change_password),
moreIcon = false moreIcon = false
) )
@@ -99,43 +125,55 @@ fun ChangePasswordScreen() {
text = currentPassword, text = currentPassword,
onValueChange = { currentPassword = it }, onValueChange = { currentPassword = it },
password = true, password = true,
label = "Current password", label = stringResource(R.string.current_password),
hint = "Enter your current password", hint = stringResource(R.string.current_password_tip5),
error = oldPasswordError error = oldPasswordError,
customHintColor = hintColor,
customLabelColor = labelColor
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
TextInputField( TextInputField(
text = newPassword, text = newPassword,
onValueChange = { newPassword = it }, onValueChange = { newPassword = it },
password = true, password = true,
label = "New password", label = stringResource(R.string.new_password),
hint = "Enter your new password", hint = stringResource(R.string.new_password),
error = passwordError error = passwordError,
customHintColor = hintColor,
customLabelColor = labelColor
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
TextInputField( TextInputField(
text = confirmPassword, text = confirmPassword,
onValueChange = { confirmPassword = it }, onValueChange = { confirmPassword = it },
password = true, password = true,
label = "Confirm new password", label = stringResource(R.string.confirm_new_password_tip1),
hint = "Enter your new password again", hint = stringResource(R.string.new_password_tip1),
error = confirmPasswordError error = confirmPasswordError,
customHintColor = hintColor,
customLabelColor = labelColor
) )
Spacer(modifier = Modifier.height(50.dp)) Spacer(modifier = Modifier.height(50.dp))
ActionButton( ActionButton(
modifier = Modifier modifier = Modifier
.width(345.dp) .width(345.dp),
.height(48.dp), text = stringResource(R.string.lets_ride_upper),
text = "Let's Ride",
backgroundImage = R.mipmap.rider_pro_signup_red_bg
) { ) {
if (validate()) { if (validate()) {
scope.launch { scope.launch {
try { try {
viewModel.changePassword(currentPassword, newPassword) viewModel.changePassword(currentPassword, newPassword)
navController.popBackStack()
navController.navigateUp()
} catch (e: ServiceException) {
when (e.errorType) {
ErrorCode.IncorrectOldPassword ->
oldPasswordError = e.errorType.toErrorMessage(context)
else ->
e.errorType.showToast(context)
}
} catch (e: Exception) { } catch (e: Exception) {
errorMessage = e.message ?: "An error occurred" Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
} }
} }
} }

View File

@@ -1,4 +1,4 @@
package com.aiosman.riderpro.ui.account package com.aiosman.ravenow.ui.account
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
@@ -34,14 +34,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.aiosman.riderpro.LocalNavController import com.aiosman.ravenow.LocalNavController
import com.aiosman.riderpro.data.AccountService import com.aiosman.ravenow.data.AccountService
import com.aiosman.riderpro.data.AccountServiceImpl import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.riderpro.data.UploadImage import com.aiosman.ravenow.data.UploadImage
import com.aiosman.riderpro.entity.AccountProfileEntity import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.riderpro.ui.composables.CustomAsyncImage import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.riderpro.ui.modifiers.noRippleClickable import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.riderpro.ui.post.NewPostViewModel.uriToFile import com.aiosman.ravenow.ui.post.NewPostViewModel.uriToFile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -82,7 +82,8 @@ fun AccountEditScreen() {
var newAvatar: UploadImage? = null var newAvatar: UploadImage? = null
cursor?.use { cur -> cursor?.use { cur ->
if (cur.moveToFirst()) { if (cur.moveToFirst()) {
val displayName = cur.getString(cur.getColumnIndex("_display_name")) val columnIndex = cur.getColumnIndex("_display_name")
val displayName = if (columnIndex >= 0) cur.getString(columnIndex) else "unknown"
val extension = displayName.substringAfterLast(".") val extension = displayName.substringAfterLast(".")
Log.d("NewPost", "File name: $displayName, extension: $extension") Log.d("NewPost", "File name: $displayName, extension: $extension")
// read as file // read as file
@@ -98,7 +99,8 @@ fun AccountEditScreen() {
var newBanner: UploadImage? = null var newBanner: UploadImage? = null
cursor?.use { cur -> cursor?.use { cur ->
if (cur.moveToFirst()) { if (cur.moveToFirst()) {
val displayName = cur.getString(cur.getColumnIndex("_display_name")) val columnIndex = cur.getColumnIndex("_display_name")
val displayName = if (columnIndex >= 0) cur.getString(columnIndex) else "unknown"
val extension = displayName.substringAfterLast(".") val extension = displayName.substringAfterLast(".")
Log.d("NewPost", "File name: $displayName, extension: $extension") Log.d("NewPost", "File name: $displayName, extension: $extension")
// read as file // read as file

View File

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

View File

@@ -0,0 +1,159 @@
package com.aiosman.ravenow.ui.account
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.Messaging
import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.data.api.ErrorCode
import com.aiosman.ravenow.data.api.showToast
import com.aiosman.ravenow.data.api.toErrorMessage
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.ActionButton
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.TextInputField
import com.aiosman.ravenow.utils.PasswordValidator
import kotlinx.coroutines.launch
@Composable
fun RemoveAccountScreen() {
val appColors = LocalAppTheme.current
val navController = LocalNavController.current
var inputPassword by remember { mutableStateOf("") }
var passwordError by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
// 暗色模式下的 hint 文本颜色
val isDarkMode = AppState.darkMode
val hintColor = if (isDarkMode) {
Color(0xFFFFFFFF).copy(alpha = 0.7f)
} else {
null // 使用默认颜色
}
fun removeAccount(password: String) {
// 使用通用密码校验器
val passwordValidation = PasswordValidator.validateCurrentPassword(password, context)
if (!passwordValidation.isValid) {
passwordError = passwordValidation.errorMessage
return
}
scope.launch {
try {
val accountService = AccountServiceImpl()
accountService.removeAccount(password)
Messaging.unregisterDevice(context)
AppStore.apply {
token = null
rememberMe = false
saveData()
}
//返回到登录页面
navController.navigate(NavigationRoute.Login.route) {
popUpTo(NavigationRoute.Login.route) {
inclusive = true
}
}
//重置AppState
AppState.ReloadAppState(context)
Toast.makeText(context, "Account has been deleted", Toast.LENGTH_SHORT).show()
} catch (e: ServiceException) {
passwordError = "Incorrect password"
// e.errorType.showToast(context)
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(context, "An error occurred. Please try again.", Toast.LENGTH_SHORT).show()
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(appColors.background),
) {
StatusBarSpacer()
Box(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
) {
NoticeScreenHeader(
title = stringResource(R.string.remove_account),
moreIcon = false
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp, vertical = 16.dp)
.background(appColors.background),
) {
Box(
modifier = Modifier
.padding(bottom = 32.dp, top = 16.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.remove_account_desc),
fontSize = 16.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = appColors.text
)
}
TextInputField(
modifier = Modifier.fillMaxWidth(),
text = inputPassword,
onValueChange = {
inputPassword = it
if (passwordError != null) {
passwordError = null
}
},
password = true,
hint = stringResource(R.string.remove_account_password_hint),
error = passwordError,
customHintColor = hintColor
)
Spacer(modifier = Modifier.weight(1f))
ActionButton(
text = stringResource(R.string.remove_account),
fullWidth = true,
enabled = true,
click = {
removeAccount(inputPassword)
}
)
}
}
}

View File

@@ -0,0 +1,815 @@
package com.aiosman.ravenow.ui.agent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.activity.compose.BackHandler
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.account.AccountEditViewModel
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.comment.ScreenHeader
import com.aiosman.ravenow.ui.comment.ScreenHeader2
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import com.aiosman.ravenow.ui.composables.ActionButton
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.form.FormTextInput
import com.aiosman.ravenow.ui.composables.form.FormTextInput2
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import androidx.compose.foundation.border
import androidx.compose.ui.draw.shadow
import com.aiosman.ravenow.AppState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.StartOffset
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.TextStyle
import com.aiosman.ravenow.ui.agent.AddAgentViewModel.showManualCreation
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.rememberLottieComposition
/**
* 添加智能体界面
*/
@Composable
fun AddAgentScreen() {
val model = AddAgentViewModel
val navController = LocalNavController.current
val context = LocalContext.current
var agnetNameError by remember { mutableStateOf<String?>(null) }
var agnetDescError by remember { mutableStateOf<String?>(null) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var isProcessing by remember { mutableStateOf(false) }
var showWaveAnimation by remember { mutableStateOf(false) }
var isCreatingAgent by remember { mutableStateOf(false) } // 控制是否处于创建状态
var showManualCreationForm by remember { mutableStateOf(false) } // 控制是否显示手动创建表单
var tempDesc by remember { mutableStateOf("") } // 独立的临时描述变量
val keyboardController = LocalSoftwareKeyboardController.current
fun onNameChange(value: String) {
model.name = value.trim()
agnetNameError = when {
else -> null
}
}
val appColors = LocalAppTheme.current
fun onDescChange(value: String) {
model.desc = value.trim()
agnetDescError = when {
value.length > 512 -> "简介长度不能大于512"
else -> null
}
}
fun onTempDescChange(value: String) {
tempDesc = value.trim()
agnetDescError = when {
value.length > 512 -> "简介长度不能大于512"
else -> null
}
}
fun validate(): Boolean {
return agnetNameError == null && agnetDescError == null
}
// AI文案优化
suspend fun optimizeTextWithAI(content: String): String? {
return try {
val sessionId = ""
val response = com.aiosman.ravenow.data.api.ApiClient.api.agentMoment(
com.aiosman.ravenow.data.api.AgentMomentRequestBody(
generateText = content,
sessionId = sessionId
)
)
response.body()?.data
} catch (e: Exception) {
e.printStackTrace()
null
}
}
// 处理系统返回键
BackHandler {
// 如果不是在选择头像过程中,则清空数据
if (!model.isSelectingAvatar) {
model.clearData()
}
navController.popBackStack()
}
// 页面进入时重置头像选择状态
LaunchedEffect(Unit) {
model.isSelectingAvatar = false
// 根据标记恢复相应的状态
if (model.isAutoModeManualForm) {
// 恢复自动模式下的手动表单状态
showManualCreationForm = model.showManualCreationForm
isCreatingAgent = model.isCreatingAgent
showWaveAnimation = model.showWaveAnimation
showManualCreation = model.showManualCreation
} else {
// 恢复手动模式下的状态
showManualCreation = model.showManualCreation
showManualCreationForm = model.showManualCreationForm
isCreatingAgent = model.isCreatingAgent
showWaveAnimation = model.showWaveAnimation
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(color = appColors.decentBackground),
horizontalAlignment = Alignment.CenterHorizontally
) {
var showManualCreation by remember {
mutableStateOf(model.showManualCreation)
}
StatusBarSpacer()
Box(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 16.dp)
.background(color = appColors.decentBackground)
) {
// 自定义header控制返回按钮行为
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = "返回",
modifier = Modifier.size(24.dp).clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
// 与BackHandler保持一致的逻辑
if (!model.isSelectingAvatar) {
model.clearData()
}
navController.navigateUp()
},
colorFilter = ColorFilter.tint(appColors.text)
)
Spacer(modifier = Modifier.size(12.dp))
Text(
stringResource(R.string.agent_add),
fontWeight = FontWeight.W600,
modifier = Modifier.weight(1f),
fontSize = 17.sp,
color = appColors.text
)
}
}
Spacer(modifier = Modifier.height(1.dp))
if (!isCreatingAgent) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.padding(horizontal = 20.dp),
) {
Image(
painter = painterResource(id = R.mipmap.group_copy),
contentDescription = "",
modifier = Modifier
.size(48.dp)
.clip(
RoundedCornerShape(48.dp)
),
contentScale = ContentScale.Crop
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Column(
modifier = Modifier.fillMaxWidth()
.padding(start = 20.dp)
) {
Text(
text = "${AppState.profile?.nickName ?: "User"} ${stringResource(R.string.welcome_1)}",
fontSize = 16.sp,
color = appColors.text,
fontWeight = FontWeight.W600
)
}
if (!isCreatingAgent) {
Spacer(modifier = Modifier.height(8.dp))
Column(
modifier = Modifier.fillMaxWidth()
.padding(start = 20.dp)
) {
if (!showManualCreation) {
Text(
text = stringResource(R.string.welcome_2),
fontSize = 14.sp,
color = appColors.text.copy(alpha = 0.6f),
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
if (!showManualCreation) {
//自动创造AI界面
Column(
modifier = Modifier
.padding(horizontal = 20.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(95.dp)
.shadow(
elevation = 10.dp,
shape = RoundedCornerShape(10.dp),
spotColor = Color(0x33F563FF),
ambientColor = Color(0x99F563FF),
clip = false
)
.background(
brush = Brush.linearGradient(
listOf(
Color(0xFF6246FF),
Color(0xFF7C45ED)
)
),
shape = RoundedCornerShape(10.dp)
)
.padding(0.5.dp)
.background(
color = appColors.inputBackground2,
shape = RoundedCornerShape(10.dp)
)
) {
val focusRequester = remember { FocusRequester() }
Box(
modifier = Modifier
.fillMaxSize()
.noRippleClickable {
model.viewModelScope.launch {
focusRequester.requestFocus()
keyboardController?.show()
}
}
)
TextField(
value = tempDesc,
onValueChange = { value -> onTempDescChange(value) },
modifier = Modifier
.fillMaxWidth()
.height(95.dp)
.focusRequester(focusRequester),
placeholder = {
Text(
text = stringResource(R.string.agent_desc_hint_auto),
color = Color.Gray
)
},
textStyle = TextStyle(
color = LocalAppTheme.current.text,
fontSize = 16.sp
),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
focusedContainerColor = appColors.inputBackground2,
unfocusedContainerColor = appColors.inputBackground2,
),
shape = RoundedCornerShape(10.dp),
supportingText = null,
trailingIcon = null,
leadingIcon = null
)
Row(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 12.dp, bottom = 12.dp)
.noRippleClickable {
// 只有在有内容且未处理中且未创建中时才可点击
if (tempDesc.isNotEmpty() && !isProcessing && !isCreatingAgent) {
isProcessing = true
showWaveAnimation = true // 显示构思动画
keyboardController?.hide()
model.viewModelScope.launch {
try {
val optimizedText = optimizeTextWithAI(tempDesc)
if (optimizedText != null) {
onTempDescChange(optimizedText)
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
isProcessing = false
showWaveAnimation = false
isCreatingAgent = true
showManualCreationForm = true
onDescChange(tempDesc)
}
}
}
}
.then(
if (tempDesc.isEmpty() || isProcessing || isCreatingAgent) {
Modifier.alpha(0.5f)
} else {
Modifier
}
),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.mipmap.icons_info_magic),
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color.Unspecified
)
Spacer(modifier = Modifier.width(5.dp))
Text(
text = stringResource(R.string.agent_text_beautify),
color = Color(0xFF6246FF),
fontSize = 14.sp
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
if ((isCreatingAgent && showWaveAnimation) || (isProcessing && showWaveAnimation)) {
// 显示构思动画
Row(
modifier = Modifier
.align(Alignment.Start)
.padding(start = 20.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(32.dp)
) {
LottieAnimation(
composition = rememberLottieComposition(LottieCompositionSpec.Asset("loading.lottie")).value,
iterations = LottieConstants.IterateForever,
modifier = Modifier.matchParentSize()
)
}
Text(
text = stringResource(R.string.ideaing),
color = appColors.text.copy(alpha = 0.6f),
fontSize = 14.sp
)
}
} else if (isCreatingAgent && !showWaveAnimation && showManualCreationForm) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.align(Alignment.Start)
) {
Text(
text = stringResource(R.string.avatar),
fontSize = 12.sp,
color = appColors.text,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(4.dp))
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(
brush = Brush.linearGradient(
colors = listOf(
Color(0x777c45ed),
Color(0x777c68ef),
Color(0x557bd8f8)
)
)
)
.align(Alignment.Start)
.noRippleClickable {
// 保存当前状态
model.showManualCreationForm = showManualCreationForm
model.isCreatingAgent = isCreatingAgent
model.showWaveAnimation = showWaveAnimation
model.showManualCreation = showManualCreation
model.isAutoModeManualForm = true // 标记为自动模式下的手动表单
// 设置正在选择头像的标志
model.isSelectingAvatar = true
navController.navigate(NavigationRoute.AgentImageCrop.route)
},
contentAlignment = Alignment.Center
){
// 如果已有裁剪后的头像,则显示头像,否则显示编辑图标
if (model.croppedBitmap != null) {
Image(
bitmap = model.croppedBitmap!!.asImageBitmap(),
contentDescription = "Avatar",
modifier = Modifier
.size(72.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
Icon(
painter = painterResource(id = R.mipmap.icons_infor_edit),
contentDescription = "Edit",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
Spacer(modifier = Modifier.height(18.dp))
// 原版两个输入框
Text(
text = stringResource(R.string.agent_name),
fontSize = 12.sp,
color = appColors.text,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(4.dp))
FormTextInput(
value = model.name,
hint = stringResource(R.string.agent_name_hint_1),
background = appColors.inputBackground2,
modifier = Modifier.fillMaxWidth(),
) { value ->
onNameChange(value)
}
Text(
text = stringResource(R.string.agent_desc),
fontSize = 12.sp,
color = appColors.text,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(4.dp))
FormTextInput2(
value = model.desc,
hint = stringResource(R.string.agent_desc_hint),
background = appColors.inputBackground2,
modifier = Modifier.fillMaxWidth(),
) { value ->
onDescChange(value)
}
}
} else if (!isCreatingAgent && !showWaveAnimation) {
Box(
modifier = Modifier
.align(Alignment.Start)
.padding(start = 20.dp)
.width(136.dp)
.height(40.dp)
.border(
width = 1.dp,
color = Color(0x33858B98),
shape = RoundedCornerShape(12.dp)
)
.background(
color = appColors.background,
shape = RoundedCornerShape(12.dp),
)
.noRippleClickable {
showManualCreation = true
tempDesc = ""
agnetDescError = null
}
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.mipmap.icons_infor_edit),
contentDescription = null,
tint = appColors.text,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.create_agent_hand),
color = appColors.text,
fontWeight = FontWeight.W600,
fontSize = 14.sp
)
}
}
}
}else {
//手动创造AI界面
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.align(Alignment.Start)
) {
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
val autoLabel = stringResource(R.string.create_agent_auto)
val measuredTextWidth = remember(autoLabel, textMeasurer) {
textMeasurer.measure(
text = AnnotatedString(autoLabel),
style = TextStyle(
color = appColors.text,
fontWeight = FontWeight.W600,
fontSize = 14.sp
)
).size.width
}
val textWidthDp = with(density) { measuredTextWidth.toDp() }
val contentWidth = 24.dp + 18.dp + 8.dp + textWidthDp
val boxWidth = if (contentWidth > 140.dp) 250.dp else 140.dp
Box(
modifier = Modifier
.align(Alignment.Start)
.width(boxWidth)
.height(40.dp)
.shadow(
elevation = 10.dp,
shape = RoundedCornerShape(10.dp),
spotColor = Color(0x33F563FF),
ambientColor = Color(0x99F563FF),
clip = false
)
.background(
brush = Brush.linearGradient(
listOf(
Color(0xFF6246FF),
Color(0xFF7C45ED)
)
),
shape = RoundedCornerShape(10.dp)
)
.padding(0.5.dp)
.background(
color = appColors.background,
shape = RoundedCornerShape(10.dp),
)
.noRippleClickable {
showManualCreation = false
model.name = ""
model.desc = ""
model.croppedBitmap = null
isCreatingAgent = false
showManualCreationForm = false
showWaveAnimation = false
isProcessing = false
}
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.mipmap.icons_info_magic),
contentDescription = null,
tint = appColors.text,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = autoLabel,
color = appColors.text,
fontWeight = FontWeight.W600,
fontSize = 14.sp
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.avatar),
fontSize = 12.sp,
color = appColors.text,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(4.dp))
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(
brush = Brush.linearGradient(
colors = listOf(
Color(0x777c45ed),
Color(0x777c68ef),
Color(0x557bd8f8)
)
)
)
.align(Alignment.Start)
.noRippleClickable {
// 保存当前状态
model.showManualCreation = showManualCreation
model.showManualCreationForm = showManualCreationForm
model.isCreatingAgent = isCreatingAgent
model.showWaveAnimation = showWaveAnimation
model.isAutoModeManualForm = false // 标记为手动模式下的手动表单
// 设置正在选择头像的标志
model.isSelectingAvatar = true
navController.navigate(NavigationRoute.AgentImageCrop.route)
},
contentAlignment = Alignment.Center
) {
// 如果已有裁剪后的头像,则显示头像,否则显示编辑图标
if (model.croppedBitmap != null) {
Image(
bitmap = model.croppedBitmap!!.asImageBitmap(),
contentDescription = "Avatar",
modifier = Modifier
.size(72.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
Icon(
painter = painterResource(id = R.mipmap.icons_infor_edit),
contentDescription = "Edit",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
}
Spacer(modifier = Modifier.height(18.dp))
// 原版两个输入框
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.agent_name),
fontSize = 12.sp,
color = appColors.text,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(4.dp))
FormTextInput(
value = model.name,
hint = stringResource(R.string.agent_name_hint_1),
background = appColors.inputBackground2,
modifier = Modifier.fillMaxWidth(),
) { value ->
onNameChange(value)
}
Text(
text = stringResource(R.string.agent_desc),
fontSize = 12.sp,
color = appColors.text,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(4.dp))
FormTextInput2(
value = model.desc,
hint = stringResource(R.string.agent_desc_hint),
background = appColors.inputBackground2,
modifier = Modifier.fillMaxWidth(),
) { value ->
onDescChange(value)
}
}
//手动创造AI界面
}
// 错误信息显示
Spacer(modifier = Modifier.weight(1f))
Box(modifier = Modifier.fillMaxWidth()) {
errorMessage?.let { error ->
Text(
text = error,
color = Color.Red,
modifier = Modifier
.padding(bottom = 20.dp)
.align(Alignment.Center),
fontSize = 14.sp
)
}
}
ActionButton(
modifier = Modifier
.width(345.dp)
.padding(bottom = 40.dp)
.background(
brush = Brush.linearGradient(
colors = listOf(
Color(0x777c45ed),
Color(0x777c68ef),
Color(0x557bd8f8)
)
),
shape = RoundedCornerShape(24.dp)
),
color = Color.White,
backgroundColor = Color.Transparent,
text = stringResource(R.string.create_confirm),
isLoading = model.isUpdating,
enabled = !model.isUpdating && validate()
) {
// 验证输入
val validationError = model.validate()
if (validationError != null) {
// 显示验证错误
errorMessage = validationError
model.viewModelScope.launch {
kotlinx.coroutines.delay(3000)
errorMessage = null
}
return@ActionButton
}
// 清除之前的错误信息
errorMessage = null
// 调用创建智能体API
model.viewModelScope.launch {
try {
val result = model.createAgent(context)
if (result != null) {
// 创建成功,清空数据并关闭页面
model.clearData()
navController.popBackStack()
AppState.agentCreatedSuccess = true
}
} catch (e: Exception) {
// 显示错误信息
errorMessage = "创建智能体失败: ${e.message}"
e.printStackTrace()
// 3秒后清除错误信息
kotlinx.coroutines.delay(3000)
errorMessage = null
}
}
}
}
}

View File

@@ -0,0 +1,98 @@
package com.aiosman.ravenow.ui.agent
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.UploadImage
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.entity.AgentEntity
import com.aiosman.ravenow.entity.createAgent
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
import com.aiosman.ravenow.utils.TrtcHelper
import java.io.File
object AddAgentViewModel : ViewModel() {
var name by mutableStateOf("")
var desc by mutableStateOf("")
var croppedBitmap by mutableStateOf<Bitmap?>(null)
var isUpdating by mutableStateOf(false)
var isSelectingAvatar by mutableStateOf(false) // 标记是否正在选择头像
var showManualCreationForm by mutableStateOf(false)
var isCreatingAgent by mutableStateOf(false)
var showWaveAnimation by mutableStateOf(false)
var showManualCreation by mutableStateOf(false)
// 添加一个标志来区分两种手动表单状态
var isAutoModeManualForm by mutableStateOf(false)
suspend fun updateAgentAvatar(context: Context) {
croppedBitmap?.let {
val file = File(context.cacheDir, "agent_avatar.jpg")
it.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream())
// 这里可以上传图片到服务器,暂时先保存到本地
// UploadImage(file, "agent_avatar.jpg", "", "jpg")
}
}
suspend fun createAgent(context: Context): AgentEntity? {
try {
isUpdating = true
// 准备头像文件
val avatarFile = if (croppedBitmap != null) {
val file = File(context.cacheDir, "agent_avatar.jpg")
croppedBitmap!!.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream())
UploadImage(file, "agent_avatar.jpg", "", "jpg")
} else {
null
}
// 调用API创建智能体
val result = createAgent(
title = name,
desc = desc,
avatar = avatarFile
)
return result
} catch (e: Exception) {
throw e
} finally {
isUpdating = false
}
}
fun validate(): String? {
return when {
name.isEmpty() -> "智能体名称不能为空"
name.length < 2 -> "智能体名称长度不能少于2个字符"
name.length > 20 -> "智能体名称长度不能超过20个字符"
desc.isEmpty() -> "智能体描述不能为空"
desc.length > 512 -> "智能体描述长度不能超过512个字符"
else -> null
}
}
/**
* 清空所有页面数据
*/
fun clearData() {
name = ""
desc = ""
croppedBitmap = null
isUpdating = false
isSelectingAvatar = false
showManualCreationForm = false
isCreatingAgent = false
showWaveAnimation = false
showManualCreation = false
isAutoModeManualForm = false
}
}

View File

@@ -0,0 +1,252 @@
package com.aiosman.ravenow.ui.agent
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.image.cropview.CropType
import com.image.cropview.EdgeType
import com.image.cropview.ImageCrop
import kotlinx.coroutines.launch
import java.io.InputStream
/**
* 专门用于智能体头像裁剪的页面
* 支持创建和编辑两种模式
*/
@Composable
fun AgentImageCropScreen() {
var imageCrop by remember { mutableStateOf<ImageCrop?>(null) }
var croppedBitmap by remember { mutableStateOf<Bitmap?>(null) }
val context = LocalContext.current
val configuration = LocalConfiguration.current
var imageWidthInDp by remember { mutableStateOf(0) }
var imageHeightInDp by remember { mutableStateOf(0) }
val density = LocalDensity.current
val navController = LocalNavController.current
// 检查是否在编辑模式通过检查是否有编辑ViewModel的实例
val isEditMode = remember {
// 通过检查导航栈或使用其他方式判断
// 暂时使用一个简单的方法检查AddAgentViewModel是否正在选择头像
// 如果不是,则可能是编辑模式
!AddAgentViewModel.isSelectingAvatar
}
val imagePickLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let {
val bitmap = uriToBitmap(context = context, uri = it)
if (bitmap != null) {
val aspectRatio = bitmap.height.toFloat() / bitmap.width.toFloat()
imageHeightInDp = (imageWidthInDp.toFloat() * aspectRatio).toInt()
imageCrop = ImageCrop(bitmap)
}
}
if (uri == null) {
// 用户取消选择图片,重置标志
if (!isEditMode) {
AddAgentViewModel.isSelectingAvatar = false
}
navController.popBackStack()
}
}
val systemUiController = rememberSystemUiController()
LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(darkIcons = false, color = Color.Black)
imagePickLauncher.launch("image/*")
}
DisposableEffect(Unit) {
onDispose {
imageCrop = null
val isDarkMode = AppState.darkMode
systemUiController.setStatusBarColor(
darkIcons = !isDarkMode,
color = if(isDarkMode)Color.Black else Color.White
)
}
}
Column(
modifier = Modifier.background(Color.Black).fillMaxSize()
) {
StatusBarSpacer()
// 头部工具栏
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp)
) {
Image(
painter = painterResource(R.drawable.rider_pro_back_icon),
contentDescription = null,
modifier = Modifier.clickable {
// 用户取消头像选择,重置标志
if (!isEditMode) {
AddAgentViewModel.isSelectingAvatar = false
}
navController.popBackStack()
},
colorFilter = ColorFilter.tint(Color.White)
)
Spacer(modifier = Modifier.weight(1f))
// 确认按钮
Icon(
Icons.Default.Check,
contentDescription = null,
tint = if (croppedBitmap != null) Color.Green else Color.White,
modifier = Modifier.clickable {
if (croppedBitmap != null) {
// 如果已经有裁剪结果,直接返回
if (isEditMode) {
// 编辑模式需要找到当前的编辑ViewModel实例
// 由于无法直接访问,我们使用一个全局状态或者通过其他方式传递
// 暂时先保存到AddAgentViewModel编辑页面会检查
AddAgentViewModel.croppedBitmap = croppedBitmap
} else {
AddAgentViewModel.croppedBitmap = croppedBitmap
// 重置头像选择标志
AddAgentViewModel.isSelectingAvatar = false
AddAgentViewModel.viewModelScope.launch {
AddAgentViewModel.updateAgentAvatar(context)
navController.popBackStack()
}
}
navController.popBackStack()
} else {
// 进行裁剪
imageCrop?.let {
val bitmap = it.onCrop()
croppedBitmap = bitmap
}
}
}
)
}
// 裁剪预览区域
Box(
modifier = Modifier.fillMaxWidth().padding(24.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(imageHeightInDp.dp)
.onGloballyPositioned { coordinates ->
with(density) {
imageWidthInDp = coordinates.size.width.toDp().value.toInt()
}
}
) {
imageCrop?.ImageCropView(
modifier = Modifier.fillMaxSize(),
guideLineColor = Color.White,
guideLineWidth = 2.dp,
edgeCircleSize = 5.dp,
cropType = CropType.SQUARE,
edgeType = EdgeType.CIRCULAR
)
}
}
// 裁剪结果预览
croppedBitmap?.let { bitmap ->
Spacer(modifier = Modifier.height(24.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
Color.Black.copy(alpha = 0.8f),
shape = RoundedCornerShape(12.dp)
)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "✅ 裁剪完成",
color = Color.Green,
fontSize = 16.sp
)
Spacer(modifier = Modifier.height(12.dp))
CustomAsyncImage(
context,
bitmap,
modifier = Modifier
.size(100.dp)
.clip(CircleShape)
.background(Color.Gray.copy(alpha = 0.3f), CircleShape),
contentDescription = "智能体头像预览",
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "点击 ✓ 确认使用此头像",
color = Color.White.copy(alpha = 0.8f),
fontSize = 12.sp
)
}
}
}
}
fun uriToBitmap(context: Context, uri: Uri): Bitmap? {
return try {
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(inputStream)
} catch (e: Exception) {
e.printStackTrace()
null
}
}

View File

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

@@ -0,0 +1,377 @@
package com.aiosman.ravenow.ui.chat
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.entity.ChatItem
import io.openim.android.sdk.OpenIMClient
import io.openim.android.sdk.enums.ConversationType
import io.openim.android.sdk.enums.ViewType
import io.openim.android.sdk.listener.OnAdvanceMsgListener
import io.openim.android.sdk.listener.OnBase
import io.openim.android.sdk.listener.OnMsgSendCallback
import io.openim.android.sdk.models.*
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
/**
* 聊天ViewModel基类包含所有聊天功能的通用实现
* 子类需要实现抽象方法来处理特定的聊天类型(单聊/群聊)
*/
abstract class BaseChatViewModel : ViewModel() {
// 通用状态属性
var chatData by mutableStateOf<List<ChatItem>>(emptyList())
var myProfile by mutableStateOf<AccountProfileEntity?>(null)
var hasMore by mutableStateOf(true)
var isLoading by mutableStateOf(false)
var lastMessage: Message? = null
val showTimestampMap = mutableMapOf<String, Boolean>()
var goToNew by mutableStateOf(false)
var conversationID: String = "" // 会话ID通过getOneConversation初始化
// 通用服务
val userService: UserService = UserServiceImpl()
val accountService: AccountService = AccountServiceImpl()
var textMessageListener: OnAdvanceMsgListener? = null
val fetchHistorySize = 20
/**
* 初始化方法,子类需要实现具体的初始化逻辑
*/
abstract fun init(context: Context)
/**
* 获取日志标签,子类需要实现
*/
abstract fun getLogTag(): String
/**
* 获取会话参数,子类需要实现
* @return Triple(targetId, conversationType, isSingleChat)
*/
abstract fun getConversationParams(): Triple<String, Int, Boolean>
/**
* 处理接收到的新消息,子类可以重写以添加特定逻辑
*/
open fun handleNewMessage(message: Message, context: Context): Boolean {
return false // 默认不处理,子类重写
}
/**
* 获取发送消息时的接收者ID子类需要实现
*/
abstract fun getReceiverInfo(): Pair<String?, String?> // (recvID, groupID)
/**
* 发送消息成功后的额外处理,子类可以重写
*/
open fun onMessageSentSuccess(message: String, sentMessage: Message?) {
// 默认无额外处理,子类可以重写
}
/**
* 获取会话信息并初始化conversationID
*/
fun getOneConversation(onSuccess: (() -> Unit)? = null) {
val (targetId, conversationType, isSingleChat) = getConversationParams()
OpenIMClient.getInstance().conversationManager.getOneConversation(
object : OnBase<ConversationInfo> {
override fun onError(code: Int, error: String) {
Log.e(getLogTag(), "getOneConversation error: $error")
}
override fun onSuccess(data: ConversationInfo) {
conversationID = data.conversationID
Log.d(getLogTag(), "获取会话信息成功conversationID: $conversationID")
onSuccess?.invoke()
}
},
targetId,
conversationType
)
}
/**
* 注册消息监听器
*/
fun RegistListener(context: Context) {
// 检查 OpenIM 是否已登录
if (!com.aiosman.ravenow.AppState.enableChat) {
Log.w(getLogTag(), "OpenIM 未登录,跳过注册消息监听器")
return
}
textMessageListener = object : OnAdvanceMsgListener {
override fun onRecvNewMessage(msg: Message?) {
msg?.let { message ->
if (handleNewMessage(message, context)) {
val chatItem = ChatItem.convertToChatItem(message, context, avatar = getMessageAvatar(message))
chatItem?.let {
chatData = listOf(it) + chatData
goToNew = true
Log.i(getLogTag(), "收到来自 ${message.sendID} 的消息,更新聊天列表")
}
}
}
}
}
OpenIMClient.getInstance().messageManager.setAdvancedMsgListener(textMessageListener)
}
/**
* 获取消息头像,子类可以重写
*/
open fun getMessageAvatar(message: Message): String? {
return null
}
/**
* 取消注册消息监听器
*/
fun UnRegistListener() {
textMessageListener = null
}
/**
* 清除未读消息
*/
fun clearUnRead() {
if (conversationID.isEmpty()) {
Log.w(getLogTag(), "conversationID为空无法清除未读消息")
return
}
OpenIMClient.getInstance().messageManager.markConversationMessageAsRead(
conversationID,
object : OnBase<String> {
override fun onSuccess(data: String?) {
Log.i("openim", "清除未读消息成功")
}
override fun onError(code: Int, error: String?) {
Log.i("openim", "清除未读消息失败, code:$code, error:$error")
}
}
)
}
/**
* 加载更多历史消息
*/
fun onLoadMore(context: Context) {
if (!hasMore || isLoading) {
return
}
loadHistoryMessages(context, isLoadMore = true)
}
/**
* 发送文本消息
*/
fun sendMessage(message: String, context: Context) {
// 检查 OpenIM 是否已登录
if (!com.aiosman.ravenow.AppState.enableChat) {
Log.w(getLogTag(), "OpenIM 未登录,无法发送消息")
return
}
val textMessage = OpenIMClient.getInstance().messageManager.createTextMessage(message)
val (recvID, groupID) = getReceiverInfo()
OpenIMClient.getInstance().messageManager.sendMessage(
object : OnMsgSendCallback {
override fun onProgress(progress: Long) {
// 发送进度
}
override fun onError(code: Int, error: String?) {
Log.e(getLogTag(), "发送消息失败: $error")
}
override fun onSuccess(data: Message?) {
Log.d(getLogTag(), "发送消息成功")
onMessageSentSuccess(message, data)
data?.let { sentMessage ->
val chatItem = ChatItem.convertToChatItem(sentMessage, context, avatar = myProfile?.avatar)
chatItem?.let {
chatData = listOf(it) + chatData
goToNew = true
}
}
}
},
textMessage,
recvID,
groupID,
OfflinePushInfo()
)
}
/**
* 发送图片消息
*/
fun sendImageMessage(imageUri: Uri, context: Context) {
val tempFile = createTempFile(context, imageUri)
val imagePath = tempFile?.path
if (imagePath != null) {
val imageMessage = OpenIMClient.getInstance().messageManager.createImageMessageFromFullPath(imagePath)
val (recvID, groupID) = getReceiverInfo()
OpenIMClient.getInstance().messageManager.sendMessage(
object : OnMsgSendCallback {
override fun onProgress(progress: Long) {
Log.d(getLogTag(), "发送图片消息进度: $progress")
}
override fun onError(code: Int, error: String?) {
Log.e(getLogTag(), "发送图片消息失败: $error")
}
override fun onSuccess(data: Message?) {
Log.d(getLogTag(), "发送图片消息成功")
data?.let { sentMessage ->
val chatItem = ChatItem.convertToChatItem(sentMessage, context, avatar = myProfile?.avatar)
chatItem?.let {
chatData = listOf(it) + chatData
goToNew = true
}
}
}
},
imageMessage,
recvID,
groupID,
OfflinePushInfo()
)
}
}
/**
* 创建临时文件
*/
fun createTempFile(context: Context, uri: Uri): File? {
return try {
val projection = arrayOf(MediaStore.Images.Media.DATA)
val cursor = context.contentResolver.query(uri, projection, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
val filePath = it.getString(columnIndex)
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
val mimeType = context.contentResolver.getType(uri)
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
val tempFile =
File.createTempFile("temp_image", ".$extension", context.cacheDir)
val outputStream = FileOutputStream(tempFile)
inputStream?.use { input ->
outputStream.use { output ->
input.copyTo(output)
}
}
tempFile
} else {
null
}
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/**
* 获取历史消息
*/
fun fetchHistoryMessage(context: Context) {
loadHistoryMessages(context, isLoadMore = false)
}
/**
* 加载历史消息的通用方法
* @param context 上下文
* @param isLoadMore 是否是加载更多true追加到现有数据false替换现有数据
*/
private fun loadHistoryMessages(context: Context, isLoadMore: Boolean) {
if (conversationID.isEmpty()) {
Log.w(getLogTag(), "conversationID为空无法${if (isLoadMore) "加载更多" else "获取"}历史消息")
return
}
if (isLoadMore) {
isLoading = true
}
viewModelScope.launch {
OpenIMClient.getInstance().messageManager.getAdvancedHistoryMessageList(
object : OnBase<AdvancedMessage> {
override fun onSuccess(data: AdvancedMessage?) {
val messages = data?.messageList ?: emptyList()
val newChatItems = messages.mapNotNull {
ChatItem.convertToChatItem(it, context, avatar = getMessageAvatar(it))
}.reversed() // 反转顺序,使最新消息在前面
// 根据是否是加载更多来决定数据处理方式
chatData = if (isLoadMore) {
chatData + newChatItems // 追加到现有数据
} else {
newChatItems // 替换现有数据
}
if (messages.size < fetchHistorySize) {
hasMore = false
}
lastMessage = messages.firstOrNull()
if (isLoadMore) {
isLoading = false
}
Log.d(getLogTag(), "${if (isLoadMore) "加载更多" else "获取"}历史消息成功")
}
override fun onError(code: Int, error: String?) {
Log.e(getLogTag(), "${if (isLoadMore) "加载更多" else "获取"}历史消息失败: $error")
if (isLoadMore) {
isLoading = false
}
}
},
conversationID,
if (isLoadMore) lastMessage else null, // 首次加载不传lastMessage
fetchHistorySize,
ViewType.History
)
}
}
/**
* 获取显示的聊天列表
*/
fun getDisplayChatList(): List<ChatItem> {
val list = chatData
// 更新每条消息的时间戳显示状态
for (item in list) {
item.showTimestamp = showTimestampMap.getOrDefault(item.msgId, false)
}
return list
}
}

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