feat(mobile-next): 补齐语音未听红点、录音浮层与 50-60s 时限
按 B-187 打回修复 4.3/4.4 阻塞项,输入框与按住说话改用 searchBg。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
e40ece184b
commit
15d7f1935e
@@ -1,6 +1,6 @@
|
|||||||
# B-185 / B-171 第二批交互补齐验收与回退
|
# B-185 / B-171 第二批交互补齐验收与回退
|
||||||
|
|
||||||
分支:`agent/agent/7b089146`(本工作副本本地名为 `agent/agent/1a87b790`,基于该远程分支 `a4d21ef`)
|
分支:`agent/agent/7b089146`
|
||||||
基线:`fb58da6`(B-171 第一批完成点)
|
基线:`fb58da6`(B-171 第一批完成点)
|
||||||
架构:B-90;视觉:B-91 `changlian-visual-spec-v2.md`
|
架构:B-90;视觉:B-91 `changlian-visual-spec-v2.md`
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## 提交主题(可逐项 `git revert`)
|
## 提交主题(可逐项 `git revert`)
|
||||||
|
|
||||||
从新到旧回退:先 B-188 验收补记与 `d9d5853`,再原第二批验收提交,再按下表从下往上 `git revert`。
|
从新到旧回退:先 B-189,再 B-188 验收补记与 `d9d5853`,再原第二批验收提交,再按下表从下往上 `git revert`。
|
||||||
|
|
||||||
| 主题 | SHA | 主要文件 |
|
| 主题 | SHA | 主要文件 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -19,17 +19,34 @@
|
|||||||
| 工号查询防抖、控制器生命周期、开关、资料页工号 | `26622676ed4f4898385d8e9e686296eb56adf4be` | `add_colleague_page.dart`、`app_pages.dart`、`company_login_page.dart`、`my_info_view.dart`、`personal_info_view.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` |
|
| 断网横幅恢复后 300ms 收起、底栏 12px | `d1657ea29f0fc59f248f2108d7cfb9bcc239921e` | `home_view.dart` |
|
||||||
| PC 第二批登录/会话/聊天/添加同事/职务行/i18n | `a44537da4e8ce7b5b17b491c05592ad21cdd0bca` | `pc-client/src/pages/**`、`ApplicationItem`、`i18n`、`global.scss`、`docs/acceptance/pc-boundary.md` |
|
| PC 第二批登录/会话/聊天/添加同事/职务行/i18n | `a44537da4e8ce7b5b17b491c05592ad21cdd0bca` | `pc-client/src/pages/**`、`ApplicationItem`、`i18n`、`global.scss`、`docs/acceptance/pc-boundary.md` |
|
||||||
| 本验收记录 | 本文件当次提交 | `docs/acceptance/b171-batch2.md` |
|
| 第二批验收记录 | `a4d21ef36777440812c6746cd792eeaf7685cd5c` | `docs/acceptance/b171-batch2.md` |
|
||||||
| B-188 定点返工:PC 会话置顶改 `setConversation` | `d9d5853f73aa8060c9ed768c326f00d691847e3e` | `pc-client/src/pages/chat/ConversationSider/ConversationItem.tsx` |
|
| B-188 定点返工:PC 会话置顶改 `setConversation` | `d9d5853f73aa8060c9ed768c326f00d691847e3e` | `pc-client/src/pages/chat/ConversationSider/ConversationItem.tsx` |
|
||||||
| B-188 验收补记 | 本文件当次提交 | `docs/acceptance/b171-batch2.md` |
|
| B-188 验收补记 | `e40ece1` | `docs/acceptance/b171-batch2.md` |
|
||||||
|
|
||||||
回退 B-188:先 `git revert` 本验收补记提交,再 `git revert d9d5853f73aa8060c9ed768c326f00d691847e3e`。
|
回退 B-188:先 `git revert e40ece1`,再 `git revert d9d5853f73aa8060c9ed768c326f00d691847e3e`。
|
||||||
|
|
||||||
|
## B-189 语音区返工(相对 `e40ece1`)
|
||||||
|
|
||||||
|
依据:B-187 打回三项阻塞 + 输入框/`按住 说话` 底色 `searchBg`。不处理其余黄色建议。
|
||||||
|
|
||||||
|
| 主题 | SHA | 主要文件 |
|
||||||
|
|---|---|---|
|
||||||
|
| 未听红点、录音浮层入场/取消区、50–60s 时限、searchBg | 见当次提交 | `chat_voice_*.dart`、`chat_item_view.dart`、`chat_input_box.dart`、`apply_official_styles.dart`、`styles.dart` |
|
||||||
|
|
||||||
|
回退 B-189:`git revert <当次 SHA>`
|
||||||
|
|
||||||
|
静态核对(对照代码,非运行):
|
||||||
|
|
||||||
|
- 未听红点:接收且 `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`,不推定通过。
|
- `dart format` / `flutter analyze` / Android debug+release:**未跑。** 本环境 PATH 中无 `flutter`/`dart`/`java`,不推定通过。
|
||||||
- PC `npm run lint` / `vite build`:**未跑。** 仓库内无 `pc-client/node_modules`,不推定通过。
|
- PC `npm run lint` / `vite build`:B-189 未改 PC、未重跑。B-188 当时 `npm run build` 通过,见下节。
|
||||||
- 运行截图:无测试账号、无客户端进程,**未截图。**
|
- 运行截图:无 Flutter 工具链、无测试账号、无客户端进程,**未截图、未实测**未听红点/播放清除/取消手势/50–60s 倒计时/到限自动发送与 toast。不得推定通过。
|
||||||
|
|
||||||
### B-188 定点返工检查(2026-08-20)
|
### B-188 定点返工检查(2026-08-20)
|
||||||
|
|
||||||
@@ -51,8 +68,6 @@
|
|||||||
- `src/pages/common/UserCardModal/index.tsx` TS2345
|
- `src/pages/common/UserCardModal/index.tsx` TS2345
|
||||||
- `src/utils/imCommon.ts` TS2322 ×11(`string | undefined` → `string`)
|
- `src/utils/imCommon.ts` TS2322 ×11(`string | undefined` → `string`)
|
||||||
|
|
||||||
本卡不自行宣布整批通过。
|
|
||||||
|
|
||||||
## 越界比对(对比 `e26abad`)
|
## 越界比对(对比 `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/`。
|
零改动:`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/`。
|
||||||
@@ -61,6 +76,8 @@
|
|||||||
|
|
||||||
`pc-boundary.md` 已扩到与 B-90 / B-185 一致:登录窗、chat **展示**、`AddColleague.tsx` 列入可改;发送/历史/store/SDK 初始化仍冻结。PC 聊天顶栏去掉「语音通话」入口;RTC 死代码未删。
|
`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 正确密码登录
|
- HTTP 正确密码登录
|
||||||
@@ -68,9 +85,10 @@
|
|||||||
- 服务端好友申请 `status=2`
|
- 服务端好友申请 `status=2`
|
||||||
- LiveKit / 语音通话(本阶段不迁、PC 不建设)
|
- LiveKit / 语音通话(本阶段不迁、PC 不建设)
|
||||||
- 登录窗独立 360×480:受 electron 冻结限制,本批只改登录页展示,未改窗口尺寸
|
- 登录窗独立 360×480:受 electron 冻结限制,本批只改登录页展示,未改窗口尺寸
|
||||||
|
- B-189 全部录音/未听红点运行时表现
|
||||||
|
|
||||||
## 品牌
|
## 品牌
|
||||||
|
|
||||||
未换标。手机登录仍按开关保留 OpenIM 标识。不增删品牌资源。
|
未换标。手机登录仍按开关保留 OpenIM 标识。不增删品牌资源。
|
||||||
|
|
||||||
功能质检与视觉审核须重新执行,本卡不自行宣布通过。
|
功能质检与视觉审核须重新执行,本卡不自行宣布整批通过。
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ void applyOfficialStylesFromTokens() {
|
|||||||
Styles.c_F4F5F7 = AppColors.chatBg;
|
Styles.c_F4F5F7 = AppColors.chatBg;
|
||||||
Styles.c_F8F9FA = AppColors.pageBg;
|
Styles.c_F8F9FA = AppColors.pageBg;
|
||||||
Styles.c_F0F2F6 = AppColors.chatBg;
|
Styles.c_F0F2F6 = AppColors.chatBg;
|
||||||
|
Styles.c_F2F3F5 = AppColors.searchBg;
|
||||||
Styles.c_F2F8FF = AppColors.primaryActive;
|
Styles.c_F2F8FF = AppColors.primaryActive;
|
||||||
|
Styles.durationOverlay = AppDuration.overlay;
|
||||||
|
Styles.durationOverlaySwitch = AppDuration.overlaySwitch;
|
||||||
|
Styles.reduceMotion = AppMotion.reduce;
|
||||||
|
Styles.motionOf = AppMotion.of;
|
||||||
Styles.c_18E875 = AppColors.success;
|
Styles.c_18E875 = AppColors.success;
|
||||||
Styles.c_FFB300 = AppColors.warning;
|
Styles.c_FFB300 = AppColors.warning;
|
||||||
Styles.c_6085B1 = AppColors.avatarGray;
|
Styles.c_6085B1 = AppColors.avatarGray;
|
||||||
@@ -91,10 +96,18 @@ void applyOfficialStylesFromTokens() {
|
|||||||
color: AppColors.danger,
|
color: AppColors.danger,
|
||||||
fontSize: AppFont.body,
|
fontSize: AppFont.body,
|
||||||
);
|
);
|
||||||
|
Styles.ts_FF381F_12sp = TextStyle(
|
||||||
|
color: AppColors.danger,
|
||||||
|
fontSize: AppFont.small,
|
||||||
|
);
|
||||||
Styles.ts_FF381F_10sp = TextStyle(
|
Styles.ts_FF381F_10sp = TextStyle(
|
||||||
color: AppColors.danger,
|
color: AppColors.danger,
|
||||||
fontSize: AppFont.small,
|
fontSize: AppFont.small,
|
||||||
);
|
);
|
||||||
|
Styles.ts_FFB300_12sp = TextStyle(
|
||||||
|
color: AppColors.warning,
|
||||||
|
fontSize: AppFont.small,
|
||||||
|
);
|
||||||
Styles.ts_6085B1_17sp_medium = TextStyle(
|
Styles.ts_6085B1_17sp_medium = TextStyle(
|
||||||
color: AppColors.avatarGray,
|
color: AppColors.avatarGray,
|
||||||
fontSize: AppFont.body,
|
fontSize: AppFont.body,
|
||||||
|
|||||||
@@ -195,7 +195,9 @@ class ChatPage extends StatelessWidget {
|
|||||||
soundUrl: soundElem.sourceUrl,
|
soundUrl: soundElem.sourceUrl,
|
||||||
duration: soundElem.duration,
|
duration: soundElem.duration,
|
||||||
isPlaying: logic.isPlaySound(message),
|
isPlaying: logic.isPlaySound(message),
|
||||||
|
isUnread: !isISend && message.isRead == false,
|
||||||
),
|
),
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -535,6 +535,8 @@ const Map<String, String> en_US = {
|
|||||||
"nWeek": "%s weeks",
|
"nWeek": "%s weeks",
|
||||||
"nMonth": "%s months",
|
"nMonth": "%s months",
|
||||||
"talkTooShort": "Talk too short",
|
"talkTooShort": "Talk too short",
|
||||||
|
"remainingRecordSeconds": "%s seconds left",
|
||||||
|
"autoSendMaxVoiceDuration": "Reached the 60-second limit, sent automatically",
|
||||||
'quoteContentBeRevoked': 'Quote content be revoked',
|
'quoteContentBeRevoked': 'Quote content be revoked',
|
||||||
'tapTooShort': 'Tap too short',
|
'tapTooShort': 'Tap too short',
|
||||||
'createGroupTips': 'The group name cannot exceed 16 characters',
|
'createGroupTips': 'The group name cannot exceed 16 characters',
|
||||||
|
|||||||
@@ -535,6 +535,8 @@ const Map<String, String> zh_CN = {
|
|||||||
"nWeek": "%s周",
|
"nWeek": "%s周",
|
||||||
"nMonth": "%s个月",
|
"nMonth": "%s个月",
|
||||||
"talkTooShort": "说话时间太短",
|
"talkTooShort": "说话时间太短",
|
||||||
|
"remainingRecordSeconds": "还可说 %s 秒",
|
||||||
|
"autoSendMaxVoiceDuration": "已达最长 60 秒,已自动发送",
|
||||||
'quoteContentBeRevoked': '引用内容已被删除',
|
'quoteContentBeRevoked': '引用内容已被删除',
|
||||||
'tapTooShort': '点击时间太短',
|
'tapTooShort': '点击时间太短',
|
||||||
'createGroupTips': '群名称不能超过16个字符',
|
'createGroupTips': '群名称不能超过16个字符',
|
||||||
|
|||||||
@@ -1090,6 +1090,10 @@ class StrRes {
|
|||||||
|
|
||||||
static String get talkTooShort => 'talkTooShort'.tr;
|
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 quoteContentBeRevoked => 'quoteContentBeRevoked'.tr;
|
||||||
|
|
||||||
static String get tapTooShort => 'tapTooShort'.tr;
|
static String get tapTooShort => 'tapTooShort'.tr;
|
||||||
|
|||||||
@@ -12,7 +12,18 @@ class Styles {
|
|||||||
static Color c_FFFFFF = const Color(0xFFFFFFFF); // 警告色
|
static Color c_FFFFFF = const Color(0xFFFFFFFF); // 警告色
|
||||||
static Color c_18E875 = const Color(0xFF18E875); // 在线
|
static Color c_18E875 = const Color(0xFF18E875); // 在线
|
||||||
static Color c_F0F2F6 = const Color(0xFFF0F2F6); // 聊天页底部
|
static Color c_F0F2F6 = const Color(0xFFF0F2F6); // 聊天页底部
|
||||||
|
static Color c_F2F3F5 = const Color(0xFFF2F3F5); // 搜索/输入框底 searchBg
|
||||||
static Color c_000000 = const Color(0xFF000000); //
|
static Color c_000000 = const Color(0xFF000000); //
|
||||||
|
|
||||||
|
static Duration durationOverlay = const Duration(milliseconds: 150);
|
||||||
|
static Duration durationOverlaySwitch = const Duration(milliseconds: 100);
|
||||||
|
|
||||||
|
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_92B3E0 = const Color(0xFF92B3E0);
|
||||||
static Color c_F2F8FF = const Color(0xFFF2F8FF); // 同步成功背景色
|
static Color c_F2F8FF = const Color(0xFFF2F8FF); // 同步成功背景色
|
||||||
static Color c_F8F9FA = const Color(0xFFF8F9FA); // 默认背景
|
static Color c_F8F9FA = const Color(0xFFF8F9FA); // 默认背景
|
||||||
@@ -264,6 +275,11 @@ class Styles {
|
|||||||
fontSize: 10.sp,
|
fontSize: 10.sp,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static TextStyle ts_FFB300_12sp = TextStyle(
|
||||||
|
color: c_FFB300,
|
||||||
|
fontSize: 12.sp,
|
||||||
|
);
|
||||||
|
|
||||||
static TextStyle ts_6085B1_17sp_medium = TextStyle(
|
static TextStyle ts_6085B1_17sp_medium = TextStyle(
|
||||||
color: c_6085B1,
|
color: c_6085B1,
|
||||||
fontSize: 17.sp,
|
fontSize: 17.sp,
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ class _ChatInputBoxState extends State<ChatInputBox> /*with TickerProviderStateM
|
|||||||
constraints: const BoxConstraints(minHeight: 40),
|
constraints: const BoxConstraints(minHeight: 40),
|
||||||
margin: EdgeInsets.only(top: 8.h, bottom: _showQuoteView ? 4.h : 8.h),
|
margin: EdgeInsets.only(top: 8.h, bottom: _showQuoteView ? 4.h : 8.h),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Styles.c_F0F2F6,
|
color: Styles.c_F2F3F5,
|
||||||
borderRadius: BorderRadius.circular(8.r),
|
borderRadius: BorderRadius.circular(8.r),
|
||||||
),
|
),
|
||||||
child: ChatTextField(
|
child: ChatTextField(
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ class _ChatItemViewState extends State<ChatItemView> {
|
|||||||
message: _message,
|
message: _message,
|
||||||
);
|
);
|
||||||
} else if (_message.isVoiceType) {
|
} else if (_message.isVoiceType) {
|
||||||
isBubbleBg = true;
|
isBubbleBg = false;
|
||||||
final sound = _message.soundElem;
|
final sound = _message.soundElem;
|
||||||
child = ChatVoiceView(
|
child = ChatVoiceView(
|
||||||
isISend: _isISend,
|
isISend: _isISend,
|
||||||
@@ -259,6 +259,7 @@ class _ChatItemViewState extends State<ChatItemView> {
|
|||||||
soundUrl: sound?.sourceUrl,
|
soundUrl: sound?.sourceUrl,
|
||||||
duration: sound?.duration,
|
duration: sound?.duration,
|
||||||
isPlaying: widget.isPlayingSound,
|
isPlaying: widget.isPlayingSound,
|
||||||
|
isUnread: !_isISend && _message.isRead == false,
|
||||||
);
|
);
|
||||||
} else if (_message.isVideoType) {
|
} else if (_message.isVideoType) {
|
||||||
child = widget.mediaItemBuilder?.call(context, _message) ??
|
child = widget.mediaItemBuilder?.call(context, _message) ??
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
||||||
import 'package:openim_common/openim_common.dart';
|
import 'package:openim_common/openim_common.dart';
|
||||||
|
|
||||||
class ChatVoiceReadStatusView extends StatelessWidget {
|
class ChatVoiceReadStatusView extends StatelessWidget {
|
||||||
@@ -7,8 +6,8 @@ class ChatVoiceReadStatusView extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) => Container(
|
||||||
width: 6.w,
|
width: 8,
|
||||||
height: 6.w,
|
height: 8,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Styles.c_FF381F,
|
color: Styles.c_FF381F,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class _ChatVoiceRecordBarState extends State<ChatVoiceRecordBar> {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: widget.speakBarColor ??
|
color: widget.speakBarColor ??
|
||||||
(_pressing ? Styles.c_F2F8FF : Styles.c_F0F2F6),
|
(_pressing ? Styles.c_F2F8FF : Styles.c_F2F3F5),
|
||||||
borderRadius: BorderRadius.circular(8.r),
|
borderRadius: BorderRadius.circular(8.r),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|||||||
import 'package:lottie/lottie.dart';
|
import 'package:lottie/lottie.dart';
|
||||||
import 'package:openim_common/openim_common.dart';
|
import 'package:openim_common/openim_common.dart';
|
||||||
import 'package:rxdart/rxdart.dart';
|
import 'package:rxdart/rxdart.dart';
|
||||||
|
import 'package:sprintf/sprintf.dart';
|
||||||
|
|
||||||
typedef SpeakViewChildBuilder = Widget Function(ChatVoiceRecordBar recordBar);
|
typedef SpeakViewChildBuilder = Widget Function(ChatVoiceRecordBar recordBar);
|
||||||
|
|
||||||
@@ -36,9 +37,12 @@ class _ChatVoiceRecordLayoutState extends State<ChatVoiceRecordLayout> {
|
|||||||
bool _showVoiceRecordView = false;
|
bool _showVoiceRecordView = false;
|
||||||
RecordBarStatus _status = RecordBarStatus.holdTalk;
|
RecordBarStatus _status = RecordBarStatus.holdTalk;
|
||||||
bool _isCancelSend = false;
|
bool _isCancelSend = false;
|
||||||
|
bool _autoSendByLimit = false;
|
||||||
|
|
||||||
void _completed(int sec, String path) {
|
void _completed(int sec, String path) {
|
||||||
if (_isCancelSend) {
|
final autoSend = _autoSendByLimit;
|
||||||
|
_autoSendByLimit = false;
|
||||||
|
if (_isCancelSend && !autoSend) {
|
||||||
File(path).delete();
|
File(path).delete();
|
||||||
_status = RecordBarStatus.holdTalk;
|
_status = RecordBarStatus.holdTalk;
|
||||||
_isCancelSend = false;
|
_isCancelSend = false;
|
||||||
@@ -48,6 +52,9 @@ class _ChatVoiceRecordLayoutState extends State<ChatVoiceRecordLayout> {
|
|||||||
IMViews.showToast(StrRes.talkTooShort);
|
IMViews.showToast(StrRes.talkTooShort);
|
||||||
} else {
|
} else {
|
||||||
widget.onCompleted?.call(sec, path);
|
widget.onCompleted?.call(sec, path);
|
||||||
|
if (autoSend) {
|
||||||
|
IMViews.showToast(StrRes.autoSendMaxVoiceDuration);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,6 +90,8 @@ class _ChatVoiceRecordLayoutState extends State<ChatVoiceRecordLayout> {
|
|||||||
},
|
},
|
||||||
onLongPressStart: (details) {
|
onLongPressStart: (details) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_autoSendByLimit = false;
|
||||||
|
_isCancelSend = false;
|
||||||
_showVoiceRecordView = true;
|
_showVoiceRecordView = true;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -93,61 +102,106 @@ class _ChatVoiceRecordLayoutState extends State<ChatVoiceRecordLayout> {
|
|||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
widget.builder(_createSpeakBar),
|
widget.builder(_createSpeakBar),
|
||||||
Visibility(
|
if (_showVoiceRecordView)
|
||||||
visible: _showVoiceRecordView,
|
_VoiceRecordOverlayFade(
|
||||||
child: ChatRecordVoiceView(
|
child: ChatRecordVoiceView(
|
||||||
onCompleted: _completed,
|
maxRecordSec: widget.maxRecordSec,
|
||||||
onInterrupt: () {
|
onCompleted: _completed,
|
||||||
setState(() {
|
onInterrupt: () {
|
||||||
_interruptSub.add(true);
|
if (_autoSendByLimit) return;
|
||||||
_showVoiceRecordView = false;
|
_autoSendByLimit = true;
|
||||||
});
|
setState(() {
|
||||||
},
|
_interruptSub.add(true);
|
||||||
builder: (_, sec) => Material(
|
_showVoiceRecordView = false;
|
||||||
color: Colors.transparent,
|
});
|
||||||
child: Center(
|
},
|
||||||
child: AnimatedContainer(
|
builder: (_, sec) => Material(
|
||||||
duration: const Duration(milliseconds: 100),
|
color: Colors.transparent,
|
||||||
curve: Curves.easeOut,
|
child: Center(
|
||||||
width: 160.w,
|
child: AnimatedContainer(
|
||||||
height: 140.h,
|
duration: Styles.motionOf(context, Styles.durationOverlaySwitch),
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
|
curve: Curves.easeOut,
|
||||||
decoration: BoxDecoration(
|
width: 160.w,
|
||||||
color: _isCancelSend
|
height: 140.h,
|
||||||
? Styles.c_FF381F_opacity70
|
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
|
||||||
: Styles.c_000000_opacity70,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12.r),
|
color: Styles.c_000000_opacity70,
|
||||||
),
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
child: Column(
|
),
|
||||||
children: [
|
child: Column(
|
||||||
IMUtils.seconds2HMS(sec).toText
|
children: [
|
||||||
..style = Styles.ts_FFFFFF_12sp,
|
_timerText(sec),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _isCancelSend
|
child: _isCancelSend
|
||||||
? Icon(Icons.delete_outline,
|
? Icon(Icons.delete_outline,
|
||||||
size: 48.w, color: Styles.c_FFFFFF)
|
size: 48.w, color: Styles.c_FF381F)
|
||||||
: _lottieAnimWidget,
|
: _lottieAnimWidget,
|
||||||
),
|
),
|
||||||
(_isCancelSend
|
(_isCancelSend
|
||||||
? StrRes.liftFingerToCancelSend
|
? StrRes.liftFingerToCancelSend
|
||||||
: StrRes.releaseToSendSwipeUpToCancel)
|
: StrRes.releaseToSendSwipeUpToCancel)
|
||||||
.toText
|
.toText
|
||||||
..style = Styles.ts_FFFFFF_12sp
|
..style = _isCancelSend
|
||||||
..maxLines = 1,
|
? Styles.ts_FF381F_12sp
|
||||||
],
|
: Styles.ts_FFFFFF_12sp
|
||||||
|
..maxLines = 1,
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
Widget get _lottieAnimWidget => Lottie.asset(
|
||||||
'assets/anim/voice_record.json',
|
'assets/anim/voice_record.json',
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
package: 'openim_common',
|
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,11 +24,13 @@ class ChatRecordVoiceView extends StatefulWidget {
|
|||||||
class _ChatRecordVoiceViewState extends State<ChatRecordVoiceView> {
|
class _ChatRecordVoiceViewState extends State<ChatRecordVoiceView> {
|
||||||
static const _dir = "voice";
|
static const _dir = "voice";
|
||||||
static const _ext = ".m4a";
|
static const _ext = ".m4a";
|
||||||
late String _path;
|
String? _path;
|
||||||
int _startTimestamp = 0;
|
int _startTimestamp = 0;
|
||||||
final _audioRecorder = AudioRecorder();
|
final _audioRecorder = AudioRecorder();
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
int _duration = 0;
|
int _duration = 0;
|
||||||
|
bool _interrupted = false;
|
||||||
|
bool _completed = false;
|
||||||
|
|
||||||
static int _now() => DateTime.now().millisecondsSinceEpoch;
|
static int _now() => DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
@@ -37,16 +39,28 @@ class _ChatRecordVoiceViewState extends State<ChatRecordVoiceView> {
|
|||||||
(() async {
|
(() async {
|
||||||
if (await _audioRecorder.hasPermission()) {
|
if (await _audioRecorder.hasPermission()) {
|
||||||
_path = '${await IMUtils.createTempDir(dir: _dir)}/${_now()}$_ext';
|
_path = '${await IMUtils.createTempDir(dir: _dir)}/${_now()}$_ext';
|
||||||
await _audioRecorder.start(RecordConfig(), path: _path);
|
await _audioRecorder.start(RecordConfig(), path: _path!);
|
||||||
|
if (!mounted) {
|
||||||
|
await _audioRecorder.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
_startTimestamp = _now();
|
_startTimestamp = _now();
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = null;
|
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) async {
|
if (!mounted) {
|
||||||
|
timer.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final duration = ((_now() - _startTimestamp) ~/ 1000);
|
||||||
|
if (duration >= widget.maxRecordSec) {
|
||||||
|
timer.cancel();
|
||||||
|
_timer = null;
|
||||||
|
_duration = duration;
|
||||||
|
_interruptByLimit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_duration = ((_now() - _startTimestamp) ~/ 1000);
|
_duration = duration;
|
||||||
if (_duration >= widget.maxRecordSec) {
|
|
||||||
widget.onInterrupt?.call();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -54,17 +68,32 @@ class _ChatRecordVoiceViewState extends State<ChatRecordVoiceView> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
void _interruptByLimit() {
|
||||||
void dispose() {
|
if (_interrupted) return;
|
||||||
(() async {
|
_interrupted = true;
|
||||||
_timer?.cancel();
|
widget.onInterrupt?.call();
|
||||||
_timer = null;
|
}
|
||||||
|
|
||||||
|
Future<void> _stopAndComplete() async {
|
||||||
|
if (_completed) return;
|
||||||
|
_completed = true;
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
try {
|
||||||
if (await _audioRecorder.isRecording()) {
|
if (await _audioRecorder.isRecording()) {
|
||||||
await _audioRecorder.stop();
|
await _audioRecorder.stop();
|
||||||
}
|
}
|
||||||
widget.onCompleted?.call(_duration, _path);
|
} catch (_) {}
|
||||||
})();
|
final path = _path;
|
||||||
|
if (path == null) return;
|
||||||
|
widget.onCompleted?.call(_duration, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
unawaited(_stopAndComplete());
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class ChatVoiceView extends StatelessWidget {
|
|||||||
final String? soundUrl;
|
final String? soundUrl;
|
||||||
final int? duration;
|
final int? duration;
|
||||||
final bool isPlaying;
|
final bool isPlaying;
|
||||||
|
final bool isUnread;
|
||||||
|
|
||||||
const ChatVoiceView({
|
const ChatVoiceView({
|
||||||
Key? key,
|
Key? key,
|
||||||
@@ -16,6 +17,7 @@ class ChatVoiceView extends StatelessWidget {
|
|||||||
this.soundUrl,
|
this.soundUrl,
|
||||||
this.duration,
|
this.duration,
|
||||||
this.isPlaying = false,
|
this.isPlaying = false,
|
||||||
|
this.isUnread = false,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
double get _bubbleWidth {
|
double get _bubbleWidth {
|
||||||
@@ -56,9 +58,22 @@ class ChatVoiceView extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SizedBox(
|
final bubble = ChatBubble(
|
||||||
width: _bubbleWidth,
|
bubbleType: isISend ? BubbleType.send : BubbleType.receiver,
|
||||||
child: _buildVoiceAnimView(),
|
child: SizedBox(
|
||||||
|
width: _bubbleWidth,
|
||||||
|
child: _buildVoiceAnimView(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!isUnread) return bubble;
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
bubble,
|
||||||
|
8.horizontalSpace,
|
||||||
|
const ChatVoiceReadStatusView(),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user