Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7f5c7d8fb | ||
|
|
e4d0407c63 | ||
|
|
754f161a3f |
@@ -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.0,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
|
||||
```
|
||||
|
||||
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 / apksigner,release):包名 `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 存量错误仍在,不作为本卡运行通过依据
|
||||
|
||||
功能质检与视觉审核须另排。本卡不宣布语音通话实测通过。
|
||||
@@ -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 标识。
|
||||
|
||||
功能质检与视觉审核须重新执行,本卡不自行宣布通过。
|
||||
@@ -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.0,platforms 含 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 / apksigner,release):包名 `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-204:Flutter 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` 视觉返工提交
|
||||
@@ -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 初始化、消息发送与历史逻辑。
|
||||
@@ -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 One,emulator-5554)、`t1002`(Test Two,emulator-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/token,sdkVersion=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 13,status 2),t1002 收到(received a new message,unreadCount 1),服务端落库复核 |
|
||||
| 8 | 文字消息 B→A | t1002 发送 | 通过 | `B95TextBtoA_155112`(seq 14,status 2),t1001 收到,服务端落库复核 |
|
||||
| 9 | 图片消息 | 相册选图发送 | 通过 | contentType 102,URL `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` 列明的目录。
|
||||
@@ -101,7 +101,7 @@ APK 元数据(aapt badging):package `io.openim.flutter.demo`,versionName
|
||||
回退命令:
|
||||
|
||||
```
|
||||
git revert 8046ec7483d68f67011913da12e5e671479b5ff0
|
||||
git revert 0a2fbac
|
||||
```
|
||||
|
||||
或直接删除并行目录(不影响现网):
|
||||
|
||||
@@ -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 badging,debug 与 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/`、服务器与现网均不受影响。
|
||||
|
||||
@@ -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 License(AGPL-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 stable(revision `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`。
|
||||
@@ -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)高于工程 Kotlin(1.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,3 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx4096M
|
||||
org.gradle.jvmargs=-Xmx1536M
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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、退场 200ms,ease-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:
|
||||
|
||||
@@ -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()}');
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
-4
@@ -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),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Generated
+2
-2
@@ -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%;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user