Compare commits

..
Author SHA1 Message Date
编码工程师andmultica-agent d7f5c7d8fb feat(mobile-next): 接入 flutter_openim_sdk 3.8.3+hotfix.12 并完成核心能力实测
- 仅升级 flutter_openim_sdk 3.8.3+3 → 3.8.3+hotfix.12(pubspec.yaml、openim_common、openim_live、pubspec.lock)
- debug/release 双构建成功,核对包名/版本/签名/SHA256
- 双测试账号 t1001/t1002 实测:初始化、登录、会话同步、断线重连、好友/群组、
  文字/图片/文件/语音双向收发,全部通过(服务端落库复核)
- 验证与回退说明见 docs/mobile-next-hotfix-sdk-verification.md

Co-authored-by: multica-agent <github@multica.ai>
2026-08-19 12:44:37 +08:00
编码工程师andmultica-agent e4d0407c63 docs(mobile-next): 回退命令引用实际导入提交 0a2fbac
Co-authored-by: multica-agent <github@multica.ai>
2026-08-19 10:45:56 +08:00
编码工程师andmultica-agent 754f161a3f feat(mobile-next): 导入官方 openim-flutter-demo 固定基线 3.8.3-patch.3 并完成双构建
- 上游: github.com/OpenIMSDK/openim-flutter-demo tag 3.8.3-patch.3
  commit b3dfdb1e8aaeaaf6f0793e10cadd20d5c184a31f
- 并行目录 mobile-next/ 原样导入(含 LICENSE),不覆盖现网 mobile/
- 锁定 Flutter 3.24.5 / Dart 3.5.4 / JDK 17 / Gradle 7.6.3 / AGP 7.3.1
- 实测 Android debug 与 release 构建均成功(见 docs/mobile-next-baseline-import.md)
- 本提交可单独回退:git revert 1db1229(重写前)

Co-authored-by: multica-agent <github@multica.ai>
2026-08-19 10:45:55 +08:00
101 changed files with 1223 additions and 5086 deletions
-120
View File
@@ -1,120 +0,0 @@
# B-204 Android 语音通话迁移与构建验证
分支:`agent/agent/3a58b06e`
施工基线 HEAD`2a817e77d899d7af66ebfecef382b55d872c1285`
越界对比基线:`e26abad`
本卡只做 Android 一对一语音通话迁移与可构建验证,不改 PC 通话、不部署、不发布、不换标。
**结论:Android debug/release 构建通过,状态机替身测试通过。缺有效账号、ARM64 真机和 LiveKit 实测,不得写成通话运行验收通过。**
## 配置入口
| 项 | 位置 | 值 |
|---|---|---|
| 开关 | `mobile-next/lib/company/feature_flags.dart` `FeatureFlags.livekitCall` | 跟随 `LiveKitCallConfig.enabled` |
| 配置门 | `mobile-next/lib/company/rtc/livekit_call_config.dart` | `shipped && endpointsReady` |
| LiveKit | `AppEndpoints.livekitUrl` | `ws://192.168.200.11:17880` |
| 换票 | `POST {authApiBase}/api/rtc_token` | Bearer `imToken`body `{room, identity}` |
| 信令 | 官方 `customType` 200–204 仅在线自定义消息 | 未自建第二套信令,未改 SDK 核心 |
官方 `Apis.getTokenForRTC``/user/rtc/get_token` + `token` 头 + `serverUrl`)与账号服务不兼容。本卡用公司薄适配 `RtcTokenApi` 覆盖 `getRtcCertificate`,缺 `liveURL` 时回落到 `AppEndpoints.livekitUrl`。未改 `account-service/` 与现网端口。
`LiveKitCallConfig.shipped = true` 仅在本卡构建通过后保持开启。若关掉 `endpointsReady`(地址不是 `ws://` / `http`),入口自动隐藏。
## 提交主题(可逐项 `git revert`
从新到旧回退:先本验收记录,再 Token 映射,再通话适配。
| 主题 | SHA | 文件 |
|---|---|---|
| 本验收记录 | 本文件当次提交 | `docs/acceptance/android-livekit-call.md` |
| 通话页 Token 对齐 | `d7f7a6aed213ad7250e7e0da28608d0e312859fb` | `apply_official_styles.dart` |
| 语音通话薄适配与入口 | `3832e080c4cba2ec9a76e1cb5b988bc06f0fd4a3` | `company/rtc/*``im_controller.dart`、聊天/资料入口、`openim_live` 超时/销毁/重连、测试 |
回退示例(新到旧):
```
git revert <本验收提交>
git revert d7f7a6aed213ad7250e7e0da28608d0e312859fb
git revert 3832e080c4cba2ec9a76e1cb5b988bc06f0fd4a3
```
回退不影响 `mobile/``account-service/``config/``scripts/`、现网服务、PC 通话死代码与已发布安装包。
## 环境
- Flutter 3.24.5 / Dart 3.5.4`C:\flutter324`
- Temurin JDK 17.0.20+8`toolchain\jdk17`
- Android SDK:既有 B-95 工具链(build-tools 含 36.0.0compileSdk 34
- `org.gradle.jvmargs=-Xmx4096M`
## 检查与构建
### 状态机替身
```
cd mobile-next
flutter test test/company/rtc_token_mapper_test.dart test/company/call_session_guard_test.dart
```
11 项通过:换票解析(缺 liveURL 回落、`serverUrl`/`liveURL`、失败码)、占线、重复信令、超时取消、销毁释放计时器。
### Dart 定点
- `lib/company/rtc/**``feature_flags.dart``im_controller.dart`**0 error**
- 全量 `flutter analyze`**529 issues**。error 仍为存量(`local_plugin/flutter_download_manager/example` 等),本卡新增文件无 error。
### Android(真实构建)
```
flutter pub get
flutter build apk --debug
flutter build apk --release
```
| 构建 | 结果 | 大小 | SHA256 | 产物 |
|---|---|---|---|---|
| debug | 成功 | 109,405,761 B | `21487F23963DC60189C9181F3B985495ED6EFF7F74C619FCB114E5DB26B259F3` | `mobile-next/build/app/outputs/flutter-apk/app-debug.apk` |
| release | 成功 | 53,284,571 B | `AADFF25F62CC98D60D5EF0C94112C4388027107669A15743D2C1BB16F2245C78` | `mobile-next/build/app/outputs/flutter-apk/app-release.apk` |
元数据(aapt / apksignerrelease):包名 `io.openim.flutter.demo`versionName `3.8.3` / versionCode `180`minSdk 24 / targetSdk 33 / compileSdk 34,仅 `arm64-v8a`label `F-DEMO`。签名 CN=openim,证书 SHA-256 `1f38a82fb3c33be7951fb5d964ef5bd9c6c9bfc084ae1e2a8a40a0d1f44ae1fe`
构建期告警:Flutter Gradle apply-script 弃用;部分依赖 Kotlin metadata 1.8.0 vs 工程预期 1.6.0。均属上游,debug/release 均通过。APK 内嵌时间戳,哈希不可位级复现。
## 越界比对
相对 `2a817e77`:仅上表主题涉及的 `mobile-next/` 与本验收文档。
相对 `e26abad`
- `mobile/``account-service/``config/``scripts/`**零改动**
- `pc-client/src/store/``pc-client/src/layout/MainContentWrap.tsx`、发送/历史链路:**零改动**
- PC 未新增任何通话入口;RTC 死代码按 B-90 保留
## 已测 / 未测
已测(替身或构建):
- 换票响应映射与失败文案
- 占线拒绝第二路、重复信令去重、30s 超时、页面销毁取消计时器
- Android debug/release 可构建
- 入口:单聊顶栏/工具箱/资料页随开关显示;仅音频;语音要麦克风权限,不要摄像头
未测(缺外部条件,禁止写成通过):
- 测试账号:无有效工号+密码,未登录
- Android ARM64 真机:未安装、未点验呼叫/来电/接听/拒绝/取消/挂断
- LiveKit 进房、听筒/扬声器、静音、后台/锁屏返回、异常断开重连的真机表现
- 与旧 `mobile/` 或 PC 的互通(PC 本阶段不建设通话)
最小所需:管理员给两个测试工号;两台 ARM64 真机装 `app-release.apk`;确认 LiveKit `:17880` 与账号服务 `:10010/api/rtc_token` 可用后做一轮双向语音。
## 风险清单
- OpenIM 许可书面结论未落;未换标,label 仍 `F-DEMO`
- 官方 chat 换票协议与现网账号服务不兼容,依赖本卡薄适配;关掉开关即无入口
- LiveKit 使用官方 `livekit_client` 2.2.5 默认重连,真机未测
- APK 仍用上游 openim keystore
- 全量 analyze 存量错误仍在,不作为本卡运行通过依据
功能质检与视觉审核须另排。本卡不宣布语音通话实测通过。
-73
View File
@@ -1,73 +0,0 @@
# B-171 第一批迁移验收与回退
分支:`agent/agent/7b089146`(基于 B-95 `agent/agent/713c11eb` / `e26abad`
回退基线:`fa8584a`
## 提交主题(可逐项 `git revert`
### 第一批
1. B-149 文档三处修正 `45d9168`
2. 手机 Token/主题入口、内网地址、功能开关 `d787c0a`
3. 工号登录薄适配与登录页 `60b6590`
4. 员工目录、通讯录/添加同事/好友申请、会话与聊天第一批界面(含在 `60b6590`
5. PC 可改目录 Token 与通讯录/申请列表重整 `0589623`
6. 本验收记录(初版)`5e098a5`
### B-173 定向返工(本轮)
| 主题 | SHA | 文件 |
|---|---|---|
| 未读角标 18×18、白字 12、圆角 9、去掉重阴影 | `da9152d36b680b76e0e9d50536dd4692baffe084` | `mobile-next/openim_common/lib/src/widgets/unread_count_view.dart` |
| 会话姓名 16sp,字号只用 12/14/16/18 | `87a5930906554d3b0b88bdf95086787624a345ab` | `mobile-next/lib/pages/conversation/conversation_view.dart` |
| 登录失败顶部 toast,2 秒消失 | `092f5137082e392de9a14bd340260a147b9e254a` | `company_login_page.dart``company_login_logic.dart` |
| 加载/失败/断网接到真实状态 | `2b2c2d28a42f57d1cd8e635153ce398685a0bc89` | 会话/通讯录/好友申请/添加同事/主页 |
| 主窗口下限 1080×680(特批仅两常量) | `63520a64889a667c79be3406f85b5c191ad24635` | `pc-client/electron/main/windowManage.ts` |
| 本轮验收记录 | 本文件当次提交 | `docs/acceptance/b171-batch1.md` |
回退本轮:从新到旧逐个 `git revert` 上表 SHA(含本验收提交)。
### B-180/B-181 小回归(本轮)
| 主题 | SHA | 文件 |
|---|---|---|
| 断网横幅外包 `SafeArea(bottom: false)` | `26d070fbaf40f909f40bc5320c65d81b1b97fb9d` | `mobile-next/lib/pages/home/home_view.dart` |
| 工号提示与发送按钮改回独立 `Obx` | `e355b89204c14708a2d51bee9af84f2227bc71dd` | `mobile-next/lib/company/ui/add_colleague_page.dart` |
| 本轮验收补记 | 本文件当次提交 | `docs/acceptance/b171-batch1.md` |
回退本轮小回归:`git revert <本验收提交>`,再 `git revert e355b89`,再 `git revert 26d070f`
`dart format` + `flutter analyze`:上述两个 Dart 文件无 issues。未重新打安装包。HTTP 登录与 LiveKit 仍不标为通过。黄色建议未处理。
## 越界比对(对比 `e26abad`
零改动:`mobile/``pc-client/src/store/``pc-client/src/layout/MainContentWrap.tsx``pc-client/src/pages/chat/queryChat/ChatFooter/useSendMessage.ts``pc-client/src/pages/chat/queryChat/useHistoryMessageList.tsx``account-service/`
`pc-client/electron/`:仅 `main/windowManage.ts``minWidth=1080``minHeight=680` 两处常量变化(队长特批)。其余 electron 文件相对 `e26abad` 为零改动。
## 窗口常量特批
允许且仅允许修改 `pc-client/electron/main/windowManage.ts``minWidth``minHeight`。整树越界比对已单独列出该差异。
## 品牌历史遗留
`pc-client/src/config/index.ts` 中历史已有的 `APP_NAME = "畅联"` 非本批引入。本批不增、不删、不改任何品牌字样、标识或资源;待 OpenIM 许可书面结论后统一处置。
B-173 黄色建议未并入本轮。
## 构建(返工后,Flutter 3.24.5 / Temurin 17 / 原 B-95 SDK
| 构建 | 结果 | 大小 | SHA256(当次产物记录,非位级可复现) |
|---|---|---|---|
| debug | 成功 | 109,478,645 B | `E51571608B10FF32D25867EA6E23C750EEA2845FA0ED31FD9C17DAA960F654B5` |
| release | 成功 | 53,279,759 B | `BA0D548B7E38203D0FF455646744F4A591E69853E686F1B3D15CCB61EE899F8C` |
`dart format` 已对返工 Dart 文件执行。`flutter analyze`:本卡改动文件无 error(仅既有 info/warning)。仓库内既有 `local_plugin/flutter_download_manager/example` 报错与 B-95 相同,非本卡引入。
PC:本环境无 `node_modules`,未跑 `npm run lint` / `vite build`,不推定通过。窗口下限改动为 TypeScript 常量,未做运行时验证。
- HTTP 工号密码登录:**不标为已通过。**
- LiveKit**未测,不标为已通过。**
- 品牌换标:未做。登录页保留 OpenIM 标识。
功能质检与视觉审核须重新执行,本卡不自行宣布通过。
-94
View File
@@ -1,94 +0,0 @@
# B-185 / B-171 第二批交互补齐验收与回退
分支:`agent/agent/7b089146`
基线:`fb58da6`B-171 第一批完成点)
架构:B-90;视觉:B-91 `changlian-visual-spec-v2.md`
本批不换标、不部署、不发安装包;LiveKit 未迁。
## 提交主题(可逐项 `git revert`
从新到旧回退:先 B-189,再 B-188 验收补记与 `d9d5853`,再原第二批验收提交,再按下表从下往上 `git revert`
| 主题 | SHA | 主要文件 |
|---|---|---|
| 状态线性图标与 toast/断网横幅动效 Token | `ce2a3ae059be4de480b73ee585d36a051b303fd0` | `app_tokens.dart``state_views.dart` |
| 手机会话页:搜索、+ 菜单、长按置顶/删除、去掉置顶角标 | `7f131457f7f5d0a30ca81414c7dc94ede6f59d84` | `conversation_view.dart` |
| 通讯录部门分组、职务行、申请窄屏 | `49b346d375e9b9b62c3a59c6f6970c7facfba94b` | `contacts_logic.dart``contacts_view.dart``friend_requests_view.dart` |
| 聊天输入区、语音气泡、大图、录音与字号映射 | `3b87812db8945c3b7fa6d4c0eddddb35f5ab10f3` | `chat_*.dart``apply_official_styles.dart` |
| 工号查询防抖、控制器生命周期、开关、资料页工号 | `26622676ed4f4898385d8e9e686296eb56adf4be` | `add_colleague_page.dart``app_pages.dart``company_login_page.dart``my_info_view.dart``personal_info_view.dart` |
| 断网横幅恢复后 300ms 收起、底栏 12px | `d1657ea29f0fc59f248f2108d7cfb9bcc239921e` | `home_view.dart` |
| PC 第二批登录/会话/聊天/添加同事/职务行/i18n | `a44537da4e8ce7b5b17b491c05592ad21cdd0bca` | `pc-client/src/pages/**``ApplicationItem``i18n``global.scss``docs/acceptance/pc-boundary.md` |
| 第二批验收记录 | `a4d21ef36777440812c6746cd792eeaf7685cd5c` | `docs/acceptance/b171-batch2.md` |
| B-188 定点返工:PC 会话置顶改 `setConversation` | `d9d5853f73aa8060c9ed768c326f00d691847e3e` | `pc-client/src/pages/chat/ConversationSider/ConversationItem.tsx` |
| B-188 验收补记 | `e40ece1` | `docs/acceptance/b171-batch2.md` |
回退 B-188:先 `git revert e40ece1`,再 `git revert d9d5853f73aa8060c9ed768c326f00d691847e3e`
## B-189 语音区返工(相对 `e40ece1`
依据:B-187 打回三项阻塞 + 输入框/`按住 说话` 底色 `searchBg`。不处理其余黄色建议。
| 主题 | SHA | 主要文件 |
|---|---|---|
| 未听红点、录音浮层入场/取消区、50–60s 时限、searchBg | `15d7f1935e38c18d127319346cf03d278b596b2c` | `chat_voice_*.dart``chat_item_view.dart``chat_input_box.dart``apply_official_styles.dart``styles.dart` |
回退 B-189:先 `git revert` 本补记提交,再 `git revert 15d7f1935e38c18d127319346cf03d278b596b2c`
静态核对(对照代码,非运行):
- 未听红点:接收且 `message.isRead == false` 时在气泡右侧 8px `danger`;播放走既有 `_playVoiceMessage` + `_markMessageAsRead`,不另造状态。
- 浮层入场:150ms ease-out 透明度 0→1`Styles.motionOf` 接到 `AppMotion.reduce` / `AppDuration.overlay`;取消区保持 `rgba(0,0,0,0.7)` 黑卡,仅垃圾桶与提示改 `danger`;切换时长走 `AppDuration.overlaySwitch`
- 50s:计时改 `warning` 文案「还可说 N 秒」;60s:既有 `ChatRecordVoiceView` 中断→dispose→一次 `onCompleted`,toast「已达最长 60 秒,已自动发送」。实例级 `_completed` 避免重复发送;timer 在 dispose 取消。
- 输入框与「按住 说话」底色改为 `Styles.c_F2F3F5``AppColors.searchBg`,不再用 `c_F0F2F6`/`chatBg`
## 检查
- `dart format` / `flutter analyze` / Android debug+release**未跑。** 本环境 PATH 中无 `flutter`/`dart`/`java`,不推定通过。
- PC `npm run lint` / `vite build`B-189 未改 PC、未重跑。B-188 当时 `npm run build` 通过,见下节。
- 运行截图:无 Flutter 工具链、无测试账号、无客户端进程,**未截图、未实测**未听红点/播放清除/取消手势/50–60s 倒计时/到限自动发送与 toast。不得推定通过。
### B-188 定点返工检查(2026-08-20
- 改动:`IMSDK.pinConversation(...)``IMSDK.setConversation({ conversationID, isPinned: !conversation.isPinned })``deleteConversationAndDeleteAllMsg` 未改。
- PC `npm run build`**通过**vite `✓ built in 31.30s`,随后写出 `dist-electron/**`)。
- `npx tsc --noEmit`(实际调用 `node node_modules/typescript/bin/tsc --noEmit`):`ConversationItem.tsx` **无本批新增错误**(无 TS2551 / `pinConversation`)。
- 会话右键「置顶/取消置顶」:**未实测**(无登录运行环境、无客户端进程),不得推定通过。
- 黄色建议:未处理。
`tsc --noEmit` 上游存量错误(非本卡引入,未修):
- `src/hooks/useGroupMembers.ts` TS18047 ×3`latestFetchState.current` possibly null
- `src/layout/LeftNavBar/About.tsx` TS2345 ×2`OnUploadLogsProgress` 回调类型)
- `src/pages/chat/queryChat/useHistoryMessageList.tsx` TS18047 ×1
- `src/pages/common/ChooseModal/ChooseBox/index.tsx` TS18047 ×2
- `src/pages/common/RtcCallModal/index.tsx` TS2786`LiveKitRoom` JSX
- `src/pages/common/RtcCallModal/RtcControl.tsx` TS2786 ×2`TrackToggle`
- `src/pages/common/RtcCallModal/RtcLayout.tsx` TS2786`VideoTrack`
- `src/pages/common/UserCardModal/index.tsx` TS2345
- `src/utils/imCommon.ts` TS2322 ×11`string | undefined``string`
## 越界比对(对比 `e26abad`
零改动:`mobile/``pc-client/src/store/``pc-client/src/layout/MainContentWrap.tsx``pc-client/src/pages/chat/queryChat/ChatFooter/useSendMessage.ts``pc-client/src/pages/chat/queryChat/useHistoryMessageList.tsx``account-service/`
`pc-client/electron/`:仅既有 `main/windowManage.ts``minWidth=1080``minHeight=680`(B-171 特批),本批未再改 electron。
`pc-boundary.md` 已扩到与 B-90 / B-185 一致:登录窗、chat **展示**`AddColleague.tsx` 列入可改;发送/历史/store/SDK 初始化仍冻结。PC 聊天顶栏去掉「语音通话」入口;RTC 死代码未删。
B-189 改动限于 `mobile-next/openim_common` 语音/输入部件、Token 接线、`mobile-next/lib/pages/chat/chat_view.dart` 自定义语音气泡防双重气泡,以及本验收记录。
## 未验证(缺账号环境,禁止推定通过)
- HTTP 正确密码登录
- 双向消息收发
- 服务端好友申请 `status=2`
- LiveKit / 语音通话(本阶段不迁、PC 不建设)
- 登录窗独立 360×480:受 electron 冻结限制,本批只改登录页展示,未改窗口尺寸
- B-189 全部录音/未听红点运行时表现
## 品牌
未换标。手机登录仍按开关保留 OpenIM 标识。不增删品牌资源。
功能质检与视觉审核须重新执行,本卡不自行宣布整批通过。
@@ -1,150 +0,0 @@
# B-197 / B-198 跨端构建修复与验收记录
分支:`agent/agent/7b089146`(本卡工作树 `agent/agent/2cae90a2`
构建基线 HEAD`2c81a11e03411fa1b033f0f74cd49139e258d1d3`
越界对比基线:`e26abad`
本卡只做构建修复与验收记录,不部署、不发布、不换标。
B-197 已真实打通 PC 与 Android debug/release,但修复仅在部署工程师本机工作树。本文件记录 B-198 复核后的入库提交与**重新真实构建**结果。
**结论:安装包构建与 PC 启动冒烟通过。登录、收发、语音、同步等运行时回归未测,不得视为跨端运行验收通过。**
## 提交主题(可逐项 `git revert`
从新到旧回退:先本验收记录,再锁文件,再 Gradle 堆,再 Dart 编译修复。
| 主题 | SHA | 文件 |
|---|---|---|
| 本验收记录 | 本文件当次提交 | `docs/acceptance/b197-cross-platform-regression.md` |
| 同步 package-lock 根版本号 1.0.2 | `c150033b39d047852eb1baed1d55b1726a24e65a` | `pc-client/package-lock.json` |
| Android Gradle 堆 4096M | `5160c8ec87f794f75a2ab2e06632a88c35f2d2e1` | `mobile-next/android/gradle.properties` |
| FeatureFlags 导入 + 去掉预览 const | `1767cf2547ca58102ba3734c06ae73ce85e646a2` | `personal_info_view.dart``chat_picture_preview.dart` |
回退示例(新到旧):
```
git revert <本验收提交>
git revert c150033b39d047852eb1baed1d55b1726a24e65a
git revert 5160c8ec87f794f75a2ab2e06632a88c35f2d2e1
git revert 1767cf2547ca58102ba3734c06ae73ce85e646a2
```
回退不影响 `mobile/``account-service/``config/``scripts/`、现网服务与已发布安装包。
## 复核的 4 处改动
1. `mobile-next/lib/pages/contacts/user_profile_panel/personal_info/personal_info_view.dart:6``../../../company``../../../../company`。该文件在 `personal_info/` 下,相对 `lib/` 需要四层。目标模块是 `lib/company/feature_flags.dart``FeatureFlags`,未引入其它模块。
2. `mobile-next/openim_common/lib/src/widgets/chat/chat_picture_preview.dart:97`:去掉 `Center` 外层 `const``Styles.c_0089FF` 定义为 `static Color`(非 const)。未改 SizedBox / CircularProgressIndicator,未做额外格式化。
3. `mobile-next/android/gradle.properties``org.gradle.jvmargs=-Xmx1536M``-Xmx4096M`。B-197 在 1536M 上 release 报 `Java heap space`;本卡用 4096M 重新打通 debug 与 release。未再试 2048M/3072M(缺少真实 release 证明),故采用已证明稳定的 4096M。
4. `pc-client/package-lock.json`:仅根节点 `"version": "1.0.1"``"1.0.2"`,与 `package.json` 的 1.0.2 对齐。`lockfileVersion` 仍为 3,无依赖树或包升级。其它 npm 回写视为环境噪音,未入库。
## Gradle 机器内存要求
- 配置:`mobile-next/android/gradle.properties``org.gradle.jvmargs=-Xmx4096M`(与 `docs/upstream/build-and-rollback.md``GRADLE_OPTS=-Xmx4096M` 一致)。
- 机器:JVM 堆预留 4GB。建议物理内存 ≥ 8GB(堆 + Gradle daemon + 系统);本机构建机约 64GB,构建时未再出现 heap space。
- 1536M 不能作为 release 稳定值。
## 环境
- Flutter 3.24.5 / Dart 3.5.4`C:\flutter324`
- Temurin JDK 17.0.20
- Android SDK:既有 B-95 工具链(build-tools 36.0.0platforms 含 compileSdk 34
- Node v24.18.0 / npm 11.16.0`C:\Program Files\nodejs`
- `npm install``husky install || true` 在 Windows cmd 下仍以 exit 1 结束(已知非阻塞);`node_modules` 可用,后续 vite / electron-builder 成功。
## 检查与构建
### Dart
- 对上述两文件做过 `dart format`,但**未提交格式化换行**,只保留两处语义改动。
- 定点 `flutter analyze``personal_info_view.dart` 0 issues`chat_picture_preview.dart``invalid_constant`,仅存量 `must_be_immutable` / `use_super_parameters`
- 全量 `flutter analyze`mobile-next):**553 issues**(约 18 error / 87 warning / 448 info)。error 全为存量:`local_plugin/flutter_download_manager/example``openim_common``push_controller.dart` / `custom_mk_controls.dart`。本卡两文件无新增 error。
### Android(必须重新真实构建)
命令(`mobile-next/`):
```
flutter pub get
flutter build apk --debug
flutter build apk --release
```
| 构建 | 结果 | 大小 | SHA256 | 产物 |
|---|---|---|---|---|
| debug | 成功 | 109,391,065 B | `CE01F30CAFBA24CAFD686F0CFB32ACBDB53359395341B0B1EA2FB795F14C0748` | `mobile-next/build/app/outputs/flutter-apk/app-debug.apk` |
| release | 成功 | 53,273,539 B | `52F02CBC855989C62A89112EB791510CC4313921D9E1998456E94A737F8B148B` | `mobile-next/build/app/outputs/flutter-apk/app-release.apk` |
元数据(aapt / apksignerrelease):包名 `io.openim.flutter.demo`versionName `3.8.3` / versionCode `180`minSdk 24 / targetSdk 33 / compileSdk 34,仅 `arm64-v8a`label `F-DEMO`。签名 CN=openim,证书 SHA-256 `1f38a82fb3c33be7951fb5d964ef5bd9c6c9bfc084ae1e2a8a40a0d1f44ae1fe`
构建期告警:Flutter Gradle apply-script 弃用;部分依赖 Kotlin metadata 1.8.0 vs 工程预期 1.6.0。均属上游,debug/release 均通过。
### PC(锁文件影响后的 build + 安装包)
```
npm install
npm run build
npx tsc --noEmit
npx electron-builder --win --x64
```
| 步骤 | 结果 |
|---|---|
| npm install | 依赖装上;husky prepare 在 Windows 报 exit 1(非阻塞) |
| vite build | 通过(`✓ built in 25.77s`),antd `"use client"` 指令被忽略(存量告警) |
| tsc --noEmit | exit 2**24 个存量错误**,与 B-188 清单一致,本卡未新增 |
| electron-builder | 通过;`extraResources` 目录缺失,构建跳过该项 |
产物:`pc-client/release/Base/1.0.2/畅联_1.0.2.exe`105,891,730 B(约 101MB),SHA256 `A5F15035D669E2184BA1ED2E68FFFB1432FB997703F1FC8103CE4B38D7BB362E`
启动冒烟:启动 `win-unpacked/畅联.exe`,进程拉起且主窗口标题为「畅联」,随后结束进程。未登录。
`tsc` 存量错误(未修):`useGroupMembers.ts` TS18047 ×3、`About.tsx` TS2345 ×2、`useHistoryMessageList.tsx` TS18047 ×1、`ChooseBox/index.tsx` TS18047 ×2、`RtcCallModal` / `RtcControl` / `RtcLayout` TS2786 ×4、`UserCardModal` TS2345 ×1、`imCommon.ts` TS2322 ×11。
## 与 B-197 报告 SHA256 的差异
B-197 评论记录:
- PC `eab9bef6285c940351cdd995d850a0d4c67aebc122b83a0ba0680ea0f76e3df2`
- Android release `b5029f0ece55cd11828a1fe4cb9364e660baad04495a74fe94f33639be8165c8`
- Android debug `035d5b6eba7b20351269d88339ad5e0cd2d1179ac7d8fdae8e18a0069fc6b6b7`
本卡重跑后哈希均不同。APK / Electron 安装包内嵌构建时间戳,**不是位级可复现**;可复现的是工具链、命令、包名、签名证书与约 50.8MB / 101MB 量级。以本表 SHA256 为准。
## 越界比对
相对 `2c81a11e`:仅上述 4 个提交涉及的文件。
相对 `e26abad`
- `mobile/``account-service/``config/``scripts/`**零改动**
- `pc-client/src/store/``pc-client/src/layout/MainContentWrap.tsx``pc-client/src/pages/chat/queryChat/ChatFooter/useSendMessage.ts``pc-client/src/pages/chat/queryChat/useHistoryMessageList.tsx`**零改动**
- `pc-client/electron/`:仅既有 `windowManage.ts``minWidth=1080``minHeight=680`B-171 特批),本卡未再改
## 未测(缺外部条件,禁止写成通过)
- 测试账号:无有效工号+密码,未登录
- Android ARM64 真机:产物仅 arm64-v8a,未安装、未点验
- 工号登录 / 会话同步 / 好友群组 / 文字图片文件语音 / 断线重连 / 录音取消 / 50–60 秒时限 / 未听红点 / 安卓语音通话
- PC 人工交互:置顶、删除、断网恢复、1080×680 稳定性、双向收发
最小所需:管理员给测试工号;真机装 `app-release.apk`PC 装 `畅联_1.0.2.exe`;两工号同时登录做一轮双向收发。
## 安装与回退(本机产物,未入库)
安装:
- Android:把 `app-release.apk` 拷到 ARM64 真机安装(包名 `io.openim.flutter.demo`,会覆盖同包名旧包)。
- PC:运行 `畅联_1.0.2.exe`
回退代码:按上文 `git revert` 顺序。回退安装包:卸载本包 / 装回旧版安装包。现网服务与旧 `mobile/` 不受影响。
## 风险清单
- OpenIM 许可书面结论未落;未换标,label 仍 `F-DEMO`
- APK 仍用上游 openim keystore;正式试用前需换正式签名
- PC `extraResources` 目录缺失(构建跳过)
- Electron 22 偏旧;内网地址写死 `192.168.200.11`
- 全量 analyze / tsc 存量错误仍在,不作为本卡回归通过依据
功能质检与视觉审核须另排。本卡不宣布跨端运行验收通过。
@@ -1,99 +0,0 @@
# B-210 Android 通话视觉打回返工
分支:`agent/agent/ede0aecc`(基于 B-204 `agent/agent/3a58b06e` HEAD `714794f`
施工依据:B-91 视觉规范 v2;打回结论 B-209;姓名字号裁定 18px,32px 仅计时器。
本卡只做打回返工、构建验证和返工证据,不部署、不发布、不换标。
**结论:9 项阻塞 + 建议项已按规范收口;Android debug/release 真实构建通过。缺有效账号和 ARM64 真机,来电/通话页实机渲染、听筒/扬声器、静音、后台/锁屏、铃声震动继续标为未测。**
## 打回项对照
| # | 打回项 | 修改位置 | 结果 |
|---|---|---|---|
| 1 | 计时器 32px 纯白 tabular figures | `AppFont.callTimer``Styles.ts_callTimer``controls.dart` 通话中状态字 | 已改 |
| 2 | 通话页底 `#1D2129`,不复用 maskBg | `AppColors.callBg``Styles.c_callBg``call_state.dart` | 已改 |
| 3 | 呼出中仅居中 64 红色取消 | `controls.dart` `_buttonArea``LiveButton.cancel` | 已改 |
| 4 | 通话中 48 次按钮 + 居中红色挂断;Token 圆底线性图标 | `live_button.dart``controls.dart` | 已改,不再用位图 PNG |
| 5 | 姓名 → 状态 → 96 头像圆角 0.18 | `controls.dart` `_infoColumn` | 姓名 18px,头像 96×0.18 |
| 6 | 结束/失败展示 1.5s 后退出,失败 toast | `call_state.dart` `beginEnding``Styles.callEndHold` | 已结束 / 网络异常,通话中断 / 对方已拒绝 |
| 7 | 未接 60s +「未接通话」系统消息;清掉 30s 双来源 | `AppDuration.callInviteTimeout``LiveKitCallConfig` / `Styles` / `CallSessionGuard` / 信令 timeout | 超时插入 `CallState.timeout`,文案 `未接通话` |
| 8 | 进出 250ms 自下而上 ease-out,状态不可被重复回调破坏 | `call_page_host.dart``live_client.dart` close 防重入、`beginEnding` `_ending/_closed` | 已改 |
| 9 | 来电每 1s 震动,拒绝/接听/退出/销毁停止 | `live_controller.dart` `_startIncomingVibrate` / `_stopSound` | 已改;真机未测 |
| 10 | 状态字 14px 白 60%;文案;静音/免提;最小化点击区 48 | `ts_callStatus`、zh_CN 文案、`LiveButton.mute/handsFree`、最小化 `IconButton` 48 | 已改 |
## 提交主题(可逐项 `git revert`
从新到旧回退:先本验收记录,再视觉返工。
| 主题 | SHA | 文件 |
|---|---|---|
| 本验收记录 | 本文件当次提交 | `docs/acceptance/b210-android-call-visual-rework.md` |
| 视觉打回返工 | `9bde926` | Token、文案、超时单一来源、通话页布局/按钮/结束态/动效/震动、测试 |
回退示例(新到旧):
```
git revert <本验收提交>
git revert 9bde926
```
回退不影响 `mobile/``account-service/``config/``scripts/`、现网服务、PC 通话与已发布安装包。B-204 原提交 `3832e080` / `d7f7a6ae` / `714794f7` 仍可按原记录回退。
## 环境
- Flutter 3.24.5 / Dart 3.5.4`C:\flutter324`
- Temurin JDK 17.0.20+8
- Android SDK:既有 B-95 工具链(compileSdk 34
- `org.gradle.jvmargs=-Xmx4096M`
## 检查与构建
### 定点测试
```
cd mobile-next
flutter test test/company/rtc_token_mapper_test.dart test/company/call_session_guard_test.dart test/company/call_visual_rework_test.dart
```
16 项通过:换票映射、占线/去重/超时/销毁、60s 单一来源、callBg≠maskBg、32px tabular figures、14px/18px、250ms/1.5s/48·64·96 尺寸。
定点 `flutter analyze` 修改文件:**0 error**。存量 info/warning 未作为本卡阻塞。
### Android(真实构建)
| 构建 | 结果 | 大小 | SHA256 | 产物 |
|---|---|---|---|---|
| debug | 成功 | 109,424,273 B | `415478F0908BB44449C7DA944F07C5214989888C77B7E13295E4D71082197308` | `mobile-next/build/app/outputs/flutter-apk/app-debug.apk` |
| release | 成功 | 53,289,471 B | `7D81C54D2A03920E0989DDCFD5E29C3433DEB4FDB15266DE2913864D6C4FFD1C` | `mobile-next/build/app/outputs/flutter-apk/app-release.apk` |
构建期告警同 B-204Flutter Gradle apply-script 弃用;部分依赖 Kotlin metadata 1.8.0 vs 工程预期 1.6.0。均属上游,debug/release 均通过。APK 内嵌时间戳,哈希不可位级复现。
## 越界比对
相对 `714794f`(本卡开工 HEAD):仅 `mobile-next/` 与本验收文档。
相对 `e26abad``mobile/``account-service/``config/``scripts/` **零改动**。本卡工作区 diff 不含 `pc-client/`
`FeatureFlags.livekitCall` 门控保持:配置不足时入口隐藏。
## 已测 / 未测
已测(替身或构建):
- Token 映射:callBg `#1D2129`、计时器 32px tabular figures、状态字 14px 白 60%、姓名 18px
- 未接超时 60s 单一来源(配置 / Styles / Guard
- 占线、重复信令、超时取消、销毁释放计时器
- Android debug/release 可构建
未测(缺外部条件,禁止写成通过):
- 无有效工号+密码,未登录
- Android ARM64 真机:呼入呼出、听筒/扬声器、静音、后台/锁屏、铃声与每秒震动
- 结束/失败 1.5s 退场与 250ms 滑入滑出的真机观感
- 「未接通话」系统消息在会话里的真机展示
## 风险与回退
- 真机铃声/震动依赖设备振动器与勿扰模式,静态审查不能代替点验
- 结束态 1.5s 内重复回调被 `_ending` 吞掉,属有意防护
- 回退:先 `git revert` 本验收提交,再 `git revert` 视觉返工提交
-37
View File
@@ -1,37 +0,0 @@
# PC 端可改边界封存(docs/acceptance
> 依据架构方案 B-90 的 PC 目录边界。B-185 第二批只改界面层,通信核心仍冻结。
## 不可动(通信核心,冻结)
| 目录/文件 | 内容 | 理由 |
|---|---|---|
| `pc-client/electron/` | 主进程、预加载、SDK 资产与打包核心 | 进程/打包核心。特批仅 `windowManage.ts``minWidth=1080``minHeight=680` |
| `pc-client/src/store/` | 会话 / 联系人 / 用户状态 | 状态唯一来源 |
| `pc-client/src/layout/MainContentWrap.tsx` | SDK 初始化与连接 | SDK 入口 |
| `pc-client/src/pages/chat/queryChat/ChatFooter/useSendMessage.ts` | 消息发送 | 发送核心 |
| `pc-client/src/pages/chat/queryChat/useHistoryMessageList.tsx` | 历史消息 | 历史核心 |
| SDK 版本 | `@openim/electron-client-sdk ^3.8.3-patch.10``@openim/wasm-client-sdk ^3.8.3-patch.10` | 锁定版本 |
## 可改(界面层)
| 目录 | 内容 |
|---|---|
| `pc-client/src/layout/LeftNavBar/` | 导航外观 |
| `pc-client/src/pages/login/**` | 登录窗展示(校验三态、窗内顶栏提示;不改登录接口) |
| `pc-client/src/pages/chat/**` | 会话栏、空会话页、聊天窗**展示**(顶栏/气泡/输入区样式、右键置顶删除、大图预览样式) |
| `pc-client/src/pages/contact/**` | 联系人展示、职务行、部门分组 |
| `pc-client/src/pages/common/AddColleague.tsx` | 添加同事弹窗五态与文案 |
| `pc-client/src/components/**` | 通用 UI 组件 |
| `pc-client/src/styles/` | 统一视觉 Token 唯一来源 |
| `pc-client/src/i18n/resources/` | 界面文案 |
聊天目录中下列文件仍冻结,即使位于 `pages/chat/**`
- `queryChat/ChatFooter/useSendMessage.ts`
- `queryChat/useHistoryMessageList.tsx`
## 明确禁止
- 不建设 PC 语音通话:不提供发起/接听/来电入口;本批已从聊天顶栏去掉「语音通话」。RTC 死代码按 B-90 保留以便回滚。
- 不改 `electron/`(窗口下限两常量除外)、`src/store/`、SDK 初始化、消息发送与历史逻辑。
-44
View File
@@ -1,44 +0,0 @@
# mobile-next SDK 兼容门禁验收记录(docs/acceptance
> 目标:官方 `mobile-next` 基线只把 `flutter_openim_sdk` 切到 `3.8.3+hotfix.12` 后,对接现网
> OpenIM API `http://192.168.200.11:10002` / WS `ws://192.168.200.11:10001`)完成双机冒烟。
> 任一核心门禁失败即停止后续迁移并在本卡汇报;不得以「同属 3.8.3」代替实测。
## 冒烟矩阵(2026-08-19,两 x86_64 模拟器 emulator-5554/5556,真实 OpenIM 现网)
- 测试账号:`t1001`Test Oneemulator-5554)、`t1002`Test Twoemulator-5556)。
- 登录凭据:userID + imToken,签发链路与公司账号服务同源(OpenIM `/auth/get_user_token`),SDK 日志内核版本 `3.8.0`hotfix 内核 3.8.3-patch12)。
| # | 能力 | 方法 | 结果 | 证据 |
|---|---|---|---|---|
| 1 | SDK 初始化 | App 启动 `initSDK(apiAddr, wsAddr, listener)` | 通过 | 双端 `IM SDK Status: connecting → connectionSucceeded → syncStart → syncEnded`GoLog 日志) |
| 2 | 登录(userID+imToken | 双端自动登录 | 通过 | 双端 `login start``login success`82ms/161ms)→ `conn start`ws sendID/tokensdkVersion=3.8.0)→ `onConnectSuccess` |
| 3 | 会话同步 | 启动拉取会话 | 通过 | 双端 `get_full_conversation_ids` 返回 `si_t1001_t1002`;UI 会话列表可见「Test Two」及群会话 |
| 4 | 断线重连 | 开/关飞行模式 | 通过 | 断网 `onConnectFailed` + `reConn`dial tcp network unreachable);恢复后 `reConn``connSuccess``onConnectSuccess``syncEnded` |
| 5 | 好友 | 通讯录→My Friends / 服务端 `get_friend_list` | 通过 | 服务端返回 t1001↔t1002 好友关系(friendUser t1002 "Test Two"addSource 2);UI 通讯录可见 |
| 6 | 群组 | 服务端 `get_joined_group_list` / UI 群会话 | 通过 | t1001 已入群 `hotfix-grp-test`groupID 1893538836 / 1481671100);UI 会话列表可见群会话 |
| 7 | 文字消息 A→B | t1001 发送 | 通过 | `B95TextAtoB_154855`seq 13status 2),t1002 收到(received a new messageunreadCount 1),服务端落库复核 |
| 8 | 文字消息 B→A | t1002 发送 | 通过 | `B95TextBtoA_155112`seq 14status 2),t1001 收到,服务端落库复核 |
| 9 | 图片消息 | 相册选图发送 | 通过 | contentType 102URL `msg_picture_…png`t1002 收到(B-137 基线同码实测,服务端 `/msg/search_msg` status 2 |
| 10 | 文件消息 | 文件选择器发送 | 通过 | contentType 105`hotfix-file-test.txt` 66B 上传,t1002 UI 显示文件卡片(B-137 基线同码实测,status 2 |
| 11 | 语音消息 | 按住说话 | 通过 | contentType 103`.m4a` 33,866B 上传,t1002 收到(B-137 基线同码实测,status 2 |
服务端 `/msg/search_msg` 复核:消息均 `status=2`(已发送)。
## 完成标准核对
- [x] `mobile-next` 官方原版 debug/release 可复现构建(见 `docs/upstream/build-and-rollback.md`,本卡实测 SHA256 与包/签名)。
- [x] SDK hotfix.12 兼容冒烟逐项有「通过」结果与证据(初始化/登录/会话同步/断线重连/好友/群组/文字双向/图片/文件/语音),非版本号相近代替实测。
- [x] 任一核心门禁失败即停止:本阶段全部通过,可进入后续公司功能迁移(第 4 阶段)。
- [x] 旧版 `mobile/``pc-client/``account-service/`、现网服务器与已发布安装包均未改动。
## 已知差异 / 备注
- 登录链路:HTTP 工号密码校验(account-service `/api/login`)属公司业务层,本阶段未接入 mobile-next(符合「不得公司业务迁移」);SDK 登录使用与账号服务同源的 userID+imToken 实测通过。
- hotfix 插件自身 `android/build.gradle` 声明 AGP 8.7.3/compileSdk 35,被应用工程工具链(AGP 7.3.1/compileSdk 34)覆盖,行为以应用工程为准,双构建均通过。
- 语音通话(LiveKit)不在本阶段范围,未测。
- 图片/文件/语音三条在 B-137 基线以相同代码完成实测,本卡按同码基线复核服务端落库记录。
## 回退
`docs/upstream/build-and-rollback.md`SDK 接入提交(`2c9dab1`)改 3 个 `pubspec.yaml` + 1 个 `pubspec.lock` + 1 个实测记录文档,可单独 `git revert`(会一并撤掉该文档)。
@@ -1,18 +0,0 @@
# 视觉规范唯一来源与 Token 入口(docs/acceptance
唯一视觉规范:B-91 附件 `changlian-visual-spec-v2.md`(自发布起替代 v1)。本阶段按 v2 落地 Token,保留 OpenIM 标识,**不做「畅联」换标**。
## 手机端唯一 Token 入口
| 层级 | 路径 | 职责 |
|---|---|---|
| 规范取值(唯一来源) | `mobile-next/lib/company/brand/app_tokens.dart` | 色值/字号/间距/圆角/动效时长,禁止页面硬编码 |
| 官方映射 | `mobile-next/lib/company/brand/apply_official_styles.dart` | 启动时把 `openim_common` `Styles` 静态色对齐到 Token |
| 主题 | `mobile-next/lib/company/brand/app_theme.dart` | `ThemeData`,由 `lib/app.dart` 使用 |
| 内网地址 | `mobile-next/lib/company/config/app_endpoints.dart` | IP/端口唯一入口,页面不得再写死 |
施工铁律:改样式只改 Token 与上述映射,禁止在页面叠加新 CSS/颜色覆盖旧样式。
## PC 端唯一 Token 入口
`pc-client/src/styles/global.scss``:root` CSS 变量为 PC 唯一来源。界面层只改 `docs/acceptance/pc-boundary.md` 列明的目录。
+1 -1
View File
@@ -101,7 +101,7 @@ APK 元数据(aapt badging):package `io.openim.flutter.demo`versionName
回退命令:
```
git revert 8046ec7483d68f67011913da12e5e671479b5ff0
git revert 0a2fbac
```
或直接删除并行目录(不影响现网):
+3 -3
View File
@@ -1,6 +1,6 @@
# mobile-next 通信组件热修接入与核心能力实测记录(B-95/B-137
# mobile-next 通信组件热修接入与核心能力实测记录(B-137)
> 基线:`8046ec7`(官方 openim-flutter-demo 3.8.3-patch.3 固定基线导入)。
> 基线:`0a2fbac`(官方 openim-flutter-demo 3.8.3-patch.3 固定基线导入)+ `44ac7d9`(文档修正)
> 本阶段只做一件事:把 `flutter_openim_sdk` 从 `3.8.3+3` 升级到 `3.8.3+hotfix.12`,锁定依赖、完成 debug/release 双构建、用两个测试账号逐项实测核心通信能力。未开始品牌换壳或公司业务迁移。
## 一、依赖升级与差异记录
@@ -103,7 +103,7 @@ APK 元数据(aapt badgingdebug 与 release 一致):package `io.openim.
本提交只改动 4 个依赖声明文件(`mobile-next/` 内),可单独回退:
```
git revert 2c9dab160eefcaa5a4ad76a24ec2948bffaccc7e
git revert <本提交哈希>
```
或手动把 4 个文件的 `flutter_openim_sdk` 改回 `3.8.3+3``flutter pub get``mobile/``pc-client/``account-service/`、服务器与现网均不受影响。
-58
View File
@@ -1,58 +0,0 @@
# mobile-next 官方固定基线(docs/upstream
## 上游来源与固定版本
| 项 | 值 |
|---|---|
| 上游仓库 | `https://github.com/OpenIMSDK/openim-flutter-demo.git` |
| 固定标签 | `3.8.3-patch.3` |
| 固定提交 | `b3dfdb1e8aaeaaf6f0793e10cadd20d5c184a31f`"Updated IM SDK"2025-03-07 |
| 校验方式 | `git ls-remote --tags` 解析 tag `^{}` 指向即上述提交 |
| 许可证 | OpenIM Open Source LicenseAGPL-3.0 + 附加条款),原文随导入保留在 `mobile-next/LICENSE`;子包 `openim_common/LICENSE``openim_live/LICENSE``local_plugin/flutter_openim_live_alert/LICENSE` |
> 必须固定在该提交,禁止追随上游 `main`。重建基线命令见 `docs/upstream/build-and-rollback.md`。
## 与本卡仓库(tongxunruanjian)的差异
基线在官方原版基础上只做了「必须才能构建/入库」的最小改动,**业务代码零改动**。与上游提交 `b3dfdb1e` 整树比对(`git diff b3dfdb1e 8046ec7:mobile-next`)的实际差异如下。
| 项 | 差异 | 说明 |
|---|---|---|
| `mobile-next/pubspec.lock` | `flutter_openim_sdk` 由锁文件 `3.8.3+2` 对齐为 `3.8.3+3` | `flutter pub get``pubspec.yaml` 声明 `3.8.3+3` 重新解析;上游锁文件落后于 pubspec。属官方产物,已披露。 |
| 6 个 `.dart` 文件 | 仅文件模式/权限位变化,blob 哈希完全相同 | 内容零改动,不属于业务差异。 |
| `.DS_Store` | 上游工作树中的 macOS 垃圾文件未随导入入库 | 删除/忽略,无源码改动。 |
| `mobile-next/.gitignore` | **与上游完全一致** | Flutter 构建产物忽略规则加在了**仓库根** `.gitignore`,不是 `mobile-next/.gitignore`。 |
除上表外,`mobile-next/` 与上游提交 `b3dfdb1e` 的跟踪内容一致(不含 `.git``build/``.dart_tool/` 等非跟踪内容)。**不存在未披露的业务改动。**
## 工具链锁定
| 工具 | 版本 | 位置 |
|---|---|---|
| Flutter | 3.24.5 stablerevision `dec2ee5c1f`engine `a18df97ca5` | `C:\flutter324` |
| Dart | 3.5.4(随 Flutter 3.24.5 分发) | 同上 |
| Java / JDK | Temurin 17.0.20+8 | `toolchain\jdk17` |
| Gradle | 7.6.3`mobile-next/android/gradle/wrapper/gradle-wrapper.properties` 锁定) | 自动下载缓存 |
| Android Gradle Plugin | 7.3.1`mobile-next/android/build.gradle` | - |
| Kotlin | 1.9.23`mobile-next/android/build.gradle` `ext.kotlin_version` | - |
| compileSdk / targetSdk / minSdk | 34 / 33 / 24`mobile-next/android/app/build.gradle` | - |
| build-tools | 36.0.0 | Android SDK |
| 依赖锁文件 | `mobile-next/pubspec.lock` | 已入库 |
### 关键依赖(对照)
| 依赖 | 版本 | 说明 |
|---|---|---|
| `flutter_openim_sdk` | 3.8.3+3(基线自带,仅对照);兼容门禁目标 `3.8.3+hotfix.12`(SDK 接入提交) | 升级差异见 SDK 接入提交与 `docs/acceptance/sdk-compat-gate.md` |
| `livekit_client` | 2.2.5(上游自带,仅对照;正式迁移不采用,保留现有 2.5.0+hotfix.3 | 不随本卡迁移 |
| `flutter_webrtc` / `dart_webrtc` | 0.12.10 / 1.5.2+hotfix.1(上游自带,仅对照) | 不随本卡迁移 |
## 许可证状态与门禁
- 本卡只构建并保留 OpenIM 标识与许可证的官方基线,不做「畅联」换标,不产出/发布试用或生产安装包。
- AGPL-3.0 附加条款(禁止未经授权的商业使用、禁止删除/修改/隐藏 OpenIM LOGO 与版权信息)的内部商用与换标边界,尚未取得书面结论;未确认前不得进入品牌迁移或对外/内部试用发布。
## 回退
- 基线导入提交:`git revert 8046ec7483d68f67011913da12e5e671479b5ff0`(本卡固定基线导入提交),可单独回退,不影响 `mobile/``pc-client/``account-service/` 与现网。
- 或直接删除并行目录:`git rm -r mobile-next`
-65
View File
@@ -1,65 +0,0 @@
# mobile-next 构建与回退方法(docs/upstream
## 前置环境
```bat
set JAVA_HOME=<JDK17 目录>
set ANDROID_HOME=<Android SDK 目录>
set PATH=%JAVA_HOME%\bin;<Flutter SDK 目录>\bin;%PATH%
set GRADLE_OPTS=-Xmx4096M
```
`mobile-next/android/local.properties` 需包含(本机路径,不入库):
```
sdk.dir=<Android SDK 目录,反斜杠转义>
flutter.sdk=<Flutter SDK 目录,反斜杠转义>
```
首次依赖解析(生成 `.dart_tool`,不产生源码改动):
```
cd mobile-next
flutter pub get
```
## 构建命令
```bat
flutter build apk --debug :: 产物 build/app/outputs/flutter-apk/app-debug.apk
flutter build apk --release :: 产物 build/app/outputs/flutter-apk/app-release.apk
```
- release 使用上游自带 keystore `android/app/openim`alias/password 均为 `openim`),保留 OpenIM 标识与签名。
- 注意:上游 `android/app/build.gradle``ndk { abiFilters "arm64-v8a" }` 只打 arm64;如需 x86_64 模拟器联调,临时放开 `"x86_64"` 再还原(本地产物,不入库)。
## 本卡实测结果(2026-08-19,本机锁定环境)
| 构建 | 结果 | 产物 | 大小 | SHA256 |
|---|---|---|---|---|
| debug | 成功 | `app-debug.apk` | 109,454,925 B | `DD7D9700BC1F5830B7495875387948AAD536CA63FDC3EF66B4C0F4D152407B72` |
| release | 成功 | `app-release.apk` | 53,323,411 B | `4969AF4A4800A8E7C0B0C2F50687CB8C765925E4F2B3B7A53762D1D9E49CA604` |
> SHA256 与字节大小仅为**当次构建产物记录**,不是位级可复现承诺。APK 内嵌构建时间戳,换机/换次重跑时哈希必然不同;大小差通常在时间戳/元数据量级。可复现的是工具链、构建命令与包名/签名证书。B-149 复核重跑:debug 109,454,749 B(差 176 B)、release 53,323,375 B(差 36 B),签名证书 SHA-256 与上表一致。
APK 元数据(aapt badging):package `io.openim.flutter.demo`versionCode `180`versionName `3.8.3`minSdk 24 / targetSdk 33 / compileSdk 34。
release 签名(apksigner verify --print-certs):CN=openim,证书 SHA-256 `1f38a82fb3c33be7951fb5d964ef5bd9c6c9bfc084ae1e2a8a40a0d1f44ae1fe`,与官方基线同一 keystore。
构建期告警(不影响产物):Flutter Gradle 插件 apply-script 弃用提示;部分依赖 Kotlin metadata 版本(1.8.x)高于工程 Kotlin1.9.23 预期 1.6.0/1.8.0)的警告,均属上游依赖组合特性,debug/release 均通过。
## 失败停止点
任一核心门禁失败即停止后续迁移,在本卡汇报并附错误证据,不得继续迁移。本阶段 debug/release 均构建成功,无失败步骤。
## 逐提交回退
本卡分支基于回退基线 `fa8584a`,主题提交相互独立,可单独回退:
| 主题 | 提交 | 回退命令 |
|---|---|---|
| 官方基线导入 + 双构建 | 8046ec7483d68f67011913da12e5e671479b5ff0 | `git revert 8046ec7483d68f67011913da12e5e671479b5ff0``git rm -r mobile-next` |
| SDK hotfix.12 接入 + 核心能力实测 | 2c9dab160eefcaa5a4ad76a24ec2948bffaccc7e | `git revert 2c9dab160eefcaa5a4ad76a24ec2948bffaccc7e` |
`2c9dab1` 实际包含 **5 个文件**`mobile-next/pubspec.yaml``openim_common/pubspec.yaml``openim_live/pubspec.yaml``mobile-next/pubspec.lock`,以及实测记录文档 `docs/mobile-next-hotfix-sdk-verification.md``git revert` 会一并撤掉该文档,不只是 4 个依赖文件。
回退任一提交均不影响 `mobile/``pc-client/``account-service/`、现网服务器与已发布安装包。SDK hotfix 提交的手动回退:把上述 3 个 `pubspec.yaml``flutter_openim_sdk` 改回 `3.8.3+3``flutter pub get`(锁文件随之回写;实测记录文档需另行保留或删除)。
+1 -1
View File
@@ -1,3 +1,3 @@
org.gradle.jvmargs=-Xmx4096M
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableJetifier=true
+63 -2
View File
@@ -1,10 +1,11 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
import 'core/controller/im_controller.dart';
import 'company/brand/app_theme.dart';
import 'routes/app_pages.dart';
import 'widgets/app_view.dart';
@@ -35,10 +36,70 @@ class ChatApp extends StatelessWidget {
getPages: AppPages.routes,
initialBinding: InitBinding(),
initialRoute: AppRoutes.splash,
theme: buildCompanyTheme(),
theme: _themeData,
),
);
}
ThemeData get _themeData => ThemeData.light().copyWith(
scaffoldBackgroundColor: Colors.grey.shade50,
canvasColor: Colors.white,
appBarTheme: const AppBarTheme(color: Colors.white),
textSelectionTheme: const TextSelectionThemeData().copyWith(cursorColor: Colors.blue),
checkboxTheme: const CheckboxThemeData().copyWith(
checkColor: WidgetStateProperty.all(Colors.white),
fillColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return Colors.grey;
}
if (states.contains(WidgetState.selected)) {
return Colors.blue;
}
return Colors.white;
}),
side: BorderSide(color: Colors.grey.shade500, width: 1),
),
dialogTheme: const DialogTheme().copyWith(
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
),
textButtonTheme: TextButtonThemeData(
style: ButtonStyle(
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4.0),
),
),
textStyle: WidgetStatePropertyAll(
TextStyle(
fontSize: 16.sp,
color: Colors.black,
),
),
foregroundColor: const WidgetStatePropertyAll(Colors.black),
),
),
progressIndicatorTheme: const ProgressIndicatorThemeData()
.copyWith(color: Colors.white, linearTrackColor: Colors.grey[300], circularTrackColor: Colors.grey[300]),
cupertinoOverrideTheme: CupertinoThemeData(
brightness: Brightness.light,
primaryColor: CupertinoColors.systemBlue,
barBackgroundColor: Colors.white,
applyThemeToAll: true,
textTheme: const CupertinoTextThemeData().copyWith(
navActionTextStyle: TextStyle(color: CupertinoColors.label, fontSize: 17.sp),
actionTextStyle: TextStyle(color: CupertinoColors.systemBlue, fontSize: 17.sp),
textStyle: TextStyle(color: CupertinoColors.label, fontSize: 17.sp),
navLargeTitleTextStyle: TextStyle(color: CupertinoColors.label, fontSize: 20.sp),
navTitleTextStyle: TextStyle(color: CupertinoColors.label, fontSize: 17.sp),
pickerTextStyle: TextStyle(color: CupertinoColors.label, fontSize: 17.sp),
tabLabelTextStyle: TextStyle(color: CupertinoColors.label, fontSize: 17.sp),
dateTimePickerTextStyle: TextStyle(color: CupertinoColors.label, fontSize: 17.sp),
),
),
);
}
class InitBinding extends Bindings {
@@ -1,80 +0,0 @@
import 'package:dio/dio.dart';
import '../config/app_endpoints.dart';
class AuthResult {
final String userID;
final String imToken;
final String nickname;
AuthResult({required this.userID, required this.imToken, required this.nickname});
factory AuthResult.fromJson(Map<String, dynamic> json) => AuthResult(
userID: json['userID']?.toString() ?? '',
imToken: json['imToken']?.toString() ?? '',
nickname: json['nickname']?.toString() ?? '',
);
}
class AuthException implements Exception {
final String message;
final bool network;
AuthException(this.message, {this.network = false});
@override
String toString() => message;
}
/// 公司工号登录。只走账号服务 POST /api/login,不改 OpenIM Server。
class AuthApi {
AuthApi({Dio? dio})
: _dio = dio ??
Dio(BaseOptions(
baseUrl: AppEndpoints.authApiBase,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
));
final Dio _dio;
Map<String, dynamic> _unwrap(dynamic body) {
if (body is Map<String, dynamic>) {
if (body['code'] == 0 && body['data'] is Map) {
return Map<String, dynamic>.from(body['data'] as Map);
}
final msg = body['msg']?.toString() ?? '';
throw AuthException(msg.isNotEmpty ? msg : '工号或密码不正确');
}
throw AuthException('工号或密码不正确');
}
Future<AuthResult> login({
required String staffNo,
required String password,
required int platformID,
}) async {
try {
final resp = await _dio.post('/api/login', data: {
'staffNo': staffNo,
'password': password,
'platformID': platformID,
});
final result = AuthResult.fromJson(_unwrap(resp.data));
if (result.userID.isEmpty || result.imToken.isEmpty) {
throw AuthException('工号或密码不正确');
}
return result;
} on DioException catch (e) {
final data = e.response?.data;
if (data is Map && data['msg'] != null && data['msg'].toString().isNotEmpty) {
throw AuthException(data['msg'].toString());
}
throw AuthException('网络异常,请稍后重试', network: true);
} on AuthException {
rethrow;
} catch (_) {
throw AuthException('网络异常,请稍后重试', network: true);
}
}
}
@@ -1,81 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:openim/core/controller/im_controller.dart';
import 'package:openim/pages/conversation/conversation_logic.dart';
import 'package:openim/routes/app_navigator.dart';
import 'package:openim_common/openim_common.dart';
import 'auth_api.dart';
class CompanyLoginLogic extends GetxController {
final staffCtrl = TextEditingController();
final pwdCtrl = TextEditingController();
final obscure = true.obs;
final submitting = false.obs;
final staffError = RxnString();
final pwdError = RxnString();
final toast = RxnString();
Timer? _toastTimer;
final _auth = AuthApi();
final _im = Get.find<IMController>();
@override
void onClose() {
_toastTimer?.cancel();
staffCtrl.dispose();
pwdCtrl.dispose();
super.onClose();
}
void toggleObscure() => obscure.toggle();
void _showToast(String msg) {
toast.value = msg;
_toastTimer?.cancel();
_toastTimer = Timer(const Duration(seconds: 2), () {
toast.value = null;
});
}
Future<void> submit() async {
if (submitting.value) return;
final staffNo = staffCtrl.text.trim();
final password = pwdCtrl.text;
staffError.value = staffNo.isEmpty ? '请输入工号' : null;
pwdError.value = password.isEmpty ? '请输入密码' : null;
if (staffNo.isEmpty || password.isEmpty) return;
submitting.value = true;
toast.value = null;
try {
final result = await _auth.login(
staffNo: staffNo,
password: password,
platformID: IMUtils.getPlatform(),
);
final cert = LoginCertificate.fromJson({
'userID': result.userID,
'imToken': result.imToken,
'chatToken': result.imToken,
});
await DataSp.putLoginCertificate(cert);
await DataSp.putLoginAccount({'phoneNumber': staffNo, 'loginType': 2});
await DataSp.putLoginType(2);
Logger.print('company login : ${result.userID}');
await _im.login(result.userID, result.imToken);
final conversations = await ConversationLogic.getConversationFirstPage();
Get.find<CacheController>().resetCache();
AppNavigator.startMain(conversations: conversations);
} on AuthException catch (e) {
_showToast(e.network ? '网络异常,请稍后重试' : '工号或密码不正确');
} catch (e, s) {
Logger.print('company login e: $e $s');
_showToast('网络异常,请稍后重试');
} finally {
submitting.value = false;
}
}
}
@@ -1,37 +0,0 @@
import 'package:flutter/material.dart';
import 'app_tokens.dart';
ThemeData buildCompanyTheme() {
return ThemeData(
useMaterial3: true,
fontFamily: 'Noto Sans SC',
primaryColor: AppColors.primary,
scaffoldBackgroundColor: AppColors.pageBg,
colorScheme: const ColorScheme.light(
primary: AppColors.primary,
onPrimary: AppColors.pageBg,
error: AppColors.danger,
surface: AppColors.pageBg,
onSurface: AppColors.textPrimary,
),
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.pageBg,
foregroundColor: AppColors.textPrimary,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
titleTextStyle: TextStyle(
color: AppColors.textPrimary,
fontSize: AppFont.title,
fontWeight: FontWeight.w600,
),
),
dividerTheme: const DividerThemeData(
color: AppColors.divider,
thickness: 0.5,
space: 0,
),
splashFactory: NoSplash.splashFactory,
);
}
@@ -1,123 +0,0 @@
import 'package:flutter/material.dart';
/// 双端唯一视觉 Token。取值只来自 B-91 v2 规范第 2 节,禁止在页面内硬编码色值/字号/间距/圆角。
///
/// 官方 widget 通过 [openim_common] `Styles` 映射到本表(见 apply_official_styles.dart),
/// 公司自建页面直接引用本文件。本阶段保留 OpenIM 标识,不做「畅联」换标。
class AppColors {
AppColors._();
static const Color primary = Color(0xFF3B87F5);
static const Color primaryActive = Color(0xFFEBF3FE);
static const Color primaryPressed = Color(0xFF2E75E0);
static const Color avatarGray = Color(0xFF8593A8);
static const Color pageBg = Color(0xFFFFFFFF);
static const Color chatBg = Color(0xFFF5F6F8);
static const Color searchBg = Color(0xFFF2F3F5);
static const Color bubbleMine = Color(0xFFD6E7FC);
static const Color bubbleOther = Color(0xFFFFFFFF);
static const Color textPrimary = Color(0xFF1D2129);
static const Color textSecondary = Color(0xFF86909C);
static const Color danger = Color(0xFFF53F3F);
static const Color divider = Color(0xFFEEEEEE);
static const Color bannerBg = Color(0xFF3A3F47);
static const Color maskBg = Color(0xFF000000);
static const Color success = Color(0xFF00B42A);
static const Color warning = Color(0xFFFF7D00);
/// 通话页纯色深底。与 [textPrimary] 同值(`#1D2129`),不得改用 [maskBg]。
static const Color callBg = textPrimary;
}
class AppFont {
AppFont._();
static const double small = 12;
static const double sub = 14;
static const double body = 16;
static const double title = 18;
/// 语音通话计时器,B-91 §2 唯一字号例外。
static const double callTimer = 32;
}
class AppGap {
AppGap._();
static const double x1 = 4;
static const double x2 = 8;
static const double x3 = 12;
static const double x4 = 16;
static const double x6 = 24;
static const double x8 = 32;
static const double x12 = 48;
/// 来电拒绝/接听间距,B-91 §4.10。
static const double x24 = 96;
}
class AppRadius {
AppRadius._();
static const double control = 8;
static const double modal = 12;
static const double bubble = 8;
static const double logo = 16;
static double avatar(double size) => size * 0.18;
}
class AppIconSize {
AppIconSize._();
static const double sm = 20;
static const double md = 22;
static const double lg = 24;
}
/// 安卓语音通话页尺寸,取值只来自 B-91 §4.10。
class AppCallSize {
AppCallSize._();
static const double nameTop = 96;
static const double avatar = 96;
static const double primaryBtn = 64;
static const double secondaryBtn = 48;
static const double incomingGap = AppGap.x24;
static const double minimizeHit = 48;
}
class AppDuration {
AppDuration._();
static const hover = Duration(milliseconds: 100);
static const overlay = Duration(milliseconds: 150);
static const overlaySwitch = Duration(milliseconds: 100);
static const route = Duration(milliseconds: 200);
static const toastOut = Duration(milliseconds: 200);
static const toastStay = Duration(seconds: 2);
static const bannerCollapse = Duration(milliseconds: 300);
static const picture = Duration(milliseconds: 200);
static const callPage = Duration(milliseconds: 250);
static const callEndHold = Duration(milliseconds: 1500);
static const callInviteTimeout = Duration(seconds: 60);
static const callVibrate = Duration(seconds: 1);
}
class AppMotion {
AppMotion._();
static bool reduce(BuildContext context) =>
MediaQuery.maybeOf(context)?.disableAnimations ?? false;
static Duration of(BuildContext context, Duration normal) =>
reduce(context) ? Duration.zero : normal;
}
class AppShadows {
AppShadows._();
static const List<BoxShadow> modal = [
BoxShadow(color: Color(0x14000000), offset: Offset(0, 4), blurRadius: 16),
];
}
@@ -1,163 +0,0 @@
import 'package:flutter/material.dart';
import 'package:openim_common/openim_common.dart';
import 'app_tokens.dart';
/// 把官方 `Styles` 静态色值对齐到 v2 Token。
///
/// 官方页面继续读 `Styles.*`,公司页面读 `AppColors.*`。只改这一处映射,
/// 禁止在页面上再叠一套颜色覆盖。必须在首屏构建前调用。
void applyOfficialStylesFromTokens() {
Styles.c_0089FF = AppColors.primary;
Styles.c_0C1C33 = AppColors.textPrimary;
Styles.c_8E9AB0 = AppColors.textSecondary;
Styles.c_E8EAEF = AppColors.divider;
Styles.c_FF381F = AppColors.danger;
Styles.c_CCE7FE = AppColors.bubbleMine;
Styles.c_F4F5F7 = AppColors.chatBg;
Styles.c_F8F9FA = AppColors.pageBg;
Styles.c_F0F2F6 = AppColors.chatBg;
Styles.c_F2F3F5 = AppColors.searchBg;
Styles.c_F2F8FF = AppColors.primaryActive;
Styles.durationOverlay = AppDuration.overlay;
Styles.durationOverlaySwitch = AppDuration.overlaySwitch;
Styles.callPageMotion = AppDuration.callPage;
Styles.callEndHold = AppDuration.callEndHold;
Styles.callInviteTimeout = AppDuration.callInviteTimeout;
Styles.callVibrate = AppDuration.callVibrate;
Styles.reduceMotion = AppMotion.reduce;
Styles.motionOf = AppMotion.of;
Styles.c_18E875 = AppColors.success;
Styles.c_FFB300 = AppColors.warning;
Styles.c_6085B1 = AppColors.avatarGray;
Styles.c_000000 = AppColors.maskBg;
Styles.c_000000_opacity70 = AppColors.maskBg.withOpacity(.7);
Styles.c_callBg = AppColors.callBg;
Styles.c_callSecondaryBg = AppColors.pageBg.withOpacity(.12);
Styles.callNameTop = AppCallSize.nameTop;
Styles.callAvatar = AppCallSize.avatar;
Styles.callPrimaryBtn = AppCallSize.primaryBtn;
Styles.callSecondaryBtn = AppCallSize.secondaryBtn;
Styles.callIncomingGap = AppCallSize.incomingGap;
Styles.callMinimizeHit = AppCallSize.minimizeHit;
Styles.callIconSize = AppIconSize.lg;
Styles.ts_FFFFFF_20sp_medium = TextStyle(
color: AppColors.pageBg,
fontSize: AppFont.title,
fontWeight: FontWeight.w500,
);
Styles.ts_FFFFFF_opacity70_17sp = TextStyle(
color: AppColors.pageBg.withOpacity(.7),
fontSize: AppFont.body,
);
Styles.ts_FFFFFF_opacity70_14sp = TextStyle(
color: AppColors.pageBg.withOpacity(.7),
fontSize: AppFont.sub,
);
Styles.ts_callName = TextStyle(
color: AppColors.pageBg,
fontSize: AppFont.title,
fontWeight: FontWeight.w500,
);
Styles.ts_callStatus = TextStyle(
color: AppColors.pageBg.withOpacity(.6),
fontSize: AppFont.sub,
);
Styles.ts_callTimer = TextStyle(
color: AppColors.pageBg,
fontSize: AppFont.callTimer,
fontFeatures: const [FontFeature.tabularFigures()],
);
Styles.c_0089FF_opacity10 = AppColors.primary.withOpacity(.1);
Styles.c_0089FF_opacity20 = AppColors.primary.withOpacity(.2);
Styles.c_0089FF_opacity50 = AppColors.primary.withOpacity(.5);
Styles.c_FF381F_opacity10 = AppColors.danger.withOpacity(.1);
Styles.c_8E9AB0_opacity13 = AppColors.textSecondary.withOpacity(.13);
Styles.c_8E9AB0_opacity15 = AppColors.textSecondary.withOpacity(.15);
Styles.c_8E9AB0_opacity16 = AppColors.textSecondary.withOpacity(.16);
Styles.c_8E9AB0_opacity30 = AppColors.textSecondary.withOpacity(.3);
Styles.c_8E9AB0_opacity50 = AppColors.textSecondary.withOpacity(.5);
Styles.c_0C1C33_opacity30 = AppColors.textPrimary.withOpacity(.3);
Styles.c_0C1C33_opacity60 = AppColors.textPrimary.withOpacity(.6);
Styles.c_0C1C33_opacity85 = AppColors.textPrimary.withOpacity(.85);
Styles.c_0C1C33_opacity80 = AppColors.textPrimary.withOpacity(.8);
Styles.ts_0C1C33_17sp = TextStyle(
color: AppColors.textPrimary,
fontSize: AppFont.body,
);
Styles.ts_0C1C33_17sp_medium = TextStyle(
color: AppColors.textPrimary,
fontSize: AppFont.body,
fontWeight: FontWeight.w500,
);
Styles.ts_0C1C33_17sp_semibold = TextStyle(
color: AppColors.textPrimary,
fontSize: AppFont.body,
fontWeight: FontWeight.w600,
);
Styles.ts_0C1C33_20sp = TextStyle(
color: AppColors.textPrimary,
fontSize: AppFont.title,
);
Styles.ts_0C1C33_20sp_medium = TextStyle(
color: AppColors.textPrimary,
fontSize: AppFont.title,
fontWeight: FontWeight.w500,
);
Styles.ts_0C1C33_20sp_semibold = TextStyle(
color: AppColors.textPrimary,
fontSize: AppFont.title,
fontWeight: FontWeight.w600,
);
Styles.ts_0089FF_10sp_semibold = TextStyle(
color: AppColors.primary,
fontSize: AppFont.small,
fontWeight: FontWeight.w600,
);
Styles.ts_0089FF_10sp = TextStyle(
color: AppColors.primary,
fontSize: AppFont.small,
);
Styles.ts_0089FF_17sp = TextStyle(
color: AppColors.primary,
fontSize: AppFont.body,
);
Styles.ts_0089FF_17sp_semibold = TextStyle(
color: AppColors.primary,
fontSize: AppFont.body,
fontWeight: FontWeight.w600,
);
Styles.ts_0089FF_17sp_medium = TextStyle(
color: AppColors.primary,
fontSize: AppFont.body,
fontWeight: FontWeight.w500,
);
Styles.ts_FF381F_17sp = TextStyle(
color: AppColors.danger,
fontSize: AppFont.body,
);
Styles.ts_FF381F_12sp = TextStyle(
color: AppColors.danger,
fontSize: AppFont.small,
);
Styles.ts_FF381F_10sp = TextStyle(
color: AppColors.danger,
fontSize: AppFont.small,
);
Styles.ts_FFB300_12sp = TextStyle(
color: AppColors.warning,
fontSize: AppFont.small,
);
Styles.ts_6085B1_17sp_medium = TextStyle(
color: AppColors.avatarGray,
fontSize: AppFont.body,
fontWeight: FontWeight.w500,
);
Styles.ts_6085B1_17sp = TextStyle(
color: AppColors.avatarGray,
fontSize: AppFont.body,
);
}
@@ -1,36 +0,0 @@
/// 内网地址唯一入口。页面与 SDK 初始化不得再硬编码 IP/端口。
///
/// 仅本地联调:`--dart-define=AUTH_HOST=127.0.0.1` 可覆盖账号服务地址。
class AppEndpoints {
AppEndpoints._();
static const String host = String.fromEnvironment(
'SERVER_HOST',
defaultValue: '192.168.200.11',
);
static const String authHost = String.fromEnvironment(
'AUTH_HOST',
defaultValue: host,
);
static const int apiPort = 10002;
static const int wsPort = 10001;
static const int authPort = 10010;
static const int livekitPort = 17880;
static const String apiAddr = 'http://$host:$apiPort';
static const String wsAddr = 'ws://$host:$wsPort';
static const String authApiBase = 'http://$authHost:$authPort';
static const String livekitUrl = 'ws://$host:$livekitPort';
/// 写入官方 DataSp 的服务器配置键,供 Config.imApiUrl / imWsUrl 读取。
static Map<String, String> toServerConfig() => {
'serverIP': host,
'apiUrl': apiAddr,
'wsUrl': wsAddr,
'authUrl': authApiBase,
'chatTokenUrl': authApiBase,
'logLevel': '5',
};
}
@@ -1,15 +0,0 @@
import 'package:openim_common/openim_common.dart';
import '../brand/apply_official_styles.dart';
import 'app_endpoints.dart';
/// 启动时注入内网地址与官方 Styles→Token 映射。不改 SDK 核心。
class CompanyBootstrap {
CompanyBootstrap._();
static Future<void> install() async {
applyOfficialStylesFromTokens();
await DataSp.putServerConfig(AppEndpoints.toServerConfig());
ApiService().setBaseUrl(AppEndpoints.host);
}
}
@@ -1,73 +0,0 @@
import 'package:dio/dio.dart';
import 'package:openim_common/openim_common.dart';
import '../auth/auth_api.dart';
import '../config/app_endpoints.dart';
class DirectoryUser {
final String userID;
final String nickname;
final String department;
final String title;
final String faceURL;
DirectoryUser({
required this.userID,
required this.nickname,
required this.department,
required this.title,
required this.faceURL,
});
factory DirectoryUser.fromJson(Map<String, dynamic> json) => DirectoryUser(
userID: json['userID']?.toString() ?? '',
nickname: json['nickname']?.toString() ?? '',
department: json['department']?.toString() ?? '',
title: json['title']?.toString() ?? '',
faceURL: json['faceURL']?.toString() ?? '',
);
}
/// 员工目录。数据来自账号服务 GET /api/directory,发起好友仍走官方 SDK。
class DirectoryApi {
DirectoryApi({Dio? dio})
: _dio = dio ??
Dio(BaseOptions(
baseUrl: AppEndpoints.authApiBase,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
));
final Dio _dio;
Future<List<DirectoryUser>> search({String keyword = '', int limit = 20}) async {
final token = DataSp.imToken;
if (token == null || token.isEmpty) {
throw AuthException('登录已过期,请重新登录');
}
try {
final resp = await _dio.get(
'/api/directory',
queryParameters: {'keyword': keyword, 'limit': limit},
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
final body = resp.data;
if (body is Map && body['code'] == 0 && body['data'] is Map) {
final items = (body['data']['items'] as List?) ?? const [];
return items
.whereType<Map>()
.map((e) => DirectoryUser.fromJson(Map<String, dynamic>.from(e)))
.where((e) => e.userID.isNotEmpty)
.toList();
}
final msg = body is Map ? body['msg']?.toString() : null;
throw AuthException((msg != null && msg.isNotEmpty) ? msg : '员工目录暂不可用');
} on DioException catch (e) {
final data = e.response?.data;
if (data is Map && data['msg'] != null && data['msg'].toString().isNotEmpty) {
throw AuthException(data['msg'].toString());
}
throw AuthException('网络异常,请稍后重试', network: true);
}
}
}
@@ -1,21 +0,0 @@
import 'rtc/livekit_call_config.dart';
/// 公司功能开关。关闭项直接不展示入口,不扩范围外功能。
class FeatureFlags {
FeatureFlags._();
/// 只用工号+密码,关闭官方手机号/邮箱/验证码登录。
static const bool staffLoginOnly = true;
/// 关闭官方注册、找回密码。
static const bool hideRegisterAndReset = true;
/// 关闭发现页 / 工作台 Tab。
static const bool hideDiscover = true;
/// 品牌迁移未取得 AGPL 书面结论前保持 false:保留 OpenIM 标识。
static const bool brandMigration = false;
/// 一对一语音通话:配置齐全且薄适配落地后才打开入口。
static bool get livekitCall => LiveKitCallConfig.enabled;
}
@@ -1,13 +0,0 @@
import 'package:openim_common/openim_common.dart';
import 'package:permission_handler/permission_handler.dart';
class CallPermissions {
CallPermissions._();
static Future<bool> ensureMicrophone() async {
final status = await Permission.microphone.request();
if (status.isGranted) return true;
IMViews.showToast('需要麦克风权限才能通话');
return false;
}
}
@@ -1,71 +0,0 @@
import 'dart:async';
import '../brand/app_tokens.dart';
/// 一对一语音会话防护:占线、超时、重复信令、销毁时释放计时器。
/// 不自建第二套信令,只约束官方 OpenIMLive 的进出场。
enum VoiceCallPhase { idle, outgoing, incoming, inCall }
class CallSessionGuard {
CallSessionGuard({this.inviteTimeout = AppDuration.callInviteTimeout});
final Duration inviteTimeout;
VoiceCallPhase phase = VoiceCallPhase.idle;
String? roomID;
void Function()? onTimeout;
final Set<String> _seenKeys = <String>{};
Timer? _timeout;
bool get isBusy => phase != VoiceCallPhase.idle;
bool acceptSignaling({required int customType, required String roomID}) {
if (roomID.isEmpty) return false;
return _seenKeys.add('$customType:$roomID');
}
String? startOutgoing(String roomID) {
if (isBusy) return 'busy';
this.roomID = roomID;
phase = VoiceCallPhase.outgoing;
_armTimeout();
return null;
}
String? startIncoming(String roomID) {
if (isBusy) return 'busy';
this.roomID = roomID;
phase = VoiceCallPhase.incoming;
_armTimeout();
return null;
}
void markConnected() {
if (!isBusy) return;
_timeout?.cancel();
_timeout = null;
phase = VoiceCallPhase.inCall;
}
bool sameRoom(String? other) =>
other != null && other.isNotEmpty && other == roomID;
void dispose() {
_timeout?.cancel();
_timeout = null;
_seenKeys.clear();
phase = VoiceCallPhase.idle;
roomID = null;
onTimeout = null;
}
void _armTimeout() {
_timeout?.cancel();
_timeout = Timer(inviteTimeout, () {
if (phase == VoiceCallPhase.outgoing || phase == VoiceCallPhase.incoming) {
onTimeout?.call();
}
});
}
}
@@ -1,23 +0,0 @@
import '../brand/app_tokens.dart';
import '../config/app_endpoints.dart';
/// Android 一对一语音通话配置门。入口只在配置齐全且本卡已落地时打开。
class LiveKitCallConfig {
LiveKitCallConfig._();
/// 公司账号服务换票路径,对应 account-service `POST /api/rtc_token`。
static const String rtcTokenPath = '/api/rtc_token';
/// 未接超时,唯一业务来源;界面层经 `Styles.callInviteTimeout` 对齐同一 Token。
static const Duration inviteTimeout = AppDuration.callInviteTimeout;
/// 薄适配与构建验证通过后为 true;配置不齐时 [enabled] 仍为 false。
static const bool shipped = true;
static bool get endpointsReady =>
AppEndpoints.livekitUrl.startsWith('ws://') &&
AppEndpoints.authApiBase.startsWith('http') &&
rtcTokenPath == '/api/rtc_token';
static bool get enabled => shipped && endpointsReady;
}
@@ -1,61 +0,0 @@
import 'package:dio/dio.dart';
import 'package:openim_common/openim_common.dart';
import '../auth/auth_api.dart';
import '../config/app_endpoints.dart';
import 'livekit_call_config.dart';
import 'rtc_token_mapper.dart';
/// 公司账号服务换 LiveKit 进房 token。不改官方 SDK 核心,也不改现网接口。
class RtcTokenApi {
RtcTokenApi({Dio? dio})
: _dio = dio ??
Dio(BaseOptions(
baseUrl: AppEndpoints.authApiBase,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
));
final Dio _dio;
Future<SignalingCertificate> getToken({
required String room,
required String identity,
required String authToken,
}) async {
if (room.isEmpty || identity.isEmpty) {
throw AuthException('通话数据不完整');
}
if (authToken.isEmpty) {
throw AuthException('登录状态已失效,请重新登录');
}
try {
final resp = await _dio.post(
LiveKitCallConfig.rtcTokenPath,
data: {'room': room, 'identity': identity},
options: Options(headers: {'Authorization': 'Bearer $authToken'}),
);
final cred = RtcTokenMapper.parse(
resp.data,
fallbackLiveUrl: AppEndpoints.livekitUrl,
);
return SignalingCertificate(
token: cred.token,
roomID: room,
liveURL: cred.liveURL,
);
} on DioException catch (e) {
final data = e.response?.data;
if (data is Map && data['msg'] != null && data['msg'].toString().isNotEmpty) {
throw AuthException(data['msg'].toString());
}
throw AuthException('连不上语音服务,请检查网络', network: true);
} on AuthException {
rethrow;
} on FormatException catch (e) {
throw AuthException(e.message);
} catch (_) {
throw AuthException('发起通话失败,请稍后再试');
}
}
}
@@ -1,42 +0,0 @@
/// 把账号服务 `/api/rtc_token` 的响应收成进房凭证。
///
/// 现网成功体:`{ code: 0, data: { token } }`,可选 `liveURL` / `serverUrl`。
/// 缺少直播地址时由调用方填 [fallbackLiveUrl],不得改服务端。
class CompanyRtcCredential {
const CompanyRtcCredential({required this.token, required this.liveURL});
final String token;
final String liveURL;
}
class RtcTokenMapper {
RtcTokenMapper._();
static CompanyRtcCredential parse(
dynamic body, {
required String fallbackLiveUrl,
}) {
if (body is! Map) {
throw const FormatException('语音通话凭证格式不正确');
}
final map = Map<String, dynamic>.from(body);
final code = map['code'];
if (code != null && code != 0) {
final msg = map['msg']?.toString() ?? '';
throw FormatException(msg.isNotEmpty ? msg : '获取通话凭证失败');
}
final data = map['data'];
final payload = data is Map ? Map<String, dynamic>.from(data) : map;
final token = payload['token']?.toString() ?? '';
if (token.isEmpty) {
throw const FormatException('服务器未返回通话凭证');
}
final live = payload['liveURL']?.toString() ??
payload['serverUrl']?.toString() ??
'';
return CompanyRtcCredential(
token: token,
liveURL: live.isNotEmpty ? live : fallbackLiveUrl,
);
}
}
@@ -1,247 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
import '../auth/auth_api.dart';
import '../brand/app_tokens.dart';
import '../directory/directory_api.dart';
import 'state_views.dart';
class AddColleagueLogic extends GetxController {
final staffCtrl = TextEditingController();
final msgCtrl = TextEditingController(text: '你好,我想加你为同事');
final sending = false.obs;
final staffError = RxnString();
final directoryHint = RxnString();
final loading = true.obs;
final loadFailed = false.obs;
final _directory = DirectoryApi();
Timer? _lookupDebounce;
@override
void onReady() {
reload();
super.onReady();
}
@override
void onClose() {
_lookupDebounce?.cancel();
staffCtrl.dispose();
msgCtrl.dispose();
super.onClose();
}
void onStaffChanged(String _) {
staffError.value = null;
_lookupDebounce?.cancel();
_lookupDebounce = Timer(const Duration(milliseconds: 300), lookupDirectory);
}
Future<void> reload() async {
loading.value = true;
loadFailed.value = false;
try {
await _directory.search(keyword: '', limit: 1);
} catch (e) {
loadFailed.value = true;
} finally {
loading.value = false;
}
}
Future<void> lookupDirectory() async {
final id = staffCtrl.text.trim();
if (id.isEmpty) {
directoryHint.value = null;
return;
}
try {
final items = await _directory.search(keyword: id, limit: 5);
final hit = items.where((e) => e.userID == id).toList();
if (hit.isNotEmpty) {
directoryHint.value = hit.first.nickname;
} else if (items.isNotEmpty) {
directoryHint.value =
items.map((e) => '${e.nickname}${e.userID}').join('');
} else {
directoryHint.value = null;
}
} catch (_) {
directoryHint.value = null;
}
}
Future<void> send() async {
if (sending.value) return;
final id = staffCtrl.text.trim();
if (id.isEmpty) {
staffError.value = '请输入对方工号';
return;
}
staffError.value = null;
sending.value = true;
try {
await OpenIM.iMManager.friendshipManager.addFriend(
userID: id,
reason:
msgCtrl.text.trim().isEmpty ? '你好,我想加你为同事' : msgCtrl.text.trim(),
);
IMViews.showToast('申请已发送,等对方通过');
Get.back();
} on AuthException catch (e) {
IMViews.showToast(e.message);
} catch (_) {
IMViews.showToast('发送失败,请确认工号正确、网络正常');
} finally {
sending.value = false;
}
}
}
class AddColleaguePage extends StatelessWidget {
const AddColleaguePage({super.key});
@override
Widget build(BuildContext context) {
final logic = Get.find<AddColleagueLogic>();
return Scaffold(
backgroundColor: AppColors.pageBg,
appBar: AppBar(
title: const Text('添加同事'),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: AppIconSize.md),
onPressed: Get.back,
),
),
body: Obx(() {
if (logic.loading.value) {
return const CompanyLoadingView();
}
if (logic.loadFailed.value) {
return CompanyFailView(onRetry: logic.reload);
}
return Padding(
padding: const EdgeInsets.all(AppGap.x4),
child: Column(
children: [
_box(
controller: logic.staffCtrl,
hint: '请输入对方工号',
height: 48,
error: logic.staffError.value,
enabled: !logic.sending.value,
onChanged: logic.onStaffChanged,
),
Obx(() {
final hint = logic.directoryHint.value;
if (hint == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: AppGap.x2),
child: Align(
alignment: Alignment.centerLeft,
child: Text(hint,
style: const TextStyle(
fontSize: AppFont.sub,
color: AppColors.textSecondary)),
),
);
}),
const SizedBox(height: AppGap.x3),
_box(
controller: logic.msgCtrl,
hint: '你好,我想加你为同事',
height: 96,
maxLines: 4,
enabled: !logic.sending.value,
),
const Spacer(),
Obx(() {
final busy = logic.sending.value;
return SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
onPressed: busy ? null : logic.send,
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
disabledBackgroundColor:
AppColors.primary.withOpacity(0.4),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(AppRadius.control)),
),
child: busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Text('发送申请',
style: TextStyle(
fontSize: AppFont.body, color: Colors.white)),
),
);
}),
const SizedBox(height: AppGap.x4),
],
),
);
}),
);
}
Widget _box({
required TextEditingController controller,
required String hint,
required double height,
int maxLines = 1,
String? error,
bool enabled = true,
ValueChanged<String>? onChanged,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: height,
padding: const EdgeInsets.symmetric(
horizontal: AppGap.x3, vertical: AppGap.x2),
decoration: BoxDecoration(
color: AppColors.searchBg,
borderRadius: BorderRadius.circular(AppRadius.control),
border: Border.all(
color: error != null ? AppColors.danger : Colors.transparent),
),
child: TextField(
controller: controller,
maxLines: maxLines,
enabled: enabled,
onChanged: onChanged,
style: const TextStyle(
fontSize: AppFont.body, color: AppColors.textPrimary),
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: hint,
hintStyle: const TextStyle(
fontSize: AppFont.body, color: AppColors.textSecondary),
),
),
),
if (error != null)
Padding(
padding: const EdgeInsets.only(top: AppGap.x1),
child: Text(error,
style: const TextStyle(
fontSize: AppFont.small, color: AppColors.danger)),
),
],
);
}
}
@@ -1,201 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
import '../auth/company_login_logic.dart';
import '../brand/app_tokens.dart';
import '../feature_flags.dart';
import 'state_views.dart';
/// 登录页第一批:v2 布局 + Token,保留 OpenIM 标识,不做畅联换标。
class CompanyLoginPage extends StatelessWidget {
const CompanyLoginPage({super.key});
@override
Widget build(BuildContext context) {
final logic = Get.find<CompanyLoginLogic>();
return Scaffold(
backgroundColor: AppColors.pageBg,
body: Stack(
children: [
SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: AppGap.x8),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(
child: Column(
children: [
const Spacer(flex: 2),
_logo(),
const SizedBox(height: AppGap.x4),
Text(
FeatureFlags.brandMigration ? '畅联' : 'OpenIM',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: AppGap.x1),
const Text(
'企业内部通讯',
style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary),
),
const SizedBox(height: AppGap.x8),
Obx(() => _field(
controller: logic.staffCtrl,
hint: '请输入工号',
icon: Icons.person_outline,
error: logic.staffError.value,
enabled: !logic.submitting.value,
)),
const SizedBox(height: AppGap.x3),
Obx(() => _field(
controller: logic.pwdCtrl,
hint: '请输入密码',
icon: Icons.lock_outline,
obscure: logic.obscure.value,
onToggleObscure: logic.toggleObscure,
error: logic.pwdError.value,
enabled: !logic.submitting.value,
onSubmitted: (_) => logic.submit(),
)),
const SizedBox(height: AppGap.x4),
Obx(() => _loginButton(logic)),
const SizedBox(height: AppGap.x3),
if (!FeatureFlags.hideRegisterAndReset)
const SizedBox.shrink(),
const Text(
'登录即代表同意内部使用规范',
style: TextStyle(
fontSize: AppFont.small,
color: AppColors.textSecondary),
),
const Spacer(flex: 3),
],
),
),
),
);
},
),
),
Obx(() => CompanyToastBanner(message: logic.toast.value)),
],
),
);
}
Widget _logo() {
return ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.logo),
child: Container(
width: 72,
height: 72,
color: AppColors.primary,
alignment: Alignment.center,
child: ImageRes.loginLogo.toImage
..width = 48
..height = 48,
),
);
}
Widget _field({
required TextEditingController controller,
required String hint,
required IconData icon,
String? error,
bool obscure = false,
VoidCallback? onToggleObscure,
bool enabled = true,
ValueChanged<String>? onSubmitted,
}) {
final borderColor = error != null ? AppColors.danger : Colors.transparent;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 48,
decoration: BoxDecoration(
color: AppColors.searchBg,
borderRadius: BorderRadius.circular(AppRadius.control),
border: Border.all(color: borderColor, width: error != null ? 1 : 0),
),
padding: const EdgeInsets.symmetric(horizontal: AppGap.x3),
child: Row(
children: [
Icon(icon, size: AppIconSize.sm, color: AppColors.textSecondary),
const SizedBox(width: AppGap.x2),
Expanded(
child: TextField(
controller: controller,
enabled: enabled,
obscureText: obscure,
onSubmitted: onSubmitted,
inputFormatters: [LengthLimitingTextInputFormatter(32)],
style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary),
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: hint,
hintStyle: const TextStyle(fontSize: AppFont.body, color: AppColors.textSecondary),
),
),
),
if (onToggleObscure != null)
IconButton(
onPressed: onToggleObscure,
icon: Icon(
obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined,
size: AppIconSize.sm,
color: AppColors.textSecondary,
),
),
],
),
),
if (error != null)
Padding(
padding: const EdgeInsets.only(top: AppGap.x1, left: AppGap.x1),
child: Text(error, style: const TextStyle(fontSize: AppFont.small, color: AppColors.danger)),
),
],
);
}
Widget _loginButton(CompanyLoginLogic logic) {
final busy = logic.submitting.value;
return SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
onPressed: busy ? null : logic.submit,
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
disabledBackgroundColor: AppColors.primary.withOpacity(0.4),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.control)),
),
child: busy
? const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
),
SizedBox(width: AppGap.x2),
Text('登录中', style: TextStyle(fontSize: AppFont.body, color: Colors.white)),
],
)
: const Text('登 录', style: TextStyle(fontSize: AppFont.body, color: Colors.white)),
),
);
}
}
-270
View File
@@ -1,270 +0,0 @@
import 'package:flutter/material.dart';
import '../brand/app_tokens.dart';
class CompanyLoadingView extends StatelessWidget {
const CompanyLoadingView({super.key, this.label = '正在加载'});
final String label;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2, color: AppColors.primary),
),
const SizedBox(height: AppGap.x3),
Text(label,
style: const TextStyle(
fontSize: AppFont.sub, color: AppColors.textSecondary)),
],
),
);
}
}
/// 规范 3.1:空白用 64px 线性图标;失败用「对话气泡 + 感叹号」组合线性图标。
class CompanyStatusIcon extends StatelessWidget {
const CompanyStatusIcon.chat({super.key})
: icon = Icons.chat_bubble_outline,
fail = false;
const CompanyStatusIcon.person({super.key})
: icon = Icons.person_outline,
fail = false;
const CompanyStatusIcon.fail({super.key})
: icon = Icons.chat_bubble_outline,
fail = true;
final IconData icon;
final bool fail;
@override
Widget build(BuildContext context) {
if (!fail) {
return Icon(icon, size: 64, color: AppColors.divider);
}
return SizedBox(
width: 64,
height: 64,
child: Stack(
alignment: Alignment.center,
children: [
Icon(icon, size: 64, color: AppColors.divider),
const Positioned(
right: 0,
bottom: 0,
child: Icon(Icons.error_outline, size: 22, color: AppColors.divider),
),
],
),
);
}
}
class CompanyEmptyView extends StatelessWidget {
const CompanyEmptyView({
super.key,
required this.icon,
required this.title,
required this.subtitle,
});
final IconData icon;
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppGap.x8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 64, color: AppColors.divider),
const SizedBox(height: AppGap.x4),
Text(title,
style: const TextStyle(
fontSize: AppFont.body, color: AppColors.textPrimary)),
const SizedBox(height: AppGap.x1),
Text(
subtitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: AppFont.sub, color: AppColors.textSecondary),
),
],
),
),
);
}
}
class CompanyFailView extends StatelessWidget {
const CompanyFailView({super.key, required this.onRetry, this.title = '加载失败'});
final VoidCallback onRetry;
final String title;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppGap.x8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CompanyStatusIcon.fail(),
const SizedBox(height: AppGap.x4),
Text(title,
style: const TextStyle(
fontSize: AppFont.body, color: AppColors.textPrimary)),
const SizedBox(height: AppGap.x1),
const Text(
'检查一下网络,再点重试',
style: TextStyle(
fontSize: AppFont.sub, color: AppColors.textSecondary),
),
const SizedBox(height: AppGap.x4),
OutlinedButton(
onPressed: onRetry,
style: OutlinedButton.styleFrom(
minimumSize: const Size(96, 36),
side: const BorderSide(
color: AppColors.textSecondary, width: 0.5),
foregroundColor: AppColors.textPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.control)),
),
child: const Text('重试', style: TextStyle(fontSize: AppFont.body)),
),
],
),
),
);
}
}
class CompanyOfflineBanner extends StatelessWidget {
const CompanyOfflineBanner({super.key});
@override
Widget build(BuildContext context) {
return Container(
height: 36,
color: AppColors.bannerBg,
padding: const EdgeInsets.symmetric(horizontal: AppGap.x4),
child: const Row(
children: [
Icon(Icons.info_outline, size: 16, color: Colors.white),
SizedBox(width: AppGap.x2),
Expanded(
child: Text(
'当前网络不可用,请检查网络连接',
style: TextStyle(fontSize: AppFont.sub, color: Colors.white),
),
),
],
),
);
}
}
/// 即时出现;恢复后 300ms ease-out 收起。减少动态效果时立刻切换。
class CompanyOfflineBannerHost extends StatelessWidget {
const CompanyOfflineBannerHost({super.key, required this.visible});
final bool visible;
@override
Widget build(BuildContext context) {
final reduce = AppMotion.reduce(context);
return AnimatedSize(
duration: visible || reduce
? Duration.zero
: AppDuration.bannerCollapse,
curve: Curves.easeOut,
alignment: Alignment.topCenter,
child: visible
? const SafeArea(bottom: false, child: CompanyOfflineBanner())
: const SizedBox(width: double.infinity, height: 0),
);
}
}
/// 入场 150ms、停留 2s、退场 200msease-out;减少动态效果时无动画。
class CompanyToastBanner extends StatefulWidget {
const CompanyToastBanner({super.key, required this.message});
final String? message;
@override
State<CompanyToastBanner> createState() => _CompanyToastBannerState();
}
class _CompanyToastBannerState extends State<CompanyToastBanner> {
String? _shown;
double _opacity = 0;
@override
void didUpdateWidget(CompanyToastBanner oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.message != null) {
_shown = widget.message;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _opacity = 1);
});
} else if (_shown != null) {
setState(() => _opacity = 0);
}
}
@override
Widget build(BuildContext context) {
if (_shown == null) return const SizedBox.shrink();
final reduce = AppMotion.reduce(context);
final duration = reduce
? Duration.zero
: (_opacity == 0 ? AppDuration.toastOut : AppDuration.overlay);
return SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.fromLTRB(AppGap.x4, AppGap.x2, AppGap.x4, 0),
child: AnimatedOpacity(
opacity: _opacity,
duration: duration,
curve: Curves.easeOut,
onEnd: () {
if (_opacity == 0 && mounted) {
setState(() => _shown = null);
}
},
child: Material(
color: AppColors.bannerBg,
borderRadius: BorderRadius.circular(AppRadius.control),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppGap.x4, vertical: AppGap.x3),
child: Text(
_shown!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: AppFont.sub, color: Colors.white),
),
),
),
),
),
),
);
}
}
@@ -59,9 +59,6 @@ class AppController extends GetxController with UpgradeManger {
if (isRunningBackground && !run) {}
isRunningBackground = run;
if (Get.isRegistered<IMController>()) {
Get.find<IMController>().backgroundSubject.add(run);
}
if (!run) {
_cancelAllNotifications();
}
@@ -9,114 +9,19 @@ import 'package:openim_common/openim_common.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:openim_live/openim_live.dart';
import '../../company/feature_flags.dart';
import '../../company/rtc/call_permissions.dart';
import '../../company/rtc/call_session_guard.dart';
import '../../company/rtc/livekit_call_config.dart';
import '../../company/rtc/rtc_token_api.dart';
import '../im_callback.dart';
class IMController extends GetxController with IMCallback, OpenIMLive {
late Rx<UserFullInfo> userInfo;
late String atAllTag;
final _rtcTokenApi = RtcTokenApi();
final callGuard = CallSessionGuard(inviteTimeout: LiveKitCallConfig.inviteTimeout);
@override
void onClose() {
callGuard.dispose();
super.close();
onCloseLive();
super.onClose();
}
@override
Future<SignalingCertificate> getRtcCertificate(String roomID, String userID) {
final token = DataSp.imToken;
if (token == null || token.isEmpty) {
return Future.error(StateError('登录状态已失效,请重新登录'));
}
return _rtcTokenApi.getToken(room: roomID, identity: userID, authToken: token);
}
@override
void onLiveSessionClosed() {
callGuard.dispose();
}
@override
void onLiveConnected() {
callGuard.markConnected();
}
@override
void receiveNewInvitation(SignalingInfo info) {
if (!FeatureFlags.livekitCall) return;
final invitation = info.invitation;
final roomID = invitation?.roomID ?? '';
if (invitation?.mediaType != 'audio' || invitation?.sessionType != ConversationType.single) {
onTapReject(info);
return;
}
if (!callGuard.acceptSignaling(customType: CustomMessageType.callingInvite, roomID: roomID)) {
return;
}
if (isBusy || callGuard.isBusy) {
onTapReject(info);
return;
}
if (callGuard.startIncoming(roomID) != null) {
onTapReject(info);
return;
}
super.receiveNewInvitation(info);
}
@override
void call({
required CallObj callObj,
required CallType callType,
CallState callState = CallState.call,
String? roomID,
String? inviterUserID,
required List<String> inviteeUserIDList,
String? groupID,
SignalingCertificate? credentials,
}) async {
if (!FeatureFlags.livekitCall) return;
if (callType != CallType.audio || callObj != CallObj.single) {
IMViews.showToast('暂只支持一对一语音通话');
return;
}
if (isBusy || callGuard.isBusy) {
IMViews.showToast(StrRes.callingBusy);
return;
}
final granted = await CallPermissions.ensureMicrophone();
if (!granted) return;
callGuard.startOutgoing(roomID ?? '');
super.call(
callObj: callObj,
callType: callType,
callState: callState,
roomID: roomID,
inviterUserID: inviterUserID,
inviteeUserIDList: inviteeUserIDList,
groupID: groupID,
credentials: credentials,
);
}
@override
Future<SignalingCertificate> onTapPickup(SignalingInfo signaling) async {
final granted = await CallPermissions.ensureMicrophone();
if (!granted) {
await onTapReject(signaling);
return Future.error(StateError('microphone denied'));
}
return super.onTapPickup(signaling);
}
@override
void onInit() async {
super.onInit();
@@ -179,14 +84,8 @@ class IMController extends GetxController with IMCallback, OpenIMLive {
customType == CustomMessageType.callingReject ||
customType == CustomMessageType.callingCancel ||
customType == CustomMessageType.callingHungup) {
if (!FeatureFlags.livekitCall) return;
final signaling = SignalingInfo(invitation: InvitationInfo.fromJson(map['data']));
signaling.userID = signaling.invitation?.inviterUserID;
final roomID = signaling.invitation?.roomID ?? '';
if (customType != CustomMessageType.callingInvite &&
!callGuard.acceptSignaling(customType: customType as int, roomID: roomID)) {
return;
}
switch (customType) {
case CustomMessageType.callingInvite:
+1 -5
View File
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:openim_common/openim_common.dart';
import 'app.dart';
import 'company/config/bootstrap.dart';
void main() {
runZonedGuarded(() {
@@ -13,10 +12,7 @@ void main() {
Logger.print('FlutterError: ${details.exception.toString()}, ${details.stack.toString()}');
};
Config.init(() async {
await CompanyBootstrap.install();
runApp(const ChatApp());
});
Config.init(() => runApp(const ChatApp()));
}, (error, stackTrace) {
Logger.print('FlutterError: ${error.toString()}, ${stackTrace.toString()}');
});
+7 -7
View File
@@ -22,7 +22,6 @@ import 'package:wechat_assets_picker/wechat_assets_picker.dart';
import 'package:wechat_camera_picker/wechat_camera_picker.dart';
import 'package:openim_live/openim_live.dart';
import '../../company/feature_flags.dart';
import '../../core/controller/app_controller.dart';
import '../../core/controller/im_controller.dart';
import '../../core/im_callback.dart';
@@ -1623,17 +1622,18 @@ class ChatLogic extends SuperController {
}
void call() {
if (!FeatureFlags.livekitCall || !isSingleChat) return;
if (rtcIsBusy) {
IMViews.showToast(StrRes.callingBusy);
return;
}
imLogic.call(
callObj: CallObj.single,
callType: CallType.audio,
inviteeUserIDList: [userID!],
);
IMViews.openIMCallSheet(nickname.value, (index) {
imLogic.call(
callObj: CallObj.single,
callType: index == 0 ? CallType.audio : CallType.video,
inviteeUserIDList: [if (isSingleChat) userID!],
);
});
}
void onScrollToTop() {
+2 -6
View File
@@ -3,7 +3,6 @@ import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
import '../../company/feature_flags.dart';
import '../../widgets/file_download_progress.dart';
import 'chat_logic.dart';
@@ -195,9 +194,7 @@ class ChatPage extends StatelessWidget {
soundUrl: soundElem.sourceUrl,
duration: soundElem.duration,
isPlaying: logic.isPlaySound(message),
isUnread: !isISend && message.isRead == false,
),
false,
);
}
}
@@ -225,13 +222,12 @@ class ChatPage extends StatelessWidget {
onCloseMultiModel: logic.exit,
onClickMoreBtn: logic.chatSetup,
onClickCallBtn: logic.call,
showCallBtn: FeatureFlags.livekitCall && logic.isSingleChat,
),
body: SafeArea(
child: WaterMarkBgView(
text: '',
path: logic.background.value,
backgroundColor: Styles.c_F0F2F6,
backgroundColor: Styles.c_FFFFFF,
floatView: _groupCallHintView,
bottomView: ChatInputBox(
forceCloseToolboxSub: logic.forceCloseToolbox,
@@ -244,7 +240,7 @@ class ChatPage extends StatelessWidget {
toolbox: ChatToolBox(
onTapAlbum: logic.onTapAlbum,
onTapCamera: logic.onTapCamera,
onTapCall: FeatureFlags.livekitCall && logic.isSingleChat ? logic.call : null,
onTapCall: logic.call,
onTapCard: logic.onTapCarte,
onTapFile: logic.onTapFile,
onTapLocation: logic.onTapLocation,
@@ -4,66 +4,20 @@ import 'package:openim/pages/contacts/group_profile_panel/group_profile_panel_lo
import 'package:openim/routes/app_navigator.dart';
import 'package:openim_common/openim_common.dart';
import '../../company/directory/directory_api.dart';
import '../../core/controller/im_controller.dart';
import '../home/home_logic.dart';
import 'select_contacts/select_contacts_logic.dart';
class ColleagueRow {
ColleagueRow({
required this.userID,
required this.nickname,
required this.faceURL,
required this.department,
required this.title,
});
final String userID;
final String nickname;
final String faceURL;
final String department;
final String title;
String get subtitle {
if (title.isNotEmpty && department.isNotEmpty) {
return '$title · $department';
}
if (title.isNotEmpty) return title;
return department;
}
}
class ContactsLogic extends GetxController
implements ViewUserProfileBridge, SelectContactsBridge, ScanBridge {
final imLogic = Get.find<IMController>();
final homeLogic = Get.find<HomeLogic>();
final _directory = DirectoryApi();
final friendApplicationList = <UserInfo>[];
final loading = true.obs;
final loadFailed = false.obs;
final colleagues = <ColleagueRow>[].obs;
int get friendApplicationCount =>
homeLogic.unhandledFriendApplicationCount.value;
int get friendApplicationCount => homeLogic.unhandledFriendApplicationCount.value;
int get groupApplicationCount =>
homeLogic.unhandledGroupApplicationCount.value;
Map<String, List<ColleagueRow>> get groupedColleagues {
final map = <String, List<ColleagueRow>>{};
for (final row in colleagues) {
final key = row.department.isEmpty ? '未分组' : row.department;
map.putIfAbsent(key, () => []).add(row);
}
final keys = map.keys.toList()
..sort((a, b) {
if (a == '未分组') return 1;
if (b == '未分组') return -1;
return a.compareTo(b);
});
return {for (final k in keys) k: map[k]!};
}
int get groupApplicationCount => homeLogic.unhandledGroupApplicationCount.value;
@override
void onInit() {
@@ -74,73 +28,6 @@ class ContactsLogic extends GetxController
super.onInit();
}
@override
void onReady() {
reload();
super.onReady();
}
Future<void> reload() async {
loading.value = true;
loadFailed.value = false;
try {
await Future.wait([
OpenIM.iMManager.friendshipManager
.getFriendApplicationListAsRecipient(),
OpenIM.iMManager.groupManager.getGroupApplicationListAsRecipient(),
_loadColleagues(),
]);
homeLogic.getUnhandledFriendApplicationCount();
homeLogic.getUnhandledGroupApplicationCount();
} catch (e, s) {
Logger.print('contacts reload e: $e $s');
loadFailed.value = true;
} finally {
loading.value = false;
}
}
Future<void> _loadColleagues() async {
final friends = <FriendInfo>[];
const page = 1000;
while (true) {
final temp = await OpenIM.iMManager.friendshipManager.getFriendListPage(
offset: friends.length,
count: page,
filterBlack: true,
);
friends.addAll(temp);
if (temp.length < page) break;
}
var dirById = <String, DirectoryUser>{};
try {
final items = await _directory.search(keyword: '', limit: 100);
dirById = {for (final e in items) e.userID: e};
} catch (e, s) {
Logger.print('contacts directory e: $e $s');
}
colleagues.assignAll(friends.where((e) => e.userID != null).map((e) {
final dir = dirById[e.userID];
return ColleagueRow(
userID: e.userID!,
nickname: (e.remark?.isNotEmpty == true)
? e.remark!
: (e.nickname?.isNotEmpty == true ? e.nickname! : e.userID!),
faceURL: e.faceURL ?? dir?.faceURL ?? '',
department: dir?.department ?? '',
title: dir?.title ?? '',
);
}));
}
void openColleague(ColleagueRow row) => AppNavigator.startUserProfilePane(
userID: row.userID,
nickname: row.nickname,
faceURL: row.faceURL,
);
@override
void onClose() {
PackageBridge.selectContactsBridge = null;
@@ -159,7 +46,7 @@ class ContactsLogic extends GetxController
void searchContacts() => AppNavigator.startGlobalSearch();
void addContacts() => AppNavigator.startAddColleague();
void addContacts() => AppNavigator.startAddContactsMethod();
@override
Future<T?>? selectContacts<T>(
@@ -182,8 +69,7 @@ class ContactsLogic extends GetxController
);
@override
viewUserProfile(String userID, String? nickname, String? faceURL,
[String? groupID]) =>
viewUserProfile(String userID, String? nickname, String? faceURL, [String? groupID]) =>
AppNavigator.startUserProfilePane(
userID: userID,
nickname: nickname,
@@ -199,6 +85,5 @@ class ContactsLogic extends GetxController
);
@override
scanOutUserID(String userID) =>
AppNavigator.startUserProfilePane(userID: userID, offAndToNamed: true);
scanOutUserID(String userID) => AppNavigator.startUserProfilePane(userID: userID, offAndToNamed: true);
}
+61 -152
View File
@@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
import '../../company/brand/app_tokens.dart';
import '../../company/ui/state_views.dart';
import 'contacts_logic.dart';
class ContactsPage extends StatelessWidget {
@@ -14,126 +13,36 @@ class ContactsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.pageBg,
appBar: AppBar(
title: const Text('通讯录'),
actions: [
IconButton(
onPressed: logic.addContacts,
icon: const Icon(Icons.person_add_alt,
size: AppIconSize.md, color: AppColors.textPrimary),
),
],
appBar: TitleBar.contacts(
onClickAddContacts: logic.addContacts,
),
body: Obx(() {
if (logic.loading.value) {
return const CompanyLoadingView();
}
if (logic.loadFailed.value) {
return CompanyFailView(onRetry: logic.reload);
}
final grouped = logic.groupedColleagues;
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Column(
children: [
_entry(
icon: Icons.person_add_alt,
label: '新的同事',
count: logic.friendApplicationCount,
onTap: logic.newFriend,
),
_entry(
icon: Icons.group_outlined,
label: '我的群聊',
count: logic.groupApplicationCount,
onTap: logic.myGroup,
),
],
),
),
if (logic.colleagues.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: CompanyEmptyView(
icon: Icons.person_outline,
title: '暂时没有同事',
subtitle: '点右上角添加同事',
),
)
else
for (final entry in grouped.entries) ...[
SliverToBoxAdapter(child: _groupHeader(entry.key)),
SliverList(
delegate: SliverChildBuilderDelegate(
(_, index) => _colleagueRow(entry.value[index]),
childCount: entry.value.length,
),
),
],
],
);
}),
);
}
Widget _groupHeader(String name) {
return Container(
height: 28,
color: AppColors.chatBg,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: AppGap.x4),
child: Text(
name,
style: const TextStyle(
fontSize: AppFont.small, color: AppColors.textSecondary),
),
);
}
Widget _colleagueRow(ColleagueRow row) {
return InkWell(
onTap: () => logic.openColleague(row),
splashColor: AppColors.chatBg,
highlightColor: AppColors.chatBg,
child: SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppGap.x4),
child: Row(
backgroundColor: Styles.c_F8F9FA,
body: Obx(
() => SingleChildScrollView(
child: Column(
children: [
AvatarView(
url: row.faceURL,
text: row.nickname,
width: 48,
height: 48,
_buildItemView(
assetsName: ImageRes.newFriend,
label: StrRes.newFriend,
count: logic.friendApplicationCount,
onTap: logic.newFriend,
),
const SizedBox(width: AppGap.x3),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
row.nickname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: AppFont.body,
color: AppColors.textPrimary),
),
if (row.subtitle.isNotEmpty)
Text(
row.subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: AppFont.sub,
color: AppColors.textSecondary),
),
],
),
_buildItemView(
assetsName: ImageRes.newGroup,
label: StrRes.newGroupRequest,
count: logic.groupApplicationCount,
onTap: logic.newGroup,
),
10.verticalSpace,
_buildItemView(
assetsName: ImageRes.myFriend,
label: StrRes.myFriend,
onTap: logic.myFriend,
),
_buildItemView(
assetsName: ImageRes.myGroup,
label: StrRes.myGroup,
onTap: logic.myGroup,
),
],
),
@@ -142,41 +51,41 @@ class ContactsPage extends StatelessWidget {
);
}
Widget _entry({
required IconData icon,
Widget _buildItemView({
String? assetsName,
required String label,
Widget? icon,
int count = 0,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
child: SizedBox(
height: 56,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppGap.x4),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.primaryActive,
borderRadius: BorderRadius.circular(AppRadius.control),
),
alignment: Alignment.center,
child:
Icon(icon, size: AppIconSize.sm, color: AppColors.primary),
),
const SizedBox(width: AppGap.x3),
Text(label,
style: const TextStyle(
fontSize: AppFont.body, color: AppColors.textPrimary)),
const Spacer(),
if (count > 0) UnreadCountView(count: count),
],
bool showRightArrow = true,
double? height,
Function()? onTap,
}) =>
Ink(
color: Styles.c_FFFFFF,
child: InkWell(
onTap: onTap,
child: Container(
height: height ?? 60.h,
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Row(
children: [
if (null != assetsName)
assetsName.toImage
..width = 42.w
..height = 42.h,
if (null != icon) icon,
12.horizontalSpace,
label.toText..style = Styles.ts_0C1C33_17sp,
const Spacer(),
if (count > 0) UnreadCountView(count: count),
4.horizontalSpace,
if (showRightArrow)
ImageRes.rightArrow.toImage
..width = 24.w
..height = 24.h,
],
),
),
),
),
);
}
);
}
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:get/get.dart';
import 'package:openim/routes/app_navigator.dart';
import 'package:openim_common/openim_common.dart';
import '../../../core/controller/im_controller.dart';
@@ -11,8 +12,6 @@ class FriendRequestsLogic extends GetxController {
final imLogic = Get.find<IMController>();
final homeLogic = Get.find<HomeLogic>();
final applicationList = <FriendApplicationInfo>[].obs;
final loading = true.obs;
final loadFailed = false.obs;
late StreamSubscription faSub;
@override
@@ -29,8 +28,6 @@ class FriendRequestsLogic extends GetxController {
super.onReady();
}
void retry() => _getFriendRequestsList();
@override
void onClose() {
faSub.cancel();
@@ -39,74 +36,42 @@ class FriendRequestsLogic extends GetxController {
}
void _getFriendRequestsList() async {
loading.value = true;
loadFailed.value = false;
try {
final list = await Future.wait([
OpenIM.iMManager.friendshipManager
.getFriendApplicationListAsRecipient(),
OpenIM.iMManager.friendshipManager
.getFriendApplicationListAsApplicant(),
]);
final list = await Future.wait([
OpenIM.iMManager.friendshipManager.getFriendApplicationListAsRecipient(),
OpenIM.iMManager.friendshipManager.getFriendApplicationListAsApplicant(),
]);
final allList = <FriendApplicationInfo>[];
allList
..addAll(list[0])
..addAll(list[1]);
final allList = <FriendApplicationInfo>[];
allList
..addAll(list[0])
..addAll(list[1]);
allList.sort((a, b) {
if (a.createTime! > b.createTime!) {
return -1;
} else if (a.createTime! < b.createTime!) {
return 1;
}
return 0;
});
allList.sort((a, b) {
if (a.createTime! > b.createTime!) {
return -1;
} else if (a.createTime! < b.createTime!) {
return 1;
}
return 0;
});
var haveReadList = DataSp.getHaveReadUnHandleFriendApplication();
haveReadList ??= <String>[];
for (var e in list[0]) {
var id = IMUtils.buildFriendApplicationID(e);
if (!haveReadList.contains(id)) {
haveReadList.add(id);
}
var haveReadList = DataSp.getHaveReadUnHandleFriendApplication();
haveReadList ??= <String>[];
for (var e in list[0]) {
var id = IMUtils.buildFriendApplicationID(e);
if (!haveReadList.contains(id)) {
haveReadList.add(id);
}
DataSp.putHaveReadUnHandleFriendApplication(haveReadList);
applicationList.assignAll(allList);
} catch (e, s) {
Logger.print('friend requests e: $e $s');
loadFailed.value = true;
} finally {
loading.value = false;
}
DataSp.putHaveReadUnHandleFriendApplication(haveReadList);
applicationList.assignAll(allList);
}
bool isISendRequest(FriendApplicationInfo info) =>
info.fromUserID == OpenIM.iMManager.userID;
bool isISendRequest(FriendApplicationInfo info) => info.fromUserID == OpenIM.iMManager.userID;
void acceptFriendApplication(FriendApplicationInfo info) async {
try {
await LoadingView.singleton.wrap(
asyncFunction: () => OpenIM.iMManager.friendshipManager
.acceptFriendApplication(userID: info.fromUserID!),
void acceptFriendApplication(FriendApplicationInfo info) => AppNavigator.startProcessFriendRequests(
applicationInfo: info,
);
IMViews.showToast(StrRes.addSuccessfully);
_getFriendRequestsList();
} catch (_) {
IMViews.showToast(StrRes.addFailed);
}
}
void refuseFriendApplication(FriendApplicationInfo info) async {
try {
await LoadingView.singleton.wrap(
asyncFunction: () => OpenIM.iMManager.friendshipManager
.refuseFriendApplication(userID: info.fromUserID!),
);
IMViews.showToast(StrRes.rejectSuccessfully);
_getFriendRequestsList();
} catch (_) {
IMViews.showToast(StrRes.rejectFailed);
}
}
void refuseFriendApplication(FriendApplicationInfo info) async {}
}
@@ -1,10 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
import '../../../company/brand/app_tokens.dart';
import '../../../company/ui/state_views.dart';
import 'friend_requests_logic.dart';
class FriendRequestsPage extends StatelessWidget {
@@ -15,156 +14,77 @@ class FriendRequestsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('新的同事')),
backgroundColor: AppColors.pageBg,
body: Obx(() {
if (logic.loading.value) {
return const CompanyLoadingView();
}
if (logic.loadFailed.value) {
return CompanyFailView(onRetry: logic.retry);
}
if (logic.applicationList.isEmpty) {
return const CompanyEmptyView(
icon: Icons.person_outline,
title: '暂无同事申请',
subtitle: '发出或收到的申请会显示在这里',
);
}
return ListView.builder(
itemCount: logic.applicationList.length,
itemBuilder: (_, index) =>
_buildItemView(context, logic.applicationList[index]),
);
}),
appBar: TitleBar.back(title: StrRes.newFriend),
backgroundColor: Styles.c_F8F9FA,
body: Obx(() => ListView.builder(
padding: EdgeInsets.only(top: 10.h),
itemCount: logic.applicationList.length,
itemBuilder: (_, index) =>
_buildItemView(logic.applicationList[index]),
)),
);
}
Widget _buildItemView(BuildContext context, FriendApplicationInfo info) {
Widget _buildItemView(FriendApplicationInfo info) {
final isISendRequest = info.fromUserID == OpenIM.iMManager.userID;
final name = isISendRequest ? info.toNickname : info.fromNickname;
final faceURL = isISendRequest ? info.toFaceURL : info.fromFaceURL;
final waiting = info.isWaitingHandle;
final rejected = info.isRejected;
final agreed = info.isAgreed;
final narrow = MediaQuery.sizeOf(context).width < 360;
final pendingActions = waiting && !isISendRequest;
final stacked = narrow && pendingActions;
Widget status;
if (waiting && isISendRequest) {
status = const Text('等待对方通过',
style: TextStyle(
fontSize: AppFont.sub, color: AppColors.textSecondary));
} else if (pendingActions) {
status = SizedBox(
width: 132,
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => logic.refuseFriendApplication(info),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 32),
padding: EdgeInsets.zero,
side: const BorderSide(
color: AppColors.textSecondary, width: 0.5),
foregroundColor: AppColors.textPrimary,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(AppRadius.control)),
),
child: const Text('拒绝',
style: TextStyle(fontSize: AppFont.sub)),
),
),
const SizedBox(width: AppGap.x2),
Expanded(
child: FilledButton(
onPressed: () => logic.acceptFriendApplication(info),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 32),
padding: EdgeInsets.zero,
backgroundColor: AppColors.primary,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(AppRadius.control)),
),
child: const Text('接受',
style: TextStyle(
fontSize: AppFont.sub, color: Colors.white)),
),
),
],
),
);
} else if (agreed) {
status = const Text('已添加',
style: TextStyle(
fontSize: AppFont.sub, color: AppColors.textSecondary));
} else if (rejected) {
status = const Text('已拒绝',
style: TextStyle(
fontSize: AppFont.sub, color: AppColors.textSecondary));
} else {
status = const SizedBox.shrink();
}
final identity = Row(
children: [
AvatarView(url: faceURL, text: name, width: 48, height: 48),
const SizedBox(width: AppGap.x3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
name ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: AppFont.body, color: AppColors.textPrimary),
),
if (IMUtils.isNotNullEmptyStr(info.reqMsg))
Text(
info.reqMsg ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: AppFont.sub, color: AppColors.textSecondary),
),
],
),
),
],
);
String? name = isISendRequest ? info.toNickname : info.fromNickname;
String? faceURL = isISendRequest ? info.toFaceURL : info.fromFaceURL;
String? reason = info.reqMsg;
return Container(
constraints: const BoxConstraints(minHeight: 72),
padding: const EdgeInsets.symmetric(
horizontal: AppGap.x4, vertical: AppGap.x3),
decoration: const BoxDecoration(
color: AppColors.pageBg,
border:
Border(bottom: BorderSide(color: AppColors.divider, width: 0.5)),
height: 68.h,
padding: EdgeInsets.symmetric(horizontal: 16.w),
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
border: BorderDirectional(
bottom: BorderSide(
color: Styles.c_F8F9FA,
width: 1,
),
),
),
child: stacked
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
child: Row(
children: [
AvatarView(url: faceURL, text: name),
10.horizontalSpace,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
identity,
const SizedBox(height: AppGap.x2),
Align(alignment: Alignment.centerRight, child: status),
],
)
: Row(
children: [
Expanded(child: identity),
const SizedBox(width: AppGap.x2),
status,
(name ?? '').toText
..style = Styles.ts_0C1C33_17sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
4.verticalSpace,
if (IMUtils.isNotNullEmptyStr(reason))
(reason ?? '').toText
..style = Styles.ts_8E9AB0_14sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
],
),
),
if (/*info.isWaitingHandle && */ isISendRequest)
ImageRes.sendRequests.toImage
..width = 20.w
..height = 20.h,
if (info.isWaitingHandle && !isISendRequest)
Button(
text: StrRes.lookOver,
textStyle: Styles.ts_FFFFFF_14sp,
onTap: () => logic.acceptFriendApplication(info),
height: 28.h,
padding: EdgeInsets.symmetric(horizontal: 13.w),
),
if (info.isWaitingHandle && isISendRequest)
StrRes.waitingForVerification.toText..style = Styles.ts_8E9AB0_14sp,
if (info.isRejected)
StrRes.rejected.toText..style = Styles.ts_8E9AB0_14sp,
if (info.isAgreed)
StrRes.approved.toText..style = Styles.ts_8E9AB0_14sp,
],
),
);
}
}
@@ -3,7 +3,6 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
import '../../../../company/feature_flags.dart';
import 'personal_info_logic.dart';
class PersonalInfoPage extends StatelessWidget {
@@ -53,9 +52,9 @@ class PersonalInfoPage extends StatelessWidget {
_buildCornerBgView(
children: [
_buildItemView(
label: FeatureFlags.staffLoginOnly ? '工号' : StrRes.mobile,
value: FeatureFlags.staffLoginOnly ? logic.userID : logic.phoneNumber,
onTap: FeatureFlags.staffLoginOnly ? null : logic.clickPhoneNumber,
label: StrRes.mobile,
value: logic.phoneNumber,
onTap: logic.clickPhoneNumber,
),
_buildItemView(
label: StrRes.email,
@@ -6,7 +6,6 @@ import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:get/get.dart';
import 'package:openim/company/feature_flags.dart';
import 'package:openim/routes/app_navigator.dart';
import 'package:openim_common/openim_common.dart';
import 'package:sprintf/sprintf.dart';
@@ -325,12 +324,13 @@ class UserProfilePanelLogic extends GetxController {
}
void toCall() {
if (!FeatureFlags.livekitCall) return;
imLogic.call(
callObj: CallObj.single,
callType: CallType.audio,
inviteeUserIDList: [userInfo.value.userID!],
);
IMViews.openIMCallSheet(userInfo.value.showName, (index) {
imLogic.call(
callObj: CallObj.single,
callType: index == 0 ? CallType.audio : CallType.video,
inviteeUserIDList: [userInfo.value.userID!],
);
});
}
void copyID() {
@@ -7,7 +7,6 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
import '../../../company/feature_flags.dart';
import 'user_profile _panel_logic.dart';
class UserProfilePanelPage extends StatelessWidget {
@@ -235,14 +234,12 @@ class UserProfilePanelPage extends StatelessWidget {
height: 108.h,
child: Row(
children: [
if (FeatureFlags.livekitCall) ...[
Expanded(
child: ImageTextButton.call(
onTap: logic.toCall,
),
Expanded(
child: ImageTextButton.call(
onTap: logic.toCall,
),
11.horizontalSpace,
],
),
11.horizontalSpace,
Expanded(
child: ImageTextButton.message(
onTap: logic.toChat,
@@ -25,13 +25,10 @@ class ConversationLogic extends GetxController {
final pageSize = 400;
final imStatus = IMSdkStatus.connectionSucceeded.obs;
final loading = true.obs;
final loadFailed = false.obs;
bool reInstall = false;
final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
final ItemPositionsListener itemPositionsListener = ItemPositionsListener.create();
int scrollIndex = -1;
final onChangeConversations = <ConversationInfo>[];
@@ -55,16 +52,13 @@ class ConversationLogic extends GetxController {
}
}
Logger.print(
'IM SDK Status: $status, reinstall: $reInstall, progress: $progress');
Logger.print('IM SDK Status: $status, reinstall: $reInstall, progress: $progress');
if (status == IMSdkStatus.syncProgress && reInstall) {
final p = (progress!).toDouble() / 100.0;
EasyLoading.showProgress(p,
status: '${StrRes.synchronizing}(${(p * 100.0).truncate()}%)');
} else if (status == IMSdkStatus.syncEnded ||
status == IMSdkStatus.syncFailed) {
EasyLoading.showProgress(p, status: '${StrRes.synchronizing}(${(p * 100.0).truncate()}%)');
} else if (status == IMSdkStatus.syncEnded || status == IMSdkStatus.syncFailed) {
EasyLoading.dismiss();
if (reInstall) {
onRefresh();
@@ -87,8 +81,7 @@ class ConversationLogic extends GetxController {
onChangeConversations.addAll(newList);
}
for (var newValue in newList) {
Logger.print(
'======== conversation changed: ${newValue.toJson()} ========');
Logger.print('======== conversation changed: ${newValue.toJson()} ========');
list.removeWhere((e) => e.conversationID == newValue.conversationID);
}
@@ -139,8 +132,7 @@ class ConversationLogic extends GetxController {
}
void deleteConversation(ConversationInfo info) async {
await OpenIM.iMManager.conversationManager
.deleteConversationAndDeleteAllMsg(
await OpenIM.iMManager.conversationManager.deleteConversationAndDeleteAllMsg(
conversationID: info.conversationID,
);
list.remove(info);
@@ -179,8 +171,7 @@ class ConversationLogic extends GetxController {
final text = IMUtils.parseNtf(info.latestMsg!, isConversation: true);
if (text != null) return text;
if (info.isSingleChat ||
info.latestMsg!.sendID == OpenIM.iMManager.userID)
if (info.isSingleChat || info.latestMsg!.sendID == OpenIM.iMManager.userID)
return IMUtils.parseMsg(info.latestMsg!, isConversation: true);
return "${info.latestMsg!.senderNickname}: ${IMUtils.parseMsg(info.latestMsg!, isConversation: true)} ";
@@ -273,31 +264,16 @@ class ConversationLogic extends GetxController {
}
bool get isFailedSdkStatus =>
imStatus.value == IMSdkStatus.connectionFailed ||
imStatus.value == IMSdkStatus.syncFailed;
imStatus.value == IMSdkStatus.connectionFailed || imStatus.value == IMSdkStatus.syncFailed;
bool get showLoading {
if (loading.value) return true;
if (list.isNotEmpty) return false;
switch (imStatus.value) {
case IMSdkStatus.connecting:
case IMSdkStatus.syncStart:
case IMSdkStatus.synchronizing:
case IMSdkStatus.syncProgress:
return true;
default:
return false;
}
}
bool get showFail => list.isEmpty && (loadFailed.value || isFailedSdkStatus);
void _sortConversationList() =>
OpenIM.iMManager.conversationManager.simpleSort(list);
void _sortConversationList() => OpenIM.iMManager.conversationManager.simpleSort(list);
void onRefresh() async {
late List<ConversationInfo> list;
try {
await reloadConversations();
list = await _request();
this.list.assignAll(list);
if (list.isEmpty || list.length < pageSize) {
refreshController.loadNoData();
} else {
@@ -309,46 +285,16 @@ class ConversationLogic extends GetxController {
}
static Future<List<ConversationInfo>> getConversationFirstPage() async {
final result = await OpenIM.iMManager.conversationManager
.getConversationListSplit(offset: 0, count: 400);
final result = await OpenIM.iMManager.conversationManager.getConversationListSplit(offset: 0, count: 400);
return result;
}
void getFirstPage() async {
loading.value = true;
loadFailed.value = false;
try {
final cached = homeLogic.conversationsAtFirstPage;
if (cached.isNotEmpty) {
list.assignAll(cached);
_sortConversationList();
return;
}
final result = await _request();
list.assignAll(result);
_sortConversationList();
} catch (e, s) {
Logger.print('conversation first page e: $e $s');
loadFailed.value = true;
} finally {
loading.value = false;
}
}
final result = homeLogic.conversationsAtFirstPage;
Future<void> reloadConversations() async {
loading.value = true;
loadFailed.value = false;
try {
final result = await _request();
list.assignAll(result);
_sortConversationList();
} catch (e, s) {
Logger.print('conversation reload e: $e $s');
loadFailed.value = true;
} finally {
loading.value = false;
}
list.assignAll(result);
_sortConversationList();
}
void clearConversations() {
@@ -359,23 +305,20 @@ class ConversationLogic extends GetxController {
final temp = <ConversationInfo>[];
while (true) {
var result =
await OpenIM.iMManager.conversationManager.getConversationListSplit(
var result = await OpenIM.iMManager.conversationManager.getConversationListSplit(
offset: temp.length,
count: pageSize,
);
if (onChangeConversations.isNotEmpty) {
final bSet = Set.from(onChangeConversations);
Logger.print(
'replace conversation: [${onChangeConversations.length}], $bSet');
Logger.print('replace conversation: [${onChangeConversations.length}], $bSet');
for (int i = 0; i < result.length; i++) {
final info = result[i];
if (bSet.contains(info)) {
result[i] =
onChangeConversations[onChangeConversations.indexOf(info)];
result[i] = onChangeConversations[onChangeConversations.indexOf(info)];
}
}
}
@@ -424,8 +367,7 @@ class ConversationLogic extends GetxController {
if (scrollIndex == 0) {
itemScrollController.jumpTo(index: 0);
} else {
itemScrollController.scrollTo(
index: scrollIndex, duration: const Duration(milliseconds: 300));
itemScrollController.scrollTo(index: scrollIndex, duration: const Duration(milliseconds: 300));
}
}
@@ -434,8 +376,7 @@ class ConversationLogic extends GetxController {
required int sessionType,
}) =>
LoadingView.singleton.wrap(
asyncFunction: () =>
OpenIM.iMManager.conversationManager.getOneConversation(
asyncFunction: () => OpenIM.iMManager.conversationManager.getOneConversation(
sourceID: sourceID,
sessionType: sessionType,
));
@@ -495,13 +436,11 @@ class ConversationLogic extends GetxController {
scan() => AppNavigator.startScan();
addFriend() => AppNavigator.startAddColleague();
addFriend() => AppNavigator.startAddContactsBySearch(searchType: SearchType.user);
createGroup() => AppNavigator.startCreateGroup(
defaultCheckedList: [OpenIM.iMManager.userInfo]);
createGroup() => AppNavigator.startCreateGroup(defaultCheckedList: [OpenIM.iMManager.userInfo]);
addGroup() =>
AppNavigator.startAddContactsBySearch(searchType: SearchType.group);
addGroup() => AppNavigator.startAddContactsBySearch(searchType: SearchType.group);
void globalSearch() => AppNavigator.startGlobalSearch();
}
@@ -1,272 +1,205 @@
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:get/get.dart';
import 'package:openim/core/controller/im_controller.dart';
import 'package:openim_common/openim_common.dart';
import 'package:rotated_corner_decoration/rotated_corner_decoration.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:sprintf/sprintf.dart';
import '../../company/brand/app_tokens.dart';
import '../../company/ui/state_views.dart';
import 'conversation_logic.dart';
class ConversationPage extends StatelessWidget {
final logic = Get.find<ConversationLogic>();
final im = Get.find<IMController>();
ConversationPage({super.key});
@override
Widget build(BuildContext context) {
return Obx(() => Scaffold(
backgroundColor: AppColors.pageBg,
appBar: TitleBar(
height: 56,
backgroundColor: AppColors.pageBg,
left: const Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'消息',
style: TextStyle(
fontSize: AppFont.title,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
),
),
right: PopButton(
backgroundColor: Styles.c_F8F9FA,
appBar: TitleBar.conversation(
statusStr: logic.imSdkStatus,
isFailed: logic.isFailedSdkStatus,
popCtrl: logic.popCtrl,
bgRadius: AppRadius.modal,
menus: [
PopMenuInfo(
text: '发起群聊',
iconWidget: const Icon(Icons.group_outlined,
size: AppIconSize.sm, color: AppColors.textPrimary),
onTap: logic.createGroup,
onScan: logic.scan,
onAddFriend: logic.addFriend,
onAddGroup: logic.addGroup,
onCreateGroup: logic.createGroup,
left: Expanded(
flex: 2,
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
AvatarView(
width: 42.w,
height: 42.h,
text: im.userInfo.value.nickname,
url: im.userInfo.value.faceURL,
),
10.horizontalSpace,
if (null != im.userInfo.value.nickname)
Flexible(
child: im.userInfo.value.nickname!.toText
..style = Styles.ts_0C1C33_17sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
),
10.horizontalSpace,
if (null != logic.imSdkStatus && (!logic.reInstall || logic.isFailedSdkStatus))
Flexible(
child: SyncStatusView(
isFailed: logic.isFailedSdkStatus,
statusStr: logic.imSdkStatus!,
)),
],
),
PopMenuInfo(
text: '添加同事',
iconWidget: const Icon(Icons.person_add_alt,
size: AppIconSize.sm, color: AppColors.textPrimary),
onTap: logic.addFriend,
),
],
child: const Padding(
padding: EdgeInsets.all(AppGap.x1),
child: Icon(Icons.add,
size: AppIconSize.md, color: AppColors.textPrimary),
),
),
),
)),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
AppGap.x4, 0, AppGap.x4, AppGap.x2),
child: GestureDetector(
onTap: logic.globalSearch,
behavior: HitTestBehavior.opaque,
child: Container(
height: 36,
decoration: BoxDecoration(
color: AppColors.searchBg,
borderRadius: BorderRadius.circular(AppRadius.control),
),
padding:
const EdgeInsets.symmetric(horizontal: AppGap.x3),
alignment: Alignment.centerLeft,
child: const Row(
children: [
Icon(Icons.search,
size: AppIconSize.sm,
color: AppColors.textSecondary),
SizedBox(width: AppGap.x2),
Text('搜索',
style: TextStyle(
fontSize: AppFont.sub,
color: AppColors.textSecondary)),
],
Expanded(
child: SlidableAutoCloseBehavior(
child: ScrollablePositionedList.builder(
itemScrollController: logic.itemScrollController,
itemBuilder: (_, index) => _buildConversationItemView(
logic.list.elementAt(index),
),
itemCount: logic.list.length,
itemPositionsListener: logic.itemPositionsListener,
),
),
),
Expanded(child: _conversationBody()),
],
),
));
}
Widget _conversationBody() {
if (logic.showLoading) {
return const CompanyLoadingView();
}
if (logic.showFail) {
return CompanyFailView(onRetry: logic.reloadConversations);
}
if (logic.list.isEmpty) {
return const CompanyEmptyView(
icon: Icons.chat_bubble_outline,
title: '暂时没有会话',
subtitle: '去通讯录找个同事聊聊吧',
Widget _buildConversationItemView(ConversationInfo info) => Slidable(
endActionPane: ActionPane(
motion: const ScrollMotion(),
extentRatio: logic.existUnreadMsg(info) ? 0.7 : (logic.isPinned(info) ? 0.5 : 0.4),
children: [
CustomSlidableAction(
onPressed: (_) => logic.pinConversation(info),
flex: logic.isPinned(info) ? 3 : 2,
backgroundColor: Styles.c_0089FF,
padding: const EdgeInsets.all(1),
child: (logic.isPinned(info) ? StrRes.cancelTop : StrRes.top).toText..style = Styles.ts_FFFFFF_14sp,
),
if (logic.existUnreadMsg(info))
CustomSlidableAction(
onPressed: (_) => logic.markMessageHasRead(info),
flex: 3,
backgroundColor: Styles.c_8E9AB0,
padding: const EdgeInsets.all(1),
child: StrRes.markHasRead.toText
..style = Styles.ts_FFFFFF_14sp
..maxLines = 1,
),
CustomSlidableAction(
onPressed: (_) => logic.deleteConversation(info),
flex: 2,
backgroundColor: Styles.c_FF381F,
padding: const EdgeInsets.all(1),
child: StrRes.delete.toText..style = Styles.ts_FFFFFF_14sp,
),
],
),
child: _buildItemView(info),
);
}
return ScrollablePositionedList.builder(
itemScrollController: logic.itemScrollController,
itemBuilder: (_, index) =>
_buildItemView(logic.list.elementAt(index)),
itemCount: logic.list.length,
itemPositionsListener: logic.itemPositionsListener,
);
}
void _showConversationMenu(BuildContext context, ConversationInfo info) {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) {
return Padding(
padding: const EdgeInsets.fromLTRB(AppGap.x4, 0, AppGap.x4, AppGap.x6),
child: Material(
color: AppColors.pageBg,
elevation: 0,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.modal),
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(
logic.isPinned(info) ? '取消置顶' : '置顶',
style: const TextStyle(
fontSize: AppFont.body, color: AppColors.textPrimary),
),
onTap: () {
Navigator.pop(ctx);
logic.pinConversation(info);
},
),
const Divider(height: 0.5, color: AppColors.divider),
ListTile(
title: const Text(
'删除',
style: TextStyle(
fontSize: AppFont.body, color: AppColors.danger),
),
onTap: () {
Navigator.pop(ctx);
logic.deleteConversation(info);
},
),
],
),
),
);
},
);
}
Widget _buildItemView(ConversationInfo info) => Builder(
builder: (context) {
return InkWell(
onTap: () => logic.toChat(conversationInfo: info),
onLongPress: () => _showConversationMenu(context, info),
splashColor: AppColors.chatBg,
highlightColor: AppColors.chatBg,
child: Container(
height: 72,
padding: const EdgeInsets.symmetric(horizontal: AppGap.x4),
child: Row(
children: [
AvatarView(
width: 48.w,
height: 48.h,
text: logic.getShowName(info),
url: info.faceURL,
isGroup: logic.isGroupChat(info),
textStyle: Styles.ts_FFFFFF_14sp_medium,
),
const SizedBox(width: AppGap.x3),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
Widget _buildItemView(ConversationInfo info) => Ink(
child: InkWell(
onTap: () => logic.toChat(conversationInfo: info),
child: Stack(
children: [
Container(
height: 68,
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Row(
children: [
Stack(
children: [
Row(
children: [
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 180.w),
child: Text(
logic.getShowName(info),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: AppFont.body,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
),
const Spacer(),
Text(
logic.getTime(info),
style: const TextStyle(
fontSize: AppFont.small,
color: AppColors.textSecondary),
),
],
),
const SizedBox(height: AppGap.x1),
Row(
children: [
MatchTextView(
text: logic.getContent(info),
textStyle: const TextStyle(
fontSize: AppFont.sub,
color: AppColors.textSecondary),
prefixSpan: TextSpan(
text: '',
children: [
if (logic.isNotDisturb(info) &&
logic.getUnreadCount(info) > 0)
TextSpan(
text: '[${sprintf(StrRes.nPieces, [
logic.getUnreadCount(info)
])}] ',
style: const TextStyle(
fontSize: AppFont.sub,
color: AppColors.textSecondary),
),
TextSpan(
text: logic.getPrefixTag(info),
style: const TextStyle(
fontSize: AppFont.sub,
color: AppColors.primary),
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const Spacer(),
if (logic.isNotDisturb(info))
const Icon(Icons.notifications_off_outlined,
size: AppIconSize.sm,
color: AppColors.textSecondary)
else
UnreadCountView(
count: logic.getUnreadCount(info)),
],
AvatarView(
width: 48.w,
height: 48.h,
text: logic.getShowName(info),
url: info.faceURL,
isGroup: logic.isGroupChat(info),
textStyle: Styles.ts_FFFFFF_14sp_medium,
),
],
),
),
],
12.horizontalSpace,
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 180.w),
child: logic.getShowName(info).toText
..style = Styles.ts_0C1C33_17sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
),
const Spacer(),
logic.getTime(info).toText..style = Styles.ts_8E9AB0_12sp,
],
),
3.verticalSpace,
Row(
children: [
MatchTextView(
text: logic.getContent(info),
textStyle: Styles.ts_8E9AB0_14sp,
prefixSpan: TextSpan(
text: '',
children: [
if (logic.isNotDisturb(info) && logic.getUnreadCount(info) > 0)
TextSpan(
text: '[${sprintf(StrRes.nPieces, [logic.getUnreadCount(info)])}] ',
style: Styles.ts_8E9AB0_14sp,
),
TextSpan(
text: logic.getPrefixTag(info),
style: Styles.ts_0089FF_14sp,
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const Spacer(),
if (logic.isNotDisturb(info))
ImageRes.notDisturb.toImage
..width = 13.63.w
..height = 14.07.h
else
UnreadCountView(count: logic.getUnreadCount(info)),
],
),
],
),
),
],
),
),
),
);
},
if (logic.isPinned(info))
Container(
height: 68.h,
margin: EdgeInsets.only(right: 6.w),
foregroundDecoration: RotatedCornerDecoration.withColor(
color: Styles.c_0089FF,
badgeSize: Size(8.29.w, 8.29.h),
),
)
],
),
),
);
}
+2 -10
View File
@@ -24,7 +24,6 @@ class HomeLogic extends SuperController {
final unhandledFriendApplicationCount = 0.obs;
final unhandledGroupApplicationCount = 0.obs;
final unhandledCount = 0.obs;
final isOffline = false.obs;
String? _lockScreenPwd;
bool _isShowScreenLock = false;
bool? _isAutoLogin;
@@ -51,8 +50,7 @@ class HomeLogic extends SuperController {
void getUnhandledFriendApplicationCount() async {
var i = 0;
var list = await OpenIM.iMManager.friendshipManager
.getFriendApplicationListAsRecipient();
var list = await OpenIM.iMManager.friendshipManager.getFriendApplicationListAsRecipient();
var haveReadList = DataSp.getHaveReadUnHandleFriendApplication();
haveReadList ??= <String>[];
for (var info in list) {
@@ -67,8 +65,7 @@ class HomeLogic extends SuperController {
void getUnhandledGroupApplicationCount() async {
var i = 0;
var list = await OpenIM.iMManager.groupManager
.getGroupApplicationListAsRecipient();
var list = await OpenIM.iMManager.groupManager.getGroupApplicationListAsRecipient();
var haveReadList = DataSp.getHaveReadUnHandleGroupApplication();
haveReadList ??= <String>[];
for (var info in list) {
@@ -104,12 +101,7 @@ class HomeLogic extends SuperController {
if (value.status == IMSdkStatus.syncStart) {
_getRTCInvitationStart();
}
isOffline.value = value.status == IMSdkStatus.connectionFailed;
});
final lastStatus = imLogic.imSdkStatusSubject.values.lastOrNull;
if (lastStatus != null) {
isOffline.value = lastStatus.status == IMSdkStatus.connectionFailed;
}
Apis.kickoffController.stream.listen((event) {
DataSp.removeLoginCertificate();
+28 -46
View File
@@ -4,12 +4,10 @@ import 'package:openim_common/openim_common.dart';
import '../contacts/contacts_view.dart';
import '../conversation/conversation_view.dart';
import '../discover/discover_view.dart';
import '../mine/mine_view.dart';
import '../discover/discover_view.dart';
import 'home_logic.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../../company/feature_flags.dart';
import '../../company/ui/state_views.dart';
class HomePage extends StatelessWidget {
final logic = Get.find<HomeLogic>();
@@ -23,43 +21,38 @@ class HomePage extends StatelessWidget {
onDoubleTap: () {
logic.scrollToUnreadMessage();
},
child: _setupIcon(
ImageRes.homeTab1Sel.toImage, logic.unreadMsgCount.value),
child: _setupIcon(ImageRes.homeTab1Sel.toImage, logic.unreadMsgCount.value),
),
inactiveIcon: _setupIcon(
ImageRes.homeTab1Nor.toImage, logic.unreadMsgCount.value),
inactiveIcon: _setupIcon(ImageRes.homeTab1Nor.toImage, logic.unreadMsgCount.value),
title: StrRes.home,
textStyle: Styles.ts_0089FF_12sp,
textStyle: Styles.ts_0089FF_10sp_semibold,
),
),
PersistentTabConfig(
screen: ContactsPage(),
item: ItemConfig(
icon: _setupIcon(
ImageRes.homeTab2Sel.toImage, logic.unhandledCount.value),
inactiveIcon: _setupIcon(
ImageRes.homeTab2Nor.toImage, logic.unhandledCount.value),
icon: _setupIcon(ImageRes.homeTab2Sel.toImage, logic.unhandledCount.value),
inactiveIcon: _setupIcon(ImageRes.homeTab2Nor.toImage, logic.unhandledCount.value),
title: StrRes.contacts,
textStyle: Styles.ts_0089FF_12sp,
textStyle: Styles.ts_0089FF_10sp_semibold,
),
),
if (!FeatureFlags.hideDiscover)
PersistentTabConfig(
screen: DiscoverPage(),
item: ItemConfig(
icon: ImageRes.homeTab3Sel.toImage,
inactiveIcon: ImageRes.homeTab3Nor.toImage,
title: StrRes.workbench,
textStyle: Styles.ts_0089FF_12sp,
),
PersistentTabConfig(
screen: DiscoverPage(),
item: ItemConfig(
icon: ImageRes.homeTab3Sel.toImage,
inactiveIcon: ImageRes.homeTab3Nor.toImage,
title: StrRes.workbench,
textStyle: Styles.ts_0089FF_10sp_semibold,
),
),
PersistentTabConfig(
screen: MinePage(),
item: ItemConfig(
icon: ImageRes.homeTab4Sel.toImage,
inactiveIcon: ImageRes.homeTab4Nor.toImage,
title: StrRes.mine,
textStyle: Styles.ts_0089FF_12sp,
textStyle: Styles.ts_0089FF_10sp_semibold,
),
),
];
@@ -86,30 +79,19 @@ class HomePage extends StatelessWidget {
return Scaffold(
backgroundColor: Styles.c_FFFFFF,
body: Obx(
() => Column(
children: [
CompanyOfflineBannerHost(visible: logic.isOffline.value),
Expanded(
child: PersistentTabView(
tabs: _tabs(),
navBarBuilder: (navBarConfig) => Style1BottomNavBar(
navBarConfig: navBarConfig,
navBarDecoration: const NavBarDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 0.5,
spreadRadius: 0.5),
],
),
),
navBarOverlap: const NavBarOverlap.none(),
screenTransitionAnimation:
const ScreenTransitionAnimation.none(),
),
() => PersistentTabView(
tabs: _tabs(),
navBarBuilder: (navBarConfig) => Style1BottomNavBar(
navBarConfig: navBarConfig,
navBarDecoration: const NavBarDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(color: Colors.black12, blurRadius: 0.5, spreadRadius: 0.5),
],
),
],
),
navBarOverlap: const NavBarOverlap.none(),
screenTransitionAnimation: const ScreenTransitionAnimation.none(),
),
),
);
@@ -1,11 +1,9 @@
import 'package:common_utils/common_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
import '../../../company/feature_flags.dart';
import '../../../core/controller/im_controller.dart';
import 'my_info_logic.dart';
@@ -59,11 +57,9 @@ class MyInfoPage extends StatelessWidget {
_buildCornerBgView(
children: [
_buildItemView(
label: FeatureFlags.staffLoginOnly ? '工号' : StrRes.mobile,
value: FeatureFlags.staffLoginOnly
? OpenIM.iMManager.userID
: imLogic.userInfo.value.phoneNumber,
showRightArrow: !FeatureFlags.staffLoginOnly,
label: StrRes.mobile,
value: imLogic.userInfo.value.phoneNumber,
showRightArrow: false,
),
_buildItemView(
label: StrRes.email,
+1 -3
View File
@@ -74,9 +74,7 @@ class AppNavigator {
static startFavoriteMange() => Get.toNamed(AppRoutes.favoriteManage);
static startAddColleague() => Get.toNamed(AppRoutes.addColleague);
static startAddContactsMethod() => Get.toNamed(AppRoutes.addColleague);
static startAddContactsMethod() => Get.toNamed(AppRoutes.addContactsMethod);
static startScan() => Permissions.camera(() => Get.to(
() => const QrcodeView(),
+4 -14
View File
@@ -74,9 +74,8 @@ import '../pages/global_search/global_search_binding.dart';
import '../pages/global_search/global_search_view.dart';
import '../pages/home/home_binding.dart';
import '../pages/home/home_view.dart';
import '../company/auth/company_login_logic.dart';
import '../company/ui/add_colleague_page.dart';
import '../company/ui/company_login_page.dart';
import '../pages/login/login_binding.dart';
import '../pages/login/login_view.dart';
import '../pages/mine/about_us/about_us_binding.dart';
import '../pages/mine/about_us/about_us_view.dart';
import '../pages/mine/account_setup/account_setup_binding.dart';
@@ -133,10 +132,8 @@ class AppPages {
),
_pageBuilder(
name: AppRoutes.login,
page: () => const CompanyLoginPage(),
binding: BindingsBuilder(() {
Get.lazyPut(() => CompanyLoginLogic());
}),
page: () => LoginPage(),
binding: LoginBinding(),
),
_pageBuilder(
name: AppRoutes.home,
@@ -165,13 +162,6 @@ class AppPages {
page: () => FavoriteManagePage(),
binding: FavoriteManageBinding(),
),
_pageBuilder(
name: AppRoutes.addColleague,
page: () => const AddColleaguePage(),
binding: BindingsBuilder(() {
Get.lazyPut(() => AddColleagueLogic());
}),
),
_pageBuilder(
name: AppRoutes.addContactsMethod,
page: () => AddContactsMethodPage(),
-1
View File
@@ -9,7 +9,6 @@ abstract class AppRoutes {
static const myQrcode = '/my_qrcode';
static const chatSetup = '/chat_setup';
static const favoriteManage = '/favorite_manage';
static const addColleague = '/add_colleague';
static const addContactsMethod = '/add_contacts_method';
static const addContactsBySearch = '/add_contacts_by_search';
static const userProfilePanel = '/user_profile_panel';
@@ -425,23 +425,18 @@ const Map<String, String> en_US = {
"outgoingCall": "Outgoing",
"microphone": "Microphone",
"speaker": "Speaker",
"callMute": "Mute",
"callHandsFree": "Speaker",
"callEnded": "Call ended",
"callNetworkInterrupted": "Network error, call interrupted",
"unansweredCall": "Missed call",
"hangUp": "Hang Up",
"pickUp": "Pick Up",
"waitingCallHint": "Calling...",
"waitingVoiceCallHint": "Waiting for the other party to accept the invitation...",
"invitedVoiceCallHint": "Inviting you to a voice call",
"waitingVoiceCallHint": "Waiting for the other party to pick up...",
"invitedVoiceCallHint": "Inviting you for a voice call...",
"waitingVideoCallHint": "Waiting for the other party to accept the invitation",
"invitedVideoCallHint": "Inviting you for a video call...",
"waitingToAnswer": "Waiting to answer",
"invitedYouToCall": "Invited you to a call",
"calling": "Calling...",
"nPeopleCalling": "%s people on the call",
'busyVideoCallHint': 'The other party is busy and cannot receive the invitation',
'busyVideoCallHint': 'A user is busy and cannot receive your invitation',
'inviterBusyVideoCallHint': 'You are in another call and cannot send an invitation',
"whoInvitedVoiceCallHint": "%s invited you for a voice call",
"whoInvitedVideoCallHint": "%s invited you for a video call",
@@ -540,8 +535,6 @@ const Map<String, String> en_US = {
"nWeek": "%s weeks",
"nMonth": "%s months",
"talkTooShort": "Talk too short",
"remainingRecordSeconds": "%s seconds left",
"autoSendMaxVoiceDuration": "Reached the 60-second limit, sent automatically",
'quoteContentBeRevoked': 'Quote content be revoked',
'tapTooShort': 'Tap too short',
'createGroupTips': 'The group name cannot exceed 16 characters',
@@ -427,23 +427,18 @@ const Map<String, String> zh_CN = {
"outgoingCall": "呼出",
"microphone": "麦克风",
"speaker": "扬声器",
"callMute": "静音",
"callHandsFree": "免提",
"callEnded": "通话已结束",
"callNetworkInterrupted": "网络异常,通话中断",
"unansweredCall": "未接通话",
"hangUp": "挂断",
"pickUp": "接听",
"waitingCallHint": "正在呼叫中…",
"waitingVoiceCallHint": "正在等待对方接受邀请",
"invitedVoiceCallHint": "邀请你语音通话",
"waitingVoiceCallHint": "正在等待对方接",
"invitedVoiceCallHint": "邀请你进行语音通话",
"waitingVideoCallHint": "正在等待对方接受邀请",
"invitedVideoCallHint": "邀请你进行视频通话…",
"waitingToAnswer": "等待接听",
"invitedYouToCall": "邀请你通话",
"calling": "通话中",
"nPeopleCalling": "%s人正在通话中",
'busyVideoCallHint': '对方正忙,无法接收邀请',
'busyVideoCallHint': '有用户正忙,无法接收到你的邀请',
'inviterBusyVideoCallHint': '你在其它通话中,无法发起邀请',
"whoInvitedVoiceCallHint": "%s邀请你进行语音通话",
"whoInvitedVideoCallHint": "%s邀请你进行视频通话",
@@ -540,8 +535,6 @@ const Map<String, String> zh_CN = {
"nWeek": "%s周",
"nMonth": "%s个月",
"talkTooShort": "说话时间太短",
"remainingRecordSeconds": "还可说 %s 秒",
"autoSendMaxVoiceDuration": "已达最长 60 秒,已自动发送",
'quoteContentBeRevoked': '引用内容已被删除',
'tapTooShort': '点击时间太短',
'createGroupTips': '群名称不能超过16个字符',
@@ -874,16 +874,6 @@ class StrRes {
static String get speaker => 'speaker'.tr;
static String get callMute => 'callMute'.tr;
static String get callHandsFree => 'callHandsFree'.tr;
static String get callEnded => 'callEnded'.tr;
static String get callNetworkInterrupted => 'callNetworkInterrupted'.tr;
static String get unansweredCall => 'unansweredCall'.tr;
static String get hangUp => 'hangUp'.tr;
static String get pickUp => 'pickUp'.tr;
@@ -1100,10 +1090,6 @@ class StrRes {
static String get talkTooShort => 'talkTooShort'.tr;
static String get remainingRecordSeconds => 'remainingRecordSeconds'.tr;
static String get autoSendMaxVoiceDuration => 'autoSendMaxVoiceDuration'.tr;
static String get quoteContentBeRevoked => 'quoteContentBeRevoked'.tr;
static String get tapTooShort => 'tapTooShort'.tr;
@@ -1,5 +1,3 @@
import 'dart:ui' show FontFeature;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
@@ -14,47 +12,7 @@ class Styles {
static Color c_FFFFFF = const Color(0xFFFFFFFF); // 警告色
static Color c_18E875 = const Color(0xFF18E875); // 在线
static Color c_F0F2F6 = const Color(0xFFF0F2F6); // 聊天页底部
static Color c_F2F3F5 = const Color(0xFFF2F3F5); // 搜索/输入框底 searchBg
static Color c_000000 = const Color(0xFF000000); //
static Duration durationOverlay = const Duration(milliseconds: 150);
static Duration durationOverlaySwitch = const Duration(milliseconds: 100);
static Duration callPageMotion = const Duration(milliseconds: 250);
static Duration callEndHold = const Duration(milliseconds: 1500);
static Duration callInviteTimeout = const Duration(seconds: 60);
static Duration callVibrate = const Duration(seconds: 1);
static Color c_callBg = const Color(0xFF1D2129);
static Color c_callSecondaryBg = c_FFFFFF.withOpacity(.12);
static double callNameTop = 96;
static double callAvatar = 96;
static double callPrimaryBtn = 64;
static double callSecondaryBtn = 48;
static double callIncomingGap = 96;
static double callMinimizeHit = 48;
static double callIconSize = 24;
static TextStyle ts_callName = TextStyle(
color: c_FFFFFF,
fontSize: 18.sp,
fontWeight: FontWeight.w500,
);
static TextStyle ts_callStatus = TextStyle(
color: c_FFFFFF.withOpacity(.6),
fontSize: 14.sp,
);
static TextStyle ts_callTimer = TextStyle(
color: c_FFFFFF,
fontSize: 32.sp,
fontFeatures: const [FontFeature.tabularFigures()],
);
static bool Function(BuildContext) reduceMotion =
(BuildContext context) => MediaQuery.maybeOf(context)?.disableAnimations ?? false;
static Duration Function(BuildContext, Duration) motionOf =
(BuildContext context, Duration normal) => reduceMotion(context) ? Duration.zero : normal;
static Color c_92B3E0 = const Color(0xFF92B3E0);
static Color c_F2F8FF = const Color(0xFFF2F8FF); // 同步成功背景色
static Color c_F8F9FA = const Color(0xFFF8F9FA); // 默认背景
@@ -306,11 +264,6 @@ class Styles {
fontSize: 10.sp,
);
static TextStyle ts_FFB300_12sp = TextStyle(
color: c_FFB300,
fontSize: 12.sp,
);
static TextStyle ts_6085B1_17sp_medium = TextStyle(
color: c_6085B1,
fontSize: 17.sp,
@@ -939,7 +939,7 @@ class IMUtils {
content = StrRes.rejectedByCaller;
break;
case 'timeout':
content = StrRes.unansweredCall;
content = StrRes.callTimeout;
break;
case 'networkError':
content = StrRes.networkAnomaly;
@@ -34,7 +34,7 @@ class ChatBubble extends StatelessWidget {
alignment: alignment,
decoration: BoxDecoration(
color:
backgroundColor ?? (isISend ? Styles.c_CCE7FE : Styles.c_FFFFFF),
backgroundColor ?? (isISend ? Styles.c_CCE7FE : Styles.c_F4F5F7),
borderRadius: borderRadius(isISend),
),
child: child,
@@ -105,20 +105,15 @@ class _ChatInputBoxState extends State<ChatInputBox> /*with TickerProviderStateM
children: [
Container(
constraints: BoxConstraints(minHeight: kInputBoxMinHeight),
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
border: Border(
top: BorderSide(color: Styles.c_E8EAEF, width: 0.5),
),
),
color: Styles.c_F0F2F6,
child: Row(
children: [
12.horizontalSpace,
(_leftKeyboardButton
? (ImageRes.openKeyboard.toImage..onTap = onTapLeftKeyboard)
: (ImageRes.openVoice.toImage..onTap = onTapSpeak))
..width = 24.w
..height = 24.h
..width = 32.w
..height = 32.h
..opacity = _opacity,
12.horizontalSpace,
Expanded(
@@ -139,33 +134,15 @@ class _ChatInputBoxState extends State<ChatInputBox> /*with TickerProviderStateM
(_rightKeyboardButton
? (ImageRes.openKeyboard.toImage..onTap = onTapRightKeyboard)
: (ImageRes.openEmoji.toImage..onTap = onTapEmoji))
..width = 24.w
..height = 24.h
..width = 32.w
..height = 32.h
..opacity = _opacity,
12.horizontalSpace,
_sendButtonVisible
? GestureDetector(
onTap: send,
child: Opacity(
opacity: _opacity,
child: Container(
height: 32,
padding: EdgeInsets.symmetric(horizontal: 12.w),
alignment: Alignment.center,
decoration: BoxDecoration(
color: Styles.c_0089FF,
borderRadius: BorderRadius.circular(8.r),
),
child: StrRes.send.toText
..style = Styles.ts_FFFFFF_14sp_medium,
),
),
)
: ((ImageRes.openToolbox).toImage
..width = 24.w
..height = 24.h
..opacity = _opacity
..onTap = toggleToolbox),
(_sendButtonVisible ? ImageRes.sendMessage : ImageRes.openToolbox).toImage
..width = 32.w
..height = 32.h
..opacity = _opacity
..onTap = _sendButtonVisible ? send : toggleToolbox,
12.horizontalSpace,
],
),
@@ -201,11 +178,10 @@ class _ChatInputBoxState extends State<ChatInputBox> /*with TickerProviderStateM
}
Widget get _textFiled => Container(
constraints: const BoxConstraints(minHeight: 40),
margin: EdgeInsets.only(top: 8.h, bottom: _showQuoteView ? 4.h : 8.h),
margin: EdgeInsets.only(top: 10.h, bottom: _showQuoteView ? 4.h : 10.h),
decoration: BoxDecoration(
color: Styles.c_F2F3F5,
borderRadius: BorderRadius.circular(8.r),
color: Styles.c_FFFFFF,
borderRadius: BorderRadius.circular(4.r),
),
child: ChatTextField(
controller: widget.controller,
@@ -8,12 +8,17 @@ import 'package:focus_detector_v2/focus_detector_v2.dart';
import 'package:openim_common/openim_common.dart';
import 'package:rxdart/rxdart.dart';
double maxWidth = 0.65.sw;
double pictureWidth = 200;
double maxWidth = 247.w;
double pictureWidth = 120.w;
double videoWidth = 120.w;
double locationWidth = 220.w;
BorderRadius borderRadius(bool isISend) => BorderRadius.circular(8.r);
BorderRadius borderRadius(bool isISend) => BorderRadius.only(
topLeft: Radius.circular(isISend ? 6.r : 0),
topRight: Radius.circular(isISend ? 0 : 6.r),
bottomLeft: Radius.circular(6.r),
bottomRight: Radius.circular(6.r),
);
class MsgStreamEv<T> {
final String id;
@@ -251,7 +256,7 @@ class _ChatItemViewState extends State<ChatItemView> {
message: _message,
);
} else if (_message.isVoiceType) {
isBubbleBg = false;
isBubbleBg = true;
final sound = _message.soundElem;
child = ChatVoiceView(
isISend: _isISend,
@@ -259,7 +264,6 @@ class _ChatItemViewState extends State<ChatItemView> {
soundUrl: sound?.sourceUrl,
duration: sound?.duration,
isPlaying: widget.isPlayingSound,
isUnread: !_isISend && _message.isRead == false,
);
} else if (_message.isVideoType) {
child = widget.mediaItemBuilder?.call(context, _message) ??
@@ -30,28 +30,14 @@ class ChatPicturePreview extends StatelessWidget {
offset: offset,
pageSize: pageSize,
),
child: Stack(
children: [
MetaHero(
heroTag: heroTag,
onTap: onTap ?? () => Get.back(),
onLongPress: () {
final index = controller?.page?.round() ?? currentIndex;
onLongPress?.call(images[index].url!);
},
child: _childView,
),
if (images.length > 1)
Positioned(
top: MediaQuery.paddingOf(context).top + 12,
left: 16,
child: _PreviewIndex(
controller: controller,
total: images.length,
initial: currentIndex,
),
),
],
child: MetaHero(
heroTag: heroTag,
onTap: onTap ?? () => Get.back(),
onLongPress: () {
final index = controller?.page?.round() ?? 0;
onLongPress?.call(images[index].url!);
},
child: _childView,
),
);
}
@@ -93,14 +79,19 @@ class ChatPicturePreview extends StatelessWidget {
if (source.url?.isVideoFileName == true) {
return null;
}
final ImageChunkEvent? loadingProgress = state.loadingProgress;
final double? progress = loadingProgress?.expectedTotalBytes != null
? loadingProgress!.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
: null;
return Center(
child: SizedBox(
width: 24,
height: 24,
return SizedBox(
width: 15.0,
height: 15.0,
child: Center(
child: CircularProgressIndicator(
color: Styles.c_0089FF,
strokeWidth: 2,
strokeWidth: 1.5,
value: progress ?? 0,
),
),
);
@@ -119,23 +110,7 @@ class ChatPicturePreview extends StatelessWidget {
);
case LoadState.failed:
state.imageProvider.evict();
return GestureDetector(
onTap: () => state.reLoadImage(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.broken_image_outlined,
size: 64, color: Styles.c_FFFFFF_opacity70),
const SizedBox(height: 16),
const Text('图片加载失败',
style: TextStyle(color: Colors.white, fontSize: 16)),
const SizedBox(height: 4),
Text('点击重试',
style: TextStyle(
color: Styles.c_FFFFFF_opacity70, fontSize: 14)),
],
),
);
return ImageRes.pictureError.toImage;
}
},
);
@@ -165,50 +140,3 @@ class MetaHero extends StatelessWidget {
return heroTag == null ? view : Hero(tag: heroTag!, child: view);
}
}
class _PreviewIndex extends StatefulWidget {
const _PreviewIndex({
required this.controller,
required this.total,
required this.initial,
});
final ExtendedPageController? controller;
final int total;
final int initial;
@override
State<_PreviewIndex> createState() => _PreviewIndexState();
}
class _PreviewIndexState extends State<_PreviewIndex> {
late int _index;
@override
void initState() {
super.initState();
_index = widget.initial;
widget.controller?.addListener(_onPage);
}
void _onPage() {
final page = widget.controller?.page?.round();
if (page != null && page != _index) {
setState(() => _index = page);
}
}
@override
void dispose() {
widget.controller?.removeListener(_onPage);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text(
'${_index + 1}/${widget.total}',
style: const TextStyle(color: Colors.white, fontSize: 14),
);
}
}
@@ -44,20 +44,12 @@ class _ChatPictureViewState extends State<ChatPictureView> {
var h = picture?.sourcePicture?.height?.toDouble() ?? 1.0;
if (pictureWidth > w) {
_trulyWidth = w < 80 ? 80 : w;
_trulyHeight = _trulyWidth * h / w;
_trulyWidth = w;
_trulyHeight = h;
} else {
_trulyWidth = pictureWidth;
_trulyHeight = _trulyWidth * h / w;
}
if (_trulyHeight < 80) {
_trulyHeight = 80;
_trulyWidth = _trulyHeight * w / h;
if (_trulyWidth > pictureWidth) {
_trulyWidth = pictureWidth;
_trulyHeight = _trulyWidth * h / w;
}
}
final height = pictureWidth * 1.sh / 1.sw;
@@ -38,7 +38,7 @@ class ChatToolBox extends StatelessWidget {
ToolboxItemInfo(
text: StrRes.toolboxCall,
icon: ImageRes.toolboxCall,
onTap: () => Permissions.microphone(onTapCall),
onTap: () => Permissions.cameraAndMicrophone(onTapCall),
),
ToolboxItemInfo(
text: StrRes.toolboxFile,
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatVoiceReadStatusView extends StatelessWidget {
@@ -6,8 +7,8 @@ class ChatVoiceReadStatusView extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
width: 8,
height: 8,
width: 6.w,
height: 6.w,
decoration: BoxDecoration(
color: Styles.c_FF381F,
shape: BoxShape.circle,
@@ -5,7 +5,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
double kVoiceRecordBarHeight = 40;
double kVoiceRecordBarHeight = 36.h;
enum RecordBarStatus {
holdTalk,
@@ -39,7 +39,7 @@ class ChatVoiceRecordBar extends StatefulWidget {
class _ChatVoiceRecordBarState extends State<ChatVoiceRecordBar> {
bool _pressing = false;
bool _canCancel = false;
final double _offset = 60;
final double _offset = 40.h;
StreamSubscription? _sub;
@override
@@ -106,15 +106,12 @@ class _ChatVoiceRecordBarState extends State<ChatVoiceRecordBar> {
height: kVoiceRecordBarHeight,
alignment: Alignment.center,
decoration: BoxDecoration(
color: widget.speakBarColor ??
(_pressing ? Styles.c_F2F8FF : Styles.c_F2F3F5),
borderRadius: BorderRadius.circular(8.r),
color: widget.speakBarColor ?? (_pressing ? Styles.c_8E9AB0_opacity30 : Styles.c_FFFFFF),
borderRadius: BorderRadius.circular(4.r),
),
child: Text(
_pressing
? (_canCancel ? StrRes.liftFingerToCancelSend : StrRes.releaseToSend)
: StrRes.holdTalk,
style: widget.speakTextStyle ?? Styles.ts_0C1C33_17sp,
_pressing ? (_canCancel ? StrRes.liftFingerToCancelSend : StrRes.releaseToSend) : StrRes.holdTalk,
style: widget.speakTextStyle ?? Styles.ts_0C1C33_14sp_medium,
),
),
);
@@ -5,7 +5,6 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:lottie/lottie.dart';
import 'package:openim_common/openim_common.dart';
import 'package:rxdart/rxdart.dart';
import 'package:sprintf/sprintf.dart';
typedef SpeakViewChildBuilder = Widget Function(ChatVoiceRecordBar recordBar);
@@ -37,12 +36,9 @@ class _ChatVoiceRecordLayoutState extends State<ChatVoiceRecordLayout> {
bool _showVoiceRecordView = false;
RecordBarStatus _status = RecordBarStatus.holdTalk;
bool _isCancelSend = false;
bool _autoSendByLimit = false;
void _completed(int sec, String path) {
final autoSend = _autoSendByLimit;
_autoSendByLimit = false;
if (_isCancelSend && !autoSend) {
if (_isCancelSend) {
File(path).delete();
_status = RecordBarStatus.holdTalk;
_isCancelSend = false;
@@ -52,9 +48,6 @@ class _ChatVoiceRecordLayoutState extends State<ChatVoiceRecordLayout> {
IMViews.showToast(StrRes.talkTooShort);
} else {
widget.onCompleted?.call(sec, path);
if (autoSend) {
IMViews.showToast(StrRes.autoSendMaxVoiceDuration);
}
}
}
}
@@ -90,8 +83,6 @@ class _ChatVoiceRecordLayoutState extends State<ChatVoiceRecordLayout> {
},
onLongPressStart: (details) {
setState(() {
_autoSendByLimit = false;
_isCancelSend = false;
_showVoiceRecordView = true;
});
},
@@ -102,106 +93,47 @@ class _ChatVoiceRecordLayoutState extends State<ChatVoiceRecordLayout> {
return Stack(
children: [
widget.builder(_createSpeakBar),
if (_showVoiceRecordView)
_VoiceRecordOverlayFade(
child: ChatRecordVoiceView(
maxRecordSec: widget.maxRecordSec,
onCompleted: _completed,
onInterrupt: () {
if (_autoSendByLimit) return;
_autoSendByLimit = true;
setState(() {
_interruptSub.add(true);
_showVoiceRecordView = false;
});
},
builder: (_, sec) => Material(
color: Colors.transparent,
child: Center(
child: AnimatedContainer(
duration: Styles.motionOf(context, Styles.durationOverlaySwitch),
curve: Curves.easeOut,
width: 160.w,
height: 140.h,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
decoration: BoxDecoration(
color: Styles.c_000000_opacity70,
borderRadius: BorderRadius.circular(12.r),
),
child: Column(
children: [
_timerText(sec),
Expanded(
child: _isCancelSend
? Icon(Icons.delete_outline,
size: 48.w, color: Styles.c_FF381F)
: _lottieAnimWidget,
),
(_isCancelSend
? StrRes.liftFingerToCancelSend
: StrRes.releaseToSendSwipeUpToCancel)
.toText
..style = _isCancelSend
? Styles.ts_FF381F_12sp
: Styles.ts_FFFFFF_12sp
..maxLines = 1,
],
),
Visibility(
visible: _showVoiceRecordView,
child: ChatRecordVoiceView(
onCompleted: _completed,
onInterrupt: () {
setState(() {
_interruptSub.add(true);
_showVoiceRecordView = false;
});
},
builder: (_, sec) => Material(
color: Colors.transparent,
child: Center(
child: Container(
width: 138.w,
height: 124.h,
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 6.h),
decoration: BoxDecoration(
color: _isCancelSend ? Styles.c_FF381F_opacity70 : Styles.c_0C1C33_opacity60,
borderRadius: BorderRadius.circular(6.r),
),
child: Column(
children: [
IMUtils.seconds2HMS(sec).toText..style = Styles.ts_FFFFFF_12sp,
Expanded(child: _lottieAnimWidget),
(_isCancelSend ? StrRes.liftFingerToCancelSend : StrRes.releaseToSendSwipeUpToCancel).toText
..style = Styles.ts_FFFFFF_12sp,
],
),
),
),
),
),
),
],
);
}
Widget _timerText(int sec) {
final remaining = widget.maxRecordSec - sec;
final nearLimit = sec >= widget.maxRecordSec - 10 && remaining > 0;
final text = nearLimit
? sprintf(StrRes.remainingRecordSeconds, [remaining])
: IMUtils.seconds2HMS(sec);
return text.toText
..style = nearLimit ? Styles.ts_FFB300_12sp : Styles.ts_FFFFFF_12sp;
}
Widget get _lottieAnimWidget => Lottie.asset(
'assets/anim/voice_record.json',
fit: BoxFit.contain,
package: 'openim_common',
);
}
class _VoiceRecordOverlayFade extends StatefulWidget {
const _VoiceRecordOverlayFade({required this.child});
final Widget child;
@override
State<_VoiceRecordOverlayFade> createState() => _VoiceRecordOverlayFadeState();
}
class _VoiceRecordOverlayFadeState extends State<_VoiceRecordOverlayFade> {
double _opacity = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() => _opacity = 1);
});
}
@override
Widget build(BuildContext context) {
final reduce = Styles.reduceMotion(context);
return AnimatedOpacity(
opacity: reduce ? 1 : _opacity,
duration: Styles.motionOf(context, Styles.durationOverlay),
curve: Curves.easeOut,
child: widget.child,
);
}
}
@@ -24,13 +24,11 @@ class ChatRecordVoiceView extends StatefulWidget {
class _ChatRecordVoiceViewState extends State<ChatRecordVoiceView> {
static const _dir = "voice";
static const _ext = ".m4a";
String? _path;
late String _path;
int _startTimestamp = 0;
final _audioRecorder = AudioRecorder();
Timer? _timer;
int _duration = 0;
bool _interrupted = false;
bool _completed = false;
static int _now() => DateTime.now().millisecondsSinceEpoch;
@@ -39,28 +37,16 @@ class _ChatRecordVoiceViewState extends State<ChatRecordVoiceView> {
(() async {
if (await _audioRecorder.hasPermission()) {
_path = '${await IMUtils.createTempDir(dir: _dir)}/${_now()}$_ext';
await _audioRecorder.start(RecordConfig(), path: _path!);
if (!mounted) {
await _audioRecorder.stop();
return;
}
await _audioRecorder.start(RecordConfig(), path: _path);
_startTimestamp = _now();
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
final duration = ((_now() - _startTimestamp) ~/ 1000);
if (duration >= widget.maxRecordSec) {
timer.cancel();
_timer = null;
_duration = duration;
_interruptByLimit();
return;
}
_timer = null;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) async {
setState(() {
_duration = duration;
_duration = ((_now() - _startTimestamp) ~/ 1000);
if (_duration >= widget.maxRecordSec) {
widget.onInterrupt?.call();
}
});
});
}
@@ -68,32 +54,17 @@ class _ChatRecordVoiceViewState extends State<ChatRecordVoiceView> {
super.initState();
}
void _interruptByLimit() {
if (_interrupted) return;
_interrupted = true;
widget.onInterrupt?.call();
}
@override
void dispose() {
(() async {
_timer?.cancel();
_timer = null;
Future<void> _stopAndComplete() async {
if (_completed) return;
_completed = true;
_timer?.cancel();
_timer = null;
try {
if (await _audioRecorder.isRecording()) {
await _audioRecorder.stop();
}
} catch (_) {}
final path = _path;
if (path == null) return;
widget.onCompleted?.call(_duration, path);
}
@override
void dispose() {
_timer?.cancel();
_timer = null;
unawaited(_stopAndComplete());
widget.onCompleted?.call(_duration, _path);
})();
super.dispose();
}
@@ -8,7 +8,6 @@ class ChatVoiceView extends StatelessWidget {
final String? soundUrl;
final int? duration;
final bool isPlaying;
final bool isUnread;
const ChatVoiceView({
Key? key,
@@ -17,63 +16,60 @@ class ChatVoiceView extends StatelessWidget {
this.soundUrl,
this.duration,
this.isPlaying = false,
this.isUnread = false,
}) : super(key: key);
double get _bubbleWidth {
final sec = duration ?? 0;
final width = 64.0 + (sec > 0 ? (sec - 1) : 0) * 8.0;
return width.clamp(64.0, 200.0);
}
Widget _buildVoiceAnimView() {
final icon = isPlaying
? (ImageRes.voiceBlueAnim.toLottie
..height = 24.h
..width = 24.w
..fit = BoxFit.fitHeight)
: (ImageRes.voiceBlue.toImage
..width = 24.w
..height = 24.h);
final seconds = '${duration ?? 0}'.toText
..style = Styles.ts_0C1C33_14sp;
return isISend
? Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
seconds,
8.horizontalSpace,
RotatedBox(quarterTurns: 90, child: icon),
'${duration ?? 0}``'.toText..style = Styles.ts_0089FF_17sp,
4.horizontalSpace,
RotatedBox(
quarterTurns: 90,
child: isPlaying
? (ImageRes.voiceBlueAnim.toLottie
..height = 25.h
..width = 25.w
..fit = BoxFit.fitHeight)
: (ImageRes.voiceBlue.toImage
..width = 25.w
..height = 25.h),
),
],
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
icon,
8.horizontalSpace,
seconds,
if (isPlaying)
ImageRes.voiceBlueAnim.toLottie
..width = 24.w
..height = 24.h
..fit = BoxFit.fitHeight
else
ImageRes.voiceBlue.toImage
..width = 24.w
..height = 24.h,
4.horizontalSpace,
'${duration ?? 0}``'.toText..style = Styles.ts_0089FF_17sp,
],
);
}
@override
Widget build(BuildContext context) {
final bubble = ChatBubble(
bubbleType: isISend ? BubbleType.send : BubbleType.receiver,
child: SizedBox(
width: _bubbleWidth,
child: _buildVoiceAnimView(),
return Container(
margin: EdgeInsets.only(
left: isISend ? _margin : 0,
right: !isISend ? _margin : 0,
),
);
if (!isUnread) return bubble;
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
bubble,
8.horizontalSpace,
const ChatVoiceReadStatusView(),
],
child: _buildVoiceAnimView(),
);
}
double get _margin {
final maxWidth = 100.w;
const maxDuration = 60;
double diff = (duration ?? 0) * maxWidth / maxDuration;
return diff > maxWidth ? maxWidth : diff;
}
}
@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class UnreadCountView extends StatelessWidget {
const UnreadCountView({
Key? key,
this.count = 0,
this.size = 18,
this.size = 13,
this.margin,
}) : super(key: key);
final int count;
@@ -19,25 +20,43 @@ class UnreadCountView extends StatelessWidget {
child: Container(
alignment: Alignment.center,
margin: margin,
padding: count > 99 ? const EdgeInsets.symmetric(horizontal: 4) : null,
constraints: BoxConstraints(
minWidth: size,
minHeight: size,
maxHeight: size,
),
decoration: BoxDecoration(
color: Styles.c_FF381F,
borderRadius: BorderRadius.circular(9),
),
child: Text(
'${count > 99 ? '99+' : count}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFFFFFFFF),
),
textAlign: TextAlign.center,
),
padding: count > 99 ? EdgeInsets.symmetric(horizontal: 4.w) : null,
constraints: BoxConstraints(maxHeight: size, minWidth: size),
decoration: _decoration,
child: _text,
),
);
}
Text get _text => Text(
'${count > 99 ? '99+' : count}',
style: TextStyle(
fontSize: 8.sp,
color: const Color(0xFFFFFFFF),
),
textAlign: TextAlign.center,
);
Decoration get _decoration => BoxDecoration(
color: Styles.c_FF381F,
shape: count > 99 ? BoxShape.rectangle : BoxShape.circle,
borderRadius: count > 99 ? BorderRadius.circular(10.r) : null,
boxShadow: [
BoxShadow(
color: const Color(0x26C61B4A),
offset: Offset(1.15.w, 1.15.h),
blurRadius: 57.58.r,
),
BoxShadow(
color: const Color(0x1AC61B4A),
offset: Offset(2.3.w, 2.3.h),
blurRadius: 11.52.r,
),
BoxShadow(
color: const Color(0x0DC61B4A),
offset: Offset(4.61.w, 4.61.h),
blurRadius: 17.28.r,
),
],
);
}
@@ -5,7 +5,6 @@ import 'package:rxdart/rxdart.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'pages/single/room.dart';
import 'widgets/call_page_host.dart';
enum CallType { audio, video }
@@ -62,8 +61,6 @@ class OpenIMLiveClient implements RTCBridge {
}
static OverlayEntry? _holder;
static GlobalKey<CallPageHostState>? _hostKey;
bool _closing = false;
bool isBusy = false;
@@ -84,25 +81,13 @@ class OpenIMLiveClient implements RTCBridge {
}
close() {
if (_closing) return;
_closing = true;
final host = _hostKey?.currentState;
if (host == null) {
_removeHolder();
return;
}
host.dismiss().whenComplete(_removeHolder);
}
void _removeHolder() {
if (_holder != null) {
_holder?.remove();
_holder = null;
}
_hostKey = null;
isBusy = false;
currentRoomID = null;
_closing = false;
// The next line disables the wakelock again.
WakelockPlus.disable();
}
@@ -133,10 +118,9 @@ class OpenIMLiveClient implements RTCBridge {
Function(dynamic error, dynamic stack)? onError,
Function()? onClose,
Function()? onRoomDisconnected,
Future Function()? onInviteTimeout,
}) {
if (isBusy) return;
_closing = false;
// close();
isBusy = true;
currentRoomID = roomID;
this.onTapHangup = onTapHangup;
@@ -144,35 +128,30 @@ class OpenIMLiveClient implements RTCBridge {
FocusScope.of(ctx).requestFocus(FocusNode());
if (callObj == CallObj.single) {
_hostKey = GlobalKey<CallPageHostState>();
_holder = OverlayEntry(
builder: (context) => CallPageHost(
key: _hostKey,
child: SingleRoomView(
callType: callType,
initState: initState,
callEventSubject: callEventSubject,
roomID: roomID,
userID: initState == CallState.call ? inviteeUserIDList.first : inviterUserID,
onDial: onDialSingle,
onTapCancel: onTapCancel,
onTapHangup: onTapHangup,
onTapReject: onTapReject,
onTapPickup: onTapPickup,
onSyncUserInfo: onSyncUserInfo,
autoPickup: autoPickup,
onBindRoomID: (roomID) => currentRoomID = roomID,
onWaitingAccept: onWaitingAccept,
onBusyLine: onBusyLine,
onStartCalling: onStartCalling,
onError: onError,
onRoomDisconnected: onRoomDisconnected,
onInviteTimeout: onInviteTimeout,
onClose: () {
onClose?.call();
close();
},
),
builder: (context) => SingleRoomView(
callType: callType,
initState: initState,
callEventSubject: callEventSubject,
roomID: roomID,
userID: initState == CallState.call ? inviteeUserIDList.first : inviterUserID,
onDial: onDialSingle,
onTapCancel: onTapCancel,
onTapHangup: onTapHangup,
onTapReject: onTapReject,
onTapPickup: onTapPickup,
onSyncUserInfo: onSyncUserInfo,
autoPickup: autoPickup,
onBindRoomID: (roomID) => currentRoomID = roomID,
onWaitingAccept: onWaitingAccept,
onBusyLine: onBusyLine,
onStartCalling: onStartCalling,
onError: onError,
onRoomDisconnected: onRoomDisconnected,
onClose: () {
onClose?.call();
close();
},
));
} else {}
@@ -10,7 +10,6 @@ import 'package:just_audio/just_audio.dart';
import 'package:openim_common/openim_common.dart';
import 'package:rxdart/rxdart.dart';
import 'package:uuid/uuid.dart';
import 'package:vibration/vibration.dart';
import '../openim_live.dart';
@@ -54,11 +53,12 @@ mixin OpenIMLive {
final _ring = 'assets/audio/live_ring.wav';
final _audioPlayer = AudioPlayer(
// Handle audio_session events ourselves for the purpose of this demo.
handleInterruptions: false,
// androidApplyAudioAttributes: false,
// handleAudioSessionActivation: false,
);
Timer? _incomingVibrateTimer;
bool get isBusy => OpenIMLiveClient().isBusy;
onCloseLive() {
@@ -94,7 +94,7 @@ mixin OpenIMLive {
(event) async {
_beCalledEvent = null;
if (event.state == CallState.beCalled) {
_playSound(vibrate: true);
_playSound();
final mediaType = event.data.invitation!.mediaType;
final sessionType = event.data.invitation!.sessionType;
final callType = mediaType == 'audio' ? CallType.audio : CallType.video;
@@ -134,12 +134,6 @@ mixin OpenIMLive {
),
onError: onError,
onRoomDisconnected: () => onRoomDisconnected(event.data),
onStartCalling: onLiveConnected,
onInviteTimeout: () => _onInviteTimeout(event.data),
onClose: () {
_stopSound();
onLiveSessionClosed();
},
);
} else if (event.state == CallState.beRejected) {
insertSignalingMessageSubject.add(event);
@@ -196,7 +190,7 @@ mixin OpenIMLive {
inviterUserID: inviterUserID,
inviteeUserIDList: inviteeUserIDList,
roomID: roomID ?? groupID ?? const Uuid().v4(),
timeout: Styles.callInviteTimeout.inSeconds,
timeout: 30,
mediaType: mediaType,
sessionType: sessionType,
platformID: IMUtils.getPlatform(),
@@ -230,20 +224,16 @@ mixin OpenIMLive {
onBusyLine: onBusyLine,
onStartCalling: () {
_stopSound();
onLiveConnected();
},
onError: onError,
onRoomDisconnected: () => onRoomDisconnected(signal),
onInviteTimeout: () => _onInviteTimeout(signal),
onClose: () {
_stopSound();
onLiveSessionClosed();
},
onClose: _stopSound,
);
}
onError(error, stack) {
Logger.print('onError=====> $error $stack');
OpenIMLiveClient().close();
_stopSound();
if (error is PlatformException) {
if (int.parse(error.code) == SDKErrorCode.hasBeenBlocked) {
@@ -251,7 +241,7 @@ mixin OpenIMLive {
return;
}
}
IMViews.showToast(StrRes.callNetworkInterrupted);
IMViews.showToast(StrRes.networkError);
}
onRoomDisconnected(SignalingInfo signalingInfo) {}
@@ -265,20 +255,11 @@ mixin OpenIMLive {
offlinePushInfo: OfflinePushInfo(),
userID: signaling.invitation!.inviteeUserIDList!.first,
isOnlineOnly: true);
final certificate = await getRtcCertificate(signaling.invitation!.roomID!, OpenIM.iMManager.userID);
final certificate = await Apis.getTokenForRTC(signaling.invitation!.roomID!, OpenIM.iMManager.userID);
return certificate;
}
/// 公司层可覆盖:走账号服务 `/api/rtc_token`,默认仍走官方 chat 换票。
Future<SignalingCertificate> getRtcCertificate(String roomID, String userID) {
return Apis.getTokenForRTC(roomID, userID);
}
void onLiveSessionClosed() {}
void onLiveConnected() {}
Future<SignalingCertificate> onTapPickup(SignalingInfo signaling) async {
_beCalledEvent = null; // ios bug
_autoPickup = false;
@@ -291,7 +272,7 @@ mixin OpenIMLive {
offlinePushInfo: OfflinePushInfo(),
userID: signaling.invitation!.inviterUserID,
isOnlineOnly: true);
final certificate = await getRtcCertificate(signaling.invitation!.roomID!, OpenIM.iMManager.userID);
final certificate = await Apis.getTokenForRTC(signaling.invitation!.roomID!, OpenIM.iMManager.userID);
return certificate;
}
@@ -333,20 +314,12 @@ mixin OpenIMLive {
OpenIM.iMManager.messageManager.sendMessage(
message: message,
offlinePushInfo: OfflinePushInfo(),
userID: signaling.invitation!.inviterUserID == OpenIM.iMManager.userID
? signaling.invitation!.inviteeUserIDList!.first
: signaling.invitation!.inviterUserID,
userID: signaling.invitation!.inviterUserID,
isOnlineOnly: true);
return true;
}
Future<void> _onInviteTimeout(SignalingInfo signaling) async {
_stopSound();
insertSignalingMessageSubject.add(CallEvent(CallState.timeout, signaling));
await onTimeoutCancelled(signaling);
}
onTapHangup(SignalingInfo signaling, int duration, bool isPositive) async {
if (isPositive) {
final data = {'customType': CustomMessageType.callingHungup, 'data': signaling.invitation!.toJson()};
@@ -397,44 +370,16 @@ mixin OpenIMLive {
return list;
}
void _playSound({bool vibrate = false}) async {
void _playSound() async {
if (!_audioPlayer.playerState.playing) {
_audioPlayer.setAsset(_ring, package: 'openim_common');
_audioPlayer.setLoopMode(LoopMode.one);
_audioPlayer.setVolume(1.0);
_audioPlayer.play();
}
if (vibrate) {
_startIncomingVibrate();
}
}
void _startIncomingVibrate() {
_stopIncomingVibrate();
_pulseVibrate();
_incomingVibrateTimer = Timer.periodic(Styles.callVibrate, (_) => _pulseVibrate());
}
void _stopIncomingVibrate() {
_incomingVibrateTimer?.cancel();
_incomingVibrateTimer = null;
try {
Vibration.cancel();
} catch (_) {}
}
Future<void> _pulseVibrate() async {
try {
if (await Vibration.hasVibrator() == true) {
await Vibration.vibrate(duration: 200);
return;
}
} catch (_) {}
HapticFeedback.vibrate();
}
void _stopSound() async {
_stopIncomingVibrate();
if (_audioPlayer.playerState.playing) {
_audioPlayer.stop();
}
@@ -31,7 +31,6 @@ class SingleRoomView extends SignalView {
super.onSyncUserInfo,
super.onError,
super.onRoomDisconnected,
super.onInviteTimeout,
});
@override
@@ -44,59 +43,50 @@ class _SingleRoomViewState extends SignalState<SingleRoomView> {
@override
void dispose() {
final room = _room;
final listener = _listener;
_room = null;
_listener = null;
room?.removeListener(_onRoomDidUpdate);
() async {
await listener?.dispose();
await room?.remoteParticipants.values.firstOrNull?.dispose();
await room?.localParticipant?.dispose();
await room?.disconnect();
await room?.dispose();
}();
// always dispose listener
(() async {
_room?.removeListener(_onRoomDidUpdate);
await _listener?.dispose();
await _room?.remoteParticipants.values.firstOrNull?.dispose();
await _room?.localParticipant?.dispose();
await _room?.disconnect();
await _room?.dispose();
})();
super.dispose();
}
@override
Future<void> connect() async {
final url = certificate.liveURL;
final token = certificate.token;
if (url == null || url.isEmpty || token == null || token.isEmpty) {
widget.onError?.call(StateError('missing livekit credential'), StackTrace.current);
onNetworkInterrupted();
return;
}
final url = certificate.liveURL!;
final token = certificate.token!;
final busyLineUsers = certificate.busyLineUserIDList ?? [];
if (busyLineUsers.isNotEmpty) {
widget.onBusyLine?.call();
await beginEnding(CallState.cancel);
widget.onClose?.call();
return;
}
// Try to connect to a room
// This will throw an Exception if it fails for any reason.
try {
_room = Room(
roomOptions: const RoomOptions(
dynacast: true,
adaptiveStream: true,
defaultCameraCaptureOptions: CameraCaptureOptions(params: VideoParametersPresets.h720_169),
defaultVideoPublishOptions: VideoPublishOptions(
simulcast: true,
videoCodec: 'VP9',
videoEncoding: VideoEncoding(
maxBitrate: 5 * 1000 * 1000,
maxFramerate: 15,
),
),
),
);
//create new room
_room = Room();
// Create a Listener before connecting
_listener = _room?.createListener();
await _room?.connect(
url,
token,
connectOptions: const ConnectOptions(autoSubscribe: true),
);
// Try to connect to the room
// This will throw an Exception if it fails for any reason.
await _room?.connect(url, token,
roomOptions: RoomOptions(
dynacast: true,
adaptiveStream: true,
defaultCameraCaptureOptions: const CameraCaptureOptions(params: VideoParametersPresets.h720_169),
defaultVideoPublishOptions: VideoPublishOptions(
simulcast: true,
videoCodec: 'VP9',
videoEncoding: const VideoEncoding(
maxBitrate: 5 * 1000 * 1000,
maxFramerate: 15,
))));
if (!mounted) return;
_room?.addListener(_onRoomDidUpdate);
if (null != _listener) _setUpListeners();
@@ -110,7 +100,6 @@ class _SingleRoomViewState extends SignalState<SingleRoomView> {
});
} catch (error, stackTrace) {
widget.onError?.call(error, stackTrace);
onNetworkInterrupted();
}
}
@@ -119,15 +108,9 @@ class _SingleRoomViewState extends SignalState<SingleRoomView> {
Logger.print('Room disconnected: reason => ${event.reason}');
WidgetsBindingCompatible.instance?.addPostFrameCallback((_) {
widget.onRoomDisconnected?.call();
onNetworkInterrupted();
widget.onClose?.call();
});
})
..on<RoomAttemptReconnectEvent>((event) {
IMViews.showToast('通话重连中');
})
..on<RoomReconnectedEvent>((event) {
IMViews.showToast('通话已恢复');
})
..on<RoomRecordingStatusChanged>((event) {})
..on<LocalTrackPublishedEvent>((_) => _sortParticipants())
..on<LocalTrackUnpublishedEvent>((_) => _sortParticipants())
@@ -36,7 +36,6 @@ abstract class SignalView extends StatefulWidget {
this.onStartCalling,
this.onError,
this.onRoomDisconnected,
this.onInviteTimeout,
}) : super(key: key);
final CallType callType;
final CallState initState;
@@ -57,7 +56,6 @@ abstract class SignalView extends StatefulWidget {
final Function()? onRoomDisconnected;
final Function(dynamic error, dynamic stack)? onError;
final Future<UserInfo?> Function(String userID)? onSyncUserInfo;
final Future Function()? onInviteTimeout;
}
abstract class SignalState<T extends SignalView> extends State<T> {
@@ -72,9 +70,6 @@ abstract class SignalState<T extends SignalView> extends State<T> {
int duration = 0;
bool enabledMicrophone = true;
bool enabledSpeaker = true;
Timer? _inviteTimeout;
bool _ending = false;
bool _closed = false;
ParticipantTrack? remoteParticipantTrack;
ParticipantTrack? localParticipantTrack;
@@ -87,14 +82,11 @@ abstract class SignalState<T extends SignalView> extends State<T> {
widget.onSyncUserInfo?.call(widget.userID).then(_onUpdateUserInfo);
onDail();
autoPickup();
_armInviteTimeout();
super.initState();
}
@override
void dispose() {
_inviteTimeout?.cancel();
_inviteTimeout = null;
callStateSubject.close();
callEventSub?.cancel();
super.dispose();
@@ -113,8 +105,9 @@ abstract class SignalState<T extends SignalView> extends State<T> {
/// 某些信令通过liveKit的监听
_onStateDidUpdate(CallEvent event) {
Logger.print("CallEvent : 当前:$callState 收到:$event");
if (!mounted || _ending) return;
if (!mounted) return;
// ui 状态只有 呼叫,被呼叫,通话中,连接中
if (event.state == CallState.call ||
event.state == CallState.beCalled ||
event.state == CallState.connecting ||
@@ -122,23 +115,18 @@ abstract class SignalState<T extends SignalView> extends State<T> {
callStateSubject.add(event.state);
}
if (event.state == CallState.beRejected) {
beginEnding(CallState.beRejected, toast: StrRes.rejectedByCaller);
} else if (event.state == CallState.beCanceled) {
beginEnding(CallState.beCanceled);
if (event.state == CallState.beRejected || event.state == CallState.beCanceled) {
widget.onClose?.call();
} else if (event.state == CallState.otherReject || event.state == CallState.otherAccepted) {
if (existParticipants()) {
return;
}
widget.onClose?.call();
IMViews.showToast(sprintf(StrRes.otherCallHandle, [event.state == CallState.otherReject ? StrRes.rejectCall : StrRes.accept]));
beginEnding(event.state);
} else if (event.state == CallState.timeout) {
beginEnding(CallState.timeout);
} else if (event.state == CallState.beHangup) {
beginEnding(CallState.beHangup);
} else if (event.state == CallState.networkError) {
beginEnding(CallState.networkError, toast: StrRes.callNetworkInterrupted);
widget.onClose?.call();
} else if (event.state == CallState.beAccepted) {
// 邀请对象比发起对象提前进入房间
if (null != remoteParticipantTrack) {
onParticipantConnected();
}
@@ -146,26 +134,21 @@ abstract class SignalState<T extends SignalView> extends State<T> {
}
onParticipantConnected() {
_cancelInviteTimeout();
callStateSubject.add(CallState.calling);
widget.onStartCalling?.call();
}
onParticipantDisconnected() {
if (_ending) return;
onTapHangup(false);
}
/// 发起者在对方为进入房间都是 等待状态
onDail() async {
if (widget.initState == CallState.call) {
try {
certificate = await widget.onDial!.call();
widget.onBindRoomID?.call(roomID = certificate.roomID!);
await connect();
} catch (error, stackTrace) {
widget.onError?.call(error, stackTrace);
}
// callStateSubject.add(CallState.connecting);
certificate = await widget.onDial!.call();
widget.onBindRoomID?.call(roomID = certificate.roomID!);
await connect();
}
}
@@ -177,41 +160,26 @@ abstract class SignalState<T extends SignalView> extends State<T> {
onTapPickup() async {
Logger.print('------------onTapPickup---------连接中--------');
try {
callStateSubject.add(CallState.connecting);
certificate = await widget.onTapPickup!.call();
widget.onBindRoomID?.call(roomID = certificate.roomID!);
await connect();
_cancelInviteTimeout();
callStateSubject.add(CallState.calling);
widget.onStartCalling?.call();
Logger.print('------------onTapPickup---------连接成功--------');
} catch (error, stackTrace) {
widget.onError?.call(error, stackTrace);
}
callStateSubject.add(CallState.connecting);
certificate = await widget.onTapPickup!.call();
widget.onBindRoomID?.call(roomID = certificate.roomID!);
await connect();
callStateSubject.add(CallState.calling);
widget.onStartCalling?.call();
Logger.print('------------onTapPickup---------连接成功--------');
}
/// [isPositive] 人为挂断行为
onTapHangup(bool isPositive) async {
if (_ending) return;
await widget.onTapHangup?.call(duration, isPositive);
await beginEnding(CallState.hangup);
await widget.onTapHangup?.call(duration, isPositive).whenComplete(() => /*isPositive ? {} : */ widget.onClose?.call());
}
onTapCancel() async {
if (_ending) return;
await widget.onTapCancel?.call();
await beginEnding(CallState.cancel);
await widget.onTapCancel?.call().whenComplete(() => widget.onClose?.call());
}
onTapReject() async {
if (_ending) return;
await widget.onTapReject?.call();
await beginEnding(CallState.reject);
}
onNetworkInterrupted() {
beginEnding(CallState.networkError, toast: StrRes.callNetworkInterrupted);
await widget.onTapReject?.call().whenComplete(() => widget.onClose?.call());
}
onTapMinimize() {
@@ -259,55 +227,6 @@ abstract class SignalState<T extends SignalView> extends State<T> {
bool smallScreenIsRemote = true;
void _armInviteTimeout() {
if (widget.initState != CallState.call && widget.initState != CallState.beCalled) {
return;
}
_inviteTimeout?.cancel();
_inviteTimeout = Timer(Styles.callInviteTimeout, () async {
if (!mounted || _ending) return;
if (callState == CallState.calling) return;
if (widget.onInviteTimeout != null) {
await widget.onInviteTimeout!.call();
} else if (widget.initState == CallState.call) {
await widget.onTapCancel?.call();
} else {
await widget.onTapReject?.call();
}
await beginEnding(CallState.timeout);
});
}
void _cancelInviteTimeout() {
_inviteTimeout?.cancel();
_inviteTimeout = null;
}
Future<void> beginEnding(CallState endState, {String? toast}) async {
if (_ending || _closed) return;
_ending = true;
_cancelInviteTimeout();
if (!mounted) {
_finishClose();
return;
}
callState = endState;
if (!callStateSubject.isClosed) {
callStateSubject.add(endState);
}
if (toast != null && toast.isNotEmpty) {
IMViews.showToast(toast);
}
await Future<void>.delayed(Styles.callEndHold);
_finishClose();
}
void _finishClose() {
if (_closed) return;
_closed = true;
widget.onClose?.call();
}
@override
Widget build(BuildContext context) => Stack(
children: [
@@ -317,7 +236,7 @@ abstract class SignalState<T extends SignalView> extends State<T> {
duration: const Duration(milliseconds: 200),
onEnd: () {},
child: Container(
color: Styles.c_callBg,
color: Styles.c_000000,
child: Stack(
children: [
// ImageRes.liveBg.toImage
@@ -3,15 +3,25 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:openim_common/openim_common.dart';
import 'package:openim_live/src/widgets/live_button.dart';
import 'package:synchronized/synchronized.dart';
import '../../../live_client.dart';
import '../../../widgets/loading_view.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:openim_common/openim_common.dart';
import 'package:openim_live/src/widgets/live_button.dart';
import 'package:openim_live/src/widgets/loading_view.dart';
import 'package:synchronized/synchronized.dart';
import '../../../live_client.dart';
class ControlsView extends StatefulWidget {
const ControlsView({
Key? key,
@@ -72,8 +82,8 @@ class _ControlsViewState extends State<ControlsView> {
/// 默认启用麦克风
bool _enabledMicrophone = true;
/// 语音默认听筒,视频默认扬声器
late bool _enabledSpeaker;
/// 默认开启扬声器
bool _enabledSpeaker = true;
final _lockAudio = Lock();
final _lockSpeaker = Lock();
@@ -90,7 +100,6 @@ class _ControlsViewState extends State<ControlsView> {
@override
void initState() {
_enabledSpeaker = widget.callType != CallType.audio;
_onChangedCallState(widget.initState);
_callStateChangedSub = widget.callStateStream.listen(_onChangedCallState);
_roomDidUpdateSub = widget.roomDidUpdateStream.listen(_roomDidUpdate);
@@ -107,7 +116,6 @@ class _ControlsViewState extends State<ControlsView> {
_participant = room.localParticipant;
_participant?.addListener(_onChange);
}
Hardware.instance.setSpeakerphoneOn(_enabledSpeaker);
}
_onChangedCallState(CallState state) {
@@ -152,7 +160,6 @@ class _ControlsViewState extends State<ControlsView> {
} else {
await _disableAudio();
}
if (mounted) setState(() {});
});
// if (null != _participant) {
@@ -233,168 +240,127 @@ class _ControlsViewState extends State<ControlsView> {
// }
}
bool get _isEnding =>
_callState == CallState.hangup ||
_callState == CallState.beHangup ||
_callState == CallState.cancel ||
_callState == CallState.beCanceled ||
_callState == CallState.timeout ||
_callState == CallState.beRejected ||
_callState == CallState.reject ||
_callState == CallState.networkError;
@override
Widget build(BuildContext context) {
final topInset = MediaQuery.of(context).padding.top;
return Stack(
children: [
Positioned(
left: 16,
top: topInset + 8,
child: SizedBox(
width: Styles.callMinimizeHit,
height: Styles.callMinimizeHit,
child: IconButton(
padding: EdgeInsets.zero,
icon: Icon(Icons.keyboard_arrow_down, color: Styles.c_FFFFFF, size: Styles.callIconSize),
onPressed: widget.onMinimize,
Widget build(BuildContext context) => SafeArea(
child: Stack(
children: [
Positioned(
left: 16.w,
top: 7.h,
child: ImageRes.liveClose.toImage
..width = 30.w
..height = 30.h
..onTap = widget.onMinimize,
),
),
),
if (null != _participant && isVideo)
Positioned(
right: 16,
top: topInset + 8,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
_participant!.isCameraEnabled() ? Icons.videocam_off_outlined : Icons.videocam_outlined,
color: Styles.c_FFFFFF,
size: Styles.callIconSize,
if (null != _participant)
Positioned(
right: 16.w,
top: 7.h,
child: Visibility(
visible: isVideo,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
(_participant!.isCameraEnabled() ? ImageRes.liveCameraOff : ImageRes.liveCameraOn).toImage
..width = 30.w
..height = 30.h
..onTap = (_participant!.isCameraEnabled() ? _disableVideo : _enableVideo),
16.horizontalSpace,
ImageRes.liveSwitchCamera.toImage
..width = 30.w
..height = 30.h
..onTap = _toggleCamera,
],
),
onPressed: _participant!.isCameraEnabled() ? _disableVideo : _enableVideo,
),
IconButton(
icon: Icon(Icons.cameraswitch_outlined, color: Styles.c_FFFFFF, size: Styles.callIconSize),
onPressed: _toggleCamera,
),
],
),
if (null != widget.userInfo)
Positioned(
top: 166.h,
width: 1.sw,
child: _userInfoView,
),
Positioned(
bottom: 32.h,
width: 1.sw,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: _buttonGroup,
),
),
),
Positioned.fill(child: _infoColumn),
Positioned(
left: 0,
right: 0,
bottom: 32 + MediaQuery.of(context).padding.bottom,
child: _buttonArea,
Positioned(
bottom: 156.h,
width: 1.sw,
child: Center(child: _videoCallingDurationView),
),
if (_callState == CallState.connecting) const LiveLoadingView(),
],
),
if (_callState == CallState.connecting) const LiveLoadingView(),
],
);
}
Widget get _infoColumn {
final nickname = widget.userInfo == null
? ''
: (IMUtils.emptyStrToNull(widget.userInfo!.remark) ?? widget.userInfo!.nickname ?? '');
final faceURL = widget.userInfo?.faceURL;
return Column(
children: [
SizedBox(height: Styles.callNameTop),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
nickname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Styles.ts_callName,
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_statusText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: isCalling && !isVideo ? Styles.ts_callTimer : Styles.ts_callStatus,
),
),
const SizedBox(height: 32),
AvatarView(
width: Styles.callAvatar,
height: Styles.callAvatar,
text: nickname,
url: faceURL,
borderRadius: BorderRadius.circular(Styles.callAvatar * 0.18),
),
],
);
}
String get _statusText {
if (_callState == CallState.call) {
return isVideo ? StrRes.waitingVideoCallHint : StrRes.waitingVoiceCallHint;
}
if (_callState == CallState.beCalled) {
return isVideo ? StrRes.invitedVideoCallHint : StrRes.invitedVoiceCallHint;
}
if (_callState == CallState.connecting) {
return StrRes.connecting;
}
if (_callState == CallState.networkError) {
return StrRes.callNetworkInterrupted;
}
if (_callState == CallState.beRejected || _callState == CallState.reject) {
return StrRes.rejectedByCaller;
}
if (_isEnding) {
return StrRes.callEnded;
}
if (isCalling) {
return _callingDurationStr;
}
return '';
}
Widget get _buttonArea {
if (_callState == CallState.call ||
(_callState == CallState.connecting && widget.initState == CallState.call)) {
return Center(child: LiveButton.cancel(onTap: widget.onCancel));
}
if (_callState == CallState.beCalled) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
LiveButton.reject(onTap: widget.onReject),
SizedBox(width: Styles.callIncomingGap),
LiveButton.pickUp(onTap: widget.onPickUp),
],
);
List<Widget> get _buttonGroup {
if (_callState == CallState.call || _callState == CallState.connecting && widget.initState == CallState.call) {
return [
LiveButton.microphone(on: _enabledMicrophone, onTap: _toggleAudio),
LiveButton.cancel(onTap: widget.onCancel),
LiveButton.speaker(on: _enabledSpeaker, onTap: _toggleSpeaker),
];
} else if (_callState == CallState.beCalled || _callState == CallState.connecting && widget.initState == CallState.beCalled) {
return [
LiveButton.reject(onTap: widget.onReject),
LiveButton.pickUp(onTap: widget.onPickUp),
];
} else if (_callState == CallState.calling) {
return [
LiveButton.microphone(on: _enabledMicrophone, onTap: _toggleAudio),
LiveButton.hungUp(onTap: () => widget.onHangUp?.call(true)),
LiveButton.speaker(on: _enabledSpeaker, onTap: _toggleSpeaker),
];
}
if (_callState == CallState.calling) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
LiveButton.mute(muted: !_enabledMicrophone, onTap: _toggleAudio),
const SizedBox(width: 48),
LiveButton.handsFree(on: _enabledSpeaker, onTap: _toggleSpeaker),
],
),
const SizedBox(height: 32),
LiveButton.hungUp(onTap: () => widget.onHangUp?.call(true)),
],
);
}
return const SizedBox.shrink();
return [];
}
bool get isVideo => widget.callType == CallType.video;
bool get isCalling => _callState == CallState.calling;
Widget get _videoCallingDurationView => Visibility(
visible: isVideo && isCalling,
child: _callingDurationStr.toText..style = Styles.ts_FFFFFF_opacity70_17sp,
);
Widget get _userInfoView {
String text;
if (_callState == CallState.call) {
text = isVideo ? StrRes.waitingVideoCallHint : StrRes.waitingVoiceCallHint;
} else if (_callState == CallState.beCalled) {
text = isVideo ? StrRes.invitedVideoCallHint : StrRes.invitedVoiceCallHint;
} else if (_callState == CallState.connecting) {
text = StrRes.connecting;
} else {
text = isVideo ? '' : _callingDurationStr;
}
String? nickname = IMUtils.emptyStrToNull(widget.userInfo!.remark) ?? widget.userInfo!.nickname;
String? faceURL = widget.userInfo!.faceURL;
return Visibility(
visible: !(isVideo && isCalling),
child: Column(
children: [
AvatarView(width: 70.w, height: 70.h, text: nickname, url: faceURL),
10.verticalSpace,
(nickname ?? '').toText..style = Styles.ts_FFFFFF_20sp_medium,
10.verticalSpace,
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: text.toText
..style = Styles.ts_FFFFFF_opacity70_17sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
),
],
),
);
}
}
@@ -1,72 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:openim_common/openim_common.dart';
/// 通话页进出:250ms 自下而上滑入 ease-out,不可打断。
class CallPageHost extends StatefulWidget {
const CallPageHost({
Key? key,
required this.child,
}) : super(key: key);
final Widget child;
@override
CallPageHostState createState() => CallPageHostState();
}
class CallPageHostState extends State<CallPageHost> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
Completer<void>? _dismissing;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: Styles.callPageMotion,
);
_controller.forward();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_controller.duration = Styles.motionOf(context, Styles.callPageMotion);
}
Future<void> dismiss() {
if (_dismissing != null) return _dismissing!.future;
_dismissing = Completer<void>();
final future = _controller.status == AnimationStatus.dismissed
? Future<void>.value()
: _controller.reverse();
future.whenComplete(() {
if (_dismissing?.isCompleted == false) {
_dismissing!.complete();
}
});
return _dismissing!.future;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)),
child: Material(
color: Styles.c_callBg,
child: widget.child,
),
);
}
}
@@ -1,119 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
/// 通话页圆形按钮:Token 色圆底 + 线性图标,禁止位图 PNG。
class LiveButton extends StatelessWidget {
const LiveButton({
Key? key,
required this.text,
required this.icon,
required this.size,
required this.background,
required this.iconColor,
this.text,
this.onTap,
}) : super(key: key);
final IconData icon;
final double size;
final Color background;
final Color iconColor;
final String? text;
final String text;
final String icon;
final Function()? onTap;
@override
Widget build(BuildContext context) {
final button = Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Ink(
width: size,
height: size,
decoration: BoxDecoration(
color: background,
shape: BoxShape.circle,
),
child: Icon(
icon,
size: Styles.callIconSize,
color: iconColor,
),
),
),
);
if (text == null || text!.isEmpty) return button;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
button,
const SizedBox(height: 8),
Text(text!, style: Styles.ts_callStatus),
icon.toImage
..width = 62.w
..height = 62.h
..onTap = onTap,
10.verticalSpace,
text.toText..style = Styles.ts_FFFFFF_opacity70_14sp,
],
);
}
static Color get _danger => Styles.c_FF381F;
static Color get _success => Styles.c_18E875;
static Color get _white => Styles.c_FFFFFF;
static Color get _dark => Styles.c_0C1C33;
LiveButton.mute({
LiveButton.microphone({
super.key,
this.onTap,
bool muted = false,
}) : text = StrRes.callMute,
icon = muted ? Icons.mic_off_outlined : Icons.mic_none_outlined,
size = Styles.callSecondaryBtn,
background = muted ? _white : Styles.c_callSecondaryBg,
iconColor = muted ? _dark : _white;
bool on = true,
}) : text = StrRes.microphone,
icon = on ? ImageRes.liveMicOn : ImageRes.liveMicOff;
LiveButton.handsFree({
LiveButton.speaker({
super.key,
this.onTap,
bool on = false,
}) : text = StrRes.callHandsFree,
icon = Icons.volume_up_outlined,
size = Styles.callSecondaryBtn,
background = on ? _white : Styles.c_callSecondaryBg,
iconColor = on ? _dark : _white;
bool on = true,
}) : text = StrRes.speaker,
icon = on ? ImageRes.liveSpeakerOn : ImageRes.liveSpeakerOff;
LiveButton.hungUp({
super.key,
this.onTap,
}) : text = StrRes.hangUp,
icon = Icons.call_end,
size = Styles.callPrimaryBtn,
background = _danger,
iconColor = _white;
icon = ImageRes.liveHangUp;
LiveButton.reject({
super.key,
this.onTap,
}) : text = StrRes.reject,
icon = Icons.call_end,
size = Styles.callPrimaryBtn,
background = _danger,
iconColor = _white;
icon = ImageRes.liveHangUp;
LiveButton.cancel({
super.key,
this.onTap,
}) : text = StrRes.cancel,
icon = Icons.call_end,
size = Styles.callPrimaryBtn,
background = _danger,
iconColor = _white;
icon = ImageRes.liveHangUp;
LiveButton.pickUp({
super.key,
this.onTap,
}) : text = StrRes.pickUp,
icon = Icons.phone_outlined,
size = Styles.callPrimaryBtn,
background = _success,
iconColor = _white;
icon = ImageRes.livePicUp;
}
-1
View File
@@ -28,7 +28,6 @@ dependencies:
openim_common:
path: ../openim_common
flutter_openim_sdk: 3.8.3+hotfix.12
vibration: ^1.7.6
dev_dependencies:
flutter_test:
@@ -1,70 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:openim/company/rtc/call_session_guard.dart';
import 'package:openim/company/rtc/livekit_call_config.dart';
void main() {
test('配置门:内网地址与换票路径齐全时才打开入口', () {
expect(LiveKitCallConfig.rtcTokenPath, '/api/rtc_token');
expect(LiveKitCallConfig.endpointsReady, isTrue);
expect(LiveKitCallConfig.enabled, LiveKitCallConfig.shipped);
});
test('默认未接超时是 60 秒', () {
expect(LiveKitCallConfig.inviteTimeout, const Duration(seconds: 60));
expect(CallSessionGuard().inviteTimeout, LiveKitCallConfig.inviteTimeout);
});
test('占线时拒绝第二路呼出/呼入', () {
final guard = CallSessionGuard();
expect(guard.startOutgoing('room-a'), isNull);
expect(guard.startOutgoing('room-b'), 'busy');
expect(guard.startIncoming('room-c'), 'busy');
guard.dispose();
expect(guard.isBusy, isFalse);
expect(guard.startIncoming('room-d'), isNull);
guard.dispose();
});
test('同一房间同一信令只处理一次', () {
final guard = CallSessionGuard();
expect(guard.acceptSignaling(customType: 200, roomID: 'r1'), isTrue);
expect(guard.acceptSignaling(customType: 200, roomID: 'r1'), isFalse);
expect(guard.acceptSignaling(customType: 201, roomID: 'r1'), isTrue);
expect(guard.acceptSignaling(customType: 200, roomID: ''), isFalse);
guard.dispose();
});
test('接听后取消超时,销毁后释放计时器', () async {
var timedOut = false;
final guard = CallSessionGuard(inviteTimeout: const Duration(milliseconds: 40));
guard.onTimeout = () => timedOut = true;
guard.startOutgoing('room-t');
guard.markConnected();
await Future<void>.delayed(const Duration(milliseconds: 80));
expect(timedOut, isFalse);
expect(guard.phase, VoiceCallPhase.inCall);
guard.dispose();
expect(guard.phase, VoiceCallPhase.idle);
expect(guard.roomID, isNull);
});
test('呼出超时回调', () async {
var timedOut = false;
final guard = CallSessionGuard(inviteTimeout: const Duration(milliseconds: 30));
guard.onTimeout = () => timedOut = true;
guard.startOutgoing('room-t');
await Future<void>.delayed(const Duration(milliseconds: 80));
expect(timedOut, isTrue);
guard.dispose();
});
test('sameRoom 只认当前房间', () {
final guard = CallSessionGuard();
guard.startIncoming('room-1');
expect(guard.sameRoom('room-1'), isTrue);
expect(guard.sameRoom('room-2'), isFalse);
expect(guard.sameRoom(null), isFalse);
guard.dispose();
});
}
@@ -1,50 +0,0 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:openim/company/brand/app_tokens.dart';
import 'package:openim/company/brand/apply_official_styles.dart';
import 'package:openim/company/rtc/call_session_guard.dart';
import 'package:openim/company/rtc/livekit_call_config.dart';
import 'package:openim_common/openim_common.dart';
void main() {
setUpAll(applyOfficialStylesFromTokens);
test('未接超时只有 60s 一个来源,不再出现 30s', () {
expect(AppDuration.callInviteTimeout, const Duration(seconds: 60));
expect(LiveKitCallConfig.inviteTimeout, AppDuration.callInviteTimeout);
expect(Styles.callInviteTimeout, AppDuration.callInviteTimeout);
expect(CallSessionGuard().inviteTimeout, AppDuration.callInviteTimeout);
});
test('通话页底色走 callBg #1D2129,不复用大图遮罩黑', () {
expect(AppColors.callBg, const Color(0xFF1D2129));
expect(AppColors.callBg, AppColors.textPrimary);
expect(Styles.c_callBg, AppColors.callBg);
expect(Styles.c_callBg, isNot(AppColors.maskBg));
expect(Styles.c_000000, AppColors.maskBg);
});
test('计时器 32px 纯白 tabular figures,状态字 14px 白 60%,姓名 18px', () {
expect(AppFont.callTimer, 32);
expect(Styles.ts_callTimer.fontSize, 32);
expect(Styles.ts_callTimer.color, AppColors.pageBg);
expect(Styles.ts_callTimer.fontFeatures, contains(FontFeature.tabularFigures()));
expect(Styles.ts_callStatus.fontSize, AppFont.sub);
expect(Styles.ts_callStatus.color, AppColors.pageBg.withOpacity(.6));
expect(Styles.ts_callName.fontSize, AppFont.title);
});
test('通话页尺寸与进出场动效来自 Token', () {
expect(Styles.callAvatar, 96);
expect(Styles.callPrimaryBtn, 64);
expect(Styles.callSecondaryBtn, 48);
expect(Styles.callIncomingGap, 96);
expect(Styles.callNameTop, 96);
expect(Styles.callMinimizeHit, 48);
expect(Styles.callPageMotion, const Duration(milliseconds: 250));
expect(Styles.callEndHold, const Duration(milliseconds: 1500));
expect(Styles.callVibrate, const Duration(seconds: 1));
expect(AppRadius.avatar(96), closeTo(17.28, 0.001));
});
}
@@ -1,71 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:openim/company/rtc/rtc_token_mapper.dart';
void main() {
const fallback = 'ws://192.168.200.11:17880';
test('解析账号服务 token,缺 liveURL 时回落到配置地址', () {
final cred = RtcTokenMapper.parse(
{
'code': 0,
'msg': '',
'data': {'token': 'lk-token'},
},
fallbackLiveUrl: fallback,
);
expect(cred.token, 'lk-token');
expect(cred.liveURL, fallback);
});
test('兼容官方 serverUrl 字段', () {
final cred = RtcTokenMapper.parse(
{
'code': 0,
'data': {'token': 't', 'serverUrl': 'ws://live.example:7880'},
},
fallbackLiveUrl: fallback,
);
expect(cred.liveURL, 'ws://live.example:7880');
});
test('优先使用 liveURL', () {
final cred = RtcTokenMapper.parse(
{
'data': {
'token': 't',
'liveURL': 'ws://a:1',
'serverUrl': 'ws://b:2',
},
},
fallbackLiveUrl: fallback,
);
expect(cred.liveURL, 'ws://a:1');
});
test('业务失败码抛出服务端文案', () {
expect(
() => RtcTokenMapper.parse(
{'code': 1, 'msg': '语音通话服务未配置,请联系管理员'},
fallbackLiveUrl: fallback,
),
throwsA(isA<FormatException>().having(
(e) => e.message,
'message',
'语音通话服务未配置,请联系管理员',
)),
);
});
test('缺 token 失败', () {
expect(
() => RtcTokenMapper.parse(
{
'code': 0,
'data': {'token': ''},
},
fallbackLiveUrl: fallback,
),
throwsA(isA<FormatException>()),
);
});
}
+2 -2
View File
@@ -35,8 +35,8 @@ export function createMainWindow() {
show: false,
width: 1200,
height: 800,
minWidth: 1080,
minHeight: 680,
minWidth: 1024,
minHeight: 726,
titleBarStyle: "hiddenInset",
webPreferences: {
preload: global.pathConfig.preload,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "changlian-pc",
"version": "1.0.2",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "changlian-pc",
"version": "1.0.2",
"version": "1.0.1",
"hasInstallScript": true,
"dependencies": {
"@ant-design/icons": "^5.1.4",
@@ -7,6 +7,7 @@ import { Button, Spin } from "antd";
import { t } from "i18next";
import { memo, useCallback, useState } from "react";
import arrow from "@/assets/images/contact/arrowTopRight.png";
import OIMAvatar from "@/components/OIMAvatar";
import { IMSDK } from "@/layout/MainContentWrap";
import { emit } from "@/utils/events";
@@ -55,7 +56,7 @@ const ApplicationItem = ({
if (source.handleResult === ApplicationHandleResult.Reject) {
return t("application.refused");
}
return isRecv ? t("application.waitingHandle") : t("application.pending");
return t("application.pending");
};
const getAvatarUrl = () => {
@@ -80,26 +81,19 @@ const ApplicationItem = ({
window.userClick(isRecv ? source.fromUserID : source.toUserID);
}, []);
const btnStyle = {
height: 32,
flex: 1,
borderRadius: 8,
};
return (
<Spin spinning={loading}>
<div className="flex min-h-[72px] flex-row items-center justify-between px-4 py-3 transition-colors duration-100 ease-out hover:bg-[var(--chat-bg)]">
<div className="mr-3 flex min-w-0 flex-1 flex-row">
<div className="flex flex-row items-center justify-between p-3.5 transition-colors hover:bg-[var(--primary-active)]">
<div className="flex flex-row">
<OIMAvatar
size={48}
src={getAvatarUrl()}
text={getTitle()}
isgroup={isGroup && !isRecv}
onClick={tryShowCard}
/>
<div className="ml-3 min-w-0 flex-1">
<p className="truncate text-base text-[var(--base-black)]">{getTitle()}</p>
<p className="truncate pt-1 text-xs text-[var(--sub-text)]">
<div className="ml-3">
<p className="text-sm">{getTitle()}</p>
<p className="pb-2.5 pt-[5px] text-xs ">
{getApplicationDesc()}
{(isGroup || (!isGroup && !isRecv)) && (
<span className="ml-1 text-xs text-[var(--primary)]">
@@ -107,30 +101,42 @@ const ApplicationItem = ({
</span>
)}
</p>
{source.reqMsg && (
<p className="truncate text-xs text-[var(--sub-text)]">{source.reqMsg}</p>
)}
<p className="text-xs text-[var(--sub-text)]">
{t("application.information")}:
</p>
<p className="text-xs text-[var(--sub-text)]">{source.reqMsg}</p>
</div>
</div>
{showActionBtn && (
<div className="flex w-[132px] shrink-0 flex-row gap-2">
<Button size="small" onClick={() => loadingWrap(false)} style={btnStyle}>
{t("application.refuse")}
</Button>
<Button
size="small"
type="primary"
style={{ ...btnStyle, backgroundColor: "var(--primary)" }}
onClick={() => loadingWrap(true)}
>
{t("application.agree")}
</Button>
<div className="flex flex-row">
<div className="mr-5.5 h-8 w-[60px]">
<Button
block={true}
size="small"
onClick={() => loadingWrap(false)}
className="!h-full !rounded-md border-2 border-[var(--primary)] text-[var(--primary)]"
>
{t("application.refuse")}
</Button>
</div>
<div className="h-8 w-[60px]">
<Button
block={true}
size="small"
type="primary"
className="!h-full !rounded-md bg-[var(--primary)]"
onClick={() => loadingWrap(true)}
>
{t("application.agree")}
</Button>
</div>
</div>
)}
{!showActionBtn && (
<div className="flex shrink-0 flex-row items-center">
<div className="flex flex-row items-center">
{!isRecv && <img className="mr-2 h-4 w-4" src={arrow} alt="" />}
<p className="text-sm text-[var(--sub-text)]">{getStatusStr()}</p>
</div>
)}
@@ -1,7 +1,7 @@
.sider_resize {
width: 280px;
width: 300px;
min-width: 240px;
max-width: 400px;
max-width: 45vw;
resize: horizontal;
overflow: scroll;
height: 100%;
+1 -16
View File
@@ -113,12 +113,7 @@
"publishFailed": "Failed to publish!",
"notCanAddFriend": "The other party has been set up not to be added as a friend!",
"fileTypeError": "Unsupported file type or folder!",
"inputStaffNo": "Please enter staff number",
"staffOrPasswordWrong": "Incorrect staff number or password",
"networkRetry": "Network error, please try again later",
"inputPeerStaffNo": "Please enter the colleague's staff number",
"addColleagueSent": "Request sent, waiting for approval",
"addColleagueFailed": "Failed to send. Check the staff number and network",
"inputStaffNo": "请输入工号",
"noMicrophone": "No microphone detected, cannot record",
"recordTooShort": "Recording is too short"
},
@@ -193,15 +188,6 @@
"qrCodeLoginTitle": "Please scan the QR code using OpenCorp mobile",
"welcome": "欢迎使用畅联",
"title": "畅联",
"internalComms": "Internal company messaging",
"loggingIn": "Signing in",
"agreeInternal": "By signing in you agree to internal usage rules",
"emptyChat": "Select a conversation on the left to start chatting",
"ungrouped": "Ungrouped",
"colleagueStaffNo": "Staff number",
"verifyMessageOptional": "Verification message (optional)",
"addColleagueDefaultMsg": "Hi, I would like to add you as a colleague",
"sendApplication": "Send request",
"subTitle": "公司内部通讯工具",
"myCreated": "I created",
"myJoined": "I joined",
@@ -482,7 +468,6 @@
"refused": "Refused",
"information": "Verification Information",
"pending": "Pending Verification",
"waitingHandle": "Pending",
"applyToJoin": "Apply to Join",
"applyToAdd": "Apply to Add",
"applyToFriend": "Applied to Add You as a Friend"
-15
View File
@@ -112,11 +112,6 @@
"inputVerifyCode": "请输入您的验证码",
"inputPassword": "请输入您的密码",
"inputStaffNo": "请输入工号",
"staffOrPasswordWrong": "工号或密码不正确",
"networkRetry": "网络异常,请稍后重试",
"inputPeerStaffNo": "请输入对方工号",
"addColleagueSent": "申请已发送,等对方通过",
"addColleagueFailed": "发送失败,请确认工号正确、网络正常",
"sendFreiendRequestSuccess": "发送好友请求成功!",
"registerSuccess": "注册成功!",
"inputInvitationCode": "请输入您的邀请码",
@@ -205,15 +200,6 @@
"qrCodeLoginTitle": "请使用OpenCorp移动端扫描二维码",
"welcome": "欢迎使用畅联",
"title": "畅联",
"internalComms": "企业内部通讯",
"loggingIn": "登录中",
"agreeInternal": "登录即代表同意内部使用规范",
"emptyChat": "从左边选一个会话,开始聊天",
"ungrouped": "未分组",
"colleagueStaffNo": "同事工号",
"verifyMessageOptional": "验证消息(可不填)",
"addColleagueDefaultMsg": "你好,我想加你为同事",
"sendApplication": "发送申请",
"subTitle": "公司内部通讯工具",
"myCreated": "我创建的",
"myJoined": "我加入的",
@@ -494,7 +480,6 @@
"refused": "已拒绝",
"information": "验证信息",
"pending": "等待对方通过",
"waitingHandle": "待处理",
"applyToJoin": "申请加入",
"applyToAdd": "申请添加",
"applyToFriend": "申请添加您为好友"
@@ -2,15 +2,13 @@ import type {
ConversationItem as ConversationItemType,
MessageItem,
} from "@openim/wasm-client-sdk/lib/types/entity";
import { Badge, Dropdown } from "antd";
import { Badge } from "antd";
import clsx from "clsx";
import { t } from "i18next";
import { memo, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import OIMAvatar from "@/components/OIMAvatar";
import { IMSDK } from "@/layout/MainContentWrap";
import { useConversationStore, useUserStore } from "@/store";
import { feedbackToast } from "@/utils/common";
import { formatConversionTime, getConversationContent } from "@/utils/imCommon";
import styles from "./conversation-item.module.scss";
@@ -52,90 +50,47 @@ const ConversationItem = ({ isActive, conversation }: IConversationProps) => {
const latestMessageTime = formatConversionTime(conversation.latestMsgSendTime);
const onMenuClick = async ({ key }: { key: string }) => {
try {
if (key === "pin") {
await IMSDK.setConversation({
conversationID: conversation.conversationID,
isPinned: !conversation.isPinned,
});
return;
}
if (key === "delete") {
await IMSDK.deleteConversationAndDeleteAllMsg(conversation.conversationID);
if (isActive) {
navigate("/chat");
}
}
} catch (error) {
feedbackToast({
error,
msg:
key === "pin"
? t("toast.pinConversationFailed")
: t("toast.deleteConversationFailed"),
});
}
};
return (
<Dropdown
trigger={["contextMenu"]}
menu={{
items: [
{
key: "pin",
label: conversation.isPinned
? t("placeholder.removeSticky")
: t("placeholder.sticky"),
},
{ key: "delete", label: t("placeholder.delete"), danger: true },
],
onClick: onMenuClick,
}}
<div
className={clsx(
styles["conversation-item"],
"border border-transparent",
isActive ? `bg-[var(--primary-active)]` : "hover:bg-[var(--chat-bg)]",
)}
onClick={toSpecifiedConversation}
>
<div
className={clsx(
styles["conversation-item"],
"h-16 border border-transparent transition-colors duration-100",
isActive
? "bg-[var(--primary-active)]"
: "hover:bg-[var(--chat-bg)]",
)}
style={{ transitionTimingFunction: "ease-out" }}
onClick={toSpecifiedConversation}
>
<OIMAvatar
size={44}
src={conversation.faceURL}
isgroup={Boolean(conversation.groupID)}
text={conversation.showName}
/>
<OIMAvatar
size={48}
src={conversation.faceURL}
isgroup={Boolean(conversation.groupID)}
text={conversation.showName}
/>
<div className="ml-3 flex min-w-0 flex-1 flex-col justify-center overflow-hidden">
<div className="flex items-center justify-between">
<div className="flex-1 truncate text-base font-bold text-[var(--base-black)]">
{conversation.showName}
</div>
<div className="ml-2 shrink-0 text-xs text-[var(--sub-text)]">
{latestMessageTime}
</div>
<div className="ml-3 flex h-12 flex-1 flex-col justify-between overflow-hidden py-0.5">
<div className="flex items-center justify-between">
<div className="flex-1 truncate text-sm font-medium">
{conversation.showName}
</div>
<div className="ml-2 text-xs text-[var(--sub-text)]">{latestMessageTime}</div>
</div>
<div className="mt-1 flex items-center justify-between">
<div className="flex min-h-[18px] min-w-0 flex-1 items-center overflow-hidden">
<div
className="truncate text-sm text-[var(--sub-text)]"
dangerouslySetInnerHTML={{
__html: latestMessageContent,
}}
></div>
</div>
<Badge className="ml-2 shrink-0" size="small" count={conversation.unreadCount} />
<div className="flex items-center justify-between">
<div className="flex min-h-[18px] flex-1 items-center overflow-hidden">
<div
className="truncate text-[13px] text-[rgba(81,94,112,0.5)]"
dangerouslySetInnerHTML={{
__html: latestMessageContent,
}}
></div>
</div>
<Badge
className="ml-2"
size="small"
count={conversation.unreadCount}
/>
</div>
</div>
</Dropdown>
</div>
);
};
@@ -1,5 +1,16 @@
.conversation-item {
@apply relative mx-2 my-0 flex cursor-pointer items-center rounded-lg px-3;
@apply relative mx-2 my-0.5 flex cursor-pointer items-center rounded-lg px-3 py-2.5;
&-pined {
&::after {
content: "";
position: absolute;
right: 0;
top: 0;
border: 4px solid;
border-color: var(--primary) var(--primary) transparent transparent;
}
}
:global(.emojione) {
width: 16px;
@@ -1,7 +1,7 @@
import { SearchOutlined, PlusOutlined } from "@ant-design/icons";
import { Button, Input, Spin } from "antd";
import { t } from "i18next";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
@@ -11,90 +11,43 @@ import { useConversationStore, useUserStore } from "@/store";
import ConversationItemComp from "./ConversationItem";
const prefersReducedMotion = () =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const OfflineBar = ({ visible }: { visible: boolean }) => {
const [shown, setShown] = useState(visible);
const [leaving, setLeaving] = useState(false);
useEffect(() => {
if (visible) {
setShown(true);
setLeaving(false);
return;
}
if (!shown) return;
if (prefersReducedMotion()) {
setShown(false);
setLeaving(false);
return;
}
setLeaving(true);
const timer = window.setTimeout(() => {
setShown(false);
setLeaving(false);
}, 300);
return () => window.clearTimeout(timer);
}, [visible, shown]);
if (!shown) return null;
return (
<div
style={{
overflow: "hidden",
maxHeight: leaving ? 0 : 80,
opacity: leaving ? 0 : 1,
transition: leaving ? "max-height 300ms ease-out, opacity 300ms ease-out" : "none",
}}
>
<div
className="flex h-9 items-center px-4 text-sm text-white"
style={{ backgroundColor: "var(--banner-bg)" }}
// 断网时顶部的深色提示横条
const OfflineBar = () => (
<div>
<div className="flex h-9 items-center bg-[#3a3f47] px-3 text-xs text-white">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
className="mr-1.5 shrink-0"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
className="mr-2 shrink-0"
>
<circle cx="12" cy="12" r="9" stroke="#fff" strokeWidth="1.8" />
<path d="M12 7.5v5.5" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="12" cy="16.5" r="1.2" fill="#fff" />
</svg>
{t("states.offline")}
</div>
<div className="px-4 py-2 text-xs text-[var(--sub-text)]">
{t("states.offlineTip")}
</div>
<circle cx="12" cy="12" r="9" stroke="#fff" strokeWidth="2" />
<path
d="M12 7.5v5.5"
stroke="#fff"
strokeWidth="2"
strokeLinecap="round"
/>
<circle cx="12" cy="16.5" r="1.2" fill="#fff" />
</svg>
{t("states.offline")}
</div>
);
};
const LinearChatIcon = () => (
<svg width="64" height="64" viewBox="0 0 24 24" fill="none">
<path
d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7.2L7 20.5v-2.5H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5Z"
stroke="var(--gap-text)"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
<div className="px-3 py-2 text-xs text-[var(--sub-text)]">
{t("states.offlineTip")}
</div>
</div>
);
const LinearFailIcon = () => (
<svg width="64" height="64" viewBox="0 0 24 24" fill="none">
const EmptyIcon = () => (
<svg width="56" height="56" viewBox="0 0 24 24" fill="none">
<path
d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7.2L7 20.5v-2.5H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5Z"
stroke="var(--gap-text)"
strokeWidth="1.6"
strokeLinejoin="round"
d="M12 3C6.98 3 3 6.58 3 11.1c0 2.5 1.28 4.73 3.28 6.24-.1.84-.46 1.87-1.02 2.66-.15.21.02.5.27.44 1.53-.34 2.92-1.03 3.9-1.73.82.18 1.68.29 2.57.29 5.02 0 9-3.58 9-8.1S17.02 3 12 3Z"
fill="#dcdfe6"
/>
<path d="M12 9v3.5" stroke="var(--warn-text)" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="12" cy="15.2" r="1" fill="var(--warn-text)" />
<circle cx="8.5" cy="11" r="1.1" fill="#fff" />
<circle cx="12" cy="11" r="1.1" fill="#fff" />
<circle cx="15.5" cy="11" r="1.1" fill="#fff" />
</svg>
);
@@ -143,12 +96,15 @@ const ConversationSider = () => {
if (syncState === "failed" && conversationList.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center">
<LinearFailIcon />
<EmptyIcon />
<div className="mt-4 text-sm">{t("states.loadFailed")}</div>
<div className="mt-1 text-xs text-[var(--sub-text)]">
{t("states.loadFailedTip")}
</div>
<Button className="mt-4" onClick={() => getConversationListByReq(false)}>
<Button
className="mt-4"
onClick={() => getConversationListByReq(false)}
>
{t("retry")}
</Button>
</div>
@@ -157,7 +113,7 @@ const ConversationSider = () => {
if (conversationList.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center">
<LinearChatIcon />
<EmptyIcon />
<div className="mt-4 text-sm">{t("states.noMessage")}</div>
<div className="mt-1 text-xs text-[var(--sub-text)]">
{t("states.noMessageTip")}
@@ -194,13 +150,12 @@ const ConversationSider = () => {
needHidden={Boolean(conversationID)}
wrapClassName="flex flex-col border-r border-[var(--gap-text)] bg-white"
>
<OfflineBar visible={connectState === "failed"} />
{connectState === "failed" && <OfflineBar />}
<div className="app-drag px-4 pt-4">
<div className="pb-3 text-base font-bold">{t("placeholder.chat")}</div>
<div className="flex items-center gap-1.5">
<Input
className="app-no-drag mb-2 rounded-md border-none bg-[var(--search-bg)]"
style={{ height: 36, backgroundColor: "var(--search-bg)" }}
className="app-no-drag mb-2 rounded-md border-none !bg-[var(--search-bg)]"
prefix={<SearchOutlined className="text-[var(--sub-text)]" rev={undefined} />}
placeholder={t("placeholder.search")}
allowClear
@@ -208,7 +163,7 @@ const ConversationSider = () => {
onChange={(e) => setKeyword(e.target.value)}
/>
<div
className="app-no-drag flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-lg text-[20px] text-[var(--sub-text)] transition-colors duration-100 ease-out hover:bg-[var(--primary-active)] hover:text-[var(--primary)]"
className="app-no-drag flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-lg text-[20px] text-[var(--sub-text)] transition-colors duration-100 hover:bg-[var(--primary-active)] hover:text-[var(--primary)]"
title={t("placeholder.addColleague")}
onClick={() => setAddColleagueOpen(true)}
>
+12 -15
View File
@@ -1,26 +1,23 @@
import { Layout } from "antd";
import { useTranslation } from "react-i18next";
const LinearChatIcon = () => (
<svg width="64" height="64" viewBox="0 0 24 24" fill="none">
<path
d="M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-7.2L7 20.5v-2.5H5A1.5 1.5 0 0 1 3.5 16V8A1.5 1.5 0 0 1 5 6.5Z"
stroke="var(--gap-text)"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
export const EmptyChat = () => {
const { t } = useTranslation();
return (
<Layout className="no-mobile flex items-center justify-center bg-[var(--chat-bg)]">
<Layout className="no-mobile flex items-center justify-center !bg-[var(--chat-bg)]">
<div className="flex flex-col items-center">
<LinearChatIcon />
<div className="mt-4 text-base font-medium text-[var(--base-black)]">
{t("placeholder.emptyChat")}
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[var(--primary)]">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none">
<path
d="M12 3C6.98 3 3 6.58 3 11.1c0 2.5 1.28 4.73 3.28 6.24-.1.84-.46 1.87-1.02 2.66-.15.21.02.5.27.44 1.53-.34 2.92-1.03 3.9-1.73.82.18 1.68.29 2.57.29 5.02 0 9-3.58 9-8.1S17.02 3 12 3Z"
fill="#fff"
/>
</svg>
</div>
<div className="mt-4 text-base font-medium">{t("placeholder.title")}</div>
<div className="mt-1 text-xs text-[var(--sub-text)]">
{t("placeholder.subTitle")}
</div>
</div>
</Layout>
@@ -33,7 +33,7 @@ const EmojiPanel = ({ onSelect }: { onSelect: (emoji: string) => void }) => (
{EMOJI_LIST.map((emoji) => (
<div
key={emoji}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-xl hover:bg-[var(--primary-active)]"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded text-xl hover:bg-[var(--primary-active)]"
onClick={() => onSelect(emoji)}
>
{emoji}
@@ -42,7 +42,7 @@ const EmojiPanel = ({ onSelect }: { onSelect: (emoji: string) => void }) => (
</div>
);
const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, _ref) => {
const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, ref) => {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const [emojiVisible, setEmojiVisible] = useState(false);
@@ -141,7 +141,7 @@ const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, _ref) => {
};
const toolIconClass =
"flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg text-[20px] text-[var(--sub-text)] transition-colors duration-100 ease-out hover:bg-[var(--primary-active)] hover:text-[var(--primary)]";
"cursor-pointer text-xl text-[var(--sub-text)] hover:text-[#515E70]";
return (
<footer className="relative bg-white">
@@ -203,15 +203,14 @@ const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, _ref) => {
<div className="px-4 pt-2">
<Input.TextArea
ref={textareaRef}
autoSize={{ minRows: 3, maxRows: 6 }}
className="!resize-none !border-none !shadow-none"
autoSize={{ minRows: 4, maxRows: 8 }}
placeholder={t("placeholder.sendToName", {
name: currentConversation?.showName ?? "",
})}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
bordered={false}
style={{ resize: "none", boxShadow: "none" }}
/>
</div>
@@ -221,11 +220,11 @@ const ChatFooter: ForwardRefRenderFunction<unknown, unknown> = (_, _ref) => {
{t("placeholder.enterToSendTip")}
</span>
<Button
className="w-fit px-6"
type="primary"
disabled={!text.trim()}
loading={sending}
onClick={sendText}
style={{ height: 32, borderRadius: 8, paddingInline: 24 }}
>
{t("placeholder.send")}
</Button>
@@ -3,10 +3,11 @@ import { SessionType } from "@openim/wasm-client-sdk";
import { Dropdown, Layout } from "antd";
import { t } from "i18next";
import { memo, useEffect, useRef } from "react";
import { v4 as uuidV4 } from "uuid";
import { OverlayVisibleHandle } from "@/hooks/useOverlayVisible";
import { useConversationStore } from "@/store";
import { emit } from "@/utils/events";
import { useConversationStore, useUserStore } from "@/store";
import emitter, { emit } from "@/utils/events";
import GroupSetting from "../GroupSetting";
import SingleSetting from "../SingleSetting";
@@ -30,12 +31,42 @@ const ChatHeader = () => {
const isSingleSession = currentConversation?.conversationType === SessionType.Single;
const startVoiceCall = () => {
if (!currentConversation?.userID) return;
emitter.emit("OPEN_RTC_MODAL", {
invitation: {
inviterUserID: useUserStore.getState().selfInfo.userID,
inviteeUserIDList: [currentConversation.userID],
groupID: "",
roomID: uuidV4(),
timeout: 60,
mediaType: "audio",
sessionType: SessionType.Single,
platformID: window.electronAPI?.getPlatform() ?? 5,
},
participant: {
userInfo: {
nickname: currentConversation.showName,
userID: currentConversation.userID,
faceURL: currentConversation.faceURL,
ex: "",
},
},
});
};
const menuItems = isSingleSession
? [{ key: "viewProfile", label: t("placeholder.viewProfile") }]
? [
{ key: "voiceCall", label: t("placeholder.voiceCall") },
{ key: "viewProfile", label: t("placeholder.viewProfile") },
]
: [{ key: "groupSetting", label: t("placeholder.groupSetting") }];
const onMenuClick = ({ key }: { key: string }) => {
switch (key) {
case "voiceCall":
startVoiceCall();
break;
case "viewProfile":
emit("OPEN_USER_CARD", {
userID: currentConversation?.userID,
@@ -51,10 +82,7 @@ const ChatHeader = () => {
};
return (
<Layout.Header
className="app-drag relative border-b border-b-[var(--gap-text)] bg-white px-4"
style={{ height: 56, lineHeight: "56px", paddingInline: 16 }}
>
<Layout.Header className="app-drag relative border-b border-b-[var(--gap-text)] !bg-white !px-4">
<div className="flex h-full items-center leading-none">
<div className="flex flex-1 items-center overflow-hidden">
<div className="truncate text-base font-bold">
@@ -67,10 +95,11 @@ const ChatHeader = () => {
placement="bottomRight"
>
<MoreOutlined
className="app-no-drag cursor-pointer p-1 text-[20px] text-[var(--sub-text)] hover:text-[var(--primary)]"
className="app-no-drag cursor-pointer p-1 text-lg text-[#515E70] hover:text-[var(--primary)]"
rev={undefined}
/>
</Dropdown>
{/* 给悬浮窗口控制条留出空间 */}
{Boolean(window.electronAPI) && <div className="w-28 shrink-0" />}
</div>
<SingleSetting ref={singleSettingRef} />
@@ -71,7 +71,7 @@ const FileMessageRender: FC<IMessageItemProps> = ({ message }) => {
return (
<div
className="flex h-16 w-[280px] cursor-pointer items-center rounded-lg bg-white px-3"
className="flex w-60 cursor-pointer items-center rounded-lg bg-white p-3"
onClick={saveFile}
>
<FileIcon />
@@ -10,9 +10,9 @@ const MediaMessageRender: FC<IMessageItemProps> = ({ message }) => {
const imageHeight = message.pictureElem!.sourcePicture.height;
const imageWidth = message.pictureElem!.sourcePicture.width;
const snapshotMaxHeight = message.pictureElem!.snapshotPicture?.height ?? imageHeight;
const minHeight = min(240, imageWidth) * (imageHeight / imageWidth) + 2;
const minHeight = min(200, imageWidth) * (imageHeight / imageWidth) + 2;
const adaptedHight = min(minHeight, snapshotMaxHeight) + 10;
const adaptedWidth = min(imageWidth, 240) + 10;
const adaptedWidth = min(imageWidth, 200) + 10;
const sourceUrl =
message.pictureElem!.snapshotPicture?.url || message.pictureElem!.sourcePicture.url;
@@ -21,10 +21,10 @@ const MediaMessageRender: FC<IMessageItemProps> = ({ message }) => {
return (
<Spin spinning={isSending}>
<div className="relative max-w-[240px]" style={minStyle}>
<div className="relative max-w-[200px]" style={minStyle}>
<Image
rootClassName="message-image cursor-pointer"
className="max-w-[240px] rounded-md"
className="max-w-[200px] rounded-md"
src={sourceUrl}
preview
placeholder={
@@ -9,8 +9,7 @@
}
.bubble {
@apply w-fit rounded-lg p-2.5;
max-width: 60%;
@apply w-fit rounded-xl p-2.5;
word-break: break-word;
background-color: var(--chat-bubble);
}
+21 -35
View File
@@ -2,7 +2,6 @@ import { IMSDK } from "@/layout/MainContentWrap";
import { useContactStore } from "@/store";
import { feedbackToast } from "@/utils/common";
import { Input, Modal } from "antd";
import { t } from "i18next";
import { useEffect, useState } from "react";
interface IAddColleagueProps {
@@ -10,16 +9,21 @@ interface IAddColleagueProps {
onClose: () => void;
}
/**
* 添加同事弹窗(视觉规范 v1 §4.2):
* 工号输入 + 可编辑验证消息 + 发送按钮,五态:默认/校验失败/发送中/成功/失败。
* 成功后刷新申请数,2s 后关闭并提示「申请已发送,等对方通过」。
*/
const AddColleague = ({ open, onClose }: IAddColleagueProps) => {
const [staffNo, setStaffNo] = useState("");
const [verifyMsg, setVerifyMsg] = useState(t("placeholder.addColleagueDefaultMsg"));
const [verifyMsg, setVerifyMsg] = useState("你好,我想加你为同事");
const [error, setError] = useState("");
const [sending, setSending] = useState(false);
useEffect(() => {
if (open) {
setStaffNo("");
setVerifyMsg(t("placeholder.addColleagueDefaultMsg"));
setVerifyMsg("你好,我想加你为同事");
setError("");
setSending(false);
}
@@ -28,21 +32,21 @@ const AddColleague = ({ open, onClose }: IAddColleagueProps) => {
const send = async () => {
const id = staffNo.trim();
if (!id) {
setError(t("toast.inputPeerStaffNo"));
setError("请输入对方工号");
return;
}
setSending(true);
try {
await IMSDK.addFriend({
toUserID: id,
reqMsg: verifyMsg.trim() || t("placeholder.addColleagueDefaultMsg"),
reqMsg: verifyMsg.trim() || "你好,我想加你为同事",
});
useContactStore.getState().getRecvFriendApplicationListByReq();
feedbackToast({ msg: t("toast.addColleagueSent"), duration: 2 });
window.setTimeout(onClose, 500);
feedbackToast({ msg: "申请已发送,等对方通过" });
setTimeout(onClose, 500);
} catch (error) {
setError(t("toast.addColleagueFailed"));
feedbackToast({ error, msg: t("toast.addColleagueFailed") });
setError("发送失败,请确认工号正确、网络正常");
feedbackToast({ error, msg: "发送失败,请确认工号正确、网络正常" });
} finally {
setSending(false);
}
@@ -50,7 +54,7 @@ const AddColleague = ({ open, onClose }: IAddColleagueProps) => {
return (
<Modal
title={t("placeholder.addColleague")}
title="添加同事"
open={open}
onCancel={sending ? undefined : onClose}
footer={null}
@@ -58,55 +62,37 @@ const AddColleague = ({ open, onClose }: IAddColleagueProps) => {
centered
maskClosable={!sending}
destroyOnClose
styles={{
content: { borderRadius: 12 },
}}
>
<div className="pt-2">
<div className="mb-1.5 text-sm text-[var(--base-black)]">
{t("placeholder.colleagueStaffNo")}
</div>
<div className="mb-1.5 text-sm text-[var(--base-black)]"></div>
<Input
value={staffNo}
onChange={(e) => {
setStaffNo(e.target.value);
if (error) setError("");
}}
placeholder={t("toast.inputPeerStaffNo")}
placeholder="请输入对方工号"
status={error ? "error" : undefined}
style={{ height: 36, borderRadius: 8, backgroundColor: "var(--search-bg)" }}
disabled={sending}
onPressEnter={send}
/>
{error ? (
<div className="mt-1 text-xs text-[var(--warn-text)]">{error}</div>
) : null}
<div className="mb-1.5 mt-4 text-sm text-[var(--base-black)]">
{t("placeholder.verifyMessageOptional")}
</div>
{error && <div className="mt-1 text-xs text-[var(--warn-text)]">{error}</div>}
<div className="mb-1.5 mt-4 text-sm text-[var(--base-black)]"></div>
<Input.TextArea
value={verifyMsg}
onChange={(e) => setVerifyMsg(e.target.value)}
placeholder={t("placeholder.addColleagueDefaultMsg")}
placeholder="你好,我想加你为同事"
autoSize={{ minRows: 2, maxRows: 4 }}
disabled={sending}
style={{
height: 72,
minHeight: 72,
borderRadius: 8,
backgroundColor: "var(--search-bg)",
}}
style={{ borderRadius: 8, backgroundColor: "var(--search-bg)" }}
/>
<button
className="mt-5 flex h-9 w-full cursor-pointer items-center justify-center rounded-lg border-none bg-[var(--primary)] text-white disabled:cursor-not-allowed disabled:opacity-60"
disabled={sending}
onClick={send}
>
{sending ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
) : (
t("placeholder.sendApplication")
)}
{sending ? <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" /> : "发送申请"}
</button>
</div>
</Modal>
@@ -5,37 +5,17 @@ import OIMAvatar from "@/components/OIMAvatar";
const FriendListItem = ({
friend,
showUserCard,
title,
}: {
friend: FriendUserItem;
showUserCard: (userID: string) => void;
title?: string;
}) => {
const job =
title ||
(() => {
try {
const extra = friend.ex ? JSON.parse(friend.ex) : {};
return extra.title || extra.jobTitle || "";
} catch {
return "";
}
})();
return (
<div
className="flex h-14 cursor-pointer items-center rounded-lg px-3.5 transition-colors duration-100 hover:bg-[var(--chat-bg)]"
className="flex items-center rounded-md px-3.5 pb-3 pt-2.5 transition-colors hover:bg-[var(--primary-active)]"
onClick={() => showUserCard(friend.userID)}
>
<OIMAvatar size={40} src={friend.faceURL} text={friend.remark || friend.nickname} />
<div className="ml-3 min-w-0 flex-1">
<div className="truncate text-base font-medium text-[var(--base-black)]">
{friend.remark || friend.nickname}
</div>
{job ? (
<div className="truncate text-sm text-[var(--sub-text)]">{job}</div>
) : null}
</div>
<OIMAvatar size={48} src={friend.faceURL} text={friend.remark || friend.nickname} />
<div className="ml-3 truncate text-sm">{friend.remark || friend.nickname}</div>
</div>
);
};
+65 -76
View File
@@ -1,85 +1,47 @@
import { FriendUserItem } from "@openim/wasm-client-sdk/lib/types/entity";
import axios from "axios";
import { useRequest } from "ahooks";
import { Empty, Spin } from "antd";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { GroupedVirtuoso } from "react-virtuoso";
import { GroupedVirtuoso, GroupedVirtuosoHandle } from "react-virtuoso";
import { useContactStore } from "@/store";
import { formatContactsByWorker } from "@/utils/contactsFormat";
import { emit } from "@/utils/events";
import { getIMToken } from "@/utils/storage";
import AlphabetIndex from "./AlphabetIndex";
import FriendListItem from "./FriendListItem";
type DirInfo = { title: string; department: string };
export const MyFriends = () => {
const { t } = useTranslation();
const friendList = useContactStore((state) => state.friendList);
const [dirMap, setDirMap] = useState<Record<string, DirInfo>>({});
const [dirReady, setDirReady] = useState(false);
const virtuoso = useRef<GroupedVirtuosoHandle>(null);
const alphabetRef = useRef<{ updateCurrentLetter: (letter: string) => void }>(null);
const { data: sectionData, cancel } = useRequest(
() => formatContactsByWorker(friendList),
{
refreshDeps: [friendList],
},
);
useEffect(() => {
let cancelled = false;
const loadTitles = async () => {
try {
const token = await getIMToken();
if (!token) {
setDirReady(true);
return;
}
const { data } = await axios.get(
`${import.meta.env.VITE_ACCOUNT_URL}/api/directory`,
{
params: { keyword: "", limit: 100 },
headers: { Authorization: `Bearer ${token}` },
},
);
if (cancelled) return;
if (data?.code === 0) {
const map: Record<string, DirInfo> = {};
for (const item of data.data?.items ?? []) {
if (item.userID) {
map[item.userID] = {
title: item.title || "",
department: item.department || "",
};
}
}
setDirMap(map);
}
} catch {
// 目录不可用时职务行留空、部门归入未分组
} finally {
if (!cancelled) setDirReady(true);
}
};
void loadTitles();
return () => {
cancelled = true;
cancel();
};
}, []);
const sectionData = useMemo(() => {
const ungrouped = t("placeholder.ungrouped");
const map = new Map<string, FriendUserItem[]>();
for (const friend of friendList) {
const dept = dirMap[friend.userID]?.department?.trim() || ungrouped;
const list = map.get(dept) ?? [];
list.push(friend);
map.set(dept, list);
}
const names = [...map.keys()].sort((a, b) => {
if (a === ungrouped) return 1;
if (b === ungrouped) return -1;
return a.localeCompare(b, "zh");
});
return {
groupCounts: names.map((name) => map.get(name)!.length),
indexList: names,
totalList: names.flatMap((name) => map.get(name)!),
};
}, [friendList, dirMap, t]);
const scrollToLetter = useCallback(
(idx: number) => {
const prevNum = sectionData?.groupCounts.slice(0, idx).reduce((a, b) => a + b, 0);
console.log(prevNum);
virtuoso.current?.scrollToIndex({
index: prevNum ?? 0,
// behavior: "smooth",
});
},
[sectionData?.groupCounts],
);
const showUserCard = useCallback((userID: string) => {
emit("OPEN_USER_CARD", {
@@ -87,36 +49,63 @@ export const MyFriends = () => {
});
}, []);
const determineCurrentGroup = (startIndex: number) => {
if (!sectionData) return;
let currentItemIndex = 0;
for (
let groupIndex = 0;
groupIndex < sectionData.groupCounts.length;
groupIndex++
) {
const groupItemCount = sectionData.groupCounts[groupIndex];
if (startIndex < currentItemIndex + groupItemCount) {
alphabetRef.current?.updateCurrentLetter(sectionData.indexList[groupIndex]);
break;
}
currentItemIndex += groupItemCount;
}
};
return (
<div className="flex h-full w-full flex-col overflow-hidden bg-white">
<div className="m-5.5 text-base font-extrabold">{t("placeholder.myFriend")}</div>
{!dirReady && friendList.length === 0 ? (
{!sectionData ? (
<Spin />
) : !friendList.length ? (
) : !sectionData.groupCounts.length ? (
<Empty className="mt-[30%]" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<div className="ml-4 mt-4 flex-1 overflow-auto pr-4">
<AlphabetIndex
ref={alphabetRef}
indexList={sectionData.indexList}
scrollToLetter={scrollToLetter}
/>
<GroupedVirtuoso
ref={virtuoso}
groupCounts={sectionData.groupCounts}
groupContent={(index) => (
<div
className="flex items-center bg-[var(--chat-bg)] px-3.5 text-xs text-[var(--sub-text)]"
style={{ height: 28 }}
>
{sectionData.indexList[index]}
<div>
<div className="bg-white px-3.5 pb-1 text-[13px] text-[var(--sub-text)]">
{sectionData.indexList[index]}
</div>
<div className="mx-3.5 mb-3 h-px w-full bg-[var(--gap-text)] bg-white" />
</div>
)}
itemContent={(index) => {
const friend = sectionData.totalList[index];
return (
<FriendListItem
key={friend.userID}
friend={friend}
title={dirMap[friend.userID]?.title}
key={sectionData.totalList[index].userID}
friend={sectionData.totalList[index]}
showUserCard={showUserCard}
/>
);
}}
rangeChanged={({ startIndex }) => determineCurrentGroup(startIndex)}
className="no-scrollbar h-full overflow-x-hidden"
/>
</div>
+16 -38
View File
@@ -4,6 +4,7 @@ import { t } from "i18next";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { feedbackToast } from "@/utils/common";
import { setIMProfile } from "@/utils/storage";
interface LoginResult {
@@ -17,11 +18,7 @@ interface LoginResult {
};
}
type LoginFormProps = {
onBanner: (text: string) => void;
};
const LoginForm = ({ onBanner }: LoginFormProps) => {
const LoginForm = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
@@ -37,14 +34,17 @@ const LoginForm = ({ onBanner }: LoginFormProps) => {
},
);
if (res.code !== 0 || !res.data) {
onBanner(t("toast.staffOrPasswordWrong"));
feedbackToast({
msg: res.msg || t("toast.accessFailed"),
error: res.msg || "login failed",
});
return;
}
const { userID, imToken } = res.data;
setIMProfile({ chatToken: imToken, imToken, userID });
navigate("/chat");
} catch {
onBanner(t("toast.networkRetry"));
} catch (error) {
feedbackToast({ msg: "登录失败,请检查网络后重试", error });
} finally {
setLoading(false);
}
@@ -52,30 +52,22 @@ const LoginForm = ({ onBanner }: LoginFormProps) => {
return (
<>
<div className="text-center text-xl font-bold text-[var(--base-black)]">
{t("placeholder.title")}
</div>
<div className="mt-1 text-center text-sm text-[var(--sub-text)]">
{t("placeholder.internalComms")}
<div className="flex flex-row items-center justify-between">
<div className="text-xl font-medium">{t("placeholder.welcome")}</div>
</div>
<Form
className="mt-8"
layout="vertical"
onFinish={onFinish}
autoComplete="off"
validateTrigger={["onSubmit"]}
labelCol={{ prefixCls: "custom-form-item" }}
>
<Form.Item
label={t("placeholder.staffNo")}
name="staffNo"
rules={[{ required: true, message: t("toast.inputStaffNo") }]}
>
<Input
allowClear
placeholder={t("toast.inputStaffNo")}
style={{ height: 40, borderRadius: 8, backgroundColor: "var(--search-bg)" }}
disabled={loading}
/>
<Input allowClear placeholder={t("toast.inputStaffNo")} />
</Form.Item>
<Form.Item
@@ -83,28 +75,14 @@ const LoginForm = ({ onBanner }: LoginFormProps) => {
name="password"
rules={[{ required: true, message: t("toast.inputPassword") }]}
>
<Input.Password
allowClear
placeholder={t("toast.inputPassword")}
style={{ height: 40, borderRadius: 8, backgroundColor: "var(--search-bg)" }}
disabled={loading}
/>
<Input.Password allowClear placeholder={t("toast.inputPassword")} />
</Form.Item>
<Form.Item className="mb-2 mt-8">
<Button
type="primary"
htmlType="submit"
block
loading={loading}
style={{ height: 40, borderRadius: 8 }}
>
{loading ? t("placeholder.loggingIn") : t("placeholder.login")}
<Form.Item className="mb-4 mt-10">
<Button type="primary" htmlType="submit" block loading={loading}>
{t("placeholder.login")}
</Button>
</Form.Item>
<div className="text-center text-xs text-[var(--sub-text)]">
{t("placeholder.agreeInternal")}
</div>
</Form>
</>
);
@@ -3,55 +3,6 @@
.ant-form-item-label {
padding: 0;
}
.ant-form-item-explain-error {
color: var(--warn-text);
font-size: 12px;
}
.ant-input-status-error,
.ant-input-affix-wrapper-status-error {
border-color: var(--warn-text) !important;
}
}
}
.login-toast {
height: 36px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
animation: login-toast-in 150ms ease-out;
}
.login-toast[data-phase="out"] {
animation: login-toast-out 200ms ease-out forwards;
}
@keyframes login-toast-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes login-toast-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.login-toast,
.login-toast[data-phase="out"] {
animation: none;
}
}
+13 -27
View File
@@ -1,7 +1,7 @@
import { t } from "i18next";
import { useCallback, useEffect, useState } from "react";
import { useCopyToClipboard } from "react-use";
import login_bg from "@/assets/images/login/login_bg.png";
import WindowControlBar from "@/components/WindowControlBar";
import { APP_NAME, APP_VERSION, SDK_VERSION } from "@/config";
import { feedbackToast } from "@/utils/common";
@@ -11,8 +11,6 @@ import LoginForm from "./LoginForm";
export const Login = () => {
const [_, copyToClipboard] = useCopyToClipboard();
const [banner, setBanner] = useState<string | null>(null);
const clearBanner = useCallback(() => setBanner(null), []);
const handleCopy = () => {
copyToClipboard(`${`${APP_NAME} ${APP_VERSION}`}/${SDK_VERSION}`);
@@ -21,16 +19,16 @@ export const Login = () => {
return (
<div className="relative flex h-full flex-col">
<div className="app-drag relative h-10 bg-[var(--primary)]">
<div className="app-drag relative h-10 bg-[var(--top-search-bar)]">
<WindowControlBar />
</div>
{banner ? <LoginToastBanner text={banner} onDone={clearBanner} /> : null}
<div className="flex flex-1 items-center justify-center">
<LeftBar />
<div
className={`${styles.login} h-[450px] w-[350px] rounded-xl bg-white p-11`}
style={{ boxShadow: "0 4px 16px rgba(0,0,0,0.08)" }}
className={`${styles.login} mr-14 h-[450px] w-[350px] rounded-md bg-white p-11`}
style={{ boxShadow: "0 0 30px rgba(0,0,0,.1)" }}
>
<LoginForm onBanner={setBanner} />
<LoginForm />
</div>
</div>
<div
@@ -44,26 +42,14 @@ export const Login = () => {
);
};
const LoginToastBanner = ({ text, onDone }: { text: string; onDone: () => void }) => {
const [phase, setPhase] = useState<"in" | "out">("in");
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const stay = window.setTimeout(() => setPhase("out"), reduce ? 2000 : 2150);
const done = window.setTimeout(onDone, reduce ? 2000 : 2350);
return () => {
window.clearTimeout(stay);
window.clearTimeout(done);
};
}, [onDone]);
const LeftBar = () => {
return (
<div
className={styles["login-toast"]}
data-phase={phase}
style={{ backgroundColor: "var(--warn-text)" }}
>
{text}
<div className="flex min-h-[420]">
<div className="mr-14 text-center">
<div className="text-2xl">{t("placeholder.title")}</div>
<span className="text-sm text-gray-400">{t("placeholder.subTitle")}</span>
<img src={login_bg} alt="login_bg" />
</div>
</div>
);
};

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